单体里的分布式系统

摘要从同步 I/O 冻结 event loop,到连接、持久 Job 与执行 owner 分离,再到把全局并发配额从匿名计数器换成具名租约:Corevo 的 Agent Runtime 还没拆微服务,就先要回答分布式系统的问题。

文章目录20 节
  1. 第一层:同步 I/O 冻结共享调度器
  2. 症状为什么像“模型变慢”
  3. async def 不会改写调用栈
  4. 线程卸载还不够
  5. Prometheus 埋点:四段区间加一条 lag 曲线
  6. 多开 Worker 不是修复
  7. 第一层留下的契约
  8. 第二层:入口均衡的是连接,不是工作量
  9. 单体不等于单进程
  10. 把连接、任务和执行者拆开
  11. execution started 是恢复边界
  12. 任务换了 Worker,事件怎样回来
  13. 这条链怎样验证
  14. 第三层:全局并发配额,从计数器到租约
  15. 计数器方案会怎么死
  16. 租约:具名、带时间、会续期的占位
  17. 配额之外:取消信号与仍然无解的部分
  18. 为什么我停止重写,也没有先拆服务
  19. 调度之外还踩过什么坑
  20. 现在的边界

Corevo 的 Agent Runtime 还没有拆成微服务,就先遇到了分布式系统的问题。

它先后暴露过三个看似不同的问题:同步 I/O 进入 async def,冻结一个 Worker 的 event loop;WebSocket、排队任务和执行者绑在同一进程,让忙 Worker 继续排队而空闲 Worker 无事可做;上游模型的全局并发配额有限,而任何一个 Worker 都只看得到自己手里那一份。

三个问题来自同一个错误假设:把进程内的局部执行环境,当成了整个系统的正确性边界。而这套系统虽然仍作为同一个制品构建、发布、回滚,运行时早就是多个进程、多个事件循环、多个线程池和一批外部执行环境——发布上是单体,运行上已经是一个小型分布式系统。调度和状态所有权,不能再按单进程思考。

这篇把三段问题放回同一条演进链:第一层保证单个 Worker 不会被同步边界拖死;第二层把 Gateway、持久 Job 和 Execution owner 拆开,让空闲 Worker 接走尚未开始的任务;第三层把跨 Worker 的全局并发配额从匿名计数器换成具名租约。文中的代码和交互实验均为公开复盘重写的最小实现,不是公司源码;实验数字来自读者浏览器,也不代表生产容量。

第一层:同步 I/O 冻结共享调度器

问题最后并不复杂:同步 SDK 被直接放进了 async def。调用它的协程在等网络,该进程或 Worker 上负责这些协程的 event loop 也跟着一起等。

先不用记结论。下面的两个按钮执行相同数量、相同耗时的工作。左边把工作直接放在共享调度器里,右边把它移到独立 Worker。这里用浏览器主线程类比服务端 event loop,用 Worker 类比线程卸载;忙循环只是为了稳定制造共享线程停顿,不模拟 I/O 类型、Python GIL 或服务端吞吐——它只说明调度关系。

Clean-room 实验 · 共享调度器

同一份工作,放在哪里执行

每个圆点是一次实际回调;被浏览器跳过的理论心跳会计入右侧差额。

浏览器主线程最大定时器延迟

理想间隔为 20ms;这里只比较两种调度方式。

实际 / 理论心跳

setInterval 不会补发漏掉的回调,因此按经过时间计算理论次数。

选择一种执行方式,观察无关心跳是否被拖住。

症状为什么像“模型变慢”

Agent Runtime 天然有很多长连接和长任务:模型流式输出、工具调用、文件处理、后台自动化、频道消息、心跳。它们经常共用一个进程里的 event loop。

当某条链路卡住时,用户看到的是“Agent 没反应”;监控可能看到 WebSocket 心跳超时、首个事件变慢、后台任务堆积。最容易被怀疑的是模型服务,因为模型本来就慢、延迟也有波动。

