从粗放 MySQL 交易库 @Transactional 包住整个大方法连远程支付调用都裹进事务里行锁被慢接口绑架数秒高峰锁等待瀑布堆积连接池占满雪崩 + 压根不懂隔离级别用默认或乱设脏读不可重复读幻读分不清还把快照读和 FOR UPDATE 当前读混用读出灵异结果 + 凡是更新就无脑 select for update 悲观锁把读多写少场景的并行更新硬串行化 + 加锁顺序五花八门死锁频发只能靠重启清场 + 更新条件不走索引 InnoDB 行锁退化成扫描路径锁住一大片甚至全表把无关更新全阻塞 + 热点大商家账户单行被每秒上万笔成交更新行锁让请求排成长龙吞吐卡死 + 库存扣减用裸 read-modify-write 并发下丢失更新导致严重超卖发不出货 + 大事务循环更新几万行跑几分钟持锁堆 undo 拖慢全库还有僵尸事务赖着不走 + 锁等待死锁长事务全是黑盒出事才 SSH 上去 show processlist 肉眼抓瞎 → 2026 现代高并发数据库工程 短事务只包必须原子的 DB 写远程调用挪到事务外 + 理解四个隔离级别权衡默认 RR 分清 MVCC 快照读当前读 + 读多写少用乐观锁版本号 CAS + 统一按主键升序加锁加死锁监控加自动重试 + 写条件必走索引 EXPLAIN 确认行锁精准只锁命中行 + 热点账户余额分桶拆成多行分散并发读时 SUM 合并 + 原子 UPDATE x=x±? 加 stock>=1 条件根治丢失更新和超卖 + 批量拆成分批小事务加长事务监控告警 + performance_schema 持续度量锁等待和长事务做大盘告警 87 天战役复盘:47 套工程修法 + 7 个 P0 复盘 + 6 条工程哲学

8 人的数据库与交易平台团队 87 天把一套跑了五年、日订单量从每天几十万笔涨到上千万笔、大促高峰每秒上万笔并发写入后种种粗放并发写法集中爆雷的核心交易数据库——@Transactional 习惯性地甩在最外层大方法上把查库改库远程支付调用发消息发短信统统圈进同一个事务里一次慢支付就让账户行锁被绑架数秒高峰期锁等待瀑布式堆积连接池被长事务占满整个交易服务超时雪崩、压根不懂事务隔离级别要么手欠调到串行化把并发拨没了要么降到读未提交读到脏数据更多人就用默认值却说不清会不会脏读不可重复读幻读还把普通快照读和 FOR UPDATE 当前读混在一个事务里用读出自己都解释不清的灵异结果、处理任何更新都条件反射地无脑 select for update 悲观锁把那些其实极少冲突的读多写少场景的并行更新硬生生串行化吞吐被锁竞争死死压住、不同代码里更新多行的加锁顺序五花八门极易形成环路死锁频发只能靠重启服务清场、更新条件字段没建索引 InnoDB 行锁直接退化成全表扫描锁住一大片把整张表无关的更新全阻塞、头部大商家的账户余额行在大促被每秒上万笔成交更新而同一行更新必须串行请求排成望不到头的长龙吞吐卡在单行速度上、库存扣减用最符合直觉的读出来改一改写回去裸操作高并发下丢失更新导致超卖明明扣减上百次库存只少了几十发不出货引发大量客诉、批量发券用一个大事务循环插入几万行跑好几分钟期间持锁阻塞 undo 清理拖慢全库还有异常分支下既不提交也不回滚的僵尸事务长期占着连接和锁、数据库内部的锁等待死锁长事务全是黑盒平时不看出事才慌忙登上去 show processlist 对着满屏连接肉眼抓瞎还理不清谁在等谁——系统性地重构成 2026 年现代高并发数据库工程体系:把事务瘦身到只包必须原子的 DB 写远程调用全挪到事务外用编程式事务精确框定范围、彻底搞懂四个隔离级别的权衡默认用 RR 分清 MVCC 的快照读与当前读、读多写少场景改用乐观锁版本号加 CAS 重试放开并发、统一按主键升序加锁杜绝死锁环路再配死锁监控和自动重试、所有写操作条件必须走索引用 EXPLAIN 确认让行锁精准只锁命中行、把热点账户余额分桶拆成多行分散并发读时 SUM 合并、库存扣减改成原子 UPDATE 加 stock>=1 条件根治丢失更新和超卖、批量改成分批小事务并给长事务装上监控告警、用 performance_schema 的 data_locks/data_lock_waits 持续度量锁等待和长事务做大盘告警把黑盒变明牌,大促高峰再没因锁等待雪崩过、半夜不再被超卖和死锁告警叫醒、同样的数据库扛住了几倍的并发交易、锁问题分钟级就能定位阻塞源头,沉淀 47 套工程修法 + 7 个 P0 复盘 + 6 条工程哲学。

这是一支 8 个人的数据库与交易平台团队,守着一套已经跑了五年的核心交易数据库——从最早每天几十万笔订单,一路涨到如今每天上千万笔、大促高峰每秒上万笔的并发写入。五年里业务狂奔,可我们对待事务、锁和并发的方式却始终停留在最粗放的"能跑就行"阶段:事务边界乱画、隔离级别不懂、锁策略全靠 select for update 硬扛、死锁频发只能靠重启续命。直到一次大促,几千个并发请求同时去更新同一个热点商家的账户余额,行锁竞争叠加几个长事务,整个交易库的锁等待瞬间爆炸、连接池被占满、大面积超时——那一刻我们才痛下决心,用 87 天打一场数据库事务与并发控制的现代化战役。下面这篇复盘,就是我们这 87 天里把交易数据库从"一到高峰就锁死"重构到"高并发下稳如磐石"的全部历程,沉淀了 47 套工程修法、7 个 P0 事故复盘和 6 条工程哲学。

先看一张总览表,把我们这五年的粗放老做法和重构后的现代做法逐项摆出来对照:

维度 古早粗放做法(重构前) 现代工程做法(重构后)
事务边界 @Transactional 包住整个大方法,连远程调用、发消息都裹在事务里,长事务持锁极久 事务范围收到最小,只包真正需要原子性的 DB 操作,远程调用挪到事务外
隔离级别 压根不懂隔离级别,用默认或随手乱设,脏读、不可重复读、幻读分不清 理解四种隔离级别的权衡,默认用 RR,特定场景按需降到 RC 提升并发
锁策略 凡是更新就无脑 select ... for update 悲观锁,锁范围大、并发被串行化 读多写少场景改用乐观锁(版本号 + CAS),冲突少时零锁开销
死锁 死锁频繁发生,事务直接报错失败,只能靠人工重试或重启服务 统一多表加锁顺序避免环路 + 死锁监控 + 失败自动重试
行锁精度 更新条件不走索引,InnoDB 行锁退化成锁住大量行甚至全表 确保 where 条件走索引,行锁精准只锁命中行,并发更新互不阻塞
热点行 热点商家账户单行被高并发更新,行锁让请求排成长队串行执行 余额分桶拆成多行分散热点,读时合并求和,把单点串行变并行
丢失更新 read-modify-write 不加任何保护,并发下后写覆盖前写丢更新 乐观锁版本校验 / 原子 UPDATE x=x+? 让数据库保证原子性
长事务 长事务迟迟不提交,持锁久、undo 日志堆积、MVCC 快照链变长拖慢全库 事务尽量短小快速提交,监控并告警超过阈值的长事务
MVCC 认知 不懂快照读和当前读的区别,混用导致读到的数据出乎意料 理解 MVCC 一致性快照与当前读,清楚每条语句读的是哪个版本
可观测 锁等待、死锁、长事务全是黑盒,出事才上去 show processlist 抓瞎 performance_schema / information_schema 持续采集锁与事务指标接入监控告警

一、事务边界:从 @Transactional 包住远程调用的大事务到只包 DB 操作的短事务

第一仗,是把那些被无意中拉得又长又胖的事务,重新瘦身成只做该做之事的短事务。古早时代我们对事务边界几乎没有概念,写业务方法时图省事,直接在最外层方法上甩一个 @Transactional 注解,然后这个方法里干的所有事情——查数据库、改数据库、调用远程的第三方支付接口、往消息队列发消息、甚至还有读写缓存、发短信——统统都被这个注解圈进了同一个数据库事务里。问题就出在这里:数据库事务一旦开启,它所修改的那些行上的锁,会一直被持有,直到整个事务提交或回滚才释放,而我们的大事务里偏偏夹着远程调用这种慢且不可控的操作——一次第三方支付接口调用可能要几百毫秒甚至几秒,这期间数据库的行锁就被死死攥着不放,别的想更新同一行的事务全在那儿干等,高并发下锁等待层层堆积,连接池被长事务占满,最终雪崩。现代做法是把事务边界收缩到极致:事务里只放真正需要保证原子性的那几条数据库读写,所有的远程调用、消息发送、缓存操作,统统挪到事务外面去——要么在事务提交之后再做,要么用事务的提交后回调机制触发。下面是事务瘦身前后的对比:

// 重构前:@Transactional 包住整个大方法,远程调用也裹在事务里,行锁被持有数秒
// @Transactional
// public void createOrder(OrderReq req) {
//     Order o = orderMapper.insert(req);          // 1. 写订单(开始持有行锁)
//     accountMapper.deduct(req.getUid(), req.getAmount()); // 2. 扣余额(持有账户行锁)
//     paymentClient.charge(req.getCard(), req.getAmount()); // 3. 远程支付!可能耗时数秒
//     mqProducer.send(new OrderCreatedEvent(o.getId()));    // 4. 发 MQ,又是网络IO
//     smsClient.notify(req.getPhone());           // 5. 发短信,继续占着事务
// }   // ↑ 整个过程行锁一直不放,3/4/5 越慢,锁持有越久,并发全卡在这

// 重构后:事务只包 1+2 两条 DB 写,远程调用挪到事务提交之后,行锁瞬间释放
public void createOrder(OrderReq req) {
    Long orderId = txTemplate.execute(status -> {   // 事务范围最小化
        Order o = orderMapper.insert(req);          // 只做必须原子的 DB 写
        accountMapper.deduct(req.getUid(), req.getAmount());
        return o.getId();                           // 事务到此立即提交,行锁马上释放
    });
    // ↓ 以下都在事务外,不再占用任何数据库锁
    paymentClient.charge(req.getCard(), req.getAmount()); // 远程支付:慢也不锁库
    mqProducer.send(new OrderCreatedEvent(orderId));
    smsClient.notify(req.getPhone());
}

