ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

乐观锁与 CAS 批量重试在大促中的弊端:CPU 空转与 ABA 问题的生产解法

乐观锁与 CAS 批量重试在大促中的弊端:CPU 空转与 ABA 问题的生产解法 乐观锁与 CAS 批量重试在大促中的弊端CPU 空转与 ABA 问题的生产解法在很多关于高并发系统设计的经典教材中“乐观锁Optimistic Locking”几乎被奉为灵丹妙药。通过在数据库表里加一个version版本号字段更新时带上WHERE version #{oldVersion}再加上一段类似 CASCompare-And-Swap的无限 while 循环自旋重试教科书告诉你“无锁化设计消除了数据库行锁等待吞吐量大幅提升”。然而如果哪位工程师把这套逻辑原封不动地搬到双 11 秒杀或者热点账户扣款场景中等待他的将是一场噩梦秒杀一开数据库 CPU 瞬间冲到 100%应用服务器各个节点的主线程全在疯狂自旋重试数以万计的UPDATE语句全部在匹配失败后抛弃原本几毫秒的接口响应时间被生生拖到十几秒最终全链路雪崩。乐观锁从来不是高并发场景下的“全能银弹”甚至在特定场景下它比最笨重的悲观锁更具破坏力。致命弊端一高冲突下的 CAS 重试风暴与 CPU 空转乐观锁的核心数学假设是读多写少并发写冲突概率极低 5%。在这个假设下绝大多数线程一次 CAS 就能成功极少数失败的线程重试一次也能顺利通过几乎没有阻塞。但在双 11 秒杀、整点抢红包这类**极高写入冲突Write Skew**场景下同一秒钟内有 10,000 个请求在试图修改同一个 SKU 的库存或同一个商户的账户余额这 10,000 个请求在读取时拿到的version全部都是 1紧接着它们同时向数据库发出UPDATE stock SET count count - 1, version 2 WHERE sku_id 101 AND version 1只有 1 个请求会更新成功剩下的 9,999 个请求全部更新失败灾难的演进如果业务代码为了保证用户体验在 Service 层写了自旋重试逻辑// 生产环境绝对禁止的“自杀式”写法 public void deductStockWithCas(String skuId, int amount) { while (true) { Stock stock stockMapper.selectById(skuId); if (stock.getCount() amount) { throw new BusinessException(库存不足); } // 乐观锁更新 int affectedRows stockMapper.updateStockCas(skuId, amount, stock.getVersion()); if (affectedRows 0) { return; // 成功退出 } // 失败立即进入下一次自旋重试无任何退避 } }那 9,999 个失败的线程不会善罢甘休它们会立即在下一微秒重新执行SELECT然后再次发起UPDATE。结果就是1 个有效请求的完成伴随着数万次无效的 SQL 往返与事务创建数据库的 Undo Log、Redo Log 被无意义的失败更新充斥InnoDB 行级锁在死锁检测上消耗大量 CPU最终整台数据库服务器被自旋产生的重试风暴彻底击穿。致命弊端二ABA 问题在分布式业务中的幽灵再现在 JVM 内存的AtomicInteger中ABA 问题通常指一个变量从 A 变成 B随后又变回 A导致 CAS 检查误以为它从未改变过。在数据库业务层如果不使用严格单调递增的version而是误用“状态码”或“库存数值自身”来充当 CAS 条件就会引发极其隐晦的业务级 ABA 灾难-- 危险的 CAS 伪乐观锁 UPDATE account SET balance balance - 100 WHERE user_id 8848 AND balance 500;场景还原线程 A 读取到账户余额为 500 元准备扣减 100 元进入复杂业务计算线程 B 在此期间将账户扣款 200 元余额变为 300 元紧接着用户恰好充值了 200 元余额又回升到了 500 元线程 A 完成计算执行上述 SQL。它看到balance 500判定匹配成功扣减落地。从字面看余额确实扣减了但线程 A 的所有风控逻辑、利率计算、授信额度判定全部是基于“该账户在这段时间内资金未发生任何异动”的前提做出的数据因果链在瞬间断裂财务对账时将彻底对不齐流水账。生产级破局与架构演进1. 严格使用绝对单调递增的自增版本号Version / Timestamp永远不要用业务状态或数值作为 CAS 条件。必须使用无法倒退的自增数字version version 1或者由雪花算法生成的全局单调分布式 IDUPDATE product_stock SET count count - #{delta}, version version 1 WHERE sku_id #{skuId} AND version #{oldVersion};2. 限制自旋次数并引入指数退避Exponential Backoff with Jitter高并发下坚决禁止死循环while(true)。必须设置最大重试上限如 3 次并在每次重试之间引入带随机抖动的退避等待打散竞争脉冲int maxRetries 3; long baseSleepMs 20; for (int i 0; i maxRetries; i) { Stock stock stockMapper.selectById(skuId); if (stock.getCount() amount) { throw new BusinessException(库存已售罄); } int updated stockMapper.updateStockCas(skuId, amount, stock.getVersion()); if (updated 0) { return; // 成功 } // 随机抖动退避防止同频共振 long sleepTime baseSleepMs * (1 i) ThreadLocalRandom.current().nextLong(15); Thread.sleep(sleepTime); } throw new BusinessException(抢购人数过多请稍后再试);3. 终极解法在大冲突场景下果断抛弃乐观锁全面拥抱悲观排队在极高写入冲突的场景下悲观排队的性能反而远远优于乐观自旋方案 A利用 MySQL 内核行锁就地原子扣减UPDATE product_stock SET count count - #{delta} WHERE sku_id #{skuId} AND count #{delta};不需要任何version直接依靠 InnoDB 的行级排他锁保证原子性。只要一条 SQL扣成功就返回受影响行数 1库存不够直接返回 0零重试、零空转方案 B将冲突移出数据库在 Redis 内存中排队利用 Redis 单线程原子 Lua 脚本或者 RocketMQ 顺序队列将并发争抢转化为单向串行流水线把数据库保护在安全的稳定节拍中。真正成熟的架构师永远懂得在冲突概率变化的拐点上精准切换乐观与悲观的攻防姿态。
返回列表