# 第 36 章 场景设计题的答题框架


> 把前三十五章的散点知识组装成可复用的作答流程：先澄清、再估算、后设计，最后主动暴露瓶颈与取舍。

## 学习目标

读完本章，你应当能够：

1. 说出设计题的七个评分维度，并判断自己上一轮回答在哪个维度丢分；
2. 按六步框架完成一道设计题，并给出每步的时间分配与话术；
3. 独立推导 `QPS`、存储量、带宽、连接数，说明数量级算错为什么比不估算更糟；
4. 画出秒杀六层防线图，推导超卖与少卖各自的根因与补救，并在短链、排行榜、Feed 流、在线人数四类题上给出被牺牲的代价。

### 前置知识检查

| 你需要知道 | 在哪一节补齐 |
|---|---|
| 吞吐、延迟与 Little 定律、缓存三件套与缓存一致性 | 第 3 章性能工程、第 27 章 Redis 原理与缓存架构 |
| 削峰、消费幂等、延迟消息、分布式 ID 六种方案与时钟回拨 | 第 33 章 MQ、第 34 章分布式理论 |
| 熔断三态、限流算法、降级分层、接口幂等落地 | 第 34、35 章 |

## 36.1 面试官在评什么：七个评分维度

| 评分维度 | 面试官在观察什么 | 低分信号 | 加分信号 |
|---|---|---|---|
| 需求澄清 | 是否主动缩小问题空间 | 直接假设日活一亿 | 问出读多写多、峰值倍数、保留期 |
| 容量估算 | 能否把需求换算成数量级 | 回避估算或算错量级 | 给出 `QPS`、存储、带宽、连接数 |
| 存储与数据结构选型 | 选型是否有依据 | "用 MySQL 存就行" | 说明为什么不是另一种，给出分片键 |
| 核心流程完整性 | 是否覆盖主链路全环 | 只画到"写数据库" | 主链路 + 异步链路 + 回调链路都画 |
| 瓶颈识别与扩展 | 能否指出先崩的一环 | "加机器就能扩展" | 指出单点、热点、有状态节点 |
| 一致性与可用性取舍 | 是否意识到要放弃某一边 | 声称"既强一致又高可用" | 明确"这里最终一致，代价是……" |
| 沟通与推进节奏 | 是否可控地推进 | 沉默三分钟画图 | 每完成一步确认一次方向 |

> [!IMPORTANT]
> 前四个维度决定及格线，后三个决定"合格"还是"优秀"。而**需求澄清是整场回答里性价比最高的一步**：花 3 分钟问清约束，可以让后面 30 分钟的方向不至于全错。

> [!WARNING]
> **最常见的扣分动作是"一上来就写代码或画架构图"**，它同时暴露不澄清需求、不做估算、不区分主次。典型错误回答："这个系统我会用 Redis 做缓存、Kafka 做削峰、MySQL 做持久化，然后水平扩展。"这句话没有规模、没有取舍、没有流程；面试官追问"削峰削多少、Kafka 几个分区、消费失败怎么办"，就只能现场编。

## 36.2 六步答题框架与时间分配

| 步骤 | 内容 | 时间 | 配套话术 |
|---|---|---|---|
| ① 澄清需求 | 六个必问 | 3~5 min | "在设计之前，我想先确认几个约束……" |
| ② 容量估算 | `QPS`/存储/带宽/连接数 | 4~6 min | "按这个量级，架构等级大概落在……" |
| ③ 接口设计 | 核心接口 + 幂等键 | 4~6 min | "对外只有三个接口，其余都是内部接口" |
| ④ 存储选型 | 表结构、分片、索引 | 8~10 min | "选它是为了 X，代价是 Y" |
| ⑤ 核心流程 | 正常 + 异常，画时序 | 12~15 min | "正常流程走完，我们再看异常分支" |
| ⑥ 瓶颈与扩展 | 先崩点、扩展手段、监控 | 6~8 min | "这个设计第一个崩的地方是……" |

若面试官只给 25 分钟，按比例压缩第 ③④ 步，但**第 ①② 步不能省**。

### 36.2.1 第一步：澄清需求，六个必问

要问**会改变架构的约束**：读写比（100:1 与 1:1 是两套架构）、峰值倍数（均值定容量，峰值定弹性与限流阈值）、强一致还是最终一致（决定能否异步）、可接受延迟（200 ms 与 30 s 是同步与异步的分界线）、数据规模与保留期（决定分片与冷热分层）、是否多端多地域（决定单元化与就近接入）。

> [!TIP]
> 澄清完用一句话回述并锁定："我理解是读多写少、峰值约为均值 4 倍、允许 1 秒内最终一致、数据保留 1 年——如果这个前提不对，后面的取舍得改。"这一句能让面试官从评判者变成合作者。

### 36.2.2 第三步：接口设计与幂等性

接口只讲核心几个，重点是**每个接口的幂等键是什么**。设计题的接口清单通常不超过四个：

```text
POST /api/v1/seckill/order  幂等键 (userId, activityId, skuId)；另带 requestId 做请求级去重
    Body: { activityId, skuId, requestId }；返回 200 { orderNo, status=PENDING } 或 409 ALREADY_JOINED
GET  /api/v1/seckill/result 查询型接口天然幂等，前端轮询用它（Params: requestId）
GET  /api/v1/activity/{id}  读接口，直接走缓存
POST /api/v1/pay/callback   第三方回调，必须按 outTradeNo 幂等（对方会重复推）
```

写接口用 `requestId` 去重，回调接口用外部单号去重；两者都不能靠"先查再插"，具体落地见第 35 章。

## 36.3 容量估算：公式、算例与推导

### 36.3.1 四个量的算法