事务边界治理让我们从"写业务方法时图省事直接在最外层方法上甩一个 @Transactional 注解然后这个方法里查数据库改数据库调用远程第三方支付接口往消息队列发消息读写缓存发短信统统被圈进同一个数据库事务里、而数据库事务一旦开启它所修改的那些行上的锁会一直被持有直到整个事务提交或回滚才释放而大事务里偏偏夹着远程调用这种慢且不可控的操作一次第三方支付接口调用可能要几百毫秒甚至几秒这期间数据库的行锁就被死死攥着不放别的想更新同一行的事务全在那儿干等高并发下锁等待层层堆积连接池被长事务占满最终雪崩"进化到了"把事务边界收缩到极致:事务里只放真正需要保证原子性的那几条数据库读写所有远程调用消息发送缓存操作统统挪到事务外面去要么在事务提交之后再做要么用事务的提交后回调机制触发":过去我们对"一个事务到底应该包多大范围"这件事毫无敬畏,默认的心智模型是"一个业务操作 = 一个事务",于是凡是一个完整的业务动作,比如下单,涉及的所有步骤——不管是数据库操作还是别的什么——就理所当然地全塞进一个 @Transactional 里,我们从没意识到事务的本质是"持有锁、保证一组操作的原子性",而锁是一种昂贵的、会阻塞他人的稀缺资源,持有的时间应该越短越好,更没意识到把一个耗时数秒、还可能超时失败的远程网络调用放进事务里,意味着数据库的锁要被这个不可控的网络操作"绑架"数秒之久,这在低并发时看不出问题,可一旦到了大促高峰,成百上千个请求同时来更新热点数据,每个事务都因为夹着远程调用而迟迟不肯释放锁,后来的请求就只能在锁的队伍里越排越长,数据库的活跃事务数、锁等待数急剧飙升,连接池里的连接全被这些慢吞吞的长事务占用殆尽,新请求连个数据库连接都拿不到,整个系统就这么被一个个本不该这么长的事务活活拖垮;现在我们把事务边界的设计当成一门必修的纪律来对待,核心原则就一条:事务里只能有数据库操作,而且只能有那些必须放在一起、要么全成功要么全失败的数据库操作,任何远程调用(支付、风控、其他微服务)、任何消息发送、任何缓存读写、任何发短信发邮件,全部一律挪到事务外面——具体来说,我们不再用那种一甩注解就把整个方法圈住的粗放方式,而是改用编程式事务(比如 TransactionTemplate),用一个小小的 lambda 把真正需要原子性的那两三条 DB 写精确地框起来,框外的代码就在事务之外执行,这样数据库的行锁从开始持有到释放,可能只有短短几毫秒,远程调用再慢也只是慢在它自己身上,丝毫不会占用宝贵的数据库锁资源,即便支付接口卡了三秒,被卡的也只是这一个请求,而不会因为它攥着锁不放而连累成百上千个想更新同一行的其他请求。我们的纪律是"事务里严禁出现任何远程调用、消息发送、缓存操作等非 DB 的 IO,事务范围收缩到只包必须原子的那几条 DB 读写,优先用编程式事务精确框定范围而非在大方法上甩 @Transactional,需要在事务成功后做的事(发 MQ、清缓存)用提交后回调,坚决不让任何慢操作绑架数据库锁"。事务边界的本质认知是:事务不是用来"框住一个业务流程"的,而是用来"保护一组必须原子的数据修改"的——它的代价是持有锁、阻塞并发,所以它的范围应该小到不能再小、它的存活时间应该短到不能再短;把远程调用塞进事务,是用数据库最宝贵的锁资源,去给一个完全不可控的网络操作做人质,这是高并发数据库性能的头号杀手;事务瘦身的智慧,就在于时刻分清"哪些操作必须原子地绑在一起"和"哪些操作只是恰好在同一个业务流程里",前者才配进事务,后者一律请出去——会写高并发数据库代码的工程师,对每一个事务的边界都锱铢必较,因为他们深知,事务每多包一行不该包的代码、每多持锁一毫秒,都是在高峰期给整个系统埋下一颗锁等待的雷。

二、隔离级别与 MVCC:从不懂隔离级别乱设到理解 RC/RR 与快照读当前读

第二仗,是补上"事务隔离级别"和"MVCC 多版本并发控制"这两块我们欠了五年的基础课。古早时代我们对数据库的隔离级别基本是一无所知的状态:有人觉得隔离级别越高越安全于是手欠把它调到最高的串行化(Serializable)结果并发性能暴跌,有人为了"性能"把它降到读未提交(Read Uncommitted)结果读到了别的事务还没提交、随时可能回滚的脏数据,更多的人则是压根不知道还有这么个东西、就用着数据库的默认值、却完全说不清这个默认级别到底会不会出现脏读、不可重复读、幻读这些并发问题,于是线上时不时冒出一些诡异的数据现象——同一个事务里前后两次查询同一行数据结果竟然不一样、统计出来的数字对不上账——我们却连这是隔离级别导致的都意识不到,只当是"灵异事件"反复排查无果。与此同时,我们也完全不理解 InnoDB 是靠 MVCC(多版本并发控制)来实现读写不互相阻塞的:不懂普通的 SELECT 是读某个时间点的一致性快照(快照读)、而 SELECT ... FOR UPDATE 或 UPDATE 读的是最新的当前版本(当前读),把这两种读混在一个事务里用,自然就读出了自己都解释不清的结果。现代做法是把这两块基础彻底搞懂:清楚地理解四个隔离级别各自防住了什么、放过了什么、代价是什么,默认采用 RR(可重复读)、并在那些读多、能容忍且需要更高并发的场景按需降到 RC(读已提交);同时理解 MVCC 的运作机理、分清快照读与当前读、写出行为可预期的事务。下面用 SQL 把隔离级别和两种读的差异演示清楚:

-- 重构前:不懂隔离级别,要么手欠调到 Serializable 并发暴跌,要么降到读未提交读到脏数据
-- SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; -- 会读到别人未提交的脏数据!
-- 还把普通快照读和 FOR UPDATE 当前读混用,同一事务前后读出不同结果,排查无果当灵异事件

-- 重构后:理解四个隔离级别的权衡,默认 RR,并分清快照读 vs 当前读
-- 查看与设置隔离级别
SELECT @@transaction_isolation;                 -- 默认 REPEATABLE-READ(RR)
-- 四级权衡:读未提交(脏读) < 读已提交RC(防脏读,有不可重复读)
--          < 可重复读RR(防不可重复读,InnoDB 用间隙锁基本防住幻读) < 串行化(全串行,并发最差)

-- 快照读:普通 SELECT,读事务开始时的一致性快照,不加锁,读写不互相阻塞
START TRANSACTION;
SELECT balance FROM account WHERE id = 100;      -- 快照读:读的是快照版本,别人改了也看不到
-- ... 此刻另一个事务把 id=100 的 balance 改了并提交 ...
SELECT balance FROM account WHERE id = 100;      -- RR 下:仍读到事务开始时的旧值(可重复读)

-- 当前读:FOR UPDATE / UPDATE,读最新已提交版本并加锁
SELECT balance FROM account WHERE id = 100 FOR UPDATE; -- 当前读:读到最新值并加行锁
UPDATE account SET balance = balance - 10 WHERE id = 100; -- 当前读 + 加锁更新
COMMIT;
-- ↑ 同一事务里混用快照读和当前读,读出的版本不同——必须分清,否则行为不可预期

隔离级别与 MVCC 治理让我们从"对数据库的隔离级别基本一无所知:有人觉得隔离级别越高越安全于是手欠把它调到最高的串行化结果并发性能暴跌、有人为了性能把它降到读未提交结果读到了别的事务还没提交随时可能回滚的脏数据、更多的人压根不知道还有这么个东西就用着默认值却完全说不清这个默认级别到底会不会出现脏读不可重复读幻读、于是线上时不时冒出同一个事务里前后两次查询同一行结果竟然不一样统计数字对不上账这些诡异现象我们却连这是隔离级别导致的都意识不到只当是灵异事件、同时完全不理解 InnoDB 是靠 MVCC 来实现读写不互相阻塞的不懂普通 SELECT 是读一致性快照而 FOR UPDATE 读的是最新当前版本把两种读混在一个事务里用读出了自己都解释不清的结果"进化到了"把这两块基础彻底搞懂:清楚理解四个隔离级别各自防住了什么放过了什么代价是什么、默认采用 RR 并在读多能容忍且需要更高并发的场景按需降到 RC、同时理解 MVCC 的运作机理分清快照读与当前读写出行为可预期的事务":过去隔离级别对我们就是一个看不懂、也从没认真看过的数据库配置项,我们对并发事务之间会互相干扰、产生脏读(读到别人未提交、可能回滚的数据)、不可重复读(同一事务两次读同一行,因别人中途提交了修改而读出不同结果)、幻读(同一事务两次按条件查询,因别人插入了新行而多出"幻影"记录)这三类经典问题完全没有概念,自然也就不理解隔离级别正是用来在"并发性能"和"避免这些干扰"之间做权衡的旋钮:级别越高,防住的并发问题越多,但加的锁越重、并发度越低;级别越低,并发越好,但放过的问题越多,数据一致性的风险越大,我们却把它当成一个可以随便拨弄的开关,有人盲目求稳拨到顶,把性能拨没了,有人盲目求快拨到底,把数据正确性拨没了,大多数人则干脆不去碰它,既不知道默认的 RR 级别帮自己防住了什么,也不知道还有哪些风险需要自己额外注意;更深一层,我们完全不知道 InnoDB 这种主流存储引擎是怎么做到让大量的读和写能够高并发地同时进行、又不互相死锁阻塞的——答案就是 MVCC,即每一行数据在被修改时,并不是直接覆盖,而是保留多个历史版本,每个事务在读取时,根据自己的隔离级别和开始时间,去读取对它而言"应该可见"的那个版本快照,从而实现了读不加锁、读写不互斥,而我们因为不懂这套机制,就分不清一条普通的 SELECT(它走的是 MVCC 快照读,读的是某个一致性时间点的历史快照,看不到别人后来的提交)和一条 SELECT ... FOR UPDATE 或 UPDATE(它走的是当前读,会无视快照、直接读取最新的已提交版本并对其加锁)这两者的天壤之别,于是在同一个事务里把这两种读随意混用,前一句快照读读到的是旧版本,后一句当前读读到的是新版本,自己把自己绕晕,数据对不上还百思不得其解;现在我们把隔离级别和 MVCC 这两块基础课老老实实补扎实了,我们能清楚地说出每一个隔离级别分别防住了脏读、不可重复读、幻读中的哪几个、又为此付出了多大的并发代价,我们默认使用 InnoDB 的 RR 级别(它通过间隙锁机制,在大多数场景下连幻读都基本防住了,且并发表现良好),并且只在那些明确是读多写少、业务上能容忍不可重复读、又确实需要榨取更高并发的场景下,才有意识地、明明白白地把隔离级别降到 RC,我们也彻底理解了 MVCC 的运作原理,在写每一个事务时都清楚地知道里面的每一条读语句到底是快照读还是当前读、它读到的会是哪个版本的数据,从而写出行为完全可预期、再也不会"读出灵异结果"的事务。我们的纪律是"必须理解四个隔离级别各防住什么、各自的并发代价,默认用 RR、只在读多可容忍的场景显式降到 RC 且写明理由,分清普通 SELECT 的快照读和 FOR UPDATE/UPDATE 的当前读、清楚每条语句读的是哪个版本,需要读到最新值并加锁就用当前读、需要一致性快照就用快照读,绝不在不理解的情况下随手改隔离级别"。隔离级别与 MVCC 的本质认知是:数据库的并发控制,本质上是在"让多个事务高效地同时跑"和"让每个事务都看到一致、正确的数据"这两个互相拉扯的目标之间寻找平衡点,而隔离级别就是这个平衡点的调节旋钮,MVCC 则是现代数据库为了能把这个旋钮调得既快又稳而发明的精巧机制——不懂它们,你就既不知道自己的数据正承受着哪些并发风险,也不知道自己的性能正被哪些不必要的锁所拖累;理解隔离级别与 MVCC 的智慧,在于它让你从一个对数据库并发行为"听天由命"的使用者,变成一个能精确预判和掌控每个事务可见性与加锁行为的设计者——你会知道什么时候该用快照读放过并发、什么时候该用当前读锁住真相,会知道把隔离级别定在哪一档才能既守住业务要的一致性、又不浪费一丝并发能力,这是写好任何高并发数据库应用都绕不开的、最底层的基本功。

