本页定位 · System Design

面试被问"设计一个短链/秒杀/朋友圈",或者工作里要把一个单体拆成能扛百万 QPS 的系统——靠的就是系统设计能力。本页给你一套可复用的方法论 + 一组核心积木:需求估算、一致性与可用性权衡、缓存/CDN/负载均衡、数据库扩展、消息队列、高可用三板斧,最后用三个真实案例把积木拼起来。学完你不会背答案,但能对着任何需求搭出靠谱骨架,并说清每一处的 trade-off。基线:云原生、容器化、托管中间件已成默认选项。

1

需求澄清与容量估算:别一上来画架构图

Requirements → Estimation → SLA → API

系统设计最忌"闷头画架构图"。正确顺序是先澄清约束、做量级估算,再动手。量级估算决定了你要不要分库、要不要加缓存、要不要上队列——它是区分"背过答案"和"真会设计"的关键。

先澄清需求:功能 vs 非功能

很多人拿到题直接写"用 Redis、用 Kafka"。停下。先问清楚两类需求:

  • 功能性需求:系统要做什么(发短链、刷朋友圈、下单)。
  • 非功能性需求:多快(延迟)、多稳(SLA)、多大(数据量/峰值 QPS)、一致到什么程度。
新手最常犯的错:跳过非功能需求

"做个短链"听起来简单,但每秒 10 次和每秒 10 万次是两套架构。不问清峰值、数据保留期、延迟要求,设计出来的东西要么过度工程、要么上线即崩。先问,再画。

容量估算:从用户数算 QPS

你不用算到精确,但要能估算量级。经典套路:从"日活 × 人均操作数 ÷ 86400"得到平均 QPS,再乘峰值系数(通常 2~5)得到峰值 QPS。

# 例:100 万 DAU,人均 20 次读、2 次写 日读 = 1e6 * 20 = 2e7 次/天 日写 = 1e6 * 2 = 2e6 次/天 平均读 QPS = 2e7 / 86400 ≈ 230 峰值读 QPS = 230 * 3 ≈ 700 # 乘峰值系数 平均写 QPS = 2e6 / 86400 ≈ 23 # 结论:写压力小,读压力大 → 重点做读缓存/CDN

论估算的意义不在数字,在"走向"

算出来的 QPS 可能差一倍,但结论稳:读多写少就猛加缓存、读写均衡就要分片、峰值高就要异步削峰。量级判断错了,架构方向就错;具体数字差一点,后面都能靠伸缩补。

存储估算:数据量与增长

存储决定"单机存不存得下""要不要分库分表"。按"单条大小 × 条数 × 保留期"估。

# 例:短视频元数据,每条 1KB,日增 100 万条,保留 3 年 日增量 = 1e6 * 1KB = 1 GB/天 年增量 ≈ 365 GB 3 年 ≈ 1.1 TB # 单库一般撑到几百 GB~1TB 就该考虑分片;这里 3 年 1TB,需提前规划分片

读写比与热点识别

不是所有数据均匀访问。二八定律:20% 的内容贡献 80% 的流量。识别出热点(大 V、爆款),才能针对性做本地缓存、多副本。

读写特征架构重点
读多写少(资讯、商品)缓存为主、读写分离
写多读少(日志、计数)异步落库、批量写、列存
强一致写(交易、库存)主库直写、分布式事务
有明显热点本地缓存 + 多副本 + 防击穿

SLA 与可用性目标:几个"9"

可用性常用"几个 9"表达,它直接决定你要做多少冗余。每少一个 9,允许的宕机时间指数级收紧。

# 一年允许的宕机时间 99% ≈ 3.65 天 99.9% ≈ 8.76 小时 99.99% ≈ 52.6 分钟 99.999% ≈ 5.26 分钟 # 金融/核心交易才追求 # 想保 99.99%,单点绝不能存在 → 多可用区、主备、自动故障转移

论可用性是被"冗余 + 自动切换"买来的

提高可用性 = 消灭单点 + 故障时自动切换。但每加一层冗余都加成本和复杂度。先问业务要几个 9,再决定投入。一个内部后台要 99.999% 是过度设计,一个支付网关要 99.9% 是拿命省钱。

定义核心接口与数据模型

估算完,先把"系统对外长什么样"钉死:核心 API、关键表/字段。这步能逼你想清边界,也方便后面逐层拆解。

# 以短链为例,先把接口和数据模型定下来 POST /api/shorten { url: "https://..." } -> { code: "a1b2c3" } GET /a1b2c3 -> 302 redirect to 原 url # 数据模型(先朴素版) Link(code PK, url, creator, created_at, expire_at)

把估算变成架构决策

估算不是写给自己看的,它要直接驱动选型。把数字翻译成"要不要上某块积木",才算用对了估算。

