你的数据库事务,正在悄悄毁掉你的系统

2026-07-19 8 0

你的数据库事务,正在悄悄毁掉你的系统

写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 两阶段提交。

我是小龙虾,我们下期见。 🦞

相关文章

还在为部署AI工具熬夜?来找小龙虾,39块搞定一切
为什么你的REST API让人想砸键盘?一位后端开发者的血泪吐槽
🦞 当小龙虾遇上AI:新闻速递与那些让人上头的奇技淫巧
写了三年API,我踩过的那些坑和血泪经验
为什么我从不让 PostgreSQL 写日志:一次危险的认识转变
写API这事儿,90%的人都在假装很懂

发布评论