一个秒杀系统的设计思考
秒杀的本质问题
秒杀场景的技术特征可以用一句话概括:在一个极短的时间窗口内,大量请求争抢有限资源并完成交易。
这个特征决定了秒杀系统与常规业务系统的本质差异——常规系统面对的是持续稳定的流量,容量规划基于均值和百分位;而秒杀面对的是一条近乎垂直的脉冲曲线,峰值可达日常流量的数十倍甚至百倍,且持续时间往往不超过数秒。
将秒杀场景产生的问题按干系方进行拆解,可以清晰看到其对系统设计的三重要求:
| 干系方 | 问题表现 | 设计要求 |
|---|---|---|
| 用户 | 系统瞬间承受平时数十倍流量,页面无响应或直接宕机 | 高性能 |
| 下单成功后付款时被告知商品已售罄 | 一致性 | |
| 商家 | 100 件库存出现 200 人下单成功,超卖导致履约困难 | 一致性 |
| 竞争对手恶意下单占用库存,正常用户无法购买 | 高可用 | |
| 秒杀器扫货,黄牛囤积,营销目的无法达成 | 高可用 | |
| 平台 | 秒杀流量冲击波及非相关业务模块,全站性能劣化 | 高可用 |
| 核心链路上下游服务全线告警,在线人数创新高 | 高性能 | |
| 库存数据集中在单行记录,数据库出现严重的单点瓶颈 | 高性能 |
这三重要求构成了秒杀系统设计的基本框架。高性能解决"扛得住"的问题,一致性解决"算得准"的问题,高可用解决"不怕坏"的问题。三者相互制约,不可偏废。
以下围绕这三个维度逐层展开。
高性能:如何承接瞬时流量洪峰
秒杀的流量特征决定了高性能是第一道关卡。性能优化的核心理念可以归纳为两条原则:对于高读场景,目标是"少读"或"读少";对于高写场景,目标是数据分片与并发隔离。
动静分离:缩短请求路径
秒杀页面中,绝大部分内容在秒杀期间是不变的——商品图片、详情描述、页面模板等静态数据占据了页面体积的 90% 以上,而真正需要实时更新的只有倒计时、库存状态、秒杀按钮状态等少量动态数据。动静分离的目标是将这两类数据的请求路径彻底拆开,使静态数据在离用户最近的位置完成响应,动态数据走独立的轻量级接口。
数据拆分
第一步是识别并分离动态数据。秒杀页面中的动态要素主要包括:
- 用户维度:登录状态、用户画像、个性化推荐等,通过独立的动态接口异步加载
- 时间维度:秒杀倒计时由服务端统一下发,客户端本地倒计,定期与服务端校准
- 库存维度:库存状态和秒杀按钮的可点击状态,通过轮询或长连接实时更新
分离后的静态数据可以作为完整的 HTTP 响应进行缓存——不仅缓存响应体,而是缓存整个 HTTP 连接。Web 代理服务器根据请求 URL 直接取出响应体返回,无需重组 HTTP 协议头,也无需解析请求参数。这要求 URL 具备唯一性,而商品系统天然满足这一条件——URL 可以基于商品 ID 唯一标识。
缓存层级选择
静态数据的缓存存在三个候选位置,各有适用边界:
| 缓存位置 | 优势 | 局限 | 适用场景 |
|---|---|---|---|
| 浏览器 | 零网络开销,响应最快 | 不可控,难以主动失效 | 真正不变的资源(JS/CSS/图片) |
| CDN | 离用户近,擅长处理大并发静态请求 | 全量节点秒级失效成本高 | 商品详情页等准静态内容 |
| 服务端 | 完全可控,可主动失效 | 连接开销大,路径长 | 需要强一致的动态数据 |
对于秒杀场景,CDN 是静态数据缓存的主力位置。但将数据分发到全国所有 CDN 节点并不现实——节点越多,缓存失效的延迟和一致性问题越严重,命中率也会因请求分散而下降。
更可行的做法是选取 CDN 的二级缓存节点作为静态化改造的目标。二级缓存节点数量有限、单节点容量更大、区域访问相对集中,既能保证秒级失效,又能维持较高的缓存命中率。节点选取的原则:临近访问量集中的地区、距离主站较远的地区、与主站网络质量良好的地区。
数据整合
动静分离后,前端需要将两部分数据重新组装成完整页面。两种主流方案:
- ESI(Edge Side Includes):在 CDN 边缘节点上请求动态数据并插入静态页面,用户获得的是完整页面。服务端压力较大,但用户体验好
- CSI(Client Side Include):CDN 只返回静态页面骨架,前端通过异步请求加载动态数据。服务端压力小,但页面存在短暂的数据空白期
当前业界的主流实践是 CSI 方案配合前端骨架屏(Skeleton Screen),在保证服务端性能的同时通过视觉手段弥补体验缺口。
热点数据治理:隔离 1% 的流量风暴
秒杀场景天然产生数据热点——少量商品承载了绝大部分流量。热点治理的核心目标是不让 1% 的热点数据拖垮服务于 99% 普通请求的基础设施。
热点识别
热点数据分为两类,识别策略不同:
| 类型 | 特征 | 识别手段 |
|---|---|---|
| 静态热点 | 可提前预测 | 大促报名机制、历史销售数据分析、运营人工标注、用户访问日志 TOP-N 统计 |
| 动态热点 | 无法提前预测,运行时突发 | 实时流量采集 + 聚合分析,秒级发现异常流量集中 |
动态热点的识别尤为关键。典型场景如直播带货——主播一句推荐可能在数秒内将一件冷门商品变成流量风暴的中心。如果该商品不在缓存中,瞬时流量会直接穿透到数据库。
动态热点发现的通用架构:
- 异步采集:在交易链路各环节(Nginx 访问日志、应用层埋点、缓存中间件统计)异步采集访问频次数据,不侵入主链路
- 实时聚合:通过流计算引擎(如 Flink)对采集数据进行滑动窗口聚合,识别超过阈值的热点 Key
- 推送通知:热点数据一旦识别,通过订阅机制推送到链路各节点,各节点根据自身角色决定处置方式——缓存层做本地缓存提升、服务层做限流降级
这套机制的核心要求是秒级时效。超过秒级的识别延迟在秒杀场景下基本没有意义。
热点隔离
热点识别后,第一原则是隔离。隔离的粒度从粗到细分为三层:
- 业务隔离:秒杀商品通过报名机制提前标记,系统可以针对性地做缓存预热和资源预分配
- 系统隔离:秒杀服务独立部署,使用独立域名和入口集群,在入口层即与普通流量分离。即使秒杀系统出现异常,也不会波及主站业务
- 数据隔离:秒杀商品的库存数据使用独立的缓存集群和数据库实例,避免热点 Key 争抢影响普通商品的数据访问
隔离的本质是故障域划分——将秒杀的爆炸半径限制在预设的边界内。
多级缓存架构
隔离之后,对热点数据的读取可以构建多级缓存体系:
客户端缓存 → CDN → Nginx Local Cache → 分布式缓存(Redis Cluster) → 数据库
每一级缓存承担不同的角色:
| 缓存层级 | 容量 | 时效性 | 命中场景 |
|---|---|---|---|
| 客户端缓存 | 极小 | 秒级失效 | 页面模板、静态资源 |
| CDN | 大 | 秒级可控失效 | 商品详情页静态部分 |
| Nginx Local Cache | 中 | 毫秒级 | 库存状态等高频读数据,Lua 脚本直接响应 |
| Redis Cluster | 大 | 毫秒级 | 库存实时数据、用户限购计数 |
| 数据库 | 无限 | 实时 | 最终数据源,兜底 |
关键设计点在于 Nginx Local Cache 层。通过 OpenResty(Nginx + Lua)在接入层直接缓存热点商品的库存状态,绝大部分读请求在 Nginx 层即可响应,无需进入后端应用服务器。这一层的命中率对整体性能有决定性影响——如果能在此层拦截 90% 以上的读请求,后端的压力将降低一个数量级。
服务端性能优化:压榨每一毫秒
在架构层面的优化之外,代码层面的性能优化同样不可忽视。秒杀场景下,毫秒级的性能差异在高并发放大效应下会产生显著影响。
减少序列化开销
序列化操作在 RPC 调用中不可避免。优化方向有二:一是减少不必要的 RPC 调用,将强关联的服务进行合并部署(trade-off 是牺牲部分微服务独立性);二是选择高效的序列化协议,Protobuf 的序列化性能通常是 JSON 的 5-10 倍,在秒杀核心链路上值得考虑。
直接输出字节流
涉及字符串的 I/O 操作(无论磁盘还是网络)都需要字符到字节的编码转换,这个过程涉及查表操作,在高并发下会成为 CPU 热点。对于频繁输出的静态字符串,可以提前编码为字节数组并缓存,通过 OutputStream 直接输出,绕过字符编码的运行时开销。
裁剪异常堆栈
超大流量下,频繁输出完整异常堆栈会显著加剧系统负载。异常堆栈的字符串拼接和 I/O 操作在高并发场景下的成本远超预期。可以通过日志框架配置控制堆栈输出深度,或对已知的高频异常(如超时、限流)使用预构建的异常对象(覆盖 fillInStackTrace 方法),避免每次抛出时的堆栈采集开销。
精简处理链路
极致性能优化场景下,可以绕过 MVC 框架的完整处理链路,直接使用 Servlet 或 Netty Handler 处理秒杀请求。传统 MVC 框架的过滤器链、拦截器链、参数解析、视图渲染等环节在秒杀场景下大多是不必要的开销。
建立性能基线
优化需要量化基准。系统应建立三类基线并持续跟踪:
- 性能基线:核心接口的 TP99/TP999 响应时间、吞吐量上限
- 成本基线:历次大促的机器资源消耗,作为下次容量规划的依据
- 链路基线:核心流程的调用拓扑和依赖关系变化,及时发现链路退化
基线不是一次性工作,而是持续的度量体系,驱动代码层面的编码质量提升、业务层面的无效调用清理、架构层面的瓶颈识别与改进。
一致性:库存扣减的精确保障
秒杀系统中,库存是核心的共享状态。超卖意味着履约成本失控,少卖意味着营销效果打折。在高并发写入条件下保证库存数据的精确性,是秒杀系统最具挑战性的技术命题。
三种减库存方式的权衡
电商场景的购买过程通常分为下单和付款两步。基于此,减库存的时机有三种选择,各有其适用边界和固有缺陷:
| 方式 | 机制 | 优势 | 劣势 |
|---|---|---|---|
| 下单减库存 | 用户提交订单时立即扣减库存 | 控制精确,不会出现下单后付不了款的情况 | 恶意下单不付款可导致库存锁死,商品无法正常售卖 |
| 付款减库存 | 用户完成支付后才扣减库存 | 避免恶意下单占用库存 | 高并发下大量用户下单成功但付款时库存已清零,体验极差 |
| 预扣库存 | 下单时预扣,设定付款时限,超时自动释放 | 兼顾体验与安全 | 恶意买家可以反复下单-超时-再下单,仍有被利用的空间 |
三种方式的本质差异在于:购物流程是多步操作,在不同步骤扣减库存,就会在不同环节暴露被利用的窗口。
业界实践:预扣库存 + 风控兜底
业界最常见的方案是预扣库存。外卖下单、电商购物中的"15 分钟有效付款时间"就是典型的预扣库存实现。但预扣库存需要配合额外的防护手段来封堵漏洞:
防恶意占用(保证卖得出去)
- 对频繁下单不付款的用户进行行为标记,打标用户下单时不做库存预扣或直接拒绝
- 设置单人最大购买件数,限制单一账号的库存占用量
- 对重复下单不付款行为设置次数限制和冷却期
- 接入风控系统,通过设备指纹、行为序列分析等手段识别秒杀器和黄牛账号
防超卖(保证数据精确)
超卖的技术防线有多种实现路径:
- 数据库事务保障:在扣减操作中判断减后库存不能为负,否则回滚事务
- 字段约束:将库存字段设置为无符号整数(UNSIGNED),库存为负时 SQL 执行直接报错
- 条件更新:使用
CASE WHEN语句做原子性的条件判断与更新
UPDATE item SET inventory = CASE
WHEN inventory >= #{quantity} THEN inventory - #{quantity}
ELSE inventory
END
WHERE id = #{itemId}
- Redis Lua 原子扣减:将库存扣减逻辑封装在 Lua 脚本中,Redis 保证脚本的原子执行,避免 check-then-set 的竞态问题
库存问题从来不是单纯的技术难题。业务手段保证商品卖得出去,技术手段保证商品不会超卖——两者缺一不可。
高并发读的分层校验
秒杀场景下,读请求量远大于写请求量(通常 100:1 甚至更高)。读优化的核心策略是分层校验:
- 前端层:倒计时未到不允许点击,本地做基础的重复请求拦截
- 接入层:校验用户登录态、请求合法性、频次限制等不涉及数据一致性的检查
- 服务层:校验用户秒杀资格、活动状态、答题结果等业务规则,从分布式缓存读取库存状态做"有货/无货"的粗略判断
- 数据层:只有通过前置所有校验的请求才进入库存扣减环节,在数据层做最终的一致性保障
分层校验的设计哲学是:不同层次尽可能过滤无效请求,只在漏斗最末端执行代价最高的一致性操作。 服务层允许存在短暂的脏读——少量已无库存的请求被误判为有库存并进入写链路,在数据层会被最终拦截。这种容忍读不一致、保证写一致的策略,是高可用与强一致之间的务实平衡。
高并发写的瓶颈突破
写操作的瓶颈通常在存储层。库存数据在数据库中往往是单行记录,大量并发请求争抢同一行的 InnoDB 行锁,导致线程排队、TPS 骤降、RT 飙升。
方案一:将库存操作上移至缓存层
如果库存扣减逻辑较为简单(不涉及复杂的 SKU 联动关系),可以将扣减操作直接放在 Redis 中完成。Redis 单线程模型天然避免了并发锁竞争,配合 Lua 脚本可以实现原子性的库存校验与扣减。扣减成功后异步落库,保证最终一致性。
这种方案的适用条件是:库存结构简单、扣减逻辑无需数据库事务支持、可以接受极端场景下的异步落库延迟。
方案二:应用层排队
在应用层引入分布式锁或本地排队机制,控制同一商品的并发写入度。目的是将数据库层面的锁竞争转化为应用层面的有序排队,减少数据库的死锁检测开销和上下文切换成本。同时,排队机制可以控制单个热点商品对数据库连接池的占用,防止热点商品挤占其他商品的数据库资源。
方案三:数据层排队优化
应用层排队存在性能损耗(分布式锁本身有网络开销)。更理想的方案是在数据库引擎层面实现针对单行记录的并发排队。阿里的 AliSQL 在 InnoDB 层实现了此类优化补丁,包括:
- 基于行级别的请求排队,替代 InnoDB 默认的锁竞争机制
COMMIT_ON_SUCCESS/ROLLBACK_ON_FAILhint,允许事务在最后一条 SQL 执行完毕后根据TARGET_AFFECT_ROW的结果直接提交或回滚,省去应用层与数据库之间的额外网络往返
方案四:库存分片
对于超高并发场景,可以将单个商品的库存拆分到多个分片中。例如 1000 件库存拆分为 10 个分片,每个分片 100 件,写请求通过哈希分散到不同分片,将单行的锁竞争分散为多行的并行写入。需要注意的是,分片会增加库存碎片化问题——某些分片为零而其他分片仍有余量,需要额外的分片间余量调度机制。
读写优化的本质差异
高读和高写的优化路径截然不同。读请求的优化空间大、手段丰富——多级缓存、副本分散、就近访问均可奏效。写请求的瓶颈始终集中在存储层的一致性保障上,优化思路本质上是在 CAP 三角中寻找适合业务场景的平衡点。
高可用:极端条件下的系统韧性
秒杀流量的时间分布不是一条缓慢上升的曲线,而是一根近乎垂直的脉冲。峰值的到来是毫秒级的,对资源的消耗几乎是瞬时完成的。在这种极端工况下,任何单一环节的失败都可能引发级联崩溃。高可用设计的目标是确保系统在意外状况下仍能维持核心功能。
流量削峰:将脉冲拉平为曲线
秒杀的有效请求额度是固定的(取决于库存量),100 人参与和 100 万人参与,最终成交的数量是一样的。并发度越高,无效请求的比例越大。削峰的目标是在不影响最终成交结果的前提下,人为地将请求脉冲拉平为一条更宽、更低的曲线。
入口层削峰:验证与答题
秒杀答题机制的引入有两个目的:
- 防止机器刷单:通过 CAPTCHA、滑块验证、知识问答等方式提升购买的复杂度,拦截秒杀器
- 延缓请求到达:将零点的毫秒级请求脉冲拉长到秒级甚至十秒级。人类完成答题需要 3-10 秒,由于答题时间的差异性,请求到达后端的时间自然分散
答题机制的一个关键细节是提交时间校验——提交时间小于 1 秒的答题几乎可以确定是机器行为,应直接拒绝。
业务层削峰:异步排队
消息队列是最常见的削峰手段,将同步的写操作转化为异步的消费处理,用队列的缓冲能力吸收瞬时峰值。除消息队列外,类似的缓冲机制还包括:
- 线程池等待队列
- 本地内存蓄洪(如环形缓冲区)
- 令牌桶限速
排队方案的代价是确定的:
- 积压风险:如果峰值持续时间超过预期,队列可能达到水位上限,此时效果等同于直接丢弃请求
- 体验损耗:异步处理引入了不确定的等待时间,用户无法获得即时反馈
排队本质是将一步同步操作拆解为两步异步操作(请求受理 + 结果通知),以时间换空间。当前业界常见的做法是给用户一个"排队中"的中间态页面,配合 WebSocket 或 SSE(Server-Sent Events)推送最终结果,在削峰的同时维持用户的等待预期。
数据层削峰:分层过滤
过滤的思路是在不同层次拦截无效请求,使最终到达数据层的写操作尽可能少而精准:
- 读限流:超出系统承载能力的读请求直接返回降级页面
- 读缓存:重复的读请求命中缓存,不穿透到后端
- 写限流:超出数据层处理能力的写请求排队或丢弃
- 写校验:对写请求做最终的一致性校验,只有真正有效的扣减操作才落库
分层过滤的效果可以量化理解:假设 100 万次秒杀请求,接入层拦截 80%(限流 + 频次控制),服务层过滤 90%(缓存 + 资格校验),最终到达数据层的写请求可能只有 2 万次——与原始流量相差两个数量级。
多级降级策略
当系统负载超过承载能力时,降级是保护核心功能的最后手段。降级的粒度和触发条件需要预先设计,而非故障发生时临时决策。
降级层次设计
| 降级级别 | 触发条件 | 降级动作 | 影响范围 |
|---|---|---|---|
| L1 轻度 | 非核心依赖响应变慢 | 关闭个性化推荐、评价展示等非核心功能 | 用户体验轻微受损 |
| L2 中度 | 核心链路 RT 超过阈值 | 库存展示从实时查询降级为缓存快照,允许一定误差 | 数据时效性降低 |
| L3 重度 | 下游服务不可用 | 秒杀页面降级为静态页,关闭下单入口,展示"已售罄"或"稍后再试" | 功能不可用但系统不崩溃 |
| L4 极端 | 系统面临雪崩风险 | 全站切换到静态兜底页,所有动态功能关闭 | 业务完全中断但平台不丢数据 |
降级策略的核心原则是有损服务优于无服务。每一级降级都有明确的触发条件和恢复条件,避免人为判断带来的延迟。
熔断与限流
熔断和限流是降级的自动化实现手段:
- 熔断:当某个下游服务的错误率或响应时间超过阈值时,自动切断调用,快速失败。类似电路中的保险丝——宁可某个功能暂时不可用,也不让故障沿调用链扩散。熔断器通常包含三个状态:关闭(正常调用)→ 打开(快速失败)→ 半开(探测恢复),形成自动化的故障隔离与恢复循环
- 限流:对系统入口或关键资源设置流量上限,超出部分排队或拒绝。限流需要在不同层级(接入层、服务层、数据层)分别设置,形成多道防线
全生命周期的可用性工程
高可用不是一个阶段性的工作,而是贯穿系统全生命周期的工程实践。
架构阶段
- 消除单点:关键组件(缓存、数据库、消息队列)至少具备双活或主从自动切换能力
- 故障域隔离:秒杀系统独立部署,与主站业务物理隔离
- 多地部署:核心服务具备多机房甚至多地域的部署能力,任何单一 IDC 故障不影响整体可用性
- 弹性伸缩:基于 Kubernetes HPA 或云平台弹性能力,根据流量自动扩缩容
编码阶段
- 所有外部调用设置合理的超时时间,防止被下游拖死
- 对外部返回的异常和非预期结果做默认处理(fail-safe),而非直接抛出
- 关键操作设置幂等性保障,防止重试导致数据不一致
测试阶段
- 单元测试覆盖核心逻辑,集成测试覆盖关键链路
- 定期进行全链路压测,验证系统在预期峰值下的表现
- 引入混沌工程实践:在预生产环境注入故障(如随机杀死 Pod、注入网络延迟、模拟依赖服务超时),验证系统的容错能力和自愈能力
发布阶段
- 前置 Checklist:变更内容、影响范围、回滚方案、监控确认
- 灰度发布:新版本先在小流量集群验证,逐步扩大流量比例
- 快速回滚:确保任何发布都可以在分钟级完成回滚
运行阶段
- 监控体系:覆盖基础设施(CPU/内存/网络/磁盘)、应用层(QPS/RT/错误率/线程池状态)、业务层(下单量/支付成功率/库存变化)三个层次
- 告警体系:基于阈值告警和趋势告警的结合,设置分级告警通道(IM → 电话 → 短信),确保关键告警不被淹没
- 常态压测:定期进行服务级和全链路级的压测,持续跟踪系统水位变化
故障响应
故障发生时的首要目标是止损,而非定位根因。标准响应流程:
- 止损:通过预案快速执行(限流、降级、切流),控制影响范围
- 定位:基于监控数据和日志快速定位故障点
- 恢复:修复问题或执行回滚,恢复服务
- 复盘:分析根因,完善预案,推动改进项落地
架构全景与设计原则
回顾整个秒杀系统的设计,本质上是围绕三个核心矛盾在不同层次做取舍:
| 核心矛盾 | 设计策略 | 关键手段 |
|---|---|---|
| 流量与容量的矛盾 | 分层拦截,逐层过滤 | 动静分离、多级缓存、限流削峰 |
| 一致性与性能的矛盾 | 读写分离,最终一致 | 分层校验、缓存抗读、数据层保写 |
| 可用性与成本的矛盾 | 隔离兜底,有损服务 | 故障域隔离、多级降级、弹性伸缩 |
将这些设计决策提炼为几条通用的设计原则:
原则一:将请求拦截在离用户最近的地方。 每多一层穿透,系统付出的代价都是指数级增长的。能在 CDN 解决的不到 Nginx,能在 Nginx 解决的不到应用层,能在应用层解决的不到数据层。
原则二:区分读写路径,分别优化。 读路径追求吞吐量,允许适度的数据不一致;写路径追求正确性,必须保证最终一致。两条路径的优化策略和 trade-off 完全不同。
原则三:隔离是最有效的保护。 无论是系统隔离、数据隔离还是部署隔离,目的都是限制故障的爆炸半径。在一个足够大的分布式系统中,故障不是"可能发生",而是"一定发生"。
原则四:可用性是一个组织问题,不仅是技术问题。 稳定性在平时不紧急、出了问题就致命。如果没有组织层面的保障——将稳定性指标纳入绩效、建立专项稳定性团队、定期进行攻防演练——再好的技术方案也会在业务压力下被逐步侵蚀。
一个秒杀系统的设计,可以根据不同级别的流量,由简单到复杂构建出不同层次的架构。没有一种方案适用于所有场景,选择何种架构取决于业务规模、团队能力和成本约束。但无论规模大小,以上设计原则和思考维度是通用的——它们不仅适用于秒杀,也适用于任何需要应对极端工况的分布式系统。