Redis(三) Java集成Redis之接口限流-解决高并发刷单

该博客介绍了如何使用Redis来限制接口的高并发访问,以防服务器受到压力。通过自定义注解、拦截器和反射机制,实现了适用于单体和分布式系统的全局限流解决方案。在用户注册、秒杀场景等高并发情况下,此方法能有效防止刷单问题。提供了Demo供读者直接应用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

本章主要介绍redis对接口进行限流访问,当接口在高并发的情况下,会对我们的服务器造成一定影响,可通过次案例提供轻微的解决方案

应用场景:用户注册,电商秒杀接口,高并发 ....

实现方案:自定义注解+拦截器+Redis实现限流 (单体和分布式均适用,全局限流)

可根据自己需求通过本文也可直接应用到项目中

Demo:https://gitee.com/Audis/ccl-coding-sso.git

1.创建自定义注解

package com.ccl.coding.sso.annotation;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Inherited
@Documented
@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface AccessLimit {
    //标识限制次数
    int limit() default 5;

    //标识时间
    int sec() default 5;
}

2.创建拦截器并通过反射对接口的访问进行限制

package com.ccl.coding.sso.interceptor;

import com.ccl.coding.sso.annotation.AccessLimit;
import com.ccl.coding.sso.exception.AccessLimitException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
<think>我们正在讨论如何使用Redis实现滑动窗口限流算法,并优化调整。根据用户提供的引用,有两种实现方式:1.使用Lua脚本(引用1):在RateLimiterHandler中通过AOP切面执行Lua脚本,脚本使用滑动窗口算法进行限流判断。2.使用ZSET(引用2):使用ZSET数据结构,key可以是用户ID+行为,value是操作的时间戳,通过维护一个时间窗口内的操作次数来判断是否限流。用户问题:如何在Java中使用Redis实现滑块窗口算法进行接口限流调整,以及优化与调整。我们将重点介绍ZSET的实现方式,因为它更直观,并且可以方便地进行调整。滑动窗口限流原理:在固定的时间窗口内(比如1分钟),限制请求次数为N。滑动窗口将固定时间窗口分割成更小的区间(比如将1分钟分成6个10秒的区间),每个区间独立计数。当请求到来时,统计当前时间往前推一个时间窗口(1分钟)内的所有请求数,如果超过N则限流。使用ZSET实现:-key:可以是接口名+用户ID等唯一标识-value:使用UUID或时间戳+随机数(保证唯一)-score:使用请求的时间戳(毫秒)步骤:1.当请求到来时,先清除时间窗口之前的记录(比如当前时间戳减去窗口长度之前的数据)。2.统计当前窗口内(即ZSET中)的记录数量。3.如果数量小于阈值,则添加当前请求(ZADD),并设置过期时间(避免无限制增长,过期时间设置为窗口长度)。4.如果数量大于等于阈值,则拒绝请求。优化点:-使用管道(pipeline)或Lua脚本保证操作的原子性,避免并发问题。-设置过期时间,避免内存无限增长。调整参数:-窗口大小(例如1分钟)-阈值(例如100次)-分片粒度(将窗口分成多个小格子,但ZSET方式其实没有显式分片,而是通过删除旧数据来实现滑动)下面我们用Java代码实现:首先,我们需要一个方法,传入key、窗口大小(毫秒)、阈值,返回是否允许请求。注意:由于Redis操作需要保证原子性,我们使用Lua脚本或者RedisTemplate的multi操作。这里我们使用Lua脚本来保证原子性。Lua脚本思路:1.获取当前时间戳(毫秒)2.计算窗口开始时间:当前时间-窗口大小3.使用ZREMRANGEBYSCORE删除窗口开始时间之前的数据4.获取当前窗口内的元素数量(ZCARD)5.如果数量小于阈值,则添加当前请求(ZADD),并设置过期时间(避免内存泄漏,设置过期时间为窗口大小)6.返回当前数量(或返回是否允许)但是注意:ZADD后需要设置过期时间,但要注意如果key已经存在,我们不应该重置过期时间,所以使用EXPIRE时,如果key是新的,设置过期时间;如果key已经存在,我们每次删除旧数据后,重新设置过期时间?或者我们可以让过期时间自动清除。优化:每次添加新元素时,设置过期时间,但这样每次请求都会设置过期时间。我们可以通过判断key是否存在,如果不存在才设置过期时间,但这样需要多个操作。因此,我们可以使用Lua脚本保证原子性。另一种做法:在添加元素后,判断整个ZSET的过期时间,如果没有设置过期时间,则设置。但这样可能每次都要判断。或者我们可以在每次添加元素时,都重新设置过期时间为窗口大小,这样保证key至少存活一个窗口时间。我们选择:每次执行脚本时,在添加元素后,设置key的过期时间为窗口大小(如果已经存在,则覆盖过期时间)。这样即使中间没有请求,key也会自动过期。Lua脚本(参考):```lua--KEYS[1]:key--ARGV[1]:窗口大小(毫秒)--ARGV[2]:阈值--ARGV[3]:当前时间戳(毫秒)localnow=tonumber(ARGV[3])localwindow=tonumber(ARGV[1])localthreshold=tonumber(ARGV[2])localwindowStart=now-window--移除旧数据redis.call('ZREMRANGEBYSCORE',KEYS[1],0,windowStart)--获取当前数量localcount=redis.call('ZCARD',KEYS[1])ifcount<thresholdthen--添加当前请求(使用一个随机值作为成员,这里用当前时间戳+一个随机数,但为了简,我们可以用时间戳+一个递增的数字,或者直接使用随机字符串。但注意成员不能重复,否则会覆盖)--由于我们只需要计数,成员不重复即可,可以用UUID,但这里我们使用一个随机数(用now+一个随机数,但是同一个毫秒可能多次请求,所以需要确保唯一。我们可以用ARGV[3]加上一个从0开始的序列?但这里我们无法生成,所以建议由调用方生成一个唯一标识作为成员)--但是我们的脚本没有传入成员,所以我们需要生成。由于Lua脚本中生成随机数可能不是最佳实践(在Redis中要求脚本是纯函数),所以由调用方传入成员(比如使用UUID)。因此我们需要增加一个参数作为成员。--因此,修改:增加一个参数ARGV[4]作为成员redis.call('ZADD',KEYS[1],now,ARGV[4])--设置过期时间(窗口大小,位秒?注意我们的窗口是毫秒,而Redis过期时间是秒,需要转换。或者用毫秒?Redis的过期时间可以用毫秒,使用pexpire)--计算过期时间:窗口大小毫秒,所以用pexpireredis.call('PEXPIRE',KEYS[1],window)return0--允许,返回0表示未超过阈值(或者返回当前数量,根据需求)elsereturn1--超过阈值,拒绝end```但是,上面的脚本中,我们通过调用方传入一个唯一的成员标识(比如UUID),这样确保每次请求添加的成员都是唯一的。另一种做法:我们不需要具体的成员,只需要计数。那么我们可以使用计数器?但是ZSET的成员必须不同。所以我们必须使用不同的成员。因此,我们调整脚本,需要4个参数:KEYS[1]是key,ARGV[1]是窗口大小,ARGV[2]是阈值,ARGV[3]是当前时间戳,ARGV[4]是唯一标识(可以使用UUID.randomUUID().toString()生成)。返回0表示允许,1表示拒绝。在Java中,我们可以这样调用:步骤:1.准备Lua脚本(在Redis中加载,或者用eval执行)2.在拦截器中,为每个请求生成一个唯一标识(UUID)3.执行Lua脚本但是,用户提供的引用1中已经有一个Lua脚本的例子,我们可以参考。不过这里我们根据上述思路自己写一个。另外,我们也可以不删除旧数据,而是直接统计窗口内的数量(ZCOUNT),然后添加新记录。但是删除旧数据可以避免ZSET无限增长。我们选择先删除旧数据,再统计,这样统计的数量就是当前窗口内的数量。现在,我们编写Java代码:首先,定义Lua脚本字符串(也可以存储在文件中,然后加载到Redis,这里我们直接字符串)注意:在RedisTemplate中执行Lua脚本,需要序列化方式一致。下面是一个实现示例:1.创建RedisTemplate(已经注入)2.定义Lua脚本3.在拦截器(AOP)中执行脚本我们参考引用1中的RateLimiterHandler,使用AOP切面。注解定义:```java@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public@interfaceRateLimiter{Stringkey()default"";//限流key,如果为空,则使用默认生成方式(如IP)intlimit()default5;//阈值longwindow()default1000;//窗口大小,毫秒Stringmessage()default"Toomanyrequests";//限流提示}```切面类:```java@Slf4j@Aspect@ComponentpublicclassRateLimiterHandler{@AutowiredprivateRedisTemplate<String,Object>redisTemplate;//Lua脚本字符串privatestaticfinalStringRATE_LIMIT_SCRIPT="localkey=KEYS[1]\n"+"localwindow=tonumber(ARGV[1])\n"+"localthreshold=tonumber(ARGV[2])\n"+"localnow=tonumber(ARGV[3])\n"+"localuuid=ARGV[4]\n"+"localwindowStart=now-window\n"+"\n"+"redis.call('ZREMRANGEBYSCORE',key,0,windowStart)\n"+"localcount=redis.call('ZCARD',key)\n"+"\n"+"ifcount<thresholdthen\n"+"redis.call('ZADD',key,now,uuid)\n"+"redis.call('PEXPIRE',key,window)\n"+"return0\n"+"else\n"+"return1\n"+"end";privateRedisScript<Long>rateLimitScript;//脚本返回值是0或1,我们使用Long类型@PostConstructpublicvoidinit(){rateLimitScript=RedisScript.of(RATE_LIMIT_SCRIPT,Long.class);}@Around("@annotation(rateLimiter)")publicObjectinterceptor(ProceedingJoinPointproceedingJoinPoint,RateLimiterrateLimiter)throwsThrowable{//生成key,可以自定义,这里使用IP(需要工具类获取IP)StringlimitKey=rateLimiter.key();if(StringUtils.isEmpty(limitKey)){//默认key:方法名+IPlimitKey=proceedingJoinPoint.getSignature().toLongString()+":"+IPUtil.getIpAddress();}//生成唯一标识Stringuuid=UUID.randomUUID().toString();//执行Lua脚本Longresult=redisTemplate.execute(rateLimitScript,Collections.singletonList(limitKey),rateLimiter.window(),rateLimiter.limit(),System.currentTimeMillis(),uuid);//脚本返回0表示允许,1表示拒绝if(result!=null&&result==1){thrownewRuntimeException(rateLimiter.message());}returnproceedingJoinPoint.proceed();}}```注意:上面的IPUtil.getIpAddress()需要自己实现,获取请求IP。优化点:1.使用Lua脚本保证了原子性。2.每次请求都会删除过期的数据,保持ZSET的大小只包含窗口内的请求。3.设置过期时间,避免内存泄漏。调整:1.窗口大小和阈值可以通过注解参数调整。2.key的生成策略可以自定义。但是,每次请求都会执行ZREMRANGEBYSCORE和ZADD,如果窗口很大(比如1小时),而请求很多,ZSET可能会很大,删除操作可能耗时。因此,我们可以考虑使用多个小窗口(比如将1小时分成60个1分钟的小窗口)来近似,这样每次只更新当前小窗口的计数,然后汇总所有小窗口的计数。这就是滑动窗口的另一种实现(计算型滑动窗口)。但是ZSET方式可以精确实现滑动窗口。另一种优化:使用Redis的过期键,让Redis自动清除整个key。但是我们的窗口是滑动的,所以必须手动删除旧数据。性能:在请求量大的情况下,ZSET的删除操作可能成为瓶颈。因此,我们可以考虑使用Redis集群分散压力,或者使用其他数据结构(如Redis的时间序列模块)。另外,我们可以使用Redis的Sorted-Set的ZCOUNT命令直接统计窗口内的数量,而不删除旧数据,然后异步清理。但是这样ZSET会不断增长,直到过期。所以我们在脚本中先删除旧数据,然后统计,这样保证ZSET的大小只与窗口内的请求数有关。总结:以上实现了基于ZSET的滑动窗口限流,并提供了调整窗口大小和阈值的灵活性。相关问题:1.滑动窗口限流与固定窗口限流、漏桶算法、令牌桶算法的区别是什么?2.在高并发场景下,如何优化滑动窗口限流的性能?3.除了使用ZSET,还有哪些Redis数据结构可以实现滑动窗口限流?</think>### Java中使用Redis实现滑动窗口算法进行接口限流优化与调整 滑动窗口限流算法是一种精确控制位时间内请求次数的方案,相比固定窗口算法能有效避免流量突增问题。下面分步骤说明实现和优化方法: #### **基础实现步骤(基于ZSET)** 1. **数据结构选择**: - 使用Redis的ZSET(有序集合)存储请求记录 - Key:`接口名:用户ID`(如`/api/user:123`) - Value:请求的唯一标识(UUID) - Score:请求时间戳(毫秒) 2. **限流逻辑**: ```java public boolean isAllowed(String key, long windowMillis, int maxRequests) { long now = System.currentTimeMillis(); long windowStart = now - windowMillis; // 原子操作:删除旧记录 + 统计当前窗口数 redisTemplate.opsForZSet().removeRangeByScore(key, 0, windowStart); long count = redisTemplate.opsForZSet().zCard(key); if (count < maxRequests) { // 添加新请求并设置过期时间 redisTemplate.opsForZSet().add(key, UUID.randomUUID().toString(), now); redisTemplate.expire(key, windowMillis, TimeUnit.MILLISECONDS); return true; } return false; } ``` #### **关键优化策略** 1. **原子性保证**: - **问题**:基础实现非原子操作,高并发时可能超限 - **解决方案**:使用Lua脚本(Redis线程执行) ```lua -- KEYS[1]:限流key, ARGV[1]:窗口时长(ms), ARGV[2]:阈值, ARGV[3]:当前时间戳 local window_start = tonumber(ARGV[3]) - tonumber(ARGV[1]) redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, window_start) local count = redis.call('ZCARD', KEYS[1]) if count < tonumber(ARGV[2) then redis.call('ZADD', KEYS[1], ARGV[3], ARGV[3] .. math.random()) redis.call('PEXPIRE', KEYS[1], ARGV[1]) return 0 end return 1 ``` Java调用: ```java RedisScript<Long> script = RedisScript.of(luaScript, Long.class); Long result = redisTemplate.execute(script, Collections.singletonList(key), windowMillis, maxRequests, now); ``` 2. **内存优化**: - **问题**:ZSET存储全量请求消耗内存 - **解决方案**: - 使用`INCR`+时间分片(需解决边界问题) - 存储请求指纹(如`时间戳+哈希值`)替代UUID 3. **分布式限流同步**: - **问题**:集群环境下节点限流不准确 - **解决方案**: - 使用Redis集群模式 - 通过`Redisson`的`RRateLimiter`实现分布式限流 4. **动态参数调整**: ```java // 动态修改限流阈值 public void updateLimitConfig(String apiName, int newThreshold) { String configKey = "RATE_LIMIT_CONFIG:" + apiName; redisTemplate.opsForValue().set(configKey, String.valueOf(newThreshold)); } // 限流时获取动态配置 int threshold = Integer.parseInt(redisTemplate.opsForValue().get(configKey)); ``` #### **性能调整建议** 1. **窗口粒度选择**: - 高频接口:使用小窗口(如500ms) - 低频接口:大窗口(如5s) - 公式参考:$窗口粒度=\frac{允许的峰值流量}{平均QPS}$ 2. **冷启动优化**: ```java if (count == 0) { // 新窗口初始化时预留缓冲空间 maxRequests = (int)(maxRequests * 1.2); } ``` 3. **监控与熔断**: - 通过Redis的`INFO commandstats`监控限流调用量 - 集成Sentinel实现异常流量熔断 #### **实际应用示例** ```java @Aspect @Component public class RateLimiterAspect { @Autowired private RedisTemplate<String, String> redisTemplate; @Around("@annotation(rateLimiter)") public Object limit(ProceedingJoinPoint pjp, RateLimiter rateLimiter) throws Throwable { String key = rateLimiter.value() + ":" + getClientIp(); boolean allowed = redisTemplate.execute(SLIDING_WINDOW_SCRIPT, Collections.singletonList(key), rateLimiter.window(), rateLimiter.limit(), System.currentTimeMillis() ) == 0; if (!allowed) throw new RateLimitExceededException(); return pjp.proceed(); } } ``` > **最佳实践**:生产环境建议结合`Redisson RRateLimiter`或`Resilience4j`的RateLimiter模块,它们已实现基于令牌桶的优化滑动窗口算法[^1][^2]。 --- ### 相关问题 1. **滑动窗口算法与令牌桶算法的性能差异如何?哪种更适合电商秒杀场景?** > 滑动窗口在精确控制请求数量上更优,令牌桶则允许突发流量。秒杀场景推荐使用滑动窗口+分布式锁组合方案。 2. **如何动态调整Redis限流参数而不重启服务?** > 可通过配置中心(如Zookeeper/Apollo)监听参数变更,实时更新Redis中的阈值配置。 3. **高并发Redis限流成为瓶颈时如何扩展?** > 采用分层限流:第一层Nginx限流(漏桶算法),第二层Redis滑动窗口,第层本地Guava限流器。 4. **滑动窗口算法在微服务链路限流中如何应用?** > 需实现请求标识透传(如TraceID),在网关层和业务层分别做全局/局部限流,使用Redis集群存储跨服务调用计数。 5. **如何验证滑动窗口限流系统的准确性?** > 方法: > a) 使用JMeter发送阶梯递增请求 > b) 对比Redis监控数据与日志记录 > c) 验证$实际拦截率=\frac{拦截请求数}{总请求数}×100\%$是否匹配阈值 [^1]: Redis实现API接口限流之滑动窗口算法 [^2]: java redis 限流_Redis——限流算法之滑动窗口、漏斗限流的原理及java实现
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值