我当时真正用来区分两者的信号不是单次请求耗时,而是无关请求是否同时停顿

  • 一个文件操作发生时,原本稳定的心跳一起迟到。
  • 模型替换成固定延迟的 mock 后,停顿仍然存在。
  • CPU 没有被持续打满,但 event-loop lag 突然抬高。
  • 采样堆栈或代码调用链最终进入同步文件、同步 HTTP 或对象存储 SDK,而不是模型推理。

在没有共享资源耗尽或重试风暴时,一次异步模型等待主要影响等待它的任务;共享 event loop 被阻塞,则会直接暂停该进程里依赖它调度的任务。

async def 不会改写调用栈

问题代码通常没有明显的 sleep,看起来甚至很“异步”:

async def persist_artifact(source, store):
    payload = source.read()          # 同步文件 I/O
    digest = sha256(payload).hexdigest()
    store.put_object(digest, payload)  # 同步网络 I/O
    return digest

async def 只说明这个函数可以挂起,并不保证里面的每次调用都会主动让出执行权。只要 read()、哈希、压缩或 SDK 的 HTTP 请求没有真正异步,event loop 就会一直执行到它们返回。

最小改法是把不可替换的同步边界整体移出共享线程:

def persist_artifact_sync(source, store):
    payload = source.read()
    digest = sha256(payload).hexdigest()
    store.put_object(digest, payload)
    return digest

async def persist_artifact(source, store):
    return await asyncio.to_thread(persist_artifact_sync, source, store)

我在生产改造里处理过的不是一个孤立调用点,而是一类边界:对象存储、同步 HTTP 客户端、媒体读写、文件同步和部分 CPU 处理。真正有效的做法,是在 adapter 层把“会阻塞”标出来,而不是在每个上层调用者里凭感觉补线程。

线程卸载还不够

把同步调用交给线程池,只解决“不要冻结 event loop”,没有解决容量。

如果每个 Agent 都能无限提交上传任务,阻塞会从 event loop 转移到线程池队列;内存、文件描述符和远端连接仍会被耗尽。公开示例里我会把适配器写成这样:

class BlockingStoreAdapter:
    def __init__(self, client, max_in_flight: int, on_background_error):
        self.client = client
        self.slots = asyncio.Semaphore(max_in_flight)
        self._running: set[asyncio.Task] = set()
        self._on_background_error = on_background_error

    def _release_slot(self, operation: asyncio.Task) -> None:
        self._running.discard(operation)
        if operation.cancelled():
            self._on_background_error(
                RuntimeError("offloaded operation Task was cancelled")
            )
            return  # fail closed:底层线程是否结束已经不可证明
        self.slots.release()
        if error := operation.exception():
            self._on_background_error(error)

    async def put(self, key: str, payload: bytes) -> None:
        await self.slots.acquire()
        try:
            operation = asyncio.create_task(asyncio.to_thread(
                self.client.put_object,
                key,
                payload,
            ))
        except BaseException:
            self.slots.release()
            raise

        self._running.add(operation)
        operation.add_done_callback(self._release_slot)
        await asyncio.shield(operation)

这段 adapter 想守住的其实是三件事。

最简单的一件,是把卸载位置钉死:上层只调用异步 adapter,不需要知道底下是同步 SDK;以后换存储客户端,改动也只发生在这一层,不会散落在每个调用点。

真正麻烦的一件,是给执行中的并发一个上限。Semaphore 数的是“正在占用线程的同步调用”,而不是“还在等待的调用方”——这两者会在取消的瞬间分岔:等在 put() 上的协程可以被取消,但已经交给线程的同步调用不会因此消失。所以槽位不能跟着调用方走,要跟着底层 operation 走:shield 保住 operation,add_done_callback 等它真正结束才释放槽位,调用方提前退出也不会腾出一个假槽位、放进第二个同步调用。同样的逻辑决定了内部 Task 不对外暴露、不得直接 cancel()——Task 一旦被取消,底层线程是否结束已经不可证明,只能 fail closed,让那个槽位永远沉没。

