在MySQL的InnoDB引擎中,行锁是按需加在索引记录上的,而且只有在事务结束也就是执行commit或者rollback之后才会释放。很多慢接口和偶发死锁,并不是因为更新语句本身慢,而是因为在事务开启以后,先跑了一段复杂查询或者远程调用,导致本该很快释放的行锁被硬生生拖住。理解这一点,就能明白为什么把耗时查询移出事务逻辑是减少锁持有时间的直接手段。

一、行锁的持有与释放机制
InnoDB采用的是两阶段锁协议。在事务执行过程中,凡是涉及到更新、删除、锁定读(比如select for update)的操作,都会对命中的行加上排他锁或共享锁。这些锁不会在语句执行完就放掉,而是要一直等到事务整体提交或回滚。这就意味着,事务里哪怕只有一条update,只要在它前面还有一段跑了八百毫秒的select统计,那这八百毫秒里占着的锁资源都不能给别的事务用。
从并发角度看,锁持有时间越长,其他会话等待的概率就越高。当多个事务以不同顺序去争用同一批行锁时,还可能形成循环等待,触发死锁检测并回滚其中一个事务。所以控制事务体积极小、让加锁操作尽量靠后,是写并发系统的基本要求。耗时查询如果跟后面的写操作没有数据依赖,就没有理由留在事务里。
二、事务内查询与事务外查询的对比
我们先看一个常见但容易出问题的写法:在事务里先查统计数据,再根据结果更新账户余额。下面这段代码把三条查询和后续更新放在同一个事务中。
// 事务内包含耗时查询的反例
@Transactional
public void settleAccount(Long userId) {
// 耗时查询:统计近30天订单
int orderCount = orderMapper.countByUser(userId);
// 耗时查询:统计退款金额
BigDecimal refund = refundMapper.sumByUser(userId);
// 耗时查询:拉取用户等级信息
UserLevel level = userMapper.selectLevel(userId);
// 以下更新才会加行锁,但前面查询已让事务开启很久
Account account = accountMapper.selectForUpdate(userId);
account.setBalance(account.getBalance()
.add(level.getReward())
.subtract(refund));
accountMapper.updateById(account);
}
上面的方法一旦被高频调用,account表里的那一行就会被长时间锁住。其他要改同一个用户余额的请求只能排队。如果统计查询因为没走索引而变慢,情况会更糟。
改进思路很直接:把不需要一致性视图、也不参与写决策的查询挪到事务外面。只有真正要更新的那一步才开事务。改写后如下。
// 耗时查询移出事务的写法
public void settleAccount(Long userId) {
// 在事务外执行耗时查询
int orderCount = orderMapper.countByUser(userId);
BigDecimal refund = refundMapper.sumByUser(userId);
UserLevel level = userMapper.selectLevel(userId);
// 仅把必须的更新包在事务里
accountService.updateBalance(userId, level.getReward(), refund);
}
@Transactional
public void updateBalance(Long userId, BigDecimal reward, BigDecimal refund) {
Account account = accountMapper.selectForUpdate(userId);
account.setBalance(account.getBalance()
.add(reward)
.subtract(refund));
accountMapper.updateById(account);
}
这样行锁的持有时间从原来的“查询耗时加更新耗时”缩减为只有select for update和update本身的时间。在压测中,这种拆分往往能把锁等待超时率降一个数量级。需要注意的是,移出去的查询读到的可能不是最新已提交数据,如果业务要求严格基于当前余额做计算,那部分数据还是要在事务内用for update读。
三、哪些查询可以安全移出事务
判断标准只有一个:这条查询的结果会不会影响事务内写操作的正确性,以及能不能接受轻微延迟的一致性问题。像配置表、历史统计、用户静态画像这类变化不频繁的数据,基本都可以提前查好传进事务。而像账户余额、库存余量、订单状态这种强一致字段,必须在事务内加锁读。
还有一个容易忽略的点是外部HTTP调用。有些逻辑会在事务里调用风控接口,这一等就是几百毫秒,锁全捂在手里。正确做法是先在事务外完成风控校验,拿到通过标记后再开事务做扣减。如果风控结果有时效性,可以在事务内只做一个轻量状态核对,而不是完整远程调用。
四、拆分时的注意事项
第一,不要为了移出查询而把本该原子化的操作拆成两个事务,那样会破坏一致性。移出的只能是“只读且不影响核心决策”的部分。第二,事务内尽量让加锁语句排在最前面,也就是先select for update再写,减少锁持有链路。第三,给所有查询加上合适索引,避免从行锁退化为表锁。
下面用一张简表总结两种写法差异:
| 维度 | 查询在事务内 | 查询移出事务 |
|---|---|---|
| 行锁持有时间 | 查询耗时加写耗时 | 仅写耗时 |
| 并发冲突概率 | 高 | 低 |
| 数据一致性 | 强 | 非核心数据弱一致 |
| 适用场景 | 强一致写依赖 | 统计类前置读 |
把耗时查询移出事务并不是银弹,但它是成本极低、收益明显的常规优化。只要理清数据依赖边界,就能在几乎不改业务逻辑的情况下,让MySQL行锁的窗口大幅缩短,系统在高并发下也更平稳。