ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

秒杀系统架构设计:三张图画清高并发链路与降级策略

秒杀系统架构设计:三张图画清高并发链路与降级策略 每年大促我们都会经历一次秒杀压测。负责画图的同学把商品、订单、库存、优惠券十几个服务拖进在线画板然后用箭头连成一张巨大的网。图很大评审时却没人能讲清楚“流量从哪进、到哪被拦住、库存到底在哪一步扣”更别说指出哪个节点会先被打挂。后来我慢慢意识到秒杀系统架构设计做到最后交付给团队的其实不是一堆组件而是一张能把关键决策讲清楚的架构图。这篇文章就围绕“秒杀系统架构设计”这件事讲讲我画架构图时实际用到的思路。适合正在做秒杀、抢购、限量预约这类高并发场景的后端同学也适合需要给团队交付架构设计文档的人。你会看到我处理三类图的方法宏观拓扑图、核心时序图、异常降级路径图以及每张图里必须标注的关键信息。重点不是教某个画图工具的按钮而是让你知道每一步为什么要这样画。1. 动手之前先想清楚这张架构图到底给谁看1.1 架构图是设计决策的载体不是组件堆砌我见过太多秒杀架构图问题不是画得不好看而是画完之后没法回答问题。一张合格的架构图本质上是一份沟通工具它传达的不是“我用了Redis、用了MQ”而是“我在哪一层做的取舍以及为什么在这个位置做”。组件堆得再多如果判断不出来哪条链路会超卖、哪个节点会雪崩这张图就没有价值。先区分看图的三种人他们的需求完全不同。老板关心成本和容量他需要知道这次秒杀要准备多少台机器、跨几个可用区、扛到多少峰值流量开发关心流程和边界他想知道同步调用和异步消息的分界点在哪、超时和重试怎么做、失败回到哪里运维关心容量和拓扑他关注每个组件的实例数、负载水位、监控告警应该布在哪些位置。一张图如果同时满足这三种人它往往意味着谁都不满意。所以我画图前会先定一个主诉这张图主要给谁看。另一个必须提前确定的问题是核心参数没有参数就没有架构设计。库存是多少、预估峰值QPS多少、可接受的下单响应时间多长、允许超卖吗。不同答案会直接导向完全不同的结构。举个例子库存1000件、瞬时请求10万QPS和库存100万件、请求量1万QPS两张架构图不可能一样。前者的重点是“拦截绝大多数流量”后者的重点才是“高效处理批量写入”。架构图如果脱离这两个数字画出来的只能是理想化的demo。1.2 画秒杀架构图前先拆出三个独立视图我习惯把秒杀系统拆成三个层次来看分别画三张图而不是硬塞进一张大图里。第一个是宏观拓扑视图解决“有哪些节点、流量怎么穿过这些节点”的问题画的是静态结构。第二个是核心时序视图解决“一次秒杀请求从进来到出结果经历了哪些步骤、前后顺序是什么”的问题画的是动态过程。第三个是异常与降级视图解决“某个节点挂了或者流量超过预期时系统怎么做限流、熔断、降级”的问题画的是边界和防御。这三张图的顺序不能反。先有拓扑你才知道时序里参与者是谁先有时序你才知道异常路径要在哪些位置插入兜底。我曾经犯过一个典型错误第一次画秒杀图时直接打开画板开始连线结果画到一半发现库存扣减的时序逻辑根本没想清楚整个图推翻重来。后来我改成先写字、再画图先用文字把一句话概括的链路写出来。比如“用户请求到达网关网关限流和验签秒杀服务校验活动时间和用户资格Redis原子扣库存成功后发MQ消费者异步下单落库最后返回用户排队中”。你先把这个文字链路写顺了再从里面挑节点画成图这样改动成本极低逻辑也不会漏。文字版本都通顺不了的链路画成图只会更乱。2. 宏观拓扑把秒杀链路拆成五层才知道节点放哪2.1 五层结构中的节点与职责我通常把秒杀的宏观拓扑画成五层。最左边是接入层包括DNS和CDN。秒杀活动的页面优先做成静态页面推到CDN上用户打开页面不会打到后端按钮的倒计时、置灰逻辑也在客户端完成。这一层做的事情很关键把“看”的流量和“买”的流量的入口分开多数用户只是来看一眼活动页根本不往后端走。第二层是网关和风控层。网关负责全局限流、用户鉴权、参数校验和基础风控。秒杀场景里最常见的恶意行为是脚本刷接口所以网关会做用户维度限流比如单用户一秒最多请求一次秒杀接口超出直接返回“操作太频繁”。风控系统可以挂在这里通过设备指纹、IP画像、用户历史行为把异常流量挡在业务逻辑之前。这一层我习惯画成矩形区域而不是一个孤零零的节点因为它是多个策略的集合。第三层是业务服务层。这里有一个关键设计原则秒杀服务必须和主站交易服务做隔离单独部署哪怕它内部复用了订单服务和库存服务的代码也必须独立出进程和集群。原因很简单秒杀是短时间超高流量的场景如果不隔离秒杀流量会把整个电商后端拖垮连正常购物用户都受影响。隔离之后就算秒杀服务被打满主站其他业务依然可用。这个决策在架构图上一定要体现出来比如在秒杀服务外围画一个虚线边界标注“独立集群”。第四层是缓存层核心是Redis集群。秒杀系统里Redis承担的任务比普通缓存多库存预热、库存扣减、用户已购标记、活动资格校验。为什么要把库存扣减放到Redis而不是直接操作数据库因为数据库的行锁在10万QPS写入面前就是灾难一个UPDATE语句排队等于整个系统雪崩。而Redis是单线程模型配合Lua脚本可以保证扣库存的原子性。这一层是秒杀系统能抗住高并发的关键。第五层是存储与异步层包括消息队列和数据库。下单操作不在同步链路里完成而是通过MQ把订单消息丢给消费者消费者再创建订单、扣减数据库库存。这样做的考虑是用户只关心“我抢到没有”至于订单流水生成、库存落库这类操作可以异步慢慢做。数据库在这里只处理最终一致性的落地不需要直接承受瞬时并发。2.2 画总览图时流量方向和关键数字怎么标画宏观拓扑时我遵循一个原则主链路从左到右从用户指向数据库一层一层穿过线不交叉。如果图里出现两条主链路线交叉甚至绕圈说明层次划分有问题需要停下来重新整理。我习惯在每个关键链路上标注预期QPS数字比如入口网关50万、业务服务10万、Redis写1万、MQ消费5000这样这张图天然就是漏斗形状看图的人一眼就能明白流量在哪个环节被削减。另外一个容易被忽略但很重要的事情是要在图上明确标注异步边界。同步调用用实线箭头异步消息用虚线箭头。秒杀系统里同步链路只到Redis扣库存成功之后都是异步链路。这个分界点必须画得非常醒目因为很多人在理解秒杀系统时最容易困惑的就是“前端显示成功但订单还没生成”这个阶段箭头画清楚了协作时沟通成本会低很多。最后是容量标注。在Redis、MQ、数据库这些核心组件旁边写清楚配置规模比如“Redis Cluster 3主3从库存预热1万”“MQ主题分区数8”“MySQL 一主两从”。不要小看这些数字它们是评审时回答“扛得住吗”这个问题的直接证据。没有容量标注的架构图只是装饰品。3. 核心时序下单扣库存的先后顺序画错一步就超卖3.1 一次秒杀请求的完整时序步骤我画时序图通常会把一次秒杀请求拆成下面这些步骤每一步画一个箭头用户点击“立即秒杀”客户端先请求一个预下发接口拿到带签名的令牌再携带令牌访问真正的秒杀接口。这一步的作用是让网关可以先拦一道避免所有人直接打秒杀接口。秒杀服务接收请求后先校验活动是否已经开始、用户是否在黑名单或风控命中。这些校验都是只读判断速度很快主要目的是把明显无效的请求过滤掉。在Redis里用用户和活动维度的key写入已购标记比如SETNX seckill:buy:{activityId}:{userId} 1。写成功的继续往下走写失败说明这个用户已经抢过直接返回“请勿重复购买”。这个标记同时能防超卖因为它天然去重。执行库存扣减脚本。这里绝不能先GET库存再DECR两个操作分开执行在高并发下会超卖。我用Lua脚本把整个判断和扣减包装成一个原子操作local stock tonumber(redis.call(get, KEYS[1])) if not stock or stock 0 then return 0 end redis.call(decr, KEYS[1]) return 1脚本返回1表示扣减成功返回0表示库存不够。这里补充说明一下为什么Redis单线程就能保证原子性Lua脚本在Redis里是串行执行的中间不会插入其他命令所以并发请求不会同时读到同一个库存值。这也是为什么秒杀系统尤其是热点商品秒杀都选择Redis而不是数据库来做扣减的原因。扣减成功之后把订单消息发送到MQ。这个时候前端就能收到“下单成功正在排队”的返回。很多设计里同时会用ZSet记录一个待支付订单集合目的是后面可以兜底扫描。MQ消费者收到消息后异步创建订单调用库存服务扣减数据库库存然后更新订单状态。数据库扣减用乐观锁比如UPDATE stock SET stock stock - 1 WHERE sku_id ? AND stock 0执行影响行数为0说明库存异常进入补偿流程。如果用户在限定时间内未支付订单过期需要把Redis里扣掉的那部分库存释放回来。这个逻辑在时序图里也要画出来不然库存会被无效订单吃掉。这一步里最值得在图上强调的是同步和异步的分界。同步链路从进入秒杀服务开始到发MQ成功就结束了。用户在同步链路里感知到的只是“抢到了”订单生成发生在异步链路里。把这个边界画清楚团队里每个人讨论问题时就会知道各自负责的是哪一段。3.2 时序图里怎么画重试、回滚和最终一致秒杀时序图里不能只画快乐路径必须画失败分支否则这张图无法指导开发。我画时序图时会重点标注三类情况。第一类是MQ发送失败。如果扣减库存成功、消息没发出去Redis里的库存就凭空消失了。我的做法是在图上标注一个本地消息表把订单消息先写入数据库本地消息表再异步投递到MQ投递成功后更新消息状态。或者更轻量一点由定时任务扫描Redis里的“已抢到但未落库”集合把超时未生成订单的请求补偿重建。不管用哪种方案图上都必须有这条补偿路径。第二类是消费者处理失败。消费者创建订单时如果数据库异常消息会重试投递但重试不能无限进行。图上要标注“重试最多3次指数退避超过次数进入死信队列”。死信队列里的消息再有专门的补偿任务来处理比如释放Redis库存、通知用户“抢购失败”。如果不画这条路径开发人员可能想当然地用无限重试导致数据库被重试流量打死。第三类是超时未支付释放库存。用户抢到后不支付库存一直占着后面想买的人买不到。所以在时序图里我用一条从“订单超时扫描器”指向“库存服务”的虚线表示定期扫描释放超24小时订单的库存。这里要特别注意释放库存必须同时操作数据库和Redis先更新数据库订单状态和库存再删掉Redis里的用户已购标记顺序不能反否则用户可能重复抢购。画这些路径的时候线条会明显多起来。我的建议是颜色语义统一正常路径用深色实线异步用虚线失败或补偿用另一个颜色图例放在图下方。这样一张图即便信息量大看图的人还是能在三秒内找到主链路。4. 异常路径限流、熔断、降级什么时候触发必须画出来4.1 把前置拦截画清楚限流、风控、资格判断秒杀系统的核心思想是“能拦在外面的流量尽量不要进到系统里”。这句话要在架构图上直接体现出来否则设计方案时大家都觉得“能抗住”上线后第一波流量就把后端打挂了。我把前端和网关这两层的拦截动作分成几类全部在图上用同一颜色或同一图例标注。第一类是静态化拦截活动页推CDN、按钮倒计时、前端验证码这些不经过后端。第二类是网关限流用令牌桶做全局限流和用户维度限流例如接口总QPS上限5万单用户单商品每秒最多1次。第三类是资格预判比如活动开始前秒杀接口直接返回“未开始”活动结束后返回“已结束”这些不需要做远端业务校验。还有一个很多人容易漏掉的动作发送验证码或答题。秒杀开始瞬间大量机器脚本会第一时间打接口而普通用户正因为页面倒计时还没到在等待。我认为在秒杀场景里加滑块或答题不是单纯为了用户体验而是用一次的廉价交互成本过滤掉机器流量。在架构图上应该把这个动作画在“网关层”和“业务服务层”之间标注“按策略开关可动态启用”。把这些前置拦截画出来后流量数字会非常直观。假设活动页带来100万次浏览其中50万用户在秒杀瞬间点击按钮经过CDN静态页挡掉大部分页面刷新流量、网关限流挡掉超出的部分、用户重复点击被限流拉长最终到达秒杀服务的请求只有10万再经过Redis扣库存真正做到下单的只有1000个。这个漏斗在图上呈现得越清晰评审时越好讲。4.2 依赖故障时的降级路径与兜底方案只画限流还不够架构图上必须画出依赖故障时的降级路径。我见过不少系统方案文档里写着“Redis高可用”但压测时Redis真挂了整个秒杀直接雪崩。真实架构设计里要提前想清楚每一个核心依赖挂了之后的动作并把动作画在图里。Redis不可用是秒杀系统最极端的故障。此时库存数据在Redis里数据库里的库存可能没有及时同步直接继续扣减一定会出问题。稳妥的降级策略是快速失败整个秒杀入口做拦截返回“活动火爆请稍后重试”而不是尝试把流量全放给数据库。因为在秒杀这种场景下保住数据库不崩、不让用户重复支付比完成一笔交易更重要。这个降级路径在图上用一条从网关直接到兜底返回页面的箭头表示旁边标注“Redis不可用或读超时限流降级”。MQ积压是另一个高频故障。如果消费者处理不过来消息会越堆越多用户明明抢到了却迟迟收不到订单。图上的应对方案有三板斧第一是下游执行线程池扩容第二是降低上游消息发送速率通过网关限流第三是开启“订单状态查询”接口让用户主动查询进度。最差的情况下Redis库存已经扣了、MQ里积压成千上万条消息系统也要保证这些消息最终被处理完不能丢。所以我在图上会画一条“积压告警”虚线连接到监控告警模块标注“队列堆积超过阈值触发自动扩容”。数据库超时降级也要画。秒杀场景里数据库实际承担的压力远小于Redis但一旦被透传的异常流量压到数据库恢复起来非常慢。所以数据库前面会有连接池和熔断器熔断器断路后不再走数据库操作而是直接返回失败。图上把熔断器画在消费者和数据库之间标注“连续失败率超过50%熔断10秒”。有人觉得熔断会损失一部分正常请求但和整个数据库打挂相比这个损失完全值得。5. 从草稿到交付画图工具、图层规范和评审要点5.1 工具选择、图层分层与视觉规范画架构图用什么工具我用下来的经验是分场景选不用迷信某一个。下面是给团队做调研时整理的对比你可以直接参考工具适合场景源文件能否进Git团队协作draw.io交付级架构图、免费、格式开放能XML格式本地文件多人协作弱ProcessOn国内访问快在线协作方便需导出保存实时多人编辑Excalidraw手绘风草稿、快速讨论思路能JSON格式实时多人编辑PlantUML时序图、代码化生成、适合评审记录能文本格式Git原生Visio正式文档、强排版能多人协作差偏正式交付如果让我推荐一个比较稳的组合我会用draw.io画拓扑图和总览图用PlantUML画时序图。原因很简单draw.io免费且源文件是XML可以直接存进Git图和代码一起走版本管理PlantUML是纯文本时序图改了逻辑之后重新生成一张即可不会因为手画导致版本不一致。图层规范是我觉得最值得分享的部分。建议每一层单独放在一个图层或一个泳道区域里画完后把图层分组命名比如“接入层”“网关层”“业务层”“缓存层”“存储层”。线型也要有固定语义我的习惯是实线表示同步调用虚线表示异步消息和最终一致性数据流粗线表示高流量主链路红色虚线表示降级路径或补偿链路。每个图右下角加一个图例块把线型和颜色含义写清楚。另外一个建议是控制信息密度。一个节点就代表一个服务或组件不需要把服务内部的类和方法画出来。有人喜欢在服务框里密密麻麻写上一堆职责说明结果图根本没法看清。服务的职责写在对应的设计文档里架构图只负责体现“它在这条链路中的位置和关系”。5.2 评审一张秒杀架构图应该问的几类问题画完之后不要急着发文档先自己按下面几个问题过一遍再去拉评审效率会高很多。第一个问题从入口到数据库逐层问一遍流量在哪一层被削减。我评审时习惯用手沿着主链路划每经过一层就问“这里挡住了多少请求、还剩多少进入下一层”。如果回答不上来说明图上的流量管控没有设计完整。第二个问题库存扣减的原子性在哪体现。在图里找到扣减库存的节点确认使用的是Lua脚本或者数据库乐观锁并且能说清楚为什么这样能避免超卖和重复扣减。如果图上这个节点画得不明确就等于没有明确方案。第三个问题假设其中一个核心节点挂了降级动作是什么。我的习惯是评审时随机点名Redis挂了怎么办、MQ挂了怎么办、数据库熔断了怎么办。如果对方眼神一空或者翻几分钟文档说明异常路径没设计到位。一张没有降级路径的秒杀架构图就是一张给自己壮胆的图。第四个问题图的粒度是否适合当前阶段。评审时如果发现图放大到看不清、缩小到啥也看不出那就是信息过载了。拓扑图控制在一页能看得清时序图控制在一次完整的请求生命周期能够讲完。讲不清楚就拆图总览图配局部图比一张全景图好用得多。第五个问题图上是否标清楚了关键指标。比如预期QPS、库存数量、网络超时时间、重试次数。这些数字是评审讨论的基础没有数字“扛不扛得住”就是瞎聊。6. 画了几轮秒杀图之后我沉淀下来的几个习惯6.1 我的画图顺序先写文字链路再分层拆图这个顺序是我反复踩坑之后才固定的。第一版交付时我直接开画板一边画一边想结果图改了三轮每次都是因为逻辑推倒重来。后来我强制自己统一按四步走先写一段文字版的完整链路把每个环节说清楚再根据文字画分层拓扑图然后画核心时序图最后补异常和降级路径。四步走完图基本只改小细节不会推翻重来。文字链路这一步特别建议写细一点不仅要写正常流程还要写每个环节的失败分支。比如“校验用户资格通过后Redis扣库存扣减失败返回已抢光扣减成功之后发MQMQ发送失败则写本地消息表”。这段文字写明白画图就是体力活。6.2 图到什么程度才算“能交付”我个人对“能交付”的验收标准是三条。第一任何一个没看过设计的人照着图在三分钟内能讲清楚主链路和库存扣减的顺序第二随机指出图上一个节点对方能说出它如果挂了会发生什么第三图上标注的QPS和容量数字能和压测场景对得上。达不到这三条图就还需要迭代。分享一个小技巧每画完一版图我会打开一个空白的白板不看原图尝试自己重新画一遍主链路。画不出来或者犹豫的地方就是设计还没想清楚的地方。用这个手段倒逼自己把细节想透比直接评审效率更高。秒杀架构图本身当然不是架构设计的全部但它是检验设计是否想清楚最直接的载体一张讲不清决策的图背后往往就是一个还不成熟的设计。
返回列表