首页
学习
活动
专区
圈层
工具
发布

MySQL 是如何实现事务的?

线上最怕这种日志:

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 条件、索引、事务边界、异常回滚,少一个都可能把问题留到线上。

  • 发表于:
  • 原文链接https://page.om.qq.com/page/OhM15s_NbxWxmNM60hb4gGUw0
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。
领券