ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从框架演进到性能瓶颈:秒杀系统高并发架构全解析

从框架演进到性能瓶颈:秒杀系统高并发架构全解析 秒杀系统做了好几年每次面试聊到项目经验十有八九都会落到“秒杀”这个场景上。一方面是因为秒杀的技术方案高度成熟网上资料铺天盖地另一方面秒杀确实把高并发场景下的典型问题压缩到了一个极小的业务闭环里——商品详情、库存扣减、订单创建、支付回调每个环节都能单独拎出来讲半小时。我最初接触秒杀系统时最大的感受不是“功能难做”而是“流量一来什么都能倒”——数据库连接池被打满、Tomcat线程池排队、Redis连接数飙升、日志把磁盘写爆。那时候才意识到秒杀系统的难点根本不在业务逻辑而在框架选型和容量规划。这篇是性能优化系列的第一篇先把秒杀系统的框架完完整整回顾一遍。后面的文章会逐步拆每个性能瓶颈点但如果你对整体框架还没有一个清晰的认知直接聊优化会非常飘。这篇文章适合两类人看一类是刚接触高并发、准备把秒杀当作学习案例的Java后端开发者另一类是业务代码写了不少、但没真正经历过瞬时流量冲击的工程师。我会按架构演进的过程来梳理把“为什么这样设计”讲清楚而不是直接给一份架构图应付了事。1. 秒杀系统到底是什么业务约束与常规架构的错位先说个直观感受。平时我们写的电商下单接口QPS每秒请求数能到几百已经算不错了因为用户的操作是分散的流量模型相对平缓。但秒杀不一样它是把大量用户集中到一个非常短的时间窗口内同时去抢一个非常有限的资源。这种流量特征和常规业务完全不是一个物种。1.1 秒杀区别于普通交易的本质普通电商交易有三个明显特征读多写少、流量可预测、用户操作时间分散。用户在浏览商品时绝大多数请求是查询真正下单的比例可能只有百分之几。搜索引擎、推荐系统、广告系统这些场景本质上都是“读多写少”的优化问题——把热点数据尽量往内存里放把数据库的读压力层层拦截掉。秒杀系统的特征恰好相反或者说它把普通电商场景里的几个极端情况同时放大了瞬时流量极高全站用户在一个固定时间点同时发起请求第一秒的QPS可能是日常峰值的几十倍甚至上百倍。热点数据高度集中抢购的商品就那么几个SKU所有的流量全部打在这几条数据上数据库里对应的库存行会被海量请求争抢。写比例显著上升参与秒杀的用户都希望真正生成订单而不是随便浏览一下写操作占比远高于普通场景。资源极度稀缺库存量是确定的可能是几百件、几千件但请求量可能是几十万、几百万绝大多数用户注定抢不到。这四点放在一起就注定常规的SSH/SSM三层架构扛不住。为什么扛不住我后面从技术细节层面拆给你看。1.2 常规CRUD架构为什么撑不住假设我们完全按照普通电商的架构来写秒杀接口请求链路大概是这样浏览器发起请求经过Nginx反代到TomcatTomcat里的Servlet调用Service层Service层拿着Spring管理的Mapper去操作数据库数据库把库存扣减结果返回最后把订单数据插入订单表。这个链路在低并发下非常清晰代码也容易维护但流量一旦打满问题会一层层暴露出来。第一层崩的是应用服务器线程池。Tomcat默认的maxThreads一般是200左右也就是说同一时刻最多只有200个请求在处理其余的请求全部在队列里排队。这个排队是有超时时间的默认情况下连接超时或处理超时一到请求直接报错。秒杀流量进来200个线程瞬间被占满后面的请求要么排队排到超时要么直接被拒。第二层崩的是数据库连接池。即使Tomcat的200个线程全部在处理请求每个线程执行SQL时都要从连接池里拿一个数据库连接。HikariCP默认maximumPoolSize是10这10个连接要分给200个线程用拿不到连接的线程只能阻塞等待。更致命的是如果请求里包含事务事务要占用连接直到commit/rollback连接被占用的时间被进一步拉长。连接池一旦被耗尽整个应用的数据库操作就会卡死然后请求超时线程被释放又产生新的请求进来最终形成雪崩。第三层崩的是数据库的行锁竞争。库存扣减这个操作不管你怎么写SQL最终都会落到一行数据上UPDATE stock SET count count - 1 WHERE sku_id ?。InnoDB的行锁会让所有针对同一行数据的更新操作排队执行同一条库存记录在同一时刻只能被一个事务修改。即使在数据库层面能做每秒几千次的更新秒杀流量进来后大量的请求都在等这一行数据的锁数据库的活跃线程数飙升死锁检测、锁等待超时随之而来。第四层崩的是页面动态渲染。很多早期的秒杀页面都是和商品详情页耦合在一起的每个请求都要经过应用层去查询商品信息、渲染模板、拼接HTML。这种动态渲染对CPU的消耗非常大本来应用服务器的线程资源就不够用还要花大量时间在模板渲染上纯粹是浪费。所以你看常规架构在秒杀流量下的崩溃路径是清晰可见的线程池满→连接池满→数据库锁等待→应用假死。这四条链路层层递进缺一不可。1.3 “扛住秒杀”到底要满足哪些指标在谈框架设计之前必须先定义什么叫“扛住”。很多团队在复盘时只盯着一个指标——系统没挂但其实用户体验和数据一致性可能已经出了大问题。我个人在评估秒杀系统时主要看四个指标QPS系统能够承受的最大请求速率这个是最直观的容量指标。RT响应时间用户的请求在多久之内得到响应。秒杀场景下抢购动作需要在几百毫秒内返回“成功/失败/排队中”而不是让用户转圈转几十秒。成功率真正抢到商品的用户下单流程能不能顺利完成。有的系统为了扛压在入口就把大部分请求直接返回失败这虽然保住了系统但对参与秒杀的用户来说是体验降级。一致性库存数据是否准确不能超卖不能出现“明明显示有货下单却失败”的严重资损问题。这四项如果都能满足才算一个真正可用的秒杀系统。注意QPS只是其中一项在后面的框架设计中你会发现很多方案的核心目标其实是“在无法无限扩容的情况下同时保住RT、成功率和一致性”。2. 框架演进从单体到集群再到动静分离秒杀系统的框架不是一天设计出来的它是随着流量压力一点点演进来的。我习惯把它分成四个阶段每个阶段都解决上一阶段遗留的某个核心瓶颈。理解这条演进路径比直接背一个最终架构图更有价值。2.1 单体阶段业务能跑通但扛不住流量最早期秒杀系统就是一个普通的Java Web应用Spring Spring MVC MyBatis部署在一台Tomcat上数据库单独一台MySQL。功能上完全能跑通商品秒杀接口就是常规的下单流程预扣库存、生成订单、返回结果。这个阶段唯一的优点是简单。秒杀活动通常只在特定的时间点出现平时访问量并不大单机部署完全能满足日常运营需求。问题只出现在秒杀开始的那一刻流量峰值瞬间把应用服务器和数据库打满。你会发现单体架构的问题不在于“代码逻辑”而在于“单点容量”。应用实例只有一个数据库只有一台再多的流量进来也只能让这唯一的一份资源超负荷。在这个阶段做性能优化的空间非常有限无非是调大Tomcat线程池、加大JVM堆内存、优化SQL索引。这些手段在流量小的时候有用但一旦流量超过单机的物理上限怎么调都没用。2.2 集群与负载均衡阶段水平扩展解决无状态服务既然单点容量有限最直接的想法就是多部署几台。把应用层做成无状态服务前面架一台Nginx做负载均衡把请求分散到多台Tomcat上。这个阶段在工程上已经很成熟了Nginx的upstream配置一下几台Tomcat就能同时对外提供服务。集群化解决了两个核心问题第一应用层的计算能力可以水平扩展流量大了加机器就行第二某台应用挂了Nginx可以把流量切换到其他节点可用性提高。但这个阶段仍然只是“解决了一部分问题”数据库仍然是单点而且负载均衡只解决了应用层的吞吐数据库连接池和库存行的锁竞争并没有任何缓解。真正捅破这层窗户纸的是不管加多少台应用服务器所有请求最后都要落到同一个数据库上。所以这个阶段的性能瓶颈从“应用服务器”转移到了“数据库”。如果你在这个阶段只做水平扩展很快会发现数据库CPU飙升、连接数打满、慢查询暴增数据库成了新的单点。2.3 动静分离与CDN把静态流量挡在最外层流量越来越大之后你会发现秒杀页面的请求中大部分其实是在获取静态资源——商品图片、CSS、JavaScript、活动页面HTML。这些静态资源如果每次都由Tomcat去读取和渲染不仅浪费应用服务器的计算能力还会占用大量带宽和连接资源。动静分离的核心思路是把动态请求和静态请求分开处理。静态资源直接由Nginx处理或者更进一步放到CDN内容分发网络上让用户从离自己最近的边缘节点获取资源。动态请求才转发到Tomcat处理。这样一来应用服务器只需要处理真正的业务逻辑吞吐能力会高出一个数量级。这个阶段还有一个常见的实践把秒杀的商品详情页在活动开始前就静态化生成HTML文件放到CDN或Nginx上。用户打开秒杀页面时根本不经过应用服务器直接拿到一个纯静态页面。只有点击“立即抢购”按钮时才会发出一个动态请求。这个策略对秒杀系统的整体压力削减非常明显因为静态页面请求通常占整体请求的90%以上。2.4 引入中间件后的成熟框架形态单体、集群、动静分离这三步走下来应用层已经具备了较强的抗压能力但数据库仍然是一个巨大的瓶颈。为了把数据库的压力彻底降下来需要在应用层和数据库之间引入一层中间件最常用的是Redis和MQ消息队列。引入Redis的目的很明确——把热点数据的读写从数据库搬到内存里。商品信息、库存数量、用户的抢购标记这些数据在秒杀期间都是热点Redis的读写性能远高于数据库。更重要的是Redis支持原子操作比如DECR自减、Lua脚本可以在内存里完成库存扣减而不需要锁数据库的行。引入MQ的目的是削峰填谷。秒杀流量是脉冲式的第一秒的流量可能是第二秒的十倍但数据库的处理能力是相对稳定的。如果让所有请求直接打到数据库数据库处理不过来但如果先让请求进入MQ由消费端按照数据库能承受的速度去消费就实现了流量的“填谷”把瞬时高峰削平让下游系统平稳地处理请求。至此秒杀系统的成熟框架基本成型我画一个简化版的架构图来描述这里不贴图用文字描述一下链路浏览器 → CDN/静态资源Nginx → 接入层Nginx限流、负载均衡 → 应用集群业务逻辑 → Redis缓存与库存预扣 → MQ削峰填谷 → 异步消费者 → MySQL持久化订单与库存。这个框架的每一层都有明确的职责后续所有性能优化工作基本都是在每一层的边界上做文章。3. 核心业务链路拆解一个请求从点击到下单经历了什么框架搭好了接下来得把一个个具体的请求走一遍看看每一层到底在做什么这样才能理解哪些环节是性能关键点哪些环节可以继续优化。3.1 从浏览器到Nginx接入层的分流与限流用户打开秒杀页面点击“立即抢购”浏览器发出的是一个HTTP请求。如果页面的静态资源已经在CDN上那这个抢购请求会先到接入层Nginx。Nginx在这一层的职责有三块第一处理来自浏览器的HTTP请求包括HTTPS的卸载解密、HTTP头解析等第二做负载均衡把请求转发到不同的Tomcat节点第三也是最容易被忽略的做限流。很多人都把Nginx单纯理解成反向代理但其实在秒杀场景下Nginx的限流能力非常重要。固定时间段内的请求数量是有限的但客户端可以无限发起请求。如果不在接入层把流量控制住后面的应用层、Redis、MQ都会被无效请求淹没。Nginx提供了limit_req_zone和limit_conn_zone模块可以分别针对IP和连接数做限流。我在实战中一般会配置两层限流一层是按IP限流防止同一个用户用脚本刷请求另一层是全局限流防止整体流量超过后端能承受的阈值。这一层的性能优化重点其实就是两个方向一是让Nginx尽可能多地处理并发连接这里涉及worker_processes、worker_connections、keepalive等参数的调优二是把能挡掉的流量挡在外面比如直接返回“已抢完”的请求不需要再往下穿透。3.2 从应用层到Redis读多写少场景的缓存化请求通过了Nginx到达应用层Tomcat。应用层的第一件事是校验一些基本参数和用户状态然后调用一个核心服务——库存预扣。这里的库存预扣绝对不应该直接查数据库而是先查Redis。为什么要用Redis最核心的原因是库存扣减是一个“写多读多”的高频操作数据库的行锁机制完全适应不了这种并发量。Redis单机QPS可以达到十万级别而且它的DECR、EVALLua脚本等操作是原子性的可以实现“先到先得”的库存扣减逻辑不需要锁。具体流程是这样Redis里维护一个库存键比如seckill:stock:{skuId}用户请求进来时应用层执行一段Lua脚本脚本逻辑是——先检查库存是否大于0如果大于0执行DECR库存减1返回扣减成功如果小于等于0直接返回失败。这个检查扣减的过程在Lua脚本里是原子执行的不会有并发覆盖的问题。这里要特别强调的是Redis扣减库存并不意味着数据库不用管库存了。Redis的库存只是一个热数据副本MySQL里的库存才是最终的数据源。Redis扣减成功之后只是生成了“抢购成功”的资格真正的订单是需要后续异步落库的。3.3 从Redis到MQ削峰填谷的核心设计Redis扣减成功之后应用层已经有了“这个用户抢到了资格”的结论。但注意如果你在请求线程里直接去写数据库那就把数据库的压力又拉回来了。正确的做法是把写数据库的操作放到消息队列里异步执行。具体流程是扣减Redis库存成功 → 生成一条订单消息包含用户ID、商品SKU、秒杀活动ID等 → 发送到MQ → 请求直接返回“抢购成功等待订单确认”。MQ的消费者在后台异步消费这些消息真正把订单数据插入MySQL。这个设计之所以关键是因为它把“写”的压力从同步调用变成了异步消费。用户感知到的RT只有几十毫秒但订单真正落库可能是在几百毫秒之后。在MQ的消费端你可以根据数据库的负载情况动态调整消费速率流量大的时候让消息堆积一会儿流量小的时候加速消费把积压清掉。这就是削峰填谷的本质——用一个可以缓冲的中间件把脉冲式流量拉平让下游系统稳定运行。在MQ选型上国内常用的有RocketMQ和Kafka。RocketMQ对事务消息和顺序消息的支持更好Kafka的吞吐量极大但消息语义相对简单。秒杀场景下订单消息对可靠性的要求很高不能丢所以一般建议用RocketMQ。3.4 从MQ到数据库最终的一致性保障MQ的消费者拿到订单消息后接下来要做的就是两件事插入订单记录、更新数据库中的库存字段。这个阶段是整个链路里唯一和数据库直接交互的地方也是所有数据正确性保障的收口点。这里有一个非常关键的细节数据库里的库存更新绝对不能依赖Redis的扣减结果必须自己再校验一次。因为Redis扣减成功只能说明缓存里的库存没超卖但数据库里可能已经因为超卖等原因触发了限制。更稳妥的做法是在数据库里执行一次条件更新UPDATE stock SET count count - 1 WHERE sku_id ? AND count 0然后检查影响行数。如果影响行数是1说明更新成功再插入订单如果影响行数是0说明数据库里的库存已经没了说明Redis和数据库之间出现了不一致此时需要回滚Redis库存并给用户返回“抢购失败”。这个“二次校验”看似重复但它是数据安全性的最后一道防线。Redis是内存数据库存在宕机、重启丢数据的风险MQ消费也可能因为各种原因乱序、重复。只有在数据库侧再验证一次才能保证最终的一致性是可靠的。另外消费者在处理订单消息时还要考虑幂等性。MQ的“At Least Once”语义可能导致同一条订单消息被消费两次如果不做幂等就会产生重复订单。常见的方案是在订单表里加一个唯一索引比如用户ID商品SKU活动ID插入时如果撞了唯一索引说明订单已存在直接丢弃当前消息即可。4. 框架设计中几个关键取舍为什么先扣Redis而不是先扣数据库框架梳理下来你会发现整个秒杀系统最核心的设计决策不是用了什么中间件而是分了“两条库存扣减链路”一条在Redis里扣减决定用户能不能抢到一条在数据库里扣减决定订单能不能最终生成。这个设计不是拍脑袋拍出来的背后有几个清晰的取舍逻辑。4.1 先扣Redis为了削峰和避免行锁第一个问题为什么不直接扣数据库库存答案前面已经提到了——数据库的行锁扛不住秒杀流量。但更深一层的原因是你把扣库存的动作放在数据库里意味着每个请求都必须占用一个数据库连接执行一次UPDATE而且如果多个请求同时修改同一行库存InnoDB会自动让它们串行执行。数据库的单行更新性能再好也就是每秒几千次面对十万级的QPS它是无论如何也扛不住的。而Redis的扣库存是纯内存操作加上原子性保障单机可以轻松支撑十万级别的QPS。把最热点的写操作放到最擅长的中间件里这是第一个取舍。第二个问题是Redis扣减库存和数据库扣减库存之间的数据一致性怎么保证答案就是前面提过的“最终一致性”。秒杀系统中用户第一时间只需要知道“我有没有抢到”而不是“我的订单是否已经生成”。所以数据的一致性不用实时允许有短暂的不一致窗口比如Redis扣减成功了但数据库的订单还没插入。这个窗口期内如果用户去查订单可能查不到“已生成”的状态但不会影响他“抢购成功”的结论。等到MQ消费完成订单落库用户就能查到订单详情了。为了保证最终一致性还要做几件兜底的事一是Redis扣减后要记录一个“预占记录”比如用户IDSKU防止同一个用户重复抢同一个商品二是如果消费者处理订单失败要定期对账把Redis扣减了但数据库没有订单的数据找出来把Redis库存回滚回去。4.2 异步化与用户感知是“同步返回成功”还是“先排队再通知”第二个关键的取舍是用户抢购请求的返回方式。一种方案是同步处理请求进来扣减库存、生成订单、返回结果一气呵成。这种方案对用户最友好响应里直接就是订单ID但系统的吞吐能力受限于最慢的那个环节数据库写入基本不可能撑住秒杀级别流量。另一种方案是异步处理请求进来只做Redis扣减和MQ投递然后立刻返回“抢购成功订单处理中”。用户端可以通过订单查询接口、轮询、或者在一段时间后收到短信/推送通知来获取最终结果。这就是秒杀系统最常见的“异步下单”模式。我个人在实际项目里更倾向于异步方案但会把用户感知做得更平滑一些。比如返回“抢购成功”之后前端的按钮会变成“订单确认中”同时提供一个订单查询接口用户随时可以刷新查看订单状态。绝不能让用户“抢到了”却完全不知道后续该干嘛否则会引来大量客服投诉。还有一个细节异步方案下“排队”这个概念必须透明。用户进入MQ的队列之后必须能感知到自己还在排队、大概需要等多久。如果产品允许可以在返回结果里带上一个“预计等待时间”或者提供一个排队序号让用户知道自己是第几个抢到的。4.3 数据一致性问题缓存与数据库的“短暂不一致”窗口Redis扣减和数据库落库之间必然存在一个时间差。在这个窗口期内你会遇到几个典型的业务数据问题订单生成延迟用户抢到了商品但订单还没生成。如果用户这时候去“我的订单”页面查询会看到一条记录都没有。库存数据不一致Redis显示库存已经扣完了但数据库里可能还有残余库存没释放比如用户抢到后不支付订单超时关闭需要回滚库存。重复抢购问题如果没有用户维度的去重同一个用户可能用多个账号、多次请求把有限的库存都占了。所以必须在Redis里维护一个用户维度的“已抢购集合”在请求进来时先检查集合里有没有这个用户有就直接拒绝。这些问题的解决方案都不是实时的而是通过一系列补偿任务来保证最终一致。比如订单超时未支付需要定时任务扫描订单表把超时订单关闭并把对应的Redis库存加回去比如Redis和数据库的库存定期对账发现差异就修正。做秒杀系统你必须接受一个现实实时一致性让位于性能和可用性最终一致性通过兜底机制来保证。这个取舍如果你不接受那后面的性能优化就无从谈起。5. 框架回顾为后续性能优化埋了哪些伏笔系列第一篇文章写到这里秒杀系统的框架基本上已经完整了。按我的经验接下来才是真正有意思的地方——性能优化。但所有的优化都应该是“基于框架”的优化是在理解每一层职责之后的针对性调优而不是乱打一通。5.1 性能优化不是一个环节的事情很多团队做性能优化容易陷入“头痛医头”的误区。比如数据库慢了就加缓存缓存满了就扩内存然后在一次大促前疯狂调参数。这种做法的最大问题是完全没有从整体链路去评估瓶颈分布。我自己的经验是性能优化第一步永远是“找瓶颈”而不是“直接改”。秒杀系统每一层都有可能成为瓶颈我在实战里常用的定位手段主要有这几类。首先看接入层Nginx的请求日志和错误码确认有没有大量Connection refused或502/504这通常意味着后端应用完全撑不住了。然后看应用层的线程池活跃线程数、连接池活跃连接数、GC频率如果GC频繁或者Full GC扎堆说明JVM内存分配不合理。接着看Redis的运行状态比如INFO命令返回的used_memory、connected_clients、instantaneous_ops_per_sec以及是否存在OOM command not allowed when used memory maxmemory的日志。最后才是看MySQL的慢查询日志、锁等待时间、连接数。每个环节的瓶颈特征都不同但秒杀系统最常见的瓶颈其实集中在三个地方Nginx的并发连接数配置、Redis的带宽和连接数、MySQL的行锁等待。这三个点如果在框架设计时没有规划好后续优化会很被动。5.2 我在实际项目中踩过的几个框架级大坑回顾这些年的秒杀项目有几个坑是反复出现的值得在这里提一下给后面做性能优化的文章埋伏笔。第一个坑是Nginx默认配置扛不住高并发。很多系统上线时没调Nginx的worker_processes和worker_connections默认情况下Nginx的并发能力非常有限。我有一次测试压测发现Nginx先成了瓶颈而不是后端的Tomcat。这个坑非常隐蔽因为Nginx的日志看起来一切正常只是连接数超过上限后直接把请求丢掉。第二个坑是Redis的瓶颈不在CPU而在网络和内存带宽。Redis处理简单命令很快但如果你存了很大的value比如几MB的缓存数据或者执行的命令是KEYS *这种全量遍历Redis会瞬间卡顿。秒杀场景里要特别注意控制缓存value的大小一般来说单个value不要超过几十KB。第三个坑是MQ消费端的幂等性没有做透。我见过不止一次因为消费者在插入订单时没有做唯一性校验导致同一条消息被重复消费生成了重复订单。这种数据问题一旦发生排查起来非常恶心尤其在大促这种高压场景下你根本没有时间去灵光一闪只能提前把这些坑填平。第四个坑是连接池大小设置不合理。很多人在配置HikariCP或Druid时喜欢把maximumPoolSize设得很大觉得越大越能抗压。但实际上数据库的连接数有一个物理上限而且连接数过多反而会增加数据库的线程调度开销。正确的做法是先测量数据库能承受的最大并发连接数再据此反推连接池大小。这四个坑在后面的性能优化文章里都会单独展开这里先埋个伏笔。5.3 后续优化规划按照我的规划这个系列后面的文章会按下列顺序逐个深入接入层优化Nginx参数调优、限制连接数、配置限流策略、以及如何用OpenResty做更细粒度的流量控制。应用层优化JVM参数调优、线程池细化、接口级别的缓存策略以及如何减少GC压力。Redis层优化数据结构选型、大key治理、Pipeline优化、主从架构与哨兵模式的高可用设计。MQ层优化消费端并发控制、幂等设计、顺序消息的处理以及削峰填谷的流速配置。数据库层优化SQL优化、索引设计、分库分表以及库存字段的最终一致性保障方案。系统容量评估压测方法、容量预估、全链路压测的注意事项。这套规划基本覆盖了一个秒杀系统从设计到上线的全流程。后面的内容会越来越偏向具体的实操但前面的框架回顾是基础今天先把地基打牢。做秒杀系统这么多年最大的体会是性能优化不是某一个高明技巧的功劳而是每一层设计都合理之后自然呈现的结果。框架决定了系统的上限后面的优化只是在接近这个上限而已。下一篇开始进入具体的优化实操第一件事就是从接入层Nginx说开去看看它到底能扛多少并发、哪些参数值得调、哪些又是智商税。
返回列表