大厂实践
开篇:理论之外,大厂是怎么做的?
前面两章我们聊了高并发和高性能的理论和通用方案。但理论终归是理论,落到具体的业务场景中,每一个细节都会变得棘手得多。
这一章我们聊几个大厂的真实架构案例 -- 秒杀系统、红包系统、火车票系统。看看这些系统在面对极端流量时,是如何把理论落地的。
一、秒杀系统设计
1.1 秒杀的核心挑战
秒杀的本质是:大量用户在极短时间内抢购极少量商品。 这意味着系统要同时面对两个极端 -- 读请求的洪峰(所有人都在刷页面)和写请求的洪峰(所有人同时点击购买)。
秒杀系统的三大目标:
- 高可用 -- 系统不能被流量打垮
- 数据一致 -- 绝对不能超卖(卖出去 1000 件但库存只有 100 件)
- 高性能 -- 尽可能让更多用户完成购买
1.2 设计原则:把请求挡在最外面
秒杀系统的设计有一个黄金法则:让请求尽量少地到达核心系统。 100 万人抢 100 件商品,只有 100 个人需要真正走到"扣库存 → 创建订单"这一步,其余 99.99% 的请求应该在外围就被拦截掉。
1.3 核心技术方案
动静分离
秒杀页面中,商品图片、CSS、JS 这些都是静态内容,所有用户看到的都一样。把这些静态资源推到 CDN 上,用户请求直接从最近的 CDN 节点返回,根本不需要回源到服务器。
只有倒计时、库存数量、购买按钮等动态内容才需要从服务器获取。这样就能把绝大部分流量挡在 CDN 这一层。
请求削峰
秒杀的流量特点是瞬时脉冲 -- 零点一到,所有人同时点击。如果让这些请求直接打到后端,再强的服务器也扛不住。
削峰的常用手段:
- 答题/验证码 -- 让用户多花几秒做一道题或拖一个滑块,天然地把请求打散到几秒内。这也是防止机器人刷单的有效手段。
- 消息队列 -- 请求先进入消息队列排队,后端按自己的处理能力匀速消费。
- 随机延迟 -- 前端在点击购买后随机延迟 0-2 秒再发请求,也能起到分散流量的作用。
热点数据隔离
秒杀商品通常就那么几款,但它们的访问量可能是普通商品的一万倍。如果和普通商品混在一起,热点数据会拖垮整个系统。
解决方案是把秒杀商品的数据单独隔离 -- 独立的缓存、独立的数据库、甚至独立的服务集群。就算秒杀系统挂了,也不影响正常的购物体验。
库存扣减方案
库存扣减是秒杀系统的核心难题。常见方案有三种,适用于不同量级:
方案一:数据库直接扣减
最简单直接,用数据库的行级锁保证不超卖:
UPDATE inventory SET quantity = quantity - 1
WHERE sku_id = ? AND quantity > 0;quantity > 0 这个条件天然防止了超卖。但原生 MySQL 在热点行更新场景下,几百 QPS 就会把 CPU 打满。
方案二:Redis 预扣减 + 异步落库
把库存放到 Redis 中,用 DECR 命令做原子扣减。扣减成功后发消息到队列,异步更新数据库。
DECR inventory:sku_1001
如果返回值 >= 0 → 扣减成功,发消息异步创建订单
如果返回值 < 0 → 库存不足,INCR 回去恢复速度极快(Redis 单机可以扛 10 万+ QPS),但需要处理 Redis 和数据库之间的数据一致性问题。
方案三:分桶 + 缓存 + 数据库组合
对于几百万 QPS 的极端场景,单个 Redis key 也可能成为瓶颈。这时候可以把库存拆分成多个桶 -- 比如 1000 件库存拆成 10 个桶,每个桶 100 件,分散到 10 个 Redis 节点上。
这种方案的并发能力是单桶的 N 倍,但需要处理分桶间的调度问题 -- 比如某个桶卖完了但其他桶还有余量,需要有一个调度机制来平衡。
1.4 大厂秒杀方案全景
实际上在大厂内部,不同业务线会根据自己的并发量级选择不同的方案:
| 并发量级 | 典型方案 | 适用场景 |
|---|---|---|
| 千级 QPS | MySQL + Inventory Hint | 小规模促销 |
| 万级 QPS | Redis 预扣减 + 异步落库 | 本地生活类电商 |
| 十万级 QPS | 分桶 + 近端缓存 | 大型电商大促 |
| 百万级 QPS | 全套组合拳 | 双 11 核心场景 |
关于 MySQL 抗秒杀 -- 大厂内部使用的 MySQL 并不是社区原版。比如阿里的 RDS 提供了 Inventory Hint 补丁,可以识别热点行和热点 SQL,对同一行的更新做分组:
- 减少行级锁的申请等待 -- 同组内的 SQL 共享一把锁
- 减少 B+ 树索引遍历 -- 第一条 SQL 定位后缓存位置,后续 SQL 直接用
- 减少事务提交次数 -- 同组 SQL 做一次组提交
原始 MySQL 热点行更新几百 QPS 就顶不住了,加了这个补丁后可以扛到几千甚至上万 QPS。
小红书的做法更进一步 -- 他们在 MySQL 内核中实现了"合并秒杀"优化。核心思路是选出一个 Leader 线程,收集一段时间内所有的更新操作,在缓存中合并扣减,最后一次性写入存储引擎。在 128 线程秒杀场景下,TPS 从 4276 提升到 23543,提升约 5.5 倍。
选哪种方案?一句话:适合的才是最好的。 复杂度和并发量成正比,杀鸡不用牛刀。
二、红包系统设计
2.1 业务特点
微信红包、支付宝集五福 -- 这类系统有几个特殊之处:
- 瞬时并发极高:春节零点可能有几亿人同时抢红包
- 金额必须精确:一分钱都不能多也不能少,红包金额总和必须等于发出的金额
- 公平性:不能因为网络快慢影响抢到的概率
2.2 关键设计
拆红包算法
"二倍均值法"是最常用的拆红包算法:
每次拆红包时:
剩余金额 = totalAmount
剩余人数 = totalCount
当前红包金额 = random(0.01, 剩余金额 / 剩余人数 * 2)比如 10 元红包分给 5 个人:
- 第 1 个人:random(0.01, 10/5*2) = random(0.01, 4.00),假设抽到 2.5 元
- 第 2 个人:random(0.01, 7.5/4*2) = random(0.01, 3.75),假设抽到 1.8 元
- ......以此类推
这个算法保证了每个人抽到的期望金额是相等的(公平),同时结果有随机性(有趣)。
预生成 vs 实时计算
- 预生成:发红包时就把每份金额算好存入数据库。抢红包时只需要按顺序取一份。缺点是发红包时计算量大,优点是抢红包时极快。
- 实时计算:抢红包时才计算金额。灵活但抢的瞬间计算压力大。
大多数系统选择预生成 -- 发红包的频率远低于抢红包的频率,把计算量放在低频操作上更合理。
并发控制
抢红包的核心操作是"取一份" -- 这里必须保证同一份红包不会被两个人同时抢到。
常见做法是用 Redis 的 List 结构存储红包:
RPUSH red_packet:1001 "2.50" "1.80" "3.20" "1.50" "1.00"抢红包时:
LPOP red_packet:1001 -- 原子操作,取出一份LPOP 是原子操作,天然保证了并发安全。
2.3 红包系统的异步化
抢红包的核心链路只做两件事:取一份红包 + 记录领取信息。至于真正的入账(把钱加到用户余额里),可以异步处理。这样核心链路的 RT 极短,用户体验好,也降低了数据库的瞬时压力。
2.4 红包系统的分层架构
一个完整的红包系统通常分为三层:
关键设计决策:
- 发红包 -- 拆分金额后存入 Redis List,同时在 MySQL 记录红包元信息(发送人、总金额、份数、过期时间)
- 抢红包 -- 从 Redis List 中 LPOP 一份,写入领取流水。真正的资金操作通过消息队列异步完成
- 红包过期 -- 定时任务扫描过期红包,将未领取金额退回发送人账户
- 对账 -- 每天跑对账任务,确保 Redis 中的领取记录和 MySQL 中的流水完全一致
这里有一个有趣的设计取舍:为什么用 Redis List 而不是直接用数据库?因为抢红包的瞬时并发极高(春节零点可能有数亿请求),数据库的行级锁根本扛不住。而 Redis 的 LPOP 是单线程原子操作,不存在锁竞争,性能可以到十万级 QPS。
三、火车票系统设计
3.1 为什么火车票比秒杀更难?
乍一看,火车票抢购和秒杀很像 -- 都是大量用户抢少量资源。但火车票的库存管理比普通商品复杂得多。
一张从北京到上海的火车票,中间经停济南、南京等站。一个座位可以卖给"北京-上海"的旅客,也可以分段卖给"北京-济南"和"济南-上海"两个旅客。库存不是简单的"有几张票",而是一个区间库存的问题。
同一个座位 001:
- 旅客甲买了"北京→济南",这个座位在济南→上海段还能卖
- 旅客乙买了"济南→上海",和旅客甲共享同一个座位
这就意味着每卖出一张票,都要同时更新多个区间段的库存。复杂度远超普通商品的库存扣减。
3.2 核心设计方案
区间库存模型
用一个二维矩阵来表示每个座位在每个区间段的可用情况:
| 座位 | 北京-济南 | 济南-南京 | 南京-上海 |
|---|---|---|---|
| 001 | 旅客甲 | 可用 | 可用 |
| 002 | 可用 | 旅客乙 | 旅客乙 |
| 003 | 可用 | 可用 | 可用 |
当有人要买"北京→上海"的全程票时,需要找到一个在所有区间段都可用的座位(比如 003)。
当有人要买"济南→上海"的票时,需要找到在"济南-南京"和"南京-上海"两个区间段都可用的座位(比如 001)。
分层过滤
火车票系统同样采用分层过滤的思路减少到达核心系统的请求:
- 前端限制 -- 查询频率限制,防止用户疯狂刷新
- CDN 缓存 -- 车次信息、站点信息等静态数据走 CDN
- 读缓存 -- 余票查询走缓存,不直接查数据库
- 排队机制 -- 提交订单后进入排队队列,按顺序处理
- 限流降级 -- 极端情况下降级部分查询功能
异步出票
用户提交购票请求后并不会立即出票,而是进入一个排队系统。系统按照提交顺序依次处理,处理完成后通过通知告知用户结果。
这就是为什么你在 12306 上点了"提交订单"之后,会看到一个排队等待的页面 -- 系统正在队列中依次处理所有请求。
3.3 12306 的演进
12306 在早期因为性能问题被用户疯狂吐槽。后来经过多次架构升级:
- 引入了 CDN 和多级缓存,大幅降低了查询的 RT
- 实现了排队机制和异步出票,避免了瞬时请求击穿数据库
- 对核心的余票查询和扣减做了内存化处理
- 引入了候补购票机制,把"抢"变成了"排队等",从根本上降低了瞬时并发
这个案例告诉我们:有时候最好的技术方案不是用更强的服务器来硬扛流量,而是通过产品设计来改变流量的形态。
3.4 从 12306 学到的架构启示
12306 的架构演进给了我们几个重要的启示:
- 技术方案要配合产品设计 -- 候补购票把"抢票"变成"排队",从产品层面降低了并发压力,比任何技术优化都有效
- 分阶段演进 -- 不可能一步到位设计出完美架构。12306 从单体 → 分布式 → 多级缓存 → 异步出票,每一步都是根据实际问题驱动的
- 核心链路极简 -- 查询走缓存、下单走队列、出票异步化,核心链路上的每一步都力求最简
- 容量规划要留余量 -- 春运的流量是平时的数十倍,系统设计必须按峰值容量来规划,同时支持弹性扩缩
四、总结:大厂实践的共同规律
看完这三个案例,会发现它们有一些共同的设计哲学:
分层过滤
不管是秒杀、红包还是火车票,核心思路都是让请求在最外层就被拦截。通过 CDN、缓存、限流、验证码等手段,让真正到达核心系统的请求只占总请求的 0.1% 甚至更少。
异步解耦
核心链路只做最少的事情,快速返回结果给用户。耗时的操作(入账、生成订单详情、发通知等)全部异步化。
热点隔离
把热点数据和热点服务独立出来,和普通业务隔离。秒杀商品独立部署,热点红包独立缓存。这样即使热点系统出问题,也不影响正常业务。
最终一致性
在极端并发下,追求强一致性的代价太高。大多数系统选择在核心操作上保证一致性(不超卖、不多发),但在非核心操作上接受短暂的不一致(余额延迟更新、订单详情延迟生成),通过异步对账来最终保证一致。
方案适配
最后也是最重要的一点 -- 没有"最好的方案",只有"最适合的方案"。
| 并发量 | 典型方案 | 复杂度 |
|---|---|---|
| 千级 | 数据库 + 简单缓存 | 低 |
| 万级 | Redis + 消息队列 | 中 |
| 十万级 | 分桶 + 多级缓存 | 高 |
| 百万级+ | 全套组合拳 | 极高 |
复杂度和并发量成正比。用三百万的方案来解决三千并发的问题,是过度设计;用三千的方案来抗三百万并发,是自不量力。根据实际的业务形态、并发量级、团队能力和基础设施来选择方案,才是真正的架构智慧。