线上最怕这种日志:
Deadlock found when trying to get lock; try restarting transaction
Lock wait timeout exceeded; try restarting transaction
业务代码看着就两行,一个扣库存,一个写订单。 可 MySQL 这边并不是“执行成功就提交,执行失败就回滚”这么简单。事务能跑起来,背后靠的是 undo log、redo log、MVCC、锁,还有提交时那套日志配合。
先看一个很常见的 Java 写法:
@Service
publicclass OrderPayService {
privatefinal JdbcTemplate jdbcTemplate;
public OrderPayService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional(rollbackFor = Exception.class)
public void pay(long orderId, long userId, int amount) {
int rows = jdbcTemplate.update("""
update account_wallet
set balance = balance - ?
where user_id = ?
and balance >= ?
""", amount, userId, amount);
if (rows != 1) {
thrownew IllegalStateException("余额不足,orderId=" + orderId);
}
jdbcTemplate.update("""
update trade_order
set pay_status = 'PAID',
pay_time = now()
where id = ?
and pay_status = 'WAIT_PAY'
""", orderId);
}
}
这段代码里,@Transactional只是让 Spring 帮你控制连接的提交和回滚。真正干活的是 InnoDB。
事务一开始,MySQL 不会立刻把每个修改都安全落到数据文件里。它会先记几样东西。
第一样是undo log。
比如这条 SQL:
update account_wallet
set balance = balance - 100
where user_id = 7788;
执行前余额是 500。InnoDB 修改数据前,会先记一条反向操作:以后如果要回滚,就把余额改回 500。
所以 rollback 不是魔法,它就是靠 undo log 把数据往回倒。
这也是为什么事务里别塞太多慢操作。你以为只是 Java 方法执行久一点,数据库那边其实一直挂着版本、锁、undo 记录。
我一般看到这种代码会先皱眉:
@Transactional(rollbackFor = Exception.class)
public void importBill(List<BillRow> rows) {
for (BillRow row : rows) {
billRepository.save(row);
}
remoteCheckClient.check(rows.size());
}
数据库事务里夹一个远程调用,挺危险。远程接口一卡,事务就跟着挂住,锁也不放。线上再来几个并发更新,慢慢就能看到 lock wait。
第二样是redo log。
undo log 管回滚,redo log 管崩溃恢复。
事务提交时,MySQL 要保证一件事:你告诉客户端提交成功了,机器突然掉电,重启后这笔数据不能丢。
如果每次提交都直接刷完整数据页到磁盘,性能会很难看。所以 InnoDB 先把“我改了哪个页、改了什么”写进 redo log。重启时如果发现数据页还没来得及刷盘,就拿 redo log 重放一遍。
这个地方可以简单理解成: 数据页可以慢慢刷,redo log 得先记住。
第三样是MVCC。
这个东西平时不报错,但它决定了你读到什么数据。
比如事务 A 更新一行还没提交,事务 B 普通查询这行数据。B 不会傻等 A 提交,它会根据 undo log 里的历史版本,读到一个“当时应该看到的版本”。
所以在默认的 RR 隔离级别下,经常会出现这种现象:
-- 事务 A
begin;
update trade_order set amount = 200 where id = 10;
-- 事务 B
begin;
select amount from trade_order where id = 10;
事务 B 读到的可能还是旧值。 这不是 MySQL 抽风,是一致性读。
但你加锁读就不一样了:
select amount
from trade_order
where id = 10
for update;
这时候它要拿锁,拿不到就等。 排查并发问题时,我一般会先把普通select和select ... for update分清楚,很多人就是在这里误判了。
第四样是锁。
事务里只要更新数据,InnoDB 就得加锁。主键更新通常比较直,怕就怕条件没走索引。
这条 SQL 看着没什么:
update trade_order
set pay_status = 'CLOSED'
where out_trade_no = ?;
如果out_trade_no没索引,问题就大了。InnoDB 可能要扫很多行,锁范围也会变得很难看。最后业务表现就是:明明只关一单,别的订单更新也开始卡。
现场可以先看:
show engine innodb status;
或者查正在等锁的事务:
select trx_id, trx_state, trx_started, trx_query
from information_schema.innodb_trx;
别一上来就怀疑 Java 线程池。事务类问题,先看 SQL、索引、锁等待,顺序别反。
还有一个经常被忽略的点:MySQL 提交事务时,不只有 InnoDB 自己的 redo log。只要你开了 binlog,事务提交还要和 binlog 配合。
大致过程是这样:
redo log prepare
写 binlog
redo log commit
这个两阶段提交是为了保证主从复制和崩溃恢复别打架。否则可能出现一种很恶心的情况:InnoDB 里事务提交了,但 binlog 没写完整,从库永远不知道这次修改。
事务能保证一致性,不代表业务一定一致。像扣库存这种逻辑,SQL 条件必须自己兜住:
int changed = jdbcTemplate.update("""
update sku_stock
set available = available - ?
where sku_id = ?
and available >= ?
""", count, skuId, count);
if (changed != 1) {
throw new IllegalStateException("库存不足,skuId=" + skuId);
}
不要先查库存,再在 Java 里判断,再 update。并发一上来,这种写法很容易翻车。
MySQL 实现事务,靠的不是某一个机制。 undo log 让它能回滚,redo log 让它崩溃后能恢复,MVCC 让读写尽量别互相堵死,锁负责处理真正冲突的修改,binlog 和 redo log 配合保证提交结果能同步出去。
写业务代码时记住一点就够了:事务不是保险箱。 SQL 条件、索引、事务边界、异常回滚,少一个都可能把问题留到线上。