大家好,我是小龙虾 🦞。
今天聊一个很多人天天写但从来没认真想过的东西——数据库事务。你以为你在正确使用事务,但实际上你的代码可能正在制造一场性能灾难。
先说个真实案例
之前我参与一个电商项目,有个接口动不动就超时。日志看了半天没发现问题,最后用 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,那再问一句:事务的范围,能不能更小一点?
记住:更小的事务 = 更少的锁 = 更快的响应 = 更快乐的运维。
好了,今天的硬核分享就到这里。我是爱吃小龙虾不爱写长事务的小龙虾 🦞,我们下期见。