Redis限流算法实战

Redis限流算法实战

Redis 的五种数据结构各有特色,用对了才能发挥它的优势。很多人只用到了 String 和 Hash,却不知道 List、Set、ZSet 在特定场景下更合适。本文从应用场景出发,讲什么时候用什么类型。

一、计数器算法(固定窗口)

#

1.1 原理

将时间划分为固定窗口(如1分钟),每个窗口内允许N个请求

时间线:
00:00 ───── 00:01 ───── 00:02 ───── 00:03
│ │ │ │
窗口1 窗口2 窗口3 窗口4
计数:50 计数:100 计数:30 计数:0
限流:100 限流:100 限流:100 限流:100

#

1.2 Redis实现

@Component
public class CounterRateLimiter {

@Autowired
private StringRedisTemplate redis;

/**
* 计数器限流
* @param key 限流key
* @param limit 窗口内最大请求数
* @param windowSeconds 窗口大小(秒)
*/
public boolean isAllowed(String key, int limit, int windowSeconds) {
String redisKey = "rate:counter:" + key;

// 获取当前计数
String countStr = redis.opsForValue().get(redisKey);
int count = countStr != null ? Integer.parseInt(countStr) : 0;

if (count >= limit) {
return false; // 超过限流
}

// 增加计数
Long newCount = redis.opsForValue().increment(redisKey);

// 如果是第一次,设置过期时间
if (newCount != null && newCount == 1) {
redis.expire(redisKey, windowSeconds, TimeUnit.SECONDS);
}

return true;
}
}

#

1.3 Lua原子实现

-- 计数器限流(原子操作)
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])

local count = redis.call('get', key)
if count and tonumber(count) >= limit then
return 0
end

local current = redis.call('incr', key)
if current == 1 then
redis.call('expire', key, window)
end

return 1
@Component
public class CounterRateLimiterLua {

@Autowired
private StringRedisTemplate redis;

private static final String COUNTER_SCRIPT =
"local key = KEYS[1] " +
"local limit = tonumber(ARGV[1]) " +
"local window = tonumber(ARGV[2]) " +
"local count = redis.call('get', key) " +
"if count and tonumber(count) >= limit then return 0 end " +
"local current = redis.call('incr', key) " +
"if current == 1 then redis.call('expire', key, window) end " +
"return 1";

public boolean isAllowed(String key, int limit, int windowSeconds) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>(COUNTER_SCRIPT, Long.class);
Long result = redis.execute(script, Collections.singletonList("rate:counter:" + key),
String.valueOf(limit), String.valueOf(windowSeconds));
return result != null && result == 1;
}
}

#

1.4 缺点

临界问题:

窗口1(00:00-00:01) 窗口2(00:01-00:02)
│ │
└── 100请求 ──┐ └── 100请求 ──
│ │
00:01 ──────────┘

在00:00:59和00:01:01之间,实际通过了200个请求!

二、滑动窗口算法

#

2.1 原理

记录每个请求的时间戳,滑动统计窗口内的请求数

时间线:
00:00:50 请求1
00:00:55 请求2
00:01:02 请求3 ← 窗口滑动,00:00:02之前的过期
00:01:05 请求4

窗口大小1分钟,当前时间00:01:05:
统计00:00:05到00:01:05的请求 = 4个

#

2.2 ZSet实现

@Component
public class SlidingWindowRateLimiter {

@Autowired
private StringRedisTemplate redis;

/**
* 滑动窗口限流
*/
public boolean isAllowed(String key, int limit, int windowMs) {
String redisKey = "rate:sliding:" + key;
long now = System.currentTimeMillis();
long windowStart = now - windowMs;

// 移除窗口外的请求记录
redis.opsForZSet().removeRangeByScore(redisKey, 0, windowStart);

// 获取当前窗口内的请求数
Long count = redis.opsForZSet().zCard(redisKey);
if (count != null && count >= limit) {
return false;
}

// 记录本次请求(使用雪花ID或UUID保证member唯一)
redis.opsForZSet().add(redisKey, now + ":" + ThreadLocalRandom.current().nextInt(100000), now);
redis.expire(redisKey, windowMs / 1000 + 1, TimeUnit.SECONDS);

return true;
}
}

#

2.3 Lua原子实现