下面用一张图概括我们这套数据库事务与并发控制的整体架构,从请求进入到事务、锁、MVCC 与可观测的全貌:

三、悲观锁 vs 乐观锁:从无脑 select for update 到版本号 CAS 乐观更新

第三仗,是把"凡是更新就先上锁"的悲观思维,换成"先假设没人跟我抢、真抢了再说"的乐观策略。古早时代我们处理任何"读出来、改一改、写回去"的更新场景,条件反射般地就是一套悲观锁组合拳:先 SELECT ... FOR UPDATE 把那一行(甚至一批行)锁住,确保在我读出来到写回去这段时间里没有别人能动它,然后再慢悠悠地做业务计算、最后 UPDATE 写回、提交事务释放锁。这套做法在逻辑上是绝对安全的,可代价极其高昂:它是一种"先小人后君子"的悲观假设——它假定每一次更新都一定会和别人冲突,所以无论实际上有没有并发冲突,都先把锁加上、把别人挡在门外,于是在那些其实绝大多数时候根本没有并发冲突的读多写少场景里,这把悲观锁就成了纯粹的浪费:大量本可以并行的更新,因为都要先去抢这把锁,被硬生生地串行化了,吞吐量被锁竞争死死压住。现代做法是在读多写少、冲突概率低的场景改用乐观锁:不预先加任何锁,而是给数据行加一个版本号(version)字段,读数据时把版本号一起读出来,更新时用"WHERE id=? AND version=读出来的版本号"作为条件、并在 SET 里把版本号 +1,如果更新影响的行数是 1 说明这期间没人改过、更新成功,如果影响行数是 0 说明版本号对不上、有人在我之前改过了、于是本次更新作废、重新读取最新数据再试一次(CAS 重试)。下面是悲观锁与乐观锁的对比:

-- 重构前:无脑悲观锁,凡是更新先 FOR UPDATE 锁住,读多写少场景把并行更新硬串行化
-- START TRANSACTION;
-- SELECT stock FROM product WHERE id = 100 FOR UPDATE;  -- 先加行锁,别人全挡门外干等
-- -- ... 做业务计算(这期间锁一直占着)...
-- UPDATE product SET stock = stock - 1 WHERE id = 100;
-- COMMIT;  -- ↑ 即使 99% 的时候根本没人跟你抢,也每次都付出了加锁/排队的代价

-- 重构后:乐观锁,加 version 字段,更新带版本号条件 + CAS 重试,无冲突时零锁开销
-- 表结构加一列:ALTER TABLE product ADD COLUMN version INT NOT NULL DEFAULT 0;

-- 1) 读数据时连版本号一起读出来(普通快照读,不加锁,高并发友好)
SELECT id, stock, version FROM product WHERE id = 100;   -- 假设读到 stock=50, version=7

-- 2) 更新时用版本号做乐观校验,并把版本号 +1
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = 7;                          -- 关键:带上读出来的版本号

-- 3) 看影响行数判断是否成功:
--    affected_rows = 1 → 这期间没人改过,更新成功
--    affected_rows = 0 → 版本号已变,有人抢先改了 → 本次作废,回到第 1 步重读重试(CAS)
-- ↑ 无冲突时全程不加锁,只有真冲突的极少数请求才需要重试,并发吞吐远胜悲观锁

悲观锁到乐观锁的转变让我们从"处理任何读出来改一改写回去的更新场景条件反射般就是一套悲观锁组合拳:先 SELECT FOR UPDATE 把那一行甚至一批行锁住确保在我读出来到写回去这段时间里没有别人能动它然后再慢悠悠做业务计算最后 UPDATE 写回提交事务释放锁、这套做法逻辑上绝对安全可代价极其高昂它是一种先小人后君子的悲观假设它假定每一次更新都一定会和别人冲突所以无论实际上有没有并发冲突都先把锁加上把别人挡在门外、于是在那些其实绝大多数时候根本没有并发冲突的读多写少场景里这把悲观锁就成了纯粹的浪费大量本可以并行的更新因为都要先去抢这把锁被硬生生串行化吞吐量被锁竞争死死压住"进化到了"在读多写少冲突概率低的场景改用乐观锁:不预先加任何锁而是给数据行加一个版本号字段读数据时把版本号一起读出来更新时用 WHERE id 加 version 等于读出来的版本号作为条件并在 SET 里把版本号加一、如果更新影响行数是 1 说明这期间没人改过更新成功如果影响行数是 0 说明版本号对不上有人在我之前改过了于是本次更新作废重新读取最新数据再试一次":过去我们对"并发更新"这件事的心智模型是彻头彻尾悲观的,我们默认假定"只要存在并发的可能,就一定会发生冲突",所以处理每一个更新,都像是要去抢一个所有人都在抢的稀缺资源一样,第一反应就是先用 SELECT ... FOR UPDATE 把要改的数据行牢牢锁住,生怕在我"读取-计算-写回"这个过程中有别人插进来把数据改了,这种悲观锁确实能保证绝对的正确,但它有一个致命的逻辑缺陷:它对所有请求一视同仁地施加了"冲突一定会发生"这个最坏假设下的代价,可现实中绝大多数业务场景其实是读多写少的,真正发生两个请求恰好同时去改同一行数据的并发冲突,概率其实相当低,我们却为了应对那一小撮真冲突,让所有的请求——包括那 99% 本来根本不会冲突的请求——全都老老实实地去排队抢锁、串行执行,这就好比因为偶尔有小偷,就给每一个进门的顾客都做一遍全身安检,把正常的客流堵得水泄不通,纯粹是用大多数人的效率,去为极少数的冲突买单;现在我们学会了根据冲突的实际概率来选择锁策略,在那些读多写少、冲突很罕见的场景下,我们果断改用乐观锁:乐观锁的哲学和悲观锁恰恰相反,它乐观地假设"大概率不会有人跟我抢",所以它读数据的时候压根不加锁(走的是高并发友好的快照读),只是顺便把这行数据的版本号 version 一起读出来记在手里,然后该干嘛干嘛地做业务计算,等到最后要写回去的那一刻,它才用一个巧妙的方式来检查"在我计算的这段时间里,有没有别人偷偷改过这行数据"——它把 UPDATE 的 WHERE 条件写成"主键 = ? AND version = 我之前读到的那个版本号",同时在 SET 里把 version 加 1,这样一来,如果这期间没有别人改过这行,那么数据库里的 version 就还等于我手里记的那个值,这条 UPDATE 就能命中、影响 1 行、更新成功,而如果这期间有别人抢先改过了,他必然已经把 version 加过 1 了,那么我这条 WHERE 里的 version 条件就匹配不上、影响 0 行、更新失败,这时我就知道发生冲突了,于是把刚才的计算作废、重新去读一遍最新的数据和版本号、再重试一次(这就是所谓的 CAS,比较并交换),由于真冲突很少,绝大多数请求第一次就成功了、全程没碰过任何锁,只有那极少数真正撞上的请求才需要重试一两次,整体的并发吞吐量因此远远超过了那个让所有人排队抢锁的悲观锁方案。我们的纪律是"读多写少、冲突概率低的场景优先用乐观锁(版本号 + CAS 重试),写冲突激烈、必须串行的场景才用悲观锁 FOR UPDATE,用乐观锁时务必检查 UPDATE 影响行数来判断成败、影响 0 行要能正确重试且设最大重试次数防活锁,悲观锁要确保锁的范围尽量小、走索引精准锁行,绝不再不分场景一律 FOR UPDATE 把并行更新无脑串行化"。悲观锁与乐观锁的本质认知是:加锁的本质,是用"阻塞他人"来换取"自己操作的安全",而这个交换是否划算,完全取决于冲突发生的概率——在冲突频繁的场景,悲观锁的预先阻塞避免了大量无效的重试,是划算的;而在冲突罕见的场景,乐观锁的"先干活、提交时才检查"避免了对绝大多数无冲突请求的无谓阻塞,才是划算的;选锁策略的智慧,就在于先估准你的业务到底是冲突频繁还是冲突罕见,然后让锁的"悲观程度"去匹配冲突的"真实概率",而不是不分青红皂白地一律悲观——会做高并发数据库的工程师,手里既有悲观锁也有乐观锁,他们会冷静地评估每个更新场景的冲突概率,在读多写少处用乐观锁放开并发,在写冲突激烈处用悲观锁守住秩序,绝不用一种锁去对付所有场景。

四、死锁治理:从死锁频发只能靠重启到统一加锁顺序 + 监控 + 自动重试

