一个秒杀系统的设计思考

秒杀的本质问题

秒杀场景的技术特征可以用一句话概括:在一个极短的时间窗口内,大量请求争抢有限资源并完成交易。

这个特征决定了秒杀系统与常规业务系统的本质差异——常规系统面对的是持续稳定的流量,容量规划基于均值和百分位;而秒杀面对的是一条近乎垂直的脉冲曲线,峰值可达日常流量的数十倍甚至百倍,且持续时间往往不超过数秒。

将秒杀场景产生的问题按干系方进行拆解,可以清晰看到其对系统设计的三重要求:

干系方 问题表现 设计要求
用户 系统瞬间承受平时数十倍流量,页面无响应或直接宕机 高性能
下单成功后付款时被告知商品已售罄 一致性
商家 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 统计
动态热点 无法提前预测,运行时突发 实时流量采集 + 聚合分析,秒级发现异常流量集中

动态热点的识别尤为关键。典型场景如直播带货——主播一句推荐可能在数秒内将一件冷门商品变成流量风暴的中心。如果该商品不在缓存中,瞬时流量会直接穿透到数据库。

动态热点发现的通用架构:

  1. 异步采集:在交易链路各环节(Nginx 访问日志、应用层埋点、缓存中间件统计)异步采集访问频次数据,不侵入主链路
  2. 实时聚合:通过流计算引擎(如 Flink)对采集数据进行滑动窗口聚合,识别超过阈值的热点 Key
  3. 推送通知:热点数据一旦识别,通过订阅机制推送到链路各节点,各节点根据自身角色决定处置方式——缓存层做本地缓存提升、服务层做限流降级

这套机制的核心要求是秒级时效。超过秒级的识别延迟在秒杀场景下基本没有意义。

热点隔离

热点识别后,第一原则是隔离。隔离的粒度从粗到细分为三层:

  • 业务隔离:秒杀商品通过报名机制提前标记,系统可以针对性地做缓存预热和资源预分配
  • 系统隔离:秒杀服务独立部署,使用独立域名和入口集群,在入口层即与普通流量分离。即使秒杀系统出现异常,也不会波及主站业务
  • 数据隔离:秒杀商品的库存数据使用独立的缓存集群和数据库实例,避免热点 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_FAIL hint,允许事务在最后一条 SQL 执行完毕后根据 TARGET_AFFECT_ROW 的结果直接提交或回滚,省去应用层与数据库之间的额外网络往返

方案四:库存分片

对于超高并发场景,可以将单个商品的库存拆分到多个分片中。例如 1000 件库存拆分为 10 个分片,每个分片 100 件,写请求通过哈希分散到不同分片,将单行的锁竞争分散为多行的并行写入。需要注意的是,分片会增加库存碎片化问题——某些分片为零而其他分片仍有余量,需要额外的分片间余量调度机制。

读写优化的本质差异

高读和高写的优化路径截然不同。读请求的优化空间大、手段丰富——多级缓存、副本分散、就近访问均可奏效。写请求的瓶颈始终集中在存储层的一致性保障上,优化思路本质上是在 CAP 三角中寻找适合业务场景的平衡点。


高可用:极端条件下的系统韧性

秒杀流量的时间分布不是一条缓慢上升的曲线,而是一根近乎垂直的脉冲。峰值的到来是毫秒级的,对资源的消耗几乎是瞬时完成的。在这种极端工况下,任何单一环节的失败都可能引发级联崩溃。高可用设计的目标是确保系统在意外状况下仍能维持核心功能。

流量削峰:将脉冲拉平为曲线

秒杀的有效请求额度是固定的(取决于库存量),100 人参与和 100 万人参与,最终成交的数量是一样的。并发度越高,无效请求的比例越大。削峰的目标是在不影响最终成交结果的前提下,人为地将请求脉冲拉平为一条更宽、更低的曲线。

入口层削峰:验证与答题