-- 滑动窗口限流
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local now = tonumber(ARGV[2])
local window = tonumber(ARGV[3])
local windowStart = now - window

-- 移除窗口外的记录
redis.call('zremrangebyscore', key, 0, windowStart)

-- 获取当前窗口内记录数
local count = redis.call('zcard', key)
if count >= limit then
return 0
end

-- 添加记录
redis.call('zadd', key, now, ARGV[4])
redis.call('expire', key, math.ceil(window / 1000) + 1)

return 1

三、漏桶算法

#

3.1 原理

请求先进入漏桶(队列),以固定速率流出处理

请求流入


┌─────────┐
│ 漏桶 │ ← 容量有限,满了则溢出(限流)
│ (队列) │
└────┬────┘
│ 固定速率流出

处理

特点:
- 流出速率固定(平滑流量)
- 桶满时新请求被拒绝
- 可以应对突发流量

#

3.2 Redis实现

@Component
public class LeakyBucketRateLimiter {

@Autowired
private StringRedisTemplate redis;

/**
* 漏桶限流
* @param key 限流key
* @param capacity 桶容量
* @param ratePerSecond 每秒流出速率
*/
public boolean isAllowed(String key, int capacity, int ratePerSecond) {
String redisKey = "rate:leaky:" + key;

String luaScript =
"local key = KEYS[1] " +
"local capacity = tonumber(ARGV[1]) " +
"local rate = tonumber(ARGV[2]) " +
"local now = tonumber(ARGV[3]) " +
"local requested = tonumber(ARGV[4]) " +
"\n" +
"local bucket = redis.call('hmget', key, 'water', 'lastTime') " +
"local water = tonumber(bucket[1]) or 0 " +
"local lastTime = tonumber(bucket[2]) or now " +
"\n" +
"-- 计算流出的水量 " +
"local leak = math.floor((now - lastTime) * rate / 1000) " +
"water = math.max(0, water - leak) " +
"\n" +
"-- 检查是否溢出 " +
"if water + requested <= capacity then " +
" water = water + requested " +
" redis.call('hmset', key, 'water', water, 'lastTime', now) " +
" redis.call('expire', key, 60) " +
" return 1 " +
"else " +
" redis.call('hmset', key, 'water', water, 'lastTime', now) " +
" redis.call('expire', key, 60) " +
" return 0 " +
"end";

DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class);
Long result = redis.execute(script, Collections.singletonList(redisKey),
String.valueOf(capacity),
String.valueOf(ratePerSecond),
String.valueOf(System.currentTimeMillis()),
"1"
);

return result != null && result == 1;
}
}

四、令牌桶算法

#

4.1 原理

以固定速率往桶里放令牌,请求需要获取令牌才能执行

令牌放入
│ 固定速率

┌─────────┐
│ 令牌桶 │ ← 容量有限,满了不再放入
│ │
└────┬────┘

请求获取令牌

├── 有令牌 → 执行
└── 无令牌 → 限流

特点:
- 可以应对一定突发流量(桶内有累积令牌)
- 长期速率固定
- 最灵活的限流算法

#

4.2 Redis实现

@Component
public class TokenBucketRateLimiter {

@Autowired
private StringRedisTemplate redis;

/**
* 令牌桶限流
* @param key 限流key
* @param capacity 桶容量
* @param ratePerSecond 每秒放入令牌数
*/
public boolean isAllowed(String key, int capacity, int ratePerSecond) {
String redisKey = "rate:token:" + key;

String luaScript =
"local key = KEYS[1] " +
"local capacity = tonumber(ARGV[1]) " +
"local rate = tonumber(ARGV[2]) " +
"local now = tonumber(ARGV[3]) " +
"local requested = tonumber(ARGV[4]) " +
"\n" +
"local bucket = redis.call('hmget', key, 'tokens', 'lastTime') " +
"local tokens = tonumber(bucket[1]) or capacity " +
"local lastTime = tonumber(bucket[2]) or now " +
"\n" +
"-- 计算新增的令牌 " +
"local delta = math.max(0, (now - lastTime) * rate / 1000) " +
"tokens = math.min(capacity, tokens + delta) " +
"\n" +
"-- 检查是否有足够令牌 " +
"if tokens >= requested then " +
" tokens = tokens - requested " +
" redis.call('hmset', key, 'tokens', tokens, 'lastTime', now) " +
" redis.call('expire', key, 60) " +
" return 1 " +
"else " +
" redis.call('hmset', key, 'tokens', tokens, 'lastTime', now) " +
" redis.call('expire', key, 60) " +
" return 0 " +
"end";

DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class);
Long result = redis.execute(script, Collections.singletonList(redisKey),
String.valueOf(capacity),
String.valueOf(ratePerSecond),
String.valueOf(System.currentTimeMillis()),
"1"
);

return result != null && result == 1;
}
}

