高并发场景题(上):当流量瞬间涌进来,系统怎么扛住?
高并发系统设计题,考察的不是"背某个方案",而是面对一个流量问题,能否从需求出发,拆出瓶颈、给出架构、讲清权衡。这一篇用四个场景,练习同一条方法论:先识别瓶颈,再用"缓存 + 削峰 + 异步 + 原子化"这套组合拳去化解。每个场景末尾都带故障与一致性边界——组件挂了怎么办,这是面试官深挖的重灾区(底层逻辑见一致性与故障边界)。
秒杀:瞬间洪峰的经典样本
秒杀的难点是:平时几千 QPS,开抢瞬间几十万 QPS,库存还只有几百件。要解决三件事——不超卖、扛得住、体验好。
架构分层来看:网关层限流挡住大部分流量 → 库存预扣到 Redis(用 Lua 原子扣减,防止超卖)→ 抢到名额后把下单请求丢进 MQ 削峰,异步创建订单 → 数据库层再兜底(唯一约束/乐观锁)。
为什么库存放 Redis 而不是直接扣数据库? 数据库扛不住瞬间流量,Redis 单机 10 万+ QPS。为什么用 MQ? 把"抢到"和"下单"解耦,平滑流量尖峰。为什么用 Lua? 扣库存必须是"读-判断-写"原子操作,Redis 单命令不够(要判断库存>0 再扣),Lua 脚本在服务端原子执行。
追问"QPS 翻 10 倍怎么办":库存分片(多个 Redis key 分摊热点,如 stock:1..10 各自预扣,解决单个 key 的写热点)、网关限流更狠、MQ 消费者扩容。
故障与一致性边界:秒杀链路每个组件挂了怎么办
| 挂的组件 | 现象 | 处理 | 一致性边界 |
|---|---|---|---|
| Redis 库存 key 挂 | 扣库存失败,秒杀无法进行 | 熔断降级:直接扣数据库库存(DB 唯一约束/乐观锁兜底,扛量下降但不超卖);或直接返回"已售罄" | 库存是必须精确的数据 → 本质要 CP 级保证;Redis 只是"性能前置",DB 是最终对账依据 |
| Redis 主从切换(丢写) | 已扣的库存可能"丢"(主挂了没同步) | 对账回补:定时用 DB 实际库存与 Redis 缓存库存比对,不一致以 DB 为准重建 | Redis 扣减是"预占",DB 扣减才是"事实"——预占丢了最坏是超卖一点点,靠 DB 唯一约束兜住,再对账修正 |
| MQ 挂 | 抢到名额但下单消息发不出去 | 本地消息表兜底(业务 + 消息同事务,后台扫表补发,见一致性与故障边界 4.7) | 下单是最终一致链路:抢到 ≠ 下单成功,以消息最终送达 + 消费幂等闭环 |
| DB 挂 | 下单/扣库存失败 | 只读缓存继续(查到"已抢到"状态);写降级返回"系统繁忙" | 数据库是强一致锚点,挂了只能降级不可将错就错(不能假成功) |
深挖链:Redis 库存和 DB 库存不一致了以谁为准?→ DB(事实源)。→ 那 Redis 预扣的意义?→ 扛瞬间流量,抢到名额先占住,DB 异步落。→ 预扣丢了呢?→ 超卖一点点,DB 唯一约束兜底 + 对账修正。→ 为什么不直接全走 DB?→ DB 扛不住瞬间几十万写。
抢红包:另一个"争抢",但更随机
抢红包和秒杀都是"瞬时争抢",但多了一个随机性:一个红包拆成 N 份,N 个人抢,要保证总金额准确、并发安全、手气随机。
两个关键技术:二倍均值算法(每次抢到的金额 = 剩余金额/剩余人数 ×2 内随机,保证最后一人也有钱);预拆分(发红包时就拆成 N 份存起来,抢的时候用 Redis LPOP 原子取一份,抢时零计算、天然不重复)。
为什么要预拆分而不是"抢的时候现算":现算要"读剩余金额 → 算 → 写剩余金额",必须加锁/原子脚本,且计算在热路径上;预拆分把计算挪到发红包时,抢的时候 LPOP 一次原子弹出一个,天然不重复、不需要锁。
故障与一致性边界:红包的金额是"钱"
- Redis 红包 key 挂 / 主从丢写:已发的红包可能"看起来没了"——绝不能接受,因为金额是钱。处理:红包数据落 DB(强一致锚点),Redis 只是读取加速;丢写靠对账从 DB 恢复;
- 金额一致性要求比秒杀库存更高:库存超卖可以"抱歉退款",红包多发钱就是资损。所以红包的"总金额 = 各份之和"必须由 DB 事务/唯一约束保证,Redis 只做"原子弹出"的并发控制;
- 深挖点:预拆分的 N 份存在 Redis 一个 list,这个 key 是热点吗?→ 单个红包最多 N 个元素,抢的人有限,不是热点;真要防热点可以按份数分片。
分布式 ID:洪峰下的基础设施
秒杀、红包都会产生海量订单,需要全局唯一、趋势递增的 ID。三方案:雪花算法(时间戳+机器号+序列号,本地生成、高性能,但依赖时钟回拨)、Redis INCR(原子自增,简单但每次网络调用)、号段模式(一次取一批号段本地发)。
趋势递增的价值在于:配合 B+ 树顺序插入,减少页分裂(呼应 S1 的知识点)。
故障与一致性边界:ID 生成器挂了
- 雪花算法:纯本地生成,单机挂了不丢历史 ID(重启后从持久化的"最后 ID"继续,或容忍序列号重置 + 时间戳保证唯一);真正的风险是时钟回拨——回拨会生成重复 ID。处理:回拨时等待时钟追上 / 用 Redis 分配机器号段兜底 / 预留"回拨容忍窗口"(上次生成 ID 的时间戳记下来,回拨不超阈值则用"最后时间戳+1"续用);
- Redis INCR / 号段模式:发号器(Redis/DB)挂了 → 订单 ID 发不出来,整个下单链路停摆。处理:号段模式天然抗挂——一次取一批号段放本地内存慢慢发,发号器挂了几千个 ID 照发不误;这是"用本地冗余换可用性"的经典;
- 唯一性靠什么兜底:无论哪种方案,DB 唯一索引永远是最后防线(重复 ID 插入直接报错,业务重试)。
缓存一致性:洪峰下的数据正确性
洪峰下必然大量用缓存,而缓存会带来"和数据库不一致"。方案是 Cache Aside(先更新库再删缓存)+ 延迟双删(覆盖删缓存后有人回填旧值的窗口)+ 订阅 binlog(删缓存失败也能靠 Canal 最终一致)。
要诚实地说:缓存和数据库做不到强一致,只能最终一致(底层原因见一致性与故障边界 三、——跨组件无法用单机事务,只能靠"删缓存 + 异步补偿"收敛)。
故障与一致性边界:缓存链路的组件挂了
| 挂的组件 | 现象 | 处理 |
|---|---|---|
| 删缓存失败 | 缓存里是旧值,DB 是新值 | 重试删除 + binlog 订阅兜底(Canal 监听 DB 变更删缓存);仍失败 → 靠缓存 TTL 自然过期(把 TTL 当最后兜底) |
| binlog 订阅(Canal)挂 | 缓存失效无人删 | 监控告警 + 缓存 TTL 兜底;对账任务扫描"DB 版本 > 缓存版本"的 key 修复 |
| Redis 缓存全挂(雪崩) | 请求全打到 DB | 多级缓存(本地 Caffeine 兜底)+ 限流 + DB 连接池保护;DB 也扛不住就降级返回旧缓存快照 |
| DB 挂 | 写失败 | 读可以继续走缓存(旧值);写必须失败或进本地消息表重试——绝不能"只写缓存假成功" |
深挖链:先更新 DB 还是先删缓存?→ 先更新 DB 再删缓存(先删缓存可能并发读回填旧值)。→ 删缓存失败怎么办?→ 重试 + binlog 订阅 + TTL 兜底。→ 能保证强一致吗?→ 不能,只能最终一致,窗口内读旧值,靠 TTL 收敛。→ 那业务怎么接受?→ 一致性要求高的数据(库存/余额)不走缓存或短 TTL,可容忍的(列表/详情)走缓存。
串起来
四个场景共享同一条主线——洪峰来了,用分层限流、Redis 原子操作、MQ 削峰、分布式 ID、缓存一致性这套组合去化解;同时每个组件都有"挂了怎么办"的边界:强一致锚点(DB/唯一约束)永远在,Redis/MQ 只是加速与削峰,丢了靠对账回补。下一篇继续这条主线,处理"分布式环境下怎么保证只执行一次、只允许那么多"——分布式锁、限流、幂等、消息可靠性。