秒杀答题机制的引入有两个目的:

  1. 防止机器刷单:通过 CAPTCHA、滑块验证、知识问答等方式提升购买的复杂度,拦截秒杀器
  2. 延缓请求到达:将零点的毫秒级请求脉冲拉长到秒级甚至十秒级。人类完成答题需要 3-10 秒,由于答题时间的差异性,请求到达后端的时间自然分散

答题机制的一个关键细节是提交时间校验——提交时间小于 1 秒的答题几乎可以确定是机器行为,应直接拒绝。

业务层削峰:异步排队

消息队列是最常见的削峰手段,将同步的写操作转化为异步的消费处理,用队列的缓冲能力吸收瞬时峰值。除消息队列外,类似的缓冲机制还包括:

  • 线程池等待队列
  • 本地内存蓄洪(如环形缓冲区)
  • 令牌桶限速

排队方案的代价是确定的:

  • 积压风险:如果峰值持续时间超过预期,队列可能达到水位上限,此时效果等同于直接丢弃请求
  • 体验损耗:异步处理引入了不确定的等待时间,用户无法获得即时反馈

排队本质是将一步同步操作拆解为两步异步操作(请求受理 + 结果通知),以时间换空间。当前业界常见的做法是给用户一个"排队中"的中间态页面,配合 WebSocket 或 SSE(Server-Sent Events)推送最终结果,在削峰的同时维持用户的等待预期。

数据层削峰:分层过滤

过滤的思路是在不同层次拦截无效请求,使最终到达数据层的写操作尽可能少而精准:

  1. 读限流:超出系统承载能力的读请求直接返回降级页面
  2. 读缓存:重复的读请求命中缓存,不穿透到后端
  3. 写限流:超出数据层处理能力的写请求排队或丢弃
  4. 写校验:对写请求做最终的一致性校验,只有真正有效的扣减操作才落库

分层过滤的效果可以量化理解:假设 100 万次秒杀请求,接入层拦截 80%(限流 + 频次控制),服务层过滤 90%(缓存 + 资格校验),最终到达数据层的写请求可能只有 2 万次——与原始流量相差两个数量级。

多级降级策略

当系统负载超过承载能力时,降级是保护核心功能的最后手段。降级的粒度和触发条件需要预先设计,而非故障发生时临时决策。

降级层次设计

降级级别 触发条件 降级动作 影响范围
L1 轻度 非核心依赖响应变慢 关闭个性化推荐、评价展示等非核心功能 用户体验轻微受损
L2 中度 核心链路 RT 超过阈值 库存展示从实时查询降级为缓存快照,允许一定误差 数据时效性降低
L3 重度 下游服务不可用 秒杀页面降级为静态页,关闭下单入口,展示"已售罄"或"稍后再试" 功能不可用但系统不崩溃
L4 极端 系统面临雪崩风险 全站切换到静态兜底页,所有动态功能关闭 业务完全中断但平台不丢数据

降级策略的核心原则是有损服务优于无服务。每一级降级都有明确的触发条件和恢复条件,避免人为判断带来的延迟。

熔断与限流

熔断和限流是降级的自动化实现手段:

  • 熔断:当某个下游服务的错误率或响应时间超过阈值时,自动切断调用,快速失败。类似电路中的保险丝——宁可某个功能暂时不可用,也不让故障沿调用链扩散。熔断器通常包含三个状态:关闭(正常调用)→ 打开(快速失败)→ 半开(探测恢复),形成自动化的故障隔离与恢复循环
  • 限流:对系统入口或关键资源设置流量上限,超出部分排队或拒绝。限流需要在不同层级(接入层、服务层、数据层)分别设置,形成多道防线

全生命周期的可用性工程

高可用不是一个阶段性的工作,而是贯穿系统全生命周期的工程实践。

