系统设计入门
这一课你将能做到
- 用「用户量 → QPS → 存储/带宽」的思路对一个业务做容量估算(数字在正确数量级内)
- 画出无状态服务 + 负载均衡 + 缓存 + 数据库的典型可扩展架构,并解释每一层解决的瓶颈
- 识别缓存穿透、击穿、雪崩,设计出符合 REST 风格的资源型 API
为什么要学
会写功能和能设计系统是两级台阶。当判题平台从课程作业走向真实运营,问题会变:
- 一天 10 万注册用户、晚上 8 点集中刷题,需要多少台应用服务器?
- 单库扛不住提交记录写入怎么办?判题结果查询慢怎么办?
- 一道热门题被几十万用户同时查看,如何不把数据库打穿?
系统设计面试考的不是背诵架构图,而是在约束下做权衡的过程:先估算量级,再识别瓶颈,然后逐层加组件,每个选择讲出取舍。本课给你这套入门框架,并始终围绕判题平台推演。
先建立图像
可扩展架构的「标准分层」
用户
│
┌──────▼──────┐
│ CDN │ 静态资源就近分发(题目图片、前端打包文件)
└──────┬──────┘
┌──────▼──────┐
│ 负载均衡 LB │ Nginx/LVS:健康检查,按策略分发
└──┬───┬───┬──┘
┌──────▼┐ ┌▼────┐ ┌▼────────┐
│应用实例1│ │实例2│ │实例3 │ 无状态:会话放 Redis,随意增删
└──┬──┬──┘ └┬────┘ └┬────────┘
│ │ │ │
┌──────▼┐ ┌▼────────────▼─────┐
│ Redis │ │ 消息队列 MQ │ 削峰填谷:提交高峰先入队
│ 缓存 │ └──────────┬──────────┘
└───────┘ ┌──────▼──────┐
┌─────▼────┐ ┌─────▼─────┐
│ MySQL主库 │ │ 判题机集群 │ 主从复制、读写分离
│ +从库 │ │ (隔离容器) │
└──────────┘ └───────────┘
核心思路:能水平扩展的层就不要垂直硬扛;状态集中到少数组件(Redis/DB),应用层保持无状态;峰值用队列削平。
跟着做
步骤 1:给判题平台做容量估算
容量估算靠「假设 + 乘除」,不需要精确,数量级对就有价值。常用备忘数字:
量级速查:1万=10⁴ 100万=10⁶ 1亿=10⁸
一天 = 86400 秒 ≈ 10⁵ 秒
QPS = 日请求量 / 86400;峰值 QPS 常按日均的 3-10 倍估(二八效应)
推演过程(写在纸上的标准四步):
- 用户规模:10 万注册用户,日活 20% = 2 万人;每人每天刷 20 道题
- 请求量:页面浏览 + 提交 + 轮询,约每人每天 200 次请求 → 日请求 400 万 → 平均 QPS ≈ 46;晚高峰按 6 倍 → 峰值约 300 QPS
- 应用层:单台 4 核 Spring Boot 实例按经验支撑 100-300 QPS(取决于接口轻重),取 2 台 + 冗余 → 3 台
- 存储量:每份提交代码平均 2KB,日新增 40 万份 × 2KB ≈ 800MB/天,一年约 290GB;题目与用户数据小;Redis 存热数据 5GB 足够
- 带宽:单次响应平均 5KB,峰值 300 QPS → 1.5MB/s ≈ 12Mbps,很小;代码提交上传另算
做完估算你会发现瓶颈往往不在「算」而在「写」:提交高峰写入 MySQL 和判题机产能才是约束,这直接引出后面的队列与判题机集群设计。
步骤 2:无状态服务与负载均衡
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class SessionDemo {
private final StringRedisTemplate redis;
public SessionDemo(StringRedisTemplate redis) {
this.redis = redis;
}
// ❌ 反模式:把登录态/计数器存在实例内存里 → 用户下次落到另一台机器就「掉线」
// ✅ 正解:状态全部放 Redis,任何实例都能处理任何请求(无状态)
@GetMapping("/me")
public String me(@RequestHeader("X-Token") String token) {
return redis.opsForValue().get("login:" + token);
}
}
负载均衡策略知道三种即可:轮询(均匀)、加权(机器配置不同)、最少连接(长连接场景);健康检查自动摘除故障实例。因为应用无状态,扩容只需启动新实例并注册到 LB,滚动发布也零停机——这是云原生弹性的基础。
步骤 3:缓存三大故障及对策
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
@Service
public class ProblemCacheService {
private final StringRedisTemplate redis;
public ProblemCacheService(StringRedisTemplate redis) {
this.redis = redis;
}
public String getProblem(Long id) {
String key = "problem:" + id;
String value = redis.opsForValue().get(key);
if (value == null) {
value = loadFromDb(id); // 回源
if (value != null) {
redis.opsForValue().set(key, value, Duration.ofMinutes(10));
} else {
// 防穿透:不存在的 key 也缓存空值(短 TTL),或用布隆过滤器前置拦截
redis.opsForValue().set(key, "", Duration.ofMinutes(1));
}
}
return value;
}
private String loadFromDb(Long id) { return null; }
}
三个概念务必分清,面试高频:
- 缓存穿透:查的是根本不存在的数据,每次都打到数据库。对策:空值缓存、布隆过滤器
- 缓存击穿:某个热点 key 恰好过期,大量请求同时回源。对策:互斥锁只放一个请求回源、热点 key 逻辑不过期
- 缓存雪崩:大批 key 同时过期或 Redis 宕机,回源洪峰压垮数据库。对策:TTL 加随机抖动、Redis 高可用集群、多级缓存与限流降级
步骤 4:设计 REST 风格 API
资源用名词,动作交给 HTTP 方法(以判题平台为例)
GET /api/problems 题目列表(分页 ?page=1&size=20)
GET /api/problems/1024 题目详情
POST /api/problems 管理员新建题目
PUT /api/problems/1024 全量更新
DELETE /api/problems/1024 删除
POST /api/problems/1024/submissions 用户提交代码(提交是「题下的资源」)
GET /api/users/me/submissions 我的提交记录
配套约定
状态码语义化:200 成功 / 201 已创建 / 400 参数错 / 401 未登录 /
403 无权限 / 404 不存在 / 429 触发限流 / 500 服务端错
版本与兼容:/api/v1/...;新增字段不破坏旧客户端
幂等性:GET/PUT/DELETE 天然幂等;POST 用唯一请求 ID 防重复提交
讲清楚要点
- 垂直扩展(加 CPU/内存)有上限,水平扩展(加机器)靠无状态:数据库这类有状态组件扩展难,所以它前面要加缓存、读写分离、分库分表;把「贵的有状态部分」做小,是所有架构演进的主线。
- 读写分离与分片是两回事:主从复制解决「读多写少」(从库承担查询),分库分表解决「单库容量/写入瓶颈」(数据按 key 拆散),两者经常组合使用。
- MQ 解决三个问题:削峰(提交高峰排队慢慢判)、解耦(提交后触发统计、通知等后续动作互不阻塞)、异步(用户不必在线等结果,配合回调/推送);代价是引入消息可靠性与顺序性问题。
- 缓存是性能组件也是事故源:要管理好 TTL、一致性(先更新库再删缓存的 Cache-Aside 最常用)、热点与大 key;缓存不能替代数据库,它丢了要能从库重建。
- CDN 和多级缓存本质相同:让数据离用户/计算更近;判题平台的题目图片、富文本、前端静态资源上 CDN,回源压力和延迟同时下降。
你可能会懵的地方
-
容量估算老算错怎么办? 初学阶段先保证「流程正确」:列假设、拆环节、一步步乘,最后做合理性检查(结果是否离谱)。数字偏差 2-3 倍完全可以接受,面试官考的是思路;多练 5-10 个案例就有手感。
-
到底什么时候才需要微服务? 单体 Spring Boot 对多数团队是更优选(部署简单、调试直接、没有分布式复杂度)。当团队规模、模块边界、独立扩缩容诉求都明确后,再按业务域拆分(如把判题执行独立成服务),拆早了是负担。
-
缓存和数据库一致性怎么保证? 常用 Cache Aside:更新数据库后删除缓存(而不是更新缓存),下次读取自然回源重建。强一致成本高且通常不必要;延迟双删、消息通知删缓存是加强手段。
-
判题机产能不足,加机器就够了吗? 先看瓶颈:任务分发是否均衡、容器启动开销是否摊薄(预热池)、单任务平均耗时(编译是否可复用、测试用例并行)。调度策略优化常常比加机器收益大。
-
系统设计面试要画代码吗? 不写业务代码,但要能写接口定义、SQL 表结构草稿、估算算式,并在白板上边画架构边讲权衡。沟通条理性和「为什么不用另一个方案」比图本身重要。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 不估算直接谈技术选型 | 方案与规模脱节,可能过度设计 | 先算 QPS/存储/带宽量级,再选组件 |
| 应用内存里放会话与本地状态 | 扩容/负载均衡后行为错乱 | 状态外移 Redis,实例无状态 |
| 所有 key 设同一个固定 TTL | 同时过期引发雪崩 | TTL = 基础值 + 随机抖动 |
| 同步调用所有下游且不降级 | 一个慢服务拖垮整条链路 | 超时、熔断、限流、降级四件套 |
URL 里放动词 /getProblem?id=1 | 不符合资源模型,路由膨胀 | 名词资源 + HTTP 方法语义 |
卡点急救
- 高峰期限提交失败:先确认入口 QPS 与后端实际产能,入口侧排队(MQ 削峰)+ 客户端排队提示,比让请求直接打满数据库好。
- 数据库慢查询突然增多:检查是否缓存集体失效或 Redis 故障;先限流保护数据库,再逐步恢复缓存,避免雪崩二次击垮系统。
- 扩容后性能没提升:检查瓶颈是否在有状态组件(数据库连接、行锁、单线程消费者);无状态层才能线性扩展,有状态层要单独优化。
面试常问
-
设计一个支持万人同时使用的在线判题系统,说说你的思路。 先估算(日活、峰值 QPS、存储量),再给分层架构:CDN + LB + 无状态应用集群 + Redis 缓存(题目/会话/排行榜)+ MySQL 主从(提交记录分片)+ MQ 削峰 + 判题机容器集群(资源隔离、任务拉取、结果回调)。主动指出瓶颈在写入与判题产能,并讲一致性与限流降级。
-
缓存穿透、击穿、雪崩的区别与应对? 穿透=数据不存在(空值缓存/布隆过滤器);击穿=单热点过期(互斥回源/逻辑过期);雪崩=大面积失效或缓存宕机(随机 TTL、集群高可用、限流降级)。
-
为什么应用层要无状态?怎么做? 无状态才能随意水平扩展、故障转移和滚动发布。把会话、计数器、锁等状态统一放 Redis/数据库,实例只保留可随时丢弃的局部状态。
自测三问
- 给你「日活 50 万、每人每天提交 5 次」,能否 10 分钟内算出 QPS、年存储量和应用实例数?
- 判题平台晚高峰写入突增 20 倍,链路上哪几个组件依次会成为瓶颈?
- Cache Aside 为什么是「删缓存」而不是「更新缓存」?并发下还有什么残留问题?
小结一句话
系统设计 = 先估算量级、再沿着「无状态化、加缓存、队列削峰、拆分有状态瓶颈」的路径逐层扩展,每一步都讲得出代价与取舍。
延伸
- 《System Design Interview》(Alex Xu)官方站点——容量估算与组件权衡的实战入门,Vol1/Vol2
- GitHub:System Design Primer(英文高星指南)——结构化的系统设计知识库
- Google SRE Book(在线免费)——可用性、容量规划与应急工程的权威实践
- 下一课:安全基础——系统能扩展之后还要保证它不被攻破:CIA 三原则、密码学体系与常见 Web 漏洞防御。