从内存字典到租约内核:分布式上游账号池如何做到不超卖
AI 生图平台通常不会只有一个上游账号。为了控制限流、封号和额度风险,平台会把多个账号放进池里,再由多个 Worker 并发调度任务。
单进程时代,一个内存字典和一个计数器就能回答"这个账号还有没有空位"。一旦扩展到多进程、多机器,计数器就变成共享状态:两个 Worker 可能同时看到同一个空位,然后同时把任务派给同一个账号。问题不再是"怎么选账号",而是"谁有权占用这个账号,以及旧 Worker 还能不能继续写"。
MUSE 的做法是把号池改造成 PostgreSQL 上的租约内核:数据库是唯一裁判,租约是占用凭据,fence 是旧 Worker 的写权限边界。
整体架构
调度系统可以拆成四条链路:请求链路负责提交任务,调度链路负责领取资源,Provider 链路负责外部调用,恢复链路负责心跳、回收和对账。
#mermaid-svg-vjlVi97Cy2OnXvDg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vjlVi97Cy2OnXvDg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vjlVi97Cy2OnXvDg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vjlVi97Cy2OnXvDg .error-icon{fill:#552222;}#mermaid-svg-vjlVi97Cy2OnXvDg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vjlVi97Cy2OnXvDg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vjlVi97Cy2OnXvDg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vjlVi97Cy2OnXvDg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vjlVi97Cy2OnXvDg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vjlVi97Cy2OnXvDg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vjlVi97Cy2OnXvDg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vjlVi97Cy2OnXvDg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vjlVi97Cy2OnXvDg .marker.cross{stroke:#333333;}#mermaid-svg-vjlVi97Cy2OnXvDg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vjlVi97Cy2OnXvDg p{margin:0;}#mermaid-svg-vjlVi97Cy2OnXvDg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-vjlVi97Cy2OnXvDg .cluster-label text{fill:#333;}#mermaid-svg-vjlVi97Cy2OnXvDg .cluster-label span{color:#333;}#mermaid-svg-vjlVi97Cy2OnXvDg .cluster-label span p{background-color:transparent;}#mermaid-svg-vjlVi97Cy2OnXvDg .label text,#mermaid-svg-vjlVi97Cy2OnXvDg span{fill:#333;color:#333;}#mermaid-svg-vjlVi97Cy2OnXvDg .node rect,#mermaid-svg-vjlVi97Cy2OnXvDg .node circle,#mermaid-svg-vjlVi97Cy2OnXvDg .node ellipse,#mermaid-svg-vjlVi97Cy2OnXvDg .node polygon,#mermaid-svg-vjlVi97Cy2OnXvDg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vjlVi97Cy2OnXvDg .rough-node .label text,#mermaid-svg-vjlVi97Cy2OnXvDg .node .label text,#mermaid-svg-vjlVi97Cy2OnXvDg .image-shape .label,#mermaid-svg-vjlVi97Cy2OnXvDg .icon-shape .label{text-anchor:middle;}#mermaid-svg-vjlVi97Cy2OnXvDg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vjlVi97Cy2OnXvDg .rough-node .label,#mermaid-svg-vjlVi97Cy2OnXvDg .node .label,#mermaid-svg-vjlVi97Cy2OnXvDg .image-shape .label,#mermaid-svg-vjlVi97Cy2OnXvDg .icon-shape .label{text-align:center;}#mermaid-svg-vjlVi97Cy2OnXvDg .node.clickable{cursor:pointer;}#mermaid-svg-vjlVi97Cy2OnXvDg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vjlVi97Cy2OnXvDg .arrowheadPath{fill:#333333;}#mermaid-svg-vjlVi97Cy2OnXvDg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vjlVi97Cy2OnXvDg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vjlVi97Cy2OnXvDg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vjlVi97Cy2OnXvDg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vjlVi97Cy2OnXvDg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vjlVi97Cy2OnXvDg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vjlVi97Cy2OnXvDg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vjlVi97Cy2OnXvDg .cluster text{fill:#333;}#mermaid-svg-vjlVi97Cy2OnXvDg .cluster span{color:#333;}#mermaid-svg-vjlVi97Cy2OnXvDg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vjlVi97Cy2OnXvDg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vjlVi97Cy2OnXvDg rect.text{fill:none;stroke-width:0;}#mermaid-svg-vjlVi97Cy2OnXvDg .icon-shape,#mermaid-svg-vjlVi97Cy2OnXvDg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vjlVi97Cy2OnXvDg .icon-shape p,#mermaid-svg-vjlVi97Cy2OnXvDg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vjlVi97Cy2OnXvDg .icon-shape .label rect,#mermaid-svg-vjlVi97Cy2OnXvDg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vjlVi97Cy2OnXvDg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vjlVi97Cy2OnXvDg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vjlVi97Cy2OnXvDg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 任务 API
任务队列
分布式调度器
PostgreSQL
permit 表
account slot 表
lease / fence 表
KMS / credential loader
Worker
上游 Provider
finish
heartbeat
lease reclaimer
槽位对账器
events / outbox
缓存失效与下游通知
数据库记录的是"谁占用了什么资源以及是否仍有资格写入",Provider 只看到一次外部请求,并不参与内部租约裁决。
三层资源模型
调度器需要同时限制全站并发和单账号并发,因此把资源拆成三层:
| 资源 | 作用 | 释放方式 |
|---|---|---|
permit |
限制整个环境的总并发 | finish 或过期回收 |
| account slot | 限制单个账号的并发上限 | finish 或过期回收 |
lease |
记录这次占用属于哪个任务和 Worker | 转换为终态,保留历史 |
此外,每次租约都有 epoch,任务和 Worker 还带有 fence。它们不负责计数,而是负责判断"这次写入是否仍然拥有资格"。
三层资源的关系可以用下面的结构图表示:
#mermaid-svg-BioNvLOauIqjm0sb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-BioNvLOauIqjm0sb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-BioNvLOauIqjm0sb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-BioNvLOauIqjm0sb .error-icon{fill:#552222;}#mermaid-svg-BioNvLOauIqjm0sb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-BioNvLOauIqjm0sb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-BioNvLOauIqjm0sb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-BioNvLOauIqjm0sb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-BioNvLOauIqjm0sb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-BioNvLOauIqjm0sb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-BioNvLOauIqjm0sb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-BioNvLOauIqjm0sb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-BioNvLOauIqjm0sb .marker.cross{stroke:#333333;}#mermaid-svg-BioNvLOauIqjm0sb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-BioNvLOauIqjm0sb p{margin:0;}#mermaid-svg-BioNvLOauIqjm0sb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-BioNvLOauIqjm0sb .cluster-label text{fill:#333;}#mermaid-svg-BioNvLOauIqjm0sb .cluster-label span{color:#333;}#mermaid-svg-BioNvLOauIqjm0sb .cluster-label span p{background-color:transparent;}#mermaid-svg-BioNvLOauIqjm0sb .label text,#mermaid-svg-BioNvLOauIqjm0sb span{fill:#333;color:#333;}#mermaid-svg-BioNvLOauIqjm0sb .node rect,#mermaid-svg-BioNvLOauIqjm0sb .node circle,#mermaid-svg-BioNvLOauIqjm0sb .node ellipse,#mermaid-svg-BioNvLOauIqjm0sb .node polygon,#mermaid-svg-BioNvLOauIqjm0sb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-BioNvLOauIqjm0sb .rough-node .label text,#mermaid-svg-BioNvLOauIqjm0sb .node .label text,#mermaid-svg-BioNvLOauIqjm0sb .image-shape .label,#mermaid-svg-BioNvLOauIqjm0sb .icon-shape .label{text-anchor:middle;}#mermaid-svg-BioNvLOauIqjm0sb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-BioNvLOauIqjm0sb .rough-node .label,#mermaid-svg-BioNvLOauIqjm0sb .node .label,#mermaid-svg-BioNvLOauIqjm0sb .image-shape .label,#mermaid-svg-BioNvLOauIqjm0sb .icon-shape .label{text-align:center;}#mermaid-svg-BioNvLOauIqjm0sb .node.clickable{cursor:pointer;}#mermaid-svg-BioNvLOauIqjm0sb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-BioNvLOauIqjm0sb .arrowheadPath{fill:#333333;}#mermaid-svg-BioNvLOauIqjm0sb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-BioNvLOauIqjm0sb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-BioNvLOauIqjm0sb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BioNvLOauIqjm0sb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-BioNvLOauIqjm0sb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BioNvLOauIqjm0sb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-BioNvLOauIqjm0sb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-BioNvLOauIqjm0sb .cluster text{fill:#333;}#mermaid-svg-BioNvLOauIqjm0sb .cluster span{color:#333;}#mermaid-svg-BioNvLOauIqjm0sb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-BioNvLOauIqjm0sb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-BioNvLOauIqjm0sb rect.text{fill:none;stroke-width:0;}#mermaid-svg-BioNvLOauIqjm0sb .icon-shape,#mermaid-svg-BioNvLOauIqjm0sb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-BioNvLOauIqjm0sb .icon-shape p,#mermaid-svg-BioNvLOauIqjm0sb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-BioNvLOauIqjm0sb .icon-shape .label rect,#mermaid-svg-BioNvLOauIqjm0sb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-BioNvLOauIqjm0sb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-BioNvLOauIqjm0sb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-BioNvLOauIqjm0sb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} environment
permit
全站并发
lease
一次占用凭据
account
上游账号
slot
单账号并发
fencing epoch
旧 Worker 写权限
一次请求的生命周期可以画成这样:
Provider PostgreSQL Worker Provider PostgreSQL Worker #mermaid-svg-YvaLe919mfVSA31X{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-YvaLe919mfVSA31X .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-YvaLe919mfVSA31X .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-YvaLe919mfVSA31X .error-icon{fill:#552222;}#mermaid-svg-YvaLe919mfVSA31X .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-YvaLe919mfVSA31X .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-YvaLe919mfVSA31X .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-YvaLe919mfVSA31X .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-YvaLe919mfVSA31X .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-YvaLe919mfVSA31X .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-YvaLe919mfVSA31X .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-YvaLe919mfVSA31X .marker{fill:#333333;stroke:#333333;}#mermaid-svg-YvaLe919mfVSA31X .marker.cross{stroke:#333333;}#mermaid-svg-YvaLe919mfVSA31X svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-YvaLe919mfVSA31X p{margin:0;}#mermaid-svg-YvaLe919mfVSA31X .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-YvaLe919mfVSA31X text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-YvaLe919mfVSA31X .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-YvaLe919mfVSA31X .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-YvaLe919mfVSA31X .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-YvaLe919mfVSA31X .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-YvaLe919mfVSA31X #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-YvaLe919mfVSA31X .sequenceNumber{fill:white;}#mermaid-svg-YvaLe919mfVSA31X #sequencenumber{fill:#333;}#mermaid-svg-YvaLe919mfVSA31X #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-YvaLe919mfVSA31X .messageText{fill:#333;stroke:none;}#mermaid-svg-YvaLe919mfVSA31X .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-YvaLe919mfVSA31X .labelText,#mermaid-svg-YvaLe919mfVSA31X .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-YvaLe919mfVSA31X .loopText,#mermaid-svg-YvaLe919mfVSA31X .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-YvaLe919mfVSA31X .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-YvaLe919mfVSA31X .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-YvaLe919mfVSA31X .noteText,#mermaid-svg-YvaLe919mfVSA31X .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-YvaLe919mfVSA31X .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-YvaLe919mfVSA31X .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-YvaLe919mfVSA31X .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-YvaLe919mfVSA31X .actorPopupMenu{position:absolute;}#mermaid-svg-YvaLe919mfVSA31X .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-YvaLe919mfVSA31X .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-YvaLe919mfVSA31X .actor-man circle,#mermaid-svg-YvaLe919mfVSA31X line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-YvaLe919mfVSA31X :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 查询同一 task fence 的既有租约SKIP LOCKED 领取 permit选择并锁定账号条件增加 active_slots创建 lease 并绑定 epoch提交事务,返回凭据在事务外调用 Providerheartbeat 续租success / rate limit / auth / transient / ambiguousfinish,校验 fence 后释放资源
领取资源和解包凭据在当前实现中属于同一个 acquire 事务;Provider HTTP 调用必须在事务外,否则几十秒的网络等待会长期占用数据库行锁。
acquire:固定顺序、跳过锁、只锁赢家
并发领取的核心不是应用层互斥锁,而是数据库事务中的几个原子动作:
python
with transaction():
if active_lease_exists(task_fence):
return existing_lease
permit = lock_idle_permit(skip_locked=True)
account = choose_candidate(skip_locked=True)
won = update_account_if_capacity_remains(account)
if won.rowcount != 1:
raise PoolBusy
lease = insert_lease(permit, account, task_fence, next_epoch)
mark_permit_leased(permit, lease)
lease.credential = load_credential(account.id)
return lease
锁顺序固定为 permit → account → lease,避免两个事务以相反顺序加锁形成死锁。SKIP LOCKED 的作用是让 Worker 绕开暂时被占用的候选行,不在一个热点账号后排队;它减少等待,但不负责保证不超卖。
真正的并发裁决由条件更新完成:
sql
UPDATE pool_accounts
SET active_slots = active_slots + 1
WHERE id = :account_id
AND active_slots < slot_limit;
只有 rowcount = 1 才算成功占用。多个 Worker 同时争抢最后一个槽位时,数据库会串行检查最新提交值,只有一个请求能把条件从真推进到新状态,输家得到 PoolBusy,不会留下半条租约。
把 acquire 展开成流程图,就是"先找幂等结果,再按固定顺序领取资源":
渲染错误: Mermaid 渲染失败: Parse error on line 2: ...art TD Aacquire(task_fence) --> B{ ----------------------^ Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'PS'
这也是为什么不能把 Redis 作为正确性来源。Redis 的 TTL 到期只能让一个键消失,无法同时回滚数据库里的槽位、任务和审计记录。Redis 可以做限流或缓存,号池的事实必须留在 PostgreSQL 的租约和事务里。
fencing:让迟到的 Worker 失去写权限
只做 TTL 还不够。考虑一个典型故障:Worker A 拿到租约后调用 Provider,网络卡住;租约过期,Worker B 回收资源并拿到同一个账号;此时 A 又恢复了,它不能把 B 的租约释放掉,也不能把账号状态改成 A 看到的旧状态。
解决方案是双层 fence:租约的 epoch 每次重新领取时递增,任务自身保留一个稳定的 task_fence。所有 heartbeat 和 finish 都带上 Worker 身份、当前 epoch 和未过期条件:
sql
UPDATE pool_leases
SET expires_at = :new_expiry
WHERE id = :lease_id
AND worker_id = :worker_id
AND fencing_epoch = :epoch
AND expires_at > now();
返回 0 行表示写权限已经失效,Worker 必须停止工作。过期是不可逆的,不能被迟到的心跳重新覆盖;新租约拥有更大的 epoch,旧 Worker 的回写自然变成无效写入。
fence 保护的是平台自己的数据库状态。Provider 如果不理解这个 token,系统不能虚假承诺外部调用的物理 exactly-once。因此,租约 TTL 要保守地覆盖最长上游请求和网络缓冲,HTTP 超时要短于 TTL,未知结果则进入 park 和对账,而不是自动重新提交。
旧 Worker 与新 Worker 的接管过程如下:
Worker B(epoch=8) PostgreSQL Worker A(epoch=7) Worker B(epoch=8) PostgreSQL Worker A(epoch=7) #mermaid-svg-pYU2A6xAxlBpzZxL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-pYU2A6xAxlBpzZxL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-pYU2A6xAxlBpzZxL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-pYU2A6xAxlBpzZxL .error-icon{fill:#552222;}#mermaid-svg-pYU2A6xAxlBpzZxL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-pYU2A6xAxlBpzZxL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-pYU2A6xAxlBpzZxL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-pYU2A6xAxlBpzZxL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-pYU2A6xAxlBpzZxL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-pYU2A6xAxlBpzZxL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-pYU2A6xAxlBpzZxL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-pYU2A6xAxlBpzZxL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-pYU2A6xAxlBpzZxL .marker.cross{stroke:#333333;}#mermaid-svg-pYU2A6xAxlBpzZxL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-pYU2A6xAxlBpzZxL p{margin:0;}#mermaid-svg-pYU2A6xAxlBpzZxL .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-pYU2A6xAxlBpzZxL text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-pYU2A6xAxlBpzZxL .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-pYU2A6xAxlBpzZxL .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-pYU2A6xAxlBpzZxL .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-pYU2A6xAxlBpzZxL .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-pYU2A6xAxlBpzZxL #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-pYU2A6xAxlBpzZxL .sequenceNumber{fill:white;}#mermaid-svg-pYU2A6xAxlBpzZxL #sequencenumber{fill:#333;}#mermaid-svg-pYU2A6xAxlBpzZxL #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-pYU2A6xAxlBpzZxL .messageText{fill:#333;stroke:none;}#mermaid-svg-pYU2A6xAxlBpzZxL .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-pYU2A6xAxlBpzZxL .labelText,#mermaid-svg-pYU2A6xAxlBpzZxL .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-pYU2A6xAxlBpzZxL .loopText,#mermaid-svg-pYU2A6xAxlBpzZxL .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-pYU2A6xAxlBpzZxL .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-pYU2A6xAxlBpzZxL .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-pYU2A6xAxlBpzZxL .noteText,#mermaid-svg-pYU2A6xAxlBpzZxL .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-pYU2A6xAxlBpzZxL .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-pYU2A6xAxlBpzZxL .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-pYU2A6xAxlBpzZxL .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-pYU2A6xAxlBpzZxL .actorPopupMenu{position:absolute;}#mermaid-svg-pYU2A6xAxlBpzZxL .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-pYU2A6xAxlBpzZxL .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-pYU2A6xAxlBpzZxL .actor-man circle,#mermaid-svg-pYU2A6xAxlBpzZxL line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-pYU2A6xAxlBpzZxL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 租约过期,回收器释放资源 heartbeat(epoch=7)成功acquire(task_fence)新租约 epoch=8finish(epoch=7)rowcount=0,LeaseExpiredfinish(epoch=8)成功收尾
finish:业务结果和账号健康分开处理
上游返回"成功""认证失败""限流""临时错误""业务内容错误""未知超时"等结果时,不能把它们都压成一个 error。
- 认证失败、额度耗尽等是账号本身的问题,需要隔离或冷却账号;
- 内容错误通常是本次业务请求的问题,不应触发账号熔断;
- 限流需要按
Retry-After或固定冷却窗口处理; - 未知超时不能猜成失败,因为 Provider 可能已经成功,应该 park 等待查单或人工裁决。
finish 必须是一次性原子收尾:校验 fence,更新租约终态,释放账号 slot 和 permit,再根据结果更新账号健康状态。所有路径都走同一个收尾函数,避免"正常完成释放了,回收路径忘了释放"的分叉。
结果分类决定后续动作,而不是简单地把所有异常都重试:
#mermaid-svg-sRGZtbtV7MVDq1wf{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-sRGZtbtV7MVDq1wf .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sRGZtbtV7MVDq1wf .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sRGZtbtV7MVDq1wf .error-icon{fill:#552222;}#mermaid-svg-sRGZtbtV7MVDq1wf .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-sRGZtbtV7MVDq1wf .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sRGZtbtV7MVDq1wf .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sRGZtbtV7MVDq1wf .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sRGZtbtV7MVDq1wf .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sRGZtbtV7MVDq1wf .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sRGZtbtV7MVDq1wf .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sRGZtbtV7MVDq1wf .marker{fill:#333333;stroke:#333333;}#mermaid-svg-sRGZtbtV7MVDq1wf .marker.cross{stroke:#333333;}#mermaid-svg-sRGZtbtV7MVDq1wf svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-sRGZtbtV7MVDq1wf p{margin:0;}#mermaid-svg-sRGZtbtV7MVDq1wf .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-sRGZtbtV7MVDq1wf .cluster-label text{fill:#333;}#mermaid-svg-sRGZtbtV7MVDq1wf .cluster-label span{color:#333;}#mermaid-svg-sRGZtbtV7MVDq1wf .cluster-label span p{background-color:transparent;}#mermaid-svg-sRGZtbtV7MVDq1wf .label text,#mermaid-svg-sRGZtbtV7MVDq1wf span{fill:#333;color:#333;}#mermaid-svg-sRGZtbtV7MVDq1wf .node rect,#mermaid-svg-sRGZtbtV7MVDq1wf .node circle,#mermaid-svg-sRGZtbtV7MVDq1wf .node ellipse,#mermaid-svg-sRGZtbtV7MVDq1wf .node polygon,#mermaid-svg-sRGZtbtV7MVDq1wf .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-sRGZtbtV7MVDq1wf .rough-node .label text,#mermaid-svg-sRGZtbtV7MVDq1wf .node .label text,#mermaid-svg-sRGZtbtV7MVDq1wf .image-shape .label,#mermaid-svg-sRGZtbtV7MVDq1wf .icon-shape .label{text-anchor:middle;}#mermaid-svg-sRGZtbtV7MVDq1wf .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-sRGZtbtV7MVDq1wf .rough-node .label,#mermaid-svg-sRGZtbtV7MVDq1wf .node .label,#mermaid-svg-sRGZtbtV7MVDq1wf .image-shape .label,#mermaid-svg-sRGZtbtV7MVDq1wf .icon-shape .label{text-align:center;}#mermaid-svg-sRGZtbtV7MVDq1wf .node.clickable{cursor:pointer;}#mermaid-svg-sRGZtbtV7MVDq1wf .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-sRGZtbtV7MVDq1wf .arrowheadPath{fill:#333333;}#mermaid-svg-sRGZtbtV7MVDq1wf .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-sRGZtbtV7MVDq1wf .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-sRGZtbtV7MVDq1wf .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sRGZtbtV7MVDq1wf .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-sRGZtbtV7MVDq1wf .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sRGZtbtV7MVDq1wf .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-sRGZtbtV7MVDq1wf .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-sRGZtbtV7MVDq1wf .cluster text{fill:#333;}#mermaid-svg-sRGZtbtV7MVDq1wf .cluster span{color:#333;}#mermaid-svg-sRGZtbtV7MVDq1wf div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-sRGZtbtV7MVDq1wf .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-sRGZtbtV7MVDq1wf rect.text{fill:none;stroke-width:0;}#mermaid-svg-sRGZtbtV7MVDq1wf .icon-shape,#mermaid-svg-sRGZtbtV7MVDq1wf .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sRGZtbtV7MVDq1wf .icon-shape p,#mermaid-svg-sRGZtbtV7MVDq1wf .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-sRGZtbtV7MVDq1wf .icon-shape .label rect,#mermaid-svg-sRGZtbtV7MVDq1wf .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sRGZtbtV7MVDq1wf .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-sRGZtbtV7MVDq1wf .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-sRGZtbtV7MVDq1wf :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} success
业务错误
认证/额度问题
限流
临时错误
未知超时
Provider 返回
结果分类
finished
释放资源
terminal
账号保持 ready
账号隔离或冷却
按冷却窗口 retry
有界 retry / requeue
park
查单或人工裁决
对崩溃 Worker,独立的 reclaimer 会扫描过期租约,把它们转成 expired,释放 slot 和 permit。回收器是兜底机制,不是正常完成路径;正常 Worker 应该尽快调用 finish,让资源回到池中。
公平调度与凭据安全
只按 active_slots 排序会让某一类账号长期成为热点。更可解释的排序方式是先比较归一化负载 active_slots / slot_limit,再按 last_leased_at 选择较久未使用的账号,最后用稳定 ID 打破并列。它提供的是可解释的近似公平,冷却、额度和 Provider 优先级造成的偏差必须能被解释。
凭据只在 acquire 返回的内存对象和 Provider 调用链中出现,不能写入任务、事件、日志或管理接口。托管 KMS 缺配置时应该 fail closed,让服务启动失败,而不是静默退回本地密钥或明文路径。
调度事实和通知也要保持一致。pool_events 作为 append-only 审计表,pool_outbox 作为待投递队列,两者与业务状态在同一个事务里写入。发布者成功后标记 published,失败则有界重试,超过 8 次转 dead_letter 并告警。这样可以保证"事件与事实一致、至少投递一次",又不会让一条毒事件无限刷日志。
影子调度和灰度切换
号池是平台的核心资源,不能直接把新调度器切到全量流量。切换前可以让新调度器进入 shadow 模式:对每次请求只计算候选账号、拒绝原因和新旧选择差异,不真正领取租约,也不调用 Provider。
只有当每一条差异都能解释,才进入实际切换。推荐的灰度顺序是:
- 建表并导入账号,校验失败立即停止;
- shadow 对比新旧调度结果;
- 短暂停止旧池写入,用单 Worker 验证领取、取消、重启回收和 finish;
- 按 2、4 个 Worker 逐步放量。
回滚时停止领取新租约,等待在途资源排空,再切回旧版本。因为 PostgreSQL 始终是唯一事实源,旧版本读到的仍是同一套租约和账号状态,不需要把数据回写到另一份 JSON 或缓存。
从 shadow 到灰度的切换边界可以画成一条单向流程:
#mermaid-svg-tZyRQeQZl2l9xtnp{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-tZyRQeQZl2l9xtnp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-tZyRQeQZl2l9xtnp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-tZyRQeQZl2l9xtnp .error-icon{fill:#552222;}#mermaid-svg-tZyRQeQZl2l9xtnp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-tZyRQeQZl2l9xtnp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-tZyRQeQZl2l9xtnp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-tZyRQeQZl2l9xtnp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-tZyRQeQZl2l9xtnp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-tZyRQeQZl2l9xtnp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-tZyRQeQZl2l9xtnp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-tZyRQeQZl2l9xtnp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-tZyRQeQZl2l9xtnp .marker.cross{stroke:#333333;}#mermaid-svg-tZyRQeQZl2l9xtnp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-tZyRQeQZl2l9xtnp p{margin:0;}#mermaid-svg-tZyRQeQZl2l9xtnp defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-tZyRQeQZl2l9xtnp g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-tZyRQeQZl2l9xtnp g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-tZyRQeQZl2l9xtnp g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-tZyRQeQZl2l9xtnp g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-tZyRQeQZl2l9xtnp g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-tZyRQeQZl2l9xtnp .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-tZyRQeQZl2l9xtnp .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-tZyRQeQZl2l9xtnp .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-tZyRQeQZl2l9xtnp .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-tZyRQeQZl2l9xtnp .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-tZyRQeQZl2l9xtnp .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-tZyRQeQZl2l9xtnp .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-tZyRQeQZl2l9xtnp .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-tZyRQeQZl2l9xtnp .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-tZyRQeQZl2l9xtnp .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-tZyRQeQZl2l9xtnp .edgeLabel .label text{fill:#333;}#mermaid-svg-tZyRQeQZl2l9xtnp .label div .edgeLabel{color:#333;}#mermaid-svg-tZyRQeQZl2l9xtnp .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-tZyRQeQZl2l9xtnp .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-tZyRQeQZl2l9xtnp .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-tZyRQeQZl2l9xtnp .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-tZyRQeQZl2l9xtnp .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-tZyRQeQZl2l9xtnp .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-tZyRQeQZl2l9xtnp .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-tZyRQeQZl2l9xtnp #statediagram-barbEnd{fill:#333333;}#mermaid-svg-tZyRQeQZl2l9xtnp .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-tZyRQeQZl2l9xtnp .cluster-label,#mermaid-svg-tZyRQeQZl2l9xtnp .nodeLabel{color:#131300;}#mermaid-svg-tZyRQeQZl2l9xtnp .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-tZyRQeQZl2l9xtnp .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-tZyRQeQZl2l9xtnp .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-tZyRQeQZl2l9xtnp .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-tZyRQeQZl2l9xtnp .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-tZyRQeQZl2l9xtnp .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-tZyRQeQZl2l9xtnp .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-tZyRQeQZl2l9xtnp .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-tZyRQeQZl2l9xtnp .note-edge{stroke-dasharray:5;}#mermaid-svg-tZyRQeQZl2l9xtnp .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-tZyRQeQZl2l9xtnp .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-tZyRQeQZl2l9xtnp .statediagram-note text{fill:black;}#mermaid-svg-tZyRQeQZl2l9xtnp .statediagram-note .nodeLabel{color:black;}#mermaid-svg-tZyRQeQZl2l9xtnp .statediagram .edgeLabel{color:red;}#mermaid-svg-tZyRQeQZl2l9xtnp #dependencyStart,#mermaid-svg-tZyRQeQZl2l9xtnp #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-tZyRQeQZl2l9xtnp .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-tZyRQeQZl2l9xtnp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 新调度器只计算不领取
所有新旧差异均可解释
验证取消、回收、finish
逐步增加 Worker
发现不变量或审计异常
发现不变量或审计异常
停止领取,排空在途,切回版本
shadow
single_worker
staged
full_rollout
rollback
验证并发不变量
调度器的测试不能只有"请求成功率"。验证金字塔至少要覆盖:
- 状态机和 fence 的确定性单测;
- PostgreSQL 真实竞态:64 个并发 Worker 抢 4 个 permit 和 2 个账号;
- acquire 中途崩溃、断链、提交重试,以及回收器并发运行;
- 4 个合成账号、4 个令牌、8 个任务、4 个 Worker 的金丝雀;
- 专用账号 379 个样本、持续 30 分钟、零错误,用于校准安全并发;
- 48 线程持续 90 秒的整机压测与 SQL 审计。
竞态门禁的断言应该包括:全部任务完成后 active_leases = 0、SUM(active_slots) = 0、已租 permit 为 0。只断言"任务都成功"没有意义,因为资源泄漏仍可能藏在数据库里。
验证过程本身也是分层的:
#mermaid-svg-UFfOZiPMlsJJkm2v{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-UFfOZiPMlsJJkm2v .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UFfOZiPMlsJJkm2v .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UFfOZiPMlsJJkm2v .error-icon{fill:#552222;}#mermaid-svg-UFfOZiPMlsJJkm2v .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UFfOZiPMlsJJkm2v .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UFfOZiPMlsJJkm2v .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UFfOZiPMlsJJkm2v .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UFfOZiPMlsJJkm2v .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UFfOZiPMlsJJkm2v .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UFfOZiPMlsJJkm2v .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UFfOZiPMlsJJkm2v .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UFfOZiPMlsJJkm2v .marker.cross{stroke:#333333;}#mermaid-svg-UFfOZiPMlsJJkm2v svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UFfOZiPMlsJJkm2v p{margin:0;}#mermaid-svg-UFfOZiPMlsJJkm2v .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-UFfOZiPMlsJJkm2v .cluster-label text{fill:#333;}#mermaid-svg-UFfOZiPMlsJJkm2v .cluster-label span{color:#333;}#mermaid-svg-UFfOZiPMlsJJkm2v .cluster-label span p{background-color:transparent;}#mermaid-svg-UFfOZiPMlsJJkm2v .label text,#mermaid-svg-UFfOZiPMlsJJkm2v span{fill:#333;color:#333;}#mermaid-svg-UFfOZiPMlsJJkm2v .node rect,#mermaid-svg-UFfOZiPMlsJJkm2v .node circle,#mermaid-svg-UFfOZiPMlsJJkm2v .node ellipse,#mermaid-svg-UFfOZiPMlsJJkm2v .node polygon,#mermaid-svg-UFfOZiPMlsJJkm2v .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-UFfOZiPMlsJJkm2v .rough-node .label text,#mermaid-svg-UFfOZiPMlsJJkm2v .node .label text,#mermaid-svg-UFfOZiPMlsJJkm2v .image-shape .label,#mermaid-svg-UFfOZiPMlsJJkm2v .icon-shape .label{text-anchor:middle;}#mermaid-svg-UFfOZiPMlsJJkm2v .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-UFfOZiPMlsJJkm2v .rough-node .label,#mermaid-svg-UFfOZiPMlsJJkm2v .node .label,#mermaid-svg-UFfOZiPMlsJJkm2v .image-shape .label,#mermaid-svg-UFfOZiPMlsJJkm2v .icon-shape .label{text-align:center;}#mermaid-svg-UFfOZiPMlsJJkm2v .node.clickable{cursor:pointer;}#mermaid-svg-UFfOZiPMlsJJkm2v .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-UFfOZiPMlsJJkm2v .arrowheadPath{fill:#333333;}#mermaid-svg-UFfOZiPMlsJJkm2v .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-UFfOZiPMlsJJkm2v .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-UFfOZiPMlsJJkm2v .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UFfOZiPMlsJJkm2v .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-UFfOZiPMlsJJkm2v .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UFfOZiPMlsJJkm2v .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-UFfOZiPMlsJJkm2v .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-UFfOZiPMlsJJkm2v .cluster text{fill:#333;}#mermaid-svg-UFfOZiPMlsJJkm2v .cluster span{color:#333;}#mermaid-svg-UFfOZiPMlsJJkm2v div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-UFfOZiPMlsJJkm2v .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-UFfOZiPMlsJJkm2v rect.text{fill:none;stroke-width:0;}#mermaid-svg-UFfOZiPMlsJJkm2v .icon-shape,#mermaid-svg-UFfOZiPMlsJJkm2v .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UFfOZiPMlsJJkm2v .icon-shape p,#mermaid-svg-UFfOZiPMlsJJkm2v .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-UFfOZiPMlsJJkm2v .icon-shape .label rect,#mermaid-svg-UFfOZiPMlsJJkm2v .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UFfOZiPMlsJJkm2v .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-UFfOZiPMlsJJkm2v .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-UFfOZiPMlsJJkm2v :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 状态机单测
PostgreSQL 竞态门禁
故障注入与回收
合成金丝雀
专用账号校准
整机压测 + SQL 审计
fail-closed 发布门禁
实测的合成金丝雀完成 8/8 任务,租约最终归零;专用账号校准得到安全并发上限 8。这里的结论边界也必须说清楚:它证明的是内部资源不超卖、旧 Worker 不能写、未知结果不会被盲目重放,不等于跨 Provider 的物理 exactly-once,也不能把单实例吞吐直接外推成生产容量。
结语
分布式号池的核心不是选一个"最空闲"的账号,而是建立一套可以被数据库证明的占用协议:permit 控全站,slot 控单账号,lease 记录事实,条件更新选出唯一赢家,heartbeat 延长有效期,fence 拒绝迟到者,reclaimer 负责最终回收。
当每次请求都必须先拿到有效租约,且所有收尾都通过同一个 fence 校验,号池才能在多 Worker、进程崩溃和网络抖动下保持可解释。系统不需要假装外部世界支持 exactly-once,只需要把自己能证明的边界做牢。