后端系统工程 27:测试账户的余额与账本
一个积分账户只要支持“扣减”,就会同时面对三类问题:多个请求同时扣同一个账户时会不会扣成负数;客户端超时后重发时会不会扣两次;余额列被某段代码改错以后,能不能从别的记录里算出正确值。本篇用一个测试账户服务回答这三个问题。账户、金额与请求都是合成数据,金额单位是“模拟积分”,不对应任何真实货币或支付渠道。
验收目标有三条,每条都落到具体的检查语句和用例编号上:
- 并发扣减不透支。
- 超时重试不重复扣款。
- 余额与流水守恒,余额可以从流水重算。
需求与范围
服务管理一组测试账户,每个账户有一个整数余额。业务方通过 HTTP 发起扣减,例如兑换一件测试奖品花掉 30 积分;运营脚本通过内部调用发放积分。不支持撤销、冻结、部分退款与多币种,也不处理跨库转账。
本章约定的业务不变量:
| 编号 | 不变量 | 检查位置 |
|---|---|---|
| I1 | 普通账户余额不小于 0 | account.balance_non_negative 约束;UPDATE ... WHERE 条件 |
| I2 | 同一客户端、同一幂等键至多产生一笔交易 | idem_request 主键、ledger_txn 唯一约束 |
| I3 | 每笔交易的分录之和为 0,且至少两条 | 对账查询 UNBALANCED_TXN |
| I4 | 每个账户的余额列等于该账户全部分录之和 | 对账查询 BALANCE_VS_ENTRIES |
系统账户有两个:sys-issuer 是积分来源,允许为负,它的负余额等于已发放的总量;sys-redeem 是兑换去向。这样每次发放或扣减都写成“从一个账户转到另一个账户”,全体账户余额之和恒为 0,对账时多一条可检查的全局等式。
教学规模假设
以下数字只用来确定实验输入,不来自任何实际系统,也不用于推算吞吐:
- 测试账户数在千级以内,单次实验建 10 个账户。
- 热点场景假设一个账户在同一时刻收到几十个扣减请求,实验取 32 个线程。
- 单次扣减金额 1 到 100 积分;客户端请求超时取 400 毫秒,服务端故意在提交后停 1500 毫秒再断开,保证客户端在超时前收不到响应;超时时服务端已经提交,由 L7 的余额断言确认。
所有扣减最终都会更新 sys-redeem 这一行,这一行是全局串行点。本章没有测量它的排队代价,只在限制里写明它的存在。
接口契约
扣减接口为 POST /accounts/{id}/debits,请求头带 X-Client-Id 与 Idempotency-Key,正文是 amount=N。幂等键的语义参照 IETF 草案 draft-ietf-httpapi-idempotency-key-header-07。该草案在 datatracker 上的状态是已过期(文本标注的过期日为 2026-04-18),不是 RFC,因此这里只把它当作接口设计参考,并逐条写出本实现与草案的差异。
表 27-1:扣减接口的响应
| 情形 | 状态码 | 是否登记幂等结果 | 重放时 |
|---|---|---|---|
| 首次扣减成功 | 201 | 是,OK | 原状态码、原 txn、原余额,加 Idempotent-Replayed: true |
| 余额不足 | 409 | 是,INSUFFICIENT | 同上 |
| 账户不存在 | 404 | 是,NO_ACCOUNT | 同上 |
| 同键不同载荷 | 422 | 否 | — |
| 缺键、金额非正、格式错误 | 400 | 否 | — |
和草案的对照如下。草案 2.2 节要求键不得在不同载荷之间复用,2.7 节建议复用时返回 422,本实现一致。草案 2.6 节说重试应返回原操作的结果,“success or an error”,所以余额不足也被登记并原样重放。草案 5 节建议把客户端发来的键与只有服务端掌握的客户端属性组合成复合键(原文为 “other client specific attributes known only to the resource”)。本实现的主键是 (client_id, idem_key),但 client_id 取自客户端自报的 X-Client-Id 请求头,没有鉴权,不属于只有服务端掌握的属性;生产实现应改用认证得到的调用方身份。
差异有四处,前三处如下,第四处是上一段所说的复合键来源。第一,草案 2.6 节建议在原请求仍在处理时对同键请求返回 409;本实现让后到的请求在唯一索引上等待先到者结束,然后返回登记好的结果,客户端拿到的总是终态。代价是等待者占用一个数据库连接,等待时间取决于先到者事务的长度。第二,本实现用 409 表示余额不足,与草案中“请求仍在处理”的 409 重叠,客户端必须读正文中的 outcome 字段区分,不能只看状态码。第三,Idempotent-Replayed 响应头是本实现自定的,草案没有定义;草案 2.1 节规定键是 RFC 8941 的 String 结构化字段,本实现没有做语法校验。
“同一个请求”的判定用指纹:requestHash 对 kind|from|to|amount 取 SHA-256 的前 16 字节。指纹不含时间戳和客户端生成的随机字段,否则合法重试也会被判为不同载荷。
数据模型
1 | |
四张表各管一件事。account.balance 是为了快速判断余额是否足够而维护的派生值,事实记录是 ledger_entry。ledger_txn 把一次业务操作的多条分录归在一起,并且自带 (client_id, idem_key) 唯一约束,即使幂等表的逻辑有缺陷,同一个键也写不出第二笔交易。idem_request 保存每个键的终态和返回给客户端的数值,重放时不重新计算。
金额用 bigint 存最小单位,不用浮点。CHECK 约束只检查当前行:PostgreSQL 16 文档 5.4.1 节明确说明不支持引用其他行的 CHECK,所以“分录之和等于余额”这种跨行条件只能放在对账查询里。
txn_id 来自序列。13.2 节的提示指出序列的推进不随事务回滚而撤销,所以交易编号可能有空洞,对账不能用“编号连续”判断是否丢了交易。
一个幂等键的状态
图 27-1:一个 (client_id, idem_key) 在 idem_request 中的状态
stateDiagram-v2
[*] --> 无记录
无记录 --> PENDING: INSERT ON CONFLICT DO NOTHING 返回一行(本事务内可见)
无记录 --> 等待: 同键行由未提交事务持有
等待 --> 无记录: 持有者回滚(含进程被杀)
等待 --> 读取终态: 持有者提交
PENDING --> OK: 条件扣减命中 + 分录写入 + 提交
PENDING --> INSUFFICIENT: 条件扣减未命中且账户存在 + 提交
PENDING --> NO_ACCOUNT: 账户不存在 + 提交
PENDING --> 无记录: 任一步失败或连接断开,事务回滚
读取终态 --> 返回原结果: 指纹相同
读取终态 --> 拒绝422: 指纹不同
PENDING 只存在于事务内部,其他会话永远看不到一个已提交的 PENDING。这一点由“登记、扣减、写分录、写结果在同一个事务里”保证,对账查询里仍然把 outcome = 'PENDING' 列为问题,用来发现将来有人把这几步拆成多个事务的情形。
正常路径
Ledger.transfer 在一个 READ COMMITTED 事务里做四步,下面是 Ledger.java 中这四步的原文(外层 try/catch 与 finally 省略):
1 | |
hook.at 是测试注入点,正常调用时为空操作。确认点是事务提交:c.commit() 返回之后,HTTP 层才把 201(或余额不足时的 409)写回客户端;提交之前的任何失败都由外层 catch 回滚。L1 用例发放 1000、扣减 300 后,余额为 700,这笔交易有 2 条分录、和为 0:
1 | |
加锁顺序固定为先 from 行再 to 行。扣减是“用户到 sys-redeem”,发放是“sys-issuer 到用户”,两种操作对同一用户行的先后位置不同,但不存在两个事务各持一行、互等对方另一行的环。13.3.4 节把“所有事务按一致顺序加锁”列为避免死锁的首要手段;本章没有实现死锁重试,加入任意账户互转时这个前提就不成立,需要按账户编号排序加锁或在 40P01 上重试。
并发扣减为什么不透支
六种写法在 READ COMMITTED 下的行为对比如下,后两种只作为备选列出:
表 27-2:扣减写法比较
| 写法 | 透支 | 丢失更新 | 依据 | 本章观察 |
|---|---|---|---|---|
| 先 SELECT,应用判断,写回绝对值 | 余额列不为负,但成功次数超出余额 | 会 | 判断与写入都用旧读数 | L4:32 次全“成功” |
| 先 SELECT,应用判断,相对扣减,无 CHECK | 会 | 否 | 判断与写入不在同一次求值 | L5:余额 -124 |
| 同上,有 CHECK | 否,报 23514 | 否 | 约束在写入时检查新行 | L6:18 次被拒 |
| 条件 UPDATE(本章采用) | 否 | 否 | 拿到行锁后对新版本重算 WHERE | L3:14 成功 18 不足 |
| SELECT FOR UPDATE 后判断 | 否 | 否 | 先锁再读最新版本 | 未实现 |
| SERIALIZABLE 加重试 | 否 | 否 | 串行化失败 40001 后重试 | 未实现 |
条件 UPDATE 的正确性来自 PostgreSQL 16 文档 13.2.1 节描述的行为:UPDATE 找到目标行后,如果该行已被并发事务更新,它会等待对方提交或回滚;对方提交后,“WHERE 子句会在更新后的版本上重新求值”,仍然满足才执行。于是“余额够不够”与“扣掉”发生在同一次对最新版本的求值里,中间没有其他事务能插进来。文档用转账举例说明这种模式适用于“每条命令只影响一个预先确定的行”的情形,本章的扣减正是这种形状。
L3 用 32 个线程、32 个连接同时对余额 100 的账户各扣 7:
1 | |
负向对照用同样的输入,但所有线程先读到余额 100,在屏障处汇合后才写。L4 写回绝对值 balance = 93,32 个事务依次覆盖,结果全部“成功”,余额停在 93,而分录记下了 224 积分的扣减;L5 在没有 CHECK 的表上做相对扣减,余额变成 -124:
1 | |
L6 把 L5 的写法放到带 CHECK 的表上,18 个事务在执行 UPDATE 时收到 SQLSTATE 23514,余额停在 2。CHECK 守住了 I1,但它守住的方式是让事务报错,应用层看到的是异常而不是“余额不足”的业务结果。本章把 CHECK 当最后一道防线,业务判断仍由条件 UPDATE 完成。
L6 还给出了 CHECK 管不到的情形:同一个业务请求先后用两个不同的键各发一次(模拟超时后换键重发),每次扣 30,余额从 100 变成 40,两笔都满足 CHECK。透支与重复是两个独立的问题,后者要靠请求标识解决。
这一保证的成立条件是:隔离级别为 READ COMMITTED,余额判断写在同一条 UPDATE 的 WHERE 里,且所有修改余额的路径都走这条语句。观察位置是 L3 的成功次数与分录条数。失败边界是:任何绕过它的写入(例如管理脚本直接写绝对值)都会重新引入 L4 的问题;若把扣减改到 REPEATABLE READ,同一行的并发更新会报 40001,需要重试循环,本章没有实现。
超时之后:重试不重复扣款
客户端超时时,服务端可能还没收到请求,也可能已经提交。客户端无法区分这两种情况,唯一安全的做法是用同一个键重发,由服务端判断。
图 27-2:提交后响应丢失,同键重试(L7)
sequenceDiagram
participant C as 客户端 app-a
participant S as LedgerServer
participant DB as PostgreSQL
C->>S: POST debits amount=30, Idempotency-Key k-0701
S->>DB: 登记 PENDING, 条件扣减, 写分录, 写 OK
DB-->>S: COMMIT 成功(确认点)
Note over S: 故障注入:不写响应,停 1500ms
Note over C: 400ms 请求超时
C->>S: 重试,同键 k-0701,amount=30
S->>DB: INSERT ON CONFLICT DO NOTHING 无返回行
S->>DB: 读取已登记结果,指纹相同
S-->>C: 201,Idempotent-Replayed: true,txn 与余额同首次
实际输出:
1 | |
最后两行是对照:同样的超时,客户端换了新键重发,余额被扣成 40。两笔交易各自平衡,I3、I4 都成立,对账查询报告零问题。这说明幂等键的生成规则属于接口契约的一部分:键必须在客户端决定发起这次业务操作时生成并持久化,重试时复用;在每次 HTTP 发送前现生成,服务端就无从识别重复。服务端只能保证“同键至多一次”,不能保证“同一业务意图至多一次”。
同键请求同时到达时,第 1 步的 INSERT ... ON CONFLICT DO NOTHING 起到排队作用。PostgreSQL 16 文档 64.5 节说明,唯一性检查遇到未提交事务插入的冲突行时,插入者会等待该事务结束再重新检查;13.2.1 节说明在 READ COMMITTED 下,DO NOTHING 可能因为另一事务的结果而不插入,即使那一行对当前快照不可见。随后的 stored() 是一条新语句,在 READ COMMITTED 下取新快照,能读到先到者已提交的终态。L8 让 16 个同键请求同时到达:
1 | |
L2 覆盖了一个容易被忽视的边界:余额为 0 时扣 1 得到 INSUFFICIENT,补足余额后用同一个键重试,返回的仍是登记好的 INSUFFICIENT,换新键才成功。键绑定的是第一次处理的结论。这符合草案 2.6 节“返回先前操作的结果,无论成功还是错误”,但调用方要明确:被拒绝的键不能在条件改变后复用。
这项保证的成立条件是:键由客户端在业务层面生成并在重试间复用;(client_id, idem_key) 上有唯一约束;登记与扣减在同一事务。观察位置是 ledger_txn 按键计数(L7、L9 断言为 1)和 L8 的响应集合。失败边界有三条:客户端换键;幂等记录被清理后再收到旧键(本章没有过期策略,idem_request 永久保留);幂等表与余额表不在同一个数据库,登记与扣减无法放进一个事务。
进程崩溃与数据库崩溃
L9 用独立 JVM 子进程执行扣减,在第 4 步之后、提交之前停住,父进程确认状态后对它执行 kill -9。
图 27-3:子进程在提交前被杀,同键等待者接手(L9)
sequenceDiagram
participant W as 子 JVM CrashWorker
participant DB as PostgreSQL
participant P as 父进程 等待者
W->>DB: BEGIN, 登记 k-0901 PENDING, 扣 40, 写分录, 写 OK
Note over W: before-commit 处暂停,打印 PAUSED_IN_TX
P->>DB: 查询 pg_stat_activity:idle in transaction;余额仍为 100
P->>DB: 同键 k-0901 扣 40:INSERT 等待
Note over P: 500ms 后仍未返回
Note over W: kill -9,退出码 137
DB-->>DB: 连接断开,后端回滚子进程事务
DB-->>P: INSERT 成功,作为首个请求执行
P->>DB: 扣 40,提交
1 | |
崩溃的进程没有留下半笔交易,也没有留下已提交的 PENDING。等待者 500 毫秒后仍未返回,这一点由用例断言;它等在哪把锁上,用例没有查询 pg_locks,“唯一索引上等待”这一判断依据的是 64.5 节的说明。这个用例还暴露了等待策略的代价:只要持有者事务不结束,等待者就一直占着连接。13.3.4 节提醒,没有死锁时锁等待不会自行超时,生产化时需要 lock_timeout 或改成草案建议的 409。
L10 在一笔扣减提交后,另开一个连接做未提交的扣减 20,然后执行 pg.sh restart-immediate,即以 immediate 模式停机、不做检查点,再启动,由 WAL 恢复:
1 | |
第一行记录了本次的持久性前提。20.5 节说明 synchronous_commit 为 on 时,提交在本地 WAL 刷盘后才返回;设为 off 时,崩溃可能丢失最近“已报告成功”的事务。若丢失的是一笔已向客户端返回 201 的扣减,客户端不会重试,这笔已确认的扣减从数据库中消失,账本内部仍然平衡,本章的对账查询发现不了。只有在客户端恰好用同键重试时,它才会被当作首次请求重新执行,而客户端此前拿到的 txn 编号不再指向任何持久记录。这一段是依据文档的推论,本章没有在 synchronous_commit=off 下做实验。本章只在 fsync=on、synchronous_commit=on 下验证,并且 immediate 停机只模拟数据库进程崩溃,不模拟主机掉电。
对账与恢复流程
对账由 Recon.run 执行四条只读查询:余额列与分录之和比较(I4)、每笔交易的分录和与条数(I3)、请求与交易的一一对应、全体余额之和为 0。四条查询放在同一个 REPEATABLE READ 只读事务里。13.2.2 节说明该级别下事务内所有语句看到同一个快照,对账期间新提交的扣减不会让第一条查询和第四条查询看到不同的数据库状态。L11 是在没有并发写入时运行的,快照一致性这一点没有在并发写入下单独验证。
1 | |
L11 在全部用例跑完后对主库对账,然后绕过账本把 acct-0001 的余额加 5,再对账:
1 | |
负向对照库里,L4 的丢失更新被同样的查询发现(balance acct-0004 stored=93 recomputed=-124),L4 的 32 笔交易没有对应的幂等登记,也被逐条列出。
对账报告问题之后的处理顺序如下。本章代码只实现了检测,下面的修复步骤没有写成程序,也没有实验覆盖:
- 冻结出问题账户的写入,避免修复期间继续偏离。
- 以分录为准。分录是追加写入的事实,余额列是派生值;按
BALANCE_VS_ENTRIES的recomputed回写余额,并记录修复前后的值。 - 交易不平衡或请求与交易不对应时,不修改历史分录,而是追加一笔带说明的调整交易,使账面重新平衡,原记录保留供追查。
- 修复后重跑对账,四条查询全部为空才解除冻结。
对账的失败边界在 L7 已经出现:业务上重复、账本上平衡的扣款,对账查不出。发现这类问题需要业务侧的对照数据,例如兑换订单与扣减交易的一一对应,那是下一层的对账,不在本章范围。
证据与限制
运行入口是 examples/ledger/run.sh,一条命令完成启动 PostgreSQL 16.15 集群、建库、编译、运行 L1 到 L11、写证据、停集群。本次运行退出码为 0,输出末行为 SUMMARY passed=56 failed=0。L1–L8、L11 是真实组件实验,L9、L10 是多进程集成。
这些证据不覆盖以下内容,详细列表见 evidence/27/LIMITS.md:
- 扣减只在 READ COMMITTED 下验证,没有 SERIALIZABLE 与重试循环。
- 响应丢失由服务端主动不写响应来模拟,不是真实网络故障;未覆盖负载均衡器或代理层的自动重试。
- 幂等记录没有过期策略;同键并发采用等待而非草案建议的 409,且没有
lock_timeout。 sys-redeem是所有扣减共享的热点行,未测量排队代价,本章不报告任何性能数据。- 只验证单库;跨库、跨分片的账户转移与本章的事务前提不兼容。
- 修复流程未实现为代码。
三条验收目标在本章的证据形态是:不透支由 L3 的计数和 L4、L5 的对照支撑;不重复扣款由 L7、L9 的“按键计数为 1”、L8 的响应一致与只执行一次,以及 L6、L7 的换键对照支撑;守恒与可重算由 L11 的对账查询和篡改对照支撑。
本批导航
- 上一篇:26 租约互斥与资源端栅栏
- 下一篇:28 稀缺名额的准入与库存预留
- 本批索引:仓库
batch-3/INDEX.md(阅读顺序与运行入口),batch-3/STATUS.md、batch-3/SOURCES.md - 运行入口:
batch-3/examples/ledger/run.sh - 证据目录:
batch-3/evidence/27/ - 本篇工程与证据快照:backend-systems-27.tar.gz(SHA-256 见同目录
backend-systems-27.tar.gz.sha256)。正文中的examples/、evidence/、SOURCES.md路径都相对仓库的batch-3/目录,与快照包内布局一致。






