跳转到内容

Redis 限流


限流(Rate Limiting)是保护系统的重要手段,防止突发流量压垮服务:

场景问题限流手段
接口被刷恶意请求耗尽资源按 IP / 用户限流
秒杀活动瞬间洪峰压垮 DB按接口全局限流
第三方 API超出调用配额按服务限流
短信验证码短信轰炸按手机号限流
固定窗口: [0s ─────────────── 60s] 允许 100 次
↑ 窗口重置,计数归零
滑动窗口: ←────── 60s 滑动 ──────→ 始终统计最近 60s 内的请求数
令牌桶: 桶(容量=100) ←─── 匀速生成令牌 ←─── 请求消耗令牌
允许突发(桶满时可瞬间消耗)
漏桶: 请求 ──▶ [桶] ──▶ 匀速流出 ──▶ 服务
强制匀速,完全不允许突发
算法突发流量精度实现复杂度适用场景
固定窗口❌ 边界双倍流量简单粗粒度限流
滑动窗口一般中等接口限流
令牌桶✅ 允许适度突发较复杂API 网关、通用限流
漏桶❌ 完全平滑较复杂强制匀速场景

3. 方案一:固定窗口(INCR + EXPIRE)

Section titled “3. 方案一:固定窗口(INCR + EXPIRE)”

最简单的实现,适合粗粒度限流场景:

@Service
@RequiredArgsConstructor
public class FixedWindowRateLimiter {
private final StringRedisTemplate redisTemplate;
/**
* 固定窗口限流
* @param key 限流维度(如 "limit:sms:13800138000")
* @param limit 窗口内允许的最大请求数
* @param window 窗口大小(秒)
* @return true=允许,false=被限流
*/
public boolean isAllowed(String key, int limit, int window) {
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
// 第一次请求,设置过期时间(窗口开始计时)
redisTemplate.expire(key, window, TimeUnit.SECONDS);
}
return count <= limit;
}
}

4. 方案二:滑动窗口(ZSet + Lua)

Section titled “4. 方案二:滑动窗口(ZSet + Lua)”

用 ZSet 记录请求时间戳,每次请求时删除窗口外的记录,统计当前窗口内的请求数:

