秒杀压测:1000 人抢 100 件,超卖了 37 件
大促压测结果:1000 个并发抢 100 件库存,超卖 37 件。你要用三种武器把超卖清零。
学 · 45 min
01用唯一索引 + 幂等键实现幂等写入
场景同一用户连点五次「抢购」按钮,只能成交一件--第一道防线。
把「同一件事」定义成唯一键(用户 + 活动 / 商品),建唯一约束 +
on conflict do nothing。重复请求在数据库层被无声吞掉,第二次起的插入根本不生效。这是所有幂等设计里最便宜、最可靠的一档:不依赖任何应用层逻辑。create unique index uq_seckill on seckill_orders (user_id, activity_id); insert into seckill_orders(user_id, activity_id, ...) values (:uid, :aid, ...) on conflict (user_id, activity_id) do nothing returning id; -- 有返回 = 抢到,无返回 = 重复请求易错幂等键的颗粒度是业务定义的:用户+活动?用户+商品+秒?定义错颗粒度,要么吞单要么重复。
02乐观锁:版本号 / CAS 更新与重试策略
场景「读-改-写」三步之间数据被人改了怎么办--提交时校验,冲突就重试。
update ... set x = 新值, version = version + 1 where id = ? and version = 旧version:影响行数为 0 说明被抢先,应用层重读再试。没有锁等待、吞吐高,适合冲突少的场景;冲突率高时重试风暴反而拖垮吞吐。-- 先读:select stock, version from products where id = 1; -- version=5 update products set stock = stock - 1, version = version + 1 where id = 1 and version = 5; -- CAS -- 返回 0 行 = 被别人抢先改了:重读、重算、重试(应用层循环)易错CAS 的 where 条件是「旧值」不是「目标值」--写成 stock >= 0 就变成了下一条的原子扣减,语义混了。
03悲观锁:SELECT ... FOR UPDATE
场景复杂的多步「读-算-写」,一步锁住,别人排队。
事务里先
select ... for update把行锁住,读到的值保证不被别人动,算完再 update、commit 放锁。绝对正确、逻辑最直白;代价是串行化:后到的全部排队,吞吐由事务长度决定。begin; select stock from products where id = 1 for update; -- 锁住 -- 应用判断 stock > 0 update products set stock = stock - 1 where id = 1; commit; -- 放锁,下一个排队者进来易错锁持有时间 = 整个事务长度:锁住行之后再调外部接口、发短信,就是把全队列按住陪等。
04原子扣减:UPDATE ... SET stock = stock - 1 WHERE stock >= 1
场景其实大多数「防超卖」只需要这一条语句。
把「判断 + 扣减」压进一条 UPDATE:
set stock = stock - 1 where id = ? and stock >= 1。单语句的原子性由数据库保证,不为 0 就扣不成负。影响行数 0 = 没抢到,应用直接处理失败分支。没有读-改-写间隙、没有竞态窗口。update products set stock = stock - 1 where id = 1 and stock >= 1 returning stock; -- 返回新库存 = 成功;无返回 = 售罄易错条件是
stock >= 1而不是应用层先判断--判断放应用层,间隙里库存就被别人扣了(这就是当初超卖 37 件的机制)。05三种方案的并发吞吐与失败率差异
场景三把武器都造好了,压测台上见真章。
① 原子扣减:单字段扣减的最优解,无重试无等待,吞吐最高;② 悲观锁:多步业务逻辑(扣库存 + 建订单 + 记流水)必须串行时的正确解,吞吐受事务长度限制;③ 乐观锁:冲突稀疏时近于无锁,冲突密集时重试风暴。选型口诀:简单扣减用原子,复杂流转用悲观,冲突少才乐观。
-- 压测脚本骨架:pgbench 或多会话并发 -- 每个会话循环执行: update products set stock = stock - 1 where id = 1 and stock >= 1; -- 记录三种方案下:成功数 / 失败数 / 平均耗时 / 超卖数(必须 = 0)易错别为「显得高级」上乐观锁重试框架--简单场景一条原子 UPDATE 完胜,工程判断力恰恰体现在克制。
练 · 60 min
- 用唯一索引 +
ON CONFLICT DO NOTHING实现幂等下单参考答案
只有第一次 returning 返回 id(= 抢到),之后每次都返回 0 行=重复请求在数据库层被无声吞掉。幂等键的颗粒度是业务定的:用户+活动还是用户+商品,想清楚再建。
-- 秒杀订单表:幂等键 = (user_id, activity_id),一人一单 create table seckill_orders ( id bigint generated always as identity primary key, user_id uuid not null, activity_id int not null, created_at timestamp not null default now(), unique (user_id, activity_id) ); -- 同一用户连点五次 = 同一条语句发五遍,连跑五遍看效果 insert into seckill_orders (user_id, activity_id) values ('00000000-0000-0000-0000-000000000042', 1) on conflict (user_id, activity_id) do nothing returning id; select * from seckill_orders; -- 无论跑几遍,永远只有一行 - 用版本号乐观锁扣库存,模拟冲突后重试
参考答案
where 带的是「读到的旧版本号」,不是目标值--写成 stock >= 0 就变成原子扣减了,语义混掉。返回 0 行 = 冲突,重读、重算、重试的循环放在应用层;冲突密集时重试风暴会拖垮吞吐,所以乐观锁只适合冲突稀疏的场景。
-- products 加版本号 alter table products add column if not exists version int not null default 0; -- 第一步:读(假设拿到 stock=100, version=5) select id, stock, version from products order by id limit 1; -- 第二步:CAS 写--把上面查到的 id 代进来 update products set stock = stock - 1, version = version + 1 where id = '查到的id'::uuid and version = 5; -- UPDATE 1:成功 -- 冲突现场:还拿旧版本 5 再试一次(另一个会话已把它改成 6) update products set stock = stock - 1, version = version + 1 where id = '查到的id'::uuid and version = 5; -- UPDATE 0:被抢先 - 用
FOR UPDATE悲观锁扣库存参考答案
绝对正确、逻辑最直白,代价是串行化:吞吐由事务长度决定。锁住行之后千万别再调外部接口、发短信--那等于按住整个队列陪等。
-- 窗口 A(事务一): begin; select id, stock from products where id = '某商品id'::uuid for update; -- 行锁到手,别人排队 -- 此处应用判断 stock > 0:锁保护下读到的值不会被人偷改 update products set stock = stock - 1 where id = '某商品id'::uuid; commit; -- 放锁,下一个排队者进来 -- 窗口 B:在 A 未 commit 时跑同样的 select ... for update -- 会一直卡住,直到 A commit 才返回 - 用原子条件更新扣库存,验证不会扣成负数
参考答案
「判断 + 扣减」压进同一条 UPDATE,原子性由单语句事务保证,不存在读-改-写的竞态窗口。当初超卖 37 件的病根,就是把 stock > 0 的判断放在了应用层。
-- 把第一个商品的库存压到 1,再连扣三次 update products set stock = 1 where id = (select id from products order by id limit 1); update products set stock = stock - 1 where id = (select id from products order by id limit 1) and stock >= 1 returning stock; -- 第一次返回 0 -- 同一条再跑两遍:返回 0 行 = 售罄 select stock from products where id = (select id from products order by id limit 1); -- 0,永远不会是 -1 - 用
pgbench或多个会话并发压测,对比三种方案的成功率与耗时参考答案
记录指标:TPS、平均/最大延迟、成功扣减数(100 - stock)、抢到人数、超卖数(必须 = 0)。三个变体各跑一轮:原子扣减直接跑;悲观锁把两条语句包进 begin...commit;乐观锁在脚本外加重试循环--预期原子版吞吐最高。
-- bench.sql:pgbench 每个事务干两件事(幂等下单 + 原子扣减) \set uid random(1, 10000) insert into seckill_orders (user_id, activity_id) values ('00000000-0000-0000-0000-' || lpad(:uid::text, 12, '0'), 1) on conflict (user_id, activity_id) do nothing; update products set stock = stock - 1 where id = '压测商品id'::uuid and stock >= 1; -- 压测前重置现场: -- update products set stock = 100 where id = '压测商品id'; -- truncate seckill_orders; -- 跑法:pgbench -c 50 -j 4 -t 20 -f bench.sql shop -- 压测后核对(超卖必须为 0) select stock from products where id = '压测商品id'; select count(*) as 抢到人数 from seckill_orders; -- 不得超过 100