一次冲动成交 = 三条 SQL:我的账本没有事务边界¶
「给个人账本引擎挑刺」系列 · 第 2/5 篇。SQLite 只保证单语句原子性。三条写语句之间的崩溃窗口,吃掉了三分之二的冲动漏损,而且一声不吭。
TL;DR "冲动成交并记账"是三条无事务的写语句;模拟崩溃窗口后,系统记得这笔购买、账本却看不见这笔钱,漏损 KPI 静默失真三分之二。这篇讲两个误解如何叠加出这个洞,以及为什么修一处就能三端受益。
账本工具最怕的不是记错账,是"以为记了"。
现象¶
"冲动成交并记账"在代码里是三条独立的写语句:
① UPDATE impulses SET status=?, resolved_at=? ... -- src/impulse.rs:152
② INSERT INTO txs(...) -- src/impulse.rs:166(经 ledger::tx_add)
③ UPDATE impulses SET tx_id=? WHERE id=? -- src/impulse.rs:179
每两条之间都是一个崩溃窗口。全仓库 grep 不到一个 transaction / BEGIN——tx_delete(src/ledger.rs:124)同样是"先解链、再删除"的两步舞。
我一开始的错误判断:把完整性等同于并发安全¶
这个判断也有物证:src/db.rs:61 里,我专门设置了 PRAGMA journal_mode=WAL 和 busy_timeout——为"两个进程同时写"做了防御,却从来没有为"一个进程按顺序写三步"做任何防御。一个会想到 WAL 的人不是不知道事务,是把"完整性"整个等同于"并发安全"了。
剩下的一半判断写在架构原则里:"账本只追加"被当成了完整性本身。它防篡改,但防不了派生链接断裂——这是后来用实验才看清楚的。
排查与关键证据¶
证据一:崩溃窗口的代价是静默的。 复现(合成数据,脚本 docs/drafts/evidence/run-experiments.sh 的 G1 段):
- 登记一条冲动;
- 手工执行①号 UPDATE(与代码逐字同款 SQL),然后"进程死亡"——②③永不执行;
- 看系统自己怎么说:
-- 数据库状态:买是买了,钱没进账
1|演示耳机|bought|2026-11-04T12:00:00Z|2026-11-07| ← tx_id 为空
review impulse 1 输出:行为 15/100「冲动成交:没熬过冷却期就下单了」——系统记得这次购买。但月报里:
冲动 漏损 ¥399.00(占支出 11.7%)
对照组里正常成交的那条 ¥399 计入了;崩溃窗口里的 ¥1299 呢?漏损本应是 ¥1698、占支出 36.1%,账本只看见 ¥399、11.7%——三分之二的冲动漏损被窗口吃掉,且没有任何报错。
证据二:污染为什么静默。 漏损率的 SQL 是 txs JOIN impulses ON t.impulse_id = i.id(src/ledger.rs:215)。JOIN 无声地跳过断链行——脏数据不会让查询失败,只会让 KPI 慢慢失真。而这个 KPI 恰好是整个产品的存在理由:"把少买变成可测量"。
证据三:修一处,三端受益。 CLI 与 Web 中枢(src/server.rs:166)调用的是同一个 impulse::resolve——洞在共享内核里,不在某个入口。这是这个架构唯一的好消息:补事务边界只需要补一次,未来硬件的 POST /api/impulse 也自动继承。
根因¶
两个误解叠加:
- 把"账本只追加"当成了完整性的全部来源。 只追加防的是篡改,防不了派生链接的断裂——
txs.impulse_id与impulses.tx_id是一对需要成对维护的双向指针,任何单边更新都是脏状态。 - 把"单机单用户"当成了免事务的理由。 崩溃窗口与并发无关:一台机器、一个用户、一次断电,就足够留下"已买但无流水"的账。WAL 和 busy_timeout(src/db.rs:61)解决的是并发访问,不是跨语句原子性——SQLite 只保证单语句原子。
约束与取舍¶
- 备选 A:显式 BEGIN/COMMIT 包住三条写(rusqlite 需手动管理事务生命周期与回滚路径)。代价:错误处理路径变复杂。未做。
- 备选 B:冲动与流水合并单表。代价:语义与查询全乱。已否决。
- 备选 C:事件溯源式只追加审计。代价:重写存储层,超出 v1 范围。已推迟。
- 现状:顺序写 + 出事后靠用户手删。 付出的代价是:完整性靠约定,不靠机制。
可复用检查清单¶
- 盘点所有"大于 1 条写语句"的业务流程,逐条标注有无事务包裹;没有的,写明"为什么可以没有"。
- 每个 KPI 的来源表清单写在 KPI 定义旁边;跨表 JOIN 出来的 KPI 优先级最高——它们对断链零抵抗。
- 双向指针(A→B 且 B→A)必须定义成对维护规则:谁先写、失败怎么回滚、删除顺序是什么。
- 给最脏的流程写一个失败注入测试:人为断在第 i 步,断言"要么全成,要么全无"。
- 错误信息必须带"哪一步失败"——否则崩溃后的残局连人工修复都无从下手。
证据与复现¶
G1 实验全部输出见仓库 docs/drafts/evidence/g1-*.txt,一键重跑:bash docs/drafts/evidence/run-experiments.sh。实验为合成数据,不含任何真实账本。
本文是「给个人账本引擎挑刺」系列第 2/5 篇:①纯函数内核的污染 → ②没有事务的账本 → ③"72 小时冷却"的真相 → ④三种"没数据" → ⑤让政策会输的测试