[译] We replaced Redis with MySQL for inventory reservations—and it scaled

We replaced Redis with MySQL for inventory reservations—and it scaled

我们用 MySQL 取代 Redis 实现库存预留——且成功支撑起高并发扩展

How we used SKIP LOCKED, composite primary keys, and connection visibility to hit our scale targets.
我们如何利用 SKIP LOCKED、复合主键以及连接可见性达成规模扩展目标。

Author: Emilie Noel | Published on May 12, 2026
作者:Emilie Noel | 发布于 2026 年 5 月 12 日

Redis to MySQL for inventory


During checkout, when a buyer clicks "Complete purchase," we need to guarantee the items they're buying are still available. If we get this wrong in one direction, two buyers purchase the same last unit: the merchant has to cancel an order, send an apology email, and eat the support cost. If we get it wrong in the other direction, we tell a buyer something is sold out when it isn't, and the merchant loses a sale they should have made.

在结账过程中,当买家点击“完成购买”(Complete purchase)时,我们必须保证他们购买的商品依然有货。如果我们在一个方向上出错,两个买家可能会买下同一件仅剩的商品:商家不得不取消订单、发送道歉邮件并承担客服成本(超卖);如果我们在另一个方向上出错,向买家提示某件商品已售罄但实际上还有库存,商家就会白白损失本该达成的销售额(少卖)。


At Shopify's scale, either failure compounds fast. On Black Friday 2025, merchants on our platform hit a record $5.1 million in sales per minute at peak. Every one of those transactions touches inventory.

在 Shopify 的体量下,任何一种错误都会迅速雪崩放大。在 2025 年的“黑色星期五”大促期间,我们平台上的商家在峰值时创下了每分钟 510 万美元销售额的纪录。而这其中的每一笔交易都会触及库存。


Our oversell protection system handles this by reserving inventory during payment processing—a short hold that prevents two concurrent checkouts from claiming the same unit. For years, this ran on Redis. When we moved toward a unified database strategy, we had to answer a hard question: could MySQL handle the same scale?

我们的防超卖保护系统(oversell protection system)通过在支付处理期间预留库存来解决这一问题——这是一种短暂的锁定(short hold),用来防止两个并发结账流程抢占同一个库存单元。多年来,该系统一直运行在 Redis 之上。当我们推进统一数据库战略时,不得不面对一个棘手的问题:MySQL 是否能够承受同等的规模?


Earlier attempts had failed. A single row with a quantity column couldn't handle the contention. MySQL 8's SKIP LOCKED feature introduced a different design: one row per inventory unit instead of one row per item. Inspired by 37signals' approach to database-backed load distribution, we rebuilt reservations on MySQL and hit our high-throughput targets during peak 2025 traffic.

早期的尝试均告失败。带有数量列(quantity column)的单行记录根本无法应对激烈的行锁争用。而 MySQL 8 的 SKIP LOCKED 特性带来了一种全然不同的设计:不再是每个商品(item)对应一行,而是每个库存单元(inventory unit)对应一行。受到 37signals 在基于数据库的负载分发做法 的启发,我们在 MySQL 上重构了库存预留系统,并在 2025 年流量峰值期间成功达到了高吞吐量目标。


But the hardest lesson wasn't about database design. It was discovering that the real bottleneck wasn’t what we were observing and measuring. This post walks through the solution and what we found along the way.

然而,最深刻的一课并非关于数据库设计,而是发现真正的瓶颈并不是我们起初所观察和测量的指标。本文将深入讲解这一解决方案以及我们在过程中获得的发现。


The challenge

面临的挑战


What is oversell protection?

什么是防超卖保护?


Oversell protection has two main operations:

防超卖保护包含两个核心操作:


  • Reserve: When payment starts, we mark items as reserved (a short hold, e.g. several minutes).

  • Claim: When payment succeeds, we permanently deduct quantity from the inventory ledger (source of truth).

  • 预留(Reserve):当支付流程开始时,我们将商品标记为已预留(短暂持有,例如数分钟)。

  • 确认核销(Claim):当支付成功后,我们永久性地从库存账本(作为唯一数据真实来源,source of truth)中扣减数量。


Checkout completion depends on this being fast and correct. Slow reservations trigger throttling and a worse buyer experience. Mistakes mean overselling (angry customers) or underselling (lost revenue).

