要说电商后端哪个话题最能检验团队的综合功底我第一个想到的就是秒杀系统。普通接口碰到高流量可以靠加机器、加缓存硬扛但秒杀会把最极端的条件同时压到你面前流量在几秒内冲上来所有请求都盯着同一个商品库存又是必须绝对准确的数据。能不能做好不是看用了多高级的框架而是看流量真正进来之前你想清楚了哪些问题。这篇文章不展开讲花哨的概念只说我自己做过、验证过、也翻过车的方案。适合正在做电商后端、或者刚接手高并发系统的朋友特别是每次大促活动一上线就心惊胆战的团队。我会从业务场景拆起讲接入层限流、库存扣减、消息队列解耦最后落到压测和上线时最容易翻车的地方。1. 一场秒杀到底发生了什么先把问题看清楚1.1 秒杀流量和普通高峰流量的本质区别很多人第一次接触秒杀下意识以为它就是流量高一点的普通接口。这个认知会直接导致后续所有设计跑偏。要知道普通高峰流量和秒杀流量最大的区别不是量级而是分布。普通的大促流量比如某电商平台的全场满减活动请求是分散在成千上万个商品上的。用户逛到哪个商品点了哪个商品热点不集中系统负载虽然高但负载是均匀摊开的常规的水平扩容、缓存、读写分离都能解决问题。秒杀则完全相反。一个活动可能只做三五个SKU甚至就一个爆款商品。所有用户的请求在同一秒涌进来全部指向同一个ID。如果你在数据库里用一张库存表那这张表的这一行就是整个系统最烫手的地方。任何一个环节的设计都得围绕单点热点来展开。另外一个容易被忽视的差别是时间窗口的极端性。普通高峰流量是持续几个小时甚至几天的系统负载曲线是缓慢爬坡再慢慢回落扩缩容有时间余量。秒杀往往只有几分钟从开始到结束可能就一分钟流量呈断崖式上涨根本没有留给你看到监控告警再扩容的时间。所以秒杀系统的所有准备必须前置所有容量预估必须提前做。1.2 一条秒杀请求要穿过哪些环节从用户点下立即抢购到看到恭喜您抢到了这一条请求链路上涉及的东西远比想象中多。搞清楚这条链路才能知道每个环节各自的瓶颈在哪里。客户端上报抢购按钮开始请求先经过接入层包括DNS、CDN、API网关然后到达业务层一般会先查一次商品信息再校验活动时间、用户资格接着进入库存服务做扣减扣减成功后再去创建订单、锁定优惠券最后是异步通知用户支付。这中间还没算上登录态校验、风控、防刷等等前置逻辑。每个环节都可能成为瓶颈。接入层的瓶颈在带宽和连接数网关的瓶颈在转发能力和限流策略商品查询环节的瓶颈在缓存命中率库存扣减的瓶颈在数据库热点行订单创建的瓶颈在事务提交和消息堆积。做秒杀系统本质上就是在每一个环节都用最省资源的方式挡住无意义请求最后只放极少量真正能成功下单的请求到最贵的数据库事务里。我经常用一个漏斗来理解秒杀系统100万用户点了按钮经过去重、限流、风控之后可能只剩十万个请求走到库存扣减十万个扣减里库存只有一千个所以最后成功创建订单的只有一千人。系统设计得好不好就看每一层漏斗往下漏的时候有没有把该挡的请求挡在便宜的那一层。2. 接入层的第一道防线把无意义的请求挡在外面2.1 详情页静态化与倒计时设计很多人设计秒杀时把百分之百的精力都放在库存扣减上结果活动开始的第一秒商品详情页先被打挂了。用户还没看到立即抢购按钮页面就白屏了。这是最基础但也最容易忽略的问题。详情页静态化是标准做法。活动期间的详情页基本没有动态数据商品图片、标题、描述这些内容直接渲染成静态页面扔到CDN上用户读取的时候根本不经过你的后端。能做到这一点详情页层面的流量再大也只是CDN的带宽问题和你的应用服务器无关。静态化解决了看的问题但点的问题还需要处理。倒计时逻辑要注意前端展示的倒计时必须从服务器时间计算不能依赖客户端本地时间。否则用户把系统时间改快十分钟倒计时提前结束按钮提前点亮就会出现一批抢跑的请求。我见过真实案例活动本来还有一个小时才开始有人通过改时间提前进入抢购页面把按钮状态调成可点击虽然后端校验活动时间能拦住真正的下单但那一瞬间涌入大量无效请求等于提前做了一次小规模攻击。一个务实的做法是把按钮置灰的逻辑做两层第一层在前端做纯粹是用户体验层面的展示第二层在接入层做用一个活动状态接口返回服务器时间、活动是否开始、用户是否有资格。前端拿到这个结果再决定按钮亮不亮这样即使用户改了本地时间活动状态接口也会把他拦下来。2.2 限流方案选型单机限流与分布式限流有了页面层的拦截依然会有大量请求涌到后端。这时候就要靠限流。限流的本质不是把合法用户拦掉而是让系统在承受范围内的请求正常处理超出的请求快速失败避免系统雪崩。单机限流最简单用Guava RateLimiter或者Semaphore就能做每个服务实例独立计数。比如每台机器限制每秒接收1000个请求20台机器就是2万QPS。优点是没有额外依赖性能极好缺点也很明显如果负载不均衡某些机器的请求量超过阈值某些机器还没跑满实际总吞吐会低于预期。而且单机限流没办法做全局维度控制比如你想针对某个用户ID每秒最多放行1个请求单机限流就做不到。分布式限流需要统一计数最常用的方案是Redis配合Lua脚本。同一个用户或者同一个活动的限流计数放在Redis里所有机器共享。我贴一段我实际用过的计数限流脚本local key KEYS[1] local limit tonumber(ARGV[1]) local window tonumber(ARGV[2]) local current tonumber(redis.call(get, key) or 0) if current 1 limit then return 0 else redis.call(incr, key) redis.call(expire, key, window) return 1 end这段脚本的意思是对某个key比如活动ID或者用户ID在指定时间窗口内计数超过阈值就直接拒绝。用Lua脚本是因为判断和自增必须是一个原子操作不能先get再incr否则并发下会出现计数不准确。我刚做秒杀系统时在这个地方吃过亏先Java里get再incr结果高并发下同一个用户的请求同时读到同一个current值两个都放行了限流形同虚设。选单机还是分布式要看你的部署规模和精度要求。机器少、对全局限流要求不高的场景单机限流完全够用但如果一个活动入口背后有几十台机器你希望所有机器合起来的流量不超过某个值那就得上分布式限流。分布式限流的副作用是每次请求多了一次Redis往返好在Redis本身很快影响可以忽略。2.3 验证码与答题不是拦人是提高机器攻击成本秒杀活动的特点是确认性强、用户意愿极高所以在秒杀里用验证码的核心目的不是阻止真人用户而是提高脚本和机器人的成本。真人用户多花两秒钟输个验证码对体验的影响是有限的但这两秒钟能让同样的机器带宽和CPU成本翻好几倍绝大部分脚本党就不跟你玩了。实际项目中常用的是滑块验证或者点选验证。很多团队会忽略的是验证码服务本身也必须高可用。我经历过一次活动由于验证码服务QPS压满导致大量正常用户卡在验证环节后台看到限流拦截率飙升但实际是验证码服务拖慢了整个入口。后来我们把验证码服务独立部署活动前单独压测这种问题才避免。还有一个点是验证码发放和校验一定要和活动入口解耦。验证码服务挂掉的时候系统要能降级为直接放行进入下一步限流而不是让整个抢购入口不可用。降级逻辑要在上线前明确写清楚否则故障时大家都在猜要不要放开验证码场面会很混乱。3. 库存扣减秒杀系统最不能出错的地方3.1 数据库直接扣减为什么扛不住库存扣减是秒杀系统最核心的环节也是超卖和少卖问题的发源地。最朴素的做法是数据库里一张库存表扣减时执行一条语句update sku_stock set stock stock - 1 where sku_id ? and stock 0;stock 0这个条件能保证不超卖这也是数据库层面最简单可靠的方案。但问题是秒杀场景下成千上万的请求同时对同一个sku_id的这一行执行update数据库会对这一行加行锁所有请求变成串行执行。数据库单机大概能扛每秒几千次到这个量级就不错了一旦超过数据库连接池被打满整个服务都会雪崩。另外一个隐藏问题是即便你扛住了更新语句这些请求还要和订单表、流水表一起放在一个事务里。事务意味着要写binlog、redo log要等磁盘落盘这些操作都会显著降低吞吐。所以数据库直扣方案不是不能用但只能用在流量可控的场景比如小规模抽奖、内测活动。3.2 Redis预扣减和Lua原子脚本业界最主流的做法是把库存预热到Redis里用Redis的内存操作扛住高峰流量再把结果异步回写到数据库。Redis单实例单线程的特性使得DECR这类操作天然原子不会出现两个人同时扣到同一个库存的情况。我实际项目里的扣减逻辑长这样-- KEYS[1]: 库存key -- KEYS[2]: 已抢用户集合key -- ARGV[1]: 扣减数量秒杀通常为1 -- ARGV[2]: 用户ID local stock tonumber(redis.call(get, KEYS[1]) or 0) if stock 0 then return -1 end local joined redis.call(sismember, KEYS[2], ARGV[2]) if joined 1 then return -2 end redis.call(decrby, KEYS[1], ARGV[1]) redis.call(sadd, KEYS[2], ARGV[2]) return 1这段脚本把查库存、查用户是否已抢、扣减、记录用户这四件事放在一个原子操作里完成。有了这段脚本并发下不会超卖同一个用户也不会重复扣减。返回-1表示售罄-2表示用户已经抢过业务层根据返回码分别处理。这里有个重要经验Redis里扣了库存数据库里必须能对得上。所以扣减成功后要发一条消息给订单服务异步去数据库创建真实订单并把库存状态落库。如果消息丢了或者异步任务失败Redis里库存和数据库库存就会不一致。我后面会专门讲补偿机制这里先记住一个原则Redis做的是流量承担层数据库做的才是最终数据层两者之间的同步必须可追踪、可对账。3.3 三种库存方案怎么选直接对比我整理过一张表格方便对应场景选型方案准确度吞吐能力复杂度适用场景数据库update ... stock 0绝对准确低受限于行锁最低小规模活动、内部测试Redis原子扣减 异步回写依赖补偿机制高可支撑数万QPS中绝大多数正经秒杀活动资格码/抽签token较准确极高库存根本不直接暴露高稀缺商品、限量抢购、强管控场景资格码方案比较有意思。先通过抽签或者排队把抢购资格发放出去用户拿到资格码后在规定时间内凭码购买。这个方案从源头把并发削掉了库存不再是瞬时争抢的对象。缺点是需要额外管理资格码的生命周期还要防囤码、防黄牛而且用户体感上少了抢的刺激感。很多平台做限量球鞋、限量手办抽签就是这类思路。3.4 关于分布式锁的一个常见误解我发现很多人一聊到秒杀就说要用分布式锁这个理解需要纠正一下。Redis的DECR本身就是原子操作它天然解决了超卖问题这时候再去加一把分布式锁反而是画蛇添足——多了一次获取锁的网络开销锁服务如果抖动还会引发大量请求阻塞。分布式锁解决的根本不是原子性问题而是多个节点互斥执行某段代码的问题。比如你想保证同一时刻只有一个定时任务在回补库存这时候用分布式锁是合理的或者某个复杂操作需要读数据-判断-写数据三步而你没有能力把三步压成一个Redis原子操作的时候才需要锁。秒杀里的大部分场景Lua脚本已经能解决原子性问题不需要上锁。这个认知很重要因为锁方案用错了地方不仅性能受损还容易在锁过期那一下出现并发问题。锁过期后两个请求同时进来执行了同一段查库存-扣库存逻辑超卖问题又会回来。4. 扣减成功之后订单队列、幂等与会话保持4.1 同步等待和异步排队怎么选库存扣减成功只是一个起点后面还要创建订单、锁定优惠券、返回结果给用户。这里面有个设计岔路口用户是等在那里直到订单创建完成还是先返回排队中同步模式的用户体感最好点击之后马上知道结果。但代价是库存扣减接口的耗时会被后续动作拖长。假设创建订单要100毫秒在2万QPS的流量下这100毫秒意味着系统中同时有2000个请求在途对线程池、数据库连接池的压力是很大的。异步模式是另一条路扣减成功之后接口立刻返回恭喜您正在为您锁定订单请稍后刷新查看同时把订单创建任务丢进消息队列由消费端异步处理。好处是接口耗时降到了最低系统吞吐量大幅提升瞬间峰值被消息队列削平。坏处是用户不能马上看到订单如果消费者处理不及时用户会焦虑地反复刷新。我现在的建议是对于秒杀这种强场景可以接受异步但必须给用户一个明确的已排队状态入口并且绝不能让用户误以为没抢到。我们是在已抢到但订单生成中这种状态卡过用户的那次体验很糟糕用户反复刷新看不到订单客服被问爆。后来在产品状态设计上加了一个锁定中的中间态配合推送通知问题才缓解。4.2 消息可靠性与消费幂等把订单创建任务丢到消息队列之后要考虑三件事消息不丢、不重复、不乱序。不丢主要靠MQ的确认机制生产者发送消息后等待broker确认没确认就重发消费者处理完业务逻辑后再确认消费没确认就重新投递。但重新投递和重试就会带来重复消息。比如消费者拿到了消息创建订单成功但在提交消费确认时网络闪断消息被重新投递给另一个消费者同一个订单就被创建了两次。解决重复消费的标准手段是幂等不能依赖MQ正好不投递重复消息必须业务层自己处理。最简单可靠的做法是给整个秒杀流程定义一个全局唯一的请求流水号比如userId skuId 活动id加时间的组合在扣减库存的时候就已经生成并且写到了Redis的已抢用户集合里。消费者创建订单前先根据流水号查订单表查到了就直接返回不再新建。这个幂等逻辑一定要写在创建订单动作的前面而且校验和创建订单要放在同一个数据库事务里否则还是会有并发重复创建的风险。4.3 消息堆积导致的雪崩问题消息队列削峰有个副作用如果消费者的处理能力跟不上生产速度消息会越积越多积压到一定程度磁盘被占满MQ本身就开始拒绝写入反而把压力反传回上游。所以消费者侧一定要有扩容预案并且最好预先给秒杀专用的一个消费组分配足够的冗余。我的实际做法是秒杀相关的消息单独用一套Topic和消费组不跟普通订单消息混在一起。这样即使秒杀消息海量积压也只影响秒杀链路不会污染正常的日常订单。活动结束后宁可多等几分钟让积压的消息慢慢消化也不要因为急于清空积压而去并发重放那样会把下游数据库压垮。我见过一个团队在秒杀活动结束之后为了尽快消化积压的订单消息把消费者的并发线程调到原来的十倍结果数据库连接池瞬间被占满反而导致整个订单系统不可用。削峰填谷的核心思想就是匀速处理峰值在队列里被削平消费端必须保持稳定节奏而不是一起冲。5. 超时未支付、库存回补与对账容易忽略的尾巴5.1 订单超时的处理路径秒杀的订单通常会设置支付时限比如十五分钟或半小时。超时未支付的订单必须取消并且要把库存还回去让给下一个人。这个过程有两个容易犯错的地方一个是回补库存的时机另一个是回补的幂等。回补时机要特别小心。用户可能已经点了支付按钮只是在支付回调返回的路上这时候如果你贸然回补库存就会出现一个尴尬场景用户支付成功了系统却认为他没支付把库存释放给了别人最终用户付了钱却拿不到货。这是一个需要把支付回调状态考虑进去的状态机问题。我的状态机设计大致是这样订单创建时状态为待支付用户确认支付或者支付回调成功之后状态变为已支付定时任务扫描那些超过时限仍处于待支付状态的订单先确认支付中心那边没有已支付记录再执行关单和库存回补。绝对不能只查本地状态就去回补库存一定要以支付中心的最终状态为准。5.2 库存回补的幂等与防重复库存回补这个动作本身必须幂等。也就是说同一张订单超时了被定时任务扫到两次只能回补一次库存。一次回补要记录一条流水两次回补就是超卖。做法是在回补前先检查这张订单是否已经回补过检查的依据最好是唯一的——比如订单号。我倾向于在数据库里建一张库存回补流水表订单号设为唯一索引回补时先insert流水insert成功才更新库存。利用唯一索引的约束天然保证了第二个任务重复执行时insert会失败也就不会二次回补。另外一个细节是回补的库存要回到Redis的库存key里还是直接更新数据库严格来说两个都要。Redis里库存是给下一次抢购用的数据库里库存是最终对账数据。所以回补操作要先更新数据库再同步更新Redis里的计数。顺序不能反如果先回了Redis再操作数据库万一数据库失败Redis库存虚高等于超卖。5.3 活动结束后的对账任务活动结束之后Redis里的库存和数据库里的库存一定存在差异。差异的来源很多Redis扣减了但异步消息没消费、用户取消了订单但回补动作失败、支付超时但定时任务没及时扫到。所以对账不能靠碰运气要有一套自动对账机制。我建议在活动结束后跑一个离线对账任务把所有参与了活动但状态异常的数据找出来。对账逻辑按两类做一类是Redis已扣减但数据库没有对应订单的说明消息丢了需要补建订单或者回补库存一类是数据库有订单但Redis没有对应扣减记录的属于数据不同步需要人工介入修正。对账任务最好错峰执行避开业务高峰期而且要加上进度汇报和失败重跑机制。对账这件事看起来不起眼却是保证秒杀不出资金事故的兜底机制。没有对账系统前面任何一环的小概率故障都会积累成账实不符的大问题。6. 压测与上线真刀真枪的注意事项6.1 压测最容易踩的三个坑秒杀系统上线前一定要压测但压测本身也容易翻车。我踩过的最大的坑有三个。第一个是只关心QPS不关心热点。用均匀分布的压测请求压出来系统能支撑5万QPS但秒杀场景下所有请求打在同一批Redis key上、同一行数据库记录上性能表现完全不一样。所以压测必须模拟真实场景构造一个热点key观察热点链路的吞吐和延迟。第二个坑是连接池耗尽而不是CPU打满。有一种常见故障现象系统CPU消耗只有百分之三四十数据库连接池和线程池却已经耗尽所有请求都在排队等待获取连接。这时候的根因往往是某个慢查询或者某个外部依赖阻塞时间太长把连接都占住不释放。压测时必须盯着连接池活跃数和线程池排队量看而不只是盯着平均延迟和吞吐量。第三个坑是日志把磁盘打满。高并发下如果每个请求都打印info日志磁盘写入量会高速增长最终磁盘满导致服务挂掉。我自己经历过一次压测到一半发现响应时间突然飙到几十秒查了半天才发现是磁盘满了。秒杀系统的日志必须降噪成功日志打到debug级别只有异常才打warn以上还要配合日志切割和清理策略。6.2 开关与降级上线前必须想好的后路秒杀系统的上线必须要有一键降级的能力。你无法保证压测环境模拟出了线上所有的真实情况所以必须假设活动过程中会出故障并且提前定义好故障时怎么办。至少要准备三个级别的开关。一二级是入口开关活动入口可以瞬间关闭让用户看到活动已结束这时候所有请求不再进入业务逻辑第二级是库存扣减降级如果Redis异常可以降级为数据库直扣哪怕吞吐低至少不超卖第三级是异步链路降级如果MQ积压严重可以先不做异步订单补偿改为直接把用户信息记录下来活动结束后人工补单。这些开关要通过配置中心下发不能靠改代码发版。开关本身要提前演练确保在活动当天真的能一键生效。我见过配置中心的开关下发有秒级延迟结果故障发生时运营在后台点了三次都没生效白白多扛了两分钟的峰值流量。6.3 一次失败活动的复盘最后分享一次真实的失败经历。那是我做的第二轮秒杀改造我把库存扣减、消息队列、异步订单全都做完了压测数据也好看当时志得意满。结果活动上线第一分钟监控就报警订单服务响应时间从30毫秒飙升到5秒随后整个服务开始大量报错。事后复盘原因让我觉得很丢脸——我漏掉了接入层限流。活动流量冲进来之后除了库存扣减之外所有请求都会去订单服务查询用户的收货地址、默认优惠券、历史订单状态等等这些查询正常情况下很轻但流量放大到几十倍之后数据库连接池被打满进而把正常购物用户的请求也拖住了。整个秒杀流程倒是没有超卖但连带影响了全站这是比秒杀失败更严重的后果。那次之后我定了一条铁律任何秒杀活动上线接入层限流是第一优先级宁可误杀一部分用户也要保证系统整体稳定。我们可以通过优化让误杀率降低但绝对不能以牺牲全站稳定性为代价。这条铁律在后面几轮活动中保护了我们很多次。做秒杀系统做到现在我的体会是它其实没有太多玄学无非是把每一层流量都管控好页面层挡掉一部分限流层挡掉一部分风控挡掉一部分最后到数据库的真实写请求已经很少了。难的是这些环节要能在同一时间稳定协作任何一个环节掉链子都会引发连锁反应。如果你正准备给团队搭一套秒杀系统我建议先别急着上各种中间件先把接入层限流和Redis库存扣减这两件最基础的事情做扎实再逐步叠加消息队列和异步链路。每加一层都要想清楚这层如果挂了系统怎么降级。这个思路比任何架构图都管用。