估算结论对应的架构决策
峰值读 QPS 高上 CDN + Redis 缓存,主从读写分离
峰值写 QPS 高消息队列异步削峰,分库分表
存储超单库上限分片,提前规划分片键
要求 99.99% 可用多可用区、主备、自动故障转移
有明显热点本地缓存 + 多副本 + 防击穿

论估算-决策映射是设计的"翻译层"

很多人会算 QPS,但不会"用"。记住这条链路:数字 → 瓶颈在哪 → 哪块积木解这个瓶颈。读多就缓存、写多就异步、存不下就分片、要稳就冗余。决策不再靠感觉,而是对估算的回应。

2

一致性与可用性:分布式里的"鱼与熊掌"

CAP · ACID/BASE · Consistency Models · Raft

单机数据库帮你保证了很多事,一旦数据跨多机,"大家都看到一样的数据吗"就成了要主动设计的选择。这一章讲清 CAP、ACID/BASE、一致性模型,以及多副本怎么达成一致(Raft)。

CAP 定理:分区不可避免,只能在 C/A 间取舍

一致性 C(所有节点同一时刻看到相同数据)、可用性 A(每个请求都有响应)、分区容忍 P(网络断了还能跑)。网络分区在分布式里不可避免,所以实际是在 CP(保一致)和 AP(保可用)间取舍。

类型分区时行为典型系统
CP拒绝不一致写入(可能不响应)ZooKeeper、etcd、HBase
AP仍响应,但可能返回旧数据Cassandra、DynamoDB、DNS
CA(理论)不允许分区,只能单机单库事务(非分布式)
"CAP 三选二"是过度简化

现实里不是非黑即白:很多系统在正常情况下同时保 C 和 A,只有发生分区时才退化成 CP 或 AP。而且"一致性"本身有强弱之分(见下一节)。别把 CAP 当教条,把它当"分区时你优先保哪边"的思考框架。

ACID vs BASE:两种哲学

单体关系库讲 ACID(原子性、一致性、隔离性、持久性),强一致、好写。分布式为了可用和扩展,常转向 BASE:基本可用、软状态、最终一致。

# ACID:转账要么全成要么全败 BEGIN; UPDATE accounts SET bal=bal-100 WHERE id=1; UPDATE accounts SET bal=bal+100 WHERE id=2; COMMIT; # BASE:下单后库存"稍后"才一致(最终一致),期间是软状态 order_svc.create(order); # 先成单 mq.publish("deduct_stock", order); # 异步去扣库存,最终对齐

论什么时候必须 ACID,什么时候能 BASE

钱、库存、订单状态——这些不能错的用 ACID(或分布式事务兜底)。点赞数、阅读量、推荐流——晚几秒一致无所谓的,用 BASE 换扩展性和可用性。设计的核心就是"给每个数据挑合适的保证级别"。

一致性模型:强一致 / 单调读 / 最终一致

"一致性"不是开关,而是一档档的谱。越强的保证体验越好、代价越高。

模型含义例子
强一致写后读一定看到新值主库直读、ZooKeeper
单调读不会"读回旧值"(不会倒退)用同一副本/sticky
最终一致无新写后,过段时间全一致DNS、Cassandra、MQ 异步
因果一致有因果关系才保序评论+回复

共识算法 Raft:多副本怎么"说了算"

多副本要就"谁是 leader、日志怎么对齐"达成一致。Raft 用"选举 + 日志复制"把这件难事讲成人话:节点分 Leader/Follower,写请求走 Leader,日志复制到多数派才算提交。etcd、Consul、很多 K8s 组件底层都是它。

# Raft 直觉 1) 选举:Leader 挂了 → Followers 超时后竞选 → 多数派投票出新 Leader 2) 日志复制:客户端写 → Leader 追加日志 → 复制到多数 Follower → 提交 3) 安全:已提交的日志不会被覆盖(Leader 带更高 term 才合法) # "多数派(quorum)" = N/2+1,所以 5 节点容忍 2 个挂

论为什么是"多数派"而不是"全部"

要求全部节点同意,一个慢节点就能卡死系统(可用性差)。要求多数派(N/2+1)同意,既能防止"两个冲突写入都生效"(脑裂),又容忍少数节点故障。5 节点挂 2 个仍可用,挂 3 个才停——这就是用冗余换可用性的数学表达。

分布式事务:2PC / Saga / TCC

跨服务/跨库要"要么都成、要么都回"时,没有单机事务兜底,得自己编排。三种主流方案各有代价。

方案思路适用 / 代价
2PC准备→提交,两阶段强一致,但协调者易成瓶颈/阻塞
Saga一串本地事务 + 补偿长流程、高可用优先(最终一致)
TCCTry-Confirm-Cancel业务可拆分预留/确认,侵入大
能不用分布式事务就别用