架构阶段

  • 消除单点:关键组件(缓存、数据库、消息队列)至少具备双活或主从自动切换能力
  • 故障域隔离:秒杀系统独立部署,与主站业务物理隔离
  • 多地部署:核心服务具备多机房甚至多地域的部署能力,任何单一 IDC 故障不影响整体可用性
  • 弹性伸缩:基于 Kubernetes HPA 或云平台弹性能力,根据流量自动扩缩容

编码阶段

  • 所有外部调用设置合理的超时时间,防止被下游拖死
  • 对外部返回的异常和非预期结果做默认处理(fail-safe),而非直接抛出
  • 关键操作设置幂等性保障,防止重试导致数据不一致

测试阶段

  • 单元测试覆盖核心逻辑,集成测试覆盖关键链路
  • 定期进行全链路压测,验证系统在预期峰值下的表现
  • 引入混沌工程实践:在预生产环境注入故障(如随机杀死 Pod、注入网络延迟、模拟依赖服务超时),验证系统的容错能力和自愈能力

发布阶段

  • 前置 Checklist:变更内容、影响范围、回滚方案、监控确认
  • 灰度发布:新版本先在小流量集群验证,逐步扩大流量比例
  • 快速回滚:确保任何发布都可以在分钟级完成回滚

运行阶段

  • 监控体系:覆盖基础设施(CPU/内存/网络/磁盘)、应用层(QPS/RT/错误率/线程池状态)、业务层(下单量/支付成功率/库存变化)三个层次
  • 告警体系:基于阈值告警和趋势告警的结合,设置分级告警通道(IM → 电话 → 短信),确保关键告警不被淹没
  • 常态压测:定期进行服务级和全链路级的压测,持续跟踪系统水位变化

故障响应

故障发生时的首要目标是止损,而非定位根因。标准响应流程:

  1. 止损:通过预案快速执行(限流、降级、切流),控制影响范围
  2. 定位:基于监控数据和日志快速定位故障点
  3. 恢复:修复问题或执行回滚,恢复服务
  4. 复盘:分析根因,完善预案,推动改进项落地

架构全景与设计原则

回顾整个秒杀系统的设计,本质上是围绕三个核心矛盾在不同层次做取舍:

核心矛盾 设计策略 关键手段
流量与容量的矛盾 分层拦截,逐层过滤 动静分离、多级缓存、限流削峰
一致性与性能的矛盾 读写分离,最终一致 分层校验、缓存抗读、数据层保写
可用性与成本的矛盾 隔离兜底,有损服务 故障域隔离、多级降级、弹性伸缩

将这些设计决策提炼为几条通用的设计原则:

原则一:将请求拦截在离用户最近的地方。 每多一层穿透,系统付出的代价都是指数级增长的。能在 CDN 解决的不到 Nginx,能在 Nginx 解决的不到应用层,能在应用层解决的不到数据层。

原则二:区分读写路径,分别优化。 读路径追求吞吐量,允许适度的数据不一致;写路径追求正确性,必须保证最终一致。两条路径的优化策略和 trade-off 完全不同。

原则三:隔离是最有效的保护。 无论是系统隔离、数据隔离还是部署隔离,目的都是限制故障的爆炸半径。在一个足够大的分布式系统中,故障不是"可能发生",而是"一定发生"。

原则四:可用性是一个组织问题,不仅是技术问题。 稳定性在平时不紧急、出了问题就致命。如果没有组织层面的保障——将稳定性指标纳入绩效、建立专项稳定性团队、定期进行攻防演练——再好的技术方案也会在业务压力下被逐步侵蚀。

一个秒杀系统的设计,可以根据不同级别的流量,由简单到复杂构建出不同层次的架构。没有一种方案适用于所有场景,选择何种架构取决于业务规模、团队能力和成本约束。但无论规模大小,以上设计原则和思考维度是通用的——它们不仅适用于秒杀,也适用于任何需要应对极端工况的分布式系统。

加载导航中...

评论