数据库API设计:事务边界在代码中如何控制。
很多团队在数据库访问层封装得很漂亮:Repository、DAO、ORM 一应俱全,但一遇到“转账”“下单扣库存”这种需要原子性的场景,事务边界就开始打架。有人把 `BEGIN` 写在 DAO 里,有人让 Service 持有 Connection,还有人靠框架注解一层层透传。结果就是:小操作没问题,一组合就出现部分提交、连接泄漏、锁等待。数据库 API 设计里,事务边界不是语法问题,而是职责划分问题。
事务边界应该由业务用例决定
一个事务对应一个业务不变量。比如“创建订单并扣减库存并写流水”是一个用例,边界就应画在 Service/Application 层。Repository 只负责持久化,不负责提交;这样组合多个 Repository 时,仍然是一个原子操作。反之,如果 DAO 内部自行 `commit`,上层就失去了组合能力,事务边界被切碎,后面只能靠分布式事务或补偿去补锅。
三种常见放置方式
第一种,DAO 内提交。适合单条 SQL 的 CRUD,简单直接,但无法组合,一旦业务需要跨表跨仓储就会失控。
第二种,Service 方法注解。最常见,`@Transactional` 包住用例。它足够省心,但要注意自调用失效、private 方法不生效、默认只对运行时异常回滚、`REQUIRES_NEW` 会新开连接等问题。别把注解当成银弹。
第三种,显式事务模板或 Unit of Work。用 `TransactionTemplate` 或 `with transaction(): ...` 让用例代码明确 `commit/rollback`。它比注解啰嗦,但边界最清晰,适合复杂流程、跨仓储组合和需要精细控制回滚点的场景。
声明式事务的陷阱
声明式事务最大的风险是“边界被隐藏”。调用方以为只是一个查询,实际却在一个长事务里;或者在事务中调用 HTTP/RPC、发送 MQ。事务未提交时消息已发出,回滚后消息无法撤回,数据与消息就不一致了。正确做法是提交后回调,或用 Outbox 表把事件先落库,再异步投递。
另外,长事务会长时间持有连接和锁,放大死锁概率,拖垮连接池。`REQUIRES_NEW` 也不是随便用的,它可能拿第二条连接,在高并发下把池子耗尽。
API 设计原则
- 让上层决定边界,底层不擅自 `commit`。
- 一个事务只做数据库工作,保持短小。
- 异常必须向上抛,统一由事务边界回滚。
- 重试放在事务外层,且业务必须幂等。
- 明确隔离级别和超时,别用默认值糊弄。
- 异步/多线程不共享事务上下文。
- 分布式场景优先 Saga、TCC、Outbox,不要硬上 XA。
一个最小示例
transactionTemplate.execute(status -> {
orderRepo.insert(order);
stockRepo.decrease(skuId, qty);
outboxRepo.save(event);
return order.id();
});
Repository 内部只有 SQL,没有 `commit`。边界清晰,测试也好 mock。
事务边界是 API 契约的一部分。设计时先问“这个业务用例的不变量是什么”,再决定边界画在哪。别让框架注解替你思考,也别让 DAO 偷偷提交。短事务、显式边界、提交后副作用,基本能避开大多数坑。
转载请注明出处,版权归原作者所有。
管理员