```text
① QPS  平均 QPS = 日请求总数 / 86400；峰值 QPS = 平均 × 峰值倍数（常见 3~5 倍）；写 QPS 要单列：只有写请求消耗数据库写入与 binlog
② 存储 日增 = 日增条数 × 单条字节 × 放大系数；放大系数含索引（1.3~1.5 倍）、副本（1 主 2 从即 3 倍）、InnoDB 页 16 KB 的行开销与填充率（实测 50%~70%）；总量 = 日增 × 保留天数
③ 带宽 出向 = 读 QPS × 平均响应大小 × 8（× 8 是转 bit/s）；入向 = 写 QPS × 请求大小 × 8；注意读放大：一次页面请求往往触发 N 次后端调用
④ 连接数 用 Little 定律反推：L = λ × W（λ 为峰值 QPS，W 为平均处理耗时/秒）；连接池 / 工作线程数 ≥ L，并留 1.5~2 倍余量应对抖动
```

> [!IMPORTANT]
> **估算的目的是决定架构等级，不是算准。** 算出 `QPS` 是 8000 还是 12000 无关紧要——两者都指向"单机 MySQL 扛不住写，需要缓存 + 分片"。但把日活 1000 万的 `QPS` 说成 20 万（量级错 100 倍），面试官会立刻判定你没有容量意识。**数量级算错比完全不估算更糟**：不估算只是没加分，算错是负分。

### 36.3.2 完整算例

题目：日活 1000 万、人均 20 次请求、每条记录 1 KB、日增 2000 万条。

```text
【QPS】日请求总数 = 1000 万 × 20 = 2 亿 ⟹ 平均 QPS = 2 亿 / 86400 ≈ 2300（验算 ✓）
  峰值 QPS = 2300 × 4 ≈ 1 万（倍数取 3~5 倍；若业务有整点抢购这类明显定点，应按"峰值分钟请求数 / 60"反算，不能套 4 倍）
【存储】日增裸数据 = 2000 万 × 1 KB = 20 GB；含索引 1.5 倍 → 30 GB/天
  逻辑数据年增 ≈ 7.3 TB，含索引 ≈ 10 TB，1 主 2 从物理占用 ≈ 30 TB — 单机放不下，必须分片
【带宽】设读:写 = 10:1，先算总峰值再拆，不能把 10 倍直接乘在均值上
  峰值读 QPS ≈ 9200 × 10/11 ≈ 8360，峰值写 ≈ 840；出向 = 8360 × 2 KB × 8 ≈ 134 Mbps，入向 = 840 × 1 KB × 8 ≈ 6.7 Mbps → 出向是瓶颈，静态内容与图片必须上 CDN
【连接数】W = 200 ms = 0.2 s，λ = 9200 ⟹ L = λ × W = 1840，即瞬时约 1840 个请求在处理中。若数据库连接池只配 200，其余在池外排队、延迟被放大一个数量级以上——"线程数与连接池怎么定"的答案：由 L 与下游承载能力共同约束，不是拍脑袋
```

> [!NOTE]
> 面试官追问的下一层通常是："`W` 变大了怎么办？"这正是过载保护存在的理由。`W` 随排队增长而增长，`L` 也随之增长，形成正反馈；系统必须在 `L` 超过阈值时主动拒绝（限流、熔断）把 `W` 压回去。所以限流阈值应按"下游能承受的并发"反推，而不是按机器 CPU 反推。

## 36.4 综合案例：秒杀系统的六层防线

秒杀是全书知识的汇合点：它同时用到限流（第 34 章）、Redis 原子操作（第 27 章）、MQ 削峰（第 33 章）、数据库扣减与唯一索引（第 23、24 章）、幂等（第 35 章）。

```text
① 前端 → ② 网关 → ③ 库存预热 → ④ Redis 原子扣减 → ⑤ MQ 削峰 → ⑥ 数据库最终扣减
① 前端：按钮点击后置灰（最廉价的过滤）；答题/验证码拉高机器人成本；静态资源走 CDN；本地倒计时，活动未开始不发请求
② 网关：活动维度令牌桶 + 用户维度 + IP 维度三重限流；黑名单与设备指纹防刷；库存已空的请求直接 302
   到售罄页。效果：把 10 万 QPS 压到后端可承受的 1 万
③ 库存预热：活动前把库存写入 Redis（权威数据源仍是 DB）；预热必须幂等，重复执行不能把库存翻倍；
   预热失败要告警并拒绝开抢，不能带着旧库存开卖
④ 原子扣减：Lua 脚本把"查重 + 判断余量 + 扣减"合并成一个原子步；禁止"先 GET 判断再 DECR"（两步
   之间有 TOCTOU 窗口）；失败直接返回售罄
⑤ MQ 削峰：下单请求入队，峰值写流量摊平到下游能消费的速率；前端不等待，改为轮询 result 接口或
   服务端推送；分区键用 skuId 保序，消费端按业务键幂等
⑥ 数据库最终扣减：UPDATE stock SET qty = qty - 1 WHERE sku_id = ? AND qty > 0，用影响行数判断
   成败（0 行 = 售罄，回滚 Redis 占位）；唯一索引 (user_id, activity_id, sku_id) 兜住重复单
```

### 36.4.1 为什么不能用"先查后减"

```text
库存 Redis 值为 1，两个请求并发到达，用"先查后减"：
  时刻  请求 A       请求 B       库存值
   t1   GET → 1                     1
   t2                GET → 1         1
   t3   判断 1>0 成立                1
   t4                判断 1>0 成立    1
   t5   DECR/SET 0                   0
   t6                DECR/SET -1    -1
  结果：卖出 2 件，库存 1 件 → 超卖 1 件
根因：GET 与 DECR 是两次独立往返，中间存在时间窗口。Redis 单线程保证的是"单条命令原子"，
      不是"两条命令之间不被插入"——这就是检查后使用（time-of-check to time-of-use, TOCTOU）竞态。
```

