
聊到Java电商秒杀系统的性能优化我习惯先把这套秒杀系统的框架从头到尾摊开看一遍。框架不清晰后面的缓存、队列、限流、分库分表全是空中楼阁。这套系统是一个标准的 Spring Boot Redis RabbitMQ MySQL 组合核心处理流程是用户点击秒杀 → 应用层校验 → Redis 预扣库存 → MQ 异步下单 → 消费端创建订单落库。所有性能优化动作都是围绕这个框架展开的所以系列第一篇文章我不急着讲调优参数先把框架给你讲明白。它适合三类人一是准备面试、想真正理解秒杀系统而不仅仅是背八股的人二是手里有一套线上系统、正准备做性能压测和容量评估的人三是想从单体慢慢演进到高并发架构、需要一个清晰参考的人。很多网上教程把秒杀系统整理成一套标准答案缓存预热、库存预扣、消息削峰、限流防刷听着很完整但真实项目和这套标准答案之间还有很大距离。我这个系列会尽量按照真实项目的演进顺序来写第一篇先把框架底子打好后面再逐个讲限流、热点、缓存一致性、DB优化的实操过程。1. 为什么性能优化要先做框架回顾1.1 从一次线上事故说起有一场大促活动秒杀开始前开发同学发现 Redis 集群带宽快要打满就去找运维加带宽。带宽加完以后流量一上来MySQL 先扛不住了CPU 直接 100%数据库连接数打满全站接口都开始超时。最后查下来问题根本不在带宽而是秒杀点击时有一个老接口还会同步去数据库查一次用户等级热点商品的详情缓存还把所有 key 的过期时间设成了同一个整点导致所有请求同时穿透到数据库。这就是典型的“头痛医头”式优化看到表现是带宽问题就加带宽看到 CPU 高就加机器结果真实瓶颈根本没有解决。那次事故之后我做了一个改变任何性能优化开始之前先花时间把当前这套系统的框架图画出来。不是画那种漂亮的架构图而是把每个请求经过的节点、每个节点后面的依赖、每个依赖的容量上限都标出来。画完之后会发现80%的优化动作其实是在补框架上的漏洞而不是单纯调某个组件的参数。1.2 框架回顾到底在看什么所谓框架回顾不是说把 Spring Boot 的 Controller、Service、Mapper 列一遍就算完而是要回答四个问题一个秒杀请求从进来到最终落库到底经过了哪些节点每个节点是同步还是异步哪些环节可能被流量打穿缓存、数据库、MQ 之间的数据流向是什么谁才是最终账本出故障的时候系统依赖了哪些外部服务哪些依赖是强依赖我经常跟团队说不要急着看代码先看调用链。代码是零件框架是整车。一辆车跑不快你把发动机参数调得再高也没用得先确认变速箱、传动轴、轮胎是不是匹配。框架回顾就是做这个匹配工作把“请求生命周期”重新梳理清楚。这个动作做完你才能给后续的性能优化划出一条清晰的路线图。2. 秒杀系统整体架构与技术选型2.1 分层架构前端、接入层、应用层、数据层这套系统从物理部署上分成四层每一层都在拦截和消化不同类型的流量。层级组件主要职责前端层CDN、静态资源服务秒杀详情页尽量静态化减少应用服务器压力接入层Nginx、API网关黑白名单、整体限流、路由转发挡住无效流量应用层Spring Boot 秒杀服务、订单服务登录校验、活动校验、Redis预扣库存、MQ消息投递数据层Redis、RabbitMQ、MySQL热点库存、异步消息、订单最终落库前端层做的是“能静态就不要动态”。秒杀页面里的大部分内容比如商品图片、介绍文案、活动规则全部推到 CDN 上只有价格、库存、按钮状态这些需要实时变化的字段才通过接口异步获取。接入层做的是“能挡就挡”比如同一个 IP 短时间疯狂请求或者某个用户反复点按钮网关层面直接用限流规则拒绝根本不给应用层添麻烦。应用层是整个框架最核心的部分它要完成业务校验、库存预扣、消息投递三个动作。数据层里的 Redis 主要用来扛热点读写RabbitMQ 用来缓冲数据库的写入压力MySQL 是最终的可靠存储。这套分层本质上是在离用户最近的每一层都消耗掉一部分流量真正能打到 MySQL 的写请求已经很少了。2.2 核心业务链路从点击秒杀到订单落库用文字描述这条链路大概是这样用户进入秒杀活动页详情数据走 CDN 和 Redis 缓存不查数据库。用户点击“立即秒杀”请求先到 API 网关网关做限流、校验 token。应用层判断用户是否登录、是否在可抢时间段内、是否已经抢过。库存服务执行 Redis Lua 脚本检查库存、预扣库存、记录用户已购脚本执行成功代表“抢购资格锁定”。应用层返回“排队中”同时把一条“秒杀成功消息”投递到 RabbitMQ。消费端从 MQ 拉取消息校验消息幂等执行数据库库存扣减生成订单最后把结果更新到订单状态表和缓存里。这条链路有一个很容易引起产品争议的点用户看到的“成功”其实只是预扣库存成功并不代表订单已经生成。我之前和产品对需求时反复强调过这个语义差异不能把“抢到资格”和“下单成功”混为一谈。很多性能优化动作之所以难推进就是业务语义没对齐技术已经做了异步化产品还坚持认为接口必须同步返回订单号。2.3 为什么选 Spring Boot Redis RabbitMQ MySQL这套框架的技术选型不是最前沿的但它是当时团队投入产出比最高的组合。Spring Boot 不说了Java 后端最普及的框架招聘、排查问题、找资料都容易。Redis 用来做热点数据缓存和分布式锁成熟可靠而且它的 Lua 脚本能力可以保证多个操作原子执行这对库存扣减来说太关键了。RabbitMQ 的选择可能会有人质疑秒杀场景不是应该用 Kafka 吗我的看法是如果单场秒杀峰值消息量达到十几万 TPSKafka 确实更适合但当时我们的峰值消息量在几万这个量级RabbitMQ 完全扛得住而且团队对它的运维和排查更熟所以选它是在性能和运维成本之间做了取舍。MySQL 则是所有数据的最终归属地不管是订单、库存流水还是活动配置全部落 MySQL前面的 Redis 和 MQ 都可以丢但 MySQL 的账不能丢。这套选型还有一个隐藏逻辑它不是把宝押在某个单点组件上而是让每个组件都做自己最擅长的事。Redis 负责快速反应MQ 负责缓冲MySQL 负责记账Spring Boot 负责串起来。后面做性能优化的时候每个方向都能定位到具体组件不会出现“系统卡了但不知道卡在哪”的尴尬。3. 框架里的核心模块与职责拆分3.1 活动配置与商品详情静态化与缓存预热秒杀商品和普通商品不能混在一张表里硬抗我们的做法是单独建了秒杀活动表。活动表主要存这些字段活动ID、商品ID、活动开始时间、结束时间、初始库存、每人限购数量、发布状态。为什么单独建表因为秒杀商品的库存语义和普通商品不一样普通商品库存是长期缓慢消耗的秒杀库存是瞬间消耗的而且需要支持活动级别回滚和清理。详情页静态化是第一个优化重点。一开始我们也是用模板引擎直接渲染但秒杀开始那一秒的页面访问量是平时的几十倍应用服务器扛不住。后来把详情页整个生成成静态 HTML 推到 CDN动态部分只保留一个库存状态接口。这样页面流量完全不走应用服务器CDN 的带宽成本比买服务器便宜得多。商品信息和库存要在活动开始前完成缓存预热。我们在开始前 15 分钟做一次定时任务把秒杀商品的基本信息、库存数量、活动状态全部刷进 Redis。这里有一个关键点预热时如果发现 Redis 里的库存和数据库不一致必须以数据库为准重新初始化不能直接把缓存里的旧数据带进活动。缓存预热很重要但验证预热数据是否正确更重要。3.2 库存服务预扣减与最终扣减库存扣减是整个秒杀框架的心脏我们的库存设计分了两级Redis 预扣减和 MySQL 最终扣减。为什么不是只用一个 Redis因为 Redis 再可靠也存在丢数据或宕机的可能如果活动结束后内存里的库存数据和数据库对不上对账会非常痛苦。为什么不是只用一个 MySQL因为数据库的连接数和单行更新锁决定了它扛不住瞬时高并发写。Redis 预扣减必须原子执行用普通 get set 一定会出问题我们直接写了 Lua 脚本把检查是否已购、检查库存、扣减库存、记录已购四个动作放在一个脚本里完成。local stock tonumber(redis.call(get, KEYS[1])) local bought redis.call(sismember, KEYS[2], ARGV[1]) if bought 1 then return -2 end if stock nil or stock 0 then return -1 end redis.call(decrby, KEYS[1], 1) redis.call(sadd, KEYS[2], ARGV[1]) return 1这个脚本里 KEYS[1] 是库存 keyKEYS[2] 是已购用户集合ARGV[1] 是用户 ID。返回 -2 表示重复抢购-1 表示库存不足1 表示预扣成功。用 Lua 的好处是 Redis 会把这个脚本作为整体原子执行中间不会插入其他命令彻底避免了并发场景下的超卖问题。数据库最终扣减发生在 MQ 消费端执行一条条件更新 SQL 保证不会把库存扣成负数。如果影响行数为 0说明数据库库存不够这时候要回补 Redis 库存并把这条消息标记为失败后续做人工补偿。这个两级扣减的设计本质上是“Redis 是闸门MySQL 是账本”闸门可以放走一部分流量但账本必须每一笔都算清楚。3.3 限流与防刷接口级和用户级限流模块分成两个维度接口级整体限流和用户级单点限流。接口级限流放在 API 网关用令牌桶算法控制整个秒杀入口的 QPS超过阈值直接返回“系统繁忙”。这一步是为了保护应用层防止流量超过下游服务的实际处理能力把整个系统拖垮。用户级限流放在应用层按userId activityId维度做滑动窗口判断。比如设置 5 秒内最多请求一次这个参数是拍脑袋定的吗不是是通过前端点击行为和正常人工操作耗时估算的。正常人不可能在 5 秒内连续点击超过一次以上还不算异常如果再结合验证码、设备指纹防脚本效果会更好。限流参数一定要根据压测数据校准。我们踩过一个大坑网关限流阈值设置得比应用层能承受的峰值还高结果限流形同虚设流量全部压到应用层最后数据库还是被打挂。正确的做法是先压测出每个节点的真实容量然后让上一层的限流阈值略低于下一层的处理能力留出一定裕量。3.4 订单创建MQ削峰与异步化订单创建为什么一定要异步因为如果用户点击秒杀后同步等待订单落库数据库的写并发会非常可怕。即使 Redis 已经挡住了大部分请求最终拿到资格的请求数量在活动开始的几十秒内仍然可能达到几万这已经远超单库事务处理能力。通过 MQ 削峰前端一小时内在数据库层面会被拉长成持续一段时间的写流量系统不至于被打穿。这里有一个接口设计细节用户点击秒杀后HTTP 请求会快速返回一个状态码和排队ID而不是阻塞等待订单号。前端拿到“排队中”之后用轮询或 WebSocket 去查订单状态。这样用户体验上虽然多了一步但后端可用性大大提升。消费端的核心要求是幂等。MQ 为了可靠性可能会重复投递消息如果消费端没有幂等处理同一条消息被消费两次就会生成两个订单。我们的方案是给消息生成全局唯一消息ID在消费端建一张幂等记录表主键是消息ID插入成功才继续处理插入失败说明已经消费过直接返回成功。4. 数据一致性与热点处理框架里的隐藏细节4.1 数据库与缓存的一致性先更新还是先删缓存缓存和数据库的一致性是老生常谈但在秒杀场景里一致性模型和我们平时做的 CRUD 系统不太一样。秒杀框架里 Redis 预扣减和 MySQL 最终扣减存在时间差所以系统根本不追求强一致而是要求最终一致。活动的最终账本以 MySQL 为准Redis 里的库存只是“预占额度”活动结束后会对账清理。在这种模型下普通“先更新数据库再删缓存”的做法就不够用了。因为 Redis 里的扣减动作是原子的数据库扣减动作是异步的两者之间出现偏差时不能简单删缓存了事而是要靠消息队列做状态同步。比如订单状态变更后发送一条“库存刷新消息”消费端收到后删除对应缓存下一次请求再回源数据库。还有一个小技巧不要用“更新缓存”来同步数据而是统一用“删除缓存 延迟双删”。更新缓存的问题在于并发场景下后写的请求可能覆盖先写的正确数据而删除缓存则让下一次请求强制回源行为更容易收敛。延迟双删可以解决“先删缓存后更新数据库”过程中的短暂脏读但在秒杀这种高并发写场景下我建议直接把缓存刷新交给异步消息处理不要在主线程里搞延迟。4.2 热点商品库存拆分把一个Key拆成多个所有请求都打在一个商品 ID 上如果这个商品 ID 直接作为 Redis 里的一个 key即使是 Redis 单实例能支撑很高的 QPS在集群模式下也会把流量集中到同一个分片上。Redis 集群对 key 做哈希分布同一个 key 必然落在一个节点这个节点就变成了单点热点。解决思路是库存分桶。比如总库存 10000拆成 10 个桶每个桶 1000每个桶对应一个独立的 Redis key。用户进入时通过用户 ID 取模或随机选择一个桶去扣减库存。int bucketIndex userId % BUCKET_COUNT; String stockKey seckill:stock: activityId : bucketIndex;这里有一个坑如果用户 ID 取模分布不均匀某些热门用户群会集中在同一个桶里导致部分桶先耗尽、部分桶还有库存。更合理的方案是先用用户 ID 算出随机盐再做哈希分散或者让用户随机选桶。随机选桶的问题是同一个用户第二次请求可能选到不同桶所以本桶没库存时不能马上返回失败而是应该继续尝试下一个桶直到所有桶都耗尽。分桶一定要注意数据库侧仍然是一个商品总库存。Redis 里的每个桶只是“预扣资格”最终数据库扣减时统一扣商品维度库存如果数据库库存不够要把 Redis 里所有桶的数据回滚。这个回滚逻辑必须在活动结束前反复测试否则很容易出现“Redis 显示已抢完数据库还有库存”或反过来。4.3 数据库连接数与事务边界我看过很多秒杀系统的代码数据库连接池配置特别随意最大连接数设个 50、100 就上线了。秒杀场景下一个慢事务可能把连接占住几百毫秒连接池很快就会被耗尽。我们的做法是把 HikariCP 最大连接数调到一个压测过的合理值并且在网关层限制总流量让应用层的数据库压力可控而不是等连接池爆了再去救火。事务边界是另一个常被忽略的点。秒杀消费端最常见的错误是在事务里做远程调用比如生成订单时去调用用户积分服务、发送短信通知这些操作会拉长事务时间导致数据库连接被长期占用最终拖垮整个消费端。我们的原则是事务里只放“更新库存”和“插入订单”这两个必须原子的操作其他动作全部挪到事务提交之后通过消息或事件驱动去执行。还有一个细节连接池的最大连接数不是越大越好因为 MySQL 的线程数有限连接数过高反而会加剧上下文切换。压测时我们看到连接数从 100 调到 200数据库 CPU 没有线性下降反而出现过短暂飙升后来还是结合慢查询和活跃连接数综合评估找到一个最合适的值。5. 框架回顾中遇到的典型问题和排查记录5.1 库存超卖是怎么发生的第一版代码非常典型先查库存判断大于 0再执行减库存更新。这段逻辑在单线程下没有问题但在并发场景下两个请求可能同时读到库存为 1都判断通过然后都执行减一最终库存变成 -1超卖发生。解决这个问题有两个层面。第一层在数据库层面把“查询更新”合并成一条条件更新 SQLUPDATE seckill_activity SET stock stock - 1 WHERE activity_id #{activityId} AND stock 0;如果影响行数为 0说明库存已经不足。但光靠这条 SQL 还不够因为高并发下数据库单行更新的锁竞争非常激烈所以第二层要靠 Redis Lua 脚本做前面说的预扣减。两层配合既能保证不超卖又能把数据库压力控制在可接受范围。5.2 缓存穿透把数据库打垮有次秒杀还没开始数据库就先报警了。一查日志发现有人写脚本遍历不存在的商品 ID 调秒杀接口这些 ID 在 Redis 里没有值每次请求都会穿透到数据库查询商品是否存在。数据库干这种无意义的活很浪费而且在秒杀流量进来之前就把连接占满会直接影响正常业务。我们的处理分了三步。第一步是参数校验商品 ID 必须为正整数且必须在活动列表内才允许继续。第二步是空值缓存对于数据库查询不存在的数据在 Redis 里放一个空标记过期时间设短一些比如 60 秒这样重复请求不会再次打到数据库。第三步是布隆过滤器拦截把所有合法商品 ID 提前加载到布隆过滤器里请求进来先判断 ID 是否可能存在不存在直接拒绝。需要提醒的是布隆过滤器有误判率不能用它替代精确校验只能作为前置过滤。而且它不能删除某个元素如果商品 ID 很少变动还好如果频繁上下架布隆过滤器维护起来比较麻烦所以我们在实际框架里空值缓存用得多一些布隆过滤器只是辅助手段。5.3 MQ消息积压导致订单迟迟不生成现象是用户提示“抢到了”但过了 40 分钟订单还没生成。最初怀疑是 MQ 性能不够但查看监控发现队列积压在持续上升消费者并没有在消费。翻消费端日志看到了大量Duplicate entry异常原来是有重复消息投递而消费端没有做幂等导致插入订单时唯一索引冲突然后消费线程不断重试把队列堵死了。这个问题的根因不是 MQ而是消费端的幂等逻辑缺失。我们在消费者入口处先查消息ID是否已经处理过如果已经处理就直接 ACK不再进入业务逻辑。同时给消费端增加了手动 ACK只有业务处理成功才确认消息处理失败则进入重试队列而不是无限重试。下面这个表是我做事故复盘时整理的问题概览问题现象根因解决方式订单迟迟不生成消费端无幂等重复消息导致唯一索引冲突增加幂等表重复消息直接 ACK消费端线程阻塞本地事务中调用外部 HTTP 接口远程调用移出事务改成事件驱动队列积压持续上涨消费者并发数配置过低且失败后无限重试合理调高并发设置最大重试次数并转入死信队列还有一个容易踩的坑消费线程里不要做太重的日志输出尤其是把整个消息体打出来。秒杀消息量大大量日志会影响消费性能而且一旦磁盘写满消费端甚至会卡死。这个经验是我们在压测踩过坑之后总结出来的。6. 下一步优化方向与复盘建议6.1 从框架回顾中得出的优化优先级框架回顾完成之后我们的优化清单不是按照“哪个组件热门就先搞哪个”来排的而是按照“流量从进来到落库这条链路哪个环节最先可能被流量打穿”来排序。优先级大致是先把接入层限流压测校准确保到达应用层的流量是可控的然后处理缓存穿透和热点 key 的拆分这一步能直接提升 Redis 层的可用性接着完善消费端的幂等和消息补偿机制保证异步链路在异常情况下还能自愈最后才是数据库连接池、慢查询、索引这类更细的优化。之所以这么排是因为限流和缓冲是“防守”数据一致性是“底线”SQL 优化是“加分项”。如果前面没有防守后面做得再好也会被突然来的流量冲垮。每次在群里看到有人问“秒杀系统用 Redis 能不能搞定”我都有点无奈Redis 只是框架里的一环能不能搞定要看你有没有给它配上合格的限流闸门、异步通道和最终一致性保障。6.2 给团队做框架复盘时的具体方法框架回顾不能只在脑子里想一定要落到纸上。我给团队定的复盘方法是把现有架构图重新画一张但不是按微服务依赖关系画而是按“一次用户请求的完整生命周期”来画从页面点击开始一格一格往下走。每到一个节点标注三个数字当前节点的最大吞吐能力、日常水位、秒杀峰值目标。然后把一次秒杀活动的日志调用链导出来按时间顺序走一遍看每个节点实际耗时是多少哪个节点的耗时曲线最先出现拐点。最后把发现的所有问题汇总成一张清单分成三类架构问题、代码问题、配置问题。架构问题需要走设计评审代码问题直接排期改配置问题当时就能调整。这个方法看起来简单但实际做下来收获非常大。因为你平时埋头写代码看到的都是局部当所有节点被画在同一张图上很多平时看不见的依赖关系和容量短板会自己冒出来。这套框架回顾做完之后我心里基本有了一张依赖矩阵哪个模块可以靠加机器扩容哪个模块只能靠代码优化哪个模块需要改业务交互流程才能减轻压力。性能优化从来不是把某个接口调快而是让每一层流量都变得可预期。如果你也在做类似系统建议花两天时间把框架图重画一遍标上每个节点的容量数据画到一半很多问题自己就浮出水面了。下一篇文章我会从流量入口的限流参数开始讲具体的压测方法和阈值设置思路。