结账流程的顺畅完成高度依赖于这一机制的迅速与准确。预留变慢会触发限流并恶化买家体验;而差错则意味着超卖(惹怒客户)或少卖(营收损失)。


Scale and correctness requirements

规模与正确性要求


Scale here is not abstract: Shopify powers over 14% of U.S. ecommerce, and on Black Friday 2025 we saw an 11% increase in sales per minute at peak over the prior year. Reservations run on every checkout that touches inventory, so the system must handle that burst without dropping requests or breaking consistency.

这里的“规模”绝非纸上谈兵:Shopify 支撑着全美 14% 以上的电商交易,在 2025 年黑色星期五,我们每分钟销售额的峰值相比前一年又增长了 11%。每一次涉及库存的结账都会执行预留操作,因此系统必须能够承受住这股突发洪峰,且既不能丢弃请求,也不能破坏一致性。


We needed to:

我们需要满足以下要求:


  • Support the platform's high-performance throughput targets during peak traffic

  • Respect multi-location inventory (only reserve from locations that can fulfill)

  • Keep ACID guarantees between reservations and the inventory ledger

  • Prioritize correctness: no overselling and no lost reservations

  • 在高峰流量期间支持平台的高性能吞吐量目标

  • 适配多发货地库存(仅从能够履约的发货地点预留)

  • 在预留与库存账本之间保持 ACID 事务特性

  • 坚持正确性优先:绝不超卖,绝不丢失预留


The Redis model and its limits

Redis 模式及其局限性


The previous system stored reservations in Redis. Each item had a quantity key, and reserving meant DECR, releasing meant INCR. Redis handled concurrency fine, but reservations and the inventory ledger lived in two different systems.

先前的系统将预留状态存放在 Redis 中。每个商品对应一个数量 key,预留执行 DECR,释放执行 INCR。Redis 在并发性能上表现良好,但库存预留与库存账本却分布在两个不同的系统中。


The claim step (payment processed, permanently deduct inventory) required updating MySQL and cleaning up Redis, and those two operations couldn't be wrapped in a single atomic step. Depending on the order, this could cause overselling (item sold but was never deducted from the ledger) or underselling (item deducted and still marked reserved).

确认核销(Claim)步骤(支付已处理,永久扣除库存)需要同时更新 MySQL 并清理 Redis,而这两步操作无法封装在单个原子操作中。根据操作先后的不同,这可能引发超卖(商品售出但未从账本中扣减)或少卖(商品已扣减但在 Redis 中仍被标记为预留)。


On top of that, the Redis model had no multi-location awareness and added the operational cost of a separate cluster to maintain. Moving reservations into the same MySQL database as the ledger meant we could wrap everything in ACID transactions and eliminate these failure modes entirely.

此外,Redis 模式缺乏多发货地感知能力,并增加了维护独立集群的运维成本。将预留操作搬迁到与库存账本相同的 MySQL 数据库中,意味着我们可以将所有操作包裹在 ACID 事务内,从根本上杜绝这些故障模式。


The solution: SKIP LOCKED

解决方案:SKIP LOCKED


Core idea: one row per unit, bounded by design

核心思想:每单元一行,有界设计


