1. 高并发下数据库稳不稳定为什么首先看事务和锁早几年我接手过一个电商项目大促当晚刚过零点订单接口的耗时曲线直接从 50ms 拉到了 3 秒开外。当时第一反应是扛不住流量赶紧加机器、上缓存。结果机器加了三台缓存也挡了一层数据库的 CPU 反而只有 20% 左右但订单表就是卡死库存还能扣成负数。最后翻慢日志才看到真相UPDATE语句本身执行只要 2ms问题出在它前面一直卡着waiting for lock。大量的请求都在等同一行库存的锁一个等一个最后事务堆积、连接池耗尽整个链路才崩掉。这个案例让我彻底改变了对高并发稳定性的理解。很多人一提高并发脑子里全是连接池、读写分离、分库分表、缓存这些性能手段但真正让一个系统在流量洪峰下不乱掉的根基其实是数据库底层的两个东西事务和锁。它们决定了同一时刻有多少请求能安全地改同一份数据以及改坏了之后能不能回滚。如果这个地基没打稳上面加多少缓存和机器都只是把问题往后推。1.1 一个典型的数据错乱现场先说一个最经典的库存案例。很久以前很多业务代码是这么写扣库存的// 伪代码很多新手项目里能见到 int stock selectStockByProductId(pid); if (stock 0) { updateStockByProductId(pid, stock - 1); }两个线程同时读到了stock 1都认为有货然后先后执行扣减最后数据库里的库存变成了-1但两笔订单都创建成功了。这就是典型的并发更新丢失本质上是把读和写拆成了两步中间没有任何机制保证我读到的时候是 1我扣的时候别人不能把它改成 0。加入事务和锁能不能解决很多人以为给方法加个Transactional就万事大吉。实际上如果你还是先SELECT再UPDATE事务确实把两步包在了同一个原子操作里但两个事务仍然可以并发地读到同一个值然后各自去改。默认隔离级别下普通SELECT是快照读它不阻塞别人修改同一行。真正有用的不是事务本身而是你把普通SELECT换成SELECT ... FOR UPDATE当前读或者干脆把判断库存这个动作合并进UPDATE的条件里整个过程才会被行锁保护起来。这个理解特别重要事务管的是要么全成功要么全失败锁管的是同一份数据同一时间只能被一个写入者改。在高并发场景下两者必须配合起来看少一个都不行。1.2 为什么高性能和高并发稳定性是两回事性能是单线程跑得快高并发稳定性是一千个线程同时跑既不乱也不算错。打个比方一条单车道公路一辆车 10 分钟跑完这是性能一百辆车同时上路如果没有红绿灯和交替通行的规则大家堵在路口谁也走不了这是并发控制问题。数据库里的事务和锁就是这套红绿灯在读写没有冲突的时候尽量放行在真正要改同一份数据的时候强制排队。数据库层面的崩溃和错乱通常有两种表现一种是锁等待超时申请锁的时间超过了innodb_lock_wait_timeout默认 50 秒直接报错回滚另一种是死锁两个事务互相握着对方想要的锁InnoDB 检测到之后会主动把其中一个小事务回滚掉释放资源。这两种情况在高并发下都不是小概率事件而是业务模型、SQL 写法、索引设计共同作用下的必然结果。所以后文我会从隔离级别开始讲再到 InnoDB 锁的具体类型最后给一套排查链路和工程优化手段。这套东西不是面试八股是真的能在你凌晨三点被报警吵醒时救命的。2. 事务隔离级别与 MVCC高并发读写模型的地基怎么选2.1 并发读写的三种异常现象在说锁之前必须先把事务隔离级别讲清楚因为隔离级别直接决定了哪些场景需要锁、需要什么类型的锁。很多人在事务上踩坑本质上是不清楚并发同时操作同一份数据时数据库到底允许哪些不一致。三个经典的异常脏读事务 A 修改了一行数据但还没提交事务 B 读到了这行半成品数据。如果 A 最终回滚B 之前基于脏数据做的判断就全废了。不可重复读事务 A 先读了一行数据事务 B 把它改了并提交事务 A 再次读同一行发现值变了。重点在于同一行数据前后不一致。幻读事务 A 按某个条件查出了一批记录事务 B 插入了一条满足条件的新记录并提交事务 A 再次按同样条件查询时结果集里多出了一行幻觉记录。重点在于结果集多了一行。SQL 标准定义了四个隔离级别从松到严分别是读未提交Read Uncommitted、读已提交Read Committed简称 RC、可重复读Repeatable Read简称 RR、串行化Serializable。它们和三种异常的关系大概是这样隔离级别脏读不可重复读幻读READ UNCOMMITTED可能可能可能READ COMMITTED不可能可能可能REPEATABLE READ不可能不可能可能InnoDB 对快照读已消除对当前读靠锁消除SERIALIZABLE不可能不可能不可能注意一个容易误解的点MySQL 的默认隔离级别是 RR但它在 RR 下通过 MVCC 机制已经让普通SELECT不会出现幻读了。换句话讲标准里的RR 存在幻读这个说法放在 MySQL 里要打折理解。后面会细说。2.2 MVCC 是如何工作的以及它和锁的关系MVCC多版本并发控制是 InnoDB 用来提升并发度的一大利器。它的核心思路是读和写互相不阻塞。当一个事务要读一行数据时如果这行正在被另一个事务修改读操作不会阻塞等待而是去读旧版本的数据。这背后有三个关键的底层结构每一行数据都有两个隐藏列事务 ID 和回滚指针。更新一行时InnoDB 会把修改前的旧值写进 undo log然后通过回滚指针串成一条版本链。每个事务在开始读时都会生成一个ReadView里面记录着当前哪些事务还没提交的清单。读到某个版本时拿版本上的事务 ID 和 ReadView 比对决定这个版本对当前事务是否可见。普通SELECT走的是快照读直接用 ReadView 判断可见性不需要加锁UPDATE、DELETE、SELECT ... FOR UPDATE走的是当前读读的是最新版本并且必须加锁防止别人同时改。理解了这条之后很多事就串起来了。比如在默认 RR 隔离级别下一个事务里连续执行两条相同条件的SELECT得到的结果永远一样因为第一次 SELECT 生成的 ReadView 在整个事务期间没有变后面的读都基于同一个快照。这就是 MVCC 解决不可重复读和快照读幻读的方式。但是有一个坑UPDATE是当前读它必须读最新版本并对记录加锁。假设事务 A 执行了一条范围更新把status pending的记录全部改成closed如果这个条件没走索引而是一条范围扫描InnoDB 为了防止其他事务在范围内插入新记录会在这段范围的间隙上加上间隙锁Gap Lock。于是事务 B 想往这个范围插入一条statuspending的新记录时会直接被锁挡住。这也是为什么高并发下你经常会看到一个插入操作莫名卡住慢日志里写的是insert ... waiting for gap lock。2.3 RC 和 RR 在实践中怎么选MySQL 8.0 之前默认隔离级别是 RR互联网公司实际生产环境反而大量用 RC。原因很简单RC 级别下 InnoDB 只做行锁不加间隙锁锁冲突的概率和死锁概率都比 RR 低很多。对绝大多数 OLTP 业务来说RC 已经能满足需求因为它至少规避了最脏的脏读问题。那么为什么还有人坚持用 RR其实是因为历史原因MySQL 在 RR 下做基于语句的复制时需要一致性快照但 5.1 之后的 ROW 格式 binlog 已经绕开了这个问题。如果你用 MySQL 5.7/8.0并且把binlog_format设成ROW完全可以把全局隔离级别改成 RC收益非常直观范围更新不再无谓地锁住一大片间隙死锁率通常能降一个数量级。我在项目里经常碰到的情况是某条 SQL 在 RC 下跑得飞快切到 RR 后频繁出现死锁。查下来多数都是间隙锁在捣乱。所以我的建议是新项目直接评估 RC ROW binlog老项目如果你确实依赖 RR 的快照读语义做报表和统计再考虑保留。隔离级别不是越高越好而是越高并发度越低锁冲突概率越大。3. InnoDB 锁机制拆解记录锁、间隙锁与死锁的真实来源3.1 为什么 InnoDB 默认用行锁但实际还会演化成表锁MyISAM 时代的锁是整表锁一次写操作锁住整张表读读之间可以并行但只要有写操作全表读写全部串行高并发下基本只能读不能写。InnoDB 之所以能支撑起今天互联网的高并发写场景核心就是它把锁的粒度降到了行级理论上不同事务可以同时改不同行互不干扰。但行锁有个前提SQL 必须命中索引。InnoDB 的行锁是通过索引记录实现的它锁的是索引条目不是抽象意义上的物理行。如果你的UPDATE语句没有走到任何索引那 InnoDB 只能全表扫描找出目标行而扫描过程中为了防止其他事务干扰它会对扫描到的每一条记录都加上锁。你以为自己在锁某一行实际上相当于把整张表都锁了。这就是为什么全表更新的 update 语句把业务打挂这种事故几乎每个 DBA 都遇到过。这里还会牵扯出意向锁。InnoDB 是分层加锁的事务要锁某一行必须先对包含这一行的表加意向锁。意向锁分两种意向共享锁IS和意向排他锁IX。它不直接挡住任何行级操作它的作用是让表级操作比如ALTER TABLE、LOCK TABLES能快速判断这张表此刻有没有行级锁被持有避免ALTER TABLE傻乎乎地逐行检查。理解了意向锁你就能看懂为什么 DDL 在业务高峰期执行很容易被堵某个长事务一直握着某张表的 IX 锁DDL 需要等这张表上的 IX/IS 全部释放才继续。锁兼容关系大致如下已持有锁 / 请求锁XSIXISX排他锁冲突冲突冲突冲突S共享锁冲突兼容兼容兼容IX意向排他锁冲突兼容兼容兼容IS意向共享锁冲突兼容兼容兼容记住一个结论排他锁和任何锁都不兼容共享锁只和共享锁/意向锁兼容。行级场景下两个事务可以同时给同一行加 S 锁但其中任何一个想升级成 X 锁都得等对方释放。3.2 记录锁、间隙锁、临键锁到底锁的是什么InnoDB 在 RR 隔离级别下行锁其实细分为三种记录锁Record Lock锁住索引上的一条记录。最简单的单行UPDATE和DELETE就是记录锁。间隙锁Gap Lock锁住两个索引记录之间的空隙防止其他事务在这个间隙里插入新记录。注意它锁的是间隙而不是记录本身所以不同事务可以同时持有同一个间隙上的间隙锁因为它们的目的是防止插入而插入操作本身需要和间隙锁互斥。这也是间隙锁比较容易引发死锁的一个伏笔。临键锁Next-Key Lock记录锁 前面的间隙锁是一个左开右闭区间。它既锁住记录本身也锁住记录前面的空隙。范围查询在 RR 下默认用临键锁目的是防止幻读也就是防止范围内突然多出新的记录。举个具体例子。假设product表里有 id 为 1、5、9 三条记录现在执行UPDATE product SET price price 10 WHERE id 3;在 RR 下InnoDB 会锁住(1, 5]、(5, 9]、(9, ∞)这些区间里的记录和间隙。这意味着其他事务即使想插入一条 id6 的记录也会被阻塞因为它落在了(5, 9]的间隙锁范围内。很多开发者在设计自增 ID 排序 范围更新的业务时经常遇到插入被莫名其妙卡住就是被间隙锁挡住了。而在 RC 隔离级别下间隙锁被取消只有UPDATE/DELETE网络中的记录才有锁所以并发插入要顺畅得多。3.3 我见过的高频死锁场景场景一订单和库存的经典交叉更新。事务 A 先更新订单表再更新库存表事务 B 先更新库存表再更新订单表。两个人各拿了一把钥匙又都在等对方手里的钥匙MySQL 检测到回路之后会选择一个事务回滚。解决这种死锁最有效的手段是统一更新顺序比如约定先订单后库存全链路都改成一致顺序。场景二并发插入引发的间隙锁互等。假设业务里有一个预约记录表每次用户新增预约时会先查一下当天的预约数量-- 事务 A SELECT COUNT(*) FROM booking WHERE business_date 2024-06-18; -- 如果小于上限则插入 INSERT INTO booking ... -- 事务 B 并发执行同样的逻辑在 RR 隔离级别下两个事务同时执行了这条范围查询都会在business_date 2024-06-18对应的间隙上加间隙锁。两个间隙锁本来互不冲突但一旦它们都想插入新记录插入前要申请插入意向锁插入意向锁和间隙锁冲突于是事务 A 等事务 B 的间隙锁事务 B 等事务 A 的间隙锁直接死锁。这类死锁极难排查日志里看锁对象都是一个普通索引范围并不直观。解决办法通常是改成 RC 隔离级别或者在应用层做串行化控制。4. 缓解锁竞争最有效的工程手段缩短事务与改条件更新4.1 把事务拆到业务真正需要的最小单元锁竞争的本质是锁被持有太久。一个事务从开始到提交中间经历了多少条 SQL、多少次网络调用、多少次用户交互这段时间里所有被它碰过的行和间隙都在耗着锁。MySQL 的锁不是用一下随手还而是事务结束才统一释放。我见过最夸张的案例一个订单创建接口事务里面做了会员查询、地址查询、商品查询、优惠券计算、库存扣减、订单插入、支付单创建、积分增加中间还调了一次远程会员服务接口这个接口平均响应时间 800ms遇到超时甚至要 3 秒。等于说整个事务持有库存行锁的时间在 2 秒以上。高并发下一到促销大家都卡在这 2 秒的锁等待里系统不崩才怪。所以优化的第一个原则很简单事务能多短就多短。只把需要保证原子性的写操作放进事务查询、计算、远程调用统统移到事务外面。比如创建订单完全可以先在外面把价格算好、库存校验好、用户信息查好最后在事务里只做扣库存 插入订单 记录流水这三个写操作。注意这里的校验好不是不加锁地校验完再等事务而是利用后面的条件更新来保证安全。4.2 把 SELECT FOR UPDATE 替换成条件更新前面提到过很多扣减类业务喜欢用SELECT ... FOR UPDATE加锁取出库存然后在应用层判断再执行更新。这写法在并发量低的时候没问题但到了高并发场景锁持有的时间被人为拉长了——从 SQL 到应用判断到下一次 SQL 执行中间隔着一段 Java 代码执行时间、网络往返时间。你持有的不是一行锁而是一行锁 一段不可控的代码执行时间。我强烈建议在库存类、余额类、批次号抢占类场景改用条件更新UPDATE product_stock SET stock stock - 1 WHERE product_id ? AND stock 0;执行完之后检查受影响行数为 1 表示扣减成功为 0 表示库存不足或记录不存在。这条语句在 InnoDB 内部是一条原子操作先走主键找到目标行加行锁读取最新的 stock判断是否大于 0满足则扣减并返回 1否则放弃。整个过程不需要应用层先查再算行锁的持有时间就是一条 SQL 的执行时间通常不到 1ms。对比一下两个版本在高并发下的行为方案锁持有时间并发正确性失败处理SELECT FOR UPDATE 应用判断 UPDATE跨多条 SQL含网络和应用耗时最长可能几十 ms正确但锁等待严重容易超时需要自己判断库存并抛异常单条条件 UPDATE单条 SQL微秒到毫秒级正确无效更新直接返回 0无脑重试失败或返回库存不足条件更新唯一的注意点是它要求product_id走了主键或唯一索引否则范围大了容易锁太多行。另外如果你的更新条件既有库存判断又有状态判断可以组合进 WHERE 里例如WHERE product_id ? AND stock ? AND status ON_SALE。只要条件能走索引锁的范围就能控制在单行或极小范围。当然条件更新没有解决热点行问题——如果所有请求都挤压同一个商品的同一行记录即使每条 SQL 只锁 1ms承接能力也就每秒一千单左右。这时候单靠 MySQL 已经顶不住需要在上游做流量削峰秒杀场景可以先用 Redis 预扣减把超过库存上限的请求直接挡掉再异步把有效请求同步到 MySQL。这部分属于架构层面的手段但底层思想仍然没变减少真正进入数据库竞争的请求量。4.3 参数调优和连接池侧的细节在不改代码的前提下有几个参数对锁相关稳定性影响很大innodb_lock_wait_timeout默认 50 秒这意味着一个请求拿不到锁会傻等 50 秒。对互联网应用来说50 秒早就超时了而且等待的请求会占着连接不释放。我一般把 Online 库设置成 3~5 秒让拿不到锁的请求快速失败由应用层决定是重试还是降级。innodb_deadlock_detect默认开启InnoDB 会主动探测死锁并回滚其中一个事务。高并发极端场景下这个探测本身也有开销但我不建议在生产上关闭它除非你的锁等待非常可控且你有完善的兜底重试。关闭后死锁不会被及时解掉只能等超时后果更严重。连接池的maximum-pool-size不是越大越好。很多团队在数据库连接池上盲目调大结果几百个线程同时挤到 InnoDB 里去抢锁锁等待线程数暴增CPU 大量消耗在上下文切换和锁检测上。连接池大小应该结合数据库的max_connections和实际并发事务数来定而不是连接越多越快。慢查询日志配合锁等待状态一起看。光看执行时间还不够如果一条 UPDATE 语句执行时间很长先用SHOW ENGINE INNODB STATUS确认它是真的在跑还是卡在等待锁上。后者的话加索引和优化 SQL 都治标不治本得找到持有锁的长事务。5. 死锁与锁等待的排查链路我常用的四条命令这一节想分享的是实战排查方法。很多朋友遇到数据库好像锁了第一反应是重启或者 kill 进程其实正确的做法是先定位再处理。我平时排查锁问题基本依赖下面这四个步骤。5.1 第一步打开慢查询日志和错误日志如果只是偶发的死锁应用层会抛出类似这样的异常Deadlock found when trying to get lock; try restarting transaction看到这个关键词第一件事不是改代码而是去 MySQL 错误日志里看死锁详情。八小时之内如果没开innodb_print_all_deadlocksInnoDB 只会在内存里保留最近一次死锁的信息。所以建议在比较核心的库上打开innodb_print_all_deadlocks 1这样每次死锁都会写入错误日志方便事后复盘。如果死锁频繁同时打开慢查询日志设置阈值 1 秒并开启log_queries_not_using_indexes那些全表扫描的 UPDATE 会被记录下来这些都是锁竞争的高危 SQL。5.2 第二步用 SHOW ENGINE INNODB STATUS 读死锁日志这是排查死锁最关键的命令。在 MySQL 客户端执行SHOW ENGINE INNODB STATUS\G输出里找到LATEST DETECTED DEADLOCK一节我贴一段浓缩后的典型长这样------------------------ LATEST DETECTED DEADLOCK ------------------------ *** (1) TRANSACTION: TRANSACTION 10086, ACTIVE 2 sec updating or deleting LOCK WAIT 20 lock struct(s), heap size 1128 TABLE order LOCK WAIT: waiting for record lock on index PRIMARY *** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 18 page no 4 n bits 80 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 18 page no 8 *** (2) TRANSACTION: TRANSACTION 10087, ACTIVE 2 sec updating or deleting LOCK WAIT 19 lock struct(s) TABLE inventory LOCK WAIT: waiting for record lock on index PRIMARY *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 18 page no 8 *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 18 page no 4 *** WE ROLL BACK TRANSACTION (2)读这个日志有个技巧先看每个事务HOLDS THE LOCK(S)和WAITING FOR THIS LOCK的部分把两个事务各自持有的对象和等待的对象列出来。上面这个例子事务 1 握着 order 表某行锁想要 inventory 表的锁事务 2 握着 inventory 表某行锁想要 order 表的锁回路形成死锁成立。InnoDB 会选择回滚代价更小的事务通常是 2 应用层收到报错后需要重试。看日志的时候一定要结合业务字段判断是哪段代码路径。一般死锁日志里事务 2 是受害者你要反推两个事务的执行顺序再去代码里找对应的两条更新语句。5.3 第三步查询当前锁等待与活跃事务死锁已经发生并自动解除了但还有一类情况更危险——不构成死锁但长时间锁等待。比如事务 A 一直握着库存某行的锁不提交事务 B 在后面等如果 A 是个未提交的长事务B 会一直等到超时。这时候排查要靠information_schema。我先查活跃事务列表SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx;关注trx_state是RUNNING但trx_started已经很早的事务它们多半是长事务是锁等待的源头。然后查当前锁等待关系SELECT waiting_trx_id, blocking_trx_id, waiting_pid, blocking_pid, waiting_query FROM sys.innodb_lock_waits;如果使用的版本没有sys库可以看performance_schema.data_lock_waits或者直接查information_schema.innodb_lock_waits。这几张表能直接告诉你哪个线程在等谁的锁。定位到阻塞源头后如果能确认是残留的僵尸事务就记录下它的trx_mysql_thread_id用KILL掉那个线程。注意 kill 之前要确认这个事务不是关键业务正在跑否则等于主动回滚可能造成数据不一致。5.4 第四步从执行计划评估锁范围大部分锁问题不是偶发而是写法的必然结果。所以我排查完之后一定会做一个动作对每个写 SQL 执行EXPLAIN看它走了哪个索引、预估影响多少行。EXPLAIN UPDATE booking SET status CANCELED WHERE business_date 2024-06-18;如果key为空说明这条 UPDATE 是全表扫描意味着它不是锁了几行而是锁了整张表的几乎所有记录。哪怕只是每次跑上几百条对一个高并发库来说也足以引起连环锁等待。这类 SQL 要么增加索引要么改成分批更新、限量更新UPDATE booking SET status CANCELED WHERE id IN ( SELECT id FROM booking WHERE business_date 2024-06-18 LIMIT 200 );分批更新在校对任务、定时任务里尤其常用。写定时任务的同学要注意一条 UPDATE 影响上万行没问题问题是它会把上万行对应的索引间隙全部锁住后续所有插入都要等它提交这个等待窗口足够压垮一个核心表。所以规律是大事务拆小小事务做快尽量按主键或唯一键定位。6. 事务注解、服务拆分与分布式事务框架层的高级踩坑6.1 Spring 事务注解失效的常见坑Java 后端最常见的数据库事务使用方式就是 Spring 的Transactional注解。但事务注解只解决了事务边界问题它不解决并发正确性问题而且它本身还有不少失效场景很多人踩了之后百思不得其解。常见失效场景有这么几个同类内部调用A 类的methodA()调用同类 A 的methodB()methodB上标了Transactional也是无效的。因为 Spring 事务靠代理实现同类this调用走的是原始对象注解压根没经过代理。非 public 方法事务注解应用在private或protected方法上不生效。异常被吞掉try-catch里捕获了异常没有抛出Spring 感知不到异常事务不会回滚。错误指定回滚类型默认只对RuntimeException和Error回滚如果有检查型异常比如故意throw new Exception()事务照样提交。另外提醒一件事Transactional默认的事务传播级别是REQUIRED如果外层已经开了一个事务内层方法会直接加入外层事务。这时候如果内层方法是耗时的远程调用整套锁的持有时间会被拉长。所以在同一个事务里调RestTemplate、Dubbo、HttpClient都是非常危险的行为响应时间一旦抖动并发一旦上来数据库锁等待必然跟着爆炸。6.2 跨服务的数据一致性到底要不要分布式事务高并发项目拆成微服务之后一个业务动作往往涉及多个服务的多个库。比如下单要扣库存订单服务 库存服务再减余额用户服务这三者不在同一个数据库MySQL 本地事务管不到了。这时候出现的选择题就是要不要上分布式事务。先说结论互联网场景大部分不用强一致分布式事务用的多是最终一致性方案。比如业内比较成熟的模式本地消息表 消息队列 定时对账。核心思路是主服务在自己库里开一个事务把业务数据和一条待发送消息一起写入消息发送成功后由后台任务把消息发到 MQ下游消费者处理消息并且保证处理逻辑是幂等的。即使中途 RPC 失败、MQ 抖动也有定时任务兜底对账最终所有数据达到一致。这里有两个工程要点幂等性是最终一致性的前提。消费者收到消息后不能无脑执行而是要先去查有没有处理过或者用唯一业务键做插入冲突保证同一个消息重试 100 次和执行 1 次的最终效果一样。回补和对账必须存在。最终一致性不是说发完消息就完事而是要有一个反向核对的任务比如每隔几分钟扫描本地消息表中未确认的消息重新推送或者对账作业定期比对订单和库存两个库的数据是否吻合发现不一致就修复。至于强一致分布式事务如两阶段提交XA性能代价很高协调者在故障时会阻塞参与者大家都要等它决定提交还是回滚这在高并发场景下几乎不可接受。所以除非是金融级强一致需求否则我通常不建议。6.3 个人踩坑后沉淀的三条纪律最后分享几点我自己在项目里定下的规矩谈不上放之四海皆准但至少让锁相关的事故变少了很多第一条所有写 SQL 必须先看执行计划。增加索引不带条件、更新条件不走索引这类代码在 Code Review 里一律打回。宁可多花一分钟看EXPLAIN也不要让一条全表 UPDATE 在半夜上线后拖垮核心业务。第二条事务里禁止远程调用。负责跨服务协调的代码必须放在事务外面事务内的 RPC、Http、消息发送统统不允许。如果你发现一个事务上面挂着几十毫秒的锁等待先检查是不是有人在事务里发了消息。第三条关键写操作必须利用数据库返回的结果。扣库存、扣余额、占位标记全部改为条件更新并检查受影响行数不要再先查再改。这样即使你突然被拉去处理别的问题代码在并发下也不会自己乱掉。高并发下的稳定性不是一个独立的架构层次它最底层的逻辑就埋在事务和锁这两个最基础的概念里。把这两块吃透很多表面的玄学问题其实都能落到具体的锁对象、锁等待和隔离级别上定位和修复也是顺理成章的事。
