秒杀压测:1000 人抢 100 件,超卖了 37 件

大促压测结果:1000 个并发抢 100 件库存,超卖 37 件。你要用三种武器把超卖清零。

学 45 min
练 60 min
盘 15 min
共 120 分钟

学 · 45 min

  1. 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;   -- 有返回 = 抢到,无返回 = 重复请求

    易错幂等键的颗粒度是业务定义的:用户+活动?用户+商品+秒?定义错颗粒度,要么吞单要么重复。

  2. 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 就变成了下一条的原子扣减,语义混了。

  3. 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;   -- 放锁,下一个排队者进来

    易错锁持有时间 = 整个事务长度:锁住行之后再调外部接口、发短信,就是把全队列按住陪等。

  4. 04原子扣减:UPDATE ... SET stock = stock - 1 WHERE stock >= 1

    场景其实大多数「防超卖」只需要这一条语句。

    把「判断 + 扣减」压进一条 UPDATEset 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 件的机制)。

  5. 05三种方案的并发吞吐与失败率差异

    场景三把武器都造好了,压测台上见真章。

    原子扣减:单字段扣减的最优解,无重试无等待,吞吐最高;② 悲观锁:多步业务逻辑(扣库存 + 建订单 + 记流水)必须串行时的正确解,吞吐受事务长度限制;③ 乐观锁:冲突稀疏时近于无锁,冲突密集时重试风暴。选型口诀:简单扣减用原子,复杂流转用悲观,冲突少才乐观。

    -- 压测脚本骨架:pgbench 或多会话并发
    -- 每个会话循环执行:
    update products set stock = stock - 1
    where id = 1 and stock >= 1;
    -- 记录三种方案下:成功数 / 失败数 / 平均耗时 / 超卖数(必须 = 0)

    易错别为「显得高级」上乐观锁重试框架--简单场景一条原子 UPDATE 完胜,工程判断力恰恰体现在克制。

练 · 60 min

  1. 用唯一索引 + 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;   -- 无论跑几遍,永远只有一行
  2. 用版本号乐观锁扣库存,模拟冲突后重试
    参考答案

    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:被抢先
  3. 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 才返回
  4. 用原子条件更新扣库存,验证不会扣成负数
    参考答案

    「判断 + 扣减」压进同一条 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
  5. 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
过关标准 能讲清三种防超卖方案各自的适用场景和代价。