```lua
-- seckill_deduct.lua：Redis 执行脚本期间不处理其他命令，下面三步对外是一个原子操作，这是消除
-- TOCTOU 窗口的手段。单命令 DECR 也原子，但无法同时写占位记录。
local userId = ARGV[1]
-- 同一用户的重放必须直接拒绝，否则"扣库存成功但请求被重试"会凭空吃掉一件库存
if redis.call('SISMEMBER', KEYS[2], userId) == 1 then return -2 end   -- 已参与过，幂等返回
local stock = tonumber(redis.call('GET', KEYS[1]) or '-1')            -- KEYS[1]=库存 key
if stock <= 0 then return -1 end                                      -- 售罄
redis.call('DECR', KEYS[1]); redis.call('SADD', KEYS[2], userId)      -- KEYS[2]=去重集合
return stock - 1             -- 返回剩余库存，供前端展示
```

> [!CAUTION]
> 用 `DECR` 再判断返回值（返回 `< 0` 就 `INCR` 回补）功能上也能防超卖，但**必须主动说出代价**：回补本身是一次新写入，若回补失败（进程崩溃、网络分区）就会少卖，且负数区间会让前端读到负库存。Lua 脚本不存在回补窗口。能说出这一层对比才是加分项。

### 36.4.2 超卖与少卖的完整分析

```text
【超卖】卖出量 > 库存量
  根因：并发扣减非原子（"先查后减"，见上一节时序）；多实例各持本地库存副本各扣各的 → 可售总量被放大 N 倍；库存在本地缓存与 Redis 各一份却只更新一处
  修复：单一扣减入口 + 原子脚本 + 所有实例共用同一份 Redis 库存
【少卖】卖出量 < 库存量：库存被扣掉却没生成有效订单
  根因：Redis 扣减成功但 MQ 投递失败（网络抖动、broker 不可用），扣了库存又没建订单且无人回补；投递成功但消费端落库失败且重试耗尽；用户下单后超时未支付而占用到期无人释放；Redis 故障重启，未持久化的扣减丢失，用户侧却已看到"抢购成功"
  修复：扣减与投递之间用本地消息表 / 事务消息，保证"扣减 ⟹ 一定投递"；消费端失败重试 + 死信队列兜底；未支付订单用延迟消息在超时时刻回补（见第 33 章）；Redis 侧开启 AOF everysec 或使用可持久化部署
```

对账与补偿是面试官最容易追问到底的一层。三条守恒式：`Redis 余量 + 已占用未支付 + 已支付 = 总库存`，不等时差值为负要**先停止新扣减再告警**、差值为正才按最小粒度补回；`Redis 占位用户数 = PENDING 订单数 + SUCCESS 订单数`，缺订单触发补单、多订单按唯一索引去重；扫超时未支付订单（例如 `now − create_time > 15 min`）发回补消息并置为 CLOSED。

> [!WARNING]
> 对账任务自身的每个修复动作都必须幂等（例如回补前先写一条补偿流水并按流水号去重），否则任务重跑一次就会把库存补多——**对账系统成为新的超卖来源**，这是很常见的真实事故形态。

## 36.5 短链接系统：小题目里的大考点

需求澄清决定方案，必问三项：是否需要自定义后缀（`vanity url`）、是否需要过期时间、是否需要访问统计。**第三项直接决定 301 还是 302**。

| 发码方案 | 短码来源 | 优点 | 代价 |
|---|---|---|---|
| 数据库自增 | `id` 转 62 进制 | 绝对唯一，实现最简单 | 每次生成一次写库，单点，扩展差 |
| 号段模式 | 批量取 `id` 区间在内存分配 | 大幅降低数据库压力 | 重启浪费一段号；多实例要求号段不重叠 |
| `Redis INCR` | 计数器自增 | 性能好，天然唯一 | Redis 故障影响发号，需持久化 |
| `Snowflake` | 时间 + 机器 + 序列转 62 进制 | 无中心依赖，趋势递增 | 依赖时钟，必须处理回拨 |
| `MurmurHash` | 长链哈希取前 N 位 | 无需发号器；同链得同码（幂等） | 有冲突必须处理；短码不可控 |

哈希方案的冲突处理是追问点：取 62 进制低 6 位（`62⁶ ≈ 5.68×10¹⁰` 个空间），冲突时在长链后追加固定盐值重算，最多重试 3 次，仍冲突则降级到发号器方案。"同链得同码"是哈希方案独有的优点：同一长链重复提交不会产生多个短码。

| 状态码 | 语义 | 浏览器行为 | 对统计的影响 |
|---|---|---|---|
| `301` | 永久重定向 | 通常永久缓存，后续直接走本地 | **统计能力丢失**：后续点击不再到达服务器 |
| `302` | 临时重定向 | 不永久缓存，每次仍请求短链服务 | 每次点击都能被记录 |
| `307`/`308` | 保留原方法的临时/永久重定向 | 同 302/301，但严格保持方法与请求体 | 短链场景全是 `GET`，实践上无差别 |

```text
【302 链路】每次点击都经过服务端，统计完整：浏览器 ──GET /aB3xK──▶ 短链服务（缓存未命中则查 DB）
  ├─▶ 异步写点击日志（先入队，不阻塞跳转）    └─▶ 302 Location: 长链 ──▶ 源站
【301 链路】第 1 次点击服务端可见；第 2 次起浏览器命中本地缓存，请求根本不到服务端。结果：真实点击 100 万次，服务端只记录到第一次，数据失真
```

> [!IMPORTANT]
> 结论要说得干脆：**只要业务需要点击统计、防刷、有效期控制或事后改链接，就必须用 `302`，代价是每次点击多一次服务端往返（同机房约 0.1~0.5 ms）与一份服务端负载。** 只有纯跳转、不要任何统计、追求极致性能时 `301` 才成立。

