数据库事务:那些容易忽略的边界情况

作者:晚风信箱 发布时间: 2026-03-18 阅读量:48 评论数:0

事务这东西,学的时候觉得简单:开始、提交、出错回滚。真在生产环境用起来,坑全在那些教科书不写的地方。下面这些是我这些年实际遇到过的,按被坑的严重程度排序。

一、在事务里调外部接口

这是我心目中的头号杀手。代码看起来非常自然:开启事务、更新本地状态、通知下游、提交。

问题是这个通知走的是网络。某天下游变慢,单次调用从 80 毫秒涨到 25 秒(超时设置忘了配,用的默认值)。于是每一个事务都持有连接和行锁 25 秒。连接池 50 个连接,两秒钟就被占满,整个服务的所有数据库操作全线阻塞——包括那些跟这个功能毫无关系的接口。

一个下游的抖动,通过事务这个放大器,变成了全服务不可用。

规则很硬:事务里只能有数据库操作。任何网络调用、文件读写、耗时计算,都必须挪到事务之外。

二、事务里发消息

上一条的变体,但危害方式不同。消息发出去了,然后事务因为后续某个操作失败回滚了。数据库里没有这条记录,但消息已经在队列里,下游收到后去查,查不到。

更隐蔽的是相反的顺序:先提交事务再发消息,中间进程挂了,消息永远发不出去,数据库和下游状态不一致。

这个问题没有免费的解法。我们最后用的是本地消息表:在同一个事务里往一张待发送表插一条记录,事务提交后由一个独立的任务扫这张表去发送,发送成功标记已发。牺牲一点实时性,换取不丢。

三、自调用导致事务声明失效

类里的方法 A 调用同一个类的方法 B,B 上标了事务注解。很多人以为 B 会开事务,实际上不会——因为事务是靠代理实现的,自调用走的是原始对象的方法,代理根本没参与。

这个坑最恶心的地方在于它不报错。代码正常跑,测试也过,只是在需要回滚的时候不回滚。我们发现它是因为一次异常之后数据处于半更新状态,查了很久。

四、异常被吞了,事务不回滚

方法里写了 try catch,捕获之后打了日志继续往下走。框架看到方法正常返回,就提交了事务。前半段的修改被持久化,后半段没执行。

还有个相关的默认行为要注意:很多框架默认只对运行时异常回滚,对受检异常是提交的。如果你自定义了一个受检的业务异常,抛出去以为会回滚,其实不会。

五、长事务的连锁反应

一个事务里更新了三万条记录,跑了 40 秒。这 40 秒里它持有三万行的锁,任何要动这些行的请求全部排队。同时数据库的回滚段一直在增长,其他的一致性读也变慢。

批量操作我现在一律分批,每批一两千条,每批一个独立事务,批与批之间有间隔。虽然失去了整体原子性,但配合幂等和进度记录,实际可用性高得多。

六、快照读和当前读不一致

在可重复读隔离级别下,普通的查询读的是事务开始时的快照,而带加锁语句的查询读的是最新数据。

于是你可能写出这样的代码:先普通查询判断余额够不够,够就扣减。查询看到的是旧快照(余额 100),实际当前值已经被别人改成 10 了。判断通过,扣减执行,余额变成负数。

正确做法是判断和更新用同一条语句:更新时带上条件,用受影响的行数判断是否成功。把判断交给数据库,不要在应用层判断完再更新。

七、更新顺序不一致导致死锁

两个任务都要更新同一批记录,一个按 ID 升序,一个按 ID 降序。跑起来必然互相持有对方需要的锁。

我们线上出过一次,每天固定在凌晨两点多报几十条死锁日志。查了两周才定位到是两个定时任务撞在一起。修复方案简单得让人生气:在两个任务里都加上按主键排序,死锁再没出现过。

八、事务里读到自己未提交的修改

这个不算 bug,是正常行为,但容易写出逻辑错误。事务里先插入了一条记录,后面又做了个统计查询,统计结果包含了这条还没提交的记录。如果你的逻辑假设统计的是提交态的数据,就会算错。

一句总结

我现在写涉及事务的代码,会先在脑子里问三个问题:这个事务会持续多久?它锁住了什么?如果中途进程被杀掉,数据会停在什么状态?第三个问题最有价值,因为它逼你去想那些你不想想的场景。

评论