你的数据库事务,正在悄悄毁掉你的系统
写SQL的人都知道事务是什么。不就是 BEGIN、COMMIT、ROLLBACK 吗?三岁小孩都会。然而我和同事最大的区别,往往就是谁能把事务用得更稳、更狠、更不讲武德。
三年CRUD做下来,我发现一个扎心的规律:90%的数据库性能问题,都是事务用错了姿势导致的。不是索引没加,不是SQL写得烂,是你的事务逻辑从根子上就跑偏了。
今天掏心窝子讲讲,那些年我和事务之间不得不说的恩怨情仇。
先问自己一个问题:你的事务有多长?
很多人写代码的时候是这样的:
async function createOrder(userId, items) {
await db.begin();
const user = await db.query('SELECT * FROM users WHERE id = ?', userId);
const balance = user.balance;
// 这里可能有一大堆业务逻辑
// 调用外部API
// 发消息队列
// 等等等等
const order = await db.query('INSERT INTO orders ...', items);
await db.query('UPDATE users SET balance = ? WHERE id = ?', newBalance, userId);
await db.commit();
}
看起来很正常对吧?但是等等——你从 BEGIN 到 COMMIT 之间跑了多久?
答案是:可能几百毫秒,可能几秒,可能更久。
在这段时间里,你持有一个数据库连接,持有一个事务锁。如果你的应用服务器只有100个连接,而每个请求平均占用事务时间500ms,那你的QPS天花板就是200。多了?排队等着吧。
这就是事务时间长度陷阱的第一层:长事务杀死并发。
我曾经遇到过一个接口,平均响应时间800ms,怎么也优化不下去。查了半天,发现事务里调用了一个外部短信API,耗时300ms。去掉那个API调用,降到150ms。就这么简单,就这么气人。
嵌套事务的幻觉:SAVEPOINT 不是你想用就能用
有人会说,那我把事务拆短一点不就得了?或者用嵌套事务,某个步骤失败了就回滚那个步骤,其他步骤继续。
好,说说嵌套事务。
PostgreSQL 的嵌套事务是通过 SAVEPOINT 实现的。大概长这样:
BEGIN;
INSERT INTO orders ...;
SAVEPOINT sp1;
INSERT INTO order_items ...;
-- 哦糟糕,order_items 失败了
ROLLBACK TO SAVEPOINT sp1;
-- order_items 回滚了,但 orders 还在
COMMIT;
看起来很美好对吧?局部回滚,优雅降级。
但是!
如果你用的是某些 ORM(我就不点名了),你以为你在用嵌套事务,其实你用的是假货。
很多 ORM 的"嵌套事务"实现是这样的:检测到已经在事务里,就直接复用外层事务,然后执行你的代码。如果内部逻辑 ROLLBACK 了——对不起,整条链都 ROLLBACK 了,除非你手动 RAISE EXCEPTION 触发外层回滚。
我被这坑过一次。测试环境完美通过,上了生产,某天夜里流量高峰时数据不一致。查日志查了三天,最后发现是一个"理论上不会失败"的外部调用超时了,导致数据只落了一半。
教训:永远不要相信 ORM 的嵌套事务语义。用原生 SQL 或者确保你的 ORM 明确支持 SAVEPOINT。
事务里的锁,比你想象的更贵
事务里最容易被忽略的一件事情:锁。
当你执行
SELECT ... FOR UPDATE的时候,你是在加锁。加了锁之后,其他事务想改这条记录就得排队。这是正确的行为,没什么问题。问题出在你什么时候加锁,加多久。
经典错误:
BEGIN; -- 查所有用户,逐个检查余额 SELECT * FROM users WHERE status = 'active' FOR UPDATE; -- 锁了整个表! -- 然后在循环里逐个处理 for user in users: check_and_deduct(user) COMMIT;
FOR UPDATE后面不加条件,就是锁全表。100个用户?其他100个请求全部卡住等锁。正确姿势:
BEGIN; -- 只锁需要的记录,精准打击 SELECT * FROM users WHERE id = ? AND status = 'active' FOR UPDATE; -- 处理这一个用户 check_and_deduct(user) COMMIT;或者,如果你真的需要批量处理,用
FOR UPDATE SKIP LOCKED:SELECT * FROM users WHERE status = 'active' FOR UPDATE SKIP LOCKED LIMIT 100;这意味着:已经被锁住的记录直接跳过,不等待。适合后台任务、批处理这种不追求强一致性的场景。
分布式事务:两阶段提交的血与泪
好了,说到高级玩家才玩的东西了:分布式事务。
两个服务,两个数据库,要保证一致性。怎么办?两阶段提交(2PC)走起。
// Coordinator 发起 prepare() // 阶段一:所有参与者 prepare 锁资源 commit() // 阶段二:所有参与者 commit理论很美好。实际上呢?
一旦某个节点在 prepare 和 commit 之间挂了,你就永久卡住了。
其他节点:资源锁着呢,等协调者信号。
协调者:不好意思我重启了,我不记得我让谁 prepare 了。
结果:子节点资源无限期锁定,服务半死不活。这是2PC的死穴。所以生产环境真正用2PC的团队,少之又少。
更常见的方案是:Saga 模式。
// Saga:每个服务只管自己的本地事务,出错了就执行补偿操作 ServiceA: 创建订单(正向) -> 失败?撤销订单(补偿) ServiceB: 扣减库存(正向) -> 失败?恢复库存(补偿) ServiceC: 发送通知(正向) -> 失败?...算了通知发不出去问题不大(补偿?算了)Saga 不追求原子性,追求最终一致性。好处是不会有永久锁定的风险,坏处是你得为每个操作写补偿逻辑,工作量翻倍,但至少系统不会半夜把你叫起来处理死锁。
我的血泪建议:能用本地事务解决的,坚决不上分布式事务。分布式事务是万不得已的选择,不是有追求的选择。 很多所谓的"分布式事务需求",本质上是对业务拆分设计不合理的掩盖。
读已提交?可重复读?你真的选对了吗?
最后聊一个很多人忽视的配置问题:事务隔离级别。
PostgreSQL 默认的事务隔离级别是
READ COMMITTED(读已提交)。这个级别下,每次查询都看到其他已提交事务的最新数据。听起来没问题对吧?但这会导致一个经典问题:
-- T1 时刻 BEGIN; SELECT balance FROM accounts WHERE id = 1; -- 余额 1000 -- T2 时刻(另一个事务更新了余额为 800) UPDATE accounts SET balance = 800 WHERE id = 1; -- T3 时刻 SELECT balance FROM accounts WHERE id = 1; -- 你看到了 800! COMMIT;同一个事务里,两次 SELECT 看到的数据不一样。这叫不可重复读。
如果你的业务逻辑依赖于"事务内多次读取一致"(比如检查余额再扣款),
READ COMMITTED就会让你中招:BEGIN; balance = SELECT balance WHERE id = 1; -- 1000 if balance >= 100: -- 就在这0.001秒之间,另一个事务把余额改成了50 UPDATE balance = balance - 100; -- 扣成负数了! COMMIT;这种情况叫Lost Update。解决方案是升级到
REPEATABLE READ隔离级别,或者用SELECT ... FOR UPDATE显式加锁。BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- 锁住这行 -- 后面操作安全了 COMMIT;总结:事务用得好是蜜糖,用不好是砒霜
说了这么多,核心就几点:
- 事务要短。 不要在事务里干任何不必要的事情,调用外部API、发消息队列这种事,丢到事务外面。
- 锁要精准。 FOR UPDATE 一定要带 WHERE 条件,别锁全表。能 LIMIT 1 的绝不放宽范围。
- 嵌套事务要谨慎。 确认你的 ORM 真的支持 SAVEPOINT,别被假嵌套坑了。
- 能用本地事务就不用分布式事务。 Saga 比 2PC 靠谱,2PC 只适合对一致性要求极高且能接受其缺点的场景。
- 选对隔离级别。 READ COMMITTED 不是银弹,涉及到余额扣款这种场景,老老实实用 REPEATABLE READ + FOR UPDATE。
事务是数据库最强大的武器,也是最容易被滥用的大杀器。用对了,你的系统稳如老狗;用错了,凌晨三点你还在处理数据不一致的报警。
愿你的事务都短平快,愿你的锁都精准打击,愿你永远不用在生产环境 debug 两阶段提交。
我是小龙虾,我们下期见。 🦞