@Service
@RequiredArgsConstructor
public class SlidingWindowRateLimiter {
private final StringRedisTemplate redisTemplate;
// Lua 脚本保证原子性
private static final String SLIDING_WINDOW_SCRIPT = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local windowStart = now - window * 1000
-- 删除窗口外的过期记录
redis.call('ZREMRANGEBYSCORE', key, '-inf', windowStart)
-- 统计当前窗口内的请求数
local count = redis.call('ZCARD', key)
if count < limit then
-- 未超限:记录本次请求(score = 时间戳,member = 时间戳+随机数保证唯一)
redis.call('ZADD', key, now, now .. math.random())
redis.call('EXPIRE', key, window + 1)
return 1 -- 允许
else
return 0 -- 拒绝
end
""";
private final RedisScript<Long> slidingWindowScript =
new DefaultRedisScript<>(SLIDING_WINDOW_SCRIPT, Long.class);
public boolean isAllowed(String key, int limit, int windowSeconds) {
long now = System.currentTimeMillis();
Long result = redisTemplate.execute(
slidingWindowScript,
Collections.singletonList(key),
String.valueOf(now),
String.valueOf(windowSeconds),
String.valueOf(limit)
);
return Long.valueOf(1L).equals(result);
}
}

滑动窗口工作示意:

时间轴:──────────────────────────────────────────────────▶
[────────── 60s 窗口 ──────────]
| |
窗口起点 当前时间
自动删除窗口外的请求记录
统计窗口内剩余请求数 → 判断是否超限

5. 方案三:Redisson 令牌桶(推荐)

Section titled “5. 方案三:Redisson 令牌桶(推荐)”

Redisson 内置令牌桶实现(RRateLimiter),线程安全,支持分布式,无需手写 Lua 脚本:

@Configuration
public class RateLimiterConfig {
@Bean
public RRateLimiter smsRateLimiter(RedissonClient redissonClient) {
RRateLimiter limiter = redissonClient.getRateLimiter("rl:sms:global");
// 令牌生成速率:每秒 10 个令牌,桶容量 10
limiter.trySetRate(RateType.OVERALL, 10, 1, RateIntervalUnit.SECONDS);
return limiter;
}
}
@Service
@RequiredArgsConstructor
public class SmsService {
private final RedissonClient redissonClient;
public void sendSms(String phone) {
// 全局限流
RRateLimiter globalLimiter = redissonClient.getRateLimiter("rl:sms:global");
// 按手机号限流(每分钟最多 1 次)
RRateLimiter phoneLimiter = redissonClient.getRateLimiter("rl:sms:" + phone);
phoneLimiter.trySetRate(RateType.OVERALL, 1, 1, RateIntervalUnit.MINUTES);
// 非阻塞尝试获取令牌
if (!globalLimiter.tryAcquire()) {
throw new RuntimeException("系统繁忙,请稍后重试");
}
if (!phoneLimiter.tryAcquire()) {
throw new RuntimeException("发送过于频繁,请 1 分钟后再试");
}
doSendSms(phone);
}
}

RRateLimiter 常用 API:

// 初始化:整体限流(OVERALL)或单客户端限流(PER_CLIENT)
limiter.trySetRate(RateType.OVERALL, 100, 1, RateIntervalUnit.SECONDS);
// 获取 1 个令牌(非阻塞,失败返回 false)
boolean ok = limiter.tryAcquire();
// 获取 N 个令牌(非阻塞)
boolean ok = limiter.tryAcquire(3);
// 获取令牌,最多等待 500ms(阻塞)
boolean ok = limiter.tryAcquire(500, TimeUnit.MILLISECONDS);
// 阻塞直到获取到令牌
limiter.acquire();

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
String key() default ""; // SpEL,空则用方法签名
int rate() default 100; // 每个时间窗口允许的请求数
int interval() default 1; // 时间窗口大小
RateIntervalUnit unit() default RateIntervalUnit.SECONDS;
String message() default "请求过于频繁,请稍后重试";
}
@Aspect
@Component
@RequiredArgsConstructor
public class RateLimitAspect {
private final RedissonClient redissonClient;
private final ExpressionParser parser = new SpelExpressionParser();
@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable {
String key = buildKey(joinPoint, rateLimit);
RRateLimiter limiter = redissonClient.getRateLimiter(key);
limiter.trySetRate(RateType.OVERALL, rateLimit.rate(),
rateLimit.interval(), rateLimit.unit());
if (!limiter.tryAcquire()) {
throw new RuntimeException(rateLimit.message());
}
return joinPoint.proceed();
}
private String buildKey(ProceedingJoinPoint joinPoint, RateLimit rateLimit) {
if (!rateLimit.key().isEmpty()) {
// 解析 SpEL
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
EvaluationContext ctx = new StandardEvaluationContext();
String[] names = sig.getParameterNames();
Object[] args = joinPoint.getArgs();
for (int i = 0; i < names.length; i++) ctx.setVariable(names[i], args[i]);
return "rl:" + parser.parseExpression(rateLimit.key()).getValue(ctx, String.class);
}
// 默认:类名.方法名
MethodSignature sig = (MethodSignature) joinPoint.getSignature();
return "rl:" + sig.getDeclaringTypeName() + "." + sig.getName();
}
}
// 使用
@RestController
public class SmsController {
// 全局:每秒最多 10 次
@RateLimit(rate = 10, interval = 1)
@PostMapping("/sms/send")
public void send(@RequestParam String phone) { ... }
// 按手机号:每分钟最多 1 次
@RateLimit(key = "#phone", rate = 1, interval = 1,
unit = RateIntervalUnit.MINUTES, message = "发送过于频繁,请 1 分钟后重试")
@PostMapping("/sms/sendByPhone")
public void sendByPhone(@RequestParam String phone) { ... }
}

场景推荐方案说明
简单接口粗粒度限流固定窗口(INCR)实现简单,够用
精确接口限流(防边界问题)滑动窗口(ZSet + Lua)精度高,无边界双倍问题
允许适度突发的限流Redisson RRateLimiter令牌桶,功能完善,推荐
强制匀速输出漏桶(Redisson + 自定义实现)少见,多用于流量整形