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