ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

扣减库存,记录流水号方案设计(项目亮点)

扣减库存,记录流水号方案设计(项目亮点) 一、 项目背景与核心矛盾· 业务场景秒杀/爆品场景数十万用户同时抢购同一个商品ID数据库仅对应一行记录。· 核心矛盾数据库单行更新天然串行行锁既要保证不超卖强一致性又要保证高吞吐低RT高QPS传统Update ... where stock0的乐观锁方案在峰值流量下必然导致行锁堆积、RT飙升。二、 面临的四大核心技术挑战1. 数据库行锁瓶颈每一次扣减都是一次行锁争夺串行化导致IOPS耗尽。2. 分布式事务原子性扣减库存DB与记录流水DB必须在同一事务若引入Redis扣减则无法保证与DB流水的原子性。3. 缓存一致性陷阱写多读少场景下同步删缓存Cache-Aside不仅增加RT且频繁无效刷新浪费资源。4. 内存队列弹性困境内存合并虽能降压但集群扩容反而会稀释单机并发量导致合并效果变差。三、 核心解决策略深度解析1. 解决行锁争抢内存合并批处理Merge Queue· 具体做法基于内存计数器如并发数3时触发将200ms窗口内同一商品的多个请求聚合成一个队列异步线程一次性执行Update stock stock - sum(数量)。· 为什么不用分库分表 分库分表确实能分散热点行将1行变N行减小锁粒度但涉及历史数据迁移、跨库事务改造成本极高。为了极少数爆品改造全库属于“杀鸡用牛刀”。· 为什么是200ms 这是RT响应时间与吞吐量的平衡点。低于此值攒批不够降压失效高于此值用户同步等待超时风险剧增。通常依据数据库连接池的平均查询耗时动态设定一般为100ms-300ms。2. 解决事务原子性SQL顺序调优缩短锁持有时间· 具体做法将事务内SQL顺序由传统的先Update加锁后Insert流水强制改为 先Insert流水无锁再Update库存持锁。· 为什么这样性能高 行锁是在执行Update时施加直到事务提交才释放。若先Update后续Insert构建索引、写盘的时间都会计入锁持有时间将Insert前置行锁持有时间被压缩到仅有Update本身那几毫秒锁竞争激烈度直线下降。3. 解决合并失败与超卖极端场景降级补偿 乐观锁退避· 场景合并后需扣减3件但库存仅剩1件。· 具体做法不直接抛错而是退化为循环单次扣减按用户购买量从大到小排序逐个尝试Update stock stock - 1 where stock 1。· 为什么这样做 保证购买量大的用户优先抢到资源在无法完全满足时最大化大客成交概率属于业务侧的最优解。4. 解决分布式事务最终一致性事务消息 流水表幂等类Saga· 问题异步线程报错导致上游超时库存已扣但订单未成反向不一致。· 具体做法上游发送带订单号的回滚消息。下游监听后以库存流水表为准——查到流水则执行回滚补库存查不到则忽略。· 为什么不用TCC或2PC 2PC同步阻塞性能太差TCC改造成本高。利用最终一致性允许极小时间窗口内的短暂不一致通过流水表做幂等回滚是兼顾性能与可靠性的最佳折中。5. 解决缓存更新性能与乱序Binlog异步监听 版本号机制· 为什么不用Cache-Aside删缓存 同步删缓存增加RT且在写多读少场景下缓存命中率极低频繁删缓存纯属浪费。· 具体做法Canal监听MySQL Binlog设置5秒时间窗口窗口内多次变更只刷一次Redis并携带版本号(Version)。只有监听到的Binlog版本号 当前缓存版本号时才覆盖更新。· 如何解决并发覆盖 版本号比较在Redis侧通过Lua脚本原子执行无需分布式锁。若遇到旧版本并发Lua直接返回0不会发生“老的覆盖新的”乱序问题。· 业务感知优化库存为“0”比具体数值更敏感。一旦扣减失败库存耗尽强制同步将缓存置为0保证页面“售罄”置灰实时生效弥补异步延时带来的体验差。6. 解决横向扩容失效服务隔离 单机调优· 为什么加机器合并效果变差 内存合并依赖单机并发数。增加机器后流量被打散每台机器的QPS降低导致窗口内攒批的“批次大小”变小合并效果指数级衰减反而增加数据库连接数。· 解决方案放弃横向扩容转为垂直隔离。将库存扣减接口单独部署在专用集群物理隔离不与报表等查询接口争抢CPU。同时将200ms窗口、线程池核心数、队列创建阈值作为动态配置项压测后定点调优榨干单机性能。四、 整体架构本质总结这套方案本质上是一个“有损性能优化”架构其精髓在于· 用异步换吞吐同步转异步合并写IO· 用最终一致性换强一致性容忍毫秒级缓存延迟用流水表兜底· 用单机深度调优换集群水平扩展既然热点无法分散索性集中火力优化单机处理逻辑极像MySQL的Group Commit组提交 机制将多次事务提交合并为一次刷盘整个方案的终局是在极端高并发下保证数据库不被打死、账务绝对不超卖同时让99.9%的用户在200ms内得到响应。问题剖析(为什么❓)为什么不能直接update假设库存为100瞬间来了1000个并发请求。数据库的处理逻辑是1. 串行排队行锁同一行记录在同一时刻只能有一个事务更新。剩余的999个请求必须在数据库层面等待行锁释放。2. 连接被占用窒息行锁不释放这999个数据库连接就不能归还给连接池。应用服务器的连接池比如最大30个连接瞬间被占满。3. 雪崩超时重试前端/上游等不到响应开始超时重试新请求不断涌入但连接池已满无法获取连接应用线程阻塞。最终数据库连接池耗尽应用服务假死这就是典型的“连接数被打死”而非数据库性能本身不够。二、这套方案如何保证数据库“绝对不死”这套方案的核心逻辑是变“高频随机争抢”为“低频有序批处理”通过以下4道防线死死摁住数据库的并发压力防线1批处理-IO次数骤降最核心· 没有方案1000个请求 1000次 Update IO数据库要处理1000次行锁争抢。· 有了方案200ms窗口内合并1000个请求聚合成 1次 Batch Updatestock stock - 总销量。· 效果数据库每秒执行的SQL数量从几千/QPS骤降到几十/QPS。行锁争抢次数减少99.9%数据库CPU和磁盘IO瞬间从“红色警戒”降为“闲庭信步”。防线2锁持有时间缩到最短SQL顺序调优· 没有方案先Update加锁后Insert流水构建索引、刷盘。整个事务可能耗时50ms行锁就牢牢锁住50ms后面的请求只能干等。· 有了方案先Insert流水无锁最后执行Update。Update执行完瞬间1-2ms事务立即提交释放行锁。· 效果行锁的持有时间缩短了几十倍。锁释放得越快等待队列清空得越快连接池越不容易堆积。防线3异步排队 超时熔断削峰填谷· 没有方案请求直达数据库流量尖峰有多高数据库压力就有多高。· 有了方案请求先进入内存队列阻塞等待最长200ms。这相当于在数据库前面修了一个大坝。无论上游流量是1万还是10万数据库侧收到的永远是每隔200ms才发起的可控数量的批处理请求。· 效果将瞬时的流量高峰平滑成了数据库能够承受的平稳低峰。防线4快速失败 降级兜底避免无效占用· 场景库存只剩1件但合并队列里有3个用户要抢。· 方案不直接报错导致事务回滚浪费时间而是退化为按购买量从大到小循环扣减。一旦发现库存不足立即返回失败并释放连接。· 效果杜绝了“尝试加锁-发现不足-回滚释放”这一过程对连接资源的无效占用确保每一个数据库连接都在处理有效业务。总结一句话对比这套方案本质上是在应用内存里消耗了200ms的等待时间换取了数据库无穷大的安全边际。这就是它最精妙的地方。
返回列表