存储用**双向索引**：短码查长链是主链路，长链查短码用于"重复提交返回同一短码"。

```sql
-- 主表：短码 → 长链。这是重定向的唯一查询路径，必须走主键
CREATE TABLE short_link (
    short_code VARCHAR(10)   NOT NULL COMMENT '62 进制短码；用 VARCHAR 而非定长，未来换方案不必搬迁表',
    long_url   VARCHAR(2048) NOT NULL,
    expire_at  DATETIME      NULL COMMENT 'NULL 表示永不过期；避免用 0 值表达"不过期"导致索引失效',
    created_at DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (short_code),
    KEY idx_long_url (long_url(255)) COMMENT '前缀索引：长链等值查重，255 字节已足够区分',
    KEY idx_expire (expire_at) COMMENT '清理任务按过期时间扫描，靠它避免全表扫'
) ENGINE = InnoDB;
-- 缓存：短码读写比通常 1000:1 以上。Redis string shade:{code} -> longUrl，TTL 与 expire_at
-- 对齐；不存在的映射用空值占位（TTL 60s）防止缓存穿透反复打库（见第 27 章缓存三件套）
```

**布隆过滤器（Bloom filter）**放在写路径：新增长链时先查过滤器判断"是否可能已存在"，不存在就直接生成新码，省掉一次数据库查重。它不支持删除，短码删除后位置仍在，会带来少量假阳性——在防重场景完全可接受，冲突仍由唯一索引兜底。

> [!NOTE]
> 追问的下一层通常是"短码不够用怎么办"和"热 key 怎么办"。前者答 62 进制位宽可扩（6 位 `5.68×10¹⁰`、7 位 `3.5×10¹²`）；后者答本地缓存 + 多级缓存，但要承认"本地缓存让过期时间不再统一"这一代价。

## 36.6 排行榜系统：Redis 数据结构的实战

`ORDER BY score DESC LIMIT 10000, 20` 在数据量大时既无法用索引完成深分页，又要为每次查询付排序成本；`ZSet`（有序集合，sorted set）用跳表 + 哈希表实现，写入与排名查询都是对数级。

| 操作 | 命令 | 复杂度 | 用途 |
|---|---|---|---|
| 写入/更新/累加分数 | `ZADD key score member` / `ZINCRBY key delta member` | `O(log N)` | 用户分数变化、点赞加分 |
| 取榜内区间 | `ZREVRANGE key start stop WITHSCORES` | `O(log N + M)` | TOP N 展示，M 为返回条数 |
| 取某人名次 / 分数 / 合并榜 | `ZREVRANK` / `ZSCORE` / `ZUNIONSTORE dest n key1 key2` | `O(log N)` / `O(1)` / `O(总元素数)` | "我的排名"无需遍历、我的分数、日榜合成周榜 |

`ZREVRANK` 是 `O(log N)` 这一点必须主动说——很多人以为要遍历整个榜才能算出"我排第几"。此外，`ZSet` 在分数相同时按**成员名称的字典序**排列，同一分数下的顺序与业务无关，解法是把时间戳编码进分数低位：

```text
编码：score = 业务分 × 10¹⁰ + (10¹⁰ − 1 − timestamp秒)，后一项使"越早到值越大"
  例：A 在 t=1000、B 在 t=2000 各得 5 分 → A.score = 5×10¹⁰ + 9999998999，B.score = 5×10¹⁰ +
      9999997999，A 排在 B 前（先到者优先）✓
  陷阱：double 只有 53 位有效精度（约 9×10¹⁵），5×10¹⁰ + 10¹⁰ ≈ 6×10¹⁰ 安全；但业务分到 10⁶
        量级再乘 10¹⁰ 就会溢出精度，此时改用业务分 × 10⁶ + (10⁶ − 毫秒偏移)
【内存估算】1000 万用户上榜（元素数超过 zset-max-listpack-entries 后不再用紧凑编码），单元素
  ≈ 成员字符串 16 B + 跳表节点(分数 8 B + 多层指针均摊 16 B) + 哈希表槽位 16 B + 对象头与分配
  对齐 40 B ≈ 100 B → 总量 ≈ 1 GB，日/周/总三榜 ≈ 3 GB
  控制手段：日榜 8 天、周榜 70 天设 EXPIRE 自动清理——主要手段；只把 TOP 10 万放进 ZSet，
  其余分数存 Hash 或落库；总榜按月归档冷数据
```

> [!TIP]
> 面试话术：考排行榜一定要主动说"分数相同时 `ZSet` 按成员字典序，所以我把时间戳编码进分数低位保证先到先得"——这一句能立刻区别于只背过 `ZADD` 的候选人。

> [!WARNING]
> `ZREVRANGE start stop` 是偏移量分页：翻页期间榜单变化（新成绩写入改变了 score）会导致重复或漏项。稳妥做法是用 `ZREVRANGEBYSCORE key (lastScore -inf LIMIT 0 20` 以上一页最后一名（最后一条）的分数作为游标。

## 36.7 分布式 ID 与全局唯一约束的落地

订单号要同时满足三个约束：**全局唯一、趋势递增（利于 B+ 树插入与范围查询）、可读（含业务语义便于排障）**。

```text
订单号结构（26 字符）：ORD + yyyyMMddHHmmss(14) + 分片号(3) + 节点号(2) + 序列号(6)
  · 业务前缀便于日志与工单里识别单据类型
  · 时间部分保证趋势递增，但时间全放高位会使同一秒的订单挤在同一 B+ 树位置，高并发写入出现
    页分裂热点——这是可读性的代价
  · 分片号是分库分表路由位，拿到订单号即可定位分片；节点号是单机多进程部署时的进程标识；序列号
    在同一毫秒内自增，溢出则自旋等到下一毫秒
时钟回拨的处理层次（见第 34 章分布式 ID 一节）：小幅回拨（< 5 ms）自旋等到下一毫秒；大幅回拨
拒绝发号并告警。绝不允许"倒着发号"——那会直接产生重复。
```

