放弃 Redis,Shopify 如何用 MySQL 支撑高并发库存预留

2026/8/10·1 views

放弃Redis,Shopify如何用MySQL支撑高并发库存预留

在电商大促场景中,库存超卖与少卖是两大棘手难题。超卖会引发订单取消、客诉与客服成本;少卖则直接造成商家营收损失。作为全球头部电商平台,Shopify在黑五峰值能够达到每分钟510万美元的交易额,每一笔下单都要经过库存预留系统校验,这套系统的正确性与吞吐量直接决定平台的交易稳定性。

长久以来,Shopify选择Redis承担库存预留能力,但是随着业务规模持续上涨,跨存储带来的数据一致性缺陷逐步暴露。最终团队完成技术迁移,将整套库存预留逻辑完全迁移至MySQL,依靠SKIP LOCKED、精细化表结构设计与可观测性建设,在大促流量洪峰下稳定运行。这一次迁移也打破了行业固有认知:很多场景不必强行引入中间件,成熟的关系型数据库经过合理设计,完全可以胜任高并发互斥类业务。

Redis库存方案的隐藏短板

原有系统中,Redis负责库存预留,MySQL作为库存总账的唯一数据源。预留库存时执行DECR扣减,释放库存执行INCR增加。Redis本身的原子指令可以处理单节点并发竞争,但最大的痛点来自两套存储无法做原子事务

当用户支付成功,需要同时完成两件事:MySQL总账扣减库存、清除Redis内的预留标记。两个操作分属不同系统,无法包裹在同一个ACID事务之内。执行顺序一旦出现异常,就会出现两类故障:支付成功但Redis预留未清理,造成超卖;总账库存被扣减,但预留没有释放,出现虚假售罄,也就是少卖。

除此之外,这套架构还有额外负担:Redis集群需要独立运维,原生很难适配多仓库、多地点的库存模型。业务朝着统一数据库架构演进时,跨存储带来的一致性风险、运维成本,都成为必须解决的痛点。于是团队提出疑问:MySQL能不能扛住高并发的库存预留?

早期简单方案被证实不可行:用单条记录记录商品可售数量,高并发下单会造成严重行锁争抢,大量事务互相阻塞,吞吐量迅速跌到瓶颈。直到MySQL8.0提供SKIP LOCKED语法,给新的设计方案带来可能性。

基于SKIP LOCKED的单元行池化方案

核心设计思路从「一个商品一行记录记录数量」改成一个可售库存单元对应数据库一行。一件商品如果有10件可售库存,数据表中就存在10条数据;需要预留3件,就在同一个事务中选出3条可用行完成状态变更。借助MySQL原生事务,库存预留、确认扣减全部在同一个数据库内完成,天然拥有ACID保障,从根源消除跨存储带来的数据错乱问题。

如果完全按真实库存生成行,爆款商品在多仓库场景下,很容易产生几十万行数据,查询扫描会急剧变慢。Shopify提出有界池(bounded pool) 的思路:每个商品+仓库组合,最多维护1000条可用库存单元行,构成一个缓冲池。业务从这个池子里消费行,后台异步补数进程,从真实库存总账向池中补充数据。

选择1000作为上限,是权衡查询性能与流量缓冲能力的结果。数值足够承接秒杀瞬时流量,同时控制表的数据规模,保证SKIP LOCKED扫描速度。

遇到极端秒杀,缓冲池被打空时,会触发同步补数逻辑。通过锁保证同一时间仅一个线程执行补数,其余并发请求等待,避免惊群效应。整个过程用户不会看到错误,只有延迟小幅上涨,只要总账还有真实库存,就不会错误返回售罄。

SKIP LOCKED是这套方案的核心:当多条并发事务争抢库存,遇到已经被其他事务锁定的行,查询会直接跳过被锁行,去拿其余未锁定的数据。事务不再互相阻塞等待锁,大幅降低锁等待时间,提升并发处理能力。

关键数据库细节优化