最后一件,是不夸大完成语义。await to_thread(...) 只证明同步 SDK 已经返回;数据是否真的持久化,服从存储 API 自己的契约,必要时还要校验或异步对账。

示例也有意留白:adapter 的关闭流程——先停新准入,再 shield 并 drain 全部 operation——没有写进来;这个 Semaphore 只约束单进程,跨 Worker 的全局并发预算是另一类问题,本文第三层单独展开;等待的协程本身也可能无限堆积,生产入口仍需要有界队列、排队超时和过载拒绝。

共享 client 还必须明确支持多线程并发。若 SDK 没有这个保证,就要使用每线程或每次调用的 client、受控连接池,或者在 adapter 内串行化访问,不能因为加了 to_thread 就默认它线程安全。

取消也不是免费的。取消等待 to_thread 的协程,并不会强行终止已经开始的同步调用。因此外部写入需要幂等键、超时和事后对账;不能假设协程收到 CancelledError,远端操作就一定没有发生。

Prometheus 埋点:四段区间加一条 lag 曲线

单看接口 P95 很难定位 Agent Runtime。落进 Prometheus 的口径,是把一次任务拆成四段区间,每段一个 Histogram:

指标口径常见问题
task_queue_seconds获得执行槽之前的区间并发准入、线程池或连接池饱和
task_dispatch_seconds进入协程到第一次真正让出的区间同步 I/O、CPU 密集处理
task_external_seconds外部调用子区间,按 target 标签区分模型/存储/数据库模型、存储、数据库的延迟与超时
task_first_event_seconds从请求开始到首个可见事件的累计结果上述区间叠加以及缓冲策略

四段拆开之后,问题会自己分类:first event 慢而 external 稳,嫌疑就回到进程内部;dispatch 出长尾,基本可以直接去找同步 I/O 或 CPU 处理。

event-loop lag 则单独埋,而不是从请求耗时反推。采法不复杂:让一个协程按固定间隔 sleep,晚醒多少,就是这段时间里共享调度器欠下的调度延迟:

LOOP_LAG = Histogram(
    "event_loop_lag_seconds",
    "共享调度器的调度延迟",
    buckets=(0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5),
)

async def sample_loop_lag(interval: float = 0.5) -> None:
    loop = asyncio.get_running_loop()
    while True:
        started = loop.time()
        await asyncio.sleep(interval)
        LOOP_LAG.observe(max(0.0, loop.time() - started - interval))

这条曲线有三个容易踩的坑:

  • 不要跨 Worker 聚合。 每个进程一个 event loop,一条曲线;多进程下必须带 Worker 标签(prometheus_client 的 multiprocess 模式),告警看 max 不看均值——十个 Worker 冻住一个,平均值几乎不动。
  • /metrics 可能和被测对象同生共死。 指标端点若由同一个 event loop 服务,loop 冻结时抓取也一起冻结。这不算缺陷,反而是免费的兜底信号:抓取超时和 up == 0 本身就该告警。
  • lag 高不等于同步 I/O。 它只证明共享调度器没有按时运行;CPU 或 GIL 占用、GC、宿主调度和容器节流都会抬高它,定位仍要与调用链、CPU 和系统指标交叉验证。

验证时也不直接上真实模型。先用固定延迟、固定响应的 mock 把模型方差拿掉,再组合短任务、长上传、取消、超时和后台任务。需要同时观察:

  • event-loop lag 的分位数和最大值;
  • 心跳、首事件与普通 API 是否互相影响;
  • 线程池排队、拒绝和远端错误率;
  • 取消之后是否出现迟到的副作用;
  • 服务恢复后是否能对账出半完成的写入。

多开 Worker 不是修复

