Skip to content

系统设计面试高频题解

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(单机)典型延迟
Redis100,000+100,000+< 1ms
Memcached200,000+200,000+< 1ms
PostgreSQL5,000-10,0001,000-5,0001-10ms
MySQL5,000-10,0001,000-5,0001-10ms
MongoDB10,000-50,0005,000-20,0001-5ms
Elasticsearch1,000-5,000500-2,0005-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/302

API 设计

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。

数据模型

sql
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 位

python
"""方案 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 编码(推荐)

python
"""方案 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 302301 浏览器缓存(省流量),302 每次过服务器(可统计)
缓存命中率帕累托法则,80%+ 命中率,DB 压力降 5 倍
异步统计点击事件走 Kafka,不影响重定向延迟

第三章 设计 Feed 流系统(Twitter / 朋友圈)

Feed 流是面试中最能区分候选人水平的题目——没有"正确答案",全看你怎么做 trade-off。关键决策就一个:推还是拉?

3.1 需求分析:发布、关注、信息流的核心场景

功能需求

核心功能:
  1. 用户可以发布内容(文字/图片/视频)
  2. 用户可以关注其他用户
  3. 用户打开首页时,看到关注的人发布的内容(时间线)
  4. Feed 按时间倒序排列(或按推荐算法排序)

非功能需求

维度假设值
DAU3 亿
每人每天刷 Feed10 次
每人关注数(中位数)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 20

Feed 缓存设计(Redis Sorted Set):

python
"""每个用户的 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 SetFeed 收件箱用 ZSET,score=时间戳,天然有序
两阶段排序召回 500 条 → ML 排序 → 返回 Top 20

第四章 设计分布式限流器(Rate Limiter)

限流器看似简单——"每分钟最多 100 次请求",但在分布式环境中,多台服务器共享同一个计数器的一致性问题,是面试官爱考的难点。

4.1 需求分析:限流的场景、限流规则(用户/IP/API 维度)

为什么需要限流?

限流的三大目的:

  ① 防止恶意攻击
     → DDoS 攻击、暴力破解、爬虫
     
  ② 保护后端资源
     → 数据库连接数有限,不能无限接受请求
     
  ③ 公平使用
     → 免费用户 100 次/分钟,付费用户 1000 次/分钟

限流维度(面试中需要确认的):

维度示例适用场景
用户user_id = 123 → 100 req/minAPI 配额
IP192.168.1.1 → 50 req/min未登录用户防刷
APIPOST /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 实现:

python
"""分布式滑动窗口限流器(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) ──────────  │   已收过?丢弃(去重)

消息去重(客户端侧):

python
"""客户端消息去重(维护已收消息 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 亿条(搜索历史 + 热搜)
DAU1 亿
每人每天搜索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 1015 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
上传 QPS5000 万 × 2 ÷ 10^5 = 1000 QPS
下载 QPS1000 × 5 = 5000 QPS(读多写少)

8.2 高层设计:元数据服务 + 块存储 + 客户端同步

分布式文件存储的核心架构:

  客户端

    ├── 上传:文件 → 分块 → 上传到块存储 → 更新元数据

    └── 下载:查元数据 → 获取块列表 → 从块存储下载 → 拼接

  ┌──────────────┐
  │  API 服务     │
  └──────┬───────┘

    ┌────┴────┐
    ▼         ▼
  ┌──────┐  ┌──────────────┐
  │ 元数据│  │   块存储      │
  │ 服务  │  │  (Block Store)│
  │(MySQL)│  │  (S3/MinIO)  │
  └──────┘  └──────────────┘

  元数据:文件名、大小、拥有者、块 ID 列表、版本号
  块数据:实际的文件内容,按 4MB 分块存储

元数据模型

sql
-- 文件元数据
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 天活跃文件
温存储HDD30-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 预扣库存——秒杀的核心技术点:

python
"""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 单机/ms4096 个 ID

B. 容量估算模板

① QPS = DAU × 每人每天操作数 ÷ 10^5
② 峰值 QPS = 平均 QPS × 3
③ 存储 = 每条大小 × 每天新增 × 保留天数
④ 带宽 = QPS × 平均响应大小
⑤ 服务器数 = 峰值 QPS ÷ 单机 QPS

C. 每道题的一句话总结

题目核心方案面试必说的关键词
短链接自增 ID + Base62读写比 100:1、301 vs 302、缓存
Feed 流推拉结合fan-out、大 V 阈值、Redis ZSET
限流器令牌桶 + 滑动窗口Redis Lua 原子性、429 Retry-After
分布式缓存Cache-Aside穿透/雪崩/击穿、删缓存不更新
消息推送WebSocket + APNsGateway 集群、ACK 去重
搜索补全Trie + Top-K 缓存三级缓存 95% 命中、防抖
文件存储元数据 + 块分离4MB 分块、SHA256 去重
秒杀Redis 预扣 + 队列削峰流量漏斗、Lua 原子操作、库存分桶

D. 推荐阅读与学习资源

资源说明
《System Design Interview》Alex Xu系统设计面试圣经,本文的灵感来源
《Designing Data-Intensive Applications》分布式系统底层原理的最佳教材
system-design-primer (GitHub)开源系统设计学习路线
本知识库《分布式系统设计入门》10 章底层原理,与本文互补

坚持是一种品格