第四仗,是把那个曾经隔三差五就来折磨我们一次的"死锁"幽灵,从一个靠运气和重启来对付的玄学问题,变成一个有章可循、能预防、能监控、能自愈的工程问题。古早时代我们对死锁的产生机理一无所知,只知道线上时不时就有事务报出"Deadlock found when trying to get lock"的错误然后整个业务操作失败,我们既不知道为什么会死锁、也不知道怎么避免,只能在出现死锁导致大面积失败时手忙脚乱地重启服务来"清场"。其实死锁的成因并不神秘:当两个事务以相反的顺序去获取多把锁时,就可能形成环路等待——比如事务 A 先锁了行 1、然后想去锁行 2,而与此同时事务 B 先锁了行 2、然后想去锁行 1,于是 A 等着 B 释放行 2、B 等着 A 释放行 1,两个事务互相死等、谁也无法前进,这就是死锁。我们过去频繁死锁的根源,正是代码里不同地方更新多行数据时,加锁的顺序五花八门、毫无约定,极易形成这种环路。现代做法是三管齐下根治死锁:其一是预防,约定一个全局统一的加锁顺序(比如永远按主键从小到大的顺序去更新多行),从根本上杜绝环路的形成;其二是监控,持续采集死锁的发生情况、用命令分析死锁日志看清是哪两个事务、因为什么 SQL 互相锁死的;其三是自愈,对那些因死锁而失败的事务,在应用层包裹自动重试逻辑(死锁导致的失败往往是偶发的,重试一次大概率就成功了)。下面是死锁治理的关键手段:

-- 重构前:更新多行时加锁顺序随意,A 先锁行1再锁行2,B 先锁行2再锁行1 → 环路死锁
-- 事务A: UPDATE t SET ... WHERE id = 1;  然后  UPDATE t SET ... WHERE id = 2;
-- 事务B: UPDATE t SET ... WHERE id = 2;  然后  UPDATE t SET ... WHERE id = 1;
-- ↑ A 等 B 放 id=2,B 等 A 放 id=1,互相死等 → Deadlock,只能靠重启清场

-- 重构后:三管齐下根治死锁
-- 1) 预防:约定全局统一加锁顺序(永远按主键升序更新多行),从根上杜绝环路
-- 应用层对要更新的 id 列表先排序,再依次更新:
UPDATE t SET ... WHERE id = 1;   -- 所有事务都按 id 升序加锁
UPDATE t SET ... WHERE id = 2;   -- A、B 顺序一致,不可能形成环路等待

-- 2) 监控:分析最近一次死锁,看清是哪两个事务因什么 SQL 互锁
SHOW ENGINE INNODB STATUS\G       -- 看 LATEST DETECTED DEADLOCK 段落
-- 持续监控死锁次数:
SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks';
-- 查当前正在发生的锁等待(谁在等谁):
SELECT * FROM performance_schema.data_lock_waits;
SELECT * FROM performance_schema.data_locks;

-- 3) 自愈:应用层对死锁失败的事务自动重试(死锁多为偶发,重试大概率成功)
-- for (int i = 0; i < MAX_RETRY; i++) {
--   try { txTemplate.execute(...); break; }           // 成功就跳出
--   catch (DeadlockLoserDataAccessException e) {       // 捕获死锁异常
--     if (i == MAX_RETRY - 1) throw e;                 // 重试到上限仍失败才抛
--     sleep(backoff(i));                               // 退避后重试
--   }
-- }

死锁治理让我们从"对死锁的产生机理一无所知只知道线上时不时就有事务报出 Deadlock found when trying to get lock 的错误然后整个业务操作失败、既不知道为什么会死锁也不知道怎么避免只能在出现死锁导致大面积失败时手忙脚乱地重启服务来清场、而死锁的根源正是代码里不同地方更新多行数据时加锁的顺序五花八门毫无约定极易形成两个事务以相反顺序获取多把锁的环路等待 A 先锁行1再想锁行2而 B 先锁行2再想锁行1于是 A 等着 B 释放行2 B 等着 A 释放行1互相死等谁也无法前进"进化到了"三管齐下根治死锁:其一预防约定一个全局统一的加锁顺序比如永远按主键从小到大的顺序去更新多行从根本上杜绝环路的形成、其二监控持续采集死锁的发生情况用命令分析死锁日志看清是哪两个事务因为什么 SQL 互相锁死、其三自愈对那些因死锁而失败的事务在应用层包裹自动重试逻辑死锁导致的失败往往是偶发的重试一次大概率就成功":过去死锁对我们来说是一个完全无法理解、也无法掌控的恐怖存在,每次线上日志里冒出那行 Deadlock 的报错、伴随着一批业务操作的失败,我们都只能干瞪眼,既说不清这次死锁到底是怎么发生的、是哪些操作撞在了一起,也想不出有什么办法能让它不再发生,我们唯一会做的、也是最无奈的应对,就是当死锁多到引发大面积失败、影响到用户时,赶紧把服务重启一遍,靠重启把所有正卡着的事务都强行清掉、暂时恢复正常,然后提心吊胆地等着它下一次不知何时又会冒出来;后来我们终于把死锁的机理彻底搞明白了:死锁的产生需要几个条件同时满足,而其中最关键、也最容易被我们在代码里无意触犯的,就是"循环等待"——当多个事务都需要获取多把锁(比如一个事务要同时更新好几行数据,每更新一行就要获取那一行的锁),而它们获取这些锁的先后顺序又不一致的时候,就极易形成一个等待的环:事务 A 已经拿到了它要的第一把锁、正伸手去够第二把,可第二把恰好被事务 B 拿在手里,而 B 此刻也正伸手去够它要的第二把锁、而那把锁又恰好在 A 手里,于是 A 在等 B 手里的锁、B 在等 A 手里的锁,两个事务就这样头顶头地僵在那里、谁都不肯也不能松手、永远等下去,数据库只能检测到这个环、然后强行牺牲掉其中一个事务(让它报死锁错误并回滚)来打破僵局,而我们过去之所以死锁频发,病根就在于团队里不同的人、在不同的业务代码里,写更新多行的逻辑时,加锁的顺序完全是随心所欲的,这里按 A、B 的顺序锁,那里按 B、A 的顺序锁,这种顺序的不一致,就是滋生循环等待环路的温床;想明白了这一层,我们就对症下药、三管齐下:第一管是预防,这是最根本的一招——我们在全团队约定并强制执行一条铁律:任何时候需要在一个事务里锁定多行数据,都必须按照一个全局统一的、确定的顺序去加锁,最简单可靠的就是永远按照主键从小到大的顺序,具体落地时,我们会在应用代码里先把要更新的那一批 id 排好序,再严格按排好的顺序一行一行地去更新,这样一来,所有的事务获取锁的顺序就都一致了,A 和 B 不可能再出现一个正序一个逆序的情况,循环等待的环就从根上没法形成了,死锁的发生率断崖式下降;第二管是监控,我们不再对死锁两眼一抹黑,而是持续地监控数据库的死锁发生次数这个指标,一旦它有异动就告警,同时,当死锁真的发生时,我们会用 SHOW ENGINE INNODB STATUS 去查看数据库记录下来的最近一次死锁的详细信息,它会清清楚楚地告诉我们是哪两个事务、各自执行的是什么 SQL、各自持有和等待的是什么锁,从而精确地定位到是哪段代码违反了加锁顺序的约定;第三管是自愈,我们清醒地认识到,死锁的预防做得再好,在极端复杂的场景下也未必能 100% 杜绝,所以我们在应用层给数据库事务包裹上了一层自动重试的逻辑,专门捕获死锁这一类异常,一旦某个事务因为死锁而失败回滚了,我们不是直接把错误抛给用户,而是稍微等待一个很短的、带退避的时间之后,自动地把这个事务重新执行一遍,由于死锁往往是几个事务在某个瞬间凑巧撞在一起的偶发事件,重试的时候那个瞬间的并发态势通常已经过去了,绝大多数重试一次就能顺利成功,用户对这背后发生过的死锁和重试甚至毫无察觉。我们的纪律是"凡是一个事务里要锁定多行,必须按全局统一顺序加锁(默认按主键升序)、应用层先对 id 排序再依次操作,持续监控 Innodb_deadlocks 死锁次数并告警,死锁发生后用 SHOW ENGINE INNODB STATUS 分析定位违规代码,对死锁类异常一律在应用层做带退避和上限的自动重试,绝不再靠重启服务来对付死锁"。死锁治理的本质认知是:死锁不是什么随机降临的厄运,而是并发程序里一个成因明确、可以被工程手段系统性消除的确定性问题——它的核心成因是"多个事务以不一致的顺序获取多把锁"形成了循环等待,而破解它的钥匙,就是消灭这种顺序的不一致;治理死锁的智慧,在于把它从一个"事后救火、靠重启清场"的运维噩梦,转变成一个"事前预防、事中监控、事后自愈"的工程闭环——用统一的加锁顺序从源头掐断环路、用持续的监控和死锁日志分析让每一次死锁都无所遁形、用自动重试为偶发的漏网之鱼兜底,会做高并发数据库的团队,从不靠运气和重启去躲避死锁,而是用一套确定性的工程纪律,让死锁要么压根无法形成、要么发生了也能被悄无声息地自动化解。

五、行锁精度与热点行:从无索引锁表 + 热点账户串行到走索引精准 + 余额分桶

第五仗,是解决两个让 InnoDB 行锁威力大打折扣、甚至演变成性能灾难的问题:一个是更新条件不走索引导致行锁退化成"锁住一大片",另一个是热点行被高并发更新导致请求在单行上排长队。古早时代我们犯的第一个错误是想当然地以为"InnoDB 是行级锁,所以更新操作只会锁住我要改的那一行、不影响别人",可我们忽略了一个关键前提:InnoDB 的行锁是加在索引上的,只有当你的更新或加锁查询的 WHERE 条件能够命中索引、精确定位到具体的行时,它才会只锁那几行,而一旦你的 WHERE 条件没有走索引(比如条件字段上压根没建索引、或者写法导致索引失效),数据库就只能退而求其次地去做全表扫描来找到符合条件的行,而在这个扫描的过程中,它会把扫描路径上的大量行(在某些情况下几乎是全表)都锁住,于是一条本以为只锁一行的 UPDATE,实际上锁住了成千上万行,把其他本来毫不相干的更新全都阻塞了。第二个错误是面对热点行束手无策——某些数据天生就是热点,比如一个超级大商家的账户余额行,大促时每秒有成千上万笔订单都要去更新它,而对同一行的更新必须串行(后一个要等前一个释放行锁),于是这一行就成了整个系统的瓶颈,所有针对它的更新请求排成一条长龙、一个一个慢慢来,吞吐量被死死限制在单行更新的速度上。现代做法是:对行锁精度,确保所有更新和加锁查询的 WHERE 条件都走索引,让行锁精准地只锁命中的行;对热点行,采用"分桶"的思路把一个热点行拆成多个分桶行,把对单行的集中更新分散到多行上去并行,读取时再把各分桶的值合并起来。下面是这两个问题的解法:

-- 重构前问题1:更新条件不走索引,行锁退化成扫描路径上锁住一大片甚至全表
-- UPDATE orders SET status = 2 WHERE biz_no = 'X123';  -- biz_no 没建索引!
-- ↑ 全表扫描找行,扫描路径上大量行被锁,其他无关更新全被阻塞

