1. 从一条UPDATE说起:事务的四条铁律到底怎么落地
先说我上周遇到的一个真实情况。业务反馈有个"确认订单"接口偶发超时,查日志发现最底下压着一行Deadlock found when trying to get lock; try restarting transaction。这个接口逻辑并不复杂:先查用户余额,再扣减,然后插入订单流水,最后更新订单状态。就这么四条SQL,按理说应该毫秒级完成,但加上事务和锁之后,事情就复杂了。
初学者总以为事务是一个高深的概念,实际上你每天都在用。你写的每一条UPDATE、DELETE、INSERT语句,只要不是显式开启事务,MySQL 都会给它们包一个隐式事务——autocommit=1时,一条语句就是一个事务,执行完立刻提交。而当你把多条SQL放进BEGIN到COMMIT之间时,它们才真正成为一个可以整体成功、整体失败的逻辑单元。
1.1 一个查询背后发生了什么:begin、commit、rollback 的真实顺序
我们常说事务就是"要么全做,要么全不做"。落地为MySQL命令其实是这样的:
START TRANSACTION; -- 也等同于 BEGIN -- Step 1: 查询余额 SELECT balance FROM account WHERE user_id = 100 FOR UPDATE; -- Step 2: 扣减余额 UPDATE account SET balance = balance - 100 WHERE user_id = 100; -- Step 3: 插入流水 INSERT INTO trade_log (user_id, amount, created_at) VALUES (100, -100, NOW()); COMMIT;如果 Step 2 执行到一半发现余额不足,或者 Step 3 插入失败,你可以直接ROLLBACK。这时之前做的所有修改全部回滚,数据库就像什么都没发生过一样。
但这背后有个关键问题:MySQL是怎么做到“回滚”的?靠的是undo log。每当你修改一行数据,InnoDB 会先把这行数据修改前的样子记到 undo log 里。事务回滚时,用 undo log 里的旧值把数据恢复回去。而redo log则负责持久性——事务提交时,修改结果先写进 redo log(顺序写,很快),即使宕机,重启后也能根据 redo log 重放,保证提交过的数据不丢。
所以ACID四个字母里的A(原子性)靠 undo log,D(持久性)靠 redo log,I(隔离性)靠锁和 MVCC,C(一致性)则靠前面三者的组合来保证——约束不会被破坏,数据最终是自洽的。不要把一致性当成某个具体机制,它更像是一个结果,由原子性、隔离性、持久性共同兜底。
1.2 autocommit:一个被忽略的性能杀手
很多项目的连接池配置里,autocommit默认是打开的。一次普通的查询、一次单行更新,都会自动提交。这本身没问题,但如果你在一个循环里更新一万条数据,每一条都自动提交,那么 MySQL 每执行一条就要fsync一次 redo log,性能损耗非常明显。
我见过一个批处理任务,循环里是这样写的:
for (Record r : records) { update(r); // 每次都自动提交 }跑到一半特别慢,后来改成:
connection.setAutoCommit(false); for (Record r : records) { update(r); } connection.commit();耗时直接降了一个数量级。所以一个很重要的经验是:当你有批量写操作时,一定要关闭 autocommit,手动控制事务边界。但同时也要注意,一个大事务提交时要写大量 undo log 和 redo log,提交瞬间也会有压力和锁的累积。后面专门讲大事务优化时再展开。
2. 隔离级别的四个档位:脏读、不可重复读、幻读是怎么被挡住的
MySQL 的事务隔离级别有四级:读未提交(Read Uncommitted)、读已提交(Read Committed)、可重复读(Repeatable Read)、串行化(Serializable)。这是面试最爱问的,也是实际调优的起点。
但很多人只是背了定义,不理解为什么默认是REPEATABLE READ,更不理解这四档和锁、和 MVCC 的耦合关系。
2.1 四个级别分别解决什么问题
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | InnoDB默认? |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 否 |
| READ COMMITTED | 不会 | 可能 | 可能 | 否 |
| REPEATABLE READ | 不会 | 不会 | 可能(InnoDB下基本能解决) | 是 |
| SERIALIZABLE | 不会 | 不会 | 不会 | 否 |
- 脏读:读到别的事务尚未提交的数据。对方回滚了,你读到的就是脏数据。
- 不可重复读:同一个事务里,同一条SQL读到不同的结果。因为别的并发事务更新并提交了。
- 幻读:同一个事务里,用同样条件查询,结果集行数突然变多或变少。通常由
INSERT或DELETE导致。
2.2 为什么MySQL默认用可重复读,而不是更宽松的读已提交
这其实是个历史包袱。MySQL 5.0 时代,binlog 默认是 statement 格式,它是基于SQL语句记录的日志。在 READ COMMITTED 隔离级别下,如果事务 A 更新了全表,事务 B 插入了新行,那么主库执行顺序和从库回放顺序如果不同步,就容易出现主从数据不一致。而 REPEATABLE READ 搭配间隙锁(Gap Lock),让事务 A 在更新范围时,其他事务无法在这个范围内插入新行,从而保证从库用相同SQL回放时结果一致。
后来有了 row 格式的 binlog,读已提交的主从安全问题其实解决了,但MySQL仍然把默认值保留为REPEATABLE READ,因为惯性。如果你明确知道自己不需要可重复读,而且对并发性能有要求,可以主动改成 READ COMMITTED——它只加记录锁,不加间隙锁,冲突概率更低,很多互联网大厂实际用的就是 RC。
改隔离级别有两种方式:
-- 当前会话生效 SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 全局生效(需要重启确认) SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;也可以直接写在配置文件的[mysqld]段里:
transaction-isolation = READ-COMMITTED如果使用连接池,比如 Spring Boot 的 HikariCP,推荐在连接初始化时设置一下隔离级别,或者直接用@Transactional(isolation = Isolation.READ_COMMITTED)注解在特定方法上覆盖,而不要全局改,免得影响其他业务逻辑。
2.3 实际业务选哪个档位
- 常规后台管理系统、ERP、对账类业务:默认 RR 没问题,省事。
- 高并发交易、秒杀、库存扣减类业务:建议评估使用 RC,减少间隙锁竞争。哪怕只是把默认级别从 RR 改成 RC,某些热更新场景的锁等待时间都可能大幅下降。
- 只读报表、查询类应用:可以设置成 READ UNCOMMITTED 吗?不建议,除非你能容忍读到未提交的数据。更稳妥的做法是保持 RC,配合合理的索引。
我自己的经验是:在没有明确违反业务语义的前提下,优先选择 RC,因为它锁的粒度更细,间隙锁少,死锁概率也低。但注意,如果业务本身依赖可重复读的特性,比如在一个事务里先查数据做校验,再更新,中间不能被其他事务修改,那就不能用 RC,必须用 RR 或显式SELECT FOR UPDATE。
3. 事务与锁:InnoDB的行锁、间隙锁、临键锁到底锁住了什么
锁和事务是分不开的。你开启一个事务执行UPDATE时,如果没有锁,并发更新同一行就会互相覆盖;如果没有锁,RR 隔离级别的可重复读也无法成立。理解锁,才能真正理解事务为什么慢、为什么会死锁、为什么一个SELECT也能锁住别人。
3.1 锁的分类:共享锁与排他锁,意向锁到底是什么
InnoDB 的锁按粒度分:表锁、行锁;按模式分:共享锁(S锁)、排他锁(X锁)、意向共享锁(IS)、意向排他锁(IX)。
一个关键点:意向锁是表级锁,但它的作用是告诉别人“这个表里已经有某些行被上锁了”,方便快速判断表锁是否兼容。
比如事务 A 对表里的某一行加了 X 锁,此时事务 B 想对整个表加 X 锁(比如执行LOCK TABLES t WRITE),如果没有意向锁,B 就要扫描每一行看看有没有冲突。有了 IX 锁,B 一看到表上有 IX 锁,就知道表里可能有行级 X 锁,立刻停止,进入等待。
常用的加锁语句:
-- 加共享锁 SELECT * FROM account WHERE id = 1 LOCK IN SHARE MODE; -- 加排他锁 SELECT * FROM account WHERE id = 1 FOR UPDATE;UPDATE和DELETE会自动对涉及的行加 X 锁。INSERT也会加 X 锁(隐式锁,具体机制我们先不深入)。
3.2 记录锁、间隙锁、临键锁:一次范围查询引发的锁风暴
这是最容易踩坑的地方。假设有张表:
CREATE TABLE `t_order` ( `id` int NOT NULL AUTO_INCREMENT, `order_no` varchar(32) DEFAULT NULL, `user_id` int DEFAULT NULL, `amount` decimal(10,2) DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;数据中有 id=1,4,7,10 四条记录。现在事务 A 执行:
SELECT * FROM t_order WHERE id >= 2 AND id <= 8 FOR UPDATE;RR 隔离级别下,InnoDB 不光会锁住 id=4,7 这两条实际存在的记录,还会在 id 范围里加上间隙锁,比如 (1,4)、(4,7)、(7,10) 这些区间。间隙锁的作用是阻止其他事务在这些区间里插入新记录。为什么?如果不锁间隙,事务 A 在 RR 下执行同一查询两次,第二次就可能多出一条别人插入的 id=5,这就产生了幻读。所以 InnoDB 用间隙锁+记录锁组合成临键锁(Next-Key Lock),把 (1,4]、(4,7]、(7,10] 这样的区间都锁住。
而如果你把隔离级别改成 RC,间隙锁基本消失,只在匹配到的记录上加锁。这也是为什么 RC 并发性能更好的原因——但是代价是可能产生幻读。
实战中,最容易出现的“锁表”问题,就是条件没走索引。比如上面那个查询,如果你写成:
SELECT * FROM t_order WHERE order_no = 'xxx' FOR UPDATE;而 order_no 上没有索引,InnoDB 只能全表扫描,这时它会对扫描到的所有行都加锁,相当于锁住了整张表。其他事务更新任何一条 id 都会被阻塞。这就是热搜里“mysql锁表”最常见的成因。解决办法很简单:给 where 条件字段建立合适的索引,让行锁精确落在目标行上。
3.3 死锁的形成与排查链路
死锁的本质是两个线程互相持有对方需要的资源,且互相等待。看一个最简单的情景:
事务 A:
UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance - 100 WHERE id = 2;事务 B:
UPDATE account SET balance = balance - 100 WHERE id = 2; UPDATE account SET balance = balance - 100 WHERE id = 1;如果 A 先锁 id=1,B 先锁 id=2,然后 A 要 id=2,B 要 id=1,两边都等对方释放,死锁就成了。
排查死锁,MySQL 提供了命令:
SHOW ENGINE INNODB STATUS;在输出里搜索LATEST DETECTED DEADLOCK,你会看到两个事务的 SQL、持有的锁和等待的锁。另外,也可以实时查:
SELECT * FROM information_schema.INNODB_TRX; SELECT * FROM information_schema.INNODB_LOCKS; SELECT * FROM information_schema.INNODB_LOCK_WAITS;上面这几张表会告诉你哪个事务持有什么锁、在等什么锁。曾经有个项目线上死锁频繁,我通过INNODB_TRX查到一条老事务(好几秒都没提交)一直握着某个订单的 X 锁,后面来的大量请求都卡在它后面,最后形成连锁等待。找出元凶后,直接强制杀掉那个连接:
-- 查看 trx_mysql_thread_id 后执行 KILL <thread_id>;很多死锁和长事务是相辅相成的。死锁本质上要求至少持有一些锁,然后又去申请另一个锁。如果事务小、执行快,两个锁的申请时间差就小,死锁概率也小。
3.4 避免死锁的实战建议
- 保持加锁顺序一致:多个表或行时,按固定顺序(比如先 id 小的,或先主表再从表)操作。
- 缩小事务范围:把查询、远程调用、复杂计算放到事务外,事务里只做必要的写操作。
- 使用更细粒度的锁:RC 代替 RR,或用唯一索引让间隙锁失效。
- 设置合理的锁等待超时:
SET innodb_lock_wait_timeout = 5; -- 默认50秒这样即使发生锁等待,也不会让请求卡在那里半分钟,尽早报错回滚,用户体验更好,系统也好自动重试。
另外,对于SELECT FOR UPDATE,要知道它是最容易扩大锁范围的语句。只读查询时不要用,用普通SELECT走 MVCC 快照,不阻塞其他事务。
4. 事务注解:Spring @Transactional 的常见误用与正确姿势
聊完数据库层面,回到开发日常。绝大多数Java项目用@Transactional管理事务,这个注解看着简单,坑却不少,而且往往是在生产环境爆发问题之后才被发现。
4.1 事务传播行为:REQUIRED 和 REQUIRES_NEW 的区别
Spring 默认的传播行为是REQUIRED,意思是:如果当前已经有事务,就直接加入;如果没有,就新建一个事务。
比如订单服务调用库存服务,都开了@Transactional(propagation = Propagation.REQUIRED),整个调用链就是同一个事务。这看起来很好,但有一个坏处:库存更新失败时,订单的插入也会回滚。在微服务架构下,这种跨服务的事务调用往往不是我们想要的,因为一旦把多个服务的数据变更放到一个本地事务里,事务时间会拉得很长,锁范围也大。
而REQUIRES_NEW会挂起当前事务,新开一个独立事务,新事务提交不受外层事务影响。适合的场景是写日志、写流水,即使主体业务失败,日志也应该留下来。
举个例子:
@Transactional(rollbackFor = Exception.class) public void createOrder(OrderDTO dto) { orderMapper.insert(dto.toEntity()); // 这个日志表即使插入失败也不影响主事务 logService.writeLog(dto, LogType.CREATE); }如果 logService.writeLog 用的是 REQUIRED,日志方法一旦抛异常,整个订单插入都会被回滚。如果改成 REQUIRES_NEW,日志方法自己提交,它的异常会向上抛出,但订单不会一起回滚——除非你在代码里显式捕获或抛异常让外部事务感知。这点特别容易混淆。
4.2 自调用失效:同一个类里调另一个 @Transactional 方法
这是掉坑率最高的问题之一。
@Service public class OrderService { @Transactional public void doSomething() { // 自己调用自己的另一个事务方法 updateStock(); } @Transactional(propagation = Propagation.REQUIRES_NEW) public void updateStock() { stockMapper.decrease(); } }doSomething()里的updateStock()调用是不会开启新事务的。因为@Transactional依赖 Spring AOP 代理,同类内部调用走的是this对象,而不是代理对象,所以注解完全失效。对应的解决办法有三种:
- 把
updateStock()拆到另一个 Spring Bean 里,注入后调用; - 在类内部注入自己(不推荐,但能解决);
- 使用
AopContext.currentProxy()获取代理对象再调用,需要配置exposeProxy=true。
我建议第一种,代码最干净,也方便后续做单元测试。
4.3 回滚的坑:为什么抛了 Exception 却不回滚
默认情况下,Spring 只会在抛出RuntimeException或Error时回滚,检查异常(比如IOException)不会触发回滚。这个细节在新手项目里非常常见:
@Transactional public void createOrder() throws IOException { // 业务代码 if (xxx) { throw new IOException("网络错误"); } }如果你的业务逻辑里自定义的是受检异常,或者你捕捉了异常没往外抛,事务都不会回滚。正确做法是:
@Transactional(rollbackFor = Exception.class) public void createOrder() throws Exception { ... }rollbackFor = Exception.class是目前最保险的写法。另外,不要在事务方法内部用 try-catch 把异常吞掉。你吞掉异常后,事务不知道出错了,照样提交。如果确实需要 catch,catch 之后手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()标记回滚。
4.4 大事务与长事务:注解管不住的那些隐患
@Transactional只管事务开启和提交/回滚,管不了事务里代码的执行时间。一个常见错误是在事务里调用远程HTTP接口:
@Transactional public void createOrderAndCallRemote() { orderMapper.insert(...); String result = httpClient.post(remoteUrl, data); // 假设耗时2秒 orderMapper.update(...); }这2秒里,数据库连接一直占用,订单表的锁一直不释放。并发一高,后面的请求全部排队。我处理过一次线上事故:一个大事务里有三次远程调用,每个费时500ms,总共1.5秒,导致事务吞吐量暴跌,数据库连接池被打满。
正确的思路是:事务方法里只做数据库操作,远程调用、消息发送、文件处理放到事务外。如果是先写库再调远程,可以考虑用事务消息或本地消息表来保证最终一致性,而不是硬塞在一个事务里。
5. 分布式事务:从CAP理论到本地消息表、TCC、Seata的现实选择
热搜词里有“分布式事务”、“订单与库存分布式事务”、“分布式事务一致性”,这确实是事务话题里的高光区。单机事务解决的是“一个库里多条SQL”的一致性问题,但微服务时代,订单在订单库,库存在自己库里,怎么保证扣库存失败时订单不创建?
这是一道经典难题。先提一个原则:分布式事务没有银弹,不要试图用一个框架解决所有场景。
5.1 为什么本地事务搞不定跨服务数据
假设下单逻辑是:
- 订单服务插入订单记录(订单库)
- 调用库存服务扣减库存(库存库)
如果两个操作分别在两个数据库里,你不可能靠BEGIN ... COMMIT包住它们,因为 MySQL 的事务只对单一连接上的单库生效。所以必须引入跨库或者跨服务的一致性方案。
5.2 常见方案对比
| 方案 | 核心思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 2PC(两阶段提交) | 准备-提交,协调者统一管理 | 强一致 | 阻塞,性能差,协调者单点 | 极少用 |
| TCC | Try-Confirm-Cancel 三段式 | 灵活,业务介入 | 改造成本高,每个接口都要写Try/Confirm/Cancel | 资金类、对一致性要求极强 |
| 本地消息表 | 业务操作同库写消息表,异步发送 | 简单可靠,最终一致 | 需要额外表,消息可能重复 | 对账、日志、异步解耦 |
| 事务消息(RocketMQ) | 半消息机制,broker确认业务提交后发消息 | 不占用业务数据库性能,最终一致 | 依赖消息中间件 | 主流互联网业务 |
| Seata(AT模式) | 自动化补偿,类似2PC但优化了 | 侵入小,接入快 | 需要独立TC服务,场景限制较多 | 中大型项目快速落地 |
5.3 我实际用过的方案:本地消息表 + 定时任务补偿
之前做一个交易项目,支付成功后需要更新订单状态,同时给用户发放优惠券。这两个操作分属两个服务,而且都在高并发的关键链路上。我们没有上TCC,因为优惠券发放允许最终一致,但一定不能丢。于是用了本地消息表:
在订单库中建一张t_message表,跟订单表放在同一个库里。业务事务里写订单 + 写消息表(状态为待发送),保证两者同库同事务。然后有个后台定时任务扫描待发送消息,通过消息队列或者直接HTTP调用优惠券服务发放,成功后把消息状态改为已发送。
核心伪代码:
BEGIN; INSERT INTO order (id, ...) VALUES (...); INSERT INTO message (msg_id, order_id, status) VALUES (..., 'PENDING'); COMMIT;之后定时任务:
List<Message> pending = messageMapper.selectPending(); for (Message m : pending) { try { couponService.send(m.getOrderId()); messageMapper.markSent(m.getMsgId()); } catch (Exception e) { // 记录错误次数,超时后人工介入 } }这样做的好处是,订单创建和消息落库是同一个本地事务,不会因为库存服务宕机而导致订单数据不一致。坏处是消息表会积压,需要做好重试和监控。注意,消息的消费方必须做幂等,因为重试或消息重复投递是常态。幂等设计通常是在消费方加唯一索引或者先查后插。
5.4 分布式事务不是万能药:如何避免过度设计
并不是所有跨服务操作都需要分布式事务。很多场景只需要“失败后做补偿”或“异步重试”。比如下单时扣优惠券失败,你完全可以先创建订单,然后异步扣券,扣失败再挂起订单、发短信告知用户。引入 Seata 这样的分布式事务框架,通常带来的是额外的网络开销、锁开销,以及分布式事务协调中心的高可用问题。
我的建议是:
- 如果两个操作必须在同一瞬间成功,且失败不允许做补偿,考虑 TCC 或 Seata。
- 如果允许最终一致,优先本地消息表或事务消息。
- 如果只是调用外部服务,且失败后可以人工补偿,最简单的方式就是重试 + 告警。
总之,分布式事务的设计目标不是“强一致”,而是“最终一致加可追踪”。把每条链路的状态字段设计好,重试和人工补偿通道打通,远远比引入重量级框架更靠谱。
6. 高并发下的事务优化:从大事务拆分到减少锁冲突
最后聊一个所有人都关心的实践问题:高并发下,系统越来越慢,数据库监控显示大量锁等待,事务到底该怎么优化。这一节没有任何花哨的理论,全是实打实的踩坑经验。
6.1 大事务的危害:为什么一个事务别超过200ms
大事务指执行时间长、操作数据多的事务。它的危害是立体的:
- 锁持有时间过长,阻塞其他事务,导致吞吐量下降。
- 死锁概率上升,事务持有的锁越多,等待链越复杂。
- undo log膨胀,回滚段变大,影响InnoDB的性能。
- binlog量巨大,主从同步延迟。
- 连接占用时间长,连接池被耗尽,应用直接雪崩。
我们项目里曾经有个凌晨批量任务,一次事务更新几万行,另一个业务在白天运行,按理说不冲突,结果因为这是同一个库里的大表,导致部分查询走全表扫描,把行锁升级成了多个页锁,引发大量锁等待。后来把批量任务改成每500条一个事务,问题瞬间消失。
6.2 常见优化手段:先查再写、分批提交、乐观锁
具体来说,我常用的几条优化策略:
- 缩小事务范围:把事务方法拆小,只把必要的写操作包在事务里。
- 分批提交:批量数据循环插入时,建议每100~500条批量提交一次,而不是一次提交所有。
- 使用索引:确保UPDATE、DELETE、SELECT FOR UPDATE 的WHERE条件走索引。没索引意味着行锁变成表锁级别的风险。
- 减少锁冲突:把更新操作放到事务末尾,前面只做查询;或者变成“先查出来,再执行UPDATE”,尽量缩短锁持有时间。
- 乐观锁代替悲观锁:比如扣库存场景,用
UPDATE stock SET count = count - 1 WHERE sku_id = ? AND count > 0即可,根本不执行 SELECT FOR UPDATE。如果更新影响行数为0,说明库存不够,直接返回失败。这比先查再锁要高效得多,因为锁只在该语句执行期间存在,而不是整个事务里。
6.3 一个扣库存优化实录:从死锁到正常
之前有段代码是这样的:
@Transactional public boolean deductStock(Long skuId, Integer qty) { // 方式1:先查询库存,再更新 Stock stock = stockMapper.selectForUpdate(skuId); if (stock.getCount() < qty) { return false; } stock.setCount(stock.getCount() - qty); stockMapper.updateById(stock); return true; }这里的selectForUpdate用的是SELECT * FROM stock WHERE sku_id = ? FOR UPDATE,会把该 sku 的行锁住。在这个事务提交之前,所有针对相同 sku 的并发请求都会排队。如果上面还嵌套了其他操作,锁时间可能达到几十毫秒,并发500时直接卡死。
优化后:
@Transactional public boolean deductStock(Long skuId, Integer qty) { int updated = stockMapper.deductStock(skuId, qty); // UPDATE stock SET count = count - ? WHERE sku_id = ? AND count >= ? return updated > 0; }deductStock映射SQL:
UPDATE stock SET count = count - #{qty} WHERE sku_id = #{skuId} AND count >= #{qty}因为影响行数为0就代表库存不足,这样不需要先查后改,也不需要在事务期间持有锁等待。这条 UPDATE 本身也是行锁,但执行时间只有毫秒级。实测下来,扣库存接口的吞吐从几十TPS提升到几百TPS,死锁日志基本消失了。
这里有个要点:用乐观锁方式写SQL时,一定要保证原子条件。不要写成先查出来再在内存里判断再update,那样等于退回到悲观锁。条件直接写在 UPDATE 的 WHERE 里,由数据库保证原子性。
6.4 隔离级别、锁与事务性能的平衡
最后,把前面的内容串起来:
- 如果你的业务可以用 RC,就尽量用 RC,减少间隙锁。
- 如果必须用 RR,请注意你的查询条件是否包含范围,范围太大,锁的范围也会大。
- 不要使用 SELECT FOR UPDATE 去读取你只读的数据,把排他锁留给真正要被修改的行。
- 关注慢查询日志和
performance_schema.data_lock_waits,及时发现长事务和锁等待。
我自己在实际项目中的习惯是:每个事务开始前,先在心里过一遍“这个事务会持有哪几把锁、持有多久、会不会和其他事务冲突”。如果你能说清楚这三件事,事务性能问题基本就解决了一大半。
最后分享一个很小的技巧:MySQL 8.0 里可以查sys.innodb_lock_waits视图,快速定位谁在等谁的锁:
SELECT * FROM sys.innodb_lock_waits\G它会直接告诉你阻塞者线程ID、事务ID以及等待时间。相比读SHOW ENGINE INNODB STATUS那段大片英文,这个视图友好太多。有了它,排查线上锁问题的时间能缩短不少。