**数据库唯一索引是最后防线**，设计题里必须主动说出来。它保证"一定不重复"：即使时钟回拨、部署错误或人工操作造成重复订单号，插入也会失败而非落两条数据。

```sql
CREATE TABLE t_order (
    order_no    VARCHAR(32)   NOT NULL COMMENT '业务发号器生成，可读且趋势递增',
    user_id     BIGINT        NOT NULL,
    activity_id BIGINT        NOT NULL,
    sku_id      BIGINT        NOT NULL,
    status      TINYINT       NOT NULL COMMENT '0 待支付 1 已支付 2 已关闭，供状态机幂等判断',
    amount      DECIMAL(10,2) NOT NULL,
    created_at  DATETIME(3)   NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    PRIMARY KEY (order_no),   -- 防"重复单据"
    -- 业务幂等键：同一用户对同一活动同一商品只允许一单，与主键互补，防"重复下单"
    UNIQUE KEY uk_user_activity_sku (user_id, activity_id, sku_id),
    KEY idx_user_created (user_id, created_at)
) ENGINE = InnoDB;
-- 依赖唯一索引兜底，而不是先 SELECT 再 INSERT——先查后插在并发下同样存在 TOCTOU 窗口（与
-- 36.4.1 同源）。捕获 DuplicateKeyException 即视为幂等成功
INSERT INTO t_order (order_no, user_id, activity_id, sku_id, status, amount)
VALUES ('ORD20240101120000123000001', 10086, 9001, 5001, 0, 99.00);
```

## 36.8 Feed 流的推拉模式

| 维度 | 推模式（写扩散，fan-out on write） | 拉模式（读扩散，fan-out on read） |
|---|---|---|
| 写/读成本 | 每个粉丝写一份收件箱（写放大 = 粉丝数），读时直取收件箱 | 只写自己的发件箱，读时聚合关注列表 |

```text
【混合模式：普通用户推、大 V 拉，读时合并】
  普通用户 A 发动态（粉丝数百到数千）→ 写扩散：ZADD inbox:{粉丝uid} timestamp postId
  大 V B 发动态（粉丝千万级）→ 只写发件箱 outbox:{B}，不扩散，粉丝自己来拉；若对大 V 也
  走推模式，一条动态写一千万次收件箱即为写风暴
  粉丝 U 刷新首页三步：① ZREVRANGE inbox:{U} 0 19 → 候选集 P
                      ② 读 U 关注的大 V 的发件箱（通常只有几十个）→ 候选集 Q
                      ③ 内存按时间戳归并 P + Q，取前 20 条 → 返回
  追问"合并贵不贵"：用户关注的大 V 只有几十个，第 ② 步是个位数到两位数的查询，归并可控
【在线推、离线拉】在线走长连接实时推送；离线不投递，登录时按游标批量拉增量。动机：离线用户
  可能一周不上线，为他每次推送消耗一次写纯属浪费；判在线用网关连接表或位图。
```

> [!WARNING]
> Feed 收件箱分页同样不能用 `ZREVRANGE` 的偏移量做无限下拉：下拉期间有新动态写入会让 `score` 变化、已有元素位置偏移，**翻页出现重复或漏项**。正确做法是用上一页最后一条的 `score` 作游标：`ZREVRANGEBYSCORE inbox:{uid} (lastScore -inf LIMIT 0 20`。

## 36.9 在线人数统计：数据结构选型

| 方案 | 命令 | 内存口径 | 能力边界 |
|---|---|---|---|
| `Set` 存用户 ID | `SADD` / `SCARD` | 每个 ID 约 40~70 B（含哈希表开销） | 精确、可查"某人在不在线"，但 1 亿用户约 4~7 GB |
| `Bitmap` | `SETBIT` / `BITCOUNT` | `用户数 / 8` 字节，1 亿用户约 12.5 MB | 精确计数与精确查单人，要求 UID 为稠密整数，稀疏时浪费 |
| `Bitmap` 按 UID 取模分片 | 多 key `SETBIT` / `BITCOUNT` | 总量不变，但分散到多实例 | 解决单 key 过大与热 key |
| `HyperLogLog` | `PFADD` / `PFCOUNT` | 固定约 12 KB/key | 标准误差约 0.81%，**不支持精确查单个用户** |

```text
【Bitmap 分片设计】总用户 1 亿，切 1024 片，每片约 10 万 UID
  片号 = uid / 100000；片内偏移 = uid % 100000；单片 = 100000 / 8 = 12500 B ≈ 12.2 KB
  总内存 ≈ 1024 × 12.2 KB ≈ 12.5 MB（与不分片总量相同）；写入 SETBIT online:{片号} 偏移 1，
  统计时各片 BITCOUNT 后求和，复杂度 O(N/8) 可忽略
  三个好处：单 key 从 12.5 MB 降到 12.2 KB（避免大 key 阻塞）、读写打散到不同实例（避免热 key）、
  可按片并行统计。注意只用 Bitmap 分不清"今天登录过"与"当前在线"——需另建以最后心跳时间为 score
  的 ZSet 剔除已下线用户，并按天分 key 便于清理
【HyperLogLog 的 0.81% 误差】可接受：UV 趋势看板、运营大盘、A/B 分组规模对比；不可接受：按在线
  人数发奖励、计费、风控名单——差一人就是一次资损。PFCOUNT 是近似值，PFMERGE 合并继续累积误差
```