-- 重构后解法1:给 WHERE 条件字段建索引,让行锁精准只锁命中行
CREATE INDEX idx_biz_no ON orders(biz_no);              -- 条件字段加索引
UPDATE orders SET status = 2 WHERE biz_no = 'X123';     -- 走索引,只锁命中的那几行
EXPLAIN UPDATE orders SET status = 2 WHERE biz_no = 'X123'; -- 确认 type 不是 ALL、用到了索引

-- 重构前问题2:热点账户单行被高并发更新,所有请求在这一行上排长队串行
-- UPDATE account SET balance = balance + ? WHERE id = 100;  -- 大商家 id=100 每秒上万次
-- ↑ 同一行更新必须串行,行锁让请求排成长龙,吞吐被卡在单行更新速度

-- 重构后解法2:余额分桶,把热点单行拆成 N 个分桶行,更新随机打散到不同桶并行
-- 建分桶表:一个账户对应 N 行(bucket 0..N-1)
-- CREATE TABLE account_bucket (
--   acct_id BIGINT, bucket INT, balance DECIMAL(20,2),
--   PRIMARY KEY (acct_id, bucket) );
-- 更新:随机选一个桶更新,把热点分散到 N 行,N 倍并行度
UPDATE account_bucket
SET balance = balance + ?
WHERE acct_id = 100 AND bucket = ?;                     -- bucket = 随机 0..N-1
-- 读取总余额:把该账户所有桶的余额求和合并
SELECT SUM(balance) FROM account_bucket WHERE acct_id = 100;
-- ↑ 原本挤在一行的上万次更新,被分散到 N 个桶行并行,单行锁瓶颈被打散

行锁精度与热点行治理让我们从"想当然地以为 InnoDB 是行级锁所以更新操作只会锁住我要改的那一行不影响别人、却忽略了 InnoDB 的行锁是加在索引上的只有当更新或加锁查询的 WHERE 条件能够命中索引精确定位到具体的行时它才会只锁那几行、而一旦 WHERE 条件没有走索引数据库就只能退而求其次去做全表扫描来找符合条件的行而在这个扫描过程中它会把扫描路径上的大量行甚至几乎全表都锁住于是一条本以为只锁一行的 UPDATE 实际上锁住了成千上万行把其他本来毫不相干的更新全都阻塞了、以及面对热点行束手无策某些数据天生就是热点比如一个超级大商家的账户余额行大促时每秒有成千上万笔订单都要去更新它而对同一行的更新必须串行于是这一行就成了整个系统的瓶颈所有针对它的更新请求排成一条长龙吞吐量被死死限制在单行更新的速度上"进化到了"对行锁精度确保所有更新和加锁查询的 WHERE 条件都走索引让行锁精准地只锁命中的行、对热点行采用分桶的思路把一个热点行拆成多个分桶行把对单行的集中更新分散到多行上去并行读取时再把各分桶的值合并起来":过去我们对 InnoDB 行锁有一个极其危险的误解,我们以为"行锁"这个词就意味着"我执行一条 UPDATE,数据库就只会锁住我这条语句要修改的那一行记录",所以我们写更新语句时从不担心会锁到别人,觉得行锁天然就是精确制导、只伤目标的,可我们完全不知道这个"精确"是有前提条件的——InnoDB 实现行锁的方式,并不是直接锁住数据行本身,而是锁住索引记录,这意味着,只有当你的 WHERE 条件能够通过索引快速、精确地定位到要操作的那几行时,数据库才能只在这几行对应的索引记录上加锁,做到真正的精确锁定,而如果你的 WHERE 条件压根没有索引可走(比如条件用的字段没建索引,或者用了函数、类型转换等导致索引失效的写法),数据库就无法精确定位,只能老老实实地从头到尾扫描整张表来逐行判断哪些符合条件,而在这个全表扫描的漫长过程中,为了保证一致性,它会把扫描所经过的大量索引记录(在 RR 隔离级别下还包括它们之间的间隙)都加上锁,结果就是,我们写的一条自以为只锁一行的 UPDATE,因为条件没走索引,实际效果竟然是把整张表的大部分行都锁住了,所有其他想更新这张表里任何一行的事务,全都被这条语句给阻塞了,这种"行锁退化成近似表锁"的现象,曾经是我们高峰期数据库突然大面积锁等待的一个隐蔽元凶;另一个我们长期无解的难题是热点行,有些数据的访问就是会极度地集中到某一行上,最典型的就是平台上那几个交易量巨大的头部商家的账户余额,在大促这种流量洪峰下,可能每一秒钟都有成千上万笔成交需要去更新这同一个商家的余额行,而数据库为了保证余额这个数字的正确性,对同一行的并发更新是必须串行化的——第二个更新必须等第一个更新提交、释放了这一行的行锁之后才能进行,于是这成千上万笔更新就只能在这一行上排起一条望不到头的长队,一个接一个地慢慢处理,这一行的更新速度,就成了整个系统吞吐的天花板,任凭我们加多少机器、开多少并发,都卡在这一个热点行上动弹不得;现在我们对这两个问题都有了清晰的解法,对于行锁精度,我们立下规矩:任何带 WHERE 条件的 UPDATE、DELETE 以及 SELECT ... FOR UPDATE,都必须确保其 WHERE 条件能够走索引,我们会用 EXPLAIN 去检查每一条这样的语句的执行计划,确认它确实用上了索引、而不是在做全表扫描,对于那些条件字段,该建索引的一定把索引建上,从而保证行锁始终是精准的、只锁真正命中的那几行,绝不让它退化成大范围的锁;对于热点行,我们引入了"分桶"这个巧妙的思路来给热点降温,核心做法是把原来集中在一行上的数据,人为地拆分到多行上去:还以大商家的账户余额为例,我们不再用一行来存它的总余额,而是为它开辟 N 个"余额桶",每个桶是一行、各自存余额的一部分,当有一笔成交需要增加它的余额时,我们不再是所有请求都去抢更新那唯一的一行,而是随机地、或者按某种规则地,挑选 N 个桶中的某一个去更新,这样原本要全部挤在一行上串行排队的成千上万次更新,就被均匀地分散到了 N 个不同的桶行上,这 N 个桶行可以被同时、并行地更新而互不阻塞,系统对这个热点账户的更新吞吐量,一下子就提升了将近 N 倍,而当我们需要知道这个账户的总余额时,只要把它名下所有 N 个桶的余额用 SUM 求和一下,合并起来就是准确的总数,用一点点读取时的合并成本,换来了写入时数倍的并发能力。我们的纪律是"所有 UPDATE/DELETE/SELECT FOR UPDATE 的 WHERE 条件必须走索引、上线前用 EXPLAIN 确认不是全表扫描以防行锁退化成锁一大片,识别出的热点行(头部账户、全局计数器等)用分桶拆成多行分散并发、读时 SUM 合并,绝不让无索引的更新和未拆分的热点行成为高峰期的锁瓶颈"。行锁精度与热点行的本质认知是:InnoDB 的行锁虽然名为"行"锁,但它的精度完全依赖于索引——索引走得准,锁就锁得精,索引一旦失效,行锁就会失控地蔓延成大范围的锁,所以"让更新走索引"不只是查询性能的要求,更是并发安全的底线;而热点行问题则揭示了一个更深的道理:对同一份数据的并发写入能力,天然受限于"对单行更新必须串行"这条物理铁律,想要突破它,唯一的办法就是从数据模型上动手、把"一份集中的热点数据"拆散成"多份分散的数据",用空间换并发、用分桶换吞吐;治理这两个问题的智慧,共同指向一点——高并发下数据库的性能,往往不取决于你加了多少资源,而取决于你的锁有多精准、你的热点有多分散,会做高并发数据库的工程师,既会用索引把每一把行锁都打磨得精准锋利、绝不让它退化伤及无辜,也会用分桶把每一个滚烫的热点都摊开冷却、绝不让它成为卡住整个系统的那一个点。

六、长事务:从迟迟不提交持锁堆 undo 到短事务 + 长事务监控告警

