引言

系统设计面试已经成为中高级工程师面试的标准环节。与算法题不同,系统设计没有"标准答案"——它考察的是你分析问题、权衡取舍和沟通协作的能力。

本文将从方法论入手,建立一套可复用的答题框架,然后通过 URL 短链接、聊天系统和限流器三个经典案例,演示如何在实际面试中运用这些方法。


一、系统设计面试方法论

答题框架:4 步法

code
1. 需求澄清(5-10 分钟)
   ├─ 功能需求:系统要做什么?
   ├─ 非功能需求:QPS、延迟、可用性要求?
   └─ 边界条件:用户量级、数据规模、读写比例?

2. 高层设计(10-15 分钟)
   ├─ 画出核心组件图
   ├─ 确定数据流方向
   └─ 选择通信协议(HTTP、WebSocket、gRPC、消息队列)

3. 深入细节(15-20 分钟)
   ├─ 数据模型设计
   ├─ 关键算法选型
   ├─ 扩展策略(分片、缓存、复制)
   └─ 瓶颈分析与优化

4. 总结回顾(5 分钟)
   ├─ 系统容量估算
   ├─ 可能的故障点
   └─ 未来演进方向

核心原则

原则 说明
先广度后深度 不要一开始就钻进某个细节,先画出整体架构
用数字说话 做任何技术决策前,先估算数据量和 QPS
没有银弹 每个方案都有 trade-off,主动分析利弊
由简入繁 从单体 → 微服务,从单库 → 分片,逐步演进
提及监控 展示工程成熟度:日志、指标、告警

容量估算速查

面试中常用的数量级参考:

操作类型 延迟参考
L1 缓存引用 0.5 ns
L2 缓存引用 7 ns
主存引用 100 ns
SSD 随机读 100 μs
内存 1MB 顺序读 3 μs
SSD 1MB 顺序读 1 ms
同数据中心往返 0.5 ms
跨洲网络往返 150 ms

二、案例一:URL 短链接系统(TinyURL)

需求分析

功能需求:

非功能需求:

短码生成算法

这是 URL 短链接系统的核心。我们来分析几种方案:

方案 A:Hash 函数

code
长 URL → MD5/SHA256 → 取前 7 位 Base62 → 短码

优点:实现简单,同 URL 生成同短码
缺点:Hash 碰撞需要额外处理
python
import hashlib

BASE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

def generate_short_code(url: str) -> str:
    hash_hex = hashlib.md5(url.encode()).hexdigest()
    # 取前 8 个十六进制字符转为整数
    num = int(hash_hex[:8], 16)
    
    # 转为 Base62
    chars = []
    for _ in range(7):
        chars.append(BASE62[num % 62])
        num //= 62
    
    return ''.join(reversed(chars))

方案 B:分布式 ID 生成器

code
Snowflake ID → Base62 编码 → 短码

优点:无碰撞,可排序,包含时间戳信息
缺点:短码不固定长度
python
# 简易 Snowflake 实现
import time

class SnowflakeID:
    def __init__(self, worker_id: int, datacenter_id: int):
        self.worker_id = worker_id
        self.datacenter_id = datacenter_id
        self.sequence = 0
        self.last_timestamp = -1
        self.epoch = 1700000000000  # 自定义起始时间

    def next_id(self) -> int:
        timestamp = int(time.time() * 1000)
        
        if timestamp < self.last_timestamp:
            raise Exception('时钟回拨')
        
        if timestamp == self.last_timestamp:
            self.sequence = (self.sequence + 1) & 0xFFF
            if self.sequence == 0:
                while timestamp <= self.last_timestamp:
                    timestamp = int(time.time() * 1000)
        else:
            self.sequence = 0
        
        self.last_timestamp = timestamp
        
        return (
            ((timestamp - self.epoch) << 22) |
            (self.datacenter_id << 17) |
            (self.worker_id << 12) |
            self.sequence
        )

数据模型

sql
CREATE TABLE short_urls (
    id BIGINT PRIMARY KEY,
    short_code VARCHAR(10) UNIQUE NOT NULL,
    original_url TEXT NOT NULL,
    user_id BIGINT,
    expires_at TIMESTAMP,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    click_count BIGINT DEFAULT 0,
    INDEX idx_short_code (short_code),
    INDEX idx_expires_at (expires_at)
);

架构设计

code
                    ┌──────────────┐
                    │   CDN/DNS    │
                    └──────┬───────┘
                           │
                    ┌──────▼───────┐
                    │ Load Balancer│
                    └──────┬───────┘
                           │
              ┌────────────┼────────────┐
              │            │            │
     ┌────────▼───┐ ┌─────▼────┐ ┌────▼──────┐
     │  API Server │ │ API Srv  │ │ API Server │
     └────────┬───┘ └─────┬────┘ └────┬──────┘
              │            │            │
              └────────────┼────────────┘
                           │
              ┌────────────┼────────────┐
              │            │            │
     ┌────────▼───┐ ┌─────▼─────┐ ┌───▼────────┐
     │   Redis     │ │  MySQL    │ │  Kafka      │
     │  (缓存热点)  │ │ (持久化)   │ │ (异步统计)   │
     └────────────┘ └───────────┘ └────────────┘

