
做电商后端这几年手里经手过的系统不少但如果让我挑一个最能体现架构功力的场景我会选秒杀。Java电商秒杀系统这个词在面试八股文里出现频率极高但真正在线上把秒杀系统扛下来的人才知道纸面理论和生产实践之间的差距。秒杀系统的性能优化从来不是单一节点的调优而是从框架设计、链路部署、数据一致性到容灾降级的系统性工程。这篇文章是系列的第一篇我先把电商秒杀系统的整体框架做一个回顾把分层架构、核心组件、关键链路和常见瓶颈讲清楚。后续的章节再针对具体的优化手段比如JVM调优、缓存策略、数据库分库分表、消息队列削峰逐一展开。这篇文章适合即将接手秒杀系统开发的Java工程师也适合面试前想建立完整知识体系的同学当然已经有经验的人也可以拿来做一次框架层面的横向对照。1. 秒杀系统为什么值得单独聊1.1 先看业务场景的几个数字秒杀和普通下单最大的区别在于流量曲线。日常业务流量是平稳的峰值和均值之间可能差个三五倍秒杀场景里活动开始前流量还在低位开始瞬间直接把链路打满峰值可能达到均值的几十倍甚至上百倍。以我参与过的一个实际项目为例日常订单量每分钟几千单但秒杀开启的那一分钟进入系统的请求量直接冲到每秒十几万而真正能下单成功的只有几千单。这就是秒杀系统最底层的矛盾流量和成交量的极度不对称。这种感觉怎么形容呢就像一家餐厅平时每桌都能坐下来慢慢点菜突然有一天所有客人同时冲进门口但店里只有十张桌子厨师也只有两个。你要做的不是让厨师炒菜更快而是先在门口想清楚怎么把大部分人挡住怎么让坐下的人尽量不浪费厨师的时间。秒杀系统的框架设计本质上就是处理这个“门口拥堵”的问题。1.2 三个核心特征决定了架构方向瞬时高并发几十万QPS级别的请求在几秒内集中涌入远超服务器正常承载能力。这里的关键词是“瞬时”它不是缓慢爬坡而是瞬间打满留给系统自动扩容的时间窗口极短。资源竞争集中所有请求都争抢同一个热点商品、同一个库存字段数据库单行锁成为天然瓶颈。热点资源的高并发竞争会让系统的吞吐能力断崖式下降。一致性要求高库存扣减必须准确不能超卖但这在高并发下极难保证。一致性问题是秒杀系统最容易踩坑的地方也是面试官最爱追问的点。这三个特征决定了秒杀系统不能用普通电商的架构去套必须在框架层面提前做流量拦截和资源隔离。很多人一上来就研究数据库怎么优化其实方向就错了秒杀系统优化第一位的问题是“怎么把请求数量降下来”第二位才是“怎么让剩下的请求跑得更快”。顺序一旦颠倒后面所有努力都会事倍功半。1.3 性能优化的整体思路做秒杀性能优化我一直遵循一个原则先挡住再分流后处理。先挡住指的是在离用户最近的地方把无效流量拦截掉比如按钮置灰、CDN静态化、网关限流再分流是让有效请求进入系统后尽可能走缓存和异步链路不打数据库后处理是订单创建的最终落库通过消息队列异步完成用削峰填谷的方式消化流量。这个思路贯穿整个框架设计后面的章节会反复出现这三个词可以先记住。这套思路其实不是秒杀系统发明的它源自所有高并发系统的通用设计哲学不要让稀缺资源直接面对所有请求。数据库是稀缺资源行锁是稀缺资源连接池也是稀缺资源凡是稀缺的东西都要想办法在它前面加缓冲和过滤。2. 秒杀系统整体框架回顾2.1 分层架构从入口到落库一个典型的Java秒杀系统框架上大致分成五层接入层Nginx、网关层Spring Cloud Gateway或Zuul、应用层Spring Boot业务服务、数据层Redis MySQL MQ。每一层承担的职责不同拦截和削峰的力度也不同。我画一个简单的分层示意方便对照客户端H5/App/小程序 ↓ 接入层Nginx CDN静态资源缓存、IP限流 ↓ 网关层Spring Cloud Gateway鉴权、令牌桶限流 ↓ 应用层秒杀订单服务库存校验、预扣库存、异步下单 ↓ 数据层Redis库存预热、扣减 → MQ削峰 → MySQL最终落库这五层凑在一起就像演唱会入场。CDN和Nginx是外场的保安负责拦住没票的和反复插队的人网关是检票口验一次票鉴权控制入场速度限流应用层是场内引导员把观众带到正确的区域业务判断Redis是VIP休息室只有少数人在这里完成最终确认库存扣减MQ是通道里的缓冲带防止所有人一下子涌进会场数据库MySQL才是真正的座位表最终记录谁坐到了位置。2.2 核心组件选型与分工实际项目中秒杀系统的组件选择在业界已经比较统一这里列一张我常用的选型表同时标注各自的职责和注意点组件技术选型核心职责注意点接入层Nginx CDN静态资源加速、IP级限流配置limit_req模块防止单IP刷接口网关层Spring Cloud Gateway鉴权、验签、令牌桶限流网关不做复杂业务逻辑否则会成为新瓶颈业务层Spring Boot库存校验、限流、预扣库存、发送MQ应用层要无状态化便于水平扩容缓存Redis Cluster商品信息缓存、库存预扣减、分布式锁Redis是整个框架的心脏稳定性优先消息队列RocketMQ/Kafka削峰、异步下单、最终一致性选择合适的消费模型避免消息堆积数据库MySQL订单表、秒杀结果表最终落库热点行更新要异步化避免行锁竞争这套选型不是拍脑袋定的背后都对应着具体的性能考量。比如网关层之所以不揽太多逻辑是因为所有请求都要经过网关任何一块同步的CPU操作在这里都会被放大几十万倍Redis之所以承担库存扣减是因为它单线程处理指令、原子性天然有保障抢库存这种操作在Redis里做远快于在数据库里做行锁更新。2.3 一条完整请求的流向把上面的架构串起来跑一个完整流程用户点击秒杀按钮Nginx返回本地缓存的静态页面同时收到下单请求。网关层检查用户token和请求签名执行限流策略超出令牌桶容量的请求直接返回“秒杀已结束”或“排队中”。应用层收到有效请求先查Redis里的商品库存如果库存不足直接返回失败不再继续。应用层通过Redis的Lua脚本执行库存预扣减这一步是原子操作保证不超卖。预扣成功后把用户ID和商品ID封装成消息发送到MQ马上给用户返回“抢购成功等待支付”。MQ消费者异步读取消息生成订单并写入MySQL完成最终的库存和订单一致性核对。这个流程最大的特点是把“扣库存”和“生成订单”解耦了。用户感知到的成功实际上是预扣成功的信号而不是订单真正落库的信号。很多刚接触秒杀的同事会觉得这种方式“不靠谱”担心消息丢了怎么办订单没生成怎么办。这些担忧合理但都可以通过MQ的事务消息、消费幂等和补偿任务来解决相比在高并发下同步写库拖垮数据库这点成本完全值得。3. 关键业务环节的框架级实现3.1 库存扣减方案从数据库行锁到Redis原子操作库存扣减是秒杀系统的灵魂。最早的方案是全链路同步数据库执行update stock set count count - 1 where id ? and count 0这个SQL本身是防超卖的但在秒杀流量下数据库行锁竞争会直接导致连接池耗尽一个请求堵住后面几千个请求全部排队超时。我后来的做法是把库存扣减前置到Redis。预热阶段把商品库存同步到Redis抢购阶段用Lua脚本做原子扣减。脚本大致是这样-- KEYS[1]商品库存key -- ARGV[1]扣减数量 if redis.call(get, KEYS[1]) - ARGV[1] 0 then return -1 end return redis.call(decrby, KEYS[1], ARGV[1])这段脚本执行完再判断返回值大于等于0说明扣减成功-1说明库存不足。Redis单线程执行Lua脚本整个判断和扣减过程是原子的无需额外加分布式锁。相比数据库行锁方案这个方案把扣减能力提升了好几个数量级也是目前业界最主流的做法。但要注意Redis扣减成功不代表订单一定生成。Redis与MySQL之间的数据一致性要靠MQ和补偿任务兜底。我的习惯是记录一份扣减流水到单独的Redis队列或日志表定期对账把Redis库存和数据库实际订单数做校准发现差异及时处理。一个人口多的系统最怕的就是只在Redis里看着库存少了但订单库里根本没有对应记录这种账实不符的问题越早发现越好处理。3.2 限流与防刷把流量拦在业务之前秒杀接口一旦暴露一定会被刷。羊毛党会用脚本去高频请求也会伪造多个账号绕开单用户限制。框架层面需要做三道防线第一道是网关限流。用Spring Cloud Gateway结合Redis实现令牌桶算法按用户维度做限流比如每个用户每秒钟最多放行5个请求。粒度不能太粗否则会影响正常用户也不能太细否则网关的Redis压力会很大限流本身反而成了瓶颈。第二道是接口防刷。下单接口的URL必须做签名校验客户端用服务端下发的token和时间戳参与签名服务端验签失败直接拒绝。同时引入图形验证码或滑块验证在活动开始前强制用户先完成验证这样绝大多数脚本在第一步就被挡掉了。验证码的核心作用不是防机器人而是把请求节奏拖慢让单用户单位时间内的请求次数降下来。第三道是购买资格校验。基于用户在Redis中的去重标识每个活动每个用户只能生成一个预扣流水。这个校验放在应用层用setnx做命令秒回成本极低Boolean first stringRedisTemplate.opsForValue() .setIfAbsent(seckill:user: activityId : userId, 1, 24, TimeUnit.HOURS); if (Boolean.FALSE.equals(first)) { return 您已经参与过本场秒杀; }3.3 异步下单与消息削峰同步下单在低并发下没问题但在秒杀峰值下会把数据库连接池打穿。框架上我采用“预扣库存成功 MQ异步下单”的组合。应用层预扣成功后往RocketMQ发送一条事务消息。这里说的“事务消息”不要和普通消息混淆RocketMQ的事务消息会把本地事务的成败和消息发送绑在一起先执行本地事务比如记录预扣流水再确认发送消息本地事务失败则消息不发送从根本上避免“库存扣了但没通知下游”的脏数据。消息消费者收到消息后执行下单落库。为了避免重复消费造成重复订单消费端必须做幂等在订单表上建立用户和活动的唯一索引插入冲突时直接忽略或者更新状态。MQ削峰的本质是缓冲。假如数据库只能承受每秒500次写入而秒杀瞬间有2万单要落库没有MQ就只能靠数据库硬抗有了MQ消费者按每秒几百的速率慢慢消费数据库始终在安全水位运行。用户侧的反馈是下单成功订单稍后可见对业务完全可接受。这里也要求产品经理能理解“异步可见”的交互逻辑秒杀下单和日常下单在用户感知上必须有意识地做区分不能让用户以为订单丢了。4. 从框架角度看性能瓶颈4.1 链路瓶颈盘点框架搭好之后性能问题往往出现在“最容易被忽略的那一层”。我做过一次全链路压测把每一层的瓶颈都标出来这里整理成一个速查表链路环节典型瓶颈表现优化方向客户端首屏加载慢、按钮重复点击大量重复请求静态化、CDN缓存、按钮置灰Nginx接入层worker连接数不够、日志写盘阻塞请求排队调大worker_connections、关闭访问日志或异步写日志网关层限流Redis压力大、路由转发耗时网关CPU飙高限流粒度优化、网关多实例部署应用层JVM GC停顿、线程池耗尽接口RT上涨JVM参数调优、线程池隔离Redis大key、热点key、连接数打满响应变慢热点key拆分、读写分离、本地缓存兜底MQ消费者处理速度慢于生产速度消息积压增加消费者实例、批量消费MySQL磁盘IO高、主从延迟入库慢分库分表、异步批量写这张表的信息量很大每一行拆开都能写一篇专项文章。系列后续章节会针对Redis和JVM单独展开这里先把框架层面的认知建立起来。有一点我要特别强调当你发现接口RT响应时间上涨时不要急着去看业务代码先按这张表从上游往下游逐层排查链路里性能问题往往是上游的流量压力传导到下游造成的不是下游自身代码变了。4.2 容量评估与压测方法秒杀系统的框架设计和容量评估是绑在一起的。你不能凭感觉说“我们上20台机器吧”要从预估流量反推每层需要多少实例。我常用的估算思路是先定目标QPS。运营给的预估参加人数是100万活动持续5分钟粗算平均QPS是100万除以300秒约3300QPS但秒杀流量不是平均的前10秒的峰值往往是平均值的5到10倍所以峰值按1.5万QPS设计。然后逐层推算。单台Nginx能扛约5万QPS两台足够单台网关实例配合限流能扛3000QPS左右预留2倍余量需要约10台Redis Cluster单实例读写能在5万以上库存扣减是热点操作建议单独部署一组Redis实例不和其他缓存混用MySQL写入能力按每秒1000单算2万单需要约20秒消化这正好验证了MQ缓冲的必要性。压测是框架上线前的必修课。我用JMeter和wrk做过混合压测先用wrk打网关层验证限流是否生效再用JMeter跑全链路验证数据库落库能力和MQ消费速度。压测时特别要注意不要把压测流量打到生产环境一定要在独立的压测环境里做否则压测本身就是一次事故。我在早期就犯过这个错误一个压测脚本配置错了目标地址结果把生产网关打满线上用户集体报错这个教训代价挺大。4.3 框架的演进路线秒杀系统的框架不是一蹴而就的我经历过三个阶段。第一个阶段是单体应用一台Tomcat扛所有秒杀来了直接把服务打挂这种方式现在基本只适合演示项目。第二个阶段是加缓存和MQ也就是上文讲的这套经典框架能扛住大多数秒杀场景。第三个阶段是服务拆分和弹性伸缩把秒杀单独拆成一个独立服务用容器化部署流量高峰期自动扩容结束后缩容避免常驻大量机器空转。如果你的项目还在单体阶段不用急着一步到位的微服务先把缓存和MQ加上收益最明显。架构越复杂的系统越难运维秒杀框架的原则是“按需演进”不要为了技术而技术。很多团队一上来就上Kubernetes、上一堆中间件结果活动没开始光运维这些组件就耗掉了大半人力这是本末倒置。5. 常见问题与排查技巧实录5.1 库存还没扣完接口却报“已售罄”这个问题我遇到过好几次基本都是库存预热环节出的问题。Redis里的库存key过期时间设置得太短活动还没结束key就没了或者预热脚本把库存写进了错误的key应用层查的是另一个key。排查方式很简单活动期间监控Redis的库存key是否存在、TTL还剩多少出问题先查预热脚本的参数是否和业务代码里的key拼写完全一致。实际排查的时候我会在活动开始前先跑一遍测试用例用一个测试账号走完整条链路确认库存扣减、消息发送、订单落库都正常。这招虽然土但能在活动前拦截掉一大批低级问题比任何监控都靠谱。线上出问题时也可以通过Redis的ttl命令和get命令快速定位key的状态而不是先去翻代码。5.2 缓存雪崩与穿透秒杀开始瞬间大量商品的缓存同时过期请求全部打到数据库数据库瞬间被压垮。解决办法有三个设置缓存过期时间时加随机抖动避免集中过期对热点商品直接设置永不过期由后台任务定期刷新MySQL前面再加一层本地缓存兜底降低穿透比例。热点key问题也值得单独说。秒杀场景下如果多个用户请求同一个商品信息但Redis里这个key的访问量特别大单分片压力会非常高。我的做法是给热点key设置多个副本把读请求打散到不同分片同时应用层做一层本地缓存例如Caffeine把热点数据直接放在进程内进一步降低Redis压力。本地缓存有个小坑是数据一致性所以我只在秒杀的极短时间内开启本地缓存活动结束后立即关闭避免出现用户体验上的脏数据。5.3 MQ消息积压导致订单迟迟不生成消费者数量不足是主因。秒杀结束后如果发现消息积压要立即扩大消费者实例同时检查消费者的处理逻辑里有没有慢操作比如在消费线程里调用了远程HTTP接口。我的原则是消费端不允许做远程同步调用所有需要外部系统的操作先落库再异步处理。此外监控告警必须覆盖“消息积压量”这个指标超过阈值立刻通知值班人员别等用户投诉了才发现。我经历过一次线上事故消费者线程因为调用外部风控接口超时导致消费速度骤降消息积压了几百万条订单生成延迟超过半小时用户投诉电话打到了客服那里我们通过监控才发现问题。当时扩容消费者实例以后积压很快消化掉但用户侧的体验已经受到了影响这个教训我一直记着消费端代码要尽量保持纯粹只做本系统的事务操作。5.4 强调一次“补偿机制”的经验不管是预扣库存还是发送MQ业界常说“一定不能丢消息”但我的实际经验是消息丢失的概率确实存在与其追求零丢失不如从框架上保证“丢了也能找回来”。具体做法是每次预扣都记录扣减流水MQ消费端每处理完一批就更新流水状态。定时任务扫描那些“已扣减但长时间未生成订单”的流水主动补单或者回滚库存。这套补偿机制比单纯增加消息可靠性更实用也更能应对极端异常。我见过太多团队把精力花在精确一次投递上其实在高并发场景下追求绝对的不丢消息和不重复处理性价比很低。分布式系统最基本的哲学就是“接受局部失败但保证最终一致”秒杀系统的补偿任务就是这一哲学的具体落地。6. 聊聊活动上线后的那些体会系列的开篇到这里就告一段落。后续我会把Redis秒杀实战、JVM与线程池优化、MySQL分库分表、MQ削峰调优逐个展开每一篇都会结合线上场景说一些踩坑细节。这里先抛一个我多次活动后沉淀下来的经验框架设计得再完整也不如把监控和应急预案提前演练到位。秒杀系统七八成的问题都发生在活动开始后的几十秒内那个时间段你根本没有翻文档的余地熔断开关、降级开关、扩容脚本都要提前备好并按剧本演练过。技术方案是骨架监控和预案才是这套系统真正能站稳的肌肉。另外我还想强调秒杀系统不是上线就完事了每场活动结束后必须有复盘文档把压测数据、实际流量、异常记录、问题处理过程都整理归档下一场活动前逐条核对这套自检机制比任何架构设计都能更快提升团队的实战能力。如果实践中有更好的思路欢迎随时交流我们互相学习。