增加进程 Worker 能缩小单个 event loop 的故障半径,也能暂时提高吞吐,但它不会让同步调用变异步。流量重新分布、长连接粘在某个 Worker 上,或者每个 Worker 都走到同一段同步代码时,问题仍会回来。

CPU 密集任务也不适合一律丢线程。Python 代码如果长期占用 GIL,应当进入进程池、独立任务服务或原生实现;线程池更适合已有同步 SDK 和会释放 GIL 的阻塞 I/O。选择的依据始终是工作负载,而不是“异步代码里不能出现线程”。

第一层留下的契约

我负责过这类阻塞链路的定位、同步边界清理和可观测性补齐;大范围调用点扫描和一部分机械迁移使用了代码 Agent 辅助,我负责定义问题、建立证据链、确定并发边界、审查改动,并设计和验收改造验证——这比把每一行都说成手工完成,更接近这次工作的真实责任。

最重要的产物不是若干个 to_thread,而是把一条工程规则变成了可检查的契约:

运行在 Agent event loop 上的路径,不得直接进入未证明非阻塞的文件、网络、数据库或子进程调用。

代码评审可以检查它,监控可以用 lag 发现它,压测可以用无关心跳验证它。这样下一次 SDK、工具或媒体链路进入系统时,不需要等到所有 Agent 一起停顿,才知道共享调度器又被占住了。

第二层:入口均衡的是连接,不是工作量

线程卸载、有界准入和 lag 监控,管住的都是单个 Worker 内部。多开 Worker 之后,问题换了一个:WebSocket 是长连接,落在哪个 Worker,就一直粘在哪个 Worker。

用户的连接在 Worker A,一条要跑几分钟的 Agent 任务就在 A 上执行;这条连接上再进来一个短任务,也只能跟着在 A 排队——哪怕 B、C 都闲着。负载均衡没有失效,它均衡的本来就是连接数,看不见一条连接背后挂着多少工作量。

所以多开 Worker 只回答了“连接摊在哪”,没有回答“任务该由谁执行”。发布还是一个单体,正确性已经是分布式的;接下来要拆开的,就是连接入口、持久任务和真实执行者这三件事。

下面先用一个确定性的状态机模拟需要排队的 Web Chat 任务。场景固定为:WebSocket 在 W1,W1 正在执行长任务,W2 与 W3 空闲;随后从原连接进入一个短任务。三条 Worker 只是为了让调度关系可见,不代表 Corevo 的生产拓扑或容量。

Clean-room 实验 · 任务归属

连接在哪,不等于任务必须在哪执行

这是离散 tick 的状态机模拟,只比较所有权关系,不模拟 Redis、数据库、模型或网络延迟。

连接入口 Gateway · W1 WebSocket 始终留在这里
持久任务 Job · short 等待运行实验
结果去向 Gateway · W1 尚无事件
可重建候选索引 索引负责唤醒,数据库条件更新决定 owner
W1gateway

长任务占用

W2worker

空闲

W3worker

空闲

到开始执行

从进入系统到真正进入 running。

当前 owner

epoch 随重新 claim 单调增加。

状态结果

fencing 模式只验证迟到的 started。

选择一种模式,观察短任务的执行归属。

单体不等于单进程

同一应用制品可以同时运行 API、后台任务等不同角色;每个角色又可能有多个进程。此时内存、锁、任务句柄和 event loop 都是局部状态,“单体”只剩下一起发布和回滚的含义。

Corevo 没有先把它们拆成网络服务,而是先在代码里分出平台控制面、中立契约和 Agent Runtime。当前自动检查明确禁止新拆出的控制面反向导入内核,也禁止内核跨过中立契约依赖控制面;历史 Runtime 代码里尚未清零的反向依赖,则作为显式债务“只减不增”。这比画一张分层图更重要:新依赖越界会直接让测试失败。

