ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

高并发秒杀系统设计实战:从零搭建削峰限流的完整方案

高并发秒杀系统设计实战:从零搭建削峰限流的完整方案 不少人对秒杀方案的第一反应是背一大堆名词消息队列、Redis、限流、分布式锁。面试的时候能说出来但真要自己从零设计一套反而容易卡住不知道先做什么后做什么更不知道为什么有人用缓存、有人用队列、还有人干脆直接怼数据库。前段时间我从头到尾做了一套秒杀方案从需求分析到压测上线走完一整个过程。我打算用读完这篇差不多15分钟的时间把整个方案的决策过程、核心实现、参数取舍全部摊开讲一遍。不是给你一堆零散概念而是让你看完能自己画出一张逻辑闭环的架构图知道每一步在挡什么问题哪些环节是被高估的、哪些才是真正的生死线。我假设的场景是这个某个商品库存1万件计划在中午12点整开抢预估同时参与用户10万峰值可能在开抢后1秒到2秒内到来。在这个背景下你来看看每一步设计到底在解决什么问题。1. 秒杀到底难在哪很多人以为秒杀难在代码写不出来。实际上代码难的是少数真正的难点在于“吞吐量”和“一致性”这对矛盾。1.1 三个绕不开的矛盾第一个矛盾是用户看到商品无论如何都会点按钮。1万个商品10万人抢注定有9万人是陪跑的但他们发出的请求量和真正买到的人一样大。系统必须在入口就把陪跑的人拦住而不是让他们一路闯到数据库去扣库存。第二个矛盾是库存只有1万但扣库存的操作不能错。多扣一个不行少扣一个也不行两个用户同时抢到最后一件商品必须只有一个人成功。数据库本身能保证这种一致性但它扛不住瞬时十万级别的写入压力。第三个矛盾是用户等不起。如果所有请求都同步处理让10万个人在前端转圈等结果等的时候连接挂着不释放应用服务器的线程池很快就被占满后面谁都进不来了。这三个矛盾叠加在一起决定了秒杀方案不能是单点技术必须是一套层层设防的漏斗结构。1.2 方案演进的过程我最早接触秒杀的时候看过一些团队的初版方案就是把库存字段放在数据库里每次扣库存用一条SQL带where条件去更新。就像这样UPDATE goods SET stock stock - 1 WHERE id ? AND stock 0这条语句的逻辑其实是正确的数据库的行锁保证了同时只有一个事务能改成功不会超卖。但问题出在性能上每秒几千个请求打到同一行记录上数据库会产生激烈的行锁竞争CPU和IO开销直线上升QPS到几百往上就开始出现连接等待、超时、慢SQL。后来演进到Redis做库存扣减把商品库存预先加载到Redis利用Redis的单线程特性做原子操作。这样扛住了并发但异步把扣减结果同步到数据库时又面临数据一致性问题。再往后才形成了一套相对成熟的做法静态化前端页面挡流量网关做限流挡流量应用层做防重挡重复请求Redis做库存扣减消息队列做异步下单数据库只处理最终落库的数据。这套思路不是某个组发明的而是经历了多次大促验证出来的组合。2. 核心链路设计漏斗模型如何层层削峰整套方案的核心思想用一个词概括就是“削峰”。10万峰值进来直接打透所有环节是不可能的必须让流量逐层变窄。每个技术组件只承担自己最擅长的一部分职责。2.1 第一次削峰前端拦截很多人忽略前端拦截觉得前端防不了刷子、动态接口也绕得开。但前端做拦截的真实价值不是防刷而是挡掉不必要的人为流量。第一层是按钮置灰。开抢前页面按钮是灰色不可点的开抢瞬间变为可点用户点过一次后立即进入“处理中”状态防止着急的用户反复疯狂点击。一个用户手速再快0.5秒内也就点个七八次全放出来就是七八倍流量。第二层是验证答题。开抢前让用户先完成一个简单的拼图或者滑块校验这个操作天然把人和机器筛选一下同时把每个用户的点击节奏打散。不要小看这一下它能把瞬时峰值从几十万摊到几秒内给后端争取缓冲时间。第三层是静态页面缓存。商品页面的图片、CSS、静态HTML全部放到CDN上前端服务器只需要处理少量动态接口。上次做活动的时候入口页面CDN扛掉了80%以上的请求真正到达服务端的流量一点都不吓人。2.2 第二次削峰网关限流前端挡不住所有流量接下来的第一道技术门槛是网关层限流。这个位置要做的不是处理业务而是用一个最简单的规则把流量限制在系统能承受的范围内。我用的方案是令牌桶算法。理解方式很生活化想象有一个桶每隔固定时间往桶里放一个令牌桶最多只能存固定数量的令牌。每个请求进来必须先拿到一个令牌才能继续往下走拿不到令牌的直接返回“已抢完”或者“请求过于频繁”。为什么选令牌桶而不是固定窗口计数器因为令牌桶允许一定程度的突发流量。桶里攒了一些令牌瞬间来一大波请求只要桶里有令牌就先放行不会一刀切得过于生硬。而固定窗口计数器经常出现窗口边界双倍流量的问题在秒杀这种短促场景下容易误伤。具体参数上我一般根据下游Redis的承受能力来定。比如预估Redis扣库存可以稳定支撑每秒5000次操作那网关层限流就设在4000左右留出余量。宁可让一部分用户在网关层失败也不能让流量突破Redis的承受极限。2.3 第三次削峰应用层防重与会话保持限流之后一部分流量进入了应用服务但这个时候还不能直接扣库存。应用层第一件事是防止同一个用户重复提交这是导致超卖的隐性杀手。同一个用户开两个页面、手机和电脑同时登录每个设备各发一次请求如果应用层不做去重一个用户就可能占用两个库存名额。经典的方案是用Redis的SETNX命令做用户维度去重key是userId加商品Idvalue的过期时间设置成活动结束后几分钟。第一次请求SETNX成功继续流程第二次请求发现key已经存在直接返回“你已参与抢购”。另外一个容易忽略的点是应用服务必须水平扩展出多实例而且每个实例需要共享同一份去重数据不能存在本地内存里。我最初有一版就是图方便把userId放在实例的本地Map里结果流量一上来负载均衡把同一个用户分到了不同实例每个实例都认为自己没见过这个用户直接导致重复扣库存。血的教训。2.4 最后一道Redis原子扣减到了这一步真正的库存扣减放在Redis层完成。为什么不是等消息队列慢慢处理到数据库再扣因为数据库的写入性能撑不住瞬时压力。而Redis单线程处理命令天然串行化不存在并发竞争每秒可以抗住接近十万级别的简单读写操作。扣减库存我用的是Lua脚本在服务端一次性完成“检查库存是否充足”和“扣减库存”两个动作保证原子性。脚本长这样if redis.call(get, KEYS[1]) 0 then return -1 end redis.call(decrby, KEYS[1], ARGV[1]) return 0这段脚本虽然极简但却是整个秒杀方案的保险栓。先检查库存判断还有没有余量有就扣减没有就返回失败整个流程中间不会被其他命令插入。如果没有Lua脚本通过程序先查库存再发命令扣减中间哪怕隔了零点几毫秒两个并发请求都可能同时读到库存还有1然后同时执行扣减那就超卖了。Redis扣完库存之后说明这个用户已经拿到了“购买资格”但订单还没生成。到这里就需要把压力往下游转移这就到了异步下单环节。3. 下单链路实现消息队列如何接住瞬时流量库存扣减成功只是拿到了资格订单的真正生成如果还是同步写数据库依然会把数据库压垮。正确的做法是让消息队列接住剩下的流程。3.1 为什么非要用消息队列我见过一台应用服务器直接下单写的方案扣减Redis库存成功之后直接调订单服务写数据库。表面上看流程最短但实际压力来的时候订单数据库的写入QPS瞬间冲到几千磁盘IO和锁竞争同时爆发数据库连接池直接耗尽。用消息队列的目的本质上就是把“同步请求”变成“异步处理”。用户发出抢购请求系统只告诉用户“你正在排队”然后掐住请求的尾巴不动真正创建订单的任务丢给MQ由消费端以每秒几百到一两千的速度慢慢消化。整个过程用户毫无感知。前端轮询查询订单状态几秒后看到订单生成这只是时间换了空间。3.2 队列设计里的取舍消息队列的选型有很多种我用的是通用中间件核心就三个要求高可用、持久化、消费顺序不敏感。为什么消费顺序不敏感因为不同用户的订单是独立的彼此之间没有先后关系不需要保证全局有序。但如果同一用户短时间内重复下单就必须按用户维度做幂等处理。我在消费端加了一张去重表主键是userId加商品Id加活动批次如果在去重表中已经存在记录说明这个用户之前已经生成过订单直接跳过。库存扣减和消息发送之间存在一个分布式事务难题Redis扣减成功了但消息没有发出去怎么办或者消息发出去了消费端消费失败怎么办这个场景我用了一个比较实用的方案把“待发送消息”记录到本地数据库表定时任务扫描这张表发现没发出去的消息就重新投递到MQ。下游消费端则通过幂等机制处理重复消息保证数据最终一致。这个方案不优雅甚至有点土但在实际项目中非常稳。3.3 订单超时与库存回滚用户拿到购买资格后并不一定会付款大量用户可能抢到之后犹豫、放弃、甚至跑了。如果订单一直占着库存商品就卖不完。所以必须给订单设定支付有效期。我设计的规则是15分钟内未支付自动取消订单并释放库存。实现方式有两种一种是定时任务扫表把超时未支付的订单批量关掉同时把Redis中的库存加回去另一种是用延迟消息每个订单创建时发一条15分钟后的延迟消息消息到了检查订单状态未支付就关单。定时任务扫表的缺点是时间粒度不好控制大量订单堆积时会短时间集中处理产生一波突发压力。延迟消息更精准但需要中间件支持延迟队列功能。两种方案我都用过最终选择了延迟消息为主、定时任务兜底的做法。库存回滚要特别注意一点必须先关闭订单再回补库存顺序反了会出现库存回补了但订单还没关掉的中间状态用户付款成功却发现没货的情况售后投诉能让人崩溃。4. 容量估算与压测验证方案设计的最后一环其实是验证方案能不能扛住预估流量。很多人忽略这一步觉得架构设计完就万事大吉结果一压测就露馅。4.1 预先估算容量做容量估算的时候我会先把数字写下来。已知条件参与人数10万商品库存1万开抢时间往后算0到5秒内是峰值。按TCP连接比例估算10万个客户端同时刷新页面网关层瞬时进来的请求可能在每秒3到5万左右。这个量级经过前端拦截、网关限流后到达Redis的流量被限制在每秒4000左右。Redis扛4000写操作完全没问题。数据库的写入来自于消息队列消费速度可以手动控制每秒几百的写入量对数据库来说是小儿科。估算完之后每个环节的承受能力都留了至少30%的余量。限流阈值设在4000是因为Redis实际每秒可以做到近10万但生产环境要考虑网络开销、GC停顿、其他业务共用的资源不能拿理论的数字去赌。4.2 压测工具与结果解读压测工具我用的是通用的压测工具脚本模拟用户行为先请求商品详情页再发送抢购请求。核心看两个指标QPS和响应时间。第一轮压测网关限流阈值没有生效压测工具每秒发出2万请求服务端直接响应时间飙升到3000毫秒错误率达到12%。定位发现限流规则没有匹配到压测请求的Header相当于裸奔没有防护。调整之后QPS稳定在4000左右响应时间平均90毫秒错误率降到0.1%以内。第二轮压测盯的是Redis。在2000并发用户下Redis的CPU使用率不到40%没有明显的命令超时。但有一个异常值得注意Lua脚本执行过程中偶尔出现“BUSY Redis is busy running a script”的报错。这个报错的根因是某个复杂脚本运行时间超过了Redis的Lua脚本执行上限。排查之后发现在同一个Redis实例上还运行着其他业务的大Key查询把CPU拖慢了。解决办法是把秒杀库存和普通业务放到不同的Redis实例上彻底隔离。4.3 压测数据说明了什么压测结束之后我整理了一套关键数据链路环节压测最大承受QPS生产预估峰值配置备注CDN静态资源无瓶颈10万带宽充足网关限流100004000令牌桶每秒恢复100应用服务600040004节点水平扩展Redis扣库存150004000独立实例隔离部署MQ消费下单500300消费端动态扩容这份数据的意义不是给领导看PPT而是能直接指导后续的告警阈值设置。压测时把报警阈值定在峰值的60%左右比如Redis的QPS到了2500就报警申请扩容或者限流再收紧。没有数据支撑的告警全是白搭。5. 上线前必须排查的隐藏坑点秒杀方案的坑大多不在架构图上而在细节里。这里把我实际踩过的几个坑全放出来什么人说“我方案没问题”的时候他最应该看看这一节。5.1 最隐蔽的坑Redis实例隔离第一坑就是刚才提到的Redis锁问题。同一个Redis实例里有秒杀的库存可能还有别的业务在做上亿条数据的聚合统计。一旦那些统计命令耗时超过几十毫秒Redis就会在处理复杂命令期间阻塞其他命令秒杀扣库存的Lua脚本就会排队等。感受到的结果就是用户明明看到库存充足点击抢购却提示失败。排查费用很贵根因是混用缓存。但预防其实只需要一句话秒杀涉及的Redis必须单独部署。不要让任何其他业务混进来。5.2 热Key打满秒杀有一个天然的热Key问题所有用户抢的就是同一个商品IDRedis里对应的是同一个Key。按哈希槽位分配这个Key固定落在某一个Redis节点上。就算你部署了Redis集群高并发下所有压力还是会聚集到这一个节点其他节点毫无压力地闲着。我遇到过单节点CPU跑到90%以上报错“RedisCommandTimeoutException”服务端开始出现大量的读超时。处理方案有两个一个是对Key做分片比如把商品库存拆成100份每份对应一个子Keygoods_001_01、goods_001_02用户请求来时按userId取模哈希到对应的子Key上去。另一个方案是只让这一个Key的流量走本地缓存和应用层标记让所有请求先查本地内存中的“已售罄”标记如果标记被设置就直接拒绝请求不再打到Redis。第一版我只用了应用层标记发现用户分布不均匀时有些前端节点的缓存先过期又穿透到了Redis还是会有零星的超时。后来做了子Key分片问题才彻底消失。5.3 库存预热与过期时间库存数据必须提前加载到Redis里不能等到开抢时再实时写否则在开抢的那一瞬间“初始化库存”和“用户扣减库存”会互相竞争。我习惯在活动开始前半小时做预加载并且在脚本里验证库存值和数据库一致后再开放入口。另一个容易忽略的是Redis里的库存Key如果忘记设置过期时间活动结束后Key会一直留在Redis占着内存不说还容易在下次活动时被误用。内存淘汰策略尽量安排为volatile-lru给库存Key设置3天过期时间过期后让它自然清理。5.4 账号安全与防刷秒杀活动的核心目标是公平但总有人想用脚本和代理绕过限制。只要没有做风控校验真实用户的抢到率就会急剧下降。基础的做法是对每个用户做设备指纹校验同一设备在短时间内不能频繁更换账号参与进阶的做法是限制同一IP段的下单数量虽然不精准但能挡住很大一部分机器。我这里必须提醒一点风控校验一定要放在比较靠前的位置否则大量脚本流量会一路坐到Redis层才被拦截白白浪费处理能力。我在网关层就调用风控接口做黑白名单判断命中名单的直接返回不再放行。5.5 监控告警与快速回滚大促期间的监控比平时多一个维度。实时盯三个核心指标即可网关层限流拒绝率、Redis扣库存成功率、MQ消费积压量。这三个指标可以快速判断出故障在哪层。举例子如果限流拒绝率一直在升高说明流量超出预期需要手动调低限流阈值或者干脆做全局限流把非核心接口的流量全部让路。如果Redis成功率下降说明库存扣减环节出了问题有可能是部分Key过期或者Redis实例不稳定。如果MQ消费积压量快速上涨说明数据库写入变慢需要临时增加消费实例并行度。应急预案上我每次大促前都会把网关层的一个开关提前预备好一键熔断。如果后端完全打不开了网关直接对所有秒杀请求返回“抢购结束”保证页面不会挂在那边加载不出来。开关平时关闭事故时人工打开不需要改代码不需要发布三秒内生效。6. 最后一个实操细节上面五部分把方案主干讲完了最后再补一个我每次秒杀结束之后一定会做的事全链路日志链路追踪。秒杀期间会产生大量无害的“失败”请求到底哪些是限流拦的、哪些是库存没了、哪些是风控拦截的分析这些数据对下一场活动至关重要。我在网关层加了请求状态码标记HTTP层统一返回200但在响应体内返回业务码和reason字段。这样CDN压缩和浏览器解析都不受影响前端也能根据reason区分是“手慢了”还是“网络拥堵”还是“资格校验失败”。这个习惯让我后面优化玩法时有据可依而不是两眼一抹黑。如果你也准备做秒杀记住一句话方案永远可以简化日志和监控绝对不能省。真正跑一次线上活动之后你会回来验证这句话的。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进