系统设计面试高频题解
8 道硅谷 & 国内大厂最高频的系统设计面试题——短链接、Feed 流、限流器、分布式缓存、消息推送、搜索自动补全、分布式文件存储、秒杀系统。每题按"需求分析 → 高层设计 → 深入设计 → 扩展讨论"四步框架拆解,附 ASCII 架构图、容量估算和 Python 伪代码。
第一章 系统设计面试方法论:4 步框架
系统设计面试没有"标准答案"——同一道题,不同公司的面试官期望看到的深度完全不同。但面试的流程是可以训练的。这一章给你一套通用框架,后面每道题都按这个框架拆解。
1.1 面试官在考什么:不是标准答案,是设计过程
很多人以为系统设计面试是在考"你能不能设计出完美的系统"。错了。面试官真正在观察的是:
系统设计面试的 5 个评估维度:
① 需求澄清能力
→ 你会不会主动问问题,而不是闷头就画架构图
→ "日活多少?""读写比是多少?""数据需要保留多久?"
② 高层设计能力
→ 你能不能在 5 分钟内画出一个合理的架构图
→ 不需要完美,但需要覆盖核心组件
③ 深入设计能力
→ 当面试官说"这里展开讲讲",你能不能 deep dive
→ 数据库选型、缓存策略、一致性方案——你要有深度
④ 权衡分析能力
→ 你是否能说出"方案 A 的优势是 X,劣势是 Y"
→ 面试官最喜欢 trade-off 分析
⑤ 处理反馈的能力
→ 面试官提出质疑时,你是固执还是灵活
→ "你说得对,如果考虑这个约束,方案 B 更合适"💡 最常见的失败模式:不问需求就开始画图 → 花 30 分钟设计了一个错误方向的系统 → 面试官引导你回来但时间已经浪费一半。前 5 分钟的需求澄清,决定了整场面试的上限。
1.2 4 步框架:需求分析 → 高层设计 → 深入设计 → 扩展讨论
45 分钟的面试,按这个节奏分配时间:
4 步框架的时间分配(45 分钟面试):
Step 1:需求分析 5 分钟 (10%)
═══════════════════════════════
• 功能需求:系统要做什么?
• 非功能需求:QPS、延迟、可用性
• 容量估算:数据量、存储、带宽
Step 2:高层设计 15 分钟 (35%)
═══════════════════════════════
• 画出核心组件和数据流
• API 设计(关键接口)
• 数据模型(主要表/文档结构)
• 架构图(client → LB → service → DB)
Step 3:深入设计 20 分钟 (45%)
═══════════════════════════════
• 面试官选 1-2 个模块 deep dive
• 难点攻坚(一致性、高并发、数据分片)
• 具体算法和方案选型
Step 4:扩展讨论 5 分钟 (10%)
═══════════════════════════════
• 如何扩展到 10x 规模?
• 有哪些可以优化的点?
• 监控、告警、错误处理Step 1 的黄金问题清单(每道题都可以问):
| 问题 | 目的 |
|---|---|
| "用户量级是多少?日活/月活?" | 判断系统规模 |
| "读写比大概是多少?" | 决定缓存策略和架构倾向 |
| "数据需要保留多久?" | 影响存储选型和成本 |
| "延迟要求是什么?P99 < 200ms?" | 影响缓存层级和地理分布 |
| "需要支持全球用户还是单地区?" | 影响多区域部署 |
| "最重要的非功能需求是什么?" | 帮面试官设定优先级 |
Step 2 的通用架构模板:
90% 的系统设计题都逃不出这个架构骨架:
客户端
│
▼
负载均衡(Nginx / ALB)
│
▼
API 网关(认证、限流、路由)
│
├──→ 服务层(业务逻辑,可水平扩展)
│ │
│ ├──→ 缓存层(Redis / Memcached)
│ │
│ ├──→ 数据库(PostgreSQL / MySQL / DynamoDB)
│ │
│ └──→ 消息队列(Kafka / RabbitMQ)
│
└──→ CDN(静态资源、就近访问)
根据具体题目,在这个骨架上增删组件。💡 面试中画图的技巧:从左到右画数据流(client → server → DB),每个组件用方框表示,组件之间用箭头标注协议(HTTP / gRPC / WebSocket)。面试官看的是你的思维过程,方框歪一点没关系。
1.3 容量估算速算:QPS、存储、带宽的"信封背面计算"
面试中不需要精确计算——只需要数量级正确。掌握这些速算公式,30 秒内给出估算:
速算公式集:
── QPS 估算 ──
日活用户 × 每人每天操作次数 ÷ 86400(一天的秒数)= 平均 QPS
峰值 QPS ≈ 平均 QPS × 2~5(取决于业务类型)
简化:86400 ≈ 10^5,一天约 10 万秒
── 存储估算 ──
每条记录大小 × 每天新增条数 × 保留天数 = 总存储
── 带宽估算 ──
QPS × 平均响应大小 = 每秒带宽
── 常用近似值(面试速算用)──
1 天 ≈ 10^5 秒
1 个月 ≈ 2.5 × 10^6 秒
1 年 ≈ 3 × 10^7 秒
1 KB = 10^3 字节
1 MB = 10^6 字节
1 GB = 10^9 字节
1 TB = 10^12 字节完整示例:估算 Twitter 的存储需求
已知条件(面试中你问出来的):
• DAU = 3 亿(日活)
• 每人每天发 2 条推文
• 每条推文 ≈ 250 字节(文本)
• 20% 的推文带图片,图片平均 500KB
• 数据保留 5 年
QPS 估算:
写 QPS = 3 × 10^8 × 2 ÷ 10^5 = 6000 QPS
峰值写 QPS ≈ 6000 × 3 = 18000 QPS
读 QPS(假设读写比 100:1)= 600,000 QPS
每日存储增量:
文本:3 × 10^8 × 2 × 250 B = 150 GB/天
图片:3 × 10^8 × 2 × 20% × 500 KB = 60 TB/天
总计 ≈ 60 TB/天(图片是大头)
5 年总存储:
60 TB × 365 × 5 ≈ 110 PB
带宽:
写带宽:18000 × 250 B ≈ 4.5 MB/s(文本可忽略)
图片上传:18000 × 20% × 500 KB = 1.8 GB/s💡 面试中估算的目的不是精确计算——是让面试官看到你有"数量级思维"。10 TB 和 100 TB 的架构差异不大,但 100 TB 和 10 PB 就完全不同了。抓住数量级,不要纠结精确数字。
1.4 系统设计常用数字:每个工程师都应该知道的延迟数据
这张表来自 Jeff Dean(Google 首席科学家),是系统设计的"直觉基础"。面试中被问到"为什么这里要加缓存",你脑子里要立刻浮现这些数字:
延迟数据(2024 年近似值):
操作 延迟 数量级
══════════════════════════════════════════════════
L1 缓存引用 1 ns
L2 缓存引用 4 ns
主内存引用 100 ns = 0.1 μs
SSD 随机读 100 μs = 0.1 ms
HDD 随机读 10 ms
同机房网络往返(RTT) 0.5 ms
SSD 顺序读 1MB 1 ms
HDD 顺序读 1MB 20 ms
同城机房网络往返 5 ms
跨城网络往返(北京→上海) 30 ms
跨洲网络往返(中国→美国) 150 ms
═══════════════════════════════════════════════
关键比例:
内存 vs SSD = 1000 倍差距
SSD vs HDD = 100 倍差距
同机房 vs 跨洲 = 300 倍差距存储系统的吞吐量对比:
| 系统 | 读 QPS(单机) | 写 QPS(单机) | 典型延迟 |
|---|---|---|---|
| Redis | 100,000+ | 100,000+ | < 1ms |
| Memcached | 200,000+ | 200,000+ | < 1ms |
| PostgreSQL | 5,000-10,000 | 1,000-5,000 | 1-10ms |
| MySQL | 5,000-10,000 | 1,000-5,000 | 1-10ms |
| MongoDB | 10,000-50,000 | 5,000-20,000 | 1-5ms |
| Elasticsearch | 1,000-5,000 | 500-2,000 | 5-50ms |
| Kafka(写入) | — | 100,000+ | 2-5ms |
一些有用的"直觉数字":
单机性能基准(面试中估算用):
Web 服务器 单机支撑 1000-5000 QPS(视业务复杂度)
MySQL/PG 单机写入 ~2000 QPS,读取 ~10000 QPS
Redis 单机 ~10 万 QPS
Kafka 单 Partition ~10 万 msg/s
Nginx 单机 ~50000 并发连接
一台标准服务器 8 核 32GB → 日活 ~10 万的服务绰绰有余
2^10 = 1024 ≈ 千
2^20 ≈ 百万
2^30 ≈ 十亿
2^40 ≈ 万亿💡 为什么缓存如此重要:Redis 读 QPS 是 PostgreSQL 的 10-20 倍,延迟是 PostgreSQL 的 1/10。一个
SELECT * FROM users WHERE id = ?在 PG 中可能需要 5ms,同样的查询在 Redis 中 < 0.5ms。这就是为什么几乎每道系统设计题的答案都包含一层缓存。
第 1 章核心知识回顾:
| 概念 | 一句话解释 |
|---|---|
| 5 个评估维度 | 需求澄清、高层设计、深入设计、权衡分析、处理反馈 |
| 4 步框架 | 需求(5min)→ 高层(15min)→ 深入(20min)→ 扩展(5min) |
| 容量估算 | 1 天 ≈ 10^5 秒,抓数量级,不纠结精确数字 |
| 延迟直觉 | 内存 0.1μs → SSD 0.1ms → 同机房 0.5ms → 跨洲 150ms |
| 缓存的价值 | Redis 比 DB 快 10-20 倍,大部分系统设计题的答案都包含缓存层 |
第二章 设计短链接系统(TinyURL)
短链接是系统设计面试里的"Hello World"——出现频率极高、难度适中、考点覆盖广(哈希、缓存、数据库、重定向)。如果你只准备一道题,就准备这道。
2.1 需求分析:功能需求与非功能需求
功能需求(面试中你应该主动确认的):
核心功能:
1. 给定一个长 URL,生成一个短链接
https://example.com/very/long/path → https://short.ly/abc123
2. 用户访问短链接时,重定向到原始长 URL
3.(可选)自定义短链别名
4.(可选)链接过期时间
5.(可选)访问统计(点击次数、来源分析)非功能需求(面试中要问的):
| 维度 | 假设值 |
|---|---|
| 日活用户 | 1 亿 |
| 读写比 | 100:1(读远大于写) |
| 短链长度 | ≤ 7 个字符 |
| 延迟 | 重定向 < 100ms |
| 可用性 | 99.99%(短链不能失效) |
| 数据保留 | 永久 |
容量估算:
写 QPS:
1 亿 DAU × 0.1 次创建/人/天 ÷ 10^5 = 100 QPS(写很少)
读 QPS:
100 × 100 = 10,000 QPS(读写比 100:1)
存储(5 年):
每天新增 URL 数 = 1 亿 × 0.1 = 1000 万条
每条记录 ≈ 500 字节(短链 + 长 URL + 元数据)
5 年 = 1000 万 × 365 × 5 × 500 B ≈ 9 TB
短链的编码空间:
Base62(a-z, A-Z, 0-9)7 位 = 62^7 ≈ 3.5 万亿
→ 足够用到宇宙热寂2.2 高层设计:短链生成 + 重定向的核心流程
短链系统的两个核心流程:
流程 1:创建短链接
═══════════════════════════════════════
客户端 ──→ API 服务 ──→ 生成短码 ──→ 存入数据库
(short_code → long_url)
返回 ←── https://short.ly/abc123
流程 2:重定向(高频路径,必须快!)
═══════════════════════════════════════
用户点击 short.ly/abc123
│
▼
API 服务
│
├──→ 查 Redis 缓存?命中 → 直接返回 301/302
│
└──→ 未命中 → 查数据库 → 写入缓存 → 返回 301/302API 设计:
POST /api/v1/shorten
Request: { "long_url": "https://example.com/...", "expiry": "30d" }
Response: { "short_url": "https://short.ly/abc123" }
GET /{short_code}
Response: HTTP 301 Location: https://example.com/...301 vs 302 重定向?
| 状态码 | 含义 | 浏览器行为 | 适用场景 |
|---|---|---|---|
| 301 | 永久重定向 | 浏览器缓存,下次不再请求服务器 | 减少服务器压力 |
| 302 | 临时重定向 | 每次都请求服务器 | 需要统计点击次数 |
💡 面试中的选择:如果需要统计点击量,用 302(每次请求都经过服务器);如果纯粹做短链转发,用 301(减少服务器负载)。大部分面试官期望你分析这个 trade-off。
数据模型:
CREATE TABLE urls (
id BIGINT PRIMARY KEY,
short_code VARCHAR(7) UNIQUE NOT NULL, -- Base62 编码
long_url TEXT NOT NULL,
user_id BIGINT,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP, -- 可选过期时间
click_count BIGINT DEFAULT 0
);
CREATE INDEX idx_short_code ON urls(short_code);
-- short_code 是查询最频繁的字段,必须有索引2.3 深入设计:哈希碰撞处理、Base62 编码、数据库选型
短码生成是这道题的核心技术决策。两种主流方案:
方案 1:哈希取前 N 位
"""方案 1:MD5/SHA256 哈希 → 取前 7 位"""
import hashlib
import base64
def shorten_hash(long_url: str) -> str:
# SHA256 生成 256 位哈希
hash_bytes = hashlib.sha256(long_url.encode()).digest()
# Base62 编码后取前 7 位
b62 = base64.b64encode(hash_bytes).decode()
short_code = b62[:7].replace('+', 'x').replace('/', 'y')
return short_code
# 问题:不同的长 URL 可能哈希前 7 位相同(碰撞)
# 解决:碰撞时在原 URL 后追加序号重新哈希
# shorten_hash(long_url + "1") → 重试方案 2:自增 ID + Base62 编码(推荐)
"""方案 2:数据库自增 ID → Base62"""
CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
def id_to_base62(num: int) -> str:
if num == 0:
return CHARS[0]
result = []
while num > 0:
result.append(CHARS[num % 62])
num //= 62
return ''.join(reversed(result))
def base62_to_id(code: str) -> int:
num = 0
for char in code:
num = num * 62 + CHARS.index(char)
return num
# id = 1000000 → base62 = "4c92"(4 位)
# id = 56800235583 → base62 = "zzzzzz"(6 位)
# 不可能碰撞,因为 ID 唯一!两种方案对比:
| 维度 | 哈希方案 | 自增 ID + Base62 |
|---|---|---|
| 碰撞 | ⚠️ 可能碰撞,需要重试 | ✅ 零碰撞 |
| 可预测 | ✅ 不可预测 | ⚠️ 可枚举(需加混淆) |
| 相同 URL | ✅ 天然去重 | ❌ 同一 URL 生成不同短码 |
| 依赖 | 无中心依赖 | 需要 ID 生成器 |
| 推荐度 | 适合去重场景 | 大部分场景首选 |
缓存策略(面试加分点):
读路径的缓存设计:
Redis 缓存
═══════════════════════════════
Key: short:abc123
Value: https://example.com/very/long/path
TTL: 24 小时(热门链接自动续期)
读写比 100:1 → 非常适合缓存
缓存命中率预估:80%+(短链遵循帕累托法则,20% 的链接占 80% 的流量)
加缓存后:
10000 QPS × 80% 命中 = 只有 2000 QPS 打到数据库
延迟从 ~5ms(DB)降到 ~0.5ms(Redis)💡 面试中推荐方案 2(自增 ID + Base62)——实现简单、零碰撞、性能好。如果面试官追问"ID 可枚举怎么办",回答:用 Snowflake ID 或对 ID 做简单的位混淆(bit shuffling),让短码看起来随机。
2.4 扩展讨论:自定义短链、过期策略、数据分析、防滥用
面试的最后 5 分钟,面试官可能追问的扩展点:
自定义短链:用户指定 short.ly/my-brand 这样的别名。实现很简单——先查数据库是否已存在,不存在则直接插入。注意长度限制和字符白名单(防注入)。
过期策略:
两种过期清理方案:
方案 1:惰性删除
═══════════════════════════════
访问短链时检查 expires_at,过期则返回 404
优点:简单,不需要后台任务
缺点:过期数据仍占存储空间
方案 2:定时清理 + 惰性删除
═══════════════════════════════
后台 Cron Job 每天凌晨批量删除过期记录
DELETE FROM urls WHERE expires_at < NOW() LIMIT 10000;
优点:及时释放空间
注意:分批删除,避免锁表数据分析——如果面试官问"如何统计点击数据":
点击事件写入 Kafka → 消费者异步落库
用户点击 short.ly/abc123
│
▼
API 服务(同步返回 302 重定向)
│
└──→ 异步发送点击事件到 Kafka
{short_code, timestamp, ip, user_agent, referer}
│
▼
消费者 → 写入 ClickHouse(分析型数据库)
→ 不影响重定向延迟(异步)
→ ClickHouse 擅长聚合查询(每日 PV、地域分布)防滥用:恶意用户用短链做钓鱼/恶意跳转。防御手段:① 创建时检查 URL 黑名单;② 限制单用户创建频率(Rate Limiting);③ Google Safe Browsing API 检测恶意 URL。
最终架构图:
完整的短链系统架构:
客户端
│
▼
负载均衡(Nginx)
│
├── POST /shorten ─→ 写服务 ─→ ID 生成器 ─→ 数据库(写)
│ │
│ ▼
│ Redis 缓存
│ ▲
└── GET /{code} ──→ 读服务 ─→ Redis ─(miss)→ 数据库(读)
│
└──→ Kafka ──→ ClickHouse(分析)第 2 章核心知识回顾:
| 概念 | 一句话解释 |
|---|---|
| 读写比 100:1 | 读多写少,非常适合加缓存 |
| Base62 编码 | 自增 ID → 7 位短码,零碰撞 |
| 301 vs 302 | 301 浏览器缓存(省流量),302 每次过服务器(可统计) |
| 缓存命中率 | 帕累托法则,80%+ 命中率,DB 压力降 5 倍 |
| 异步统计 | 点击事件走 Kafka,不影响重定向延迟 |
第三章 设计 Feed 流系统(Twitter / 朋友圈)
Feed 流是面试中最能区分候选人水平的题目——没有"正确答案",全看你怎么做 trade-off。关键决策就一个:推还是拉?
3.1 需求分析:发布、关注、信息流的核心场景
功能需求:
核心功能:
1. 用户可以发布内容(文字/图片/视频)
2. 用户可以关注其他用户
3. 用户打开首页时,看到关注的人发布的内容(时间线)
4. Feed 按时间倒序排列(或按推荐算法排序)非功能需求:
| 维度 | 假设值 |
|---|---|
| DAU | 3 亿 |
| 每人每天刷 Feed | 10 次 |
| 每人关注数(中位数) | 200 人 |
| 大 V 粉丝数(头部) | 1000 万+ |
| Feed 延迟 | 发布后 5 秒内所有粉丝可见 |
| Feed 页大小 | 每页 20 条 |
容量估算:
读 QPS(刷 Feed):
3 × 10^8 × 10 ÷ 10^5 = 30,000 QPS
写 QPS(发布内容):
3 × 10^8 × 0.5(假设 50% 的用户每天发 1 条)÷ 10^5 = 1,500 QPS
难点不在 QPS,在于 Fan-out(扇出):
一个大 V 发一条内容 → 1000 万粉丝的 Feed 都要更新
这 1000 万次写入如何在 5 秒内完成?3.2 高层设计:推模式(Fan-out-on-write)vs 拉模式(Fan-out-on-read)
这是整道题的灵魂决策。先理解两种模式的本质区别:
推模式(Fan-out-on-write):写时扩散
推模式:发布时就把内容"推"到所有粉丝的收件箱
用户 A 发布了一条内容
│
▼
系统读取 A 的粉丝列表(假设 500 人)
│
▼
向 500 个粉丝的 Feed 收件箱各写入一条记录
┌──────────────────────────────────┐
│ feed_inbox:user_1 → [A的内容] │
│ feed_inbox:user_2 → [A的内容] │
│ feed_inbox:user_3 → [A的内容] │
│ ... │
│ feed_inbox:user_500 → [A的内容] │
└──────────────────────────────────┘
粉丝打开 Feed 时:直接读自己的收件箱 → 超快!
SELECT * FROM feed_inbox
WHERE user_id = ? ORDER BY created_at DESC LIMIT 20;拉模式(Fan-out-on-read):读时聚合
拉模式:用户打开 Feed 时,实时去"拉"关注者的最新内容
用户 B 打开首页
│
▼
系统读取 B 的关注列表(假设 200 人)
│
▼
从 200 个用户的发布表中各取最新 N 条
┌──────────────────────────────────┐
│ SELECT * FROM posts │
│ WHERE author_id IN (关注列表) │
│ ORDER BY created_at DESC │
│ LIMIT 20; │
└──────────────────────────────────┘
合并、排序、返回 Top 20
发布时:只写一条记录到 posts 表 → 超快!两种模式的本质对比:
| 维度 | 推模式 | 拉模式 |
|---|---|---|
| 写入成本 | 高(1 次发布 → N 次写入) | 低(1 次发布 → 1 次写入) |
| 读取成本 | 低(直接读收件箱) | 高(实时聚合多个源) |
| 发布延迟 | 高(要推给所有粉丝) | 低(只写自己的表) |
| 读取延迟 | 低(< 10ms) | 高(聚合+排序,~100ms) |
| 存储 | 大(每条内容存 N 份) | 小(只存 1 份) |
| 大 V 问题 | 💀 灾难性(1000 万次写入) | ✅ 无影响 |
| 适合场景 | 粉丝数少、读多写少 | 大 V 多、存储敏感 |
💡 面试中说出这个对比表,面试官基本就满意了。 但更高分的回答是:两种模式都不完美,真实系统用的是第三种——推拉结合。
3.3 深入设计:推拉结合策略、大V 问题、Feed 缓存与排序
推拉结合(Hybrid)——真实系统的做法:
推拉结合策略:按粉丝数分流
用户发布内容
│
▼
判断:粉丝数 > 阈值?(如 50 万)
│
├── 否(普通用户)→ 推模式
│ 把内容推到所有粉丝的 Feed 收件箱
│ → 粉丝少,推的成本可控
│
└── 是(大 V)→ 不推
只写到自己的发布表
粉丝打开 Feed 时再实时拉取
用户打开 Feed:
═══════════════════════════════
① 从 Feed 收件箱读取已推送的内容(普通关注者发的)
② 从关注的大 V 列表,实时拉取最新内容
③ 合并 ① 和 ②,按时间排序,返回 Top 20推拉结合的架构图:
发布者发布内容
│
├──→ 写入 posts 表(所有人都写)
│
└──→ 粉丝数 < 50 万?
│
├── 是 → Fan-out Worker → 推到粉丝 Feed 收件箱(Redis)
│
└── 否 → 不推(标记为"大 V")
用户读取 Feed
│
├──→ 读 Feed 收件箱(Redis Sorted Set)→ 普通关注者的内容
│
├──→ 读关注的大 V 最新发布(拉模式)→ 大 V 的内容
│
└──→ 合并、去重、排序 → 返回 Top 20Feed 缓存设计(Redis Sorted Set):
"""每个用户的 Feed 收件箱用 Redis Sorted Set 存储"""
import redis
r = redis.Redis()
# 推送:把内容 ID 插入粉丝的 Feed
def push_to_feed(follower_id: int, post_id: int, timestamp: float):
key = f"feed:{follower_id}"
r.zadd(key, {post_id: timestamp}) # score = 时间戳
r.zremrangebyrank(key, 0, -801) # 只保留最新 800 条
# 读取:获取用户 Feed 的第 N 页
def get_feed(user_id: int, page: int = 0, size: int = 20):
key = f"feed:{user_id}"
start = page * size
end = start + size - 1
# ZREVRANGE:按 score 倒序(最新的在前面)
post_ids = r.zrevrange(key, start, end)
return post_ids # 再用 post_ids 批量查 posts 表获取完整内容排序策略——面试加分项:
| 策略 | 算法 | 适用场景 |
|---|---|---|
| 时间线 | 纯按 timestamp 排序 | Twitter 早期、朋友圈 |
| 热度加权 | score = timestamp + log(likes) × α | 兼顾时效性和互动 |
| 推荐算法 | 机器学习模型打分 | 现代 Feed(抖音、Instagram) |
💡 面试时间有限,推荐分层回答:先说时间线排序(最简单),再提一句"生产环境通常用推荐算法混合时间因子",展示你知道实际系统比面试题复杂。
3.4 扩展讨论:推荐算法介入、多媒体 Feed、热点事件扩容
推荐算法介入:
现代 Feed 流的两阶段架构:
阶段 1:召回(Retrieval)
═══════════════════════════════
从候选池中快速筛出 ~500 条候选内容
来源:关注者发布 + 推荐系统补充(你可能感兴趣的)
阶段 2:排序(Ranking)
═══════════════════════════════
用 ML 模型对 ~500 条打分,取 Top 20 返回
特征:用户兴趣、内容热度、社交关系、时效性
→ 面试中不需要细讲 ML 模型
→ 但提一句"两阶段架构"会加分热点事件的应急扩容:春晚、世界杯决赛——大 V 密集发内容,Fan-out 流量暴涨。应对方案:
- 降级:暂时关闭推模式,全部走拉模式(牺牲读延迟,保护写入)
- 限速:对 Fan-out Worker 限制每秒处理条数,用队列缓冲
- 预热:提前扩容 Redis 集群和 Fan-out Worker 实例数
最终架构图:
Feed 流完整架构:
客户端
│
▼
API Gateway
│
├── POST /posts ──→ Post Service ──→ posts DB
│ │
│ └──→ Fan-out Service(异步)
│ │
│ ├── 普通用户 → 推到 Redis Feed
│ └── 大 V → 跳过推送
│
└── GET /feed ───→ Feed Service
│
├── 读 Redis Feed 收件箱
├── 拉关注的大 V 最新内容
└── 合并排序 → 返回 Top 20第 3 章核心知识回顾:
| 概念 | 一句话解释 |
|---|---|
| 推模式 | 写时扩散,读快写慢,大 V 灾难 |
| 拉模式 | 读时聚合,写快读慢,大 V 友好 |
| 推拉结合 | 按粉丝数阈值分流,普通用户推、大 V 拉 |
| Redis Sorted Set | Feed 收件箱用 ZSET,score=时间戳,天然有序 |
| 两阶段排序 | 召回 500 条 → ML 排序 → 返回 Top 20 |
第四章 设计分布式限流器(Rate Limiter)
限流器看似简单——"每分钟最多 100 次请求",但在分布式环境中,多台服务器共享同一个计数器的一致性问题,是面试官爱考的难点。
4.1 需求分析:限流的场景、限流规则(用户/IP/API 维度)
为什么需要限流?
限流的三大目的:
① 防止恶意攻击
→ DDoS 攻击、暴力破解、爬虫
② 保护后端资源
→ 数据库连接数有限,不能无限接受请求
③ 公平使用
→ 免费用户 100 次/分钟,付费用户 1000 次/分钟限流维度(面试中需要确认的):
| 维度 | 示例 | 适用场景 |
|---|---|---|
| 用户 | user_id = 123 → 100 req/min | API 配额 |
| IP | 192.168.1.1 → 50 req/min | 未登录用户防刷 |
| API | POST /login → 5 req/min | 防暴力破解 |
| 全局 | 整个系统 → 100K req/s | 保护后端 |
4.2 高层设计:令牌桶 / 漏桶 / 固定窗口 / 滑动窗口 四种算法
算法 1:令牌桶(Token Bucket)⭐ 最常用
令牌桶原理:
┌────────────────────┐
│ 令牌桶 │ ← 每秒放入 r 个令牌
│ 容量 = b │
│ ◉ ◉ ◉ ◉ ◉ │ ← 桶里有令牌
└────────────────────┘
│
请求到达 ▼
有令牌 → 取走一个令牌,放行 ✅
没令牌 → 拒绝请求 ❌
特点:
• 允许突发流量(桶满时可以一次性取 b 个)
• 长期来看,速率不超过 r/s
• Amazon API Gateway、Stripe 都用这个算法 2:漏桶(Leaky Bucket)
漏桶原理:
请求进入 ──→ ┌────────┐ ──→ 以固定速率 r 流出
│ 队列 │
│ ● ● ● ●│
└────────┘
队列满 → 丢弃 ❌
特点:
• 输出速率严格恒定(无突发)
• 适合需要平滑流量的场景
• 会引入排队延迟算法 3:固定窗口(Fixed Window)
固定窗口原理:
时间轴:
|---------- 窗口 1 ----------|---------- 窗口 2 ----------|
0:00 1:00 2:00
窗口 1 内允许 100 次请求
窗口 2 内重新计数,允许 100 次
⚠️ 边界问题:
0:59 来了 100 个请求 → 允许
1:00 又来了 100 个请求 → 也允许(新窗口)
→ 1 分钟内实际通过了 200 个请求!(窗口边界突发)算法 4:滑动窗口(Sliding Window Log / Counter)
滑动窗口原理:
当前时刻的前 60 秒内,允许 100 次请求
时间轴:
|←───── 60 秒 ─────→|
─────────────────────────● ← 当前时刻
统计这 60 秒内的请求数
> 100 → 拒绝 ❌
≤ 100 → 放行 ✅
解决了固定窗口的边界问题 ✅
但需要存储每个请求的时间戳(内存大)四种算法对比:
| 算法 | 突发流量 | 内存 | 精确度 | 复杂度 | 推荐度 |
|---|---|---|---|---|---|
| 令牌桶 | ✅ 允许 | 低 | 中 | 中 | ⭐ 首选 |
| 漏桶 | ❌ 不允许 | 低 | 高 | 低 | 适合平滑场景 |
| 固定窗口 | ⚠️ 边界突发 | 极低 | 低 | 极低 | 只适合粗略限流 |
| 滑动窗口 | ✅ 精确 | 高 | 极高 | 高 | 精确度要求高时 |
💡 面试推荐回答令牌桶 + 滑动窗口。先说令牌桶(工业标准),再分析固定窗口的边界问题引出滑动窗口,展示你的深度思考。
4.3 深入设计:Redis + Lua 实现分布式限流、竞态条件处理
单机限流用内存计数器就行,但多台服务器需要共享计数器——Redis 是标准方案。
滑动窗口 + Redis Sorted Set 实现:
"""分布式滑动窗口限流器(Redis + Lua)"""
import time
import redis
r = redis.Redis()
# Lua 脚本保证原子性(避免竞态条件)
SLIDING_WINDOW_LUA = """
local key = KEYS[1]
local window = tonumber(ARGV[1]) -- 窗口大小(秒)
local limit = tonumber(ARGV[2]) -- 限制次数
local now = tonumber(ARGV[3]) -- 当前时间戳(毫秒)
-- 删除窗口之外的旧记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window * 1000)
-- 统计窗口内的请求数
local count = redis.call('ZCARD', key)
if count < limit then
-- 未超限,记录本次请求
redis.call('ZADD', key, now, now .. '-' .. math.random(1000000))
redis.call('EXPIRE', key, window + 1)
return 1 -- 放行
else
return 0 -- 拒绝
end
"""
def is_allowed(user_id: str, window: int = 60, limit: int = 100) -> bool:
key = f"ratelimit:{user_id}"
now = int(time.time() * 1000)
result = r.eval(SLIDING_WINDOW_LUA, 1, key, window, limit, now)
return result == 1为什么必须用 Lua 脚本?
竞态条件(Race Condition):
没有 Lua 的情况下(非原子操作):
服务器 A: ZCARD key → 99(还没到 100)
服务器 B: ZCARD key → 99(还没到 100)
服务器 A: ZADD key ... → 实际变成 100
服务器 B: ZADD key ... → 实际变成 101 ← 超限了!
用 Lua 脚本:
整个"检查 + 写入"操作在 Redis 内部原子执行
→ 不可能被其他命令打断
→ 彻底解决竞态条件限流器的部署位置:
三种部署位置:
① API 网关层(推荐)
═══════════════════════════════
客户端 → Nginx/Kong → 限流 → 后端服务
优点:所有请求统一入口,规则集中管理
典型:Nginx limit_req、Kong Rate Limiting 插件
② 应用层中间件
═══════════════════════════════
客户端 → 后端服务 → 限流中间件 → 业务逻辑
优点:可以拿到用户身份(user_id),精细限流
典型:FastAPI middleware、Django throttle
③ 独立限流服务
═══════════════════════════════
客户端 → 后端服务 → gRPC 调用限流服务 → 放行/拒绝
优点:解耦、可独立扩展
缺点:多一次网络调用(+1~2ms)💡 面试推荐答案:网关层做粗粒度限流(IP 级别),应用层做细粒度限流(用户/API 级别)。两层配合,既防攻击又保公平。
4.4 扩展讨论:多级限流、动态规则、限流降级与用户体验
被限流后的响应设计——这是面试中常被忽略的细节:
限流响应的最佳实践:
HTTP 429 Too Many Requests
Headers:
Retry-After: 30 ← 告诉客户端 30 秒后重试
X-RateLimit-Limit: 100 ← 总配额
X-RateLimit-Remaining: 0 ← 剩余配额
X-RateLimit-Reset: 1712345 ← 配额重置的 Unix 时间戳
Body:
{
"error": "rate_limit_exceeded",
"message": "请求过于频繁,请 30 秒后重试",
"retry_after": 30
}多级限流策略:
| 层级 | 规则 | 作用 |
|---|---|---|
| Nginx | 单 IP 1000 req/min | 防 DDoS |
| API 网关 | 单用户 100 req/min | 配额控制 |
| 应用层 | POST /login 5 req/min | 防暴力破解 |
| 数据库 | 连接池上限 100 | 保护 DB |
动态规则:限流规则不应该硬编码——应该存在配置中心(如 etcd / Consul),支持热更新。大促前调高全局阈值,发现攻击时动态封禁特定 IP。
限流 vs 降级的区别:
限流(Rate Limiting):
→ 直接拒绝多余请求(返回 429)
→ 保护系统不被压垮
降级(Degradation):
→ 不拒绝请求,但返回简化结果
→ 例:推荐服务超时 → 返回热门榜单(兜底数据)
生产系统通常两者配合使用第 4 章核心知识回顾:
| 概念 | 一句话解释 |
|---|---|
| 令牌桶 | 允许突发、长期限速,工业标准(Amazon/Stripe) |
| 滑动窗口 | 精确统计任意 60s 窗口内的请求数 |
| Redis + Lua | 原子操作解决分布式竞态条件 |
| 多级限流 | 网关粗粒度 + 应用细粒度,层层防御 |
| 429 + Retry-After | 限流响应必须告诉客户端何时重试 |
第五章 设计分布式缓存系统(Redis / Memcached)
缓存是系统设计面试的"万金油"——几乎每道题的答案里都有缓存层。但面试官真正想考的不是"加个 Redis",而是缓存三大经典问题(穿透/雪崩/击穿)和缓存与数据库的一致性。
5.1 需求分析:为什么需要缓存、缓存的读写模式
缓存的核心价值:
没有缓存 有缓存
══════════ ══════════
DB: 10,000 QPS DB: 2,000 QPS(缓存挡住 80%)
延迟: 5-10ms 延迟: 0.5ms(命中缓存)
成本: 高 成本: 低(Redis 内存 << DB CPU)
数据库是最贵的资源——缓存的本质是用便宜的内存保护昂贵的数据库。什么数据适合缓存?
| 特征 | 适合缓存 | 不适合缓存 |
|---|---|---|
| 读写比 | 读多写少(>10:1) | 写多读少 |
| 一致性要求 | 允许短暂不一致 | 要求强一致(如余额) |
| 数据量 | 热数据集 < 内存 | 数据量远超内存 |
| 计算成本 | 查询复杂(JOIN/聚合) | 简单主键查询 |
5.2 高层设计:Cache-Aside / Read-Through / Write-Behind 三种模式
模式 1:Cache-Aside(旁路缓存)⭐ 最常用
Cache-Aside 读路径:
应用
│
├──→ ① 查缓存(Redis)
│ │
│ ├── 命中 → 直接返回 ✅
│ │
│ └── 未命中 → ② 查数据库
│ │
│ ▼
│ ③ 写入缓存(设 TTL)
│ │
└── ← ── ← ── ← ── ── 返回数据
Cache-Aside 写路径:
应用
│
├──→ ① 更新数据库
│
└──→ ② 删除缓存(不是更新!)
→ 下次读取时会重新从 DB 加载最新数据模式 2:Read-Through / Write-Through
Read-Through:缓存层自动加载
应用 → 缓存层 → 缓存未命中?→ 缓存层自己去查 DB → 写入缓存 → 返回
区别:应用只和缓存交互,不直接访问 DB
优点:应用代码更简洁
缺点:需要缓存中间件支持(如 NCache、Hazelcast)
Write-Through:写入时同步更新缓存和 DB
应用 → 写缓存 → 缓存层同步写 DB → 返回成功
优点:缓存和 DB 强一致
缺点:写入延迟增加(要写两遍)模式 3:Write-Behind(异步写回)
Write-Behind:先写缓存,异步刷回 DB
应用 → 写缓存 → 立即返回成功 → 后台异步批量写 DB
优点:写入极快(只写内存)
缺点:缓存挂了数据丢失!(Redis 宕机 = 数据丢失)
适用:允许少量丢失的场景(如点赞计数、页面浏览量)三种模式对比:
| 模式 | 一致性 | 写入速度 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| Cache-Aside | 最终一致 | 中 | 低 | ⭐ 90% 的场景 |
| Read/Write-Through | 强一致 | 慢 | 中 | 需要框架支持 |
| Write-Behind | 弱 | 极快 | 高 | 允许丢数据 |
💡 面试默认答 Cache-Aside——它最简单、最通用、最容易说清楚。然后主动提一句"写路径是先更新 DB 再删缓存,而不是更新缓存",这是面试官等着听的关键细节。
5.3 深入设计:缓存穿透、雪崩、击穿的解决方案
这三个问题是缓存面试的必考送分题——答不上来直接扣分。
问题 1:缓存穿透 — 查询不存在的数据
缓存穿透:
攻击者请求 user_id = -1(数据库里不存在)
│
▼
① 查 Redis → 未命中
② 查 DB → 也不存在
③ 不往缓存写(因为没数据)
→ 每次请求都穿透到数据库!
→ 攻击者用大量不存在的 ID 发请求 → DB 被打爆
解决方案 1:缓存空值
═══════════════════════════════
DB 查不到?→ 在 Redis 写入 key="" TTL=5min
下次同样的请求 → Redis 命中空值 → 直接返回"不存在"
解决方案 2:布隆过滤器(Bloom Filter)
═══════════════════════════════
在 Redis 前加一层 Bloom Filter
请求到达 → 先查布隆过滤器 → "这个 ID 一定不存在" → 直接拒绝
布隆过滤器说"可能存在" → 继续查缓存和 DB问题 2:缓存雪崩 — 大量 Key 同时过期
缓存雪崩:
场景:凌晨 3 点批量预热了 100 万个 Key,TTL 都设为 24 小时
→ 第二天凌晨 3 点,100 万个 Key 同时过期
→ 瞬间 100 万个请求穿透到数据库 → DB 宕机
解决方案 1:TTL 加随机偏移
═══════════════════════════════
TTL = base_ttl + random(0, 300)
→ 100 万个 Key 在 24h ~ 24h5m 之间随机过期
→ 不会同时失效
解决方案 2:多级缓存
═══════════════════════════════
L1: 本地缓存(进程内,Caffeine / lru_cache)
L2: Redis 分布式缓存
→ Redis 挂了,本地缓存还能扛一阵
解决方案 3:缓存永不过期 + 异步更新
═══════════════════════════════
缓存不设 TTL,由后台任务定时刷新
→ 彻底消除过期导致的穿透问题 3:缓存击穿 — 热 Key 过期的瞬间
缓存击穿:
场景:某明星出轨,相关新闻的缓存 Key 是超级热点
→ 该 Key 过期的瞬间,10 万个请求同时涌入
→ 10 万个请求全部打到 DB → DB 宕机
解决方案:互斥锁(分布式锁)
═══════════════════════════════
缓存未命中 → 尝试获取分布式锁(Redis SETNX)
获取成功 → 查 DB → 写缓存 → 释放锁
获取失败 → 等 50ms → 重试读缓存(此时大概率已被写入)
→ 只有 1 个请求穿透到 DB,其他请求等待| 问题 | 现象 | 核心解决方案 |
|---|---|---|
| 穿透 | 查不存在的数据 | 缓存空值 / 布隆过滤器 |
| 雪崩 | 大量 Key 同时过期 | TTL 加随机偏移 |
| 击穿 | 热 Key 瞬间过期 | 互斥锁(SETNX) |
💡 面试中把这三个问题一一说清楚,基本就过了。 关键是能区分三者的不同——穿透是"查不到",雪崩是"同时失效",击穿是"热点失效"。
5.4 扩展讨论:一致性哈希分片、热 Key 问题、缓存与 DB 一致性
一致性哈希——缓存集群的分片策略:
为什么不用简单取模?
hash(key) % N → 节点编号
→ N 变了(加减节点)→ 几乎所有 Key 重新映射 → 缓存大面积失效
一致性哈希:
把 0~2^32 的哈希空间组成一个环
节点和 Key 都映射到环上
Key 顺时针找到的第一个节点 = 数据所在节点
加减节点时,只影响环上相邻节点的 Key → 迁移量极小热 Key 打散:
热 Key 问题:
某个 Key 的访问量极大 → 全部请求打到同一个 Redis 节点
→ 该节点成为瓶颈
解决方案:Key 加后缀打散到多个节点
原始 Key: hot_news_123
打散后: hot_news_123_0, hot_news_123_1, ... hot_news_123_9
读取时: 随机选一个后缀读取
写入时: 同时更新所有 10 个副本
→ 10 个 Key 分散在不同节点,压力降 10 倍缓存与 DB 一致性——面试官最爱追问的难题:
先更新 DB 再删缓存(Cache-Aside 标准做法):
为什么是"删缓存"而不是"更新缓存"?
═══════════════════════════════
并发场景下,"更新缓存"可能导致脏数据:
A: 更新 DB 为 10
B: 更新 DB 为 20
B: 更新缓存为 20
A: 更新缓存为 10 ← 缓存变成旧值!(DB 是 20)
"删缓存"则不会:
A: 更新 DB 为 10 → 删缓存
B: 更新 DB 为 20 → 删缓存
下次读取 → 从 DB 加载最新值 20 → 写入缓存
→ 一定是最新值 ✅💡 延迟双删(进阶方案):写 DB → 删缓存 → 等 500ms → 再删一次缓存。第二次删除是为了防止"读线程在删缓存之前读到旧值,又把旧值写回了缓存"的极端情况。
第 5 章核心知识回顾:
| 概念 | 一句话解释 |
|---|---|
| Cache-Aside | 读时加载、写时删缓存,最通用的模式 |
| 穿透/雪崩/击穿 | 查不到/同时过期/热点过期,三个问题三种方案 |
| 一致性哈希 | 加减节点只影响相邻,避免大面积缓存失效 |
| 热 Key 打散 | Key 加后缀分散到多节点 |
| 删缓存 > 更新缓存 | 删缓存避免并发写导致的脏数据 |
第六章 设计消息推送系统(Push Notification)
消息推送系统的难点不在"推一条消息",而在百万级连接管理、消息可靠性和多端同步。这道题考的是你对长连接、消息队列和推送网关的理解。
6.1 需求分析:实时推送 vs 离线推送、推送通道选型
功能需求:
核心场景:
1. 实时推送:用户在线时,消息即时送达(如 IM 聊天)
2. 离线推送:用户不在线时,通过 APNs/FCM 推送通知
3. 多端同步:手机、PC、Web 同时收到消息
4. 消息可靠:不丢消息、不重复推送推送通道选型:
| 方案 | 延迟 | 服务端推送 | 连接数开销 | 适用场景 |
|---|---|---|---|---|
| 轮询 | 高(秒级) | ❌ 客户端发起 | 低 | 几乎不用了 |
| 长轮询 | 中 | ✅ 挂起等待 | 中 | 兼容性好 |
| WebSocket | 低(ms 级) | ✅ 双向 | 高(长连接) | ⭐ 实时通信首选 |
| SSE | 低 | ✅ 单向推送 | 中 | 通知/Feed 更新 |
| APNs/FCM | 不确定 | ✅ 系统级推送 | 零 | 离线/移动端通知 |
💡 面试推荐组合:在线用 WebSocket 实时推送,离线用 APNs/FCM 系统推送。这是所有 IM 系统的标准做法。
6.2 高层设计:连接管理层 + 消息路由层 + 推送网关层
消息推送系统的三层架构:
发送方
│
▼
┌─────────────────────────────────────┐
│ 消息服务(Message Service) │
│ • 接收消息、持久化存储 │
│ • 判断接收方在线状态 │
└─────────────┬───────────────────────┘
│
┌───────────┼───────────┐
▼ ▼ ▼
┌──────┐ ┌──────┐ ┌──────────┐
│ 在线 │ │ 离线 │ │ 存储 │
│ 推送 │ │ 推送 │ │ 离线消息 │
└──┬───┘ └──┬───┘ └──────────┘
│ │
▼ ▼
WebSocket APNs / FCM
连接管理 推送网关
│
▼
接收方(在线)连接管理层的核心设计:
百万级 WebSocket 连接管理:
用户上线时:
═══════════════════════════════
① 客户端与 Gateway 建立 WebSocket 连接
② Gateway 在 Redis 注册:user_123 → gateway_node_2
③ 维护心跳(每 30s ping/pong)
推送消息时:
═══════════════════════════════
① 消息服务查 Redis → user_123 在 gateway_node_2
② 通过内部 RPC 发给 gateway_node_2
③ gateway_node_2 通过 WebSocket 推给客户端
用户下线时:
═══════════════════════════════
① 心跳超时 / 连接断开
② 从 Redis 删除 user_123 的连接信息
③ 后续消息走离线存储 + APNs/FCM关键数据结构(Redis):
# 用户在线状态
online:user_123 → { gateway: "node_2", connected_at: 1712345678 }
TTL = 60s(心跳续期)
# 用户的设备列表(多端同步)
devices:user_123 → [
{ device_id: "iphone_abc", gateway: "node_2", platform: "ios" },
{ device_id: "web_xyz", gateway: "node_5", platform: "web" }
]6.3 深入设计:百万级长连接管理、消息可靠投递与去重
单机连接数瓶颈与水平扩展:
单机 WebSocket 连接数上限:
Linux 默认 → ~65,000(受端口数限制)
调优后 → ~100,000 - 500,000(调大 ulimit、文件描述符)
100 万在线用户 → 需要 3-10 台 Gateway 节点
水平扩展架构:
客户端
│
▼
负载均衡(支持 WebSocket 的 L7 LB)
│
├──→ Gateway Node 1(10 万连接)
├──→ Gateway Node 2(10 万连接)
├──→ Gateway Node 3(10 万连接)
...
└──→ Gateway Node N
注意:WebSocket 是长连接,不能用普通的轮询 LB
→ 使用 IP Hash 或 Connection-based 负载均衡消息可靠投递(ACK 机制):
消息投递的三种语义:
至多一次(At-most-once):发了就不管 → 可能丢消息
至少一次(At-least-once):没收到 ACK 就重发 → 可能重复
精确一次(Exactly-once):去重 + 至少一次 → 理想但复杂
推荐方案:至少一次 + 客户端去重
服务端 客户端
│ │
│ ──── 推送消息 (msg_id=100) ────→ │
│ │
│ ←── ACK (msg_id=100) ────────── │
│ │
│ 超时未收到 ACK?重发! │
│ ──── 推送消息 (msg_id=100) ────→ │
│ │ → 客户端检查 msg_id
│ ←── ACK (msg_id=100) ────────── │ 已收过?丢弃(去重)消息去重(客户端侧):
"""客户端消息去重(维护已收消息 ID 集合)"""
class MessageDeduplicator:
def __init__(self, max_size=10000):
self.seen_ids = set() # 或用 Redis Bloom Filter
def is_duplicate(self, msg_id: str) -> bool:
if msg_id in self.seen_ids:
return True # 重复消息,丢弃
self.seen_ids.add(msg_id)
return False💡 面试中的关键表述:消息系统不追求"精确一次"——因为网络不可靠,精确一次的成本极高。工业界的做法是"至少一次投递 + 接收端幂等去重"。
6.4 扩展讨论:多端同步、推送优先级、APNs / FCM 集成
多端同步:
用户同时在手机和电脑上登录:
消息服务收到发给 user_123 的消息
│
▼
查 Redis → user_123 有 2 个设备在线
│
├──→ Gateway Node 2 → WebSocket → iPhone
│
└──→ Gateway Node 5 → WebSocket → Web 浏览器
→ 两端同时收到消息 ✅
→ 一端已读 → 同步"已读"状态到另一端推送优先级——不是所有消息都一样紧急:
| 优先级 | 消息类型 | 处理方式 |
|---|---|---|
| P0 紧急 | 私聊消息、支付通知 | 立即推送,离线也推 APNs |
| P1 普通 | 群聊消息、评论 | 在线推送,离线存储不推 |
| P2 低 | 推荐内容、营销 | 攒批推送,客户端拉取 |
离线推送集成(APNs / FCM):
用户离线时的推送链路:
消息到达 → 查在线状态 → 离线!
│
▼
存入离线消息表
│
▼
查用户设备 Token(推送注册时保存的)
│
├── iOS → 调 APNs HTTP/2 API
└── Android → 调 FCM HTTP API
用户上线时:
① 拉取离线消息(GET /messages?since=last_msg_id)
② 标记已送达第 6 章核心知识回顾:
| 概念 | 一句话解释 |
|---|---|
| WebSocket + APNs/FCM | 在线走 WebSocket,离线走系统推送 |
| Gateway 集群 | 单机 10 万连接,多节点水平扩展 |
| Redis 路由表 | user_id → gateway_node,实现消息路由 |
| ACK + 去重 | 至少一次投递 + 客户端 msg_id 去重 |
| 推送优先级 | P0 立即推送,P2 攒批推送 |
第七章 设计搜索自动补全系统(Typeahead)
用户输入 "sys" → 下拉框立即出现 "system design", "systematic", "syslog"。看似简单,但要在 50ms 内从亿级候选词中返回 Top 10,背后的数据结构和缓存策略是考点。
7.1 需求分析:延迟要求、候选词数量、个性化
| 维度 | 假设值 |
|---|---|
| 候选词总量 | 1 亿条(搜索历史 + 热搜) |
| DAU | 1 亿 |
| 每人每天搜索 | 5 次,平均输入 5 个字符 |
| 延迟要求 | P99 < 50ms |
| 每次按键触发 | 是(最多 10 QPS/用户) |
QPS 估算:
每次搜索 → 5 个字符 → 5 次自动补全请求
1 亿 × 5 × 5 ÷ 10^5 = 25,000 QPS
峰值 ≈ 75,000 QPS → 需要缓存7.2 高层设计:数据收集层 + 索引构建层 + 查询层
Typeahead 系统的三层架构:
用户输入 "sys"
│
▼
┌──────────────────────────┐
│ 查询层(< 50ms) │
│ ① 查浏览器/CDN 缓存 │
│ ② 查 Redis 缓存 │
│ ③ 查 Trie 索引服务 │
└──────────────────────────┘
▲
│ 离线构建
┌──────────────────────────┐
│ 索引构建层(异步) │
│ 收集搜索日志 → 统计词频 │
│ → 构建 Trie 树 │
│ → 每 15 分钟更新一次 │
└──────────────────────────┘
▲
│
┌──────────────────────────┐
│ 数据收集层 │
│ 搜索日志 → Kafka → 聚合 │
└──────────────────────────┘7.3 深入设计:Trie 树优化、Top-K 热度排序、多级缓存
Trie 树(前缀树)——核心数据结构:
Trie 树存储 ["tree", "try", "true", "toy"]:
root
│
└── t
├── r
│ ├── e
│ │ └── e ⭐ "tree" (热度: 500)
│ ├── u
│ │ └── e ⭐ "true" (热度: 300)
│ └── y ⭐ "try" (热度: 200)
└── o
└── y ⭐ "toy" (热度: 100)
查询 "tr" → 遍历 t → r → 收集子树所有词
→ ["tree"(500), "true"(300), "try"(200)]
→ 按热度排序返回 Top 10优化:每个节点缓存 Top-K 结果
优化后的 Trie(每个节点缓存 Top 10):
节点 "t" → top10: ["tree", "try", "true", "toy", ...]
节点 "tr" → top10: ["tree", "true", "try"]
节点 "tre"→ top10: ["tree"]
查询 "tr" → 直接返回该节点的 top10 → O(1)!
不需要遍历子树!多级缓存策略:
| 层级 | 缓存内容 | TTL | 命中率 |
|---|---|---|---|
| 浏览器缓存 | 最近搜索过的前缀 | 会话级 | ~30% |
| CDN 缓存 | 热门前缀("怎么", "为什么") | 5 min | ~40% |
| Redis | 所有前缀的 Top 10 | 15 min | ~25% |
| Trie 服务 | 全量索引 | — | ~5% |
💡 三级缓存加起来 95% 命中率,只有 5% 的请求打到 Trie 索引服务。75000 QPS × 5% = 3750 QPS → 单个 Trie 服务可以轻松扛住。
7.4 扩展讨论:多语言支持、实时热搜更新、个性化排序
实时热搜更新:
搜索日志 → Kafka → Flink 实时聚合
→ 每 5 分钟更新热度排名
→ 增量更新 Trie 节点的 Top-K 列表
→ 刷新 Redis 缓存个性化排序:在全局热度基础上,加入用户自身的搜索历史加权。score = global_hot × 0.7 + user_history × 0.3。
防抖/节流:客户端不是每次按键都发请求——等用户停止输入 200ms 后再发请求(debounce),避免无效请求。
第 7 章核心知识回顾:
| 概念 | 一句话解释 |
|---|---|
| Trie 树 | 前缀匹配的最优数据结构 |
| 节点缓存 Top-K | 每个节点缓存热门结果,查询 O(1) |
| 三级缓存 | 浏览器 → CDN → Redis,95% 命中率 |
| 异步构建 | 搜索日志 → Kafka → 聚合 → 定期重建索引 |
| 防抖 | 客户端 debounce 200ms,减少无效请求 |
第八章 设计分布式文件存储(类 S3 / Google Drive)
这道题的核心思想:元数据和文件数据分开存储。理解了这一点,整个架构就通了。
8.1 需求分析:上传/下载/共享/版本控制
| 维度 | 假设值 |
|---|---|
| 用户数 | 5 亿注册,5000 万 DAU |
| 每人平均文件 | 200 个,平均 500KB |
| 总存储 | 5 亿 × 200 × 500KB = 50 PB |
| 上传 QPS | 5000 万 × 2 ÷ 10^5 = 1000 QPS |
| 下载 QPS | 1000 × 5 = 5000 QPS(读多写少) |
8.2 高层设计:元数据服务 + 块存储 + 客户端同步
分布式文件存储的核心架构:
客户端
│
├── 上传:文件 → 分块 → 上传到块存储 → 更新元数据
│
└── 下载:查元数据 → 获取块列表 → 从块存储下载 → 拼接
┌──────────────┐
│ API 服务 │
└──────┬───────┘
│
┌────┴────┐
▼ ▼
┌──────┐ ┌──────────────┐
│ 元数据│ │ 块存储 │
│ 服务 │ │ (Block Store)│
│(MySQL)│ │ (S3/MinIO) │
└──────┘ └──────────────┘
元数据:文件名、大小、拥有者、块 ID 列表、版本号
块数据:实际的文件内容,按 4MB 分块存储元数据模型:
-- 文件元数据
CREATE TABLE files (
id BIGINT PRIMARY KEY,
name VARCHAR(255),
user_id BIGINT,
size BIGINT,
checksum VARCHAR(64), -- SHA256 校验
version INT DEFAULT 1,
is_deleted BOOLEAN DEFAULT false,
created_at TIMESTAMP,
updated_at TIMESTAMP
);
-- 文件与块的映射
CREATE TABLE file_blocks (
file_id BIGINT,
block_index INT, -- 第几个块
block_hash VARCHAR(64), -- 块的 SHA256
block_size INT,
PRIMARY KEY (file_id, block_index)
);8.3 深入设计:文件分块、去重(Deduplication)、一致性同步
文件分块上传:
文件分块的好处:
100MB 文件 → 分成 25 个 4MB 的块
① 断点续传:上传到第 15 块断了 → 从第 16 块继续
② 并行上传:多个块同时上传,提高速度
③ 去重:相同内容的块只存一份
④ 增量同步:文件修改后只上传变化的块去重(Deduplication)——节省 50%+ 存储:
去重原理:
文件 A: [块1_abc, 块2_def, 块3_ghi]
文件 B: [块1_abc, 块2_xyz, 块3_ghi]
↑
块1 和 块3 内容一样!
对每个块计算 SHA256 → 相同 hash = 相同内容
→ 块1_abc 和 块3_ghi 只存一份
→ 文件 B 实际只需要额外存 块2_xyz
节省效果:企业文档共享场景,去重率可达 60-80%版本控制(简化版):
使用版本链表:
file_v1 → [块A, 块B, 块C]
file_v2 → [块A, 块B', 块C] ← 只有块 B 变了
file_v3 → [块A, 块B', 块C, 块D] ← 追加了块 D
→ 每个版本存一份块列表(非常小)
→ 块本身去重存储,不同版本共享相同的块
→ 回滚 = 切换元数据指向旧版本的块列表8.4 扩展讨论:断点续传、CDN 加速、冷热数据分层
断点续传:客户端记录已上传的块 index,断网恢复后查询服务端已收到哪些块,从下一个块继续。
冷热数据分层:
| 层级 | 存储介质 | 成本 | 适用数据 |
|---|---|---|---|
| 热存储 | SSD | 高 | 近 30 天活跃文件 |
| 温存储 | HDD | 中 | 30-180 天未访问 |
| 冷存储 | S3 Glacier | 极低 | 180 天+ 未访问 |
第 8 章核心知识回顾:
| 概念 | 一句话解释 |
|---|---|
| 元数据 + 块分离 | 文件信息存 DB,文件内容分块存对象存储 |
| 4MB 分块 | 支持断点续传、并行上传、增量同步 |
| SHA256 去重 | 相同内容的块只存一份,节省 60%+ |
| 版本控制 | 每个版本只存块列表引用,不复制块 |
| 冷热分层 | SSD → HDD → Glacier,按访问频率分级 |
第九章 设计秒杀系统
秒杀是所有高并发场景里最极端的——100 万人在同一秒抢 100 件商品。这道题考的不是 CRUD,是你对流量控制、原子操作和降级策略的理解。
9.1 需求分析:极高并发 + 极低库存 + 极短时间窗口
秒杀的三个极端特征:
① 极高并发:100 万用户同时点击"抢购"
→ 瞬时 QPS 可达 100 万+
② 极低库存:只有 100 件商品
→ 99.99% 的请求注定失败
③ 极短时间窗口:从开始到售罄 < 10 秒
→ 系统必须在几秒内处理完所有请求
核心矛盾:100 万个请求,只有 100 个能成功
→ 设计目标:尽早拒绝 99.99% 的请求,保护后端9.2 高层设计:CDN 静态化 → 网关限流 → 队列削峰 → 库存扣减
秒杀系统的流量漏斗模型:
100 万请求
│
▼
┌────────────────────┐
│ 第 1 层:CDN 静态化 │ 秒杀页面 HTML/JS 全部走 CDN
│ 拦截 60% 的请求 │ 倒计时、按钮禁用都在前端
└────────┬───────────┘
│ 40 万请求
▼
┌────────────────────┐
│ 第 2 层:网关限流 │ IP 限流 + 用户限流
│ 拦截 90% │ 同一用户 1 秒内只允许 1 次
└────────┬───────────┘
│ 4 万请求
▼
┌────────────────────┐
│ 第 3 层:Redis 预判 │ 库存已经扣完?直接返回"已售罄"
│ 拦截 99% │ 内存操作,< 1ms
└────────┬───────────┘
│ 400 请求
▼
┌────────────────────┐
│ 第 4 层:消息队列 │ 进入排队,异步处理
│ 削峰填谷 │ 消费者按顺序扣减数据库库存
└────────┬───────────┘
│ 100 个成功
▼
创建订单 → 支付9.3 深入设计:Redis 预扣库存、消息队列异步下单、防超卖
Redis 预扣库存——秒杀的核心技术点:
"""Redis 预扣库存(Lua 脚本保证原子性)"""
SECKILL_LUA = """
local stock_key = KEYS[1]
local user_set_key = KEYS[2]
local user_id = ARGV[1]
-- 1. 检查用户是否已抢过(防重复)
if redis.call('SISMEMBER', user_set_key, user_id) == 1 then
return -1 -- 重复抢购
end
-- 2. 检查库存
local stock = tonumber(redis.call('GET', stock_key))
if stock <= 0 then
return 0 -- 已售罄
end
-- 3. 扣减库存 + 记录用户
redis.call('DECR', stock_key)
redis.call('SADD', user_set_key, user_id)
return 1 -- 抢购成功
"""
# 初始化:秒杀开始前
r.set("seckill:item_001:stock", 100) # 预加载库存到 Redis为什么不直接扣数据库?
Redis vs 数据库扣库存:
Redis:
═══════════════════════════════
DECR seckill:stock → 原子操作,< 0.1ms
10 万 QPS 轻松扛住
数据库:
═══════════════════════════════
UPDATE items SET stock = stock - 1
WHERE id = ? AND stock > 0;
→ 行锁竞争,~5ms/次,最多 ~2000 QPS
→ 100 万并发直接打爆
结论:Redis 预扣 → 消息队列 → 异步落库(数据库只处理 100 个订单)消息队列异步下单:
Redis 扣减成功后的流程:
Redis 返回 1(抢购成功)
│
▼
发送消息到 Kafka
{ user_id: 123, item_id: "001", timestamp: ... }
│
▼
消费者按顺序处理:
① 数据库扣减库存(双重保险)
② 创建订单
③ 给用户发通知:"抢购成功,请在 15 分钟内支付"
→ 数据库只需要处理 ~100 个订单 → 毫无压力9.4 扩展讨论:热 Key 打散、库存分桶、风控反作弊
库存分桶——应对单 Key 瓶颈:
100 件库存 → 分成 10 个桶,每桶 10 件
seckill:item_001:bucket_0 → 10
seckill:item_001:bucket_1 → 10
...
seckill:item_001:bucket_9 → 10
用户请求 → hash(user_id) % 10 → 路由到对应桶
→ 10 个 Key 分散在不同 Redis 节点
→ 单 Key 压力降 10 倍风控反作弊:
| 手段 | 方法 |
|---|---|
| 防机器人 | 验证码 / 滑块验证 |
| 防刷接口 | 同 IP 限流 + 同设备指纹限流 |
| 黑名单 | 历史作弊用户直接拦截 |
| 请求签名 | 前端加密请求参数,防止直接调 API |
未支付订单的处理:15 分钟未支付 → 延迟队列自动取消订单 → 库存回滚。
第 9 章核心知识回顾:
| 概念 | 一句话解释 |
|---|---|
| 流量漏斗 | CDN → 限流 → Redis → 队列,层层过滤 |
| Redis 预扣库存 | Lua 原子操作,10 万 QPS |
| 异步下单 | Kafka 削峰,DB 只处理成功的订单 |
| 库存分桶 | 分散热 Key 压力到多个节点 |
| 延迟队列 | 15 分钟未支付自动取消 + 回滚库存 |
附录
A. 系统设计面试常用数字速查表
| 指标 | 数值 |
|---|---|
| 1 天 | ≈ 10^5 秒 |
| Redis 单机 QPS | ~10 万 |
| MySQL/PG 单机写 QPS | ~2000 |
| Kafka 单 Partition 写 | ~10 万 msg/s |
| WebSocket 单机连接 | ~10 万(调优后) |
| Base62 7 位编码空间 | 3.5 万亿 |
| Snowflake 单机/ms | 4096 个 ID |
B. 容量估算模板
① QPS = DAU × 每人每天操作数 ÷ 10^5
② 峰值 QPS = 平均 QPS × 3
③ 存储 = 每条大小 × 每天新增 × 保留天数
④ 带宽 = QPS × 平均响应大小
⑤ 服务器数 = 峰值 QPS ÷ 单机 QPSC. 每道题的一句话总结
| 题目 | 核心方案 | 面试必说的关键词 |
|---|---|---|
| 短链接 | 自增 ID + Base62 | 读写比 100:1、301 vs 302、缓存 |
| Feed 流 | 推拉结合 | fan-out、大 V 阈值、Redis ZSET |
| 限流器 | 令牌桶 + 滑动窗口 | Redis Lua 原子性、429 Retry-After |
| 分布式缓存 | Cache-Aside | 穿透/雪崩/击穿、删缓存不更新 |
| 消息推送 | WebSocket + APNs | Gateway 集群、ACK 去重 |
| 搜索补全 | Trie + Top-K 缓存 | 三级缓存 95% 命中、防抖 |
| 文件存储 | 元数据 + 块分离 | 4MB 分块、SHA256 去重 |
| 秒杀 | Redis 预扣 + 队列削峰 | 流量漏斗、Lua 原子操作、库存分桶 |
D. 推荐阅读与学习资源
| 资源 | 说明 |
|---|---|
| 《System Design Interview》Alex Xu | 系统设计面试圣经,本文的灵感来源 |
| 《Designing Data-Intensive Applications》 | 分布式系统底层原理的最佳教材 |
| system-design-primer (GitHub) | 开源系统设计学习路线 |
| 本知识库《分布式系统设计入门》 | 10 章底层原理,与本文互补 |