第六仗,是把那些迟迟不肯提交、像幽灵一样长期游荡在数据库里的长事务清理干净。古早时代我们对"一个事务开了之后应该尽快提交"这件事毫无意识,代码里到处都是长事务的温床:有的事务里夹着前面说的远程调用,一卡就是几秒;有的事务里在做大批量的数据处理,一个事务里循环更新成千上万行,跑几分钟才提交;还有更隐蔽的,是有些地方因为代码逻辑或异常处理的疏漏,事务开了之后在某些分支上既没提交也没回滚,就那么一直挂着,直到连接超时。这些长事务的危害是多方面且深远的:首先,它在整个存活期间会一直持有它已经获取的所有锁,别的事务想动这些被锁的行就只能干等,长事务越长,它阻塞别人的时间就越长;其次,InnoDB 为了支持 MVCC 和事务回滚,会保留被修改数据的旧版本(undo log),而一个旧版本能不能被清理掉,取决于还有没有比它更早的、可能需要读到它的活跃事务存在,只要有一个长事务一直不结束,它之前的那些本该被清理的 undo 旧版本就都不能清理、只能不断堆积,导致 undo 表空间膨胀、MVCC 的版本链越拉越长,而版本链一长,其他事务的快照读为了找到自己该看的那个版本,就要顺着越来越长的链一个个回溯,读性能也随之劣化,可以说一个长事务能拖慢的是整个库。现代做法是双管齐下:一是从写法上根除,让每一个事务都尽可能地短小精悍、快速提交,绝不在事务里夹任何慢操作、绝不用一个大事务去处理海量数据(改成分批小事务);二是从监控上兜底,持续地监控数据库里是否存在运行时间超过阈值的长事务、以及未提交事务的最长存活时间,一旦发现有事务开了很久还不提交,立即告警,揪出那段有问题的代码。长事务治理让我们从"对一个事务开了之后应该尽快提交这件事毫无意识代码里到处都是长事务的温床:有的事务里夹着远程调用一卡就是几秒、有的事务里在做大批量数据处理一个事务里循环更新成千上万行跑几分钟才提交、还有更隐蔽的是有些地方因为代码逻辑或异常处理的疏漏事务开了之后在某些分支上既没提交也没回滚就那么一直挂着、而这些长事务的危害是多方面且深远的它在整个存活期间会一直持有它已经获取的所有锁别的事务想动这些被锁的行就只能干等、InnoDB 为了支持 MVCC 和事务回滚会保留被修改数据的旧版本只要有一个长事务一直不结束它之前那些本该被清理的 undo 旧版本就都不能清理只能不断堆积导致 undo 表空间膨胀 MVCC 版本链越拉越长其他事务的快照读为了找到自己该看的版本就要顺着越来越长的链一个个回溯读性能随之劣化"进化到了"双管齐下:一是从写法上根除让每一个事务都尽可能短小精悍快速提交绝不在事务里夹任何慢操作绝不用一个大事务去处理海量数据改成分批小事务、二是从监控上兜底持续监控数据库里是否存在运行时间超过阈值的长事务以及未提交事务的最长存活时间一旦发现有事务开了很久还不提交立即告警揪出那段有问题的代码":过去我们完全没把"事务的存活时长"当回事,在我们朴素的认知里,一个事务无非就是"开始、做事、结束"这么个过程,至于它从开始到结束中间隔了多久,我们觉得无所谓,反正最后总会结束的,正是这种麻木,让我们的代码里滋生出了形形色色的长事务:最常见的是把远程调用包进了事务(这个前面已经痛陈过),其次是批处理场景下的巨型事务,比如要给几万个用户发放优惠券,我们图省事就用一个事务把这几万次插入全包起来,觉得这样有原子性、要么全成功要么全失败,却没想到这个事务会持续运行好几分钟、在这几分钟里持有大量的锁、并阻止 undo 的清理,还有一类是最阴险的"被遗忘的事务",由于异常处理写得不严谨,某些异常分支下事务既没走到提交、也没走到回滚,就被晾在那里成了一个一直占着连接和锁的僵尸,我们对这些长事务在背后所造成的破坏长期一无所知,只是隐约觉得数据库有时候会莫名其妙地变慢、锁等待会莫名其妙地变多、磁盘空间会莫名其妙地被 undo 占掉一大块,却从没把这些现象和长事务联系起来;后来我们才痛彻地理解到,长事务对数据库的伤害是系统性的:它持有的锁会无谓地阻塞并发,它的存在会卡住 undo log 的清理(因为数据库不敢清理任何可能还会被这个老事务用快照读读到的旧版本)、导致 undo 不断膨胀和 MVCC 版本链变长、进而拖慢全库的读性能,它占用的连接也是对连接池资源的长期侵占,一个长事务,就像一个迟迟不肯离场的客人,占着座位、占着资源、还碍着其他所有人做事;现在我们对事务时长建立起了高度的敏感和严格的纪律:在写法上,我们追求把每一个事务都做到极致的短,事务里只放那几条必须原子的、能瞬间完成的 DB 操作,任何慢操作一律请出事务,对于批量处理海量数据的场景,我们坚决不用一个大事务硬扛,而是拆分成一批一批的小事务、每处理一小批就提交一次,用多个短事务替代一个长事务,既控制了单个事务的时长和持锁量、也让 undo 能够被及时清理;在监控上,我们给数据库装上了长事务的探针,持续地查询 information_schema 里的事务信息,盯着有没有哪个事务的运行时间超过了我们设定的阈值(比如几秒钟)、盯着当前未提交事务里最老的那个已经存活了多久,一旦有长事务冒头,监控立刻告警,我们就能顺藤摸瓜地揪出是哪段代码、在什么场景下开出了这个长事务,把它扼杀掉。我们的纪律是"每个事务都要短小快速提交、事务里只放能瞬间完成的必须原子的 DB 操作,批量处理海量数据必须拆成分批小事务而非一个大事务硬扛,严格审查异常分支确保事务在任何路径下都能正确提交或回滚杜绝僵尸事务,持续监控 information_schema 里的长事务和最老未提交事务时长并设阈值告警,把长事务当成会拖慢全库的隐患来对待"。长事务的本质认知是:事务的"长度"绝不是一个无关紧要的细节,而是直接决定它对数据库伤害程度的关键变量——一个事务存活得越久,它阻塞并发的锁就持有得越久、它卡住的 undo 清理就堆积得越多、它拉长的 MVCC 版本链就拖慢得越广,长事务的危害是会向整个数据库扩散的;治理长事务的智慧,在于深刻认识到"短事务"不仅仅是一种写法上的偏好,更是高并发数据库的一条生命线——把大事务拆成小事务、把慢操作赶出事务、把僵尸事务监控起来,本质上都是在缩短每一个事务持有资源、影响他人的那个时间窗口,会做高并发数据库的工程师,对待事务就像对待一个借用公共资源的契约,借得越快还得越好,绝不允许任何一个事务长期霸占着锁和版本资源、拖累整个数据库的健康。

七、并发丢失更新:从 read-modify-write 裸操作到乐观锁 + 原子 UPDATE

第七仗,是堵上"丢失更新"这个在高并发下悄无声息吞掉数据的漏洞。古早时代我们处理"在原值基础上修改"这类操作时(比如给库存减一、给计数器加一、在原余额上累加),写法极其朴素直白,就是经典的 read-modify-write 三部曲:先用一条 SELECT 把当前值从数据库读到应用内存里(read),然后在应用代码里用这个值做计算得出新值(modify),最后再用一条 UPDATE 把算出来的新值整个写回数据库(write)。这套写法在单线程下毫无问题,可一旦遇到并发就会出现致命的"丢失更新":设想库存原本是 10,两个请求几乎同时到来都要减 1,请求 A 读到库存是 10、请求 B 也读到库存是 10(因为 A 还没来得及写回),然后 A 算出新值 9 写回、B 也算出新值 9 写回,两次扣减最后库存只减了 1、变成 9 而不是正确的 8,B 的那次扣减被 A 覆盖、凭空丢失了,这就是丢失更新,在高并发的库存扣减、计数统计、余额变动场景里,它会导致超卖、计数偏少、账目不平这些极其严重的数据正确性问题,而且因为它是偶发的、和并发时序相关的,事后极难复现和排查。现代做法有两条路:一是用前面讲过的乐观锁,在 read 的时候连版本号一起读出,write 的时候用版本号做 CAS 校验,一旦发现版本变了说明被人抢先改过、本次作废重试,从而保证不会用一个基于过期数据算出的值去覆盖别人的修改;二是更直接、对这类场景更高效的——把"读-改-写"三步合并成数据库里的一条原子 UPDATE 语句,不再把当前值读到应用里去算,而是直接让数据库在它自己内部、在持有行锁的状态下完成"取当前值并在其上修改"这个动作,比如扣库存就直接写"UPDATE ... SET stock = stock - 1 WHERE id=? AND stock >= 1",计数就直接写"UPDATE ... SET cnt = cnt + 1 WHERE id=?",由于这条 UPDATE 是数据库的一个原子操作、整个过程对该行加锁、不会被别的并发操作插入打断,所以彻底杜绝了丢失更新。并发丢失更新治理让我们从"处理在原值基础上修改这类操作时写法极其朴素直白就是经典的 read-modify-write 三部曲先用一条 SELECT 把当前值从数据库读到应用内存里然后在应用代码里用这个值做计算得出新值最后再用一条 UPDATE 把算出来的新值整个写回数据库、这套写法在单线程下毫无问题可一旦遇到并发就会出现致命的丢失更新:库存原本是 10 两个请求几乎同时到来都要减 1 请求 A 读到库存是 10 请求 B 也读到库存是 10 然后 A 算出新值 9 写回 B 也算出新值 9 写回两次扣减最后库存只减了 1 变成 9 而不是正确的 8 B 的那次扣减被 A 覆盖凭空丢失、在高并发的库存扣减计数统计余额变动场景里它会导致超卖计数偏少账目不平这些极其严重的数据正确性问题而且因为它是偶发的和并发时序相关的事后极难复现和排查"进化到了"两条路:一是用乐观锁在 read 的时候连版本号一起读出 write 的时候用版本号做 CAS 校验一旦发现版本变了说明被人抢先改过本次作废重试、二是更直接对这类场景更高效的把读改写三步合并成数据库里的一条原子 UPDATE 语句不再把当前值读到应用里去算而是直接让数据库在它自己内部在持有行锁的状态下完成取当前值并在其上修改这个动作":过去我们写库存扣减、积分累加这类代码,几乎是不假思索地就用了那套最符合直觉的"读出来-改一改-写回去"的流程,因为这个流程读起来天经地义、和我们脑子里想这件事的方式一模一样,我们完全没有意识到,正是这个看似无害的流程里,藏着一个会在并发下吃掉数据的黑洞——问题的核心在于,从我们 read 读出当前值的那一刻,到我们 write 把新值写回的那一刻,这中间有一个时间窗口,而在这个窗口里,我们手里攥着的那个"当前值"随时可能已经被别的并发请求给改掉、变成过期的脏数据了,可我们浑然不觉,还在用这个过期的值去做计算、然后用算出的结果去覆盖数据库里那个可能已经被别人更新过的最新值,于是别人的更新就被我们这一覆盖给抹掉了、丢失了,在低并发时这个时间窗口里恰好有别人也来改同一行的概率很低,所以问题很少暴露,可一到大促这种高并发场景,成百上千个请求挤在同一个热点库存上你读我也读、你写我也写,丢失更新就频繁地发生,直接的后果就是超卖——明明库存的扣减记录有 100 条,可库存数字只减少了 60,凭空多卖了 40 件,等到发货时才发现根本没那么多货,引发客诉和赔付,而我们去排查时,因为这是并发时序问题、根本无法稳定复现,只能对着对不上的账目干着急;现在我们彻底改掉了这种危险的裸 read-modify-write 写法,根据场景选用两种安全的方案,对于那些"改"的逻辑比较复杂、需要在应用层做一堆判断和计算的场景,我们用乐观锁,read 时把版本号一起读出来揣好,write 时在 UPDATE 的条件里带上"版本号必须还等于我读到的那个值",这样如果在我计算的窗口期里有人改过(版本号必然已经变了),我的这条 UPDATE 就会落空、影响 0 行,我便知道自己手里的值已经过期、于是丢弃重来,绝不会用过期值去覆盖别人;而对于库存减一、计数加一这种"改"的逻辑很简单、就是在原值上做个加减的场景,我们用更干脆利落的原子 UPDATE,根本不把值读到应用层来,而是把整个"在当前值基础上加减"的动作,写成一条 UPDATE 语句直接交给数据库去原子地完成,比如扣库存写成 SET stock = stock - 1 WHERE id = ? AND stock >= 1(后半个条件还顺便防住了超卖到负数),数据库执行这条语句时会对这一行加锁、保证"读取 stock 当前值、减 1、写回"这一连串动作是不可分割的原子操作、中间绝无可能被其他并发操作插足,从根上消灭了丢失更新的窗口。我们的纪律是"严禁用裸的 read-modify-write(读到应用层算完再写回)去处理并发会修改的共享数据,简单的加减类更新一律用原子 UPDATE SET x = x ± ? 交给数据库原子完成(库存扣减再加 stock >= n 条件防超卖),需要复杂计算的更新用乐观锁版本号 CAS 保护并能正确重试,把丢失更新当成高并发下必然发生的数据正确性事故来严防"。并发丢失更新的本质认知是:read-modify-write 这个最符合人类直觉的更新流程,恰恰是并发编程里最经典的陷阱之一——它的致命弱点在于"读"和"写"被拆成了两步、中间留出了一个数据可能被他人篡改的时间窗口,而单线程的直觉让我们对这个窗口的存在毫无警觉;堵住丢失更新的智慧,在于消灭那个危险的时间窗口——要么用原子 UPDATE 让"读改写"在数据库内部一气呵成、不留窗口(适用于简单加减),要么用乐观锁的版本校验,让任何基于窗口期内过期数据的写入都无法得逞、被迫重试(适用于复杂计算),会做高并发数据库的工程师,对任何"在原值基础上修改共享数据"的操作都本能地保持警惕,绝不天真地用裸读写去处理,因为他们深知,在高并发下,凡是存在那个时间窗口的地方,丢失更新就一定会发生,只是迟早而已。

