CTO Plus技术服务栈

系统设计面试通关指南:高频题目的标准解题框架

系统设计面试的通用解题框架(SNAKE原则),覆盖短链接系统、秒杀系统、实时聊天、分布式ID生成器、限流器、消息队列等10+高频题目的完整设计方案。包含架构图、容量估算、数据库选型和trade-off分析。

SteveRocket
北京,中国
3 min read

系统设计面试为什么重要?

在高级工程师面试中,系统设计环节往往占 40-50% 的权重。代码写得好只能证明你能实现功能,系统设计能力才体现你能独立负责一个系统

我们团队面试过几百位候选人,发现一个规律:通过系统设计面试的候选人,不一定是因为答案完美,而是因为他展示了结构化的思维过程

这篇文章总结了系统设计面试的标准解题框架,以及 6 个高频题目的核心设计要点。

通用解题框架:SNAKE 原则

面试官不期待你在 45 分钟内设计出 Google 级别的系统。他们想看的是你的思考过程。我总结了一个 SNAKE 框架:

S — Scope(明确需求)

很多候选人一上来就开始画架构图,这是大忌。必须先搞清楚:

  • 功能需求:系统要做什么?(核心功能 vs 锦上添花)
  • 非功能需求:可用性、一致性、延迟、扩展性
  • 规模估算:DAU、QPS、存储量、带宽

话术模板:

“在开始设计之前,我想先确认几个关键需求。首先,这个系统主要支持哪些核心功能?其次,预期的用户规模大概是什么量级——是百万级还是亿级?另外,对一致性和可用性的要求是怎样的?“

N — Non-functional Requirements(非功能需求)

明确 CAP 取舍:

  • CP 系统(一致性优先):银行转账、订单系统
  • AP 系统(可用性优先):社交媒体、CDN
  • 最终一致性:大多数互联网场景

话术模板:

“基于需求分析,我认为这是一个写少读多的场景,我们可以接受短暂的不一致(最终一致性),但必须保证高可用。所以在 CAP 中我们选择 AP。“

A — Architecture(高层架构)

画出核心架构图。记住:先画逻辑架构,再画物理架构

  • 用什么协议通信?(HTTP/REST、gRPC、WebSocket)
  • 数据怎么流转?(同步/异步、推/拉)
  • 有哪些核心服务?

K — Key Components(关键组件深挖)

对核心组件进行深挖:

  • 数据库选型(SQL vs NoSQL,具体选哪个)
  • 缓存策略(Cache-Aside、Read-Through、Write-Behind)
  • 消息队列(Kafka vs RabbitMQ vs Redis Stream)
  • 负载均衡策略

E — Edge Cases(边界与优化)

  • 系统瓶颈在哪里?
  • 如何扩展?
  • 如何保证安全?
  • 如何监控和运维?

高频题一:设计短链接系统

需求分析

功能需求:

  • 用户输入长 URL,生成短链接
  • 访问短链接,重定向到原始 URL
  • 可选的:自定义短链接、过期时间、访问统计

规模估算:

  • 日均生成 1 亿条短链接
  • 写 QPS:100M / 86400 ≈ 1,200
  • 读 QPS:假设读写比 100:1 → 120,000 QPS
  • 存储:1 亿 × 365 × (8B ID + 512B URL + 128B 元数据) ≈ 24TB/年

核心设计

短链生成算法选型:

方案优点缺点
Hash(MD5/SHA256截取)简单冲突需要处理
自增ID + Base62无冲突,短依赖ID生成器
随机字符串简单需检查重复

推荐方案:分布式ID + Base62 编码

ID = Snowflake生成
短码 = Base62(ID)  // 如:ID=123456789 → "8m0Kx"

架构设计

客户端

CDN / 反向代理

负载均衡
  ↓           ↓
短链服务        重定向服务
  ↓ (写)       ↓ (读)
ID生成器      Redis缓存
  ↓           ↓
MySQL          MySQL

重定向流程:

1. 用户访问 https://short.url/8m0Kx
2. 查询 Redis 缓存 → 命中直接返回 302
3. 缓存未命中 → 查 MySQL
4. 返回 302 + Location: 原始URL
5. 更新缓存(异步)

关键优化:

  • 短链预生成,放入池中(减少生成延迟)
  • Redis 集群缓存热点数据
  • CDN 缓存 302 响应

高频题二:设计秒杀系统

需求分析

核心挑战:

  • 瞬时超高并发(1 万 QPS → 100 万 QPS,100 倍差异)
  • 库存扣减不能超卖
  • 系统不能崩溃

核心原则:

流量要分层过滤,库存要最终一致

架构设计

用户请求

CDN(静态资源 + 秒杀页面)

Nginx 限流层(令牌桶/漏桶算法)
  ↓ — 过滤 80% 流量
验证码/答题(防机器人)
  ↓ — 过滤 10% 流量