边界检查的形态值得多说一句。它是随每次提交运行的依赖测试:扫描模块之间的 import 关系,只放行穿过中立契约层的引用;历史反向依赖记录在一份显式清单里,清单只允许变短——删一条是还债,加一条直接让构建变红。分层图人人会画,难的是三个月后它还作不作数;变成测试之后,这件事不再依赖任何人的记忆和自觉。

把连接、任务和执行者拆开

最危险的耦合,不是三者在类型上真是同一个对象,而是它们被同一条进程本地 WebSocket 链路绑定:入口 handler 接收输入,本地状态记录排队,进程内句柄决定执行 owner。这里实际混在一起的是三种身份:

  1. Gateway:谁持有客户端连接,负责接收输入和推送流式事件。
  2. Job:用户提交了什么、是否准入、为什么正在排队。
  3. Execution owner:此刻由哪个 Worker 执行这一次尝试。

只要三种身份仍被这条本地链路绑定,连接落到哪里,任务就只能在哪里排队;用户重连到另一个进程后,本地也可能找不到原来的运行句柄。

Job 和 Execution 也不是同一个事实。Job 记录准入、排队、owner 和控制状态;Execution 描述一次真实执行尝试及其父子关系。前者解释“为什么还没开始”,后者让 trace、成本和副作用归到真正发生的那次执行。

并不是所有请求都绕一圈全局队列。入口 Worker 有容量时仍走本地快路径——绝大多数请求到达时本地就放得下,让它们全部绕数据库和候选索引一圈,等于为少数排队请求向所有请求征税。这个分叉也让改造可以增量上线:先只让需要排队的 Web Chat 走新链路,其他入口原地不动。排队路径持久化启动输入,写入 ownerless Job,再进入可重建的候选索引:

Gateway 创建 Job
  ├─ 当前 Worker 有容量 → local direct
  └─ 需要排队 → 持久化 submission
                 → ready index 暴露候选
                 → 空闲 Worker 尝试 claim
                 → 数据库决定唯一 owner

这里有一个刻意的“不聪明”:Redis 只保存待办目录,不负责宣布“谁抢到了”。Worker 看到候选后,用数据库条件更新争夺 owner。下面的字段名已经泛化,但原子条件保留完整:

UPDATE jobs
SET owner = :worker,
    claimed_at = :now,
    epoch = epoch + 1,
    token = :token
WHERE id = :job
  AND state = 'queued'
  AND owner IS NULL
  AND claimed_at IS NULL
  AND execution_id IS NULL
  AND execution_started_at IS NULL;

只有更新命中一行才算 claim 成功。两个 Worker 同时看到同一个候选没有关系,数据库只允许一个成为 owner;随后候选才从 Redis 移除。索引丢失可以从数据库重建,反过来却不成立——一次 Redis pop 不能成为任务所有权的事实。

顺着这个角色分配往下推,把 Redis 整个拿掉也不影响正确性:候选索引只是唤醒的加速器,丢了从数据库重建,坏了退化成轮询,只是慢。这和第三层里取消信号的设计是同一句话——事件和索引负责快,持久状态负责对。

execution_started 是恢复边界

一个 Worker 在真正开始执行前短暂失联,任务被释放并由另一个 Worker 重新领取;旧 Worker 随后恢复,仍可能提交迟到的 running。因此每次 claim 都生成新的 epoch + tokenqueued → running 必须同时匹配 owner、epoch 和 token:

UPDATE jobs
SET state = 'running',
    execution_id = :execution,
    execution_started_at = :now
WHERE id = :job
  AND state = 'queued'
  AND owner = :worker
  AND claimed_at IS NOT NULL
  AND epoch = :epoch
  AND token = :token
  AND execution_id IS NULL
  AND execution_started_at IS NULL;

