PostgreSQL 双分录账本:并发扣款、冻结冲正与故障验证¶
30 秒版(开场)¶
资金账本用不可变 journal/entries 保存事实,账户余额是同事务维护、可重建的投影。每笔转账在同一币种下两条有符号分录净额为零;锁定账户、验证余额、写分录和 Outbox 必须原子提交。重试绑定完整业务意图,冲正追加反向事实。仓库提供真实 PostgreSQL 并发、连接终止与差异修复演练。
3 分钟版(精讲深度)¶
- 金额表示:例子以原子单位
bigint记账,按币种管理精度,不使用浮点;USDC 与 BTC 不可相加证明守恒。 - 交易边界:唯一业务 ID + intent 绑定;按账户 ID 排序加行锁,条件扣减防负余额,同时写 journal、entries、余额和 outbox。
- 恢复边界:未提交故障全部回滚;已提交但响应丢失时,用同业务 ID 查询/重试。冲正只新增事实,余额差异先核验不可变事实再在受控写入隔离下重建。
10 分钟版(原理 + 图示)¶
sequenceDiagram
participant C as 业务调用
participant DB as PostgreSQL 本地事务
participant MQ as 异步事件投递
C->>DB: transfer(id, from, to, currency, amount)
DB->>DB: 插入意图 / 相同 ID 校验 payload
DB->>DB: 按序锁账户 / 校验币种与余额
DB->>DB: 扣增余额 / 插入两条分录 / 插入 Outbox
DB->>DB: 提交时校验 journal 平衡
DB-->>C: 提交结果
DB-->>MQ: 提交后由 relay 投递
| 表或对象 | 职责 | 不变量 |
|---|---|---|
| accounts | 币种、期初快照、当前余额 | 余额非负;币种不可混记;普通应用无直接写权限 |
| journals | ID、完整 intent、reversal_of | ID 重试必须匹配同意图;原事实不可更新/删除 |
| entries | journal、account、currency、delta | 本例每笔恰好两条,按币种 SUM(delta)=0 |
| outbox | 相同本地事务内的待发事件 | journal 未提交时不存在可投递事件 |
| balance_drift | 对比余额与期初 + entries 汇总 | 差异必须可见,不能仅证明分录平衡就声称账户余额正确 |
本例用“来源账户负 delta、目标账户正 delta”表示双边守恒,不是完整会计科目系统。 资产/负债科目、正常借贷方向、手续费多分录、批量过账、多币种兑换需单独建模; 兑换应拆成每个币种各自平衡的分录组。期初快照是显式 fixture,生产期初导入必须有可核验对账证据。
冻结将 alice_available 转入 alice_held;解冻对冻结 journal 追加反向 journal。
这是子账户迁移模型;示例只演示全额释放/全额冲正,不实现部分冻结消耗、部分退款或业务授权服务。
冲正也必须有足够可用资金;已被花掉的收款不能靠强制负余额掩盖,需要催收/应收或其他明确科目。
lab.transfer 与 lab.reverse_transfer 是固定 search_path 的 SECURITY DEFINER 入口。
底层过账函数不授予应用执行权限,表也不授予 DML;避免应用绕过分录直接改余额或伪造 reversal。
这仍然是内部数据库接口,不能直接暴露给用户;租户/账户授权、审批与额度应在可信服务层检查。
管理员能够绕过或破坏约束,演练的误删与漂移注入正是管理员故障,数据库超级用户不是威胁隔离边界。
生产场景¶
- 提现预占、订单冻结与支付转账使用稳定业务键;请求超时不能直接创建新业务 ID 再扣一次款。
- 只在确认 SQLSTATE 和事务结果后重试;死锁/序列化冲突需要完整事务重试,未知提交要先查权威 journal。
- 本例排序加锁降低常见反向转账死锁,不能保证包含其他对象锁的复杂流程永远无死锁。
排查与工具¶
# Python 标准库 + Docker;自动创建并清理独立容器,不连接现有数据库
python3 examples/postgres-reliability/verify.py --only ledger
| 故障注入 | 应观察到的结果 |
|---|---|
| 同业务 ID 并发重试 4 次 | 只扣一次;相同 ID 改金额被拒绝 |
| 余额 100 的账户并发支付两笔 80 | 只允许一笔,剩余 20 |
| 冻结后重复解冻、另一 ID 再次冲正 | 同 ID 幂等;第二次冲正被唯一约束拒绝 |
| 转账函数执行后、COMMIT 前终止连接 | journal、分录、余额、Outbox 全部回滚 |
| 无分录 journal 尝试 COMMIT | 延迟约束拒绝提交 |
| 管理员制造余额 +1 的漂移 | drift 视图检出;隔离写入后从分录重建归零 |
记录 business_id、journal ID、账户、币种、金额、SQLSTATE 和事务耗时;监控余额差异、
锁等待、失败比例与 Outbox 最老年龄。日志不记录敏感用户资料。
架构取舍¶
单库行锁与本地事务适合先建立可靠资金核心;跨库资金移动需要清结算与补偿,不能把本例行锁扩大为跨服务原子性。
将过账逻辑放在数据库便于将约束与事实放在一起,但增加 SQL 迁移、权限与版本测试责任。
Go 服务可调用这些入口;演练通过 psql 操作真实 PostgreSQL,不另加 Go 数据库驱动。
深挖问答¶
- 为什么不只 UPDATE balance? → 缺少可追溯事实、重建依据与按币种守恒核验,也无法解释历史变动。
- 唯一业务键足够吗? → 不够。同键不同金额/账户必须报冲突,不能返回第一次结果并静默忽略新意图。
- 为什么冲正不能 DELETE 原分录? → 会破坏审计与下游已消费事实,应追加引用原 journal 的反向事实。
- 漂移能直接修余额吗? → 必须先核验期初和分录可信、明确差异来源并隔离并发写入;若事实本身错误,应走业务纠错而非投影覆盖。
- 为什么不用分布式锁? → 权威数据就在同一数据库,行锁与约束能直接保护事务;外部租约不能代替数据库原子条件。
反模式与事故¶
- 金额使用 float,或将不同资产数量相加声称账平。
- 在拿到数据库提交结果前发送不可撤销外部事件。
- 超时后换新 ID 重扣,或把未知提交一律标失败。
- 普通应用可以直接 UPDATE 余额,跳过过账入口。
证据边界与相关链接¶
本例在 PostgreSQL 单实例演练中检查守恒、余额投影、权限、并发与回滚;没有实现完整会计、审批、 数据库 HA 或生产资金托管。固定样本通过不代表吞吐能力或所有隔离异常均已覆盖。