Instead of one row per item with a quantity column, we use one row per sellable unit. An item with 10 units has 10 rows. Reserving three units means selecting and moving three rows in a single transaction. By keeping reservations and the inventory ledger in the same database, we get ACID across reserve and claim—fixing classes of bugs that were possible with Redis (e.g. payment succeeds but inventory isn't claimed, or the reverse).

我们不再使用“每个商品对应一行并带有数量列”的模式,而是为每一个可售单元分配单独的一行记录。一件拥有 10 件库存的商品对应 10 行数据。预留 3 件意味着在单个事务中选出并移动 3 行。通过将预留与库存账本保存在同一数据库中,我们在预留与核销之间实现了跨操作的 ACID 保证——彻底解决了 Redis 方案下可能出现的各类别 Bug(如支付成功但库存未核销,或反之)。


A simplified reserve flow looks like this:

一个简化的预留流程如下所示:


-- Start transaction with READ COMMITTED isolation
BEGIN;

-- Select N units, skipping any locked by other transactions
SELECT id
FROM available_units
WHERE shop_id = ?
  AND inventory_item_id = ?
  AND inventory_group_id = ?
ORDER BY shop_id ASC, inventory_item_id ASC, inventory_group_id ASC, id ASC
LIMIT 3
FOR UPDATE SKIP LOCKED;

-- Move selected units to reserved_units table
INSERT INTO reserved_units (unit_id, cart_token, expires_at, ...)

-- Remove from available pool
DELETE FROM available_units
WHERE (shop_id, inventory_item_id, inventory_group_id, id) IN (?, ?, ?, ?);

COMMIT;
-- 以 READ COMMITTED 隔离级别开启事务
BEGIN;

-- 选取 N 个单元,自动跳过被其他并发事务锁定的行
SELECT id
FROM available_units
WHERE shop_id = ?
  AND inventory_item_id = ?
  AND inventory_group_id = ?
ORDER BY shop_id ASC, inventory_item_id ASC, inventory_group_id ASC, id ASC
LIMIT 3
FOR UPDATE SKIP LOCKED;

-- 将选中的单元移动到 reserved_units 表
INSERT INTO reserved_units (unit_id, cart_token, expires_at, ...)

-- 从可用库存池中移除
DELETE FROM available_units
WHERE (shop_id, inventory_item_id, inventory_group_id, id) IN (?, ?, ?, ?);

COMMIT;

SKIP LOCKED is what makes this scalable: if another transaction has locked some rows, MySQL skips them and returns other available rows. No waiting on the same row, less contention.

SKIP LOCKED 正是其具备高扩展性的关键:如果有另一个事务锁定了某些行,MySQL 会直接跳过它们并返回其他未被锁定的可用行。事务之间无需排队等待同一行锁释放,锁争用大幅降低。


But one row per unit for all inventory would break down at scale—an item with 50,000 units across 10 locations would mean 500,000 rows, and the reserve query would slow as it scans through them. Instead, we maintain a bounded pool of available rows, capped at 1,000 per item/location combination. Reservations consume rows from this pool; a replenishment process refills it from the inventory ledger.

但是,如果对所有库存都按每单元建一行,在超大规模下必将崩溃——一个在 10 个发货地共有 50,000 件库存的商品将产生 500,000 行记录,预留查询在扫描这些行时性能会显著退化。为此,我们维护了一个有界的可用行池(bounded pool),每个“商品/地点”组合的上限固定为 1,000 行。预留操作从该池中消费行,而独立的补货流程(replenishment)则负责从库存账本中向其补充。


Why 1,000? The cap needs to be large enough to absorb bursts without running dry, but small enough to keep the table compact and the SKIP LOCKED scan fast. We sized it based on observed peak reservation rates per item/location during flash sales: 1,000 gives us enough headroom that replenishment can keep up under sustained load without the table growing to a point where query performance degrades.

为什么设定为 1,000? 这个上限既要足够大以吸收突发流量峰值而不至于迅速枯竭,又要足够小以确保表体积紧凑、SKIP LOCKED 扫描足够迅速。我们根据闪购秒杀期间观察到的单商品/地点峰值预留速率确定了该数值:1,000 提供了充裕的缓冲空间,使得补货流程在持续的高负载下完全能够跟得上消耗,同时避免了数据表因过度膨胀而导致查询性能下降。


What happens if the pool empties? During an extreme flash sale, the pool for a hot item can be temporarily exhausted. When that happens, the reserve path triggers replenishment inline. A lock ensures only one transaction replenishes at a time; other concurrent reserves for the same item wait for it to finish rather than all racing to insert rows, avoiding a thundering herd. Once replenishment completes, the waiting transactions proceed with a full pool. The buyer never sees the item as unavailable (unless it truly is). This adds latency to that specific reservation, but it preserves correctness: a buyer with available inventory is never turned away.

如果池子耗尽了会怎样? 在极度火爆的秒杀期间,某个热门商品的池子可能会被暂时抽空。当这种情况发生时,预留路径会内联(inline)触发补货。通过互斥锁保证同一时间只有一个事务执行补货;针对同一商品的其他并发预留请求会等待其完成,而不是同时争相插入数据,从而规避了惊群效应(thundering herd)。一旦补货完成,等待中的事务便可在补满的池子中继续执行。买家绝不会看到商品显示为“缺货”(除非库存确实见底了)。虽然这会给这批特定的预留请求带来轻微延迟,但它确保了正确性:只要还有真实库存,就绝不会拒买家于门外。


Key technical decisions

关键技术决策


1. Composite primary key: fewer locks per row

1. 复合主键:减少每行持有的锁数量


Our first prototype used an auto-increment ID as the primary key. When we observed lock behavior (e.g. with SHOW ENGINE INNODB STATUS), we saw two row locks per reservation instead of one.

我们的首个原型采用了自增 ID 作为主键。当我们观测锁行为时(例如通过 SHOW ENGINE INNODB STATUS),发现每个预留单元竟然产生了两个行锁,而不是一个。


With an auto-increment primary key, InnoDB was locking both the secondary index used in the WHERE clause and the clustered index (primary key). We switched to a composite primary key (shop_id, inventory_item_id, inventory_group_id, id) so the columns we filter on are part of the primary key. That reduced to one lock per row, which was important when running many reservations per second.

在自增主键模式下,InnoDB 会同时锁定 WHERE 条件中命中的二级索引以及聚簇索引(主键)。我们将主键重构为复合主键(shop_id, inventory_item_id, inventory_group_id, id),使我们过滤筛选的列本身就成为主键的一部分。这样就将锁数量降到了每行仅一个锁,这在每秒执行海量预留操作时至关重要。


Takeaway: at this scale, index and primary key design directly affect lock count and throughput.

核心启示:在这样的业务规模下,索引与主键的设计直接决定了加锁数量与系统吞吐量。


2. READ COMMITTED: avoiding gap (supremum) locks

2. READ COMMITTED:规避间隙锁(Supremum 锁)


When we ran SELECT ... FOR UPDATE SKIP LOCKED on an empty table that needed replenishment, we saw gap locks (including on the "supremum" pseudo-record). Those locks blocked the replenishment transaction from inserting new rows and could lead to deadlocks.

当我们在一个需要补货的空表上运行 SELECT ... FOR UPDATE SKIP LOCKED 时,我们观察到了间隙锁(gap locks,甚至锁定了 "supremum" 伪记录)。这些间隙锁会阻塞补货事务插入新行,并可能诱发死锁。


We changed the transaction isolation level from REPEATABLE READ (MySQL default) to READ COMMITTED for these transactions. Under READ COMMITTED, InnoDB doesn't take gap locks in the same way, so replenishment could proceed. Jahfer Husain's guide to InnoDB locking was very helpful for understanding this. This was our first use of a non-default isolation level in this codebase; it required small framework support for setting isolation per transaction.

我们针对这类事务将事务隔离级别从 REPEATABLE READ(MySQL 默认级别)调整为了 READ COMMITTED。在 READ COMMITTED 级别下,InnoDB 不会以同样的方式施加间隙锁,因此补货能够顺利执行。Jahfer Husain 的 InnoDB 锁机制指南 对我们理解这一点提供了极大的帮助。这也是我们该代码库中首次采用非默认的隔离级别,为此我们在框架层引入了针对单事务配置隔离级别的轻量支持。


3. Consistent lock ordering: avoiding deadlocks

3. 一致的加锁顺序:杜绝死锁


We hit deadlocks when reserve and claim touched two tables in different orders. Reserve was doing INSERT into reserved_quantities then DELETE from reservation_units; claim was doing DELETE from reserved_quantities. Different transactions could lock the two tables in different orders and form a cycle.

当预留(reserve)和核销(claim)以不同顺序操作两张表时,我们遭遇了死锁。预留操作是先向 reserved_quantities 写入 INSERT,再从 reservation_units 执行 DELETE;而核销操作则是从 reserved_quantities 执行 DELETE。不同事务以不一致的顺序为两张表加锁,便形成了死锁环路。


The fix was to standardize the order: reserve always DELETEs from the units table first, then INSERTs into reserved_quantities. Claim only touches reserved_quantities. Because both paths now acquire locks in the same order, neither can hold a lock the other is waiting for—no more circular waits.

解决方案是将加锁顺序标准化:预留操作始终先从 units 表执行 DELETE,随后再 INSERT 到 reserved_quantities;而核销操作只触及 reserved_quantities。由于两条执行路径获取锁的顺序完全一致,任何一方都不会持有对方正在等待的锁——环路等待彻底不复存在。


4. Batching with UNION ALL

4. 利用 UNION ALL 进行批量合并


Each round trip to the database has a cost. For carts with multiple line items, we batch reservation queries using UNION ALL so we fetch all needed units in one round trip:

与数据库之间的每次网络往返(round trip)都伴随着性能开销。针对包含多件不同商品的购物车,我们使用 UNION ALL 将预留查询批量合并,从而只需一次网络往返就能取到全部所需单元:


(SELECT id, inventory_item_id, inventory_group_id
 FROM available_units
 WHERE shop_id = 1 AND inventory_item_id = 100 AND inventory_group_id = 1
 ORDER BY shop_id, inventory_item_id, inventory_group_id, id
 LIMIT 2 FOR UPDATE SKIP LOCKED)
UNION ALL
(SELECT id, inventory_item_id, inventory_group_id
 FROM available_units
 WHERE shop_id = 1 AND inventory_item_id = 200 AND inventory_group_id = 1
 ORDER BY shop_id, inventory_item_id, inventory_group_id, id
 LIMIT 5 FOR UPDATE SKIP LOCKED)
(SELECT id, inventory_item_id, inventory_group_id
 FROM available_units
 WHERE shop_id = 1 AND inventory_item_id = 100 AND inventory_group_id = 1
 ORDER BY shop_id, inventory_item_id, inventory_group_id, id
 LIMIT 2 FOR UPDATE SKIP LOCKED)
UNION ALL
(SELECT id, inventory_item_id, inventory_group_id
 FROM available_units
 WHERE shop_id = 1 AND inventory_item_id = 200 AND inventory_group_id = 1
 ORDER BY shop_id, inventory_item_id, inventory_group_id, id
 LIMIT 5 FOR UPDATE SKIP LOCKED)

That cut total round trips and helped latency under load.

这显著削减了总的网络往返次数,改善了高负载下的响应延迟。


The real bottleneck: connections, not CPU

真正的瓶颈:连接数,而非 CPU


In production we hit a throughput ceiling well below our target. Reservation latency (e.g. P90) was acceptable, CPU wasn't maxed out, and the queries were already optimized. So we looked elsewhere.

在生产环境中,我们撞上了一个远低于预定目标的吞吐量天花板。预留操作的延迟(如 P90)仍在可接受范围内,CPU 远未饱和,查询本身也早已做足了优化。因此,我们不得不将视线转向别处。


We tried batching reservations across multiple checkouts in a single SKIP LOCKED query to use fewer connections. That helped in load tests but added complexity. We also moved some read load to replicas. Still, something didn't add up.

我们曾尝试在单个 SKIP LOCKED 查询中对跨多个结账流程的预留请求进行批处理,以减少连接占用。这在负载测试中有一定起色,但引入了额外的复杂度。我们还将部分读负载分流到了只读副本(replicas)。然而,各种现象依然解释不通。


Following the symptoms

顺着症状深入探查


During load tests we saw:

在负载测试期间,我们观察到:


  • Threads queuing in MySQL

  • CPU spiking when queued work ran

  • Connection exhaustion to MySQL backends on ProxySQL layer

  • MySQL 内部线程发生堆积排队

  • 排队任务集中执行时 CPU 发生毛刺暴涨

  • ProxySQL 代理层对 MySQL 后端的数据库连接彻底耗尽


So we added visibility into which business processes were holding database connections and for how long. Knowing that connections are exhausted doesn't tell you who is holding them. We needed per-caller attribution.

于是,我们增加了细粒度的可观测性,以弄清楚到底是哪些业务流程在持有数据库连接、各自持有了多久。仅仅知道连接池耗尽并不能指出究竟是谁占着连接。我们需要精确到调用方维度的追踪归因(per-caller attribution)。


On the application side, we annotated every SQL statement with a comment tag identifying the business process, like /* conn_tag:checkout_completion */. On the ProxySQL layer, we added tracking that parses the tag and measures how long each caller holds a connection. The result: total connection hold time, broken down by business process.

在应用端,我们为每条 SQL 语句附加了一段标明业务流程的注释标签,例如 /* conn_tag:checkout_completion */。在 ProxySQL 代理层,我们加入了专门的追踪逻辑,负责解析这一标签并统计每个调用方持有连接的时长。最终产出的结果是:按业务流程细分展示的连接持有总时间。


This immediately showed which callers were consuming the most connection time. Not which queries were slow, but which processes were holding connections across long transactions. If you're hitting connection limits and can't tell why, this pattern (tag at the app layer, aggregate at the proxy) is straightforward to implement and immediately actionable.

这立刻指明了究竟是哪些调用方吞噬了最多的连接时间。核心问题并不在于哪条查询执行得慢,而在于哪些业务流程在跨长事务期间一直占着连接不放。如果你也遇到了连接数瓶颈却找不到元凶,这种模式(应用层打标,代理层聚合)实现难度低且立竿见影。


What we found

我们的发现


Once we could see connection usage, we learned that reservations weren't the only heavy user. Other parts of the checkout path were holding connections longer than necessary. They hadn't been optimized because they hadn't been the first to hit the limit. Connections are finite: at high throughput we need many short transactions per second. When other code held connections longer, reservations were the straw that broke the camel's back—not because reservations were slow, but because the pool was already near depletion.

一旦连接使用情况可视化,我们才发现预留并不是唯一的“连接大胃王”。结账链路中的其他环节持有连接的时间同样远超实际需要。之所以此前未被优化,是因为它们并不是首个触碰到瓶颈的模块。数据库连接是极其有限的资源:在高吞吐场景下,我们需要的是每秒成千上万个短平快的轻量事务。当其他业务代码长期霸占连接时,预留操作成了压垮骆驼的最后一根稻草——不是因为预留本身慢,而是因为连接池早已经被消耗殆尽。


The cleanup of the checkout path removed 50% of reads and 33% of transactions on the primary database. We also revisited MySQL configuration. InnoDB thread concurrency had been set conservatively years earlier and never re-evaluated. Our workload had changed. After increasing thread concurrency where we had headroom, we removed a bottleneck we hadn't seen until we had connection and CPU metrics side by side.

对结账主链路的治理精简消除了主库上 50% 的读操作以及 33% 的事务量。我们还重新审视了 MySQL 的底层配置。InnoDB 线程并发度(thread concurrency)在数年前被设置得相当保守,之后从未被重新评估。然而我们的工作负载早已天差地别。在硬件尚有富余容量的前提下上调线程并发度后,我们消除了一个此前只有将连接指标和 CPU 指标并排对比才能看清的瓶颈。


Combined, the cleanup and configuration changes removed the ceiling. We could scale beyond our previous limit and meet our targets. During high-volume flash sales, writer CPU stayed under 50% and reader CPU under 16%, with headroom to spare.

代码精简与参数调优双管齐下,彻底粉碎了此前的性能天花板。我们成功突破了先前的容量极限并达成了既定目标。在大流量秒杀促销期间,主库写节点的 CPU 维持在 50% 以下,从库读节点的 CPU 维持在 16% 以下,留出了充裕的弹性缓冲。


The cutover

线上割接切流


We didn't flip a switch from Redis to MySQL. We ran both systems in parallel in what we called "shadow mode": every reservation was written to both Redis and MySQL, with Redis remaining the source of truth. This let us compare the two systems side by side, validating that MySQL produced the correct business outcomes and met our performance requirements on real production traffic. Because both systems were live, there were no in-flight reservations to migrate. Redis reservations continued to be honored while MySQL built up its own state.

我们并没有采取“一刀切”的断崖式切换。我们让两个系统在所谓的“影子模式”(shadow mode)下并行运转:每一次预留都同时写入 Redis 和 MySQL,但依然以 Redis 作为业务的唯一权威源(source of truth)。这使我们能够在真实的生产流量下对两个系统进行横向比对,验证 MySQL 能产出正确的业务结果并在性能上满足预期。由于两套系统同时在线,因此完全不存在需要迁移的在途(in-flight)预留。Redis 预留继续正常生效,而 MySQL 则借此构建起自身的完整状态。


Once we were satisfied with correctness and performance, we switched the source of truth to MySQL. If anything went wrong, we could revert to Redis with a kill switch; the dual-write path was still active, so Redis had a complete view of reservations at all times. The rollout was gradual, pod by pod, starting with low-traffic pods and working up to our highest-volume merchants.

当我们对正确性和性能表现均感到满意之后,才将权威数据源正式切向 MySQL。一旦出现任何异常,我们都可以通过一键熔断开关(kill switch)秒级切回 Redis;双写链路此时仍然保持活跃,因此 Redis 始终掌握着预留数据的全局全量视图。发布过程采取逐个 Pod 渐进推进的策略,从低流量 Pod 着手,最终稳步覆盖到交易量最大的顶级核心商家。


What we learned

经验与总结


We learned so much from this project but the two main takeaways:

从这个项目中我们收获颇丰,其中最为核心的两点心得是:


1. Revisit old decisions

1. 定期重新审视过去的决策


What wasn't possible five years ago (e.g. MySQL for this workload) can be possible today with new features like SKIP LOCKED. The same goes for configuration: thread limits and other "rule of thumb" settings are worth re-checking when workload and hardware evolve. If the numbers don't add up (e.g. low CPU but queuing), dig in.

五年前在技术上行不通的事(例如让 MySQL 承载此类高并发工作负载),在如今拥有了诸如 SKIP LOCKED 等新特性的加持下,可能已经变得完全可行。配置参数亦是如此:随着工作负载与服务器硬件的迭代,线程限制等各类“经验法则”设置都值得重新推敲。如果监控指标呈现异常矛盾(例如 CPU 利用率很低但排队严重),务必追查到底。


2. Start small and observe

2. 从微小原型起步,注重实际观察


We got a lot of value from a minimal prototype: a small ruby script and MySQL, no full framework like Rails. Observing the database (e.g. lock behavior in a second terminal) taught us more than theory alone. Simple tooling and a tight feedback loop beat big, opaque systems when exploring.

我们从极简原型中获益匪浅:一段简短的 Ruby 脚本搭配 MySQL,不引入像 Rails 这样庞大的全功能框架。亲眼观察数据库的真实表现(例如在另一个终端中实时观测加锁行为),能让你学到远多于空谈理论的真知灼见。在探索未知领域时,简单的工具链与紧凑的高频反馈闭环远胜于庞大臃肿且黑盒的系统。


MySQL can now handle workloads we used to assume required specialized infrastructure. If you're reaching for Redis, Kafka, or a custom coordination layer for high-throughput mutual exclusion, your existing database might already be enough.

MySQL 如今已足以应对过去我们认为必须依赖专用基础设施的工作负载。如果你正考虑为了高吞吐互斥而引入 Redis、Kafka 或自研的分布式协调层,不妨重新审视一下——你手中现有的关系型数据库或许就已经完全能够胜任。


The bottleneck wasn't where we expected. We optimized queries and locks for weeks; the real limit was connection usage in code we weren't even looking at. If the numbers don't add up—low CPU but high queuing—instrument the full path. The answer is often in the plumbing, not the engine.

瓶颈往往潜藏在出乎意料的盲区。我们苦心优化查询与锁长达数周,但真正的制约却来自我们此前压根没有关注的代码中所产生的连接占用。如果指标出现矛盾——低 CPU 伴随高排队——请在全链路进行埋点检测。答案往往藏在管道管线中,而不是引擎本体。


Crucially, this wasn't about making reservations fast. It was about making them safe neighbors. Reservations share a database with cart updates, payment processing, and order creation. A system that saturates connections or holds locks too long jeopardizes all of them. The real bar was sustaining throughput without degrading database health for everything else.

最核心的要义在于:这项改造并不仅仅是为了让库存预留变快,更是为了让它成为一个“安全的邻居”。库存预留与购物车更新、支付结算、订单创建等核心业务共享同一套数据库。任何一个耗尽连接或长时间占锁的子系统都会危及整个大局。真正的技术门槛在于:在保证自身高吞吐的同时,绝不损害数据库为其他所有业务维持健康运行的能力。


For us, the payoff is concrete: more reliable reservations mean no oversells and more successful purchases from our merchants.

对我们而言,带来的收益是实打实且显著的:更加坚固可靠的库存预留机制意味着彻底告别超卖,也意味着我们平台上的商家能够顺利拿下更多笔交易。


Water line vs race meme: inventory

posted @ 2026-09-20 09:16  talentzemin  阅读(18)  评论(0)    收藏  举报