epoch 提供顺序,token 绑定这一次租约。旧 Worker 即使补交 started 写入,也不能绑定过期 Execution。终态收敛是另一条独立的不变量:完成链还必须携带并校验自己预期的 execution 身份,不能从“started CAS 被拒绝”推导出“旧调用方的后续清理一定无害”。因此上面的实验停在迟到 started 被拒绝,不宣称 W3 后续一定完成。

更重要的是:只有 execution_started 之前的 claim 才会释放并重新排队。 一旦 Agent 已经开始调用外部工具,自动重跑可能重复发消息、写数据或扣款。运行中的 owner 失联,只能把平台事实收敛为 losttimeout,不能假装把外部副作用无状态迁移走。

这些条件更新保护的是 claim 与 started ownership,不是整条完成链,更不是外部世界的 exactly-once。

任务换了 Worker,事件怎样回来

queued submission 必须先持久化,因为后来 claim 它的 W2 没有 W1 那条 WebSocket 的 Python 对象。W2 从持久输入重建运行命令,启动无前端连接的执行器;模型 delta、工具事件和终态再经过跨进程事件通道,投影回仍持有连接的 W1。Gateway 和 execution owner 至此真正分开。

通道上的每条事件都要带完整身份和递增序号,最小形态是:

event = { job_id, execution_id, epoch, seq, type, payload }

投影端按 (execution_id, seq) 消费:重复丢弃,乱序缓冲,epoch 换代则整体作废。跨进程通道本身只承诺尽力投递,所以事件还必须落一份持久历史——通道负责低延迟,历史负责补课,两边合起来才构成交付语义。少了后者,一次订阅重连就足够让前端永远缺一段输出。

取消和运行中追加输入走反方向:Gateway 先读取当前 owner,再把命令路由过去;owner 消费前还要复核 Job、Execution 和租约,避免旧命令落到新一代执行上。运行句柄继续留在本地进程,不需要为了横向扩展把整个 Agent 对象序列化。

这套拆分顺手把重连从灾难降级成普通操作。用户刷新页面,新的 WebSocket 可能落在 W3——没关系,连接本来就不是任务的容身之处。新 Gateway 拿 job_id 找到持久状态,先从事件历史回放已经发生的部分,再从通道接上后续增量;用户看到的只是流式输出顿了一下,不是“会话丢了”。做到这一点的前提,恰恰是三种身份已经拆开:如果运行句柄还绑在 W1 的内存里,重连到 W3 的用户面对的就是一个找不到任何上下文的进程。

但“消息已发出”不等于“操作已完成”。尽力投递的控制消息只能表示 requested,最终还要区分 owner accepted、already finished 和 unknown。平台把 Job 标成 lost,也不能自动证明远端进程和外部副作用已经停止。

同理,Agent 循环进入 completed 只说明平台执行结束。发消息、写第三方系统等动作仍需要业务回执、幂等键或 outbox;模型说“已完成”不能充当 commit log。

这条链怎样验证

浏览器实验只解释状态关系。团队对真实实现的验收还需要并发和故障用例:

故障用例必须成立的不变量
两个 Worker 同时 claim只有一个数据库更新命中,只有一个 owner
Gateway 与 owner 不同流式事件和终态仍回到原连接
pre-start owner 失联原排队时间不重置,epoch 增加后可重新领取
running owner 失联收敛为 lost,不自动重新执行
控制命令发到旧 owner租约复核失败,不能触碰当前 Execution

测法本身也有讲究。并发 claim 不能用 mock 验证——要两个真实进程(至少是两个独立事件循环加真实数据库连接)同时发出条件更新,断言恰好一行命中,而且要循环跑到真实交错出现为止,跑一次碰巧没撞上不算数。失联用 SIGKILL 制造,不用优雅退出:优雅退出会走完清理逻辑,生产里的进程消失不会给你这个面子。迟到写入则把旧 Worker 冻结在 started 之前,等新 epoch 被领走后再放行,断言旧 token 的 CAS 零命中。

