从计数器到租约
摘要上游模型 429 逼出的全局并发配额:INCR/DECR 计数器为什么必然泄漏,ZSET + Lua 怎样把匿名占位变成可过期的具名租约,以及集中式限流仍然无解的部分。
这篇接着《单体里的分布式系统》往下写。那篇的结尾,Corevo 的 Agent Runtime 已经变成多进程、多 Worker 的形态:为了绕开 GIL,请求被摊到 N 个进程上。随之而来的一个问题是,上游模型服务的并发配额是全局有限的,超过就直接 429——而此时任何一个 Worker 都只知道自己手里有几个在飞的请求,不知道全局有多少。
单进程时代一个 asyncio.Semaphore 就能解决的事,现在需要一个跨进程的全局并发配额。这篇复盘我们在这一步踩过和推演过的坑:为什么最直觉的 Redis 计数器方案在生产里必然坏掉,为什么给 key 加 TTL 会把问题变得更隐蔽,以及最终的形态——用 ZSET 把「占一个坑」变成「签一份有名字、有期限的租约」。
照旧声明:文中的代码是为公开复盘重写的最小实现,不是公司源码;交互实验只在读者浏览器里运行,演示的是调度与回收关系,不是生产容量。真实经历承担的是问题来源和判断过程,不负责给文章加戏。
计数器方案会怎么死
最直觉的方案五分钟就能写完:Redis 放一个计数器,请求前 INCR,超了就拒绝,结束后 DECR。
async def acquire(redis, limit):
n = await redis.incr("llm:inflight")
if n > limit:
await redis.decr("llm:inflight")
return False
return True
async def release(redis):
await redis.decr("llm:inflight")
这段代码能通过所有测试,能扛住压测,然后在生产里慢慢死掉。死法不止一种。
第一种:释放永远不会发生。 INCR 成功之后,进程 OOM、容器被杀、机器掉电、部署时被 SIGKILL——DECR 就永远丢了。try/finally 只能兜住普通异常,兜不住进程消失。计数只升不降,一周之后配额被这些「幽灵占位」吃光,所有请求被拒,而监控里每个 Worker 看起来都很闲。
第二种:拒绝路径自己也在泄漏。 上面的代码已经小心地在拒绝前把自己的增量退了回去。但客户端在 INCR 成功、响应包丢失时会怎么办?它不知道刚才到底加没加上——重试则一个请求占两个坑,不重试则可能凭空泄漏一个。网络分区把「我占了几个位」这件事变成了薛定谔状态。
第三种:负数与超卖。 最简单的例子是,同一个请求被释放了两次。假设并发上限是 1、当前计数是 0,请求 A 先执行 INCR,计数变成 1,正常拿到唯一的槽位。但异常处理和外层 finally 都以为自己负责清理:
try:
await call_model()
except Exception:
await release(redis) # 第一次 DECR:1 → 0
raise
finally:
await release(redis) # 第二次 DECR:0 → -1
现在 Redis 中的计数是 -1,相当于系统凭空多记了一个空位。请求 B 到来后执行 INCR,计数从 -1 变成 0,未超过上限,被放行;请求 C 再执行一次 INCR,计数从 0 变成 1,也被放行。实际运行的是 B、C 两个请求,但限流器看到的计数只有 1。 并发上限明明是 1,系统却放进了 2 个请求,这就是超卖。
真实系统未必会写出这么明显的双重 release,但超时回调、取消回调、重试补偿和正常结束路径都可能分别清理同一次占用。匿名计数器无法判断某个请求是否已经释放过,所以任何一次重复 DECR 都会变成后续请求可以花掉的“负数额度”。
这三种死法有一个共同的根源:计数是匿名的。一个泄漏的 +1 不知道是谁加的、什么时候加的、它的主人还活着没有。你没有任何办法把它安全地收回来,因为你分不清哪个 +1 是尸体、哪个是正在跑的任务。
发现计数会泄漏后,一个很自然的补救是给 key 设置过期时间:哪怕某次 DECR 丢了,等 TTL 到期,Redis 也能把计数清零。问题是,key 到期只会删除数字,不会停止仍在运行的请求。数字清零之后,旧请求还会继续执行,结束时也仍会调用 DECR。
假设全局并发上限是 1,计数器 TTL 是 30 秒:
| 时间 | 实际运行中的请求 | Redis 里的计数 |
|---|---|---|
| 0 秒 | A 启动 | A 执行 INCR,计数变成 1 |
| 30 秒 | A 还没结束 | key 到期被删,Redis 相当于认为计数是 0 |
| 31 秒 | A、B 同时运行 | B 执行 INCR,计数重新变成 1;B 被放行,但实际并发已经是 2 |
| 40 秒 | B 仍在运行 | A 结束并执行 DECR,计数从 1 变成 0;Redis 又误以为有空位 |
错误在第 30 秒就已经发生:A 明明还占着槽位,Redis 却把它忘了。第 40 秒又错一次:A 的 DECR 不知道自己要释放的是“A 的槽位”,它只会把当前数字减一,于是误减了 B 的计数。此后再来一个请求 C,也会被错误放行。
这也解释了为什么 TTL 没有一个正确取值:
- TTL 设短了,慢请求还没结束,计数就被清零,导致超卖。
- TTL 设长了,进程崩溃留下的幽灵计数要很久才能清理,系统会一直假满。
- 每次请求都刷新 TTL,持续有流量时 key 永不过期,泄漏仍然留着。
- 只在计数从 0 变成 1 时设置 TTL,任何运行时间超过 TTL 的请求都会重现上面的时间线。
所以这不是 TTL 参数没调好,而是表达方式不对:一个 key 只有一个过期时间,但每个请求都有自己的开始、结束和失联时间。 要安全回收,系统必须分别知道“哪个请求占了槽位”以及“这个请求的占位何时过期”。
下面这个实验演示两种回收语义的差别。左边是计数器:任务崩溃后槽位永久泄漏;右边是租约:同样的崩溃率,泄漏的槽位在超时后被自动出清。崩溃概率被故意调高以便在几十秒内看到累积效应,它演示的是回收关系,不是真实故障率。
Clean-room 实验 · 槽位回收
同样的崩溃,两种回收语义
全局并发上限 10。每个方块是一个槽位;任务有概率“崩溃”而不释放。
可用容量
绿色=任务持有,红色=持有者已崩溃的泄漏槽位。
累计拒绝
配额被泄漏吃掉后,新请求只能被拒绝。
选择一种回收语义,观察 30 秒内可用容量的走向。
租约:具名、带时间、会续期的占位
走到这里,需求已经自己浮出来了:每份占用必须有名字(才能单独回收、幂等释放),必须有自己的时间(才能各自过期,而不是全体清零)。Redis 里恰好有一个结构同时满足这两点——ZSET,成员唯一、每个成员挂一个分数、按分数有序。
把它用作配额时的映射关系:
- member = 任务 ID:占位是具名的。释放是
ZREM member,删一个不存在的成员是无害空操作——迟到的释放不可能误伤别人的槽位,代际问题从结构上消失。 - score = 最近一次心跳的时间戳:过期按成员各算各的。清理泄漏变成一条区间删除:
ZREMRANGEBYSCORE key -inf (now - timeout),把所有超过租期没有心跳的占位统统删掉。 - 当前并发数 = ZCARD:数一下集合大小。
「谁持有」和「何时过期」被拆进了两个正交的字段。计数器时代所有的死法,追根溯源都是这两件事被挤在一个匿名整数里。
落到实现,acquire 需要三个动作:清理尸体、检查容量、占位。三个动作之间不能被别的 Worker 插进来,否则两个 Worker 会同时看到「还剩一个名额」然后一起挤进去。Redis 执行 Lua 脚本是原子的,这三步放进同一段脚本就是一次准入判定:
-- KEYS[1] = 配额 ZSET
-- ARGV = now_ms, timeout_ms, limit, task_id
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1] - ARGV[2])
if redis.call('ZCARD', KEYS[1]) < tonumber(ARGV[3]) then
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[4])
return 1
end
return 0
顺序有讲究:先扫尸体再数数。清理是惰性的,由每一次 acquire 顺手完成,不需要任何后台定时任务——系统越忙,清理越勤,这正是你想要的方向。
ZADD 的语义还免费送了一个性质:同一个 member 重复加入不会多占槽位,只会刷新 score。于是「响应包丢了要不要重试」这个计数器时代的死结,答案变成放心重试,但必须带同一个任务 ID。幂等不是 Redis 单方面给的,它是和客户端签的契约——重试换 ID,照样双倍占位。
到这里还剩 TTL 两难里最硬的一半:长任务。Agent 任务跑十分钟很正常,租期设多长都不对——短了误杀活人,长了尸体压床。
答案是不猜任务多长,让任务自己证明自己活着:运行中的任务每隔几秒 ZADD 一次自己的 ID,把 score 刷新到当前时间。这就是租约的完整含义——占位不是一次性的所有权转移,是一份不断续期的短期合同。活着的任务永远续得上,崩溃的任务心跳停止,超过租期后被惰性清理出局。租期不再需要预估任务时长,只需要覆盖心跳间隔的两到三倍。
代价同样要写清楚:任务的执行路径里必须真的有机会跳心跳。一个把线程堵死十秒的任务,租约会被别人误判为尸体——所以这套机制和《单体里的分布式系统》第一层的阻塞治理是一体的,事件循环不健康,租约先冤死。
配额之外:取消信号与仍然无解的部分
配额之外还有一条相邻的链路:跨 Worker 取消。用户点了停止,但任务在另一个 Worker 上执行,需要把取消信号送过去。直觉方案是 Redis pub/sub,但 pub/sub 是 fire-and-forget 的——订阅者恰好在重连、消息就地蒸发,没有重投。
如果取消的唯一载体是那条消息,丢了就意味着任务永远停不下来。我们的做法是把真相放进共享状态:取消时先写一个带 TTL 的取消标记(SET cancel:<task_id> 1),再发 pub/sub。执行方收到消息立即处理;收不到也没关系——它本来就要定期跳心跳,心跳时顺路查一眼取消标记。两条路径合起来是一个经得起丢消息的系统:pub/sub 负责快,共享状态负责对。事件是加速器,状态才是真相。
租约在这里顺手补了最后一层兜底:就算取消标记也丢了、执行方彻底失联,它的心跳终会停止,槽位照样被回收。配额层不依赖任何人善后。
诚实收尾。这套设计解决的是「占位的所有权和生命周期」,有三件事它没有解决,换任何数据结构都解决不了:
Redis 本身是单点。 Redis 挂掉时只有两个选项:fail-open 全体放行,恰好在故障时把 429 风暴引向下游;fail-closed 全体拒绝,业务整体不可用。主从切换时异步复制还可能丢掉刚写入的占位,短暂超卖。我们选了 fail-closed 加短路缓存的折中,但这是在坏选项里挑一个,不是解。
热 key。 所有 Worker 的所有准入判定都打向同一个 key,限流器自己成为吞吐瓶颈和故障域焦点。要摊开只能按模型或租户分片 key,或者在 Worker 本地加一层令牌做一级缓冲——每一种都是拿精确性换扩展性。
「结束」的语义。 HTTP 返回不等于后台任务完成,客户端断开不等于下游停止,流式输出可以持续很久。槽位到底该在哪一刻释放,Redis 回答不了——这条边界最终由《单体里的分布式系统》里的 Job/Execution 所有权模型来划,谁是 Execution 的 owner,谁负责在真正的终态释放租约。
回头看,这次演进和那篇是同一个主题的两次重复:匿名的、无主的状态在分布式环境里必然腐烂。计数器坏掉不是因为 Redis 不可靠,是因为一个 +1 没有主人;租约能工作,是因为每一份占用都被迫回答三个问题——你是谁,你什么时候还活着,你死了之后谁来收尸。把这三个问题问遍系统里每一份共享状态,大部分「诡异的生产问题」在设计阶段就消失了。