2PC 在协调者故障时会长时间锁资源;Saga 补偿写起来容易漏边界;TCC 要业务方配合拆三步。优先用本地事务 + 可靠消息(事务消息/发件箱模式)达到最终一致,把"强一致"范围缩到最小。强一致是奢侈品,按需使用。

事务隔离级别:脏读 / 不可重复读 / 幻读

ACID 里的 I(隔离性)指"并发事务互不干扰",但完全隔离太慢,所以数据库给出几档可选级别——隔离越严,正确性越好、并发越差。面试常考这三档现象:

级别防脏读防不可重复读防幻读
读未提交否否否
读已提交 RC是否否
可重复读 RR是是否(MySQL 用 MVCC 实际防住)
串行化是是是(但最慢)

论为什么默认不是"串行化"

串行化等于把并发变串行,吞吐量暴跌。多数业务用读已提交或可重复读就够,靠 MVCC(多版本并发控制)让读写不互相阻塞:写时留旧版本,读按快照取,既隔离又不锁读。理解隔离级别,你才懂"为什么我读到了别的事务中间状态"这类诡异 bug 从哪来。

3

缓存 / CDN / 负载均衡:性能的"三把斧"

Cache · CDN · L4/L7 Load Balancing

性能优化的第一杠杆是别让请求打到慢的地方。离用户越近、离磁盘越远的缓存越值钱:CDN 缓存静态资源在边缘,Redis 缓存 DB 查询结果在内存,负载均衡把流量分摊到多机。三者常配合出现。

缓存的位置与层次

缓存是个"金字塔":越往上越快越小越贵。典型层次:浏览器缓存 → CDN → 反向代理缓存 → 应用本地缓存 → 分布式缓存(Redis) → 数据库缓冲池。

# 一次读请求的"缓存命中链" 读 key → 本地缓存有?返回(纳秒级) → Redis 有?返回(毫秒级) → 数据库有?返回,并回填 Redis → 都没有?查源头,回填

论缓存为什么"快"

内存比磁盘快几个数量级,且缓存层通常无复杂索引/事务。把热点数据请进内存,等于把"每次都问慢的 DB"变成"大多数时候问快的内存"。但缓存引入了一致性问题——数据改了,缓存里的旧值怎么办?看下一节。

缓存三大坑:穿透 / 击穿 / 雪崩

这三兄弟是面试和事故常客,必须分清:

坑现象解法
穿透查不存在的 key,直击 DB空值缓存 / 布隆过滤器
击穿某热点 key 过期瞬间海量请求涌向 DB互斥锁重建 / 逻辑过期
雪崩大量 key 同时失效过期时间加随机抖动
# 雪崩防护:过期时间加随机抖动,避免同时失效 expire = base + random(0, jitter) # 如 3600 + rand(0,600) # 击穿防护:单线程重建,其它请求等结果(互斥锁) if (v = redis.get(k)) return v; if (redis.setnx(lock_k, 1, 3s)) { # 抢到锁才查 DB v = db.load(k); redis.set(k, v, expire); redis.del(lock_k); }

缓存更新策略:Cache Aside 最常用

缓存和 DB 怎么保持一致?业界最常用 Cache Aside(旁路缓存):读时回填,写时先更新 DB、再删缓存(不是更新缓存,避免并发写错序)。

# 读路径 v = redis.get(k); if (!v) { v = db.get(k); redis.set(k, v, ttl); } # 写路径:先 DB 后删缓存(delete 而非 update) db.update(k, val); redis.del(k); # 为什么删不是更"新":并发下"更新缓存"可能覆盖成旧值
"先删缓存再更 DB"的坑

顺序是坑点:若先删缓存、再更 DB,期间另一读线程可能把旧值重新填回缓存,造成长期不一致。更稳妥是"先更 DB、再删缓存",并配合短暂的"延迟双删"兜极端并发。没有完美顺序,只有更优。

CDN:把静态内容推到边缘

CDN 把图片/JS/视频缓存到离用户最近的边缘节点,用户就近取货,源站压力与跨地域延迟骤降。它与缓存同理,只是位置在"网络边缘"。

# 验证 CDN 是否命中 curl -I https://cdn.example.com/app.js | grep -i "x-cache\|cf-cache-status" # x-cache: HIT = 边缘命中;MISS = 回源了 # 文件名带哈希(main.a1b2c3.js)可放心长缓存,发版即换名

负载均衡:L4 vs L7

流量先到负载均衡器,再分摊到多台后端。L4 只看 IP/端口(快),L7 能看 URL/Header/Cookie(聪明,可做按路径路由、限流、TLS 卸载)。

维度L4L7
决策依据IP、端口URL、Host、Cookie
性能极高略低(解析应用层)
典型LVS、云 NLBNginx、Envoy、云 ALB
单点即风险

