2016 年 2 月,Kylie Cosmetics 在 Shopify 上做了一次 drop,把自己的店和同一个 database shard 上其他所有店一起打挂了。Shopify 后来在工程博客里复盘,那次的根因在 checkout 的写入:每开一次 checkout 就在 MySQL 里建一条记录,流程往下走的每一步又都回头改这同一条记录。库存不是那次的主角,撞车的方式却一样:并发全压在同一行上。

这篇的起点是 Shopify 2026 年 5 月那篇工程博客,他们把库存预留从 Redis 换回了 MySQL。顺着它往外查,同一个防超卖问题在近几年的公开材料里至少有六种解法,彼此差得很远:Shopify 把队摆进数据模型,阿里云把队摆进数据库内核,国内秒杀常见的分桶把队摆在应用层的路由上,等候室把队直接摆到用户面前,DynamoDB 干脆不排队、撞了再重试,Cloudflare 的 Durable Objects 让并发根本不发生。差别不来自技术水平,来自各自的背景。同样叫 flash sale,形态至少有三种:几百万商家各自开卖、单 SKU 零点同步开闸、长尾 SKU 偶尔撞一次车。争抢密度差着好几个数量级,能用的方案跟着分岔。

几百万商家各自开卖:Shopify 把 quantity 切成离散行#

Shopify 工程博客 2026 年 5 月记了这次替换,参照的量级是 2025 年黑五峰值每分钟 510 万美元的成交额。这一节分三段看:Redis 为什么出局、新模型长什么样、把队摆在 SQL 层要付的代价。

Redis 的 quantity key 进不了 ledger 的事务#

防超卖在他们那里拆成两个动作:reserve 在付款开始时把货占住,claim 在付款成功后把货从 inventory ledger 里真正扣掉。预留只是一次短暂持有,原文给的量级是几分钟。

原文把范围限定在这两个操作上。预留怎么释放、怎么过期、付款失败或者弃单时那批货怎么回到可售状态,全篇都没有交代。想照着这套设计落地的话,这几条要自己补。

换掉之前,可售数量记在 Redis 里,每个 item 一个 quantity key,reserve 是一次 DECR,把数加回去是一次 INCR。并发计数这块 Redis 没出过问题。出问题的是 claim:它既要更新 MySQL 里的 ledger,又要清掉 Redis 里的那条预留,没有一个事务能把这两步一起提交。两步只成了一步,账就对不上,要么超卖,要么少卖。

flowchart TB subgraph OLD["换掉之前:claim 跨两个系统,没有共同的事务"] C1["claim"] -->|"① 扣 inventory ledger"| M1[("MySQL")] C1 -->|"② 清掉那条预留"| R1[("Redis")] end subgraph NEW["换掉之后:三张表同库,一个事务一起提交"] C2["reserve / claim"] --> U2["reservation_units"] C2 --> Q2["reserved_quantities"] C2 --> L2["inventory ledger"] end

Redis 的并发计数从头到尾都是对的,出局是因为它进不了 ledger 的事务。把一块状态挪进一个更快的独立系统,换来的吞吐是真的,代价是这块状态从此落在业务的事务边界外面。记成「最终一致就行」是不够的,落到 Shopify 这个场景,具体代价就是 claim 那一步永远拿不到事务保证。

另外两笔账是模型本身欠的:Redis 那个 quantity key 没有 multi-location 的概念,而平台要支持按库存地点分别管货;再就是多养一个集群的运维成本。

行池、SKIP LOCKED 和 inline 补货#

新模型换掉的是「一个 item 一行、行上挂一个 quantity 列」这个结构:改成一个可售单元占一行,multi-location 靠行里的 inventory_group_id 区分。reserve 在一个事务里用 SELECT ... FOR UPDATE SKIP LOCKED 去挑可用行,别的事务已经锁住的行直接跳过,MySQL 返回其余可用的行。这就是整套方案能扩上去的地方:并发的预留各挑各的行,互相不排队。

这个模式的原型是座位。MySQL 8.0.1 发布 SKIP LOCKED 时,官方博客举的例子就是球场订票,一行一个座位:

CREATE TABLE seats (
  seat_no INT PRIMARY KEY,
  booked ENUM('YES', 'NO') DEFAULT 'NO'
);

START TRANSACTION;
SELECT * FROM seats WHERE seat_no BETWEEN 2 AND 3 AND booked = 'NO'
FOR UPDATE SKIP LOCKED;

