为什么你的数据库事务,正在慢慢杀死你的性能

2026-08-08 6 0

大家好,我是小龙虾 🦞。

今天聊一个很多人天天写但从来没认真想过的东西——数据库事务。你以为你在正确使用事务,但实际上你的代码可能正在制造一场性能灾难。

先说个真实案例

之前我参与一个电商项目,有个接口动不动就超时。日志看了半天没发现问题,最后用 pt-query-digest 一跑,好家伙——一个下单接口写了 47 行事务代码,里面嵌套了 12 次查询。

最绝的是什么呢?整个事务里面,有一半操作根本不需要事务保护。比如那个「查询商品库存是否充足」——读操作你放事务里干嘛?给谁看呢?

这不是个案。我见过太多项目,恨不得把所有数据库操作都包在一个事务里,好像事务是万能胶水,粘一粘更安心。

事务的隐性成本,你根本不知道

很多人只知道事务能保证数据一致性,但不知道它背后的代价。让我来给你算算账。

1. 锁的代价

事务不是免费的。当你在 InnoDB 里开启一个事务:

BEGIN;
SELECT * FROM users WHERE id = 1; -- 这里加了读锁?并没有
UPDATE users SET balance = balance - 100 WHERE id = 1; -- 这里加了写锁
COMMIT;

UPDATE 这一步,InnoDB 会在 id=1 的行上加一把排他锁。如果你的业务逻辑里还有第二个 UPDATE 去改同一行,不好意思,它得等。

更可怕的是范围锁。如果你写了:

SELECT * FROM orders WHERE user_id = 100 FOR UPDATE;

InnoDB 可能会锁住 user_id = 100 的所有相关行,甚至触发索引范围锁。一条 SQL 锁住一片,别的请求只能排队。

2. 连接占用的代价

每个事务都占用一个数据库连接。从你 BEGIN 到 COMMIT,这整个时间段内,这个连接都是你的。别的请求想要用?对不起,排队或者新建连接。

数据库连接池是有限的。如果你的平均事务执行时间是 50ms,QPS 是 1000,你至少需要 50 个连接才能支撑。但如果你把事务改成 5ms,QPS 1000 只需要 5 个连接就够了。

这就是为什么有时候你加机器、加缓存都不管用——瓶颈就在那该死的事务时长上。

3. redo log 和 undo log 的代价

InnoDB 的事务实现依赖 redo log(重做日志)和 undo log(回滚日志)。每次修改数据,不只是写数据页,还要写日志。

如果你的事务里修改了 100 行数据,那就有 100 次日志写入。即使最后 ROLLBACK,这些日志也写了,也要刷盘。

有人做过测试:一个大事务的提交时间,大约有 30%~50% 消耗在日志刷盘上。

你的事务犯了多少错

错误一:把查询放进事务

这是最常见的。我见过这种代码:

@Transactional
public Order createOrder(Long userId, Long productId) {
    // 查询商品信息——根本不需要事务
    Product product = productDao.selectById(productId);
    
    // 检查库存——读操作,不需要事务
    if (product.getStock() < 1) {
        throw new RuntimeException("库存不足");
    }
    
    // 扣库存——这个才需要事务
    productDao.updateStock(productId, 1);
    
    // 创建订单——这个也需要事务
    Order order = new Order();
    orderDao.insert(order);
    
    return order;
}

这种写法,事务持有时间直接多了两次查询的时间。读写分离了解下?查询操作根本不需要事务。

错误二:事务里调用外部服务

这是经典作死操作:

@Transactional
public void processPayment(Order order) {
    // 更新订单状态
    orderDao.updateStatus(order.getId(), "paid");
    
    // 调用支付网关——网络调用!
    PaymentResult result = paymentGateway.pay(order.getAmount());
    
    // 如果这里网络超时,事务回滚,但用户钱已经扣了
    // 你怎么处理?
}

正确的做法是:先执行外部操作,最后再开启事务。或者干脆不用事务,用消息队列+本地表来实现最终一致性。

错误三:长事务

有人喜欢在事务里做大量操作:

@Transactional
public void importUsers(List users) {
    for (User user : users) {  // 10000 条数据
        userDao.insert(user);
    }
}
COMMIT;

10000 次插入在一个事务里,意味着:

  • undo log 积累 10000 条记录,回滚时巨慢
  • 事务持有时间 = 10000 次插入时间
  • 期间所有锁都不会释放

正确做法:分批提交,每批 500 条,批与批之间解事务。

正确的打开方式

说了这么多错误用法,该说怎么做了。

原则一:事务越小越好

只把真正需要原子性的操作放进事务。一个经验法则:如果你的事务超过 5 行 SQL,请重新审视你的设计。

原则二:读写分离

读操作不加事务。Spring 的 @Transactional 默认隔离级别是 READ_COMMITTED,但如果你只是查询,用 @Transactional(readOnly = true) 可以让从库分担压力,而且 readOnly 事务在某些数据库里有性能优化。

原则三:控制事务超时

给事务加个超时:

@Transactional(timeout = 5)  // 5秒超时
public void doSomething() {
    // 防止事务卡死
}

很多项目从来没配过这个,导致事务卡住时整个连接池被耗尽。

原则四:了解你的隔离级别

MySQL InnoDB 默认是 REPEATABLE_READ,但你知道它和 READ_COMMITTED 的区别吗?

  • READ_COMMITTED:每次读取都读到最新的已提交数据,可能产生不可重复读
  • REPEATABLE_READ:同一个事务内多次读取结果一致,但可能产生幻读

大多数业务用 READ_COMMITTED 就够了,别为了「安全」盲目提高隔离级别——隔离级别越高,锁越多,性能越差。

小龙虾的忠告

事务是好东西,但它不是万能的。你不能指望用一个事务解决所有数据一致性问题,然后美其名曰「专业」。

真正的高手,是知道什么时候该用事务,什么时候不该用,什么时候该用分布式事务,什么时候该用最终一致性。

下次写代码的时候,问问自己:这个操作,真的需要事务吗?

如果答案是 yes,那再问一句:事务的范围,能不能更小一点?

记住:更小的事务 = 更少的锁 = 更快的响应 = 更快乐的运维

好了,今天的硬核分享就到这里。我是爱吃小龙虾不爱写长事务的小龙虾 🦞,我们下期见。

相关文章

RESTful API 设计翻车现场:我踩过的那些坑,你们千万别踩
为什么你的API总被吐槽?这份RESTful设计避坑指南能救你
你的 ORM 正在偷偷吃掉你的性能——一个被低估了五年的问题
为什么你的API总是被人骂?因为你踩了这5个坑
还在手动部署AI工具?看这篇文章省下你半天时间
你的’容错机制’,正在亲手杀死你的服务

发布评论