负载均衡器本身不能成单点——通常做主备(Keepalived/VRRP)或用云厂商托管。否则它一挂,后面再健康也白搭。高可用是从 LB 这一层就开始设计的。

一致性哈希:扩容友好的分片

缓存集群扩缩容时,普通取模 hash(key)%N 会让几乎所有 key 重新映射,引发瞬时雪崩。一致性哈希把节点和数据放到哈希环上,新增节点只影响邻近少量 key。

# 一致性哈希直觉(虚拟节点让分布更均匀) 环: 0 ── nodeA ── nodeB ── nodeC ── 2^32 key 顺时针找第一个节点 # 加 nodeD 只"抢走"环上相邻一段,其余 key 不动 → 缓存命中率稳

缓存命中率与容量规划

缓存不是"加了就快",要看命中率(Hit Rate)。命中率 = 命中次数 / 总次数,低于 80% 通常说明缓存策略或容量有问题。容量也要估:热点数据全集能否放进内存?

# 命中率直觉 命中率 99% → 99% 请求不走 DB,DB 压力降到 1% 命中率 50% → 一半还打 DB,缓存形同虚设 # 容量:热点数据集 = 单条大小 × 热点条目数 # 若 1KB × 1000万热点 = 10GB,单节点 Redis 够;更大就分片+LRU 淘汰
缓存命中率暴跌的常见原因

① key 带随机参数(如每次加 timestamp),每个 key 都不同,永远不命中;② 缓存时间太短,还没热就过期;③ 热点突变,新爆款没预热。命中率要当监控指标盯着,掉下去第一时间查这三样。

4

数据库扩展:从单库到分片

Read/Write Split · Replication · Sharding

单库是起点,但不是终点。流量涨起来,先读写分离(主写从读),再不行就分库分表(Sharding)。每一步都引入新复杂度——分片键的选择尤其要命。

垂直拆分与读写分离

第一步往往是"按业务拆库"(用户库、订单库)和"主从复制":一主多从,写走主库、读走从库,把读压力摊出去。这是成本最低、收益最高的扩展。

# 读写分离示意 写请求 → 主库 (Master) 读请求 → 从库1 / 从库2 / 从库3 # 负载均衡分摊 # 应用层用数据源路由:写用 master,读用 replica if (op == WRITE) ds = master; else ds = pick(replicas);

主从复制:原理与延迟

主库把写操作记成 binlog,异步发给从库重放。问题来了:复制有延迟,刚写的数据立刻从从库读可能读不到("读己之写"失效)。

复制模式特点一致性
异步复制主提交即返回,从后追可能丢/读延迟
半同步至少一个从确认收到折中
全同步所有从确认强,但慢
"刚写完马上读"的坑

用户改了昵称,页面刷新却还是旧的——因为读请求被路由到还没追上复制延迟的从库。解法:写后短暂读主库(写主读主一小段时间)、或关键读强制走主、或业务上接受短暂不一致。别假设"写进去立刻读得到"。

分库分表:分片键怎么选

单库到容量/连接上限,就按分片键把数据拆到多库多表。分片键决定数据怎么分布,选错后期极痛。

# 按 user_id 哈希分 4 库 shard = hash(user_id) % 4 # 查某用户订单:直接定位到那一个分片 SELECT * FROM orders WHERE user_id = 123 # 带分片键,好 SELECT * FROM orders WHERE shop_id = 9 # 没分片键 → 全库广播,糟

论分片键要贴合"最主要的热点查询路径"

选了"按地区"但查询多按"用户",会变成全库广播(每片都查一遍)。选键要让绝大多数查询能靠键定位到单一分片。且尽量不可逆——后期想换分片键,意味着数据大迁移,是工程噩梦。

分片后的难点:join / 事务 / 全局 ID

分片省了单库压力,但把"简单的事"变难:跨片 join 要应用层做、跨片事务只能上分布式事务、自增主键会撞。

# 全局唯一 ID:不能再用数据库自增 # 雪花算法 Snowflake:时间戳(41) + 机器(10) + 序列(12) id = (timestamp << 22) | (workerId << 12) | seq # 优点:趋势递增、不依赖中心、单机每 ms 可生成 4096 个 # 坑:时钟回拨会导致重复,需检测+等待
别过早分片

分片是"最后手段",不是"时髦标配"。读写分离 + 缓存 + 加索引往往能扛到很大体量。过早分片把 join/事务/运维复杂度提前引入,是过度工程的高发区。先量再分。

选型:NoSQL 何时才有必要

不是"分片就上 NoSQL"。关系库仍是默认;只有在特定痛点时才引入专用存储。