缓存策略

python
import redis
from functools import lru_cache

cache = redis.Redis(host='localhost', port=6379, decode_responses=True)

async def resolve_short_url(short_code: str) -> str | None:
    # 1. 先查本地缓存(L1)
    # 2. 再查 Redis(L2)
    
    original_url = cache.get(f"url:{short_code}")
    if original_url:
        return original_url
    
    # 3. 查数据库
    original_url = await db.fetch_one(
        "SELECT original_url FROM short_urls WHERE short_code = ?",
        short_code
    )
    
    if original_url:
        # 写入 Redis,TTL 根据过期时间或默认 7 天
        cache.setex(f"url:{short_code}", 604800, original_url)
        return original_url
    
    return None

关键权衡

决策点 选项 A 选项 B 选择
短码生成 Hash ID 生成器 ID 生成器(无碰撞)
存储 MySQL NoSQL MySQL(关系简单)
缓存 Redis CDN Redis + CDN
清理策略 定时任务 TTL 过期 TTL + 懒删除

三、案例二:实时聊天系统

需求分析

功能需求:

非功能需求:

通信协议选型

协议 方向 适用场景 特点
HTTP 短轮询 客户端 → 服务端 不推荐 浪费资源
HTTP 长轮询 双向 过渡方案 兼容性好,但效率低
WebSocket 双向 推荐 全双工,低延迟
SSE 服务端 → 客户端 单向通知 自动重连,走 HTTP
WebRTC P2P 音视频 NAT 穿透,UDP

推荐:WebSocket 作为主要通道 + HTTP 用于历史消息拉取和文件上传。

架构设计

code
┌─────────┐                         ┌─────────────┐
│  Client  │──── WebSocket ────────►│   Gateway    │
│   A      │◄─── connection ───────│   Server     │
└─────────┘                         └──────┬───────┘
                                            │
┌─────────┐                         ┌──────▼───────┐
│  Client  │──── WebSocket ────────►│   Gateway    │
│   B      │◄─── connection ───────│   Server     │
└─────────┘                         └──────┬───────┘
                                            │
                              ┌─────────────┼─────────────┐
                              │             │             │
                     ┌────────▼───┐  ┌──────▼────┐ ┌─────▼──────┐
                     │   Kafka    │  │   Redis   │ │ PostgreSQL │
                     │  (消息队列)  │  │ (在线状态)  │ │ (持久化存储) │
                     └────────────┘  └───────────┘ └────────────┘

消息可靠性保障

typescript
// 客户端消息确认机制
interface ChatMessage {
  id: string;           // 客户端生成的唯一 ID
  senderId: string;
  receiverId: string;
  content: string;
  timestamp: number;
  sequenceNo: number;   // 会话级递增序列号
}

class ChatClient {
  private pendingMessages: Map<string, ChatMessage> = new Map();
  private lastAckedSeq: Map<string, number> = new Map();

  async sendMessage(message: ChatMessage): Promise<void> {
    // 1. 乐观地先显示消息
    this.displayMessage(message, 'sending');

    // 2. 存入待确认队列
    this.pendingMessages.set(message.id, message);

    // 3. 通过 WebSocket 发送
    this.ws.send(JSON.stringify({ type: 'message', payload: message }));

    // 4. 设置超时重发(指数退避)
    this.scheduleRetry(message.id, 0);
  }

  private scheduleRetry(messageId: string, attempt: number): void {
    const timeout = Math.min(1000 * Math.pow(2, attempt), 30000);
    setTimeout(() => {
      if (this.pendingMessages.has(messageId)) {
        const msg = this.pendingMessages.get(messageId)!;
        this.ws.send(JSON.stringify({ type: 'message', payload: msg }));
        this.scheduleRetry(messageId, attempt + 1);
      }
    }, timeout);
  }

  handleAck(messageId: string): void {
    const message = this.pendingMessages.get(messageId);
    if (message) {
      this.displayMessage(message, 'sent');
      this.pendingMessages.delete(messageId);
    }
  }
}

消息持久化策略

code
消息写入路径(保证不丢):

Client → Gateway → Kafka → Message Service → PostgreSQL + 推送
                │                                      │
                └── 持久化到 Kafka 即返回 ACK ◄────────┘
                    异步写入 DB + 推送给接收方

优势:
- Kafka 保证消息持久化和顺序
- 解耦网关与存储层
- 支持消息回放和多个消费者

一致性哈希分配用户连接

python
import hashlib

class ConsistentHash:
    def __init__(self, servers: list[str], virtual_nodes: int = 150):
        self.virtual_nodes = virtual_nodes
        self.ring: dict[int, str] = {}
        self.sorted_keys: list[int] = []
        
        for server in servers:
            self._add_server(server)
    
    def _hash(self, key: str) -> int:
        return int(hashlib.md5(key.encode()).hexdigest(), 16)
    
    def _add_server(self, server: str):
        for i in range(self.virtual_nodes):
            node_key = f"{server}:vnode:{i}"
            hash_val = self._hash(node_key)
            self.ring[hash_val] = server
            self.sorted_keys.append(hash_val)
        self.sorted_keys.sort()
    
    def get_server(self, user_id: str) -> str:
        """查找用户连接应该路由到哪个服务器"""
        hash_val = self._hash(user_id)
        
        for key in self.sorted_keys:
            if hash_val <= key:
                return self.ring[key]
        
        return self.ring[self.sorted_keys[0]]