原型跑通后,团队遇到锁数量、死锁、隔离级别等一系列工程问题,多项关键决策决定最终系统能否落地。

  1. 复合主键减少行锁开销
    最初方案使用自增ID作为主键,测试发现每一次预留会产生两把行锁:二级索引锁 + 聚簇索引锁。改成组合主键shop_id, inventory_item_id, inventory_group_id, id,查询过滤字段直接落在主键上,将单次操作锁数量降低为一把,极大提升高并发下的吞吐能力。在超大流量系统中,主键、索引的设计会直接影响锁的数量,不可忽视。

  2. 调整隔离级别消除间隙锁
    MySQL默认隔离级别是REPEATABLE READ,这种模式下InnoDB会产生间隙锁。当库存缓冲池为空,准备插入新行进行补数时,间隙锁会阻塞插入,引发死锁。团队针对该部分事务单独切换到READ COMMITTED隔离级别,消除间隙锁带来的阻塞问题。这需要框架支持按事务动态指定隔离等级,也是一次打破默认配置的实践。

  3. 统一锁顺序规避死锁
    预留、确认扣减两个业务流程操作两张表,如果锁表顺序不一致,就会形成循环等待,产生死锁。团队标准化执行顺序:预留流程优先操作单元库存表做删除,再向预留记录表插入;支付确认扣减流程仅操作预留记录表。全部业务流程获取锁的顺序完全统一,彻底消除循环等待死锁。

  4. UNION ALL批量查询减少数据库往返
    用户购物车经常包含多件商品,每一件商品单独查询会产生多次数据库网络往返。使用UNION ALL做批量查询,一次SQL取回多个商品需要的库存单元,减少RT,降低数据库压力。

真正的瓶颈不在SQL,而在数据库连接

完成SQL、锁、索引全套优化之后,上线压测却遇到意料之外的吞吐量天花板。查询P90延迟尚可,数据库CPU使用率并不高,但系统无法达到预期性能目标。压测观测到MySQL线程排队,ProxySQL代理层出现数据库连接耗尽的现象。

最开始团队尝试跨请求批量处理查询、读请求下放到从库,效果有限。单纯监控连接耗尽,无法知道究竟是哪部分业务逻辑占用连接。团队设计一套可观测方案:应用层给每一条SQL增加注释标签标记业务流程,例如/* conn_tag:checkout_completion */;在ProxySQL代理层解析标签,统计每个业务持有数据库连接的时长

这套手段很快定位真相:库存预留本身并不是连接最大消耗方,下单链路里其他业务代码长时间持有数据库连接,已经把连接池消耗到临界值。当大流量到来,库存预留请求进来,直接触发连接耗尽。问题根源不在预留业务本身,而是整个下单链路其他模块的连接滥用。

针对整个下单链路做治理,移除主库50%读请求,减少33%事务;同时重新评估调整InnoDB线程并发参数。完成优化后吞吐量瓶颈直接消失。在真实大促流量中,写节点CPU低于50%,读节点CPU低于16%,留有充足余量。

核心启示:当CPU不高,但系统大量排队,不要只聚焦核心业务本身,要完整观测整条链路资源占用,很多瓶颈藏在周边不起眼的代码里。

灰度切换上线策略

为了保障线上业务安全,团队没有直接一刀切切换。采用影子双写模式:所有库存预留同时写入Redis与MySQL,仍然以Redis作为真实数据源。真实流量同时打两套系统,持续比对两边结果正确性,验证MySQL方案业务逻辑、性能全部达标。

验证完成后逐步切换数据源,保留开关可以快速切回Redis。按Pod灰度,先低流量实例,再迁移高流量商家。双写逻辑持续保留,保证一旦出现故障,随时回退到Redis,不会丢失预留数据,整个迁移过程业务无感知。

实践总结与启示

这次迁移带给工程团队两条重要经验。

第一,不要固守旧时代的技术认知。五年前做这件事MySQL确实难以实现,但随着数据库版本迭代,SKIP LOCKED这类新能力出现,很多过往只能靠中间件解决的问题,现有数据库就可以完成。同时数据库配置也不能照搬多年前经验,硬件、业务负载发生变化,旧的经验参数需要重新评估校验。当指标现象矛盾,例如CPU空闲但请求大量排队,一定要深挖底层资源。

第二,原型优先,重视直接观测。先用最小原型脚本做验证,直接观察数据库锁、事务行为,远比纸上谈兵更有效。可观测性不能只看查询耗时,连接持有时间、锁行为、事务生命周期,都是高并发系统的重要指标。

另外一个重要认知:高并发系统不仅要把自己的业务做快,更要做「友好邻居」。库存预留和购物车、支付、订单创建共用一套数据库,不能无限制抢占锁、占用连接。即便自身逻辑足够高效,如果耗尽公共资源,也会拖垮整条业务链路。

很多开发者遇到并发互斥、计数、预留场景第一反应引入Redis、消息队列等中间件。Shopify案例告诉我们,先审视现有数据库能力。合理的表结构、锁策略、事务隔离级别,配合完备观测手段,关系数据库完全可以支撑极高的业务吞吐量,同时收获ACID事务带来的数据一致性,降低多组件带来的运维复杂度。

当然,这套方案也不是万能银弹。池化设计、行粒度存储会带来更多开发复杂度,需要充分评估业务场景。但它为电商库存这类强一致性、高并发场景,提供了一条极具参考价值的落地路径。