需求合适选型
强一致事务、复杂查询关系库(PostgreSQL/MySQL)
海量 KV、超高分片扩展KV(Redis、DynamoDB)
写多、按时间查、列可变列族/宽表(Cassandra、HBase)
文档结构、灵活 schema文档库(MongoDB)
关系图谱图库(Neo4j)

连接池、慢查询与索引:DB 的日常命门

分片解决"容量",但日常让 DB 先挂的往往是连接耗尽和慢查询。应用直连 DB 每次建连开销大,必须走连接池(如 HikariCP);慢 SQL 不建索引会把单库 CPU 打满,拖垮所有请求。

# 连接池关键参数直觉 maximumPoolSize = CPU_cores * 2 ~ 3 # 不是越大越好,多了反而上下文切换 # 慢查询:没索引的 LIKE '%x%' 全表扫,分片也救不了单分片内的慢 EXPLAIN SELECT * FROM orders WHERE user_id=123 AND status=0; # 看 type 列:ALL=全表扫(糟),ref/range=用索引(好)
连接池开太大反而更慢

很多人以为"池越大并发越高",结果连接数爆炸,DB 侧上下文切换和内存压力反成瓶颈,吞吐下降。经验值:池大小 ≈ (核心数 × 2) + 有效磁盘数,再压测调。比"拍大"有用的是加索引 + 缓存,从源头减少 DB 压力。

5

消息队列与异步:解耦、削峰与可靠性

Decoupling · Peak Shaving · at-least-once / exactly-once

把"当下必须同步做完"变成"丢进队列稍后做",系统立刻解耦且能扛突发流量(削峰填谷)。代价是引入最终一致性,要处理消费失败、重复消费。这是高吞吐系统的标配积木。

为什么异步:解耦与削峰

同步调用像"打电话"——对方不接你就卡住。异步像"发短信"——发出去就继续干别的,对方有空再处理。两个好处:

  • 解耦:下单不直接调库存/积分/通知,只发消息,下游各自演进。
  • 削峰:秒杀瞬时 10 万请求,队列按消费能力匀速处理,后端不被冲垮。
# 同步 vs 异步 # 同步(脆弱):下单 → 扣库存 → 加积分 → 发通知,任一步慢全慢 # 异步(韧性):下单 → 发消息 → 立刻返回;库存/积分/通知各自消费 mq.publish("order_created", order); # 主链路只做这一件

消息模型:topic / queue / consumer group

不同 MQ 模型略异,但核心概念相通:生产者发到 Topic/Queue,消费者组里多个实例分摊消费,一条消息在同组内只被一个实例处理(实现并行)。

# Kafka 概念 Topic # 逻辑主题,如 order_created Partition # Topic 的分区,并行度单位,保证分区内有序 ConsumerGroup # 一组消费者,组内负载均衡消费分区 # 分区数 = 最大并行消费者数;扩消费者超过分区数也白搭

论分区数决定并行上限

Kafka 里一条消息只进一个分区,一个分区只被组内一个消费者独占。所以消费者实例数超过分区数时,多余的实例闲着。设计 Topic 分区数时要预估峰值并行度,并留扩容余量(分区只能增不能随便减)。

可靠性:at-least-once / exactly-once

网络不可靠,消息可能丢、可能重。MQ 通常保证至少一次(at-least-once):可能重复投递,但尽量不丢。强求精确一次(exactly-once)要靠"幂等 + 事务消息/去重表"。

语义含义代价
at-most-once最多一次,可能丢最简,但丢数据
at-least-once至少一次,可能重复常见默认,靠幂等兜底
exactly-once恰好一次复杂(事务/去重),开销大

幂等:重复消费的安全带

既然 at-least-once 会重复,消费者必须幂等:同一消息处理多次,效果和一次一样。否则"扣款"重复一次就多扣一次。

# 用"已处理消息表"做幂等 begin if exists(processed WHERE msg_id = ?) then return; # 已处理过,直接跳过 do_business(); insert(processed, msg_id); commit; # 业务+去重在同一个事务里,原子
幂等不是"加个 if 那么简单"

关键是"判重"和"业务写入"必须在同一事务/同一原子操作里,否则并发下仍可能都通过判重。用唯一约束(msg_id 唯一索引)让数据库帮你保证原子性,比应用层 if 可靠。

顺序消息与延迟消息

有些业务要顺序:同一订单的状态变更不能乱序。Kafka 靠"同一 key 进同一分区"保分区内有序。延迟消息(如 30 分钟后关单)可用 MQ 的延迟队列或外部定时器。

# Kafka:同 orderId 进同分区 → 该订单消息有序 producer.send(new ProducerRecord(topic, orderId, payload)); # 延迟消息:RabbitMQ 用死信队列+TTL,RocketMQ 原生支持 delay level

选型:Kafka vs RabbitMQ vs Pulsar

没有最好的 MQ,只有最合适的。