四、案例三:分布式限流器

算法选型

算法 描述 优点 缺点
固定窗口 每个时间窗口内固定次数 实现简单 边界突发问题
滑动窗口 平滑的时间窗口 均匀限流 内存开销较大
漏桶 恒定速率输出 平滑流量 应对突发能力差
令牌桶 固定速率生成令牌 允许合理突发 实现稍复杂

推荐:令牌桶算法——既能平滑限流,又允许合理突发流量。

令牌桶实现

python
import time
import asyncio
from dataclasses import dataclass

@dataclass
class TokenBucket:
    capacity: int          # 桶容量
    refill_rate: float     # 每秒填充令牌数
    tokens: float = 0.0
    last_refill: float = 0.0
    
    def __post_init__(self):
        self.tokens = float(self.capacity)
        self.last_refill = time.monotonic()
    
    def _refill(self):
        now = time.monotonic()
        elapsed = now - self.last_refill
        self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
        self.last_refill = now
    
    def allow_request(self, tokens: int = 1) -> bool:
        self._refill()
        if self.tokens >= tokens:
            self.tokens -= tokens
            return True
        return False

分布式限流:Redis + Lua

lua
-- rate_limiter.lua
-- KEYS[1]: 令牌桶 key
-- ARGV[1]: 桶容量
-- ARGV[2]: 每秒填充速率
-- ARGV[3]: 本次请求需要的令牌数

local bucket_key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local requested = tonumber(ARGV[3])

local now = redis.call('TIME')[1]  -- 秒级时间戳

local bucket = redis.call('HMGET', bucket_key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now

-- 补充令牌
local elapsed = now - last_refill
if elapsed > 0 then
    tokens = math.min(capacity, tokens + elapsed * refill_rate)
end

-- 判断是否可以放行
if tokens >= requested then
    tokens = tokens - requested
    redis.call('HMSET', bucket_key, 'tokens', tokens, 'last_refill', now)
    redis.call('EXPIRE', bucket_key, 60)
    return 1  -- 放行
else
    redis.call('HMSET', bucket_key, 'tokens', tokens, 'last_refill', now)
    redis.call('EXPIRE', bucket_key, 60)
    return 0  -- 限流
end
python
import redis.asyncio as redis

class DistributedRateLimiter:
    def __init__(self, redis_client: redis.Redis):
        self.redis = redis_client
        
        # 加载 Lua 脚本
        with open('rate_limiter.lua') as f:
            self.script = self.redis.register_script(f.read())
    
    async def is_allowed(
        self,
        user_id: str,
        endpoint: str,
        capacity: int = 100,
        refill_rate: float = 10.0,
        tokens: int = 1,
    ) -> bool:
        key = f"rate_limit:{user_id}:{endpoint}"
        result = await self.script(
            keys=[key],
            args=[capacity, refill_rate, tokens],
        )
        return result == 1

多级限流架构

code
                  ┌──────────────────┐
                  │   API Gateway    │
                  │  (全局限流: IP级)  │
                  └────────┬─────────┘
                           │
                  ┌────────▼─────────┐
                  │  Service Mesh    │
                  │  (服务限流: 接口级) │
                  └────────┬─────────┘
                           │
                  ┌────────▼─────────┐
                  │  Application     │
                  │  (业务限流: 用户级) │
                  └──────────────────┘

五、面试常见问题与应对

"系统宕机了怎么办?"

展示你考虑了高可用设计:

"系统如何扩展?"

按以下维度逐一分析:

"数据库怎么选?"

需求 推荐 原因
关系型、事务 PostgreSQL / MySQL ACID,JOIN,成熟工具
文档存储、灵活 Schema MongoDB JSON 原生支持
键值、高并发读写 Redis / DynamoDB 极低延迟
时序数据 TimescaleDB / InfluxDB 时间窗口优化
全文搜索 Elasticsearch 倒排索引
图关系 Neo4j 图遍历优化

结语

系统设计面试考察的不是你能记住多少架构图,而是你分析问题的思维方式。

四个关键能力:

  1. 需求驱动设计——先理解约束(QPS、数据量、延迟要求),再推导架构
  2. 渐进式思考——从最简单的可行方案出发,逐步升级复杂度
  3. 量化权衡——用数据支撑你的技术选型,"SQL 比 NoSQL 更合适,因为……"
  4. 全局视角——不只停留在写代码,考虑部署、监控、扩展和故障恢复

建议每周选择一个系统(消息队列、分布式文件系统、推荐引擎等)进行设计练习,在 45 分钟内完成从需求到架构的全过程。练习中保持出声思考,因为面试官想看到的不仅是你的最终方案,更是你的思考过程。

好的系统设计,是工程约束下做出的一系列合理权衡。