课程三:消息队列
课程三:电商秒杀系统架构演进案例
目标:电商秒杀是极具挑战性的高并发场景,往往是检验系统架构能力的"试金石"。本课程将带你经历一个秒杀系统的逐步优化过程,深入理解和掌握瞬时高并发应对、库存一致性保证、分布式事务处理、以及限流防刷对抗等核心架构难题。
阶段 0:初始系统(最简单的秒杀尝试)
系统描述
- 功能非常直接:
- 用户可以浏览特定商品。
- 在指定时间点(如上午 10:00)参与限量抢购(比如 100 台 iPhone)。
- 点击"抢购"按钮后,系统尝试扣减库存,成功则生成订单。
- 技术栈:依旧简单:
- 前端:一个静态页面,加个 JavaScript 倒计时。
- 后端:Spring Boot(单体应用),所有逻辑揉在一起。
- 数据库:MySQL(单机单表搞定商品信息、库存和订单)。
当前架构图
此刻的特点: - 秒杀逻辑很"朴素":查询库存 → 判断库存 > 0 → 更新库存 (-1) → 创建订单。这一切都在一个数据库事务里完成。 - 致命问题:设想一下,1000 人甚至更多用户在 10:00:00 准时点击"抢购"按钮,争夺那 100 台 iPhone。会发生什么? 大量的请求瞬间涌入,数据库连接池瞬间被打满,CPU 飙升,最终数据库可能直接崩溃,所有人都抢不到。阶段 1:直面瞬时高并发 → 页面静态化 + 缓存扛流量
挑战浮现
- 秒杀开始的刹那,QPS (每秒请求数) 可能从平时的几十飙升到数万甚至十万。数据库连接池瞬间耗尽,根本无法响应。
❓ 架构师的思考时刻:第一波洪峰如何挡住?不能让流量直接打到数据库!
(前端能做些什么?核心瓶颈在哪?缓存是万能药吗?)
✅ 演进方向:动静分离,核心逻辑前置到缓存
- 前端页面静态化:
- 秒杀活动页面(包含商品信息、倒计时等)应尽可能做成纯静态的 HTML/CSS/JS。
- 将这些静态资源提前推送到 CDN 节点。这样,绝大部分浏览请求由 CDN 处理,不会到达后端服务器。
- 库存预热到缓存:
- 秒杀开始前,将秒杀商品的库存数量加载到 Redis 中(例如
SET seckill:iphone:stock 100
)。后续的库存扣减操作直接在 Redis 中进行。
- 秒杀开始前,将秒杀商品的库存数量加载到 Redis 中(例如
- 利用 Redis 原子操作扣库存:
- 抢购请求到达后端时,不再查询数据库库存,而是直接操作 Redis 库存。
- 必须使用 Redis 的原子操作(如
DECR
命令或更复杂的 Lua 脚本)来扣减库存,确保操作的原子性,避免并发导致超卖。 - 一个简单的 Lua 脚本示例:
架构调整:
[用户浏览器] ----> [CDN (静态秒杀页)]
↘ [Web 服务器 (接收抢购请求)] → [Redis (原子扣库存)] → [后续异步处理...]
↳ [MySQL (用于订单持久化)]
阶段 2:遭遇"羊毛党"和机器人 → 限流防刷是关键
新的挑战:恶意请求
- 简单的库存前置无法阻止黄牛和机器人。他们使用脚本以远超正常用户的频率疯狂请求秒杀接口。
- 单个 IP 或用户在短时间内发起成千上万次请求,不仅占用了服务器资源,也让正常用户几乎没有机会抢到。
❓ 架构师的思考时刻:如何识别和拦截这些异常流量?
(IP 限流?用户限流?验证码有用吗?如何在网关层就挡住大部分恶意请求?)
✅ 演进方向:多维度限流 + 人机识别
- 接口层限流:
- 在 API 网关层或应用入口层,实施基于 令牌桶 或 漏桶 算法的限流。
- 关键在于限流维度:不仅要限制 IP 维度,更要限制 用户 ID 维度(例如,每个用户 ID 在 10 秒内只能请求秒杀接口 1 次)。这通常需要结合 Redis 实现 (
SETNX
或 Lua 脚本)。
- 前端增加人机验证:
- 在点击"抢购"按钮前,要求用户完成 图形验证码 或 滑块验证(如 Google reCAPTCHA, hCaptcha)。这能有效拦截大部分简单的脚本。
- 隐藏秒杀接口 + 动态令牌:
- 秒杀接口地址不直接暴露在前端代码中,而是在秒杀即将开始时,由服务端动态下发一个加密的、有时效性的令牌 (Token)。用户抢购时必须携带合法的令牌,增加脚本模拟的难度。
架构调整(关注点):
阶段 3: Redis 扣库存成功了,但订单丢了? → 保证最终一致性
一致性的难题
- 现在流量挡住了,库存也在 Redis 里原子扣减了,但新的问题出现了:
- 如果在 Redis 扣减库存成功后,服务器挂了,或者后续创建 MySQL 订单失败了,那么库存就永久性地少了一件,但用户并没有得到订单(数据不一致)。
- 如果秒杀系统部署了多个实例,它们同时操作 Redis(即使是原子操作),后续的订单创建环节如何协调?
❓ 架构师的思考时刻:如何确保库存扣减和订单创建要么都成功,要么都失败?
(分布式事务?2PC 太重了。消息队列能解决吗?失败了如何补偿?)
✅ 演进方向:异步下单 + 消息队列 + 补偿机制
- 核心流程异步化:将同步的"扣库存+创订单"流程拆分为异步。
- Web 服务器在 Redis 原子扣库存成功后(这是抢购成功的关键凭证),不再立即创建 MySQL 订单。
- 而是将包含用户 ID、商品 ID 等信息的"下单请求"消息发送到 消息队列 (Kafka/RabbitMQ) 中。
- 独立的订单服务消费消息:
- 创建一个独立的 订单服务,订阅消息队列中的下单请求消息。
- 订单服务负责从队列中拉取消息,并异步地在 MySQL 中创建订单记录。
- 保证可靠性和补偿:
- 消息队列持久化:确保消息不丢失。
- 消费者幂等性:订单服务需要保证即使重复消费同一条消息,也只创建一个订单(例如,根据消息中的唯一请求 ID 或 用户ID+商品ID 做幂等判断)。
- 失败处理与补偿:如果订单服务消费消息后,因数据库问题或其他原因创建订单失败,需要有机制记录失败,并进行重试。如果最终无法创建订单,理论上需要进行库存回补(在 Redis 中
INCR
库存),但这在秒杀场景下逻辑复杂且可能引入新问题,通常优先保证订单服务的高可用和重试成功率,或者接受少量库存损失(业务层面考虑)。
- (可选) 分布式锁:对于"防止用户重复抢购"的场景(在 Lua 脚本中加 SADD 只是初步),如果需要更强的锁机制,可以考虑引入分布式锁(如基于 Redis 或 ZooKeeper),但在秒杀核心路径上使用分布式锁需要非常谨慎,因为它可能成为新的性能瓶颈。
架构调整:
graph TD
subgraph "抢购流程 (Web Server)"
A[请求到达] --> B{限流/令牌校验};
B -- 通过 --> C(Redis 原子扣库存 Lua);
C -- 成功 --> D("发送下单消息到 Kafka");
C -- 失败/售罄 --> E(返回抢购失败);
D --> F(返回排队中/抢购资格获取成功);
end
subgraph "下单流程 (Order Service)"
G["消费 Kafka 下单消息"] --> H{幂等校验};
H -- 新请求 --> I("创建 MySQL 订单");
I -- 成功 --> K(更新订单状态/通知用户);
I -- 失败 --> J("记录失败/延迟重试");
H -- 重复请求 --> L(直接忽略或返回成功);
end
subgraph "(可选)补偿流程"
J --> M("若最终失败,考虑库存回补");
end
阶段 4:订单量暴增 → 数据库也需要分库分表
海量订单的存储瓶颈
- 随着秒杀活动常态化和业务发展,
orders
订单表的数据量可能突破几千万甚至上亿。单表的查询性能(尤其是按用户 ID 查询历史订单)会变得越来越慢,影响用户体验和后台管理。
❓ 架构师的思考时刻:订单数据如何水平扩展?
(按什么维度分片最合适?用户 ID?订单 ID?时间?历史数据怎么办?)
✅ 演进方向:按用户维度分库分表 + 历史数据归档
- 按用户 ID 进行分库分表:
- 对于订单这类与用户强关联的数据,通常按 用户 ID 进行哈希分片是比较合适的策略。
- 例如:
hash(user_id) % 16
路由到 16 个库,每个库内再按user_id
哈希或取模分成 32 或 64 张表。这样可以将单个用户的订单数据聚合在同一个库或少数几个表中,方便查询。 - 同样需要 ShardingSphere 等数据库中间件来管理分片规则。
- 历史订单数据归档:
- 秒杀订单通常查询频率随时间降低。可以将超过一定时间(如 3 个月或 6 个月)的历史订单从在线 MySQL 集群迁移到成本更低的存储中。
- 方案一:迁移到 Elasticsearch,提供历史订单的查询能力。
- 方案二:迁移到 对象存储 (S3/OSS) 或廉价的数据仓库 (如 Hive/ClickHouse),用于离线分析或特定查询。
- 考虑 CQRS 模式:
- 如果读写负载差异极大,或者历史订单查询逻辑复杂,可以考虑 CQRS (命令查询职责分离) 模式。写操作依然写入分片的 MySQL,但读操作(尤其是复杂查询)可以走 Elasticsearch 或其他专门优化的读库。
架构调整(数据存储层):
在线订单存储: [MySQL 分库分表集群 (ShardingSphere 管理)]
历史订单查询: [Elasticsearch 集群]
历史订单归档: [对象存储 (S3/OSS) / 数据仓库]
阶段 5:应对大促峰值 → 弹性伸缩与熔断降级
流量波峰波谷的挑战
- 大型促销活动(如"双十一")期间的秒杀流量可能是平时的数十甚至上百倍,而非大促期间流量又相对平稳。
- 如何动态调整服务器资源以应对剧烈的流量波动?如何在极端压力下保证核心服务不崩溃?
❓ 架构师的思考时刻:如何让系统具备弹性,从容应对大促?
(只靠加机器够吗?哪些服务需要自动扩缩容?系统扛不住时怎么办?)
✅ 演进方向:拥抱云原生,实现自动伸缩与智能容错
- 容器化与 Kubernetes 自动扩缩容 (HPA):
- 将无状态的应用(如 Web 服务器、API 网关、订单服务等)进行容器化 (Docker)。
- 部署到 Kubernetes (K8s) 集群中。
- 配置 HPA (Horizontal Pod Autoscaler),让 K8s 根据 CPU 利用率、内存使用率或自定义指标 (如 QPS、消息队列积压数) 自动增加或减少服务实例 (Pod) 的数量,实现弹性伸缩。
- 服务降级预案:
- 为应对超出预期的流量峰值,需要提前准备好降级预案。
- 例如:在大促高峰期,可以临时关闭一些非核心功能,如商品评价显示、优惠券推荐等,将宝贵的系统资源留给核心的交易链路。
- 熔断机制:
- 服务间的调用必须配置熔断器(可以使用 Hystrix, Sentinel 或 Istio 的能力)。
- 当某个下游服务(如库存服务、用户服务)出现故障或响应过慢时,熔断器会快速失败,阻止请求继续涌向下游,并可以执行预设的 Fallback 逻辑(如返回"服务繁忙,请稍后重试"),防止级联失败(雪崩效应)。
架构调整(部署与容错层面):
[用户] → [CDN/WAF] → [K8s Ingress (网关层)] → [自动伸缩的秒杀核心服务 Pods (Web/Order...)]
↑ (HPA根据负载自动调整Pod数量)
│ (服务间调用有熔断器保护)
└─→ [其他依赖服务 (可能被降级)]
总结:秒杀系统架构的"千锤百炼"之路
阶段 | 核心挑战 | 关键解决方案 | 代表技术/模式 |
---|---|---|---|
0. 单体 | 无法应对并发 | (无法应对) | 事务锁争用 |
1. 抗流量 | 瞬时高并发打垮 DB | 动静分离 + 缓存原子扣库存 | CDN, Redis (Lua/DECR) |
2. 防刷 | 黄牛/机器人恶意请求 | 多维度限流 + 人机识别 + 动态令牌 | Nginx/Redis 限流, 验证码, 加密 Token |
3. 一致性 | 库存订单数据不一致 | 异步下单 + 消息队列 + 补偿机制 | Kafka/RabbitMQ, 幂等消费 |
4. 数据扩展 | 订单表容量/性能瓶颈 | 按用户分库分表 + 历史数据归档 | ShardingSphere, Elasticsearch/对象存储, CQRS |
5. 弹性容错 | 应对流量波动/雪崩 | 容器化 + K8s 自动伸缩 + 熔断降级预案 | Docker, K8s HPA, Sentinel/Hystrix/Istio |
课程设计亮点与思考
- 真实且极致的场景:秒杀场景将高并发、一致性、可用性等架构挑战推向极致,迫使我们思考各种优化手段。
- 技术的综合运用:缓存、消息队列、分布式锁(慎用)、数据库分片、限流、熔断、降级、弹性伸缩等技术在这个场景下得到了淋漓尽致的体现。
- 同步与异步的权衡:从最初的同步事务处理,到引入缓存,再到最终的异步下单,展示了在性能和一致性之间进行权衡的过程。
- 动手实践价值高:
- 可以尝试用 JMeter 或 k6 等工具模拟高并发请求,观察不同架构下的系统表现。
- 可以尝试实现 Redis Lua 脚本进行原子扣库存。
- 可以尝试使用 Arthas 等工具在线诊断 Java 应用在高并发下的性能瓶颈。
理解秒杀系统的架构演进,不仅能掌握应对高并发的技术,更能深刻体会到架构设计中层层递进、不断权衡、持续优化的核心思想。