八、锁与事务的可观测:从出事才 show processlist 抓瞎到 performance_schema 持续度量

第八仗,是把数据库里的锁、事务、等待这些原本完全看不见的内部状态,变成持续可度量、可告警的明牌。古早时代我们对数据库内部正在发生什么几乎是全盲的,平时根本不去关心有多少活跃事务、有没有锁等待、有没有长事务,只有当线上已经出了问题——服务大面积超时、数据库连接被占满、CPU 飙高——我们才如梦初醒地慌忙登上数据库,敲一个 show processlist 出来,瞪着那一长串当前连接列表,试图从里面那些状态、那些正在执行的 SQL 中,用肉眼去找出到底是哪个连接、哪个事务、哪条语句在捣乱,可这种临时的、靠肉眼扫一眼当前快照的排查方式问题极大:一是它只能看到敲命令那一瞬间的状态、缺乏连续的历史、根本看不出问题是从什么时候、怎么一步步恶化到现在这个地步的,二是 show processlist 给出的信息相当有限和粗糙、它能告诉你有哪些连接在跑什么 SQL、却很难清晰地告诉你谁在等谁的锁、这个锁等待的链条是怎样的、哪个事务是阻塞的源头,我们常常对着满屏的连接列表却理不出头绪,三是等我们反应过来、登录上去、敲出命令的时候,往往那个最尖锐的故障瞬间已经过去了、现场已经变了。现代做法是把数据库的锁和事务状态纳入持续的可观测体系:充分利用 MySQL 的 performance_schema 和 information_schema 这两个内置的"数据库自省"库,它们里面有专门的表实时地暴露着数据库内部的运行状态——比如通过 data_locks 表能看到当前都有哪些锁、加在哪些行上、被哪些事务持有,通过 data_lock_waits 表能精确地看到锁等待关系、谁在等谁、阻塞源头是谁,通过 innodb_trx 相关信息能看到所有活跃事务、它们各自运行了多久,我们用监控系统持续地、周期性地去采集这些表里的关键指标(活跃事务数、锁等待数、最长事务运行时间、死锁次数等),把它们画成带历史趋势的监控大盘、并配上告警规则,这样数据库内部的健康状况就持续地、透明地呈现在我们面前,锁等待刚开始堆积、长事务刚一冒头、死锁次数刚有抬升,告警就会第一时间触发,我们就能在问题还是苗头的时候就介入,而不是等它演变成大面积故障后再去抓瞎。锁与事务可观测让我们从"对数据库内部正在发生什么几乎是全盲的平时根本不去关心有多少活跃事务有没有锁等待有没有长事务只有当线上已经出了问题服务大面积超时数据库连接被占满 CPU 飙高才如梦初醒地慌忙登上数据库敲一个 show processlist 出来瞪着那一长串当前连接列表试图用肉眼去找出到底是哪个连接哪个事务哪条语句在捣乱、可这种临时的靠肉眼扫一眼当前快照的排查方式问题极大它只能看到敲命令那一瞬间的状态缺乏连续的历史根本看不出问题是从什么时候怎么一步步恶化到现在的、show processlist 给出的信息相当有限和粗糙很难清晰告诉你谁在等谁的锁这个锁等待的链条是怎样的哪个事务是阻塞的源头、等我们反应过来登录上去敲出命令的时候往往那个最尖锐的故障瞬间已经过去了现场已经变了"进化到了"把数据库的锁和事务状态纳入持续的可观测体系:充分利用 MySQL 的 performance_schema 和 information_schema 这两个内置的数据库自省库它们里面有专门的表实时地暴露着数据库内部的运行状态通过 data_locks 表能看到当前都有哪些锁加在哪些行上被哪些事务持有通过 data_lock_waits 表能精确看到锁等待关系谁在等谁阻塞源头是谁通过 innodb_trx 能看到所有活跃事务各自运行了多久、用监控系统持续周期性地采集这些表里的关键指标画成带历史趋势的监控大盘并配上告警规则":过去我们对数据库的态度,和对待很多系统一样,是典型的"不出事不管、出了事乱抓"——在数据库平稳运行的时候,它内部到底有多少个事务在并发地跑、它们之间有没有锁的争用和等待、有没有哪个事务开了很久还赖着不走,这些至关重要的内部状态,我们是完全不看、也没有任何手段去持续看的,数据库对我们来说就是一个只要不报错就不去搭理的黑盒子,而一旦这个黑盒子出了状况、表现为上层服务的超时和连接耗尽,我们的第一反应、也是当时唯一会的反应,就是赶紧 SSH 登录上去、敲出 show processlist 这条命令,然后对着它吐出来的当前所有数据库连接的列表——每一行显示一个连接正在执行的命令、状态、以及已经执行了多久——用我们的肉眼一行一行地扫,试图凭经验从这几十上百行里找出那个"看起来不太对劲"的连接,猜测是不是它锁住了别人,这种排查方式的无力感是全方位的:首先它给我们的永远只是一张"此刻"的快照,我们看不到这张快照之前的任何历史,所以根本无法判断现在这个糟糕的状态是刚刚突然发生的、还是已经缓慢恶化了很久,更找不到那个最初引爆问题的源头时刻,其次,show processlist 这个工具本身能提供的信息就很初级,它会列出连接和它们在跑的 SQL,但对于我们最想知道的"锁的持有和等待关系"——也就是到底是哪个事务持有了哪把锁、又有哪些事务正卡在那里等着这把锁、整个阻塞链条是怎么传导的——它几乎无能为力,我们常常能看到一堆连接的状态是"等待锁",却很难快速理清这堆等待背后真正的那个始作俑者是谁,最后,这种纯人工、纯临时的方式严重依赖出事时人的及时响应和现场的肉眼分析,而等我们收到上层告警、回过神来、连上数据库、敲出命令的这几分钟里,那个最关键的故障爆发现场可能早已时过境迁;现在我们把数据库的锁与事务彻底纳入了主动的、持续的可观测体系,我们不再依赖出事时的临时抓取和肉眼分析,而是用好 MySQL 自带的 performance_schema 和 information_schema 这两个堪称"数据库自省之眼"的系统库,它们以表的形式,实时地、结构化地暴露出数据库内部丰富的运行状态:data_locks 表清清楚楚地列出了当前系统里存在的每一把锁、它锁的是哪个表的哪个索引记录、是被哪个事务持有的,data_lock_waits 表则直接给出了锁的等待关系——哪个正在等待的事务,在等哪个正在持有锁的事务,把阻塞的因果链条明明白白地呈现出来,让我们一眼就能定位到阻塞的源头事务,而 innodb_trx 相关的视图则列出了当前所有活跃的事务、每个事务开始了多久、执行到了哪一步,我们让监控系统周期性地、自动地去查询和采集这些表里的关键信息,提炼出活跃事务总数、当前锁等待的数量、运行时间最长的事务已经跑了多久、累计死锁发生了多少次等核心指标,把它们送进监控系统、绘制成一块块带有完整历史趋势曲线的大盘,并针对这些指标设置好告警阈值,于是数据库内部那些曾经对我们完全不可见的锁与事务的动态,如今变成了大盘上持续刷新的、可回溯历史的客观曲线,任何异常——锁等待数量开始不正常地攀升、出现了运行时间超长的事务、死锁计数突然跳增——都会在它酿成大面积故障之前,就被监控曲线和告警第一时间捕捉到,把我们的应对从"事后登录抓瞎"提前到了"事前苗头预警"。我们的纪律是"善用 performance_schema 的 data_locks/data_lock_waits 看清锁的持有与等待链条、用 innodb_trx 看活跃事务和时长,把活跃事务数/锁等待数/最长事务时长/死锁次数等关键指标持续采集进监控做大盘和告警,可观测要平时就持续建好而非出事才临时抓,排查锁问题优先看锁等待关系定位阻塞源头而非肉眼扫 processlist 猜,让数据库内部状态持续透明"。锁与事务可观测的本质认知是:数据库内部的锁争用、事务堆积、死锁这些动态,是高并发系统稳定性的命门所在,而它们又恰恰是最难凭直觉感知、最容易被当成黑盒忽视的——一个不去主动观测数据库内部锁与事务状态的团队,等于是闭着眼睛在高速公路上开车,只有等撞了车(服务超时崩溃)才知道刚才出了事,却永远不知道是怎么撞的、还会不会再撞;建立锁与事务可观测的智慧,在于借助数据库自身提供的自省能力(performance_schema / information_schema),把那些深藏在引擎内部的锁与事务状态持续地抽取出来、变成有历史、有趋势、能告警的明牌,从而把对并发问题的应对,从被动、滞后、靠猜的"事后抓瞎",彻底转变为主动、持续、精准的"事前预警和快速定位",会运维高并发数据库的团队,从不把数据库当成一个只看错误日志的黑盒,而是让它内部锁与事务的每一次脉动,都持续透明地呈现在监控大盘之上。

九、7 个 P0 事故复盘

7 事故:(1) 一次大促一个把第三方支付调用包在事务里的下单方法,在支付接口变慢到几秒时,大量订单事务长时间持有账户行锁,锁等待瀑布式堆积、连接池被占满、整个交易服务超时雪崩,事后把远程调用全部挪出事务、事务只包 DB 写;(2) 一个热点大商家账户余额行在大促高峰被每秒上万笔成交更新,行锁让请求排成长龙、更新吞吐卡死,引入余额分桶把单行拆成 N 行分散并发后吞吐提升数倍;(3) 一条更新条件字段没建索引的 UPDATE 在高峰执行,行锁退化成近乎全表锁,把整张订单表的所有更新全阻塞,补上索引、并立规所有写条件必须 EXPLAIN 确认走索引;(4) 库存扣减用裸的 read-modify-write,大促并发下丢失更新导致严重超卖、发不出货引发大量客诉,改成原子 UPDATE stock=stock-1 WHERE id=? AND stock>=1;(5) 两段更新多行的代码加锁顺序相反,高峰期频繁死锁、大批事务失败,统一改成按主键升序加锁并加死锁自动重试;(6) 一个批量发券任务用一个大事务循环插入几万行,跑了好几分钟,期间持锁、阻塞 undo 清理、拖慢全库读,拆成分批小事务后恢复;(7) 一处异常分支事务既没提交也没回滚形成僵尸长事务,长期占着连接和锁,加长事务监控告警后被揪出修复。每个 P0 都做 5-Why 复盘,固化成事务边界规约、热点分桶基线、写操作索引红线或长事务监控规范,确保同类问题不再复发。

