一、问题的起点:热点行与持锁时间
在高并发写场景中,数据库的吞吐瓶颈往往不在 CPU、磁盘,而是在热点行的行锁上。
以抽奖系统为例:某个奖池的库存只存在少数的几行库存槽记录里。同一时刻几千个请求都在抢同一个奖池,也就是说有几千个请求都在竞争这十几行记录。这,就是热点行。
行锁是互斥的:同一行同一时刻只有一个事务能够持有。那么其他想锁这行的事务都会被阻塞,只在 MySQL 内部排成等待队列。当前持锁事务提交后,队列里的下一个事务才能拿到锁,如此接力。
也就是说:队列里每个请求的等待时间 = 前面所有事务的持锁时间之和。
由此可以得出一个简单的吞吐公式:热点行上的吞吐上限 ≈ 1 / 平均持锁时间
- 持锁 10ms → 单行最多约 100 次/秒;
- 持锁 2ms → 单行最多约 500 次/秒。
事务每多一毫秒,不是浪费一个请求的一毫秒,而是浪费队列里所有请求各一毫秒。因此缩短事务持锁时间,是提高热点行并发上限最直接的手段——加机器是没用的,因为行锁在数据库层全局串行,只有缩短持锁时间才最有效的。
而一次抽奖请求在真正扣库存之前,其实需要一大堆「数据」:
- 活动有没有开始、有没有结束、是不是被暂停了?
- 这个商品参不参加活动?流水码在不在允许的码段里?
- 各奖池的概率区间是怎么分的?当前生效的是哪个配置版本?
- 这个人中过几次奖了?中奖上限是多少?
这些数据的读取(查多张配置表、调缓存、甚至调远程服务)耗时是不可控的:快的时候一两毫秒,慢的时候几十毫秒,赶上网络抖动或 GC 停顿可能更久。
如果把这些读取放进数据库事务里,会发生什么?这就是本文要解决的问题。
先说结论,今天要介绍的对策叫**「事务外预载配置」**,它和「写时 CAS 校验」是一对搭档,合起来是八个字:
读时乐观,写时验真。
下面我将逐步拆解。
二、前置知识:事务、行锁、CAS
在讨论「什么操作算进持锁时间」之前,必须先搞清楚 InnoDB 加锁、放锁的时机,以及 CAS 的基本概念。
2.1 事务
一组数据库操作,要么全部生效(提交commit),要么全部撤销(回滚rollback)。一次抽奖的「扣库存 + 写结果」必须绑成一个事务——不能出现「库存扣了但结果没写」或反之。
事务有个关键特性:从第一个写操作开始,到提交为止,它一直持有锁。
2.2 行锁与两阶段锁协议
InnoDB(MySQL 默认存储引擎)的锁粒度不是「锁整张表」,而是「锁被修改的那几行」。执行UPDATE ... WHERE slot_no = 5时,只有 slot 5 这一行被锁住,其他行不受影响。
InnoDB 遵循两阶段锁协议(2PL, Two-Phase Locking),事务的一生被分为两个阶段:
- 加锁阶段:事务执行过程中,随时可以加锁。执行到哪条语句、需要哪行的锁,就当场拿锁。
- 放锁阶段:拿到的锁一律不提前释放,攒到事务
COMMIT或ROLLBACK的那一刻,才统一全部释放。
用时间线表示:
关键点:UPDATE 一旦执行成功,那一行的锁就要一直持有到提交。UPDATE 之后你在事务里做的任何事情——哪怕只是一个慢悠悠的查询、一次缓存调用、一次对象组装——那把锁都不会松手。
所以,「事务要短」精确的含义是:
从第一个写操作(第一次拿热点行锁)到
COMMIT之间,距离越短越好。
2.3 锁队列与吞吐
行锁是互斥的。其他想锁这行的事务会被阻塞,在 MySQL 内部排成等待队列,这就是锁队列。当前持锁事务提交后,队列里的下一个事务才能拿到锁。
队列里每个请求的等待时间 = 前面所有事务的持锁时间之和。
假设热点行上排了 100 个事务,每个事务持锁 10ms:
- 第 100 个请求等待 ≈ 100 × 10ms = 1 秒。
- 如果每个事务 2ms,第 100 个请求等待 ≈ 0.2 秒。
事务超时设了 5 秒,再长就大面积超时回滚。
吞吐:单位时间能完成的请求数。而在前面就说过:热点行上的吞吐上限 ≈ 1 / 平均持锁时间。所以想提升吞吐量,最直接的方式就是缩短事务时间。
2.4 CAS(Compare-And-Swap)
CAS 是一种实现多线程/多进程同步的原子操作算法。核心逻辑:包含三个操作数——内存位置(V)、预期原值(A)和新值(B)。只有当内存位置 V 的值等于预期原值 A 时,才将内存位置的值修改为新值 B;否则不进行任何操作。
本质是一种乐观锁策略:假设冲突很少发生,因此不直接加锁阻塞线程,而是在更新时检查数据是否被他人修改过。
CAS 的三要素:
- 内存地址/主键定位 (V):确定要操作的具体数据对象。
- 预期旧值 (A):你手中持有的、认为当前应该存在的值(通常是版本号或状态值)。
- 新值 (B):你希望更新成的目标值。
判定公式:
IF (Current_Value == Expected_Value) THEN Update(Current_Value, New_Value) ELSE Fail在数据库中的典型应用:
UPDATEactivity_prize_pool_stock_slot sJOINactivity_prize_pool pONp.id=s.pool_idSETs.remaining_quantity=s.remaining_quantity-1,s.lock_version=s.lock_version+1WHEREs.pool_id=#{poolId}ANDs.slot_no=#{slotNo}ANDs.remaining_quantity>0-- 业务条件:不能超发ANDs.lock_version=#{lockVersion} -- 校验一:行没人动过ANDp.effective_pool_config_id=#{expectedPoolConfigId} -- 校验二:配置没换版V:WHERE s.pool_id = ... AND s.slot_no = ...定位具体行。A:AND s.lock_version = #{lockVersion},只有版本号一致才更新。B:SET s.lock_version = s.lock_version + 1,更新成功后版本号自增,旧值失效。
如果在此期间有其他线程成功扣减,数据库中的lock_version变成old_version + 1,WHERE条件不成立,SQL 执行结果为 0 行受影响。这就是 CAS 的失败反馈。
数据库级 CAS 的优点:
- 无死锁风险:不长期持有排他锁,只在更新瞬间加行锁。
- 高吞吐量:低到中等并发下性能优于悲观锁。
- 数据强一致性:检查与更新在一条 SQL 中原子完成。
- 业务语义丰富:可在
WHERE中加入库存 > 0 等条件。
缺点:
- 自旋开销:高并发下大量请求因版本不匹配失败,若应用层重试,会增加数据库 CPU 负载。
- 只能保证单行原子性:跨表或多行原子更新实现复杂。
- JOIN 性能隐患:高并发下对两张表加锁和校验的开销比较大。
ABA 问题:CAS 只检查值是否变化,不关心过程。如果数据从 A 变成 B 又变回 A,CAS 会误以为从未修改。但此处lock_version + 1单调递增,会天然避免 ABA问题。
三、如果把「读配置」放进事务,时间都花在哪了?
假设一个未经优化的写法:
@Transactional(rollbackFor=Exception.class,timeout=5)publicvoiddraw(LongticketId){// ① 查活动状态(配置表,可能还要 JOIN 两三张表)Activityactivity=activityMapper.selectCurrent(activityId);// ② 查商品是否参加活动、码段规则// ③ 查各奖池概率区间// ④ 查中奖上限、该用户已中次数// ⑤ 随机数 + 概率计算// ⑥ 扣库存(CAS UPDATE,此刻拿到热点行锁)// ⑦ 插入抽奖结果// 方法返回,Spring 提交事务}问题分两层。
第一层:即使读操作在 UPDATE 之前,它也在拖垮系统。
- 每个请求占着一个数据库连接的时间变长了。数据库连接池是稀缺资源(通常只有几十个),1000 个请求并发进来,连接池先被耗尽,后面的请求连排队的资格都没有。
- 5 秒事务超时是从事务开始计时的。前面的读取抖动 3 秒,后面扣库存只剩 2 秒预算,无谓地提高了超时回滚率。
- 配置读取一旦涉及远程调用,等于把数据库事务的命运绑在网络和第三方服务的 SLA 上——第三方如果有问题,那么热点行锁队列就会集体受到影响。
第二层:更糟的写法是把读取放在 UPDATE 之后(比如先扣库存,再查概率、查配置决定结果)。根据两阶段锁协议,此时热点行锁已经持有,后续每个慢操作都在直接延长热点行持锁时间。前面算过:这里多 10ms,队列第 100 个请求就多等 1 秒。
正反对比,优化方向非常明确:
| 操作 | 能不能放进事务? | 原因 |
|---|---|---|
| 凭证校验、幂等查询 | 不应该放到事务内 | 可能失败,失败就不该开事务;且涉及缓存往返 |
| 读活动配置、概率、资格规则 | 事务外预载 | 数据量大、耗时不可控,与热点行无关 |
| 随机数、概率落点计算 | 纯内存,放哪都行 | 不碰数据库,纳秒~微秒级 |
| 扣库存 CAS | 必须在事务内 | 核心写操作 |
| 写抽奖结果 | 必须在事务内 | 必须与扣库存原子提交 |
| 凭证收尾、缓存标记 | 事务提交后做 | 不允许它们的耗时摊进锁持有窗口 |
四、总体结构:一次抽奖的三阶段生命周期
三个阶段,由应用层编排:
事务边界在代码里只有薄薄一层:
/** 独立 Bean:避免 Spring AOP 自调用导致 @Transactional 失效 */@ComponentclassLotteryTransactionExecutor{// 事务只包围 execute:凭证领取、配置预载、提交后收尾都在事务外@Transactional(rollbackFor=Exception.class,timeout=5)publicvoidexecute(LotteryDrawDomainServicedomain){domain.execute();}}应用层的调用顺序:
domain.prepare();// 事务外准备try{transactionExecutor.execute(domain);// 唯一的短事务}catch(DrawResultPersistenceConflictExceptionconflict){domain.recoverPersistenceConflict();// 唯一键竞争的事务外恢复}returndomain.complete();// 事务外收尾注意timeout = 5—— 5 秒超时是最后一道保险丝。正因为事务里只剩写操作,正常路径通常几毫秒就提交了,5 秒预算几乎永远用不完;一旦真的逼近超时,说明锁队列已经严重堆积,快速回滚反而能保护数据库不被拖垮。
工程要点:LotteryTransactionExecutor被刻意做成一个独立的 Bean,而不是在同一个类里直接调一个@Transactional方法。这是因为 Spring 的声明式事务基于代理(AOP)实现,类内部自调用不会经过代理,注解会静默失效——方法照样执行,但根本没有事务,数据库操作不会回滚,脏数据直接落库。这是极易踩的坑。
五、阶段一 prepare:开始前,把该查的数据一次查齐
prepare阶段做四件事,全部在事务之外:
① 领取并核验抽奖凭证(缓存操作)
凭证是用户扫码验真后拿到的「抽奖入场券」,有有效期、防重复使用。领取动作通过缓存的原子操作完成:
- 凭证无效/过期 → 直接业务拒绝;
- 凭证正在处理中 → 返回「处理中」;
- 凭证已经被消费过 → 走幂等恢复,直接回查 MySQL 里的历史结果返回。
这些拒绝路径连数据库事务都不会开启——不占连接、不碰任何行锁,是成本最低的拒绝。
② 幂等检查:这张票是不是已经抽过了?
outcome=queryGateway.resultByTicketId(credential.ticketId());if(outcome!=null){phase=DrawPhase.PREPARED;// 已有结果,后续 execute 直接空转return;}按稳定的票据 ID 查 MySQL 结果表。查到了,说明这个请求其实是重试/重复提交,后面的短事务里什么都不写,把历史结果原样返回即可。
这一查放在事务外还有一个重要意义:幂等读与写解耦。重复请求根本不会进入锁队列争抢热点行——它在事务开启前就分流走了。
③ 预载配置快照
snapshot=queryGateway.currentActivitySnapshot(credential.activityId());一行调用,把这次抽奖需要的整套活动配置一次性取回来,冻结成一个不可变快照对象,之后资格判断、概率落点、结果落库全部使用这同一个版本。
④ 基于快照做资格校验(纯内存计算)
if(!participation.hasProduct(credential.productId())){// 商品已不在本活动中 → 拒绝}if(!participation.isSerialEligible(credential.productId(),credential.serialCode())){// 流水码不在允许码段 → 拒绝}商品是否参加活动、流水码落在不在码段区间内——这些判断只和快照数据有关,是纯粹的内存运算,零数据库开销。不合格同样在事务外返回。
prepare结束时,这次抽奖需要的所有「数据」都已经在手边了。
六、阶段二 execute:短事务里,只剩三笔写
进入事务后,代码的逻辑依然依赖prepare拿到的快照,但不再做任何配置读取:
// 活动是否在进行中?——纯内存判断,读的是快照里的状态与时间窗if(currentEligibility()!=ALLOWED){/* 记录终态失败,不写任何业务行 */}// 随机数 + 概率落点 —— 纯内存,区间表来自快照RandomPointpoint=RandomPoint.secure();PoolIntervalinterval=ProbabilitySelector.selectPool(point.value(),snapshot.getIntervals(credential.productId()));// —— 以下是事务中仅有的三笔写 ——commandGateway.advanceDrawVersion(consumerId,state.drawVersion());// 写①StockReservationstock=commandGateway.reserveInventory(// 写②interval.getPoolId(),interval.getPoolConfigId(),ticketId);commandGateway.saveResult(outcome,...,participation.configVersionId(),resultId);// 写③三笔写各有分工,这是项目不容违反的硬约束:
| 写操作 | 锁住的行 | 热点程度 | 作用 |
|---|---|---|---|
写①advanceDrawVersion | 消费者自己那一行 | 低(按人分散) | 推进个人抽奖版本号,配合中奖上限做并发互斥 |
写②reserveInventory | 库存槽的某一行 | 全局最高热点 | CAS 扣减库存,同时核对奖池配置指针 |
写③saveResult | 新插入的结果行(雪花 ID,行行不同) | 无热点 | 写入不可变抽奖事实,父子两张表 |
为什么热点行的实际持锁窗口极短?因为写②一旦成功,后面只剩写③的一次插入(全新行,不与任何人争锁),随即COMMIT释放库存槽锁。扣减成功之后再安排查询或RPC等这类动作,在这里是被禁止的。
事务里为什么还留着两条 SELECT?
仔细看代码,事务内并非绝对零读:
// 为写①现场取证:读消费者当前版本号、当前中奖数WinningLimitStatestate=queryGateway.winningLimitState(activityId,consumerId);// 为写②现场取证:读当前有余量的槽位及其 lock_versionList<StockSlot>slots=mapper.selectAvailableSlots(poolId);这恰恰体现了「预载」的精确边界:
- 预载出去的是「配置类」读取:数据量大、可能跨多表、可能走缓存/远程、耗时不可控。
- 留下来的是「为写服务的最小取证读」:CAS 必须拿到此刻的
lock_version、draw_version才能比对,这正是 CAS 三要素中的「预期原值 A」。它们都是按索引的单行/少量行普通快照读(MVCC,不加锁),微秒级返回,并且必须紧贴对应的写语句,把读到用之间的时间窗压到最小。
一句话总结划分原则:
配置与数据提前做;为 CAS 取证的版本号,临写前再读,并且只需读索引上的一两行。
写②内部:一次扣减尝试长什么样
库存预留的实现也刻意保持短小:
// 1. 一行 SQL 取出所有还有余量的槽位号 + 当前版本号(普通读,不加锁)List<StockSlot>slots=mapper.selectAvailableSlots(poolId);// 2. 用票据哈希决定候选起始槽位,依次尝试 CAS(把流量打散到 16 个槽)intstableHash=ticketId.hashCode();for(inti=0;i<slots.size();i++){StockSlotslot=slots.get(StockSlotLayout.candidateIndex(stableHash,i,slots.size()));intaffected=mapper.decrementCas(poolId,slot.slotNo(),slot.lockVersion(),expectedPoolConfigId);if(affected>0)returnStockReservation.success();// 影响 1 行 = 扣中}// 3. 所有候选槽都失败 → 慢路径诊断:是配置切换了?纯并发冲突?还是真没库存了?returndiagnose(poolId,expectedPoolConfigId);两个细节:
- 哈希选槽:不同票据被哈希到不同的起始槽位,几千个并发请求不会全挤在 slot 0,而是尽量均匀地分摊到初始 16 个槽上——这是在「行锁天然串行」之外,用数据布局主动降低单槽热度。
- 慢路径诊断:全部槽位 CAS 失败后,才额外读两次奖池配置指针(读前、读后),区分三种结论:配置已切换(
versionChanged)、纯并发冲突(conflict)、库存已耗尽(exhausted)。诊断查询只在失败时触发,不拖累成功主路径。
七、阶段三 complete:执行完成之后,再做收尾
事务成功提交后,锁已释放,此时才处理不影响原子性的收尾动作:
- 消费抽奖凭证(缓存操作):把入场券标记为已用,并记录结论(中奖/未中奖/各类拒绝原因)。
- 中奖上限缓存提示:如果这次中奖恰好触顶,向缓存写一个「已达上限」的提示,供后续请求快速预判。这个操作即使失败也只记日志、不影响结果——因为真正的上限并发安全由事务内写①的版本号 CAS 保证,缓存提示只是加速层。
异常路径则由统一的abort兜底:释放仍持有的凭证,避免异常请求把入场券永久卡死。
为什么凭证消费可以放在提交后?不怕「扣了库存但凭证没销账」吗?
不怕,因为MySQL 里不可变的抽奖结果才是最终事实。凭证在缓存里只是一张短期入场券。即使提交后凭证消费失败,重试请求进来会在prepare阶段按票据 ID 查到已提交的结果,走幂等恢复返回同一结论,绝不可能再扣一次库存。任何恢复路径都是「先认 MySQL 事实,再修补凭证状态」,顺序不能反。
八、配置快照详解
预载不是随便SELECT *一把,而是装在一个不可变值对象里。它的内容可以概括为「这次抽奖判定需要的全部规则」,但明确不包含动态库存:
| 快照内容 | 用途 | 使用阶段 |
|---|---|---|
| 活动生命周期状态、暂停标记 | 判断活动是否有效 | 事务内纯内存判定 |
| 活动时间窗(开始/抽奖截止时间) | 判断未开始/已截止 | 事务内纯内存判定 |
| 中奖次数上限 | 个人中奖限额判断 | 事务内,配合写① CAS |
| 参与商品集合 | 商品是否参加活动 | prepare阶段 |
| 商品 → 可参与码段区间 | 流水码资格校验 | prepare阶段 |
| 商品 → 奖池概率区间(连续不重叠) | 随机数落点、决定中哪个池 | 事务内纯内存选池 |
| 配置版本 ID | 标识「这套规则是哪一版」 | 结果落库留痕 + 写时校验 |
不可变性在代码里是被强制的:构造时对概率区间集合做深拷贝并包装为只读集合,任何后续代码都改不动。一次已受理请求的配置视图:
// 构造器内:深拷贝 + 冻结,防止加载方之后修改同一次判定的配置this.probabilityIntervals=deepCopyIntervals(probabilityIntervals);为什么必须不可变?因为快照会从事务外”活“到事务内。如果它是个普通可变对象,别的线程在你事务执行期间换掉了里面的概率表,就会出现「资格按 v1 判、概率按 v2 抽、库存按 v3 扣」的混搭事故。快照的语义是:本次请求从准备到提交,只承认这一个版本的规则。
为什么不把库存也放进快照?因为库存是实时事实,不是配置。预载一份库存数字进内存,等你事务里扣的时候它早就过期了——库存必须在事务内现查现扣(写②),用 CAS 对真实行负责。配置可以读旧的(写时有校验兜底),库存不能读旧的。
快照从哪来:本地内存优先,冷了只读一次缓存
预载要真正缩短事务,前提是预载本身也要足够快、足够稳。如果预载是每次都查十几张配置表,那只是把耗时从事务内挪到事务外,数据库压力并没有减轻。本项目的配置供给是两级结构:
三个关键设计点:
①热路径零网络。配置发布频率极低(运营一天改不了几次),而读取 QPS 极高,是最典型的读多写少场景。配置在本地内存里以不可变对象常驻,绝大多数抽奖请求连缓存都不访问。
②冷启动防击穿。服务刚重启、本地内存为空时,如果 1000 个请求同时到达,不能允许 1000 次缓存读取。机制上同一活动只有第一个请求发起加载,其余请求等待同一个CompletableFuture,加载完成后共同享用。
③配置切换是「整版替换」而不是「原地修改」。运营改配置走的是独立的发布流程:新版本在缓存中先呈现「同步中」状态,所有实例加载完毕后原子发布为「就绪」,再一次性装进各实例的本地内存。读配置的线程在任何瞬间看到的要么是完整旧版、要么是完整新版,永远看不到半新半旧的配置。
这也回答了一个边界问题:配置读缓存,会不会和「MySQL 是唯一事实来源」的原则冲突?
不冲突,因为两者承载的东西性质不同:
| 活动配置 | 库存 / 抽奖结果 | |
|---|---|---|
| 变化频率 | 极低(运营发布) | 极高(每次抽奖) |
| 丢失后果 | 可从 MySQL 配置表完整重建 | 无法重建(丢了就是真丢了) |
| 读取策略 | 版本化快照,内存/缓存供给 | 事务内实时读真实行 |
| 出错兜底 | 写时配置指针 CAS 拦截旧版本 | 本身就是事实,不允许缓存代劳 |
配置可以放在缓存里,是因为缓存只负责「分发副本」,源头在 MySQL,丢了能重建;库存和结果绝不用缓存拼装,因为它们就是事实本身。
九、灵魂拷问:事务外读的快照,写的时候过时了怎么办?
这是初学者最不放心的地方。
场景:prepare阶段读到 v1 配置;就在execute执行前 1 毫秒,运营发布了 v2 配置(调整了奖池概率/库存布局),奖池表上的「当前生效配置指针」已经切到 v2。拿着 v1 快照的请求,会不会按旧规则扣错库存?
答案是:它连一行都扣不动。这就是 CAS 与预载的咬合之处。回顾写②的 SQL:
UPDATEactivity_prize_pool_stock_slot sJOINactivity_prize_pool pONp.id=s.pool_idSETs.remaining_quantity=s.remaining_quantity-1,s.lock_version=s.lock_version+1WHEREs.pool_id=#{poolId}ANDs.slot_no=#{slotNo}ANDs.remaining_quantity>0-- 业务条件:不能超发ANDs.lock_version=#{lockVersion} -- 校验一:行没人动过ANDp.effective_pool_config_id=#{expectedPoolConfigId} -- 校验二:配置没换版- 快照里带着每个奖池的
poolConfigId(「我是按哪一版规则选中这个池的」); - 写库存时,这个 ID 必须等于奖池表上此刻的生效配置指针,否则影响行数为 0,扣减失败。
配置切换的完整时序:
除了库存这一道,写路径上实际共有三道版本/状态校验,构成「写时验真」的完整防线:
| 防线 | 校验内容 | 防止的问题 |
|---|---|---|
| 写①:消费者行 CAS | WHERE draw_version = 读到的版本号 | 同一用户的并发请求重复推进、突破中奖上限 |
| 写②:库存槽 CAS | lock_version相等 | 同一槽位被两个请求各扣一份(超卖) |
| 写②:配置指针 CAS | effective_pool_config_id相等 | 旧版快照在换版后扣减新版库存(错版扣减) |
| 写③:结果留痕 | 结果行带上config_version_id | 事后可审计「每一笔抽奖按哪版规则产生」 |
另外还有一个天然的「最终防线」:结果表上票据 ID、流水码的唯一键。即使发生极端的唯一键竞争(两个重复请求都走到了插入),数据库会拒绝第二条插入,应用层在事务外按票据 ID 回查恢复,返回先提交的那一条事实。
所以「读旧快照」从来不是问题——只要所有写操作都是有条件的,旧快照就无法产生任何错误写入,它最多导致本次请求失败重试。这正是乐观策略的精髓:不阻塞、不加悲观锁,把一致性押在「写的那一刻条件必须成立」上。
CAS 冲突了,为什么不立刻重试,而是直接失败?
本项目在冲突时的选择非常克制:
配置变更 / 库存冲突 → 立即回滚,返回「服务繁忙」,由用户端稍后重试,服务端不在事务内自旋。
原因有三个:
- 持锁中重试 = 让热点行陪着重试烧时间。重试意味着重查槽位、重算落点、再次争抢行锁,而当前事务已经持有了写①的消费者行锁;越重试,锁队列越长。
- 配置切换是极低频事件。为千分之一秒级的换版窗口在服务端做复杂重试,收益极低,却要为每次抽奖背上重试框架的成本。
- 客户端重试天然携带最新状态。用户重试时是一个全新请求,会重新
prepare、拿到最新快照——这比在旧事务里用旧快照反复重试要正确得多。
注意区分:写②内部对多个候选槽位的依次尝试不属于「冲突自旋」——那只是同一版本快照下、在 16 个槽位里选一个能扣中的槽,是正常的一次扣减流程;所有槽位都失败后,绝不重新抽奖、绝不重算随机点。
十、收益分析
把两种实现放在时间轴上对比(示意数字,仅用于建立量级感):
- 热点行单槽吞吐天花板从「被十几毫秒的杂活压低」恢复到「由纯写延迟决定」,这是加机器买不到的提升——串行点在数据库行锁上,应用层扩多少副本都绕不过它;
- 数据库连接的占用时间从几十毫秒降到几毫秒,连接池同样的容量能支撑更多并发;
- 远程缓存、配置加载的抖动被关在事务门外,它们再慢也不会把锁队列拖垮,最差表现是
prepare阶段的快速失败; - 5 秒超时预算与实际 1~3ms 的正常路径之间隔了三个数量级,系统有充足的缓冲吸收正常的锁排队抖动。
十一、设计原则与适用边界
把这套模式浓缩成四条可以迁移到其他系统的原则:
- 事务只装「必须一起生效的写」,外加为写服务的最小索引读;凭证、缓存、远程调用、配置读取、提交后通知一律在事务外。
- 配置读多写少,就做成版本化的不可变快照:本地内存供给、整版原子替换、源头在 MySQL 可重建;动态事实(库存、余额、结果)不进快照。
- 读时乐观,写时验真:允许拿着稍旧的快照做计算,但每一笔写都必须带版本条件(行版本号 + 业务版本指针),旧快照写不进即失败。
- 冲突快速失败,不做事务内自旋;把重试交给全新的顶层请求,让它重新预载最新状态。短事务 + 有界超时是最后的保险丝。
但它也不是万能药,适用前提需要同时满足:
- 被预载的数据确实读远多于写,且能整体版本化;
- 写操作能够用「条件更新」表达版本校验(有版本号或配置指针可比);
- 业务能接受极低概率的「本次失败、客户端重试一次」,而不要求请求在服务端内部串行等到成功。
而像跨行转账、必须严格按既定顺序成交的强顺序事务,读和写本身就是业务事实的一部分,不能预载、不能快败,那种场景老老实实用悲观锁并精心安排锁顺序,才是正确选择——工程上没有高级的方案,只有匹配约束的方案。
十二、常见疑问(FAQ)
Q1:用旧快照做判断,这不就是「脏读」吗?
不是。脏读指读到了别人尚未提交的数据;快照里的每个版本都是运营完整发布、正式生效的配置,是合法的旧版本。且写操作有条件兜底,旧版本无法产生错误写入,最多让本次请求失败。
Q2:既然库存也在数据库里,为什么不干脆把扣库存放 Redis(Lua 脚本)做到几万 TPS?
因为 Redis 计数不是持久化事实:故障切换、重启都可能让计数与真实库存偏离。本项目的取舍是:抽奖并发要高,但「奖品不能超发、结果不能丢」是硬业务底线,因此扣减与结果必须以 MySQL 不可变行为准,用缩短持锁时间把 MySQL 的吞吐压到够用,而不是把事实押在缓存上。
Q3:为什么不用SELECT ... FOR UPDATE把配置行锁住?
配置是读多写少的公共数据,悲观锁会让海量读请求也被串行化,纯属自废武功。配置变更只需要在「写库存」这一瞬通过版本指针发现即可,没有任何读请求需要被阻塞。
Q4:预载快照会不会占很多内存?
快照是按活动维度组织的不可变小对象(状态、时间窗、码段、概率区间),且只缓存「当前生效」的一个版本;旧版本在发布后被整版替换。相对于它挡在数据库之外的查询量,这点内存成本可以忽略。
附录:专业词汇表
| 术语 | 解释 |
|---|---|
| 事务 | 一组数据库操作,要么全部生效,要么全部撤销。从第一个写操作开始到提交,一直持有锁。 |
| 行锁 | InnoDB 的锁粒度是锁被修改的那几行,不是锁整张表。 |
| 热点行 | 被大量并发请求同时争抢的同一批行。抽奖场景中库存槽行是全局最高热点。 |
| 锁队列 | 行锁互斥,其他想锁这行的事务阻塞排队。队列中每个请求的等待时间 = 前面所有事务持锁时间之和。 |
| 持锁时间 | 从事务开始(第一次拿锁)到提交的时间。事务每多一毫秒,队列里所有请求各多等一毫秒。 |
| 吞吐 | 单位时间完成的请求数。热点行吞吐上限 ≈ 1 / 平均持锁时间。 |
| 串行化 | 排队一个一个来。事务保证写操作的串行化,设计目标是让串行化部分尽量小。 |
| CAS | Compare-And-Swap,比较并交换。三要素:内存位置 V、预期原值 A、新值 B。只有当前值等于预期值才更新。是一种乐观锁策略。 |
| 乐观锁 | 假设冲突很少,不直接加锁阻塞,而是在更新时检查数据是否被他人修改过。 |
| 悲观锁 | 假设冲突经常发生,先加锁再操作,如SELECT ... FOR UPDATE。 |
| 事务外预载配置 | 将配置类读取提前到数据库事务之外完成,事务内只保留必须一起生效的写操作。 |
至此,关于「如何缩短热点行持锁时间」我们有了完整答案:用事务外预载把所有杂活清出事务,用不可变快照承载读,用写时 CAS 守住一致性,用快速失败保护数据库。下一节可以继续聊:当 16 个库存槽仍然不够、单槽成为瓶颈时,库存布局本身还能如何演进(槽位扩容与追加式布局)。