BodySense 数据库第 11 课:Transaction Boundary 与 ACID——从 Go Service 到 Commit
以 BodySense TransactionManager 和跨 Repository 业务动作学习 ACID、autocommit、commit/rollback、事务边界与 side effect 边界。
[!info] related notes
- 课程 MOC:BodySense 数据库工程深化课程 MOC
- 前置:第 10 课
- 知识:事务、ACID
BodySense 数据库第 11 课:Transaction Boundary 与 ACID——从 Go Service 到 Commit
[!abstract] 本课目标 会说 ACID 不够;必须能在真实业务代码里指出事务从哪里开始、哪些 SQL 共享它、什么时候 commit,以及哪些副作用不在数据库事务里。
从零建立概念:Transaction 不是“多条 SQL 放一起”这么简单
Transaction 可以理解成:
数据库对一组读写提供的一个一致状态转换边界。
假设 BodySense 做一个操作:
create new TreatmentRevision
update Treatment.current_revision_id
write audit/decision trace
如果只完成第一步:
revision exists
but current pointer still old
数据库可能进入业务上不允许的中间状态。
所以我们希望:
before state
↓
all required DB changes
↓
commit
↓
after state
要么:
after state exists completely
要么:
none of these changes become committed
Autocommit:即使你没写 BEGIN,也不是“没有 Transaction”
PostgreSQL 中,显式 transaction:
BEGIN;
UPDATE ...;
INSERT ...;
COMMIT;
如果不显式 BEGIN:
UPDATE users SET ...;
这条 statement 仍然运行在自己的单语句 transaction 中。
所以:
no explicit transaction block
≠
database has no transaction semantics
区别是:
多条 statement 是否共享同一个 transaction boundary。
BEGIN / COMMIT / ROLLBACK
BEGIN
开始显式 transaction。
COMMIT
告诉数据库:
我希望这个 transaction 的修改正式成为可见的 committed state。
Commit 也可能失败。
所以 application 不应在 Commit() 返回 error 后仍假设结果成功。
ROLLBACK
放弃这个 transaction 尚未 commit 的修改。
Rollback 不是“撤销任何外部世界副作用”。
它只作用在这个数据库 transaction 范围内。
ACID 从零讲
A — Atomicity
transaction changes
→ commit as a unit
or
→ rollback / abort
对外不应该只提交其中一半。
注意:
Atomicity 是 transaction 的提交边界,不代表“代码永远不会抛错”。
C — Consistency
最容易被讲错的一项。
Consistency 不是:
PostgreSQL 自动知道所有业务规则。
更准确:
Transaction 从一个满足数据库/业务 invariant 的状态出发,在正确的 application logic + constraints 下,应该到达另一个满足 invariant 的状态。
数据库能直接帮助的:
PK
FK
UNIQUE
CHECK
NOT NULL
transaction
业务没有表达给数据库的规则,DB 不会读心。
I — Isolation
多个 transaction 同时执行时:
彼此能看见什么?
冲突时等待还是失败?
结果能否等价成某种串行顺序?
下一课专门展开。
D — Durability
成功 Commit 后:
即使进程/机器随后 crash,数据库也应能根据持久化机制恢复已提交状态。
PostgreSQL 主要依赖 WAL 等机制,第 16 课展开。
Transaction Scope 应该由业务 Invariant 决定
错误思路:
一个函数
→ 一个 Transaction
或:
每个 Repository method
→ 各开自己的 Transaction
正确问法:
哪些数据库变化必须一起成立,业务 invariant 才不被破坏?
例如:
Create User
单表 insert
→ 单 statement transaction 也许足够
另一个:
Accept Treatment
├─ mark proposal accepted
├─ create immutable revision
├─ move current pointer
└─ write transition record
可能需要更大的 Application transaction。
Why Service Owns Transaction Boundary
Repository 通常只知道:
怎样读/写自己的数据
Service / Use Case 才知道:
这次业务动作包含哪些 Repository
哪些变化必须一起 commit
因此 BodySense:
TransactionManager.WithinTransaction
→ Service establishes tx
Repository
→ FromContext joins current tx
是一种很清晰的 ownership。
一个非常危险的错误:Transaction 内外混用 DB Handle
如果:
tx 已开始
Repository A uses tx
Repository B accidentally uses root db
Repository B 的 SQL 可能在另一个 connection / transaction执行。
结果:
A rollback
B already committed
这就是 FromContext(ctx, fallback) 设计要系统性避免的问题。
Go 官方 database/sql 也明确提醒:transaction 中不要混用普通 sql.DB 方法来完成本应在 Tx 内的操作。
External Side Effect 与 Transaction Boundary
最常见陷阱:
BEGIN
INSERT order
call payment API
COMMIT
情况:
payment API success
COMMIT fails
数据库可以 rollback INSERT,
但:
credit card charge
不会因为 PostgreSQL rollback 自动消失。
同理:
send email
publish Kafka
write Redis
upload object
call LLM/tool
都不是本地 PostgreSQL ACID transaction 的一部分。
跨系统一致性的几个方向
Outbox
DB business write
+ outbox event
→ same DB transaction
→ relay later publishes
Idempotency
重复 side effect 请求使用同一 business identity,避免真正重复执行。
Saga / Compensation
多个独立系统的步骤分别 commit,失败后执行补偿动作,而不是假装有一个巨大的跨系统 rollback。
这些不是本课全部展开,但必须先知道:
本地 ACID 的边界在哪里,才知道什么时候需要分布式一致性协议。
Transaction 越大越安全吗?
不是。
Transaction 过长会:
hold connection longer
hold locks longer
keep snapshot alive
increase contention
hurt VACUUM
increase deadlock window
所以目标是:
足够大以保护 invariant,又尽量小以降低并发和资源成本。
1. Transaction 是状态转换边界
例如一个业务动作:
创建 Treatment revision
→ 更新 current pointer
→ 写 DecisionTrace
如果这些事实必须同时成立或同时不成立,它们需要共享一个 transaction boundary。
2. ACID 重新放回工程语境
Atomicity → 一组 DB change commit or rollback
Consistency → transaction 前后数据库 invariant 成立
Isolation → 并发事务的可见性/冲突语义
Durability → commit 后 crash 仍可恢复
Consistency 不是数据库自动理解业务;它依赖 constraint、transaction logic 和并发策略。
3. BodySense TransactionManager
Service
→ WithinTransaction(ctx, fn)
→ tx 放入 context
→ Repository FromContext(ctx, fallback)
→ 多个 repository 共享同一个 *gorm.DB tx
这个设计把 application-level transaction ownership 与 repository SQL ownership 分开。
4. External Side Effect 不会自动加入 ACID
事务里:
INSERT DB
→ call external HTTP
→ COMMIT
如果外部调用成功但 COMMIT 失败,数据库 rollback 并不会让外部世界自动 rollback。跨系统一致性需要 Outbox/Saga/idempotency 等另外的协议。
Prediction
[!question]
- 两个 repository 方法分别成功 commit,但业务要求要么都成功要么都失败,问题在哪?
- 事务里先写 DB,再发邮件,邮件成功后 DB commit 失败,ACID 能回滚邮件吗?
- 所有 service 方法都套 transaction 是否更安全?
- Transaction 很长但几乎不写数据,为什么仍可能有代价?
- BodySense 的
WithinTransaction + FromContext主要解决什么?
[!success]- Answer / 解析 1. Transaction boundary 放错了;它应该包住业务原子动作,而不是各 repository 自行 commit。
2. 不能。 ACID 只覆盖该数据库事务,外部 side effect 需要独立一致性协议。
3. 不一定。 不需要原子组合的单查询套长事务只会增加连接占用、snapshot/lock 时间和复杂度。
4. 它持续占用连接和 snapshot,可能延迟 vacuum 清理旧 tuple,也可能持有锁。
5. 让 Application Service 能建立一个事务并让多个 repository 自动参与同一个 tx,而不是各自提交。
Lab
Trace 一个真实 BodySense Service:标出 transaction begin、每个 repository call、SQL mutation、commit/rollback。再列出函数里任何不受 DB transaction 保护的 side effect。
L4 Gate
给定一个新业务流程,能先写 invariant,再据此决定 transaction boundary,而不是按函数层级机械加 .Transaction()。