AI 生图平台如何避免资损:用"扣款与凭据同事务"构建三态账本
AI 生图平台的计费难点不在于"单价乘以张数"。真正棘手的是:一次任务会跨越队列、上游服务、网络重试和进程崩溃,而账本必须在每个时刻都能回答三个问题:这笔钱属于谁、为什么被冻结、最终有没有被消费。
在 MUSE 的计费改造中,我把这件事收敛成一条账本铁律:**钱只在有凭据的情况下动,扣款与凭据必须在同一个事务里提交。**任务开始时先建立预留单,成功后按实际用量结算,失败或取消时释放预留。这个模型让并发、重试和故障恢复都变成数据库状态机可以裁决的问题。
整体架构
计费链路横跨请求入口、任务队列、图片 Worker 和数据库账本。数据库保存资金事实,队列只负责传递待执行任务,异步清扫和对账负责处理没有正常回调的残留状态。
#mermaid-svg-jZy7Tw773uI5fq3w{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-jZy7Tw773uI5fq3w .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-jZy7Tw773uI5fq3w .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-jZy7Tw773uI5fq3w .error-icon{fill:#552222;}#mermaid-svg-jZy7Tw773uI5fq3w .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-jZy7Tw773uI5fq3w .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-jZy7Tw773uI5fq3w .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-jZy7Tw773uI5fq3w .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-jZy7Tw773uI5fq3w .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-jZy7Tw773uI5fq3w .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-jZy7Tw773uI5fq3w .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-jZy7Tw773uI5fq3w .marker{fill:#333333;stroke:#333333;}#mermaid-svg-jZy7Tw773uI5fq3w .marker.cross{stroke:#333333;}#mermaid-svg-jZy7Tw773uI5fq3w svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-jZy7Tw773uI5fq3w p{margin:0;}#mermaid-svg-jZy7Tw773uI5fq3w .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-jZy7Tw773uI5fq3w .cluster-label text{fill:#333;}#mermaid-svg-jZy7Tw773uI5fq3w .cluster-label span{color:#333;}#mermaid-svg-jZy7Tw773uI5fq3w .cluster-label span p{background-color:transparent;}#mermaid-svg-jZy7Tw773uI5fq3w .label text,#mermaid-svg-jZy7Tw773uI5fq3w span{fill:#333;color:#333;}#mermaid-svg-jZy7Tw773uI5fq3w .node rect,#mermaid-svg-jZy7Tw773uI5fq3w .node circle,#mermaid-svg-jZy7Tw773uI5fq3w .node ellipse,#mermaid-svg-jZy7Tw773uI5fq3w .node polygon,#mermaid-svg-jZy7Tw773uI5fq3w .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-jZy7Tw773uI5fq3w .rough-node .label text,#mermaid-svg-jZy7Tw773uI5fq3w .node .label text,#mermaid-svg-jZy7Tw773uI5fq3w .image-shape .label,#mermaid-svg-jZy7Tw773uI5fq3w .icon-shape .label{text-anchor:middle;}#mermaid-svg-jZy7Tw773uI5fq3w .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-jZy7Tw773uI5fq3w .rough-node .label,#mermaid-svg-jZy7Tw773uI5fq3w .node .label,#mermaid-svg-jZy7Tw773uI5fq3w .image-shape .label,#mermaid-svg-jZy7Tw773uI5fq3w .icon-shape .label{text-align:center;}#mermaid-svg-jZy7Tw773uI5fq3w .node.clickable{cursor:pointer;}#mermaid-svg-jZy7Tw773uI5fq3w .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-jZy7Tw773uI5fq3w .arrowheadPath{fill:#333333;}#mermaid-svg-jZy7Tw773uI5fq3w .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-jZy7Tw773uI5fq3w .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-jZy7Tw773uI5fq3w .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-jZy7Tw773uI5fq3w .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-jZy7Tw773uI5fq3w .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-jZy7Tw773uI5fq3w .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-jZy7Tw773uI5fq3w .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-jZy7Tw773uI5fq3w .cluster text{fill:#333;}#mermaid-svg-jZy7Tw773uI5fq3w .cluster span{color:#333;}#mermaid-svg-jZy7Tw773uI5fq3w 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-jZy7Tw773uI5fq3w .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-jZy7Tw773uI5fq3w rect.text{fill:none;stroke-width:0;}#mermaid-svg-jZy7Tw773uI5fq3w .icon-shape,#mermaid-svg-jZy7Tw773uI5fq3w .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-jZy7Tw773uI5fq3w .icon-shape p,#mermaid-svg-jZy7Tw773uI5fq3w .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-jZy7Tw773uI5fq3w .icon-shape .label rect,#mermaid-svg-jZy7Tw773uI5fq3w .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-jZy7Tw773uI5fq3w .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-jZy7Tw773uI5fq3w .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-jZy7Tw773uI5fq3w :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用户 / Agent
图片任务 API
提交事务
PostgreSQL 站点账本
图片任务表
任务队列
图片 Worker
上游 Provider
成功 / 失败 / 未知结果
settle / release / park
TTL 清扫器
对账与审计
流水、收据与审计查询
最重要的边界是:Provider 调用在数据库事务之外,reserve、任务创建和资金凭据在事务之内;图片交付与最终结算则在收尾事务中重新汇合。
先定义三个金额
计费系统里有三个容易混淆的数字:
balance:账户的总余额,代表账面上拥有的点数。held:正在执行的任务已经冻结的点数,它还不是最终消费。available = balance - held:当前真正可以再次使用的点数。
任务开始时不能只看 balance。如果有多个请求同时进来,它们必须竞争 available,否则每个请求都可能看到同一份旧余额。
一次任务的资金生命周期很简单:
#mermaid-svg-bRefGbINnsR8CrJJ{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-bRefGbINnsR8CrJJ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-bRefGbINnsR8CrJJ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-bRefGbINnsR8CrJJ .error-icon{fill:#552222;}#mermaid-svg-bRefGbINnsR8CrJJ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-bRefGbINnsR8CrJJ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-bRefGbINnsR8CrJJ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-bRefGbINnsR8CrJJ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-bRefGbINnsR8CrJJ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-bRefGbINnsR8CrJJ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-bRefGbINnsR8CrJJ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-bRefGbINnsR8CrJJ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-bRefGbINnsR8CrJJ .marker.cross{stroke:#333333;}#mermaid-svg-bRefGbINnsR8CrJJ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-bRefGbINnsR8CrJJ p{margin:0;}#mermaid-svg-bRefGbINnsR8CrJJ defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-bRefGbINnsR8CrJJ g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-bRefGbINnsR8CrJJ g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-bRefGbINnsR8CrJJ g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-bRefGbINnsR8CrJJ g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-bRefGbINnsR8CrJJ g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-bRefGbINnsR8CrJJ .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-bRefGbINnsR8CrJJ .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-bRefGbINnsR8CrJJ .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-bRefGbINnsR8CrJJ .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-bRefGbINnsR8CrJJ .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-bRefGbINnsR8CrJJ .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-bRefGbINnsR8CrJJ .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-bRefGbINnsR8CrJJ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-bRefGbINnsR8CrJJ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-bRefGbINnsR8CrJJ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-bRefGbINnsR8CrJJ .edgeLabel .label text{fill:#333;}#mermaid-svg-bRefGbINnsR8CrJJ .label div .edgeLabel{color:#333;}#mermaid-svg-bRefGbINnsR8CrJJ .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-bRefGbINnsR8CrJJ .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-bRefGbINnsR8CrJJ .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-bRefGbINnsR8CrJJ .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-bRefGbINnsR8CrJJ .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-bRefGbINnsR8CrJJ .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-bRefGbINnsR8CrJJ .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-bRefGbINnsR8CrJJ #statediagram-barbEnd{fill:#333333;}#mermaid-svg-bRefGbINnsR8CrJJ .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-bRefGbINnsR8CrJJ .cluster-label,#mermaid-svg-bRefGbINnsR8CrJJ .nodeLabel{color:#131300;}#mermaid-svg-bRefGbINnsR8CrJJ .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-bRefGbINnsR8CrJJ .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-bRefGbINnsR8CrJJ .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-bRefGbINnsR8CrJJ .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-bRefGbINnsR8CrJJ .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-bRefGbINnsR8CrJJ .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-bRefGbINnsR8CrJJ .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-bRefGbINnsR8CrJJ .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-bRefGbINnsR8CrJJ .note-edge{stroke-dasharray:5;}#mermaid-svg-bRefGbINnsR8CrJJ .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-bRefGbINnsR8CrJJ .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-bRefGbINnsR8CrJJ .statediagram-note text{fill:black;}#mermaid-svg-bRefGbINnsR8CrJJ .statediagram-note .nodeLabel{color:black;}#mermaid-svg-bRefGbINnsR8CrJJ .statediagram .edgeLabel{color:red;}#mermaid-svg-bRefGbINnsR8CrJJ #dependencyStart,#mermaid-svg-bRefGbINnsR8CrJJ #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-bRefGbINnsR8CrJJ .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-bRefGbINnsR8CrJJ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} reserve
成功后按实际用量结算
失败、取消或超时回收
held
settled
released
held 是唯一可以被收尾的中间状态。settled 和 released 都是终态,不能被重新激活。
从请求到结算的完整流程如下:
#mermaid-svg-SB9gCW7X6HgfP8ct{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-SB9gCW7X6HgfP8ct .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-SB9gCW7X6HgfP8ct .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-SB9gCW7X6HgfP8ct .error-icon{fill:#552222;}#mermaid-svg-SB9gCW7X6HgfP8ct .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-SB9gCW7X6HgfP8ct .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-SB9gCW7X6HgfP8ct .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-SB9gCW7X6HgfP8ct .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-SB9gCW7X6HgfP8ct .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-SB9gCW7X6HgfP8ct .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-SB9gCW7X6HgfP8ct .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-SB9gCW7X6HgfP8ct .marker{fill:#333333;stroke:#333333;}#mermaid-svg-SB9gCW7X6HgfP8ct .marker.cross{stroke:#333333;}#mermaid-svg-SB9gCW7X6HgfP8ct svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-SB9gCW7X6HgfP8ct p{margin:0;}#mermaid-svg-SB9gCW7X6HgfP8ct .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-SB9gCW7X6HgfP8ct .cluster-label text{fill:#333;}#mermaid-svg-SB9gCW7X6HgfP8ct .cluster-label span{color:#333;}#mermaid-svg-SB9gCW7X6HgfP8ct .cluster-label span p{background-color:transparent;}#mermaid-svg-SB9gCW7X6HgfP8ct .label text,#mermaid-svg-SB9gCW7X6HgfP8ct span{fill:#333;color:#333;}#mermaid-svg-SB9gCW7X6HgfP8ct .node rect,#mermaid-svg-SB9gCW7X6HgfP8ct .node circle,#mermaid-svg-SB9gCW7X6HgfP8ct .node ellipse,#mermaid-svg-SB9gCW7X6HgfP8ct .node polygon,#mermaid-svg-SB9gCW7X6HgfP8ct .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-SB9gCW7X6HgfP8ct .rough-node .label text,#mermaid-svg-SB9gCW7X6HgfP8ct .node .label text,#mermaid-svg-SB9gCW7X6HgfP8ct .image-shape .label,#mermaid-svg-SB9gCW7X6HgfP8ct .icon-shape .label{text-anchor:middle;}#mermaid-svg-SB9gCW7X6HgfP8ct .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-SB9gCW7X6HgfP8ct .rough-node .label,#mermaid-svg-SB9gCW7X6HgfP8ct .node .label,#mermaid-svg-SB9gCW7X6HgfP8ct .image-shape .label,#mermaid-svg-SB9gCW7X6HgfP8ct .icon-shape .label{text-align:center;}#mermaid-svg-SB9gCW7X6HgfP8ct .node.clickable{cursor:pointer;}#mermaid-svg-SB9gCW7X6HgfP8ct .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-SB9gCW7X6HgfP8ct .arrowheadPath{fill:#333333;}#mermaid-svg-SB9gCW7X6HgfP8ct .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-SB9gCW7X6HgfP8ct .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-SB9gCW7X6HgfP8ct .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SB9gCW7X6HgfP8ct .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-SB9gCW7X6HgfP8ct .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SB9gCW7X6HgfP8ct .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-SB9gCW7X6HgfP8ct .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-SB9gCW7X6HgfP8ct .cluster text{fill:#333;}#mermaid-svg-SB9gCW7X6HgfP8ct .cluster span{color:#333;}#mermaid-svg-SB9gCW7X6HgfP8ct 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-SB9gCW7X6HgfP8ct .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-SB9gCW7X6HgfP8ct rect.text{fill:none;stroke-width:0;}#mermaid-svg-SB9gCW7X6HgfP8ct .icon-shape,#mermaid-svg-SB9gCW7X6HgfP8ct .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SB9gCW7X6HgfP8ct .icon-shape p,#mermaid-svg-SB9gCW7X6HgfP8ct .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-SB9gCW7X6HgfP8ct .icon-shape .label rect,#mermaid-svg-SB9gCW7X6HgfP8ct .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SB9gCW7X6HgfP8ct .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-SB9gCW7X6HgfP8ct .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-SB9gCW7X6HgfP8ct :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
否
是
成功
失败/取消
无法确认
请求到达
幂等键已有任务?
返回已有任务
校验报价与可用余额
条件扣减成功?
返回余额不足
写入 hold + receipt + task
提交事务并排队
Provider 结果
按实际用量 settle
全额 release
park,等待对账
图片与账务一起提交
任务失败与退款一起提交
为什么不能"成功后再扣款"
直接后扣看起来最简单,却把平台暴露在一个无法补救的窗口里:图片已经生成并交付,进程在扣款前崩溃,用户拿到了真实成本,账上却没有一笔可追踪的消费记录。
"先扣款,失败再退款"也不够。如果扣款和退款凭据分属两个事务,进程可能在扣款提交后、凭据写入前退出。此时余额确实少了,但系统不知道这笔钱为何减少,也没有可靠的退款坐标。
异步对账只能发现数字不一致,无法判断业务语义。对账窗口里,用户可能已经看到余额变化,也可能已经拿着图片继续发起新任务。计费系统需要的是提交瞬间的一致性,而不是事后猜测。
因此,预留单不是一个"方便退款的表",而是资金状态的持久化凭据:它记录账户、金额、任务、状态、过期时间和幂等键,让崩溃恢复有明确的落点。
让任务和账本同生共死
一次提交的关键写入应该在同一个数据库事务里完成:
#mermaid-svg-8NE9WesyzISSMnMS{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-8NE9WesyzISSMnMS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8NE9WesyzISSMnMS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8NE9WesyzISSMnMS .error-icon{fill:#552222;}#mermaid-svg-8NE9WesyzISSMnMS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8NE9WesyzISSMnMS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8NE9WesyzISSMnMS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8NE9WesyzISSMnMS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8NE9WesyzISSMnMS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8NE9WesyzISSMnMS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8NE9WesyzISSMnMS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8NE9WesyzISSMnMS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8NE9WesyzISSMnMS .marker.cross{stroke:#333333;}#mermaid-svg-8NE9WesyzISSMnMS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8NE9WesyzISSMnMS p{margin:0;}#mermaid-svg-8NE9WesyzISSMnMS .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-8NE9WesyzISSMnMS .cluster-label text{fill:#333;}#mermaid-svg-8NE9WesyzISSMnMS .cluster-label span{color:#333;}#mermaid-svg-8NE9WesyzISSMnMS .cluster-label span p{background-color:transparent;}#mermaid-svg-8NE9WesyzISSMnMS .label text,#mermaid-svg-8NE9WesyzISSMnMS span{fill:#333;color:#333;}#mermaid-svg-8NE9WesyzISSMnMS .node rect,#mermaid-svg-8NE9WesyzISSMnMS .node circle,#mermaid-svg-8NE9WesyzISSMnMS .node ellipse,#mermaid-svg-8NE9WesyzISSMnMS .node polygon,#mermaid-svg-8NE9WesyzISSMnMS .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8NE9WesyzISSMnMS .rough-node .label text,#mermaid-svg-8NE9WesyzISSMnMS .node .label text,#mermaid-svg-8NE9WesyzISSMnMS .image-shape .label,#mermaid-svg-8NE9WesyzISSMnMS .icon-shape .label{text-anchor:middle;}#mermaid-svg-8NE9WesyzISSMnMS .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-8NE9WesyzISSMnMS .rough-node .label,#mermaid-svg-8NE9WesyzISSMnMS .node .label,#mermaid-svg-8NE9WesyzISSMnMS .image-shape .label,#mermaid-svg-8NE9WesyzISSMnMS .icon-shape .label{text-align:center;}#mermaid-svg-8NE9WesyzISSMnMS .node.clickable{cursor:pointer;}#mermaid-svg-8NE9WesyzISSMnMS .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-8NE9WesyzISSMnMS .arrowheadPath{fill:#333333;}#mermaid-svg-8NE9WesyzISSMnMS .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-8NE9WesyzISSMnMS .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-8NE9WesyzISSMnMS .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8NE9WesyzISSMnMS .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8NE9WesyzISSMnMS .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8NE9WesyzISSMnMS .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-8NE9WesyzISSMnMS .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-8NE9WesyzISSMnMS .cluster text{fill:#333;}#mermaid-svg-8NE9WesyzISSMnMS .cluster span{color:#333;}#mermaid-svg-8NE9WesyzISSMnMS 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-8NE9WesyzISSMnMS .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8NE9WesyzISSMnMS rect.text{fill:none;stroke-width:0;}#mermaid-svg-8NE9WesyzISSMnMS .icon-shape,#mermaid-svg-8NE9WesyzISSMnMS .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8NE9WesyzISSMnMS .icon-shape p,#mermaid-svg-8NE9WesyzISSMnMS .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-8NE9WesyzISSMnMS .icon-shape .label rect,#mermaid-svg-8NE9WesyzISSMnMS .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8NE9WesyzISSMnMS .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-8NE9WesyzISSMnMS .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-8NE9WesyzISSMnMS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 校验价格
条件扣减可用余额
创建 credit_hold
追加 credit_entry
创建 image_task
提交并进入队列
这里有一个关键的"缝合点":账本 API 必须提供接收调用方事务的版本。否则,预留单可能先独立提交,随后任务创建失败,用户的点数会在没有任务认领的情况下被冻结到 TTL 清扫。
任务收尾也要缝合。成功时,图片落库、任务转为成功、settle 结算必须一起提交;失败时,任务转为失败与 release 释放也必须一起提交。不能先把任务标成失败,再在另一个事务里吞掉退款异常,否则用户会看到"任务失败",预留却永久卡在 held。
条件更新就是并发裁决器
扣余额不应该采用"先查询、再判断、再更新"的三步写法。正确做法是把条件写进 UPDATE:
sql
UPDATE credit_balances
SET balance_micros = balance_micros - :amount,
updated_at = now()
WHERE account_id = :account_id
AND balance_micros - held_micros >= :amount;
返回的 rowcount 就是裁决结果:
rowcount = 1:本请求赢得扣减,可以继续创建预留单;rowcount = 0:余额不足,数据库没有改变任何状态。
假设账户还有 5 点,三个请求同时各申请 2 点。第一个请求把余额推进到 3,第二个请求拿到锁后基于最新提交值推进到 1,第三个请求的条件不再成立,于是得到结构化的余额不足错误。最终结果是 2 个成功、1 个拒绝,剩余 1 点,而不是三个请求都"认为自己成功"。
并发时的数据库行为可以用一张时序图表示:
余额行 请求 C 请求 B 请求 A 余额行 请求 C 请求 B 请求 A #mermaid-svg-lc5G3WJL38p1q8Gh{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-lc5G3WJL38p1q8Gh .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lc5G3WJL38p1q8Gh .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lc5G3WJL38p1q8Gh .error-icon{fill:#552222;}#mermaid-svg-lc5G3WJL38p1q8Gh .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lc5G3WJL38p1q8Gh .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lc5G3WJL38p1q8Gh .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lc5G3WJL38p1q8Gh .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lc5G3WJL38p1q8Gh .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lc5G3WJL38p1q8Gh .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lc5G3WJL38p1q8Gh .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lc5G3WJL38p1q8Gh .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lc5G3WJL38p1q8Gh .marker.cross{stroke:#333333;}#mermaid-svg-lc5G3WJL38p1q8Gh svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lc5G3WJL38p1q8Gh p{margin:0;}#mermaid-svg-lc5G3WJL38p1q8Gh .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-lc5G3WJL38p1q8Gh text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-lc5G3WJL38p1q8Gh .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-lc5G3WJL38p1q8Gh .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-lc5G3WJL38p1q8Gh .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-lc5G3WJL38p1q8Gh .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-lc5G3WJL38p1q8Gh #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-lc5G3WJL38p1q8Gh .sequenceNumber{fill:white;}#mermaid-svg-lc5G3WJL38p1q8Gh #sequencenumber{fill:#333;}#mermaid-svg-lc5G3WJL38p1q8Gh #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-lc5G3WJL38p1q8Gh .messageText{fill:#333;stroke:none;}#mermaid-svg-lc5G3WJL38p1q8Gh .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-lc5G3WJL38p1q8Gh .labelText,#mermaid-svg-lc5G3WJL38p1q8Gh .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-lc5G3WJL38p1q8Gh .loopText,#mermaid-svg-lc5G3WJL38p1q8Gh .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-lc5G3WJL38p1q8Gh .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-lc5G3WJL38p1q8Gh .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-lc5G3WJL38p1q8Gh .noteText,#mermaid-svg-lc5G3WJL38p1q8Gh .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-lc5G3WJL38p1q8Gh .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-lc5G3WJL38p1q8Gh .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-lc5G3WJL38p1q8Gh .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-lc5G3WJL38p1q8Gh .actorPopupMenu{position:absolute;}#mermaid-svg-lc5G3WJL38p1q8Gh .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-lc5G3WJL38p1q8Gh .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-lc5G3WJL38p1q8Gh .actor-man circle,#mermaid-svg-lc5G3WJL38p1q8Gh line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-lc5G3WJL38p1q8Gh :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} A 先拿锁,5 → 3 B 获锁后重新检查,3 → 1 C 获锁后重新检查,1 < 2 UPDATE ... WHERE available >= 2UPDATE ... WHERE available >= 2UPDATE ... WHERE available >= 2rowcount = 1rowcount = 1rowcount = 0
计账金额在接口层可以使用 Decimal,进入账本后统一转换为整数微点:1 点等于 1_000_000 微点。这样 SQLite 和 PostgreSQL 使用同一套整数运算,避免浮点误差累积;超出最小单位的金额直接拒绝,不做隐式四舍五入。
settle 和 release 必须竞争同一张单
最危险的计费竞态是:成功路径已经扣款,迟到的失败处理又把预留全额退回。结果就是用户拿到图片,却没有真正付费。
解决方法不是增加更多标记,而是让 settle 与 release 都竞争同一个状态条件:
sql
UPDATE credit_holds
SET status = 'settled', settled_micros = :actual
WHERE id = :hold_id
AND status = 'held';
release 使用同样的 status = 'held' 条件。数据库会串行处理同一行的更新,先提交的一方得到 rowcount = 1,另一方得到 rowcount = 0 并且不产生副作用。重复结算、重复释放和迟到回调因此天然幂等。
结算金额不能超过预留金额。一次预留 4 张图,实际成功 3 张时,只保留 3 张的消费,剩余 1 张对应的点数通过退款流水释放;如果 Provider 的结果无法确认,则不能猜成失败,必须保留 held,进入对账或人工裁决流程。
settle 和 release 的竞争关系也可以单独画出来:
credit_hold 失败/取消收尾 成功收尾 credit_hold 失败/取消收尾 成功收尾 #mermaid-svg-SDz7TtKbtsSVHqok{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-SDz7TtKbtsSVHqok .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-SDz7TtKbtsSVHqok .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-SDz7TtKbtsSVHqok .error-icon{fill:#552222;}#mermaid-svg-SDz7TtKbtsSVHqok .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-SDz7TtKbtsSVHqok .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-SDz7TtKbtsSVHqok .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-SDz7TtKbtsSVHqok .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-SDz7TtKbtsSVHqok .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-SDz7TtKbtsSVHqok .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-SDz7TtKbtsSVHqok .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-SDz7TtKbtsSVHqok .marker{fill:#333333;stroke:#333333;}#mermaid-svg-SDz7TtKbtsSVHqok .marker.cross{stroke:#333333;}#mermaid-svg-SDz7TtKbtsSVHqok svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-SDz7TtKbtsSVHqok p{margin:0;}#mermaid-svg-SDz7TtKbtsSVHqok .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-SDz7TtKbtsSVHqok text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-SDz7TtKbtsSVHqok .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-SDz7TtKbtsSVHqok .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-SDz7TtKbtsSVHqok .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-SDz7TtKbtsSVHqok .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-SDz7TtKbtsSVHqok #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-SDz7TtKbtsSVHqok .sequenceNumber{fill:white;}#mermaid-svg-SDz7TtKbtsSVHqok #sequencenumber{fill:#333;}#mermaid-svg-SDz7TtKbtsSVHqok #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-SDz7TtKbtsSVHqok .messageText{fill:#333;stroke:none;}#mermaid-svg-SDz7TtKbtsSVHqok .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-SDz7TtKbtsSVHqok .labelText,#mermaid-svg-SDz7TtKbtsSVHqok .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-SDz7TtKbtsSVHqok .loopText,#mermaid-svg-SDz7TtKbtsSVHqok .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-SDz7TtKbtsSVHqok .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-SDz7TtKbtsSVHqok .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-SDz7TtKbtsSVHqok .noteText,#mermaid-svg-SDz7TtKbtsSVHqok .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-SDz7TtKbtsSVHqok .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-SDz7TtKbtsSVHqok .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-SDz7TtKbtsSVHqok .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-SDz7TtKbtsSVHqok .actorPopupMenu{position:absolute;}#mermaid-svg-SDz7TtKbtsSVHqok .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-SDz7TtKbtsSVHqok .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-SDz7TtKbtsSVHqok .actor-man circle,#mermaid-svg-SDz7TtKbtsSVHqok line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-SDz7TtKbtsSVHqok :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} altS 先提交R 先提交 UPDATE ... WHERE status = 'held'UPDATE ... WHERE status = 'held'rowcount = 1,进入 settledrowcount = 0,无副作用rowcount = 1,进入 releasedrowcount = 0,无副作用
幂等、TTL 与审计
幂等键由调用方提供,并通过唯一约束落库。同一个幂等键重复提交时,系统返回原有任务;同键但请求内容不同,则明确报告冲突。不要用进程内集合或 Redis Set 作为唯一依据,因为它们可能重启丢失或被驱逐。
TTL 清扫是崩溃恢复的兜底:任务进程消失、没有成功回调、也没有失败回调时,清扫器可以找到过期的 held 预留并释放它。TTL 只处理确定可以释放的情况,未知结果仍然需要 park,避免把已发生的上游成本错误退回。
余额表、预留表、流水表和任务表各自承担不同责任:
| 数据 | 责任 |
|---|---|
credit_balances |
当前余额状态,条件更新的目标 |
credit_holds |
在途资金的状态机与 TTL |
credit_entries |
append-only 历史流水 |
image_tasks |
业务任务,通过 hold_id 绑定账本 |
流水记录 amount、kind、hold_id、actor 和 balance_after。这样既能从流水重算余额,也能在任意一行检查"这笔变化之后应该剩多少"。状态可以更新,事实流水不能被覆盖。
用数据库状态验证,而不是只看日志
账本验收必须直接检查最终状态。压测中,几个最有价值的场景是:
- 3 路抢 5 点、单价 2 点:2 个请求成功、1 个返回 402,余额剩 1;
- 同一个幂等键 8 路并发:8 个请求都能得到同一结果,但只创建 1 个任务、只扣 1 次;
- 零余额 32 路并发:意外成功数为 0;
- 提交后立即取消:预留释放,余额恢复原值;
- 48 线程持续 90 秒突发:约 88 create/s,5xx 为 0,429 是预期的站点限流。
压测后还要跑库审计:负余额为 0、同一预留挂多个任务为 0、预留和任务归属不一致为 0。末尾仍在执行的预留可以有少量,但必须在约定窗口内收敛;实测的 8 条在途预留在 90 秒后全部收敛。
账本审计关注的是不变量,而不是日志行数:
#mermaid-svg-6WOoJPiwAUokkJZg{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-6WOoJPiwAUokkJZg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6WOoJPiwAUokkJZg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6WOoJPiwAUokkJZg .error-icon{fill:#552222;}#mermaid-svg-6WOoJPiwAUokkJZg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6WOoJPiwAUokkJZg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6WOoJPiwAUokkJZg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6WOoJPiwAUokkJZg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6WOoJPiwAUokkJZg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6WOoJPiwAUokkJZg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6WOoJPiwAUokkJZg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6WOoJPiwAUokkJZg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6WOoJPiwAUokkJZg .marker.cross{stroke:#333333;}#mermaid-svg-6WOoJPiwAUokkJZg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6WOoJPiwAUokkJZg p{margin:0;}#mermaid-svg-6WOoJPiwAUokkJZg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6WOoJPiwAUokkJZg .cluster-label text{fill:#333;}#mermaid-svg-6WOoJPiwAUokkJZg .cluster-label span{color:#333;}#mermaid-svg-6WOoJPiwAUokkJZg .cluster-label span p{background-color:transparent;}#mermaid-svg-6WOoJPiwAUokkJZg .label text,#mermaid-svg-6WOoJPiwAUokkJZg span{fill:#333;color:#333;}#mermaid-svg-6WOoJPiwAUokkJZg .node rect,#mermaid-svg-6WOoJPiwAUokkJZg .node circle,#mermaid-svg-6WOoJPiwAUokkJZg .node ellipse,#mermaid-svg-6WOoJPiwAUokkJZg .node polygon,#mermaid-svg-6WOoJPiwAUokkJZg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6WOoJPiwAUokkJZg .rough-node .label text,#mermaid-svg-6WOoJPiwAUokkJZg .node .label text,#mermaid-svg-6WOoJPiwAUokkJZg .image-shape .label,#mermaid-svg-6WOoJPiwAUokkJZg .icon-shape .label{text-anchor:middle;}#mermaid-svg-6WOoJPiwAUokkJZg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6WOoJPiwAUokkJZg .rough-node .label,#mermaid-svg-6WOoJPiwAUokkJZg .node .label,#mermaid-svg-6WOoJPiwAUokkJZg .image-shape .label,#mermaid-svg-6WOoJPiwAUokkJZg .icon-shape .label{text-align:center;}#mermaid-svg-6WOoJPiwAUokkJZg .node.clickable{cursor:pointer;}#mermaid-svg-6WOoJPiwAUokkJZg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6WOoJPiwAUokkJZg .arrowheadPath{fill:#333333;}#mermaid-svg-6WOoJPiwAUokkJZg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6WOoJPiwAUokkJZg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6WOoJPiwAUokkJZg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6WOoJPiwAUokkJZg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6WOoJPiwAUokkJZg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6WOoJPiwAUokkJZg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6WOoJPiwAUokkJZg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6WOoJPiwAUokkJZg .cluster text{fill:#333;}#mermaid-svg-6WOoJPiwAUokkJZg .cluster span{color:#333;}#mermaid-svg-6WOoJPiwAUokkJZg 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-6WOoJPiwAUokkJZg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6WOoJPiwAUokkJZg rect.text{fill:none;stroke-width:0;}#mermaid-svg-6WOoJPiwAUokkJZg .icon-shape,#mermaid-svg-6WOoJPiwAUokkJZg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6WOoJPiwAUokkJZg .icon-shape p,#mermaid-svg-6WOoJPiwAUokkJZg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6WOoJPiwAUokkJZg .icon-shape .label rect,#mermaid-svg-6WOoJPiwAUokkJZg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6WOoJPiwAUokkJZg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6WOoJPiwAUokkJZg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6WOoJPiwAUokkJZg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 压测结束
检查负余额
检查 held 预留是否收敛
检查一个 hold 是否绑定多个任务
检查 hold owner 与 task owner
全部为 0?
在 TTL 窗口内归零?
全部唯一?
全部一致?
允许进入下一轮验证
这些证据说明的是账本不变量成立,不等于承诺生产环境的无限吞吐。账本设计保证"没有凭据不动钱、同一预留只有一个终态赢家、任务和资金状态不会只提交一半",而上游容量仍需要单独校准。
结语
一个可靠的 AI 计费系统,核心不是把扣款代码放进某个成功回调,而是把资金变化建模成可审计的状态机:先 reserve,后 settle 或 release;用条件更新裁决并发,用事务缝合业务与财务,用唯一约束保证幂等,用 TTL 和对账处理崩溃残留。
当"扣款"和"为什么扣款"永远一起出现,系统才有资格面对重试、并发和故障。用户拿到几张图、平台实际承担了多少上游成本,也才能在任何时刻被准确解释。