这些用例定义了多 Worker 链路应守住的不变量,不等于端到端正确性已经自动成立。尤其是旧代 started 被拒绝后的失败清理,也必须继续携带旧代身份,不能误收口当前 Execution;这正是独立代码复审仍然抓得到的边界。受控环境的结果不能换算成生产并发数字,“一个平台终态”也不能写成外部副作用 exactly-once。

第三层:全局并发配额,从计数器到租约

所有权拆开之后,还剩一类问题没有着落。上游模型服务的并发配额是全局有限的,超过就直接 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 没有主人;租约能工作,是因为每一份占用都被迫回答三个问题——你是谁,你什么时候还活着,你死了之后谁来收尸。把这三个问题问遍系统里每一份共享状态,大部分“诡异的生产问题”在设计阶段就消失了。

为什么我停止重写,也没有先拆服务

Corevo 早期耦合很重时,我曾从零写过一套更干净的 Agent 内核。它有独立循环、工具层、CLI 和测试,却没有承载真实产品主链。测试能证明新循环自己可以运行,不能证明它保留了旧系统积累的上下文、工具、错误和流式语义;继续维护只会得到两个内核、两套事实。

我最终停止重写,删除未接入主链的新循环和工具骨架,改为迁移当时经过真实链路验证的七层代码,并用薄 CLI 适配和依赖测试守住新增边界。后续依赖清理由团队继续完成。真实调用方、行为对照和可回滚迁移,比一套崭新的目录更重要。

现在再判断一次重写值不值得继续,我只问一个问题:它有没有承载真实调用方。没有真实调用方的代码,测试再绿也只是另一份等着腐烂的假设。

这也形成了我的判断:此时不应先拆微服务。当状态 owner 和失败协议还在变化时,加入 RPC、独立发布和网络部分失败,只会把模糊边界搬到进程外。等某个模块确实需要独立扩缩容、故障隔离或发布节奏,而且协议已经稳定,再拆部署单元才是运维选择,不是业务重写。

调度之外还踩过什么坑

  • 进程内锁不是全局配额。 我在另一条空间并发链路里也处理过“先计数、再占位”的竞态,机制上就是第三层那套原子清理、心跳与过期回收;它和第二层的 Platform Job claim 是两套机制,不能混为一谈。
  • 多进程不会修复 event loop 阻塞。 同步上传、文件扫描或数据库调用进入 async def,仍会冻结所在 Worker 的心跳和流式输出;本文前半部分已经给出证据链和卸载边界。
  • 本地缓存一扩副本就是分布式状态。 一个进程清掉工具缓存,不代表其他进程已经失效。Pub/Sub 适合提醒,持久事实、版本或 TTL 才负责自愈。
  • 数据库事务不能跨长时间远程操作。 启动沙箱、上传对象和等待模型前先提交短事务,之后再用条件更新收敛结果;否则长任务会同时占住连接池和外部副作用。

这些坑的共同点是:多开进程能暴露错误假设,却不会自动替你建立全局不变量。

现在的边界

当前代码已经具备模块化控制面、queued Web Chat 的全局 claim、pre-start release/reclaim、started CAS 和跨 Worker 事件回推;这套 Job/Execution 控制面由团队共同演进。旧代失败清理还没有端到端带齐 fencing 身份,因此我不把它表述成“完整的 pre-start 恢复”。本文中我能直接归到自己名下的,是最初的依赖边界检查、一次错误内核重写的停止与迁移转向、旧空间配额的原子准入,以及子 Agent 的 best-effort 跨 Worker 取消语义。

我不把运行中迁移、持久化控制命令、任意复制后台角色或外部副作用 exactly-once 算作已经完成的能力。这些不是谦虚措辞,而是系统对故障负责的边界。

单体里的这场分布式改造,最终教会我的不是“单体比微服务好”。更准确的结论是:发布单元可以晚拆,状态所有权必须早拆;网络边界可以暂缓,正确性边界不能含糊。