业务网关(鉴权 + 风控)

Redis 库存预扣减(原子操作)
  ↓ — 只有 1% 流量到达
消息队列(削峰填谷)

订单服务(异步创建订单)

MySQL(最终落库)

核心组件详解

1. Redis 库存扣减(防超卖)

-- Lua 脚本保证原子性
local key = KEYS[1]
local quantity = tonumber(ARGV[1])
local stock = tonumber(redis.call('get', key) or 0)

if stock >= quantity then
    redis.call('decrby', key, quantity)
    return 1  -- 扣减成功
else
    return 0  -- 库存不足
end

2. 限流策略

# Nginx 限流配置
limit_req_zone $binary_remote_addr zone=seckill:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=addr:10m;

3. 异步下单

扣减成功 → 发送消息到 Kafka → 订单服务消费 → 创建订单 → 扣减 DB 库存

关键 Trade-off

选择理由
Redis 预扣减MySQL 扛不住秒杀 QPS
异步下单削峰,用户不需要立即看到订单
先到先得不要用”随机抽签”增加复杂度
最终一致性可以接受短暂的数据不一致

高频题三:设计实时聊天系统

需求分析

功能需求:

  • 一对一聊天、群聊
  • 消息已读/未读状态
  • 在线状态
  • 历史消息

非功能需求:

  • 低延迟(< 100ms)
  • 高可用
  • 消息不丢失、不重复

核心设计

通信协议选择:WebSocket

为什么不用 HTTP 轮询?
- 短轮询:延迟高,浪费带宽
- 长轮询:服务器压力大

为什么不用 Server-Sent Events?
- 单向通信(服务器→客户端)
- 聊天需要双向通信

架构设计

客户端 (WebSocket)

负载均衡 (支持 WebSocket 的 LB)

WebSocket 服务集群
  ↓           ↓
Redis Pub/Sub  Kafka
(实时消息)    (消息持久化)

消息存储服务 → MySQL / HBase

消息流转:

1. Alice 发送消息给 Bob
2. WebSocket 服务器收到消息
3. 写入 Kafka(持久化)
4. 查询 Bob 连接的 WebSocket 服务器(从 Redis 获取)
5. 通过 Redis Pub/Sub 转发到 Bob 的服务器
6. Bob 的服务器推送消息给 Bob

数据模型

-- 消息表(分表策略:按 chat_id 哈希)
CREATE TABLE messages (
    id BIGINT PRIMARY KEY,
    chat_id VARCHAR(64) NOT NULL,
    sender_id BIGINT NOT NULL,
    content TEXT,
    type TINYINT,  -- 1:文本 2:图片 3:文件
    created_at TIMESTAMP,
    INDEX idx_chat_time (chat_id, created_at)
);

-- 已读状态(可以用 Redis 或单独表)
-- Redis: SET read_status:{chat_id}:{user_id} {last_read_msg_id}

高频题四:设计分布式ID生成器

需求分析

核心要求:

  • 全局唯一
  • 趋势递增(方便数据库索引)
  • 高性能(单机 10 万+/秒)
  • 高可用

方案对比

方案优点缺点
UUID简单,无中心节点太长,非递增,索引不友好
数据库自增ID简单,递增单点瓶颈,扩展困难
Redis INCR高性能依赖 Redis 持久化
Snowflake高性能,趋势递增依赖时钟同步
号段模式(Leaf)高性能,灵活实现复杂

Snowflake 详解(推荐)

64-bit ID 结构:
┌─┬─────────────────────┬───────────┬──────────┬────────────┐
│0│    41-bit 时间戳     │10-bit 机器│12-bit 序号│
│ │  (毫秒,约69年)      │          │          │
└─┴─────────────────────┴───────────┴──────────┴────────────┘

每秒可生成:2^12 × 1000 = 409.6 万个 ID
type Snowflake struct {
    mu        sync.Mutex
    timestamp int64  // 上次生成ID的时间戳
    workerId  int64  // 机器ID (0-1023)
    sequence  int64  // 毫秒内序列号 (0-4095)
    epoch     int64  // 起始时间戳
}

func (s *Snowflake) NextId() int64 {
    s.mu.Lock()
    defer s.mu.Unlock()
    
    now := time.Now().UnixMilli()
    
    if now == s.timestamp {
        // 同一毫秒内,序列号递增
        s.sequence = (s.sequence + 1) & 0xFFF
        if s.sequence == 0 {
            // 序列号用完,等待下一毫秒
            for now <= s.timestamp {
                now = time.Now().UnixMilli()
            }
        }
    } else {
        s.sequence = 0
    }
    
    s.timestamp = now
    
    return ((now - s.epoch) << 22) | (s.workerId << 12) | s.sequence
}

时钟回拨问题处理:

  1. 等待时钟追上
  2. 使用备用 workerId
  3. 抛出异常(最安全)

高频题五:设计限流器

限流算法对比

