在电商系统中,库存预留是一个看似简单却极其关键的操作。当买家点击"完成购买"时,系统必须在极短时间内保证所购商品仍然可用——如果出错,要么两个买家买到同一件最后的库存(商家不得不取消订单、发送道歉邮件并承担客服成本),要么告诉买家有货但实际无货(商家损失一笔本可完成的销售)。
在Shopify的规模下,任何一种错误都会快速放大。2025年黑色星期五,平台商家创下了每分钟500万美元销售额的纪录。每一次交易都涉及库存操作。
长期以来,Shopify的超卖保护系统(oversell protection)依赖Redis运行。当公司转向统一的数据库战略时,团队面临一个艰难的问题:MySQL能扛住同样的规模吗?

Redis模型的局限
原有的Redis系统中,每件商品有一个quantity key,预约库存意味着执行DECR命令,释放则执行INCR。Redis处理并发没问题,但预约和库存账本分别存在于两个不同的系统。支付成功后需要更新MySQL同时清理Redis,而这两个操作无法包装在单一的原子步骤中——可能导致超卖(商品售出但账本未扣减)或欠卖(账本已扣减但仍标记为已预约)。
此外,Redis模型不支持多仓库库存感知,且增加了单独维护一个集群的运营成本。将预约系统移入MySQL数据库,与账本保持一致,意味着可以用ACID事务包装一切,彻底消除这些失败模式。
解决方案:SKIP LOCKED
核心设计思路是"每件库存一行记录"——而非传统的"每种商品一行记录加quantity列"。以10件库存的商品为例,新设计会有10行记录。预约3件意味着在单个事务中选取并移动3行。通过将预约和库存账本保持在同一数据库,获得了跨reserve和claim的ACID保证。
MySQL 8.0的SKIP LOCKED特性是实现可扩展性的关键:当一个事务锁定了某些行时,MySQL会跳过这些行并返回其他可用行,不会产生等待。这意味着在高频预约场景下,不同事务之间不会相互阻塞。
但仅靠一行一条记录不足以支撑超大规模场景——一件商品跨10个仓库、共5万件库存,意味着50万行记录,预约查询扫描这些行会明显变慢。解决方案是维护一个有限容量的可用行池,每种商品/仓库组合最多1000行。预约从池中消耗行;补充进程从库存账本向池中补充。
真正的瓶颈:连接数而非CPU
在生产环境进行负载测试时,团队遇到了一个意想不到的问题:吞吐量远低于目标,但预约延迟(P90)可以接受,CPU也没有满。查询已经优化过,那问题在哪?
通过在应用层为每个SQL语句添加注释标签(如 /* conn_tag:checkout_completion */),并在ProxySQL层解析这些标签,团队终于看清了真相:其他业务逻辑(如购物车更新、支付处理)持有连接的时间比必要时间更长。这些长时间持有连接的操作消耗了连接池,当系统高吞吐时,预约成了"压垮骆驼的最后一根稻草"——不是因为预约本身慢,而是因为连接池早已接近耗尽。
清理Checkout路径后,主数据库的读取减少了50%,事务减少了33%。同时重新评估了MySQL配置——InnoDB线程并发数多年前设置得过于保守,从未重新评估。增加线程并发配置后,移除了一个从未在连接数和CPU指标并列分析前发现的瓶颈。
切换策略
团队没有从Redis到MySQL"开关式"切换,而是采用"影子模式"并行运行:每次预约同时写入Redis和MySQL,Redis保持为数据源。这样可以并排比较两个系统,验证MySQL产生了正确的业务结果且满足性能要求。一旦对正确性和性能满意,再将数据源切换为MySQL。整个切换过程逐步进行,从低流量Pod开始,逐步扩展到最高容量商家。
Shopify的实践证明:MySQL现在可以处理曾被认为需要专门基础设施的工作负载。如果你在考虑为高吞吐互斥场景使用Redis、Kafka或自定义协调层,你现有的数据库可能已经足够。瓶颈往往不在你预期的地方——优化查询和锁花了数周时间,真正的限制却来自那些根本没在看的代码。
来源:Shopify Engineering Blog (https://shopify.engineering/scaling-inventory-reservations)