把「少接点」翻译成代码
上一篇的雪崩链第一味药是限流:流量超过容量,就果断少接点。限流的需求说出来只有一句话——「下单接口每秒最多放行 200 个请求,多余的拒绝」。把这句话翻译成代码,最朴素的答案叫固定窗口计数器(Fixed Window Counter):把时间切成一格一格的窗口(比如每秒一格),每个窗口一个计数器,请求进来计数器加一,超过阈值就拒绝,窗口切换时计数器清零重来。
// 单机版固定窗口:AtomicLong + 时间戳
private final AtomicLong counter = new AtomicLong();
private volatile long windowStart = System.currentTimeMillis();
private static final long WINDOW_MS = 1000; // 窗口 1 秒
private static final long LIMIT = 200; // 阈值 200
public boolean tryAcquire() {
long now = System.currentTimeMillis();
if (now - windowStart >= WINDOW_MS) { // 进入新窗口
windowStart = now; // 换格子,清计数
counter.set(0);
}
return counter.incrementAndGet() <= LIMIT; // 超阈值返回 false
}三行逻辑与两个必答题
核心逻辑就三行:判窗口、清计数、加计数。便宜到几乎不会出错:内存里一个 long,判定 O(1),没有锁竞争(AtomicLong 用 CAS)。但把这段代码上到生产之前,有两个必答题要先回答。第一问:限什么维度?——按接口限(全接口共享 200)、按用户限(每人每秒 1 单)、按 IP 限(防刷子)、还是按租户限(防大客户吃光)?维度不同,计数器的 key 就不同,业务上往往多维度叠加:接口整体 200,单用户 1。第二问:超了怎么办?——直接拒绝(返回「活动太火爆」)、排队等待(进队列慢慢消费)、还是降级处理(返回默认值或读缓存)?拒绝是最诚实的,排队是把压力延后,降级是把体验打折——选哪种,取决于业务能接受什么。
多实例之后:计数器放到 Redis
应用是多实例部署,每台机器各数各的,「全接口 200」就变成了「每实例 200」,总量失控。计数器必须挪到所有实例共享的地方——Redis:
-- 固定窗口的 Redis 版(Lua 保证原子)
local c = redis.call("INCR", KEYS[1]) -- 计数 +1
if c == 1 then
redis.call("PEXPIRE", KEYS[1], ARGV[1]) -- 第一个请求设过期
end
if c > tonumber(ARGV[2]) then
return 0 -- 超阈值,拒绝
end
return 1 -- 放行KEYS[1] 是「接口名 + 窗口号」拼出的 key(比如 limit:order:1736889600),ARGV 是窗口时长与阈值。INCR 加 PEXPIRE 打包在一个 Lua 里,避开「先 INCR 再 EXPIRE 之间进程崩了产生永生 key」的坑——这个坑与分布式锁系列第 2 篇的永生锁同源,读者应该已经眼熟。Redis 版的代价是多一次网络往返(RT 加 1 毫秒左右)与 Redis 自身的可用性依赖,下一篇的网关层限流可以再加一道前置闸分摊压力。
阈值从哪来:压测说了算
阈值 200 是拍脑袋还是算出来的?答案是压测说了算:对接口做全链路压测,找到「RT 开始陡增、错误率开始抬头」的拐点,拐点流量打七折作为限流阈值——留三成余量吸收波动。阈值不是永恒的:代码优化后重新压、扩容后重新压、促销前重新压。把阈值标成拍脑袋数字的限流,比没有限流更危险——它给了你虚假的安全感,也让真正的容量问题被掩盖。
它有个藏在边界的坑
固定窗口的弱点藏在一个极端场景里:假设窗口边界是 10:00:00,攻击者在 9:59:59.9 到 10:00:00.1 之间打出 400 个请求——前 200 个落在上一窗口(9:59 窗口刚到 200,全部放行),后 200 个落在下一窗口(10:00 窗口刚清零,又全部放行)。在跨越边界的 200 毫秒里,系统实际承受了 400 个请求,是阈值的两倍。这个「临界突刺」在平滑流量下无感,在秒杀、定时任务扎堆的场景里恰好是最要命的时刻。下一篇的滑动窗口,就是为磨平这根突刺而生。
评论 (0)