## 36.10 白板上的沟通节奏

1. **先说结论再展开**："这个系统分三层：接入层限流、服务层异步、存储层分片。下面逐层说。"面试官需要在 30 秒内知道你的整体判断。
2. **每做完一步确认方向**："接口设计就到这，接下来看数据模型，这样可以吗？"既给机会纠偏，也把节奏握在自己手里。
3. **主动暴露取舍**："Redis 扣减成功后投递失败就会少卖，我的取舍是接受短暂不一致、用对账兜底。"主动说缺陷远比被问出来得分高。
4. **被质疑时先复述再回答**："您的意思是问 Redis 故障时会丢多少数据，对吗？"复述避免答错题，也换来 3 秒思考时间。
5. **画图与说话交替**：沉默画三分钟架构图是低分行为；边画边解释每层存在的理由，不确定处标注"这一层我还没想清楚，先画上"。

> [!TIP]
> 时间不够时的收尾模板（30 秒内说完）："时间关系我收一下。整体方案是三层：接入层用网关限流挡掉八成无效流量；服务层用 `Redis` 原子扣减 + `MQ` 削峰；存储层用 `UPDATE ... WHERE stock > 0` 兜底并靠唯一索引防重。还有三点没展开：一是库存对账任务的频率与幂等设计；二是 `Redis` 主从切换期间的少卖窗口；三是多机房部署时的一致性方案。您有兴趣的话我可以先讲其中一点。"

## 36.11 答不全时的补救

| 情形 | 补救策略 | 具体说法 |
|---|---|---|
| 只记得结论、推不出过程 | 拆成"我确定的部分"与"我需要推理的部分" | "我确定这里必须原子，至于是 Lua 还是 `WATCH`，我需要从原子性范围推一下。" |
| 完全没接触过 | 类比迁移 | "这和 `Redis` 持久化里 RDB 与 AOF 的取舍同类：要么牺牲实时性换吞吐，要么反过来。" |
| 有理论无实战 | 承认边界 + 给推理 | "这块我没有生产调参经验，我的推理是……如果要落地，我会先压测验证这个假设。" |
| 数字记不准 | 给量级并标注口径 | "具体数字记不清，量级是几十毫秒——机械盘随机读在毫秒级，SSD 在百微秒级。" |
| 方案被指出漏洞 | 接受并修正，不硬撑 | "您说得对，主从切换确实有这个窗口。修正方案是……" |

> [!CAUTION]
> **绝对不要编造具体数字或系统行为。** 典型的崩盘回答："`ZSet` 插入是 `O(1)`"（概念错误，实际 `O(log N)`）、"`Kafka` 单分区能扛 100 万 QPS"——无从验证。面试官一旦发现你在编，后面所有陈述都会被重新打折。**说"我不确定，我的推理是……"永远比编一个数字安全。**

## 36.12 设计题知识索引

| 设计题里的能力 | 需要的知识点 | 本书出处 |
|---|---|---|
| 防线 ①② 前端与网关 | 按钮置灰、验证码、CDN 分流、限流四算法、防刷黑名单 | 第 19 章 DNS 与 CDN、第 34 章限流、第 35 章网关 |
| 防线 ③④ 库存与扣减 | Redis 原子命令、Lua 脚本、持久化与主从 | 第 27 章 Redis 原理与缓存架构 |
| 防线 ⑤⑥ 削峰与落库 | 削峰填谷、分区顺序、消费幂等、延迟消息、`UPDATE ... WHERE stock > 0`、唯一索引、行锁 | 第 33 章可靠三件套、第 23 章索引、第 24 章锁 |
| 幂等与去重、熔断降级 | `requestId` 去重、唯一索引、状态机、`SETNX`、`Redlock` 争议、熔断三态、隔离策略、降级层次 | 第 35 章接口幂等与治理、第 33 章 MQ 视角、第 27/34 章分布式锁 |
| 分布式 ID 与一致性 | 六种方案、时钟回拨、号段、`Snowflake`、CAP/PACELC、BASE、柔性事务 | 第 34 章 分布式理论 |
| 容量与数据结构选型 | `QPS` 换算、Little 定律、延迟数量级、`ZSet`/`Bitmap`/`HyperLogLog`/布隆过滤器 | 第 3 章性能工程、第 27 章 Redis |

## 本章小结

| 关键结论 | 一句话表述 | 常见错误 |
|---|---|---|
| 评分维度先于方案 | 先澄清、再估算，方案才有靶子 | 一上来画架构图或写代码 |
| 估算决定架构等级 | `QPS ≈ 2300` 与 `≈ 10000` 都指向缓存 + 分片 | 日活 1000 万却给出 20 万 `QPS` |
| 原子扣减消除 TOCTOU | 判断与扣减必须在一次原子操作里 | 先 GET 后 DECR 导致超卖 |
| 少卖来自"扣了没下单" | 扣减与投递之间必须可靠 | 只防超卖，不设计回补与对账 |
| 统计场景必须 `302` | `301` 被浏览器永久缓存，统计失真 | 只答"301 性能更好"而不提统计 |
| 排行榜要点在同分 | 时间戳编码进分数低位保证先到先得 | 直接 `ZADD` 业务分，忽略字典序 |
| 唯一索引是最后防线 | 发号器"尽量不重复"，索引"一定不重复" | 只讲 `Snowflake`，不提数据库约束 |
| 在线人数看查询需求 | 精确查询用 `Bitmap`/`Set`，大盘用 `HyperLogLog` | 用 HLL 去做发奖励的精确判断 |
| 沟通与收尾同样计分 | 主动说取舍，时间不够就说清没展开的点 | 沉默画图、草草收尾或编造数字 |

## 自测题

