cswork / 系统设计

第 2 章 / 共 57 章 · 约 7 分钟

第 2 章:系统设计的基本构件

本页目录
  1. 1. 负载均衡(Load Balancer)
  2. 2. 缓存
  3. 3. 数据库:SQL 与 NoSQL
  4. 4. 复制与分片
  5. 5. 消息队列与流
  6. 6. CDN
  7. 7. 一致性与 CAP(实践视角)
  8. 小结
  9. 章节测验

绝大多数系统设计题都由同一批“积木”拼成。面试中不需要讲清每个组件的全部细节,但需要知道三件事:它解决什么问题、要付出什么代价、什么时候不该用它。

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
数据模型表 + 固定 schemaKV、文档、宽列、图
事务成熟的 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 的强一致读),而关系型数据库挂上异步从库后同样可能读到旧数据。

小结

每引入一个组件,都要能回答三个问题:它满足了哪条需求?它带来了什么代价(成本、复杂度、一致性)?去掉它会怎样?能答好这三个问题,比多记几个组件名有用得多。

章节测验

6 道选择题
  1. 使用 cache-aside 模式时,推荐的写路径是什么?
  2. 一个订单事件需要被风控、推荐、数仓三个下游系统各自独立消费,并且支持出问题后回放。最合适的组件是?
  3. 无主复制系统中 N = 3。在严格 quorum、没有并发写的前提下,哪组配置能让读副本集合与写副本集合必然重叠?
  4. 为聊天应用的消息表选择分片键,最常见的查询是“按时间分页读取某个会话的消息”。哪个分片键最合适?
  5. 下面哪个功能最需要强一致性?
  6. 某位明星的主页数据成了热 key,单个 Redis 节点 CPU 被打满。最合适的处理方式是?

章节讨论

正在加载讨论…