十、高并发数据库工程师的 6 条工程哲学

6 哲学:(1) 事务越短越好——事务的代价是持锁阻塞他人,范围小到只包必须原子的 DB 写、存活短到毫秒级,绝不让远程调用和慢操作绑架数据库锁;(2) 锁策略要匹配冲突概率——读多写少冲突罕见用乐观锁放开并发,写冲突激烈用悲观锁守住秩序,不分场景一律 FOR UPDATE 是把并行无脑串行化;(3) 行锁的精度全靠索引——写操作条件必须走索引否则行锁退化成锁一大片,EXPLAIN 是写操作和查询一样的必修;(4) 热点要分散——对单行的并发写受限于物理串行,头部账户和全局计数器用分桶拆成多行,用空间换并发;(5) 并发更新必有丢失更新陷阱——裸 read-modify-write 在并发下必丢更新,简单加减用原子 UPDATE、复杂计算用乐观锁 CAS;(6) 锁与事务要可观测——performance_schema 持续度量锁等待和长事务,出事才 show processlist 抓瞎既滞后又理不清阻塞链。这 6 条哲学,是我们用 7 个 P0 事故和 87 天攻坚换来的集体共识。它们共同指向一个认知:高并发数据库的瓶颈往往不在硬件、而在我们是否理解事务、锁、MVCC 的运行机理——会用数据库的团队,用短事务、合适的锁策略、精准的索引、分散的热点、原子的更新和持续的可观测这套与数据库并发机理相契合的方法,把它的并发能力压榨到极致,而不是用大事务、无脑悲观锁、无索引更新、未拆分的热点、裸读写的粗放做法去糟蹋它、再抱怨数据库扛不住高并发。

十一、重构收益的量化:7 个关键数字

7 数字:(1) 高峰锁等待:大事务持锁导致锁等待瀑布堆积连接池占满 → 事务瘦身后高峰锁等待数降一个数量级;(2) 热点账户更新吞吐:单行串行卡死 → 余额分桶后吞吐提升数倍;(3) 死锁发生频率:加锁顺序混乱频繁死锁 → 统一顺序加重试后死锁基本归零;(4) 超卖事故:裸 read-modify-write 丢失更新导致超卖 → 原子 UPDATE 后超卖归零账目对平;(5) 行锁影响范围:无索引更新锁住近全表 → 走索引后只锁命中行并发更新互不阻塞;(6) 长事务拖累:大事务持锁堆 undo 拖慢全库 → 拆分小事务后 undo 可控读性能恢复;(7) 锁问题定位时长:出事 show processlist 肉眼抓瞎常耗时数十分钟 → performance_schema 监控后分钟级定位阻塞源头。这些数字背后,是 87 天里 8 个人一个事务一个事务地瘦身、一个热点一个热点地分桶、一条 SQL 一条 SQL 地确认索引,但每一个都实打实地转化成了高峰稳定性、并发吞吐和数据正确性的提升。当我们把这份数据汇报给管理层时,最有说服力的不是任何数据库名词,而是"大促高峰再没因为锁等待雪崩过、半夜不再被超卖和死锁告警叫醒、同样的数据库扛住了几倍的并发交易、锁问题分钟级就能定位"这几条。

十二、留给后来者的最后一句话

87 天的数据库事务与并发现代化战役,我们走过的不只是一条从大事务持锁雪崩到短事务、从无脑悲观锁到乐观锁、从死锁靠重启到统一加锁顺序加重试、从无索引锁表到走索引精准、从热点单行串行到余额分桶、从裸读写丢更新到原子 UPDATE、从长事务拖垮全库到分批小事务、从出事 show processlist 抓瞎到 performance_schema 持续可观测的技术升级路,更是一次从"以为数据库就是个存数据的黑盒、能跑就行、不用懂它的事务和锁机理、用大事务无脑悲观锁裸读写去糟蹋它再怪它扛不住高并发"到"沉下心去理解它事务加锁加 MVCC 的并发机理、把它的并发能力压榨到极致"的范式跃迁。当一个曾经一到大促高峰就因大事务持锁而锁等待雪崩的交易库,在事务瘦身之后高峰稳如磐石、当一个被每秒上万笔成交压死的热点账户在分桶之后吞吐数倍跃升、当一桩因丢失更新导致的严重超卖在原子 UPDATE 之后彻底绝迹账目终于对平、当一个频繁死锁靠重启续命的系统在统一加锁顺序之后死锁归零、当一条无索引更新引发的近全表锁定在补上索引之后只锁命中行、当一个曾经只能靠 show processlist 肉眼抓瞎的锁故障在 performance_schema 监控下分钟级锁定阻塞源头的那一刻,真正让我们踏实的,不是给数据库升级了多少配置,而是'交易的稳、快、准,终于从依赖低并发的侥幸和重启的运气,变成了由短事务、合适锁策略、精准索引、热点分桶、原子更新和持续可观测这套工程方法结构性保障'的笃定。高并发数据库没有银弹,关键是理解事务边界、隔离级别与 MVCC、悲观锁与乐观锁、死锁、行锁与热点、长事务、丢失更新、可观测各自解决什么问题、又各自带来什么代价,然后从把事务瘦短、把锁策略选对这些地基做起、用索引和分桶落地——尤其要克制"图省事用大事务包住一切、图省事一律悲观锁、图省事更新不管有没有索引、图省事热点不拆、图省事裸读写、图省事出事才抓"的旧习惯,因为每一个被远程调用绑架的长事务、每一把不分场景的悲观锁、每一条没走索引的更新、每一个没被分桶的热点、每一处裸的读改写,都是在亲手给未来某次高峰锁等待雪崩、超卖、死锁或查不出根因的事故埋雷。愿每一位还在和大事务、死锁、超卖和锁等待搏斗的同行,都能早日让自己的数据库被这套并发工程方法稳稳地托住。共勉,后会有期。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

从粗放 JVM 运维 CMS 大促高峰 Full GC 十几秒 STW 把交易服务冻死大面积超时 + 默认参数裸跑从不按负载调堆和分代 + 静态 Map 缓存无上限只进不出缓慢漏到 OOM 周期性崩溃重启 + 性能问题靠几个人围一起凭印象猜哪段慢挑可疑处改改上线试盲人摸象白忙几天 + 热点路径循环里反复 new 加字符串 + 拼接加装箱海量临时对象涌入新生代 Young GC 频繁晋升加重 Full GC + 线程池核心数最大数队列长全靠拍脑袋甚至用 Executors 无界队列任务积压到 OOM + 共享数据无脑 synchronized 锁整个方法粒度极粗高并发大批线程挤一把锁排队吞吐被锁死 + 堆外 DirectByteBuffer 泄漏堆内监控一切正常进程却被容器 OOM Killer 反复杀查大半天没头绪 + 不懂 JIT 新实例冷启动全解释执行刚接流量就大量超时误判成网络问题 + 线上 JVM 黑盒平时不看出事才 SSH 上去 jmap 导几十G大堆把垂危服务彻底压垮 → 2026 现代 JVM 性能工程 G1/ZGC 低延迟回收设停顿目标 + 按负载调堆 Xms=Xmx 分代 + HeapDumpOnOutOfMemoryError 加 MAT 引用链定位泄漏加 Caffeine 缓存上限 + JFR 加 async-profiler 火焰图数据驱动定位热点 + StringBuilder 加预分配容量加避免装箱加逃逸分析减分配 + ThreadPoolExecutor 精细化有界队列加拒绝策略加命名 + ConcurrentHashMap 加 LongAdder 替代重锁 + NMT 原生内存追踪加 MaxDirectMemorySize 治堆外 + 分层编译加预热 warm-up 解决冷启动 + JFR 持续黑匣子加 Micrometer 加 Prometheus/Grafana 可观测 87 天战役复盘:47 套工程修法 + 7 个 P0 复盘 + 6 条工程哲学

2026-5-29 0:33:19

技术教程

从粗放跨网络调用天真当成调一下拿到结果不设超时无脑重试没熔断不限流不隔离 + 不设超时或设几十秒慢下游把调用线程一个个挂死耗尽线程池调用方自己也垮再沿调用链层层传导冲垮整个核心链路雪崩 + 失败就立即原地无脑固定次数猛重试给过载下游火上浇油 N 客户端乘 M 重试瞬间放大数倍把下游彻底打死死亡螺旋还对扣款下单非幂等操作重试导致重复扣款下单 + 下游已经挂了还一根筋拼命猛打无效请求堆积线程全卡在死路上白耗资源还堵死下游恢复 + 对入口流量来者不拒突发洪峰直接把服务自身资源榨干压垮容量内请求也一起玉石俱焚 + 所有下游调用共用一个线程池连接池一个无关紧要的边缘慢下游就把公共池占满连核心订单支付调用也申请不到线程全线瘫痪 + 下游一挂调用方直接把故障原样上抛一个推荐挂掉整个详情页打不开用户连看商品下单都做不了被次要功能绑架核心 + 客户端无脑轮询所有后端不感知健康把请求往挂掉卡死的实例上送间歇性报错忙的更忙 + 网络调用是黑盒出事只能逐个翻日志加 tcpdump 抓包连蒙带猜耗时数小时抓不到是哪一跳慢 → 2026 现代服务间通信韧性工程 每个调用设合理超时加全链路 deadline 剩余预算传播 + 指数退避加抖动加只重试幂等加重试预算绝不放大故障 + 熔断器失败率超阈值快速失败给下游喘息给自己止血加半开试探 + 令牌桶漏桶限流超容量快速拒绝保住核心容量 + 舱壁隔离每个下游独立线程池连接池故障关在单个舱室 + 降级 fallback 下游不可用返回兜底数据保住主流程 + 健康检查加 P2C 最少连接加异常实例自动摘除 + 每跳成功率延迟熔断状态指标加分布式链路追踪 TraceID 串起整条链路 87 天战役复盘:47 套工程修法 + 7 个 P0 复盘 + 6 条工程哲学

2026-5-29 1:00:11

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索