**Q1**（计算）日活 5000 万、人均 12 次请求、读:写 = 20:1、平均响应 3 KB、平均处理耗时 150 ms、峰值倍数取 4。求峰值读 `QPS`、峰值写 `QPS`、出向带宽、所需处理并发数，并说明该规模对应的架构等级。

<details><summary>答案与解析</summary>

```text
日请求总数 = 5000 万 × 12 = 6 亿；平均 QPS = 6 亿 / 86400 ≈ 6944；峰值 QPS = 6944 × 4 ≈ 27778 → 约 2.8 万
读:写 = 20:1 ⟹ 读占 20/21，写占 1/21：峰值读 QPS ≈ 27778 × 20/21 ≈ 26455，峰值写 QPS ≈ 1323
出向带宽 = 26455 × 3 KB × 8 ≈ 635 Mbps；处理并发 = λ × W = 27778 × 0.15 ≈ 4167
```

写 `QPS` 仅约 1300，单主库能扛，**不需要为写分库**；读 `QPS` 2.6 万必须靠缓存与读写分离；出向 635 Mbps 说明静态与图片必须进 CDN。**错在哪**：把读写比用反（把 20:1 当成写多），或忘了"先乘峰值倍数再拆读写"。**面试官会追问**："这 4167 的并发怎么落到线程池和连接池上？"答法是用 Little 定律给出 `L`，说明连接池至少要覆盖 `L` 且留余量，同时强调超过下游承载能力时必须限流而不是加线程。

</details>

**Q2**（设计）秒杀结束统计发现：库存 1000 件，实际生成订单 1003 张。给出两条以上根因，并说明如何定位、如何修复。

<details><summary>答案与解析</summary>

超卖 3 件，根因候选：扣减非原子（存在"先查后减"的 TOCTOU 窗口）；多实例各持本地库存副本各自扣减；库存在本地缓存与 `Redis` 各一份却只更新一处；数据库最终扣减没用 `WHERE qty > 0` 而是先查后更新。

定位与修复：核对 `Redis` 扣减路径与 Lua 脚本版本，确认是否存在非原子分支；按 `created_at` 取出超出的订单，看 `orderNo` 是否落在同一毫秒、同一分片——**同毫秒聚集就是并发窗口的直接证据**；比对 `Redis` 扣减流水条数与数据库订单条数。修复则把判断与扣减合并进单个 Lua 脚本、库存只保留一份权威副本、数据库改为 `UPDATE stock SET qty = qty - 1 WHERE sku_id = ? AND qty > 0` 并按影响行数判断成败，再加对账任务持续校验库存守恒。**错在哪**：只答"加分布式锁"而不定位根因，会把同毫秒的并发强行串行化，引入延迟与锁失效风险（锁的命令与坑见第 27 章）。**面试官会追问**："如果是 Redis 主从切换导致的超卖呢？"要指出异步复制下主库扣减未同步就从库升主会丢扣减；补救是使用 `WAIT`、对库存路径强制走主库，并接受这段时间的部分不可用。

</details>

**Q3**（辨析）面试官问："短链服务为什么不用 `301`？`301` 不是对浏览器和搜索引擎都更友好吗？"

<details><summary>答案与解析</summary>

三层要点：① `301` 是永久重定向，浏览器通常永久缓存映射，之后的点击直接走本地缓存、不再到达服务端，于是点击统计、防刷、来源分析全部失效；② `302` 是临时重定向，浏览器每次仍请求短链服务，服务端能记录每一次点击，代价是每次多一次服务端往返（同机房约 0.1~0.5 ms）与一份服务端负载；③ "对搜索引擎友好"在短链场景方向相反——外链权重会随 `301` 传给目标页，运营方通常不希望外部站点白拿权重，`302` 能保留短链自身的权重。

判定规则：只要需要统计、防刷、有效期控制或事后改链接，就必须 `302`；只有纯跳转、不要统计、追求极限性能时才 `301`。**错在哪**：只背"`301` 永久、`302` 临时"的定义（本科已教），却说不出对统计能力的影响，这是本题的零分线。**面试官会追问**："`307` 和 `308` 有什么区别？"答：两者强制保持原请求方法与请求体（`301`/`302` 历史上允许客户端把 `POST` 改成 `GET`），短链场景全是 `GET`，实践上无差别。

</details>

**Q4**（设计）排行榜要求支持 TOP 100、"我的排名"、同分先到者优先，同时维护日榜周榜总榜。给出数据结构、分数编码、分页方式与内存控制方案。

<details><summary>答案与解析</summary>

结构：每榜一个 `ZSet`，`key` 为 `rank:daily:{yyyyMMdd}` / `rank:weekly:{yyyyWW}` / `rank:total`。写入用 `ZADD`/`ZINCRBY`；TOP N 用 `ZREVRANGE key 0 99 WITHSCORES`；我的排名用 `ZREVRANK`（`O(log N)`）。分数编码：`score = 业务分 × 10¹⁰ + (10¹⁰ − 1 − timestamp秒)`，让先到者分数更大；必须检查 `double` 的 53 位有效精度（约 9×10¹⁵）不被撑爆。分页与内存：不用偏移量分页（榜单变化会导致重复/漏项），用 `ZREVRANGEBYSCORE key (lastScore -inf LIMIT 0 20` 以分数为游标，同分边界用成员名做二次去重。日榜/周榜设 `EXPIRE`（日榜 8 天、周榜 70 天）让 Redis 自动清理；总榜按月归档冷数据；只展示头部时可只把 TOP 10 万放进 `ZSet`。周期榜合并用 `ZUNIONSTORE`，复杂度 `O(总元素数)`，必须低峰执行——否则阻塞单线程的 Redis。