五、算法对比

算法 优点 缺点 适用场景
计数器 简单,内存小 临界问题 简单限流
滑动窗口 精确,无临界问题 内存占用大 精确限流
漏桶 平滑流量 无法应对突发 流量整形
令牌桶 允许突发,灵活 实现复杂 通用限流

六、Spring Boot整合

#

6.1 AOP限流

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
String key() default "";
int limit() default 100;
int window() default 60;
String message() default "请求过于频繁,请稍后再试";
}

@Aspect
@Component
public class RateLimitAspect {

@Autowired
private TokenBucketRateLimiter rateLimiter;

@Around("@annotation(rateLimit)")
public Object around(ProceedingJoinPoint point, RateLimit rateLimit) throws Throwable {
String key = rateLimit.key();
if (key.isEmpty()) {
key = point.getSignature().toShortString();
}

// 可以加入用户ID等维度
Object[] args = point.getArgs();
if (args.length > 0 && args[0] instanceof String) {
key = key + ":" + args[0];
}

boolean allowed = rateLimiter.isAllowed(key, rateLimit.limit(), rateLimit.window());
if (!allowed) {
throw new RateLimitException(rateLimit.message());
}

return point.proceed();
}
}

#

6.2 使用注解

@RestController
public class ApiController {

@RateLimit(key = "api:order", limit = 10, window = 60)
@PostMapping("/order")
public Response createOrder(@RequestBody OrderRequest request) {
// 创建订单
return Response.success();
}

@RateLimit(key = "api:query", limit = 100, window = 60)
@GetMapping("/products")
public List<Product> listProducts() {
// 查询商品
return productService.list();
}
}

#

6.3 全局异常处理

@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(RateLimitException.class)
public ResponseEntity<ErrorResponse> handleRateLimit(RateLimitException e) {
return ResponseEntity.status(429)
.body(new ErrorResponse(429, e.getMessage()));
}
}

七、多级限流

#

7.1 分层限流

@Component
public class MultiLevelRateLimiter {

@Autowired
private TokenBucketRateLimiter tokenBucket;

public boolean isAllowed(String userId, String api) {
// L1: 全局限流(保护系统)
if (!tokenBucket.isAllowed("global:" + api, 10000, 1000)) {
return false;
}

// L2: API限流(保护单个API)
if (!tokenBucket.isAllowed("api:" + api, 1000, 100)) {
return false;
}

// L3: 用户限流(防止单个用户刷接口)
if (!tokenBucket.isAllowed("user:" + userId + ":" + api, 10, 1)) {
return false;
}

return true;
}
}

八、总结

实现方式 复杂度 精度 推荐场景
计数器 简单接口保护
滑动窗口 精确限流
漏桶 流量整形
令牌桶 通用限流(推荐)

限流设计的核心原则:

  1. 多层防护:网关层、应用层、接口层逐级限流
  2. 粒度合理:按用户、IP、接口多维度限流
  3. 降级友好:限流时返回友好提示
  4. 监控告警:及时发现异常流量
  5. 动态调整:支持动态修改限流阈值

核心要点

  1. String:简单的键值对,适合缓存、计数器

  2. Hash:存储对象属性,适合用户信息、配置

  3. List:有序列表,适合消息队列、最新列表

  4. Set:无序去重,适合共同好友、抽奖

  5. ZSet:有序集合,适合排行榜、积分系统

总结

选择合适的数据结构是使用 Redis 的关键。在实际项目中,根据业务需求选择合适的类型,可以提升性能和开发效率。


   转载规则


《Redis限流算法实战》 小乐 采用 知识共享署名 4.0 国际许可协议 进行许可。
  目录