☰
MySQL事务实战:从ACID到分布式事务与高并发优化
2026/10/9 3:37:43 网站建设 项目流程

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 避免死锁的实战建议

  1. 保持加锁顺序一致:多个表或行时,按固定顺序(比如先 id 小的,或先主表再从表)操作。
  2. 缩小事务范围:把查询、远程调用、复杂计算放到事务外,事务里只做必要的写操作。
  3. 使用更细粒度的锁:RC 代替 RR,或用唯一索引让间隙锁失效。
  4. 设置合理的锁等待超时:
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 为什么本地事务搞不定跨服务数据

假设下单逻辑是:

  1. 订单服务插入订单记录(订单库)
  2. 调用库存服务扣减库存(库存库)

如果两个操作分别在两个数据库里,你不可能靠BEGIN ... COMMIT包住它们,因为 MySQL 的事务只对单一连接上的单库生效。所以必须引入跨库或者跨服务的一致性方案。

5.2 常见方案对比

方案核心思路优点缺点适用场景
2PC(两阶段提交)准备-提交,协调者统一管理强一致阻塞,性能差,协调者单点极少用
TCCTry-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

大事务指执行时间长、操作数据多的事务。它的危害是立体的:

  1. 锁持有时间过长,阻塞其他事务,导致吞吐量下降。
  2. 死锁概率上升,事务持有的锁越多,等待链越复杂。
  3. undo log膨胀,回滚段变大,影响InnoDB的性能。
  4. binlog量巨大,主从同步延迟。
  5. 连接占用时间长,连接池被耗尽,应用直接雪崩。

我们项目里曾经有个凌晨批量任务,一次事务更新几万行,另一个业务在白天运行,按理说不冲突,结果因为这是同一个库里的大表,导致部分查询走全表扫描,把行锁升级成了多个页锁,引发大量锁等待。后来把批量任务改成每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那段大片英文,这个视图友好太多。有了它,排查线上锁问题的时间能缩短不少。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询