
秒杀系统大概是我面试中被问得最多的业务场景也是很多团队攻坚持久战的主战场。但说实话面试聊设计一个秒杀系统和真正上线一个秒杀活动难度差距大概有十倍——前者只需要背出RedisMQ限流几个词后者要面对的是流量突刺瞬间打满数据库连接、库存多扣了无法向运营交代、有人用脚本刷了几百台设备的验证码等等一系列连环炸。这篇文章我不打算写教科书式架构图而是直接把我从实际项目里趟出来的经验拆给你看重点放在并发场景下那些最容易翻车的环节。1. 秒杀系统的核心矛盾先从一次事故说起先讲个真实案例。我们之前做一个促销活动预估峰值两万QPS压测时跑到一万八觉得挺稳结果上线当天开抢不到三十秒监控大屏上订单入库的耗时从平均12毫秒直接飙到了三千多毫秒数据库连接池被打满大量请求堆积最后被迫切了降级开关活动草草结束。复盘下来问题其实很典型架构图上画了Redis、画了消息队列但真正的扣库存动作还是同步落到了MySQL上。前端几十万人同时点按钮经过网关之后真正进来的读请求大部分压在了Redis上读扛住了但最后一个扣库存的写操作把所有流量又集中导向了数据库等于前面做的一切削峰动作都在最后一公里前功尽弃。1.1 秒杀和普通高并发到底有什么不一样跟商详页、内容流这种高并发场景对比秒杀有两个非常特殊的地方。第一流量极度集中。普通业务的高并发是持续均匀的比如一天几百万次请求分摊到24小时每秒几百上千就够用了秒杀是瞬时突刺——平时几百QPS的接口开抢那一秒可能冲上几万甚至几十万十倍二十倍的尖峰。这个尖峰持续时间通常只有几十秒到几分钟但就这几分钟足以把系统击穿。第二写多读少且是有状态的写。大部分高并发系统是读多写少可以无限堆缓存秒杀恰恰相反开抢那一瞬间大量请求都在做同一个动作——修改同一个商品的库存。这是典型的热点行更新问题。所有请求都去更新数据库里的同一行记录无论你数据库连接池开到多大行锁冲突都会把所有并发请求排队串行化。这不是加机器能解决的必须从架构层面改变请求的到达模式。理解了这两个特性你就明白秒杀系统设计的真正目标不是处理掉所有请求而是让绝大多数请求在极短时间内被快速淘汰只让极少数真正有资格下单的请求穿透到后端。整个系统的设计逻辑都是围绕如何优雅地拒绝展开的。1.2 成败指标不是QPS而是这三个数字我给新接手秒杀系统的同学看需求时从不让对方先讲架构而是先对齐三个指标这三个数字决定了技术方案长什么样指标含义我们的经验值库存量这次活动放出去多少件商品几百到几千基本不会上万峰值QPS预估开抢瞬间的请求量视用户规模和投放渠道而定成功率实际抢购成功的请求占真实用户请求的比例10%以下很正常不用追求高成功率为什么库存量这么关键因为库存就是天然的限流阈值——一百件库存理论上最多只有一百个真实用户能成功。系统要做的是在这一百个幸运儿之外让其余几十万请求在最前端以最低成本被拦截掉而不是让它们都挤到数据库门口排队。想清楚这一点你再看任何秒杀架构方案逻辑就通透了为什么要有Redis缓存库存为什么要有消息队列为什么下单要异步化一切都是为了降低对MySQL这同一个热点更新点的并发压力。2. 整体架构怎么搭每一层分别扛什么压力秒杀系统的架构设计核心思路是做分层过滤让流量逐层衰减。每一层都有自己明确的职责上游处理不了的请求宁可直接拒绝也绝不放行到下游。下面这张分层逻辑我们从接入层开始往下捋。2.1 接入层Nginx层面先挡掉一波流量进来第一站通常是Nginx。很多团队忽略Nginx的并发配置默认配置下单个worker能建立的连接数是有限的配置不合理可能连前置这一层都会成为瓶颈根本轮不到后端处理。几个我实测过的关键点worker_processes建议设为CPU核心数worker_connections调到65535左右这是一个相对稳妥的起点Nginx作为负载均衡时默认的keepalive长连接要打开否则每次请求都重新握手握手开销在超高并发下会占掉相当多CPU时间片秒杀活动通常会单独配置一个server块和其他业务隔离设置独立的连接池大小和超时时间避免秒杀流量异常影响正常线上业务。如果你有CDN或者云盾类的防护能力也可以在接入层做最粗粒度的拦截——比如对同一个IP、同一设备ID的请求做前置限速。这块逻辑不需要复杂能挡掉一批脚本和基础爬虫就够了。2.2 应用层无状态接口设计 本地缓存挡热点到应用层比如Spring Boot服务集群这里首先要保证接口设计成无状态的每台机器独立处理请求不依赖session这样横向扩容才有效果。秒杀集群最理想的形态是压力上来时运维一键把实例从二十个加到五十个请求均匀分散不需要做任何数据迁移。应用层最容易忽略的是JVM参数和连接池配置。秒杀场景下Tomcat线程池默认200个线程每个线程都在等待下游Redis返回——如果Redis本身开始出现毛刺线程会全部阻塞在等待上新请求只能排队进而拖垮整个服务。这种连接池耗尽导致雪崩的问题我在压测里几乎每次都能踩到。解决思路是给秒杀接口单独设置超时时间比如Redis查询超过50ms直接快速失败宁可当前请求返回拥挤也绝不阻塞线程。另外应用层一定要做本地缓存来挡热点读。比如商品详情、活动配置这类数据用Caffeine或者Guava Cache在每台机器的JVM里缓存一份TTU设短一点几十秒。别小看这层它能减少大量穿透到Redis的请求尤其是同一个爆款商品详情被上万人刷的场景本地缓存能拦掉百分之八十以上的堆叠。2.3 缓存层Redis是整个系统的定海神针Redis在秒杀系统里承担两个角色库存预扣的原子执行器和热点数据的承载者。先说库存预扣。所有请求进入秒杀接口后先执行一个Lua脚本来尝试扣减Redis中的库存只有扣减成功的请求才有资格继续往下走-- 扣减库存的Lua脚本 if redis.call(exists, KEYS[1]) 0 then return -1 end local stock tonumber(redis.call(get, KEYS[1])) if stock 0 then return -2 -- 库存不足 end if redis.call(decrby, KEYS[1], ARGV[1]) 0 then return redis.call(get, KEYS[1]) end -- 扣减后小于0需要回滚 redis.call(incrby, KEYS[1], ARGV[1]) return -2为什么用Lua脚本而不用事务Lua脚本在Redis单线程模型下是原子执行的中间不会被其他命令插入既保证了检查库存-扣减-返回三步操作的原子性性能又远优于MULTI/EXEC事务。这个脚本的执行耗时大约在微秒到毫秒级别一万次扣减根本不在话下。不过Redis这边有个非常坑的点是热key问题。秒杀商品的库存key就是那个被全网几十万人同时读写的热点key如果这一秒内大量请求都路由到Redis集群里的同一个分片这个分片的CPU会瞬间飙高响应延迟拉长。业界常用手段是做多级key或者读写分离——比如把库存分成50个段位stock_001stock_002…用随机key分散读负载写操作扣减按段位分发从源头上避免单key热点。这个方案实现成本不算高但收益立竿见影我强烈建议库存量大的活动采用。2.4 数据层MySQL面对的其实是常量流量经过前面几层的层层过滤真正走到MySQL的写流量已经非常可控了。设计上要把这个局面继续巩固住把同步写转化为异步写。秒杀接口收到扣减成功的结果后只做一件事——往消息队列里丢一条下单消息然后立刻给用户返回已进入排队。至于订单数据的落库、后续的物流、优惠券核销等等全部由消费者异步处理。这个模式下MySQL写库的频率取决于你的消费速率而不是瞬间的请求量相当于把千军万马过独木桥改造成了有序过桥——平均每秒稳定写入几百条MySQL完全扛得住。3. 库存扣减方案为什么数据库扣减最容易出事库存扣减是整个秒杀系统技术选型里争议最多、也是最容易出事故的一环。我从第一版到最终版用过三套方案把各自的优劣、踩过的坑都讲一遍你可以根据自己团队的实际情况来选。3.1 方案一数据库乐观锁直接扣减第一版方案很朴素每次扣减库存都走SQL利用乐观锁机制通过版本号或者条件更新保证不超卖// 乐观锁扣减库存 int count mapper.decreaseStock(productId, quantity); // decreaseStock对应的SQL大致是 // UPDATE product_stock SET stock stock - #{quantity} // WHERE id #{productId} AND stock #{quantity} if (count 0) { throw new InsufficientStockException(库存不足); }这种做法逻辑最简单、强一致、不会超卖因为stock quantity条件在数据库行锁的保护下是严格成立的。但它的致命弱点在于并发性能极低。所有扣减操作都要对同一行记录加锁数据库InnoDB的行锁是串行释放的实测单行热点更新在TPS层面能到两三百就算不错了QPS稍高一点慢了查询一多整个库都被拖住。而且秒杀场景下大量请求会做无意义的UPDATE操作试扣库存但实际库存早没了白白消耗行锁资源。结论是库存几十件的内部小活动可以真正的秒杀扛不住。3.2 方案二数据库扣减 Redis前置准入挡板第二版改正了方向用Redis先挡一刀再放行到数据库扣减。具体做法是Redis内存中预存可用库存扣减时先用Lua脚本检查Redis里的库存扣减成功后封装一条MQ消息消费者异步去数据库里做实际的库存扣减和订单生成。这里数据库依然采取乐观锁扣款但因为有了Redis前置挡板真正穿透到数据库的请求量被限制在库存量级别甚至更低行锁竞争压力大幅减少数据库扣减的性能问题就变得可以接受。这一版能不能用能但有个隐患——Redis和MySQL的一致性维护。如果Redis扣减成功了但异步扣减数据库时失败了或者消费消息时服务重启了就会出现Redis显示已扣库存但数据库实际没扣的账不平。需要一套对账机制兜底比预期复杂。3.3 方案三最终版Redis Lua原子扣减 数据库最终落库我们最后上线用的还是这个组合拳也是目前业界跑得最普遍的方案活动启动时把数据库库存预热到Redis。请求进来执行Lua脚本原子扣减Redis库存扣减成功的请求才是真正的抢购资格获得者。请求拿到资格后发送MQ消息消费者消费消息后在数据库执行真实的库存扣减和订单生成。数据库库存扣减使用条件更新检查库存然后扣减——由于请求量已经被控制在库存量级这一层几乎没有并发压力。定时任务比对Redis剩余库存和数据库剩余库存发现不一致时以数据库为准做补偿。这个方案的巧妙之处在于Redis只负责发放抢购资格MySQL负责最终确认。Redis的高性能保证了秒杀接口的响应速度和并发上限MySQL的强一致保住了数据的准确性MQ做解耦和削峰让两者之间的协作有序进行。给大家看下Java这块的大致实现// 秒杀入口先扣Redis库存拿资格 public boolean tryAcquireSeckill(Long productId, Long userId) { DefaultRedisScriptLong script new DefaultRedisScript(LUA_SCRIPT, Long.class); Long result redisTemplate.execute(script, Arrays.asList(seckill:stock: productId), String.valueOf(1)); if (result null || result 0) { return false; } // 拿到资格发MQ消息异步落库 SeckillOrderMessage msg new SeckillOrderMessage(); msg.setProductId(productId); msg.setUserId(userId); // 幂等控制使用userIdproductId作为消息唯一键 mqProducer.send(order-topic, msg); return true; }3.4 超卖到底是怎么产生的怎么彻底堵死超卖问题的根源在于检查库存和扣库存之间不是原子的并发请求在两个操作之间穿插执行导致每个请求都读到同一个剩余库存0的值然后都执行了扣减。在Redis Lua方案下超卖发生的窗口已经不存在了因为脚本是原子的。但在异步消息落库这个环节仍然可能出现数据库库存不一致——比如消费服务重复消费了一条MQ消息同一个用户订单被创建两次数据库库存被扣了两次。这就是为什么我上面代码里特意标注了幂等控制消息消费必须幂等用userId productId做唯一键消费前先查订单表有没有这个用户的已购记录有就直接跳过没有才创建订单。这里再强调一遍幂等设计不是可选项是必备项。消息队列在大多数场景下提供的是At-least-once语义至少一次不做幂等处理库存差额就是这么来的。4. 异步削峰的本质消息队列不只是排队而已很多人理解异步削峰就以为是在接口和数据库之间塞个MQ让请求排队慢慢处理。这话对了一半但它忽略了一个核心消息队列真正做的是把瞬时压力摊平到时间轴上让下游系统的处理速率保持恒定而不是跟着流量突刺走。4.1 为什么说MQ是秒杀的流量整形器想象一下秒杀瞬间十万QPS如果直接打到MySQL数据库几十秒内必挂。但有了MQ之后秒杀接口只需要做投递消息这个动作——投递本身非常快每秒几万乃至几十万完全不是问题。下游消费者固定速率比如每秒500条去拉消息、处理订单多余的消息在MQ里排队。用户视角看到的是已提交等待结果几秒后异步刷新就能看到订单状态。这个投递和消费速率解耦的设计才是削峰的本质。4.2 消费者端要考虑的三件事消费者端并不是傻傻地拉消息消费就行实践中有三个关键点一是消费速率控制。用RocketMQ或者Kafka的消费组配合手动Ack保证每条消息都处理成功后才提交位点。如果发现下游数据库压力大可以临时调低消费并发给数据库喘息时间。二是重试与死信。消息消费失败比如数据库临时抖动要进入重试队列还是失败就进死信队列。死信队列不是用来积压的要配告警让值班人员及时发现是哪种类型的失败持续存在。三是消费逻辑的幂等性。我前面已经讲过了这是异步架构下数据准确性的生命线。消费前查重、消费时用唯一约束兜底双保险。4.3 用户端查询结果怎么设计异步之后用户的请求拿不到实时订单结果这就要求提供一个查询抢购状态的接口。传统做法是用户提交后轮询但几万人轮询同样是并发压力所以查询也要讲究策略把抢购状态放到Redis里成功或失败状态标记查询接口只读Redis不查数据库。Redis里查不到就返回处理中让用户隔几秒再查。成功率记录在Redis的有效期内比如15分钟过期后回源到订单库查一次真实状态。这个接口的设计目标还是那句话——把查询压力引向缓存而不是数据库。5. 限流与防刷流量进来了不能全靠硬扛秒杀系统设计到这一步很多团队会觉得能并发撑住了就万事大吉。但上线之后你会发现真正的威胁不只是流量大还有流量坏——脚本党、羊毛党、机器人。5.1 接口限流的三层漏斗限流要做成漏斗式从粗到细逐层收紧全局粗粒度限流在Nginx/API网关层对秒杀接口设置全局限流。比如这个活动接口整体QPS上限设为10万超过了直接返回系统繁忙。用漏桶算法保证流量绝对平滑请求进入的速度永远恒定。Nginx的limit_req_zone模块就能干这个事但要注意配置在秒杀活动开始前提前生效别等流量进来了再改配置。单用户粒度限流同一UserId在单位时间内比如1秒最多允许请求N次通常设2~3次包含JS自动重试。这个判断可以在应用层用Redis的INCR过期时间快速实现点查一次Redis开销极低。设备/IP维度防刷配合业务方的风控数据对同一IP限5次/秒、同一设备ID限3次/秒做限制。更激进的做法是接入滑块验证码——开抢前先完成验证持有验证凭证的用户请求才能进入后续逻辑。验证码能优雅地让真人觉得抢到就是运气同时挡掉大半自动化脚本。注意限流最重要的是保证拦截逻辑本身的高性能。如果限流模块自己需要查数据库那它自己就会成为瓶颈。限流判定一律走Redis或本地内存保持O(1)复杂度。5.2 秒杀地址动态化一个容易被忽视的防刷手段很多团队的活动链接是固定的比如/seckill/10001。脚本党早把链接写死在代码里了等开抢时间一到自动请求根本不用经过页面。标准做法是秒杀地址动态化——每次活动开始前由后端生成随机URL或者携带签名参数的URL页面加载时动态拼接签名有效期设置得很短// 生成带签名的秒杀地址 String sign MD5(productId salt expireTime); String seckillUrl /seckill/{productId}?sign sign expire expireTime;签名校验放在拦截器层做过期或签名错误直接拒绝。这个手段成本极低但能挡掉绝大多数把URL写死的初级脚本。5.3 降级预案一切手段都失效时的最后防线哪怕架构设计得再完善也要预设被击穿的兜底方案。我负责的每次活动都会提前写好降级开关Redis异常降级Redis挂了无法扣库存秒杀接口直接返回活动太火爆不抛异常不影响普通商品浏览和其他活动。前端开关降级通过配置中心下发放弃抢购指令前端按钮置灰直接拦截用户点击从源头减少流量。熔断策略下单服务连续失败率达到阈值比如10%上游调用方启动熔断不再调用下游服务快速返回失败给下游恢复的时间。降级场景必须提前演练。我们内部有个规矩压测结束后一定会做一次故障注入演练人为把Redis停了看系统表现把MySQL CPU拉高看熔断是否生效。演练时出问题比上线时出问题让人幸福一百倍。6. 压测与调优上线前必须做的那几件事秒杀系统没有压测数据支撑就跟闭着眼跑步一样撞墙了才知道疼。但压测本身门道也不少不是用JMeter开2000线程跑一下这么简单。6.1 怎么构造一个可信的压测场景压测场景必须贴近真实秒杀请求要经过完整的调用链Nginx - 网关 - 应用 - Redis - MQ不能只压单个接口。我建议用JMeter或者开源的压测工具按下述参数启动用阶梯加压而不是一次性拉满并发。比如从1000并发开始每10秒增加1000直到系统瓶颈出现观察瓶颈出现时的QPS和RT曲线。压测数据要模拟热点集中比如只有10件商品库存但所有请求都打这10个商品ID模拟热点行更新压力。压测环境的数据量要与生产环境等比例不能拿一个空库存表压数据库慢查询在数据量大和小的行为差异巨大。压测时要盯的指标不止是QPS还包括应用线程池的活跃线程数是否打满Redis的CPU和延迟毛刺数据库的连接数、活跃会话、慢查询MQ的积压量和消费延迟6.2 我压测时踩过的几个高频坑坑一连接池没调大。默认情况下很多框架的数据库连接池最大是10或者20压测一上量瞬间打满大量线程等待获取连接。秒杀场景建议把HikariCP的maximumPoolSize调到50~100根据数据库实例规格定制Tomcat线程池调到500RocketMQ生产端的超时时间设置合理值别用默认的超时太久。坑二被毛刺曲线骗了。从平均RT看一切正常但TP99和TP999惨不忍睹。秒杀系统尤其要看TP999因为极高并发下哪怕只有0.1%的慢请求也意味着几十上百个请求卡住了这些请求会占用线程池资源形成级联拖垮。优化目标是让TP999稳定在P95的3倍以内。坑三开启了数据库慢查询日志却没关注CPU。压测中数据库CPU冲到90%以上但慢查询日志只有零星几条你以为没事。实际是行锁等待耗掉的CPU而不是单个SQL慢。这种问题要看performance_schema里的锁等待时间指标集中在InnoDB row lock time上。6.3 从压测数据反推配置的最优解好的压测是帮你在几百个参数之间找到平衡点。比如我们某次活动压测最初Nginx配置能扛住6万QPS但后端应用却只扛到1.2万瓶颈在Redis热key上把库存拆分成10个分段key之后Redis这一侧稳定了瓶颈转移到MySQL的消费速率上最后调整消费者线程数叠加批量消费整体链路达到了期望的2万QPS库存扣减成功率和数据一致性完全没出错。每次压测都要有一个明确结论瓶颈在哪一层、怎么解决的、提升到了多少。这样才能保证压测不是走形式而是真正为了上线减少风险。7. 上线后的运维与复盘秒杀结束只是另一场战斗的开始秒杀活动从开抢到结束可能只有几十秒但运维监控的战役会持续整个活动周期甚至结束后还有暗雷要排除。7.1 活动期间紧盯的四个监控维度我上线期间基本是四块看板同时开流量看板QPS曲线、响应时间、错误率、拦截比例看流量是否符合预期拦截逻辑是否生效。数据一致性看板Redis库存、MySQL库存、MQ积压量三个数字并排看发现任何一边对不上马上查看是哪个环节出了问题。资源看板Nginx连接数、应用CPU和内存、数据库连接数和慢查询、集群带宽。内存和CPU异常往往是代码层面有问题的前兆。活动效果看板实际下单量、支付转化率、退款率、投诉量。运营视角的指标和技术视角的指标同样重要它决定下次活动还要不要搞、怎么搞。7.2 一个容易被忽略的尾单处理卖不完的库存怎么办秒杀活动经常遇到的情况是没被抢完——库存还剩几十件但没人下单了。这个状态如果不处理Redis里的库存一直保持一定值会导致用户在活动结束前再点一次抢购发现还能成功但数据库里没有对应的持续活动配置对账系统一直报数据库库存与Redis库存不一致触发错误告警。标准做法是活动结束后启动一个对账清算任务把Redis剩余库存回写数据库、关闭秒杀标记、回收未支付的预占订单。在活动结束前后各执行一次确保最终数据跟数据库中的初始库存减去已成交订单完全一致。7.3 复盘时我会重点回答的四个问题活动结束后的复盘我不太看峰值QPS有多高而是重点复盘四个事哪个环节距离崩溃最近如果再来一个同样流量的活动这个环节会不会成为必然瓶颈拦截效果如何多少流量在入口就被挡下了有多少进入了真实下单流程拦截规则有没有误伤正常用户数据有差异吗差异出现在哪个步骤是消息重复消费、对账脚本逻辑缺陷、还是极端情况下缓存与数据库的一致性窗口团队协作有什么问题秒杀不是技术一个部门的事运营、客服、风控都要盯。客服收到的相关投诉有没有暴露产品设计的盲点答好这四个问题哪怕这次活动流量没爆你也会发现架构中可以优化的地方——这可能比单纯堆高并发能力更有价值。8. 最后再分享一个我执行了多年的习惯每次秒杀活动结束后无论成功还是出过事故我都会把当天完整的监控截图、故障排查记录、压测报告、架构改动说明归档到团队知识库然后花半小时写一份下次绝不犯的清单。技术能力的提升不是一个瞬间而是不断把实战经验转化为代码和配置的过程。秒杀系统设计得再好也扛不住所有人看同一份方案用三年因为流量在涨、用户在变、业务形态也在迭代唯一不变的是并发问题本质上是资源竞争问题这个底层逻辑——理解它你设计任何高并发系统都会心里有底。如果你正在设计秒杀系统我建议从简单方案起步先保证数据不错再逐步优化性能。别一上来就搬出分布式锁、分库分表、容器编排全家桶复杂的方案意味着更多的故障点。把拦截大部分请求、保护数据库、保证最终一致这三件事做好你的秒杀系统就已经跑赢了市面上大多数翻车的案例。