**错在哪**：答"用 MySQL `ORDER BY` + `LIMIT` 分页"会同时踩两个坑（深分页性能塌方、同分排序不稳定）；答"`ZSet` 分数相同随机排序"是概念错误（实际按成员字典序）。**面试官会追问**："`ZUNIONSTORE` 会不会阻塞？"这正是要主动提的，并给出错峰或离线计算写回的方案。

</details>

**Q5**（设计）某社交产品日活 500 万，其中头部 2000 个账号各有百万级粉丝。选择 Feed 流推拉模式并说明读路径；若追问"收件箱怎么分页"如何回答？

<details><summary>答案与解析</summary>

选混合模式：普通用户走推模式（写扩散），发动态时 `ZADD inbox:{followerUid} timestamp postId`；头部 2000 个大 V 走拉模式（读扩散），只写自己的发件箱 `outbox:{uid}`。读路径三步：① `ZREVRANGE inbox:{uid} 0 19` 取自己收件箱最新 20 条；② 读该用户关注的大 V 的发件箱中最新若干条（通常只有几十个）；③ 内存按时间戳归并两个候选集，取前 20 条返回。

理由要用数字说清：若对大 V 也走推模式，一条动态写 100 万次收件箱，2000 个大 V 各发一条就是 20 亿次写入，写放大无法接受；若全部走拉模式，一个关注 2000 人的普通用户每次刷新要查 2000 个发件箱，读放大同样不可接受。**混合模式的本质是把不可控的一侧（粉丝规模）从写路径移到读路径，只在粉丝数超过阈值时迁移。** 另需补充在线推、离线拉：离线用户不投递，登录时按游标批量拉增量。

分页答法：`ZREVRANGE` 偏移量分页在下拉期间有新动态写入时会重复或漏项，必须用 `ZREVRANGEBYSCORE inbox:{uid} (lastScore -inf LIMIT 0 20` 以上一页最后一条的分数作游标；同一时间戳有多条时再叠加 `postId` 做二次游标。**错在哪**：只答"用推模式因为读快"，不提大 V 的写放大；或只答"用拉模式省存储"，不提读放大。**面试官会追问**："阈值定多少、怎么迁移？"可答按粉丝数阈值（例如 10 万）判定，账号跨越阈值只影响后续动态，历史收件箱靠 `TTL` 自然过期收敛，不需要回填。

</details>

## 面试高频问题与陷阱

| 面试问法 | 考查角度 | 陷阱 / 加分点 | 对应本章小节 |
|---|---|---|---|
| "设计一个秒杀系统。"你打算从哪里开始？ | 答题框架与需求澄清 | 错误回答：直接画架构图或打开编辑器写代码，不提规模、不问读写比与一致性要求 | 36.1、36.2 |
| "日活 1000 万的系统，`QPS` 大概多少？一天存储涨多少？" | 容量估算的量级意识 | 错误回答：答"大概几万"但推不出过程，或把日请求量当 `QPS`（漏除 86400）。加分：给出均值—峰值—索引—副本的完整链条 | 36.3 |
| "秒杀库存为什么不能先查一下还有没有，再减一？" | 并发原子性 | 错误回答：答"会有并发问题"却说不出 TOCTOU 窗口的具体时序，也说不出为什么 Lua 能解决 | 36.4.1 |
| "扣减成功之后消息投递失败，库存怎么办？" | 少卖分析与补偿设计 | 错误回答：只答"重试"就不管了，没有回补、没有对账、回补本身也不幂等 | 36.4.2 |
| "短链接用 `301` 还是 `302`？" | 状态码语义与统计能力 | 错误回答：只背"`301` 永久、`302` 临时"的定义，或答"`301` 性能好所以用 `301`"，完全不提统计失真 | 36.5 |
| "排行榜里两个人分数一样，`Redis` 会怎么排？" | `ZSet` 内部排序规则 | 错误回答：答"随机排"或"按插入顺序排"。正确：按成员字典序，需把时间戳编码进分数低位 | 36.6 |
| "在线人数统计你用什么结构？" | 数据结构选型与精度取舍 | 错误回答：直接答 `HyperLogLog` 而不问"是否需要精确查某个用户"，也不给 0.81% 误差的适用边界 | 36.9 |
| "订单号怎么生成？如果发号器出现重复怎么办？" | 分布式 ID 与最后防线 | 错误回答：只说 `Snowflake`，不提时钟回拨处理，更不提数据库唯一索引兜底 | 36.7 |

## 延伸阅读

- Martin Kleppmann《数据密集型应用系统设计》第 1 章（可靠性、可扩展性、可维护性）与第 11 章（流处理）——写放大与推拉模式的通用框架。
- Redis 官方文档：`ZADD`/`ZREVRANGE`/`ZREVRANGEBYSCORE`（分页游标）、`PFADD`/`PFCOUNT`（HyperLogLog 误差口径）、`SETBIT`/`BITCOUNT`（Bitmap 复杂度）、`EVAL`（Lua 脚本的原子性说明）。
- MySQL 8.0 官方手册：`INSERT ... ON DUPLICATE KEY`、`UPDATE` 的"影响行数"语义、InnoDB 唯一索引与死锁检测；Apache Kafka 官方文档 "Design" 章节（分区内有序性与消费位点语义）、Apache RocketMQ 官方文档（延时消息与事务消息）。
- RFC 7231《Hypertext Transfer Protocol (HTTP/1.1): Semantics and Content》第 6.4 节：`301`/`302`/`307`/`308` 的语义与缓存行为；本书交叉章节：第 3 章（Little 定律与延迟数量级）、第 27 章（Redis 数据结构与缓存一致性）、第 33 章（MQ 可靠性三件套）、第 34 章（CAP、分布式 ID、限流）、第 35 章（熔断降级与接口幂等）。


---

> 作者: [破戒僧](https://onlyblacktea.cn)  
> URL: https://onlyblacktea.cn/book/backend/ch36/  

