设计一个分布式限流系统——从单机到跨数据中心的限流方案
设计一个分布式限流系统——从单机到跨数据中心的限流方案
适读人群:关注高并发系统稳定性的后端工程师 | 阅读时长:约17分钟 | 核心价值:限流不只是加个 Guava RateLimiter,分布式场景下的限流方案选型与踩坑实录
先从一次差点压垮数据库的事故说起
那是一个普通的工作日下午,我们在做一个营销活动功能:用户可以领取优惠券,每个用户每天最多领取 5 张。
上线后没多久,DBA 过来找我,说数据库连接数打满了,CPU 飙到 100%。
我登上去一看:某个用户在 1 秒内发送了 800 次领券请求,数据库里跑了 800 个并发的"查询今日已领取数量"查询。
我们当时有限流,但是是单机限流——每台机器上用 Guava 的 RateLimiter 做的,配置是单用户每秒最多 10 次。
问题是:我们有 8 台机器。8 台机器 * 每台 10 次 = 这个用户在 1 秒内可以合法打过来 80 次,再加上负载均衡的不均匀,某些情况下能达到更高。
更糟糕的是,那个用户是用脚本在 8 台机器上轮流打,完美地绕过了单机限流。
这件事让我系统性地思考了一遍限流这个话题。
限流算法:先搞清楚有哪些选择
在讨论分布式之前,先把基础的限流算法过一遍。
固定窗口计数器
最简单:每个时间窗口内,请求数不超过阈值就放过,超了就拒绝。
public class FixedWindowRateLimiter {
private final int limit;
private final long windowMs;
private long windowStart;
private int count;
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
if (now - windowStart >= windowMs) {
windowStart = now;
count = 0;
}
if (count < limit) {
count++;
return true;
}
return false;
}
}问题: 存在临界问题。假设限制每分钟 100 次,在 0:59 放过了 100 次,在 1:01 又放过了 100 次,实际上 2 秒内通过了 200 次。
滑动窗口计数器
把固定窗口切成多个小槽,只统计过去 N 个时间槽的总量,没有临界问题。
// 用 Redis ZSet 实现滑动窗口
public boolean tryAcquire(String key, int limit, long windowMs) {
long now = System.currentTimeMillis();
long windowStart = now - windowMs;
String script = """
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window_start = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local expire = tonumber(ARGV[4])
redis.call('ZREMRANGEBYSCORE', key, 0, window_start)
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now .. math.random())
redis.call('EXPIRE', key, expire)
return 1
end
return 0
""";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
List.of(key),
String.valueOf(now),
String.valueOf(windowStart),
String.valueOf(limit),
String.valueOf(windowMs / 1000 + 1)
);
return Long.valueOf(1).equals(result);
}优点: 没有临界问题,相对精确。 缺点: ZSet 存每次请求的时间戳,数据量大时内存占用高。
令牌桶
系统以固定速率往桶里放令牌,请求来时消费令牌,桶满了不再放。允许一定程度的突发流量。
优点: 允许突发,适合有合理突发需求的业务(比如批量操作)。 缺点: 实现稍复杂,分布式下需要同步令牌状态。
漏桶
请求进入漏桶,漏桶以固定速率流出,满了就拒绝。严格平滑流量,不允许突发。
优点: 流量极平滑,保护下游。 缺点: 不允许突发,对合法的瞬时高峰也会拒绝。
分布式限流:核心问题是"共享状态"
单机限流的状态在本机内存里,分布式限流的状态必须放在共享存储里。
通常的选择是 Redis,因为 Redis 是单线程的,天然解决了并发写的原子性问题。
方案一:Redis + Lua 脚本(推荐)
Redis 的 Lua 脚本在单个 Redis 节点上是原子执行的,可以保证"查询+更新"的原子性,避免竞态条件。
@Component
public class RedisRateLimiter {
private static final String RATE_LIMIT_SCRIPT = """
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local expire = tonumber(ARGV[2])
local current = redis.call('GET', key)
if current and tonumber(current) >= limit then
return 0
end
local new_val = redis.call('INCR', key)
if new_val == 1 then
redis.call('EXPIRE', key, expire)
end
return 1
""";
private final DefaultRedisScript<Long> script;
private final RedisTemplate<String, String> redisTemplate;
public RedisRateLimiter(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
this.script = new DefaultRedisScript<>(RATE_LIMIT_SCRIPT, Long.class);
}
/**
* @param key 限流 Key(如 "rate:user:123")
* @param limit 窗口内最大次数
* @param windowSeconds 窗口秒数
*/
public boolean tryAcquire(String key, int limit, int windowSeconds) {
Long result = redisTemplate.execute(
script,
List.of(key),
String.valueOf(limit),
String.valueOf(windowSeconds)
);
return Long.valueOf(1).equals(result);
}
}使用示例:
@PostMapping("/coupon/claim")
public ResponseEntity<?> claimCoupon(@RequestBody ClaimRequest req,
HttpServletRequest httpReq) {
String userId = req.getUserId();
String key = "rate:coupon_claim:user:" + userId;
// 每个用户每秒最多 2 次领券请求
if (!rateLimiter.tryAcquire(key, 2, 1)) {
return ResponseEntity.status(429).body("请求过于频繁,请稍后再试");
}
return ResponseEntity.ok(couponService.claim(userId, req.getCouponId()));
}踩坑记录
踩坑一:Redis 限流 Key 设计不当,被绕过
最开始我们的限流 Key 设计是:rate:api:/coupon/claim:user:123
但有个场景我们漏了:同一个 API 路径,用户可以用不同的参数反复调用。比如领券接口里有个 couponId 参数,用户可以给不同的 couponId 发请求,每个 couponId 对应一个独立的限流 Key,等于没有限流。
修复方案:限流 Key 的粒度要根据业务语义来,不能只按 API 路径分。这个场景应该是:rate:coupon_claim:user:123,不管领哪种券,都算在同一个桶里。
教训:限流 Key 的设计需要业务理解,不是一刀切按 API 路径。
踩坑二:Redis 限流本身成了性能瓶颈
当接口 QPS 达到几万时,每次请求都要打 Redis,Redis 的网络 RTT(大概 1-2ms)直接叠加到了接口响应时间里。
更严重的是,Redis 本身的 QPS 上限大概是 10 万,一旦接近这个上限,Redis 成了新的瓶颈,反而拖垮了本来要保护的服务。
解决方案:本地限流 + 分布式限流两级架构。
本地限流(Guava RateLimiter 或 Caffeine)做粗粒度保护,吸收大部分请求,只有少部分请求需要穿透到 Redis 做精确限流。
public boolean tryAcquire(String userId, String apiKey) {
// 第一级:本地令牌桶,每台机器独立,极快
// 预设为全局限额 / 预期节点数 * 1.5(留 buffer)
RateLimiter localLimiter = localLimiters.computeIfAbsent(
apiKey + ":" + userId,
k -> RateLimiter.create(20) // 本地每秒 20 次
);
if (!localLimiter.tryAcquire()) {
return false; // 本地就拦截了,不打 Redis
}
// 第二级:Redis 精确限流
String redisKey = "rate:" + apiKey + ":user:" + userId;
return redisRateLimiter.tryAcquire(redisKey, 10, 1);
}踩坑三:跨数据中心限流的延迟问题
我们做了双机房部署后,两个机房的限流 Redis 是同一个集群(跨机房复制),但跨机房的网络延迟有 20-50ms。
这意味着每次限流检查都多了 20-50ms 的延迟,对于追求低延迟的接口是不可接受的。
两种思路:
思路 A:每个机房独立限流,全局限额按比例分配
- 全局限额 1000,A 机房分配 500,B 机房分配 500
- 优点:完全本地化,无跨机房延迟
- 缺点:流量不均时某个机房的额度会浪费,整体利用率低
思路 B:异步同步计数,允许短暂超限
- 每个机房维护本地计数,定期(比如 100ms)向全局 Redis 同步
- 优点:基本消除限流路径上的跨机房延迟
- 缺点:存在 100ms 的窗口期超限,需要业务能接受
我们选了方案 A,因为我们的业务对超限零容忍(涉及资金),宁可浪费一些额度也不能超。
限流系统完整架构
限流的配置化管理
硬编码限流阈值是很危险的。业务场景变化、大促活动、突发流量,都需要动态调整限流配置。
我们最终把限流配置抽到了配置中心(Apollo),支持实时生效:
@Component
public class DynamicRateLimitConfig {
@ApolloJsonValue("${rate.limit.rules:{}}")
private Map<String, RateLimitRule> rules = new ConcurrentHashMap<>();
public RateLimitRule getRule(String apiKey) {
return rules.getOrDefault(apiKey, DEFAULT_RULE);
}
}
// Apollo 配置示例(JSON):
// {
// "coupon_claim": {"limit": 10, "windowSeconds": 1, "scope": "user"},
// "product_query": {"limit": 100, "windowSeconds": 1, "scope": "ip"}
// }一句话总结
限流不是加一行代码的事,是一个需要结合业务场景、系统架构、运维能力综合设计的子系统。
从单机 → 分布式 → 跨机房,每一步都有新的问题要解,没有银弹,只有针对当前约束的最优解。
