秒杀压测: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

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

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

  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
过关标准 能讲清三种防超卖方案各自的适用场景和代价。

今日复盘

  1. 写出 N+1 的具体现象,并给出 JOIN 或批量查询的改法
    参考答案

    识别特征比改法更重要:应用日志里同一模板的语句海量出现,基本就是 N+1。ORM 懒加载是重灾区,列表场景必须显式预取。

    -- 现象:后端先取 100 个订单,再循环逐个查用户 = 101 次往返
    -- 日志特征:同一语句模板几毫秒内刷屏 100 次
    
    -- 改法一:join 一次取回
    select o.id, o.total_amount, u.name as 用户名
    from orders o
    join users u on u.id = o.user_id
    where o.status = 2
    order by o.created_at desc
    limit 100;
    
    -- 改法二:数组参数批量取(ORM 里就是预取)
    select id, name from users
    where id = any((select array_agg(user_id)
                    from (select user_id from orders
                          where status = 2
                          order by created_at desc limit 100) t));
  2. 把 orders 按月做 RANGE 分区,迁移数据
    参考答案

    第一个坑就是主键:分区键 created_at 不进主键,建表直接报错。超出所有分区范围的行 insert 会报错,线上可以再补一个 default 分区兜底。

    -- ① 分区父表:结构同 orders,但主键必须含分区键
    create table orders_p (
      id           uuid not null,
      user_id      uuid not null,
      status       integer,
      total_amount numeric(10,2),
      created_at   timestamp not null,
      paid_at      timestamp,
      primary key (id, created_at)          -- 分区键必须进主键
    ) partition by range (created_at);
    
    -- ② 数据跨两年多,用循环把逐月分区建齐
    do $$
    declare
      d date := date_trunc('month', current_date) - interval '24 months';
    begin
      while d <= date_trunc('month', current_date) loop
        execute format(
          'create table orders_p_%s partition of orders_p for values from (%L) to (%L)',
          to_char(d, 'YYYY_MM'), d, d + interval '1 month');
        d := d + interval '1 month';
      end loop;
    end $$;
    
    -- ③ 迁移(练习库 100 万行一次搬可接受;线上要走双写或停写窗口)
    insert into orders_p (id, user_id, status, total_amount, created_at, paid_at)
    select id, user_id, status, total_amount, created_at, paid_at
    from orders;
    
    -- ④ 核对行数一致后切换
    select (select count(*) from orders) as 老表,
           (select count(*) from orders_p) as 新表;
    -- 一致后:drop table orders; alter table orders_p rename to orders;
  3. 六类失效:函数包裹列、隐式类型转换、前导 %、OR 连接、低选择性、排序方向不匹配
    易错点

    六类里只有①②④是「写法病」要治;⑤ 是优化器正确判断,治了反而慢。