座位天生就是离散的,一行一座不用谁去设计。同一篇博客里还有个语义相反的 NOWAIT:拿不到锁立刻抛 ERROR 3572,不跳过。选座位要的是 NOWAIT,你点的那个位子被别人占了得当场告诉你;抢任意票要的是 SKIP LOCKED,哪个座都行,给个空的就成。Shopify 做的事情,等于把本来可替换的 quantity 人工切成座位,好让 SKIP LOCKED 用得上。

行池是有上限的,每个 item/location 组合最多 1000 行。文中给这个数字的理由只有一句,1000 行留的余量足够让补货在持续压力下跟得上。可用行被挑空的时候,补货是 inline 做的,并且用一把锁保证同一时刻只有一个事务在补,其余撞上来的 reserve 等它做完再走,避免一窝蜂插行造成 thundering herd。

flowchart TD A["reserve 事务开始"] --> B["SELECT ... FOR UPDATE SKIP LOCKED
从 reservation_units 挑可用行"] B --> C{"挑够了吗"} C -->|够| D["DELETE 挑中的行"] C -->|行池空了| E["取补货锁
同一时刻只放一个事务进来"] E --> F["补回可用行,上限 1000 行/item/location"] F --> B D --> G["INSERT 进 reserved_quantities"] G --> H["与 ledger 同一个事务提交"]

这套模型是拿一种竞争换了另一种。一行上的并发争抢没有了,代价是多出一池行要维护,而补货是整条路径上唯一被串起来的一段,触发时机又正好是行被抢空的时候。1000 这个上限就卡在中间:给小了,补货变成新的排队点;给大了,每次 SKIP LOCKED 要扫的范围又变长。这个数跟商品的售罄速度直接挂钩,换个品类就不成立,得按自己的峰值补货速率压一遍。

把队摆在 SQL 层,这些细节都得自己扛#

文章末尾把方法交代了:拿一个 ruby 脚本加一个 MySQL 搭最小原型,边跑边看 SHOW ENGINE INNODB STATUS。四条调优都是这样试出来的,前三条收的是锁的数量和范围,第四条收的是往返次数。

主键从自增 id 换成 (shop_id, inventory_item_id, inventory_group_id, id),过滤条件本身成了主键前缀,一次预留在一行上从拿两把锁(二级索引一把、回主键一把)降到一把。表被挑空要补货时,REPEATABLE READ 下的范围扫描会拿 gap lock,把补货的 INSERT 挡在外面,他们把这条路径单独换成 READ COMMITTED;换掉的同时也交出了「同一个事务里两次读到相同结果」这个保证,放在这条路径上代价不大,一个 reserve 事务里并没有需要前后一致的第二次读。死锁那一环靠固定加锁顺序:reserve 永远先 DELETE reservation_units 再 INSERT 进 reserved_quantities,claim 只碰 reserved_quantities,两条路径按同一个顺序拿锁。购物车里有多个 item 时,多条预留查询用 UNION ALL 拼成一个请求发下去,省掉往返。

换过去之后拖住吞吐的不在行锁上。他们看到的症状有三样:MySQL 里线程在排队、排到的活一跑 CPU 就尖峰、ProxySQL 那层到 MySQL 后端的连接被打满。判断落在连接数上,因为高吞吐要的是每秒很多笔短事务,谁把连接握久了谁就占着名额。对行池方案来说这是个好消息:瓶颈不在它自己身上,是 checkout 路径上其他组件把连接握得比需要的久。

放量按 pod 逐个来,先低流量的,最后才轮到成交量最高的商家。

结果那头要分清两类。换到 MySQL 直接换来的是跨系统不一致的那类 bug 跟着消失、连接天花板搬开之后吞吐还能往上走、少养一个 Redis 集群。另一类(主库上少掉 50% 的读和 33% 的事务)是顺着连接归因清理 checkout 路径得来的,跟预留存在哪里没有关系。余量那头,高量级 flash sale 期间 writer CPU 低于 50%,reader CPU 低于 16%。延迟分布文中没有给,而 reserve 是靠行锁排队的,队伍变长的时候 CPU 恰恰是闲的,p99 却已经在涨,所以拿 CPU 余量当「扛住了」的证据,在这条路径上说服力有限。

三类应对:不让并发发生、让它排队、让它撞了再说#

把上面这套拆开看,Shopify 用了两层。行池和 SKIP LOCKED 在 SQL 与数据模型这一层,而在它前面还有一层是原文没提的:checkout throttle。Kylie 那次之后,Shopify 在边缘的 nginx 上用 OpenResty 的 Lua 模块做了一个漏桶(leaky bucket),超过限额的 checkout 请求不返回错误码,改成跳转到排队页;放进来的用户拿一个签名 cookie,这个 session 后面不再被限。洪峰在进应用层之前就被削掉了一截。

把近几年的公开材料摊开,应对并发扣减的路子分三类,「队摆在哪一层」只是其中一类里面的事:

flowchart LR U["并发的扣减请求"] --> K{"怎么对付并发"} K -->|"不让它发生"| A["单写者对象
一个 SKU 一个单线程 actor
Cloudflare Durable Objects"] K -->|"让它排队"| Q["排队点从内到外五层
引擎语句排队 → 行池 SKIP LOCKED
→ 应用分桶 → 边缘限流 → 等候室"] K -->|"都放进来,撞了再说"| O["条件写 + 失败重试
DynamoDB"] A --> D[("库存落盘")] Q --> D O --> D

排队那一类里,越靠内的层越精确也越贵:引擎层的排队知道两条语句撞的是不是同一行,代价是这个能力得数据库自己提供。越靠外越便宜也越粗糙:边缘限流不用知道任何业务语义,代价是被挡下的人可能本来买得到。下面先按从内到外的顺序把排队这一类走完,再看另外两类。

单 SKU 零点同步开闸:AliSQL 把语句排进引擎#

阿里云走的是相反方向:保留「一个 item 一行、行上挂一个 quantity 列」这个结构,把排队做进数据库内核。文档自己把场景写得很直白,「在秒杀等业务场景中,扣减库存是一个常见的需要高并发,同时也需要串行化的任务模型」,冲的就是所有人同一秒抢同一行。淘宝自己那套秒杀栈公开的材料不多,能拿到一手文档的是阿里云卖给 RDS 和 PolarDB 客户的这两个功能。

Statement Queue 在 server 层给语句分桶排队,把可能撞同一行的语句放进同一个桶,桶内串行,避免它们一起冲到 InnoDB 的行锁上互相冲突。hint 有两种写法,一种按给定值哈希,一种按 WHERE 里某个字段的值哈希:

update /*+ ccl_queue_value(1) */ t set c=c+1 where id = 1;
update /*+ ccl_queue_field(id) */ t set c=c+1 where id = 1 and name = 'xyz';

桶数由 ccl_queue_bucket_count 控制(1 到 64,默认 4),每个桶里的并发语句数由 ccl_queue_bucket_size 控制(1 到 4096,默认 64)。

Inventory Hint 管的是另一头,把「扣减成功就提交」压进一条语句:

UPDATE /*+ COMMIT_ON_SUCCESS ROLLBACK_ON_FAIL TARGET_AFFECT_ROW(1) */ T
SET c = c - 1 WHERE id = 1;

TARGET_AFFECT_ROW(1) 的语义是语句影响的行数跟设定值一致才算成功,否则算失败;COMMIT_ON_SUCCESS 成功即提交,ROLLBACK_ON_FAIL 失败即回滚。行锁的持有时间被压到一条语句的长度,应用那边不用再发一个 commit 过来。

代价写在文档的限制里:这几个 hint 不能在 autocommit 模式下用,而且因为成功就提交,热点行更新必须是事务里的最后一条语句,存储过程和触发器里也用不了。

数字那头都是厂商自己的口径,看趋势比看绝对值有意义。RDS 文档给的是单行热点更新可达 3.1 万 TPS。AliSQL 的 wiki 上有一张对照表,8C-16G 规格、单行更新,并发拉到 2048 线程时社区版 MySQL 掉到 1 TPS,AliSQL 还有 4896 TPS。Statement Queue 的文档里有一条更直白的:4096 线程下不开这个功能,实例直接发生了主备切换。

这条路的前提写在 hint 的名字里。ccl_queue_*COMMIT_ON_SUCCESS 都是 AliSQL 和 PolarDB 自己加的,upstream MySQL 没有。能走这条路,因为数据库内核是自己的。

秒杀级争抢加事后对账:库存分桶拆成 N 份#

分桶跟 Shopify 的行池是同一个想法的两个实现,都把一份库存拆开,让并发撞不同的份。差别在拆的粒度、放在哪一层,以及扣不动的时候谁来兜。

阿里云开发者社区上有篇讲这个演进的文章,给的起点是单行更新的 QPS 在 500 以内,而且单一商品的超高并发扣减会影响到同一数据库实例上其他商品的扣减。分桶的做法是给热点商品再做多 key 拆分,请求按桶数取模路由,先走缓存扣减;某个桶的库存接近扣完时,系统自动去 MySQL 库存集群的总池子里捞一部分过来放进桶内。

分歧在扣不动之后。原文点出的坑是碎片:

所谓碎片问题,举个例子,假如扣减的是红包金额,假设红包金额至少要发 1 块钱,换算成整型数也就是 100,在多个分桶扣减的情况下,最后部分分桶的剩余库存值可能低于 100,而所有分桶加起来的总额又大于 100。如果不做处理,就会造成资损。

按这个例子摆一下(每个桶的数字是示意):

flowchart TB T["总剩余 300,按最小单位 100 算还能发 3 份"] --> B1["桶 1 剩 80"] T --> B2["桶 2 剩 60"] T --> B3["桶 3 剩 90"] T --> B4["桶 4 剩 70"] B1 --> X["四个桶单独看都不够 100
不处理就一份也发不出去"] B2 --> X B3 --> X B4 --> X

文章给的处理办法是把碎片桶自动下线、折回数据库总池,重新分配到新的缓存 key,直到碎片消解;或者在总量快扣完时,尾量直接回数据库扣。

Shopify 那套不需要处理碎片,因为它的行是整数个可售单元,一行就是一个单元,挑不到行就是真的没货了。分桶扣的是数值,才会剩下不够一个最小单位的零头。「拆成离散行」和「拆成数值桶」的这点实际差别,比放在 Redis 还是放在 MySQL 上更要紧。

另一处分歧是兜底手段。分桶这条路普遍配一套异步落库加对账:缓存扣完就返回,订单进队列,后台消费再去数据库把账做平。Shopify 明确拒绝了这条路,reserve、claim 和 ledger 的改动要落在同一个事务里一起提交。

开卖瞬间的洪峰和抢跑脚本:等候室按速率放行#

前面几种都在让扣减这一步变快或者变有序。等候室换了个位置:洪峰进系统之前就被拦在一条你能定速率的队里,而且这条队用户看得见。

Cloudflare Waiting Room 跑在 Workers 上,算法按 Waiting Room 的全局状态给每个 Worker 分配名额,名额用完就开始排队,全局状态靠一条 Durable Objects 的 pipeline 每几秒同步一次。要调的是三个阈值:total active users(站上同时允许多少人)、new users per minute(每分钟放进来多少新人)、session duration(进去之后算活跃多久)。放行按 FIFO,每个人拿一个 cookie 管出队。

这一类还带来一个前面几种都没碰的维度:公平。开卖瞬间涌进来的里面有大量脚本,它们比人快几个数量级,先到先得的队会被它们占满。Queue-it 的做法是开卖前先把人收进一个倒计时 pre-queue,按它自己的说法,「At sale start, visitors are randomized and assigned a place in line, and visitors who arrive after the sale starts get a first-come, first-served place in line」。开卖那一刻位置被打乱,早到几毫秒就不再是优势。

代价有两头。架构上,Cloudflare 那套要求 CDN 已经在你前面(proxied DNS 或者负载均衡器指过来),不然用不了;换第三方就是多接一个外部依赖,而且排队页本身进了关键路径。能力上,它不解决超卖,只把进来的人压到你扛得住的速率,扣减那一步该怎么做还得照前面几节选一套。

Queue-it 在容量这件事上的一些判断记在 Queue-it 播客:autoscaling 什么时候会失效

争抢稀疏的长尾 SKU:DynamoDB 条件写重试#

前三种做法都在挑队摆在哪一层。DynamoDB 的答案不在这条轴上:谁都不排队,让并发一起写,撞上的那几笔失败了再重试。

AWS 的文档把并发控制分成乐观和悲观两类。乐观那条的标准做法是版本号加条件写:读的时候拿到 version,写回去时把它挂进 ConditionExpression,对不上就整条写失败。扣库存可以省掉版本号,直接把余量本身写进条件。AWS Database Blog 那篇举的例子是几个厨师抢公共配料做三明治,等于多个 SKU 一次扣减外加一个幂等键:

UpdateExpression:
  ADD sandwiches :this_sandwich
  SET bacon = bacon + :minus_two, bread_slice = bread_slice + :minus_two,
      lettuce = lettuce + :minus_two, tomato = tomato + :minus_two

ConditionExpression:
  bacon >= :two AND bread_slice >= :two AND lettuce >= :two AND tomato >= :two
  AND not contains(sandwiches, :sandwich_id)

条件不满足时返回 ConditionalCheckFailedException。那篇博客讲的就是这个异常以前不好用:只知道写失败了,不知道是哪样配料不够,得再发一次 GetItem 去问。把 ReturnValuesOnConditionCheckFailure 设成 ALL_OLD 之后,失败响应里直接带回当前值,那次多余的读省掉了。

代价写在 AWS 自己的选型表里。乐观锁那一行的适用条件是「低争抢、重试便宜」;多项原子性加中等争抢推 TransactWriteItems;长流程和跨进程协调推一张专门的锁表,带 lease 和 heartbeat。争抢一密这条路就不划算,每次撞车都是一次已经花掉的写,重试回去还可能再撞,而爆款 SKU 恰好是争抢最密的那种。

跨区域还有一条限制:global tables 的写是 last writer wins,版本号的条件检查在跨区域时不按预期生效,AWS 给的建议是把冲突挪到应用层自己处理。

峰值压得进单线程:Durable Objects 让并发不发生#

前面所有做法都在管理并发。Cloudflare 的 Durable Objects 把问题挪走了,让同一个 SKU 上根本不存在并发。

一个 Durable Object 有一个全局唯一 id,请求按 id 路由到同一个实例,而「Durable Objects are single-threaded and cooperatively multi-tasked, just like code running in a web browser」。一个 SKU 一个对象,扣减就是一段普通的读改写,不需要锁,因为同一时刻只有一个请求在跑。每个对象还自带「durable, transactional, and strongly consistent storage」,上限 10 GB,扣减和账本落在同一个对象里天然就是一个事务。

代价写得很直白:「An individual Object has a soft limit of 1,000 requests per second」。串行化换来的正确性,天花板就是这个数,而爆款 SKU 要冲的恰好是这个上限。一个对象扛不住的时候,只能把这个 SKU 再拆成多个对象,于是又绕回分桶那套碎片问题。

同一个想法在别处也有:Redis 单线程执行 Lua 脚本,同一个 key 上的复合操作天然串行;把同一个 SKU 的扣减都路由到一个分区、交给一个消费者,效果也一样。共同点是拿单写者换掉并发控制,共同代价是那个单写者的吞吐就是硬顶。

背景决定解法:五个变量#

六种做法摆在一起,差别能收进五个变量。前四个各自能把一批方案直接判出局,第五个正交,管的是另一件事。

争抢集中在一个 SKU 上,还是摊在几百万商家上#

天猫 2020 年双 11 的订单创建峰值是 58.3 万笔每秒,出现在 11 月 11 日零点零分 26 秒,是同一秒开闸之后的瞬时值;秒杀这种玩法本身就是把人往同一个 SKU 上赶。Shopify 公开的 BFCM 2024 数字是边缘 2.84 亿 requests/min、应用层 8000 万 requests/min,2025 年的压测里有一项是 8 万以上 checkouts/min,折合约 1300 checkouts 每秒;这些量摊在几百万商家上,跨时区铺开几十个小时。两个口径不可直接比,可比的是 checkouts 那一项,差距在两三个数量级。

还有两件事让 Shopify 单个 MySQL 面对的并发再降一级。一是 pod 分片:shop_id 是分片键,每个 pod 是一个独立的 MySQL shard 外加自己的 Redis 和 Memcached,新商家注册时分到某个 shard,失衡了用 Ghostferry 把店迁走。二是前面那个 checkout throttle,洪峰在边缘就削掉了一截。

所以「换回 MySQL 还扛得住」这个结论,有一半功劳在分片和限流上,SKIP LOCKED 没有单独撑起它。库存表如果是全局单库,又正对着同步开闸的秒杀,同样的行池模型未必成立。Kylie 那次的教训也在这里:pod 隔离挡得住跨 shard 的连带,挡不住同一个 shard 内部被一家店打满。这个变量最先决定的是走乐观还是走悲观,AWS 给乐观锁写的适用条件就是低争抢,到了爆款 SKU 上它第一个出局。同一个变量也卡着单写者那条路:单个 Durable Object 1000 rps 的软上限,决定了它能不能用。

可售单元天然离散,还是一个可加减的数#

座位、票、编号的限量款天生一行一个,行池不用造。普通商品的库存是一个可加减的数,Shopify 要先把它切成行,于是多出行池维护、inline 补货、补货锁、1000 这个上限,以及这几样各自的调优。分桶那条路选择不切成离散单元,直接拆数值,省掉了造行的工夫,换来的是碎片要兜底。

数据库内核改不改得动#

AliSQL 那条路的门槛在这里。ccl_queue_fieldCOMMIT_ON_SUCCESS 要求你用的是 AliSQL 或者 PolarDB。Shopify 用的是 upstream MySQL,内核动不了,能动的只有 schema、SQL 和隔离级别,所以它的解法全部落在 SQL 与数据模型层。两边的可选项范围本来就不一样,这一条跟谁的方案更高明无关。

少卖和对账能不能接受#

Shopify 的 claim 要跟 inventory ledger 同事务,这条约束一立,跨系统的方案全出局,Redis 再快也进不来。分桶那套默认接受最终一致:缓存先扣,订单进队列,后台异步落库,再靠对账把账做平,碎片带来的少卖也在这个框架里消化。哪条路合适,取决于账做不平的时候谁来兜、兜得起多少。

抢跑的脚本和准点的人要不要分开#

前四个变量都只管一件事:别超卖。没有一个回答谁先拿到货。限量发售里这是另一道验收,开卖瞬间涌进来的里面有大量脚本,它们比人快几个数量级,先到先得的队会被它们占满。这几种里只有等候室正面处理它。这个变量跟前四个正交,要公平就在入口加一道,不影响下面选哪套扣减方案。

按五个变量选一条路#

五个变量里前四个决定走哪条路,第五个正交,落在入口那一道上。连起来是这样:

flowchart TD E{"这个 SKU 的峰值
压不压得进一个单线程对象"} -->|"压得进,1000 rps 以内"| S0["单写者对象
代价:单对象 1000 rps 软上限就是天花板"] E -->|"压不进"| W["入口先摆一道等候室或限流
要公平就用等候室,和下面几条都不冲突"] W --> Q0{"同一个 SKU 上
撞车频不频繁"} Q0 -->|"不频繁,重试便宜"| S1["条件写 + 失败重试
代价:争抢一密,重试本身变成负载"] Q0 -->|"爆款级,必须排队"| Q1{"少卖和对账
能接受吗"} Q1 -->|"能,账后面补得平"| S2["库存分桶
代价:碎片要兜底,另养一套异步落库和对账"] Q1 -->|"不能,扣减要和账本同事务"| Q2{"数据库内核
动得了吗"} Q2 -->|"动得了:AliSQL / PolarDB"| S3["引擎层语句排队 + 事务性 hint
代价:不能用在 autocommit,必须是事务末条语句"] Q2 -->|"动不了:upstream MySQL"| Q3{"可售单元
切得成离散行吗"} Q3 -->|"切得成"| S4["行池 + SKIP LOCKED
代价:行池维护、补货锁、隔离级别和加锁顺序自己调"] Q3 -->|"切不成"| S5["只剩单行热点
代价:并发全压回一行,余地只剩入口限流"]

最后那条叶子是变量全卡死之后的结果:可售单元切不成离散行,内核又动不了,扣减还要跟账本同事务,并发就只能压回同一行。Shopify 能走到行池那条,靠的是库存本来就能按可售单元切开。

小结#

公开材料在同一个地方都缺一块:延迟分布。Shopify 给的是 CPU 余量,阿里给的是 TPS,Cloudflare 给的是单个对象 1000 rps 的软上限,AWS 给的是「低争抢、重试便宜」这种定性条件,p99 没人给。而这六种里有五种的代价都长在延迟的尾巴上,队伍变长的时候吞吐和 CPU 都还好看,p99 已经在涨。容量压测里这个口径问题很常见,见 九个瓶颈里只有三个在数据库上

还有一条:六种做法里只有等候室回答了「谁先拿到」。其余五种都只保证不超卖,不保证抢到的是准点来的人而不是脚本。限量发售如果把这两件事合在一起验收,漏掉的总是后面那件。

参考资料#

相关文章#