MQ强项适用
Kafka高吞吐、持久化、流处理日志、事件流、大数据管道
RabbitMQ灵活路由、低延迟任务队列、RPC、复杂路由
Pulsar存算分离、多租户、分层存储云原生、超大规模

论别为"可能用上"的feature选复杂方案

中小团队、任务队列场景,RabbitMQ 够用且好运维;只有真到"每秒几十万事件 + 流处理"才上 Kafka。Pulsar 能力强但运维重。选你团队运维得起的,而不是 benchmark 上最快的。

死信队列与重试策略

消费失败不可避免:下游临时抖、消息格式错。直接丢弃会丢数据,无限重试会堵队列。死信队列(DLQ)是标准解法:重试 N 次仍失败,消息转进 DLQ,主队列继续往前走,人工/定时去 DLQ 排查。

# 重试策略三档 立即重试 # 网络抖一下,马上重试(错峰极短) 指数退避 # 1s,2s,4s... 避免对故障下游"下毒" 死信队列 # 超过 maxRetry → DLQ,另行处理 # RabbitMQ:x-dead-letter-exchange 指定 DLQ 路由

论重试必须配合退避和幂等

对"正在故障"的下游疯狂重试,等于持续补刀(重试风暴)。指数退避 + 抖动让重试请求错开;同时消费者必须幂等(见上节),因为退避重试必然产生重复投递。DLQ 保证"坏消息不阻塞好消息",是异步系统的安全网。

6

设计案例:把积木拼成系统

Short URL · Feed · Seckill · Resilience

前面是零件,这一章组装。我用三个高频案例演示"方法论怎么落地",并在最后把高可用三板斧(限流/熔断/降级)和权衡清单收口——这是把设计从"能跑"变成"扛得住"的关键。

方法论回顾 + 画图习惯

拿到任何需求,固定走这套:①澄清需求→②估算→③定义接口→④由外到内逐层加积木(LB→Web→缓存→DB→MQ)→⑤写清每处 trade-off。画图从"一个框"开始,别一上来画 30 个组件。

# 通用骨架(自上而下推敲) Client → CDN/LB → API 层 → [Cache] → [DB] ↓ 异步 MQ → Worker # 每加一个框,问:它解决什么痛点?带来什么新成本?

案例一:短链系统

需求:长 URL 转短码,访问短码 302 跳原 URL,高读低写。按方法论走:

# 写入:长 URL → 生成短码(发号器/哈希+冲突重试) code = base62(snowflake.next()) # 或 hash(url) 取前 7 位 redis.set(code, url, ttl) # 缓存,热点直接命中 db.insert(code, url, expire_at) # 读取: GET /{code} → 查缓存 → 查 DB → 302 Location: url # 读路径极简

论短链的两个设计点

① 短码生成:自增发号器+base62 最省空间且不可猜;哈希法可能冲突需重试。② 302 还是 200:302 跳转,原 URL 变更/统计都灵活;若想让 CDN 缓存"展开结果"可用 200+前端跳,但失去服务端统计。按"要不要统计点击"选。

短链也要防滥用

短链常被用来隐藏恶意网址/钓鱼。要加:生成频率限制、可疑 URL 黑名单、点击时扫毒。否则你的短链服务会变成黑产跳板,域名被墙。

案例二:Feed 流(信息流/朋友圈)

需求:用户刷"关注的人发的动态",读多写少、要新鲜。两种经典方案:

方案做法优劣
拉(Pull)刷时查所有关注者的新动态再聚合实时,但大 V 粉丝多时查询爆炸
推(Push)发动态时写进每个粉丝的收件箱读快,但大 V 发一条写爆(扇出)
推拉结合大 V 用拉、普通用户用推折中,工程复杂但扛得住
# 推模式:发动态时扇出到粉丝收件箱(用 Redis 有序集合按时间) for fan in followers(uid): redis.zadd("feed:"+fan, score=now, value=post_id) # 读:ZREVRANGE 取最新 N 条,O(log N) 极快 feed = redis.zrevrange("feed:"+me, 0, 20)

案例三:秒杀与限流

需求:瞬时海量请求抢有限库存,不能超卖、不能压垮。核心思路:层层削峰 + 原子扣减。

# 1) 入口限流(Nginx/网关)挡掉大部分无效请求 # 2) 库存预热到 Redis,用原子递减判是否抢到 if redis.decr("stock:"+item) >= 0: mq.publish("order", uid) # 抢到名额,异步创建订单 else: redis.incr("stock:"+item); return "售罄" # 回补,避免误判 # 3) 订单落库用事务消息,保证"扣库存"与"建单"最终一致
秒杀最忌"直接查 DB 库存"

