第 2 章 / 共 57 章 · 约 7 分钟
第 2 章:系统设计的基本构件
绝大多数系统设计题都由同一批“积木”拼成。面试中不需要讲清每个组件的全部细节,但需要知道三件事:它解决什么问题、要付出什么代价、什么时候不该用它。
1. 负载均衡(Load Balancer)
负载均衡负责把流量分发到多台服务器,并通过健康检查把故障节点摘掉。
| 类型 | 工作层 | 能看到什么 | 典型用途 |
|---|---|---|---|
| L4 | 传输层 | IP、端口 | 超大连接数、非 HTTP 协议(自定义 TCP、游戏服、数据库代理) |
| L7 | 应用层 | URL、Header、Cookie | 按路径路由、TLS 终止、灰度发布 |
WebSocket 并不只能走 L4:L7 负载均衡(如 ALB、Nginx)同样支持 WebSocket。连接数极大、或者协议根本不是 HTTP 时,才需要换成 L4。
常见策略:轮询(round robin)、最少连接(least connections)、按 key 一致性哈希(让同一用户总是落到同一节点,适合节点上有本地状态的服务)。
要点:
- 应用服务器尽量无状态,session 放到 Redis 之类的外部存储,扩缩容才简单。
- 负载均衡器自身也要高可用:主备加虚拟 IP,或者直接用云厂商的托管负载均衡。
2. 缓存
缓存可以出现在多个层级:浏览器/客户端 → CDN → 应用进程内存 → 分布式缓存(Redis、Memcached)→ 数据库自己的 buffer pool。
读写模式
- Cache-aside(旁路缓存):最常用。读时先查缓存,未命中再查库并回填;写时先更新数据库,再删除缓存。
- Write-through:写操作同步写缓存和数据库,缓存始终较新,但写延迟更高。
- Write-behind(write-back):先写缓存,再异步刷到数据库。写入快,但缓存节点故障时可能丢数据。
为什么写时“删缓存”而不是“更新缓存”?两个并发写请求可能以不同顺序更新缓存,最后缓存里留下旧值。删除后由下一次读请求从数据库回填,再配合 TTL 兜底,出错的窗口更小。
但“先写库再删缓存”仍有残留竞态:读请求 A 未命中,从数据库读到旧值;写请求 B 更新数据库并删除缓存;A 随后把旧值回填进缓存。这个窗口很窄(A 读库要早于 B 写库,A 回填要晚于 B 删除),但确实存在。更常见的一种情况是回填读的是有复制延迟的只读副本,同样会把旧值写回缓存。常见应对:
- 延迟双删:删除缓存后隔几百毫秒再删一次,间隔要大于一次“读库 + 回填”的耗时或复制延迟
- 基于 binlog / CDC 的失效:由独立进程订阅数据库变更日志来删除缓存,失败可以重试
- TTL 兜底:不管用哪种方案都设过期时间,保证脏数据最终会消失
常见问题
| 问题 | 现象 | 应对 |
|---|---|---|
| 击穿 | 某个热 key 过期瞬间,大量请求同时打到数据库 | 互斥锁 / single-flight 合并请求,过期前异步刷新 |
| 雪崩 | 大量 key 同时过期,或缓存集群整体宕机 | TTL 加随机抖动,缓存集群做高可用,数据库前加限流 |
| 穿透 | 反复查询不存在的数据,每次都落到数据库 | 缓存空值(短 TTL),布隆过滤器 |
| 热 key | 单个 key 的 QPS 超过一个缓存节点的能力 | 进程内本地缓存,给 key 加后缀复制到多个节点 |
淘汰策略:LRU 最常见;LFU 适合热点比较稳定的场景;TTL 用来控制数据新鲜度。
3. 数据库:SQL 与 NoSQL
| 关系型(PostgreSQL、MySQL) | NoSQL | |
|---|---|---|
| 数据模型 | 表 + 固定 schema | KV、文档、宽列、图 |
| 事务 | 成熟的 ACID,支持多行事务 | 多数只保证单 key 或单分区原子性(部分产品支持更多) |
| 查询 | JOIN、灵活的二级索引 | 需要围绕访问模式设计主键 |
| 扩展 | 垂直扩展 + 读副本,分片需要额外工作 | 很多产品原生支持水平分区 |
面试中的稳妥策略是默认选关系型数据库,出现明确理由时再换:
- 写入量极大、访问模式简单(日志、IoT 时序、聊天记录)→ 宽列存储,如 Cassandra
- 按 key 读写、需要托管和弹性扩容 → DynamoDB 一类的 KV 存储
- 结构多变的文档数据 → 文档数据库,如 MongoDB
底层索引结构也值得一提:B 树/B+ 树读性能稳定,是大多数关系型数据库的默认选择;LSM 树把随机写转成顺序写,擅长写多读少的场景,Cassandra、RocksDB 都基于它。
4. 复制与分片
复制(Replication):同一份数据保存多份,用来提升可用性和读吞吐。
- 单主复制:写主库,读可以走从库。异步复制存在复制延迟,用户刚发的帖子可能在从库上读不到。解决办法是保证“读己之写”,例如写入后短时间内该用户的读请求路由到主库。
- 多主复制:多个数据中心都能接收写入,但必须处理写冲突。
- 无主复制(Dynamo 风格,指 2007 年 Amazon Dynamo 论文的设计,Cassandra、Riak 属于这一类;托管服务 DynamoDB 的每个分区其实有 leader):数据有 N 个副本,写入 W 个成功即返回,读取时读 R 个。当 R + W > N 时,读副本集合和写副本集合必然重叠,在严格 quorum、没有并发写的前提下,能读到最新一次已确认的写入。但这不等于线性一致:并发写、sloppy quorum、部分失败的写入仍可能读到旧值或产生冲突,所以还需要版本号(详见案例 9)。
分片(Sharding / Partitioning):数据量或写入量超过单机能力时,把数据拆到多台机器上。
- 按范围分片:方便范围查询,但容易出现热点。例如按时间分片,所有新写入都集中在最新的分片。
- 按哈希分片:数据分布均匀,但范围查询需要扫描所有分片。
- 一致性哈希:增减节点时只需迁移一小部分数据。
哈希环(0 ~ 2³²,首尾相连)
0 ───[A]──k1──[B]────k2────[C]──k3──▶ 回到 0
每个 key 顺时针找第一个节点:k1 → B,k2 → C,k3 → A(绕回起点)
在 k2 和 C 之间新增节点 D:只有 k2 从 C 迁到 D,其余不动
分片键的选择最关键:让最常见的查询只命中一个分片,同时避免热点。例如聊天系统按 conversation_id 分片,同一个会话的消息都在一个分片上,按时间翻页只需要查一个节点。
5. 消息队列与流
| 消息队列(SQS、RabbitMQ) | 日志型流(Kafka、Kinesis) | |
|---|---|---|
| 消费之后 | 确认后消息被删除 | 消息保留一段时间,可以回放 |
| 多个消费者 | 竞争消费,一条消息只给一个 worker | 多个消费者组各自独立读取全量数据 |
| 顺序 | 一般不保证(有 FIFO 变体) | 分区内有序 |
| 典型场景 | 任务分发、异步作业 | 事件流、数据管道、多个下游订阅 |
用途:服务解耦;削峰填谷(突发流量先进入队列,再按消费能力慢慢处理);把耗时操作异步化(如视频转码)。
投递语义分为至多一次、至少一次、精确一次。实践中最常见的组合是至少一次 + 消费端幂等(例如按消息 ID 去重),比追求端到端的精确一次更简单可靠。多次处理失败的消息进入死信队列(DLQ),方便排查。
6. CDN
CDN 把内容缓存在离用户近的边缘节点上,降低延迟,同时减轻源站压力。最适合图片、视频、JS/CSS 等静态资源,也能用于加速动态请求。
- 拉模式:用户首次访问时,边缘节点回源拉取并缓存。最常用。
- 推模式:主动把内容推到边缘节点,适合提前知道会被大量访问的大文件。
- 失效:在文件名中加入版本号或内容哈希(如
app.3f9a.js),比主动 purge 更可靠。
7. 一致性与 CAP(实践视角)
CAP 的含义是:发生网络分区时,系统只能在一致性(C)和可用性(A)之间二选一。分布式系统无法避免分区,所以真正的问题是“分区时牺牲哪一个”。PACELC 做了补充:即使没有分区,也存在延迟与一致性之间的权衡,比如同步复制更一致,但更慢。
常见一致性级别(由强到弱):
- 线性一致(强一致):读一定能看到最近完成的写
- 读己之写:自己写入的数据,自己一定能读到
- 单调读:不会读到比上一次更旧的数据
- 最终一致:没有新写入时,所有副本最终会收敛到相同的值
面试中的正确姿势是按功能选择,不要给整个系统贴一个标签:
| 功能 | 选择 | 原因 |
|---|---|---|
| 门票库存扣减、转账 | 强一致 | 超卖或重复扣款不可接受 |
| 点赞数、播放量 | 最终一致 | 短暂不准无伤大雅,可用性和延迟更重要 |
| 修改个人资料 | 读己之写 | 本人要立刻看到,别人晚几秒无所谓 |
避免说“SQL 就是 CP,NoSQL 就是 AP”。很多数据库的一致性是可配置的(如 Cassandra 的一致性级别、DynamoDB 的强一致读),而关系型数据库挂上异步从库后同样可能读到旧数据。
小结
每引入一个组件,都要能回答三个问题:它满足了哪条需求?它带来了什么代价(成本、复杂度、一致性)?去掉它会怎样?能答好这三个问题,比多记几个组件名有用得多。