算法原理优点缺点
固定窗口每秒/每分钟计数简单边界突刺
滑动窗口窗口随时间滑动平滑内存占用
漏桶恒定速率处理流量整形不能应对突发
令牌桶恒定速率生成令牌允许突发实现稍复杂

令牌桶实现

type TokenBucket struct {
    rate       float64 // 每秒生成令牌数
    capacity   int64   // 桶容量(允许的突发流量)
    tokens     float64 // 当前令牌数
    lastRefill time.Time
    mu         sync.Mutex
}

func (tb *TokenBucket) Allow() bool {
    tb.mu.Lock()
    defer tb.mu.Unlock()
    
    // 补充令牌
    now := time.Now()
    elapsed := now.Sub(tb.lastRefill).Seconds()
    tb.tokens = math.Min(
        float64(tb.capacity),
        tb.tokens + elapsed * tb.rate,
    )
    tb.lastRefill = now
    
    // 判断是否有可用令牌
    if tb.tokens >= 1 {
        tb.tokens -= 1
        return true
    }
    return false
}

分布式限流

单机限流用内存,分布式限流用 Redis:

-- Redis 令牌桶 Lua 脚本
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local last_refill = tonumber(redis.call('hget', key, 'last_refill') or now)
local tokens = tonumber(redis.call('hget', key, 'tokens') or capacity)

-- 补充令牌
local elapsed = (now - last_refill) / 1000
tokens = math.min(capacity, tokens + elapsed * rate)

if tokens >= requested then
    redis.call('hset', key, 'tokens', tokens - requested)
    redis.call('hset', key, 'last_refill', now)
    return 1
else
    redis.call('hset', key, 'last_refill', now)
    return 0
end

高频题六:设计消息队列

核心设计

消息队列的本质: 一个分布式的、持久化的、支持多消费者的日志系统。

架构设计

Producer → Partition 1 → Consumer Group A
         → Partition 2 → Consumer Group A
         → Partition 3 → Consumer Group B

        Broker 集群(Controller + Follower)

        ZooKeeper / KRaft(元数据管理)

数据存储设计

顺序写磁盘是关键:

每个 Partition 是一个目录:
/topic-name-0/
    00000000000000000000.log     ← 消息数据文件
    00000000000000000000.index   ← 稀疏索引
    00000000000000000000.timeindex

写入流程:
1. Producer 发送消息 → Broker
2. Broker 追加到最新的 .log 文件(顺序写)
3. 更新内存中的偏移量
4. 异步刷盘(或同步,看配置)

为什么 Kafka 这么快?

  1. 顺序写磁盘(比随机写快 100 倍)
  2. Page Cache(利用 OS 缓存)
  3. 零拷贝(sendfile 系统调用)
  4. 批量压缩和发送
  5. 分区并行

消费者偏移量管理

旧方案:ZooKeeper 存储 offset
新方案:内部 Topic __consumer_offsets

消费者流程:
1. 拉取消息
2. 处理消息
3. 提交 offset(可自动或手动)
4. 重启时从上次 offset 继续消费

消息可靠性保证

级别配置可靠性性能
At most onceacks=0可能丢消息最高
At least onceacks=1不丢(Leader确认)中等
Exactly onceacks=all + 幂等不丢不重最低

面试技巧总结

面试中应该做的

  1. 先澄清需求:不要假设,要确认
  2. 展示思维过程:说清楚为什么做这个选择
  3. 主动提 Trade-off:没有完美的方案,展示你的权衡能力
  4. 从简单到复杂:先给一个能用的方案,再逐步优化
  5. 画图:架构图比纯语言描述清晰 10 倍

面试中应该避免的

  1. 不要直接跳进细节:先有大局观
  2. 不要忽略非功能需求:只满足功能需求是初级工程师的水平
  3. 不要回避问题:被问住了不要慌,展示分析过程
  4. 不要过度设计:不要为一个日活 1000 的系统设计 K8s 集群
  5. 不要背诵答案:面试官能听出来,要展示真实理解

关键公式

容量估算公式:
QPS = DAU × 人均请求数 / 86400 × 峰值系数
存储 = QPS × 每条数据大小 × 保留天数
带宽 = QPS × 每条数据大小

数据库选型公式:
需要事务/强一致性 → SQL
需要灵活Schema/高扩展 → NoSQL
需要全文搜索 → Elasticsearch
需要时序数据 → InfluxDB/TimescaleDB

总结

系统设计面试考察的不是”你记住了多少方案”,而是”你如何分析一个没有标准答案的问题”。核心要点:

  1. SNAKE 框架是思考的骨架,不是答案的模板
  2. 理解 Trade-off 比记住方案更重要
  3. 从需求出发,不要让技术选型倒逼需求
  4. 容量估算要熟练,这是工程师的基本功
  5. 多画图、多解释、多互动,面试是双向沟通

Comments

Link copied to clipboard!