几万人同时 SELECT stock FROM ... FOR UPDATE,数据库瞬间被行锁打死。正确姿势:请求先进 Redis 原子扣减(内存级、快),只有扣到名额的才进 DB 建单。把"是否还有货"的判断挡在离用户最近、最快的地方。

高可用三板斧:限流 / 熔断 / 降级

系统总会遇到超出预期的压力或故障。高可用不是"不挂",而是"挂得优雅"——把影响圈在最小范围。

# 限流:令牌桶,保护后端不被冲垮 if !bucket.tryAcquire(): return 429; # 令牌没了对外拒,保住系统 # 熔断:下游连续出错就先断开,给它就绪时间 if errorRate > 50%: open_circuit(); # 直接快速失败,不再打挂下游 # 降级:非核心功能在压力下关掉或走兜底 recommend = enabled ? recommendSvc() : []; # 推荐挂了,首页照常返回
手段目的常见实现
限流保护容量不被突破令牌桶/漏桶、Nginx limit_req
熔断防故障扩散(雪崩)Sentinel、Hystrix 思路
降级保主链路开关/兜底数据/默认值
幂等重试/重复安全唯一约束/去重表(见第 5 章)

论为何限流是"对用户的温柔"

不限流,系统被冲垮,所有用户都用不了;限流,只拒绝超额的那部分,大多数用户正常。拒绝 1% 好过崩给 100%。所以限流不是拦用户,是保住能服务的那批用户——前提是配好阈值和友好提示。

权衡清单:每个选择都写代价

好设计的标志不是"用了多炫的技术",而是清楚每处取舍。交卷前过一遍:

# 设计自检清单 [ ] 容量:估算过 QPS/存储吗?够用到什么时候? [ ] 一致性:哪些数据强一致?哪些可最终一致?为什么? [ ] 单点:LB/DB/Cache 有冗余和自动切换吗? [ ] 缓存:三大坑防了吗?更新策略是什么? [ ] 异步:哪些能异步?幂等做了吗? [ ] 高可用:限流/熔断/降级阈值怎么定? [ ] 瓶颈:当前架构最先挂的是哪一层?怎么扩?

上线前压测:用数据验证设计

设计再漂亮,没压测就是猜想。上线前用工具模拟目标峰值,看架构在哪一层先跪——是连接池满、缓存击穿、还是 DB 锁。

# 用 k6 写个压测脚本(目标:验证 700 读 QPS 是否稳) import http from 'k6/http'; export const options = { vus: 100, duration: '5m' }; export default () => { http.get('https://api.example.com/feed'); }; # 看指标:p95 延迟、错误率、吞吐量;配合监控看哪层先到瓶颈

论压测要"带真实数据分布"

用均匀随机数据压,往往压不出问题——真实流量有热点(大 V、爆款)。压测要造出贴近真实的热点分布,才能暴露缓存击穿、单分片过热。压测结论 + 监控证据,才是对"设计对不对"的最终回答。

结
本页要点

系统设计先澄清功能/非功能需求与量级(QPS、存储、读写比、SLA 几个 9)再动手。分布式下在 CAP 中取舍(P 不可避免,选 CP 或 AP),用 ACID/BASE 选保证级别,靠 Raft 做多副本共识,用 Saga/2PC 管跨服务事务。性能三板斧:CDN 边缘缓存、Redis 缓存(警惕穿透/击穿/雪崩,用 Cache Aside)、L4/L7 负载均衡(一致性哈希扩容友好)。数据库先读写分离、主从复制(注意复制延迟),再按分片键分库分表(避开过早分片)。消息队列解耦削峰,用 at-least-once + 幂等兜底,Kafka/RabbitMQ/Pulsar 按场景选。高可用靠限流/熔断/降级/幂等"挂得优雅"。案例(短链/Feed/秒杀)与权衡清单把积木拼成可扛的系统。

自测 · 看你是否真懂

1.缓存"穿透、击穿、雪崩"三者区别是什么?各怎么防?

查看答案

穿透=查不存在的 key 打穿到 DB(布隆过滤器/空值缓存);击穿=单热点 key 过期瞬间海量请求涌向 DB(互斥锁重建/逻辑过期);雪崩=大量 key 同时失效(过期时间加随机抖动)。三者都围绕"别让请求无缓冲地砸到慢的 DB"。

2.CAP 里的 P(分区容忍)为什么"必须保"?CP 和 AP 怎么选?

查看答案

网络分区(节点间失联)在分布式里不可避免,所以 P 是前提,只能在 C(强一致)和 A(高可用)间取舍:CP 系统分区时拒绝不一致写入(如 etcd),AP 系统分区时仍响应但可能返回旧数据(如 Cassandra)。选哪边看业务——钱/库存要 CP,点赞/feed 可 AP。

3.为什么分布式系统尤其强调"幂等"?怎么实现?

查看答案

网络不可靠,请求会被重试、消息会被重复投递(at-least-once)。若"扣款"不幂等,重复一次就多扣一次。实现:用 msg_id 唯一索引/去重表,让"判重"和"业务写入"在同一事务里原子完成,保证多次处理等同一次。

4.分片键(sharding key)选错会有什么后果?怎么选?

查看答案

选错会让主要查询变成全库广播(每片都查),失去分片意义,且后期换键=数据大迁移。应选贴合最主要热点查询路径的字段(如 user_id),让多数查询能靠键定位到单一分片;尽量选不可逆的键。

5.秒杀系统为什么不能"直接查数据库库存"?正确思路是什么?

查看答案

几万人同时 SELECT ... FOR UPDATE 会瞬间把数据库行锁打死。正确:请求先在 Redis 用原子递减(DECR)判是否抢到名额,只有抢到的才进 MQ 异步建单;库存挡在最快的内存层,层层削峰,DB 只处理真正成交的少量写。

6.主从复制下,"刚写完马上读"可能读到旧值,为什么?怎么缓解?

查看答案

因为复制是异步的,从库追 binlog 有延迟。缓解:写后短暂强制读主库、关键读走主、或业务接受短暂不一致。别假设"写进去立刻读得到"。

下一步往哪走

路学完系统设计之后

① 串技术栈:去 tech-middleware 看 Nginx/Kafka/Redis 真家伙,本页是"为什么用"、那里是"怎么配"。

② 配合网络:回看 tech-network 的 LB/CDN/TLS,本页的架构决策要靠那些协议落地。

③ 动手:挑一个需求(短链/秒杀/评论系统)完整走一遍方法论,画架构图并写下每处的 trade-off——能写清取舍,才算真懂。

补

算法复杂度分析:大 O 与大白话

Complexity Analysis

复杂度衡量的是「输入规模 n 增长时,资源(时间/空间)的增长趋势」,不是精确运行次数;用大 O 记法忽略常数和低阶项,方便在写代码前就能比较两种思路谁更扛得住大数据。

为什么需要复杂度

同一个功能,双循环和哈希表两种写法在小数据上几乎看不出差别,但 n 到十万、百万时差距就是"秒回"和"卡死"。复杂度给你一个不依赖机器、不依赖语言的可比标尺。

论常见阶对照

记住几档典型量级:O(1) 哈希表读写(均摊);O(log n) 二分查找、平衡树;O(n) 线性扫描;O(n log n) 归并排序、快排平均;O(n²) 冒泡、朴素双重循环;O(2ⁿ) 朴素斐波那契递归、子集枚举。最好/最坏/平均情况要分开看,例如快排平均 O(n log n)、最坏 O(n²)(已排序且首元素为轴)。空间复杂度同样重要,递归深度、额外数组都算。

复杂度典型场景
O(1)哈希表读写(均摊)
O(log n)二分查找、平衡树
O(n)线性扫描
O(n log n)归并排序、快排(平均)
O(n²)冒泡排序、朴素双重循环
O(2ⁿ)朴素斐波那契递归、子集枚举

论主定理简述

对分治递归 T(n)=aT(n/b)+f(n),主定理给三种归并情形,可秒判很多递归的阶,不必手推(如归并/快排/二分)。

常见坑

复杂度是渐进上界,常数因子在小 n 时可能主导;别盲目追求理论最优而忽略实际常数与缓存局部性。n 很大时低阶项无所谓,但 O(n²) 在 n=10⁵ 会直接爆,优先选 O(n log n)。

自测

Q1. 二分查找为什么是 O(log n)?

参考答案

每次把搜索区间对半砍,k 次后剩 1 个元素,即 2^k≈n,所以 k≈log₂n。

动手片段

下面这段可直接运行,把抽象概念变成"手感"。

import timeit # O(n):一次遍历求和 def sum_linear(a): s = 0 for x in a: s += x return s # O(n^2):双重循环(仅演示,生产别这么写) def sum_pairs(a): s = 0 for i in range(len(a)): for j in range(len(a)): s += a[i] * a[j] return s n = 2000 a = list(range(n)) t1 = timeit.timeit(lambda: sum_linear(a), number=50) t2 = timeit.timeit(lambda: sum_pairs(a), number=50) print(f"O(n) 耗时: {t1:.4f}s") print(f"O(n^2) 耗时: {t2:.4f}s (n 翻倍时约 4 倍)") # O(log n):二分查找(数组必须有序) def binary_search(arr, target): lo, hi = 0, len(arr) - 1 while lo <= hi: mid = (lo + hi) // 2 if arr[mid] == target: return mid elif arr[mid] < target: lo = mid + 1 else: hi = mid - 1 return -1 print(binary_search([1, 3, 5, 7, 9], 7)) # 输出 3