8 月那场秒杀,客户定的规则很简单:200 份茶礼,上午 9 点整开抢,每人限 1 份,先到先得。听着是常规活动,我们前后改了三版,压测跑了好几轮。记一下过程。
第一版,压测就塌了
代码沿用商城下单那套:请求进来,查 MySQL 库存,够就 INSERT 订单再 UPDATE 库存。用 ab 压到 150 并发,两个问题一起冒出来。
一是超卖。200 份卖出了 217 份,日志里最夸张的一秒有 9 个请求都读到「还剩 1」。老毛病,先查后写中间没有互斥。
二是 502。每个请求都要走一遍查库、插订单、更新库存、写日志,MySQL 连接池被占满,PHP-FPM 32 个进程全在等,后面的请求直接超时。
那天下午我们在会议室白板上把三步圈出来:查库存、扣库存、写订单。三步分在应用层和数据库层,中间隔着网络,谁也没法保证原子。
第二版,把库存挪进 Redis
思路是别让秒杀请求碰 MySQL。
活动开始前 10 分钟,由一个定时任务把 200 这个数写进 Redis,key 是 seckill:20210820:stock,同时把商品信息、限购规则也缓存好。
抢购请求进来,直接对 Redis 做操作。但也不能用「先 GET 再 DECR」,那样还是两步。我们写了个 Lua 脚本,一段里做完三件事:读剩余、判断大于 0、DECR、把用户 id 塞进一个已抢集合防重复。Redis 单线程执行 Lua,天然原子。
脚本返回 1 表示抢到,返回 0 表示没了,返回 2 表示这人抢过。应用层拿到结果,只往 RabbitMQ 丢一条消息,立刻回前端「抢到了,正在生成订单」。
订单落库交给消费者慢慢做,不占抢购那几秒。
这一版压到 500 并发,Redis 那边稳,超卖没了。但新问题出在异步:有 3 条消息消费失败,用户看到「抢到了」,半小时后订单还没出来。
第三版,补两头的窟窿
异步这条链,我们加了三样。
- 消息带唯一 msg_id,消费端按它去重,重试不会生成两张订单。
- 消费失败不丢,进重试队列,最多 5 次,还不行就写一张 fail_order 表,定时任务扫到就告警,人工补。
- 用户那边加了一个「结果查询」接口,前端拿到「抢到了」之后每 2 秒查一次,最多查 30 秒,订单出来就跳支付页。查不到就提示稍后再看,不写死失败。
MySQL 那层也没完全不管。库存表加了一个 version 字段,消费者真正扣减时用乐观锁:
UPDATE goods SET stock = stock - 1, version = version + 1 WHERE id = 某个 AND version = 读到的值
受影响行数为 0 就说明被人改过,回滚这条消息。正常流量下这一步几乎不会冲突,它是给「Redis 和数据库不一致」留的最后一道。
限购和刷子
每人限 1 份,靠 Redis 那个已抢集合挡。但上线前有人提了一句:同一个人换几个手机号呢。我们没上复杂的风控,只加了两层很朴素的:接口要登录态,token 里带用户 id;同一 IP 一分钟内超过 20 次请求直接返回 429,Nginx 层限流,不到应用层。
后来翻日志,被 429 拦掉的有 300 多次,多数是同一个脚本在试。没追究,也没影响正常用户。
9 点那三分钟
8 月 20 号,8 点 50 我们把库存数打到监控大屏上,200。三个人各盯一块:一个看 Redis 的 QPS 和 stock 剩余,一个看 RabbitMQ 队列长度,一个看 MySQL 和错误日志。
9 点整,QPS 曲线一根竖线。9 点 00 分 08 秒,stock 掉到 0,也就是 8 秒抢完。后来发现不对,是 Lua 脚本里已抢集合的过期时间设短了,有几个人被判成「没抢过」,又抢了一次,实际生成了 203 条消息。
队列长度最高堆到 400 多,消费者 6 个进程,一分半钟排空。9 点 03 分,200 份对应的订单全部落库,多出来的 3 条被乐观锁那步挡回去,写了 3 行 fail_order,客服照着退了。
没有负数,没有超卖,也没人打电话说付了钱没订单。
复盘时吵的一件事
有人问,8 秒就抢完,前面那些压测是不是白做了。争了会儿没结论。我自己的看法是,压测不是为了撑住 8 秒,是为了那 8 秒里别出负数、别 502、别让用户看到「抢到了」却没订单。这三样都没出。
散会前把已抢集合的过期时间从 5 分钟改到 2 小时,重新跑了一遍脚本。那行配置现在还在,注释写着:宁可占点内存。
下午客户发来一张照片,仓库那边 200 份茶礼装了半人高的箱子,贴着面单。挺实在的。

