系统设计
系统设计不是背标准答案,而是在明确约束下做可解释的取舍:量级决定架构形态,一致性要求决定数据方案,可用性目标决定冗余与降级策略。本文给出一套可复用的分析框架(估算 → 积木 → 一致性 → 高并发 → 高可用),并用短链服务做一次完整的容量估算实战。
一、方法论:五步法
| 步骤 | 做什么 | 产出 |
|---|---|---|
| 1. 澄清需求与量级 | 日活、读写比例、QPS、存储年限、延迟要求 | 可计算的数字,而非"高并发"这类形容词 |
| 2. 明确约束与假设 | 一致性 vs 可用性、成本上限、团队规模、合规 | 取舍的优先级 |
| 3. 顶层设计 | 画出主干链路(客户端 → 接入 → 服务 → 存储) | 一张能讲清楚的架构草图 |
| 4. 深入关键组件 | 对瓶颈部件给出方案与理由 | 存储选型、缓存策略、分片键 |
| 5. 收尾与瓶颈 | 单点、扩容路径、故障场景、监控 | 已知短板与下一步 |
估算速查表
时间:1 天 ≈ 8.64 万秒 ≈ 10^5 秒(按 10 万估算,误差可接受)
QPS:1 QPS → 日 8.64 万次;100 QPS → 日 864 万次;1000 QPS → 日 8640 万次
存储:1 KB × 1000 万 = 10 GB;1 MB × 100 万 = 1 TB
带宽:1 Gbps ≈ 125 MB/s;单次响应 10 KB × 1000 QPS ≈ 100 MB/s
延迟参考:内存 100ns / SSD 读 100µs / 同机房网络 0.5ms / 跨城 10-30ms / 磁盘寻道 10ms估算的意义不在精确,而在判断量级:是"单机能扛"还是"必须分布式",直接决定方案复杂度。
二、核心积木
| 积木 | 解决什么 | 关键选择 |
|---|---|---|
| 负载均衡 | 把流量分到多实例 | L4(快、不解析内容)vs L7(按路径/Header 路由);算法:轮询、最少连接、一致性哈希 |
| 缓存 | 降低延迟与后端压力 | 位置:客户端 → CDN → 网关 → 应用本地 → 分布式缓存;策略见 §四 |
| 异步队列 | 削峰、解耦、重试 | 见 消息队列 |
| CDN | 就近分发静态资源与部分动态内容 | 命中率决定收益;回源要与源站容量匹配 |
| 读写分离 | 扩展读能力 | 主从延迟要有容忍策略(写后读走主库) |
| 分库分表 | 突破单库容量与写入瓶颈 | 分片键决定一切;避免跨片事务与跨片查询 |
| 搜索引擎 | 复杂检索与聚合 | 倒排索引,异步同步,最终一致 |
| 对象存储 | 图片/视频/大文件 | 直传 + 回调,不走应用服务器 |
一致性哈希(分库分表与负载均衡常用):把节点和数据都映射到同一个哈希环,数据顺时针找最近的节点,这样节点增减时只需迁移少量数据。Node v22.22.1 实测(5 节点 / 10 万 key / md5 取前 32 位):
虚拟节点 1/节点 → 各节点命中 17456/32364/8572/16313/25295 标准差 8143 最大偏移 61.8%
虚拟节点 10/节点 → 18100/36964/14650/12368/17918 标准差 8747 最大偏移 84.8%
虚拟节点 50/节点 → 15664/16243/22173/21183/24737 标准差 3506 最大偏移 23.7%
虚拟节点 100/节点 → 16377/18086/21753/21567/22217 标准差 2334 最大偏移 11.1%
虚拟节点 200/节点 → 19090/20191/20550/20618/19551 标准差 592 最大偏移 3.1%
5 节点 → 6 节点:迁移 14924/100000 = 14.92%(理论最小迁移率 1/6 ≈ 16.67%)结论:虚拟节点数至少上百才能把偏移压到个位数;节点增减只迁移约 1/N 的数据,这是它相对"取模分片"的核心优势(取模分片扩缩容需迁移绝大部分数据)。
三、容量估算实战:短链服务
需求:日新增短链 100 万条,读写比 100:1,保存 5 年,短码长度待定。
① 总量:100 万/天 × 365 × 5 年 = 18.25 亿条
② 短码空间(Node 实测):
base62 6 位 → 56,800,235,584 条(568 亿)
base62 7 位 → 3,521,614,606,208 条
base62 8 位 → 218,340,105,584,896 条
→ 6 位空间 568 亿 ≫ 18.25 亿,够用且留足余量
③ 存储:单行约 556 B(id 8 + 原始 URL 500 + 时间戳 8 + 统计 8 + 索引与行开销 32)
18.25 亿 × 556 B ≈ 945 GB(未计索引与副本)
④ 写入:100 万 / 86400 ≈ 11.6 QPS(均值)
⑤ 读取:读写比 100:1 → 1157 QPS 均值;峰值按 5 倍系数 ≈ 5787 QPS由此得出的设计结论:
| 维度 | 结论 | 理由 |
|---|---|---|
| 写入 | MySQL 单机完全够(11.6 QPS) | 写入不是瓶颈,不必一上来就分库 |
| 读取 | 5787 QPS 必须靠缓存 | 缓存命中率 99% 时,落到 DB 只有 ~58 QPS |
| 存储 | 945 GB 必须分库分表 | 单表建议控制在 2000 万行内 → 需 100+ 张表;按短码哈希分片 |
| 短码生成 | 雪花 ID → base62,或号段发号器 | 见 §六 |
| 跳转 | 301(永久)vs 302(临时) | 需要统计访问量用 302;追求性能与 SEO 用 301 |
注意估算里的"单行约 556 B"是个很松的假设(原始 URL 按 500 B 上限估)。真实设计时要按 P95 实际长度取值,并考虑冷热分离:热数据放缓存与 SSD,冷数据归档。
四、缓存:三大问题与应对
| 问题 | 现象 | 解法 |
|---|---|---|
| 缓存穿透 | 查询必然不存在的数据(如不存在的 id),每次都打到 DB | 空值缓存(短 TTL)+ 布隆过滤器 + 参数校验 |
| 缓存击穿 | 单个热点 key 过期瞬间,大量请求同时回源 | 热点 key 永不过期 + 逻辑过期 + 单飞(互斥重建) |
| 缓存雪崩 | 大量 key 同时过期,或缓存集群整体不可用 | TTL 加随机抖动 + 多级缓存 + 限流降级 + 集群高可用 |
更新策略取舍:
| 策略 | 一致性 | 适用 |
|---|---|---|
| Cache Aside(旁路) | 最终一致,存在短暂不一致窗口 | 默认选择:读时未命中才加载,写时更新 DB 后删除缓存 |
| Write Through | 强一些 | 写多读少、对一致性要求高 |
| Write Back | 弱,有丢数据风险 | 极高写入量且可容忍丢失(如计数) |
先更新 DB 再删缓存,且删除失败要有重试(如订阅 binlog 异步删除)。反过来"先删缓存再更新 DB"会有一个并发窗口:删缓存 → 读请求 miss 并加载旧值 → 更新 DB,此后缓存里长期是脏数据。
五、一致性:从 CAP 到可落地的方案
- CAP:网络分区(P)发生时,只能在一致性(C)和可用性(A)之间选一个——注意这是分区发生时的取舍,正常状态下两者都可兼顾;
- PACELC:更贴近现实——分区时在 C/A 间取舍,否则(Else)在延迟(L)与一致性(C)间取舍(同步复制越广,延迟越高);
- BASE:基本可用、软状态、最终一致——互联网业务的常态。
分布式事务方案对比:
| 方案 | 一致性 | 侵入性 | 适用 |
|---|---|---|---|
| 2PC / XA | 强一致 | 低(数据库层) | 低并发、短事务;协调者单点、锁持有时间长 |
| TCC(Try-Confirm-Cancel) | 最终一致 | 高(每个操作写三段逻辑) | 资金、交易等强管控场景 |
| Saga(编排/协同) | 最终一致 | 中(需写补偿动作) | 长事务、跨多服务业务流程 |
| 本地消息表 | 最终一致 | 中 | 简单可靠:本地事务写业务 + 写消息,异步投递 |
| 事务消息(MQ) | 最终一致 | 低 | 有 MQ 支持(如 RocketMQ),半消息 + 回查 |
| 最大努力通知 | 最弱 | 低 | 对一致性要求不高的通知类场景 |
落地顺序:优先避免分布式事务(把需要强一致的逻辑收敛到同一个服务、同一库),确实跨服务再选 Saga 或事务消息。强一致方案的成本远高于业务方的心理预期。
六、ID 生成
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 数据库自增 | 简单、单调 | 单库瓶颈,ID 可推算 | 小系统 |
| UUID | 本地生成、无协调 | 无序(索引页分裂)、128 位太长 | 对顺序无要求的场景 |
| 雪花算法 | 本地生成、趋势递增、64 位 | 依赖时钟(回拨问题) | 分布式系统主流 |
| 号段发号器 | 趋势递增、DB 压力小 | 需额外服务与高可用设计 | 对 ID 连续性有要求 |
// 雪花 ID:1 位符号 + 41 位时间戳 + 5 位数据中心 + 5 位机器 + 12 位序列
const EPOCH = 1735689600000 // 自定义起始时间(2025-01-01),可延长可用年限
class Snowflake {
constructor(workerId, datacenterId) {
this.workerId = workerId & 0x1f
this.datacenterId = datacenterId & 0x1f
this.sequence = 0
this.lastTs = -1
}
nextId() {
let ts = Date.now()
if (ts === this.lastTs) { // 同一毫秒内自增序列
this.sequence = (this.sequence + 1) & 0xfff
if (this.sequence === 0) while (ts <= this.lastTs) ts = Date.now() // 序列耗尽,等下一毫秒
} else this.sequence = 0
this.lastTs = ts
return ((BigInt(ts - EPOCH) << 22n) | (BigInt(this.datacenterId) << 17n) |
(BigInt(this.workerId) << 12n) | BigInt(this.sequence)).toString()
}
}Node v22.22.1 实测(worker=1, datacenter=1):
220861667481882624 → 2026-09-02T11:05:24.939Z worker=1 seq=0
220861667481882625 → 2026-09-02T11:05:24.939Z worker=1 seq=1
220861667481882626 → 2026-09-02T11:05:24.939Z worker=1 seq=2
220861667481882627 → 2026-09-02T11:05:24.939Z worker=1 seq=3
220861667481882628 → 2026-09-02T11:05:24.939Z worker=1 seq=4
同毫秒内单调递增: true三个必须处理的细节:时钟回拨(回拨则拒绝发号或等待,否则 ID 重复)、机器号分配(用注册中心/配置下发,避免手抄重复)、JS 安全整数(ID 超过 2^53 必须作为字符串传输,JSON 里不要转成 number)。
七、高并发三板斧
| 手段 | 做法 | 代价 |
|---|---|---|
| 缓存 | 多级缓存、热点探测、命中率治理 | 一致性窗口 |
| 削峰 | 队列异步化、排队页、验证码、预约 | 延迟上升,实时性下降 |
| 扩容 | 无状态水平扩展、分库分表、读写分离 | 架构与运维复杂度 |
7.1 秒杀:把流量挡在链路最外层
① 前端:按钮置灰、验证码、答题 → 拦掉重复点击与脚本
② 网关:按用户/接口限流、黑名单 → 拦掉超额流量(限流算法见 微服务篇)
③ 缓存:Redis 原子预扣库存(Lua) → 库存判断不落库
④ 队列:扣减成功后发消息,异步创建订单 → 削峰,DB 按自身能力消费
⑤ 兜底:库存售罄标记、超时未支付回滚-- Redis 原子扣减库存:判断与扣减必须在同一脚本内,否则并发下会超卖
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil then return -1 end -- 未初始化
if stock <= 0 then return 0 end -- 已售罄
redis.call('DECR', KEYS[1])
return 1 -- 扣减成功超卖的根因几乎都是"先查再改"的竞态。要么用 Redis 单线程 + Lua 保证原子,要么在 DB 层用条件更新
UPDATE stock SET qty = qty - 1 WHERE id = ? AND qty > 0靠受影响行数判断,二者都是把判断与写入合成一个原子操作。
7.2 Feed 流:推拉之择
| 模式 | 做法 | 适用 |
|---|---|---|
| 推(写扩散) | 发内容时写入每个粉丝的收件箱 | 粉丝量小、读多写少(如朋友圈) |
| 拉(读扩散) | 读时聚合关注者的内容 | 粉丝量极大(大 V),写入成本敏感 |
| 推拉结合 | 大 V 用拉、普通用户用推 | 主流做法,按粉丝数分档 |
八、高可用:冗余 + 快速失败 + 降级
- 冗余:无单点(多实例、多可用区、主备切换),注意"主备"必须定期演练切换,否则备份等于没有;
- 超时与重试:见 微服务,重试必须幂等;
- 熔断与限流:保护自己、也保护下游,算法实测见微服务篇;
- 降级与兜底:关闭非核心功能、返回兜底内容,保住主链路;
- 多活:同城双活(延迟低、实现较易)→ 异地多活(需解决数据同步与流量调度,成本高)。
可用性的算术:一个依赖链上有 5 个组件,每个 99.9%,整体只有约 99.5%。每多一个强依赖,可用性就掉一档——因此降级策略的本质是"把强依赖变成弱依赖"。
九、检查清单
- 需求已量化:QPS、存储、延迟、一致性要求、增长预期;
- 有容量估算,且能说清单机扛不扛得住、瓶颈在哪;
- 关键路径无单点,每个组件都有明确的故障影响与降级方案;
- 缓存策略明确:更新方式、过期策略、穿透/击穿/雪崩的应对;
- 分片键选择合理,避免跨片事务与热点分片;
- 分布式事务方案与业务一致性要求匹配(优先避免,其次 Saga/事务消息);
- 有幂等设计:重试、消息重投、用户重复提交;
- 有限流与熔断,且明确限流维度(用户/接口/租户/IP);
- 有监控与告警:黄金四指标(延迟、流量、错误、饱和度)+ 业务指标;
- 有压测数据与容量水位,扩容路径可执行(加机器 or 加分片)。
十、常见坑速查
- 不做估算直接上分布式:日活几千的系统搞分库分表,复杂度白付;
- 分片键拍脑袋:用时间戳分片 → 写入全压在一个分片(热点);用会变更的字段分片 → 数据要迁移;
- 缓存与 DB 更新顺序搞反:先删缓存再更新 DB,会留下长期脏数据;
- 缓存 TTL 完全一致:同时过期 → 雪崩(加随机抖动);
- 热点 key 无保护:过期瞬间全量回源,DB 被打垮(单飞重建 + 逻辑过期);
- "先查库存再扣减":并发下必然超卖,判断与写入必须原子(§7.1);
- 重试非幂等接口:重复扣款/重复下单——幂等键是重试的前置条件;
- ID 用 number 传输:雪花 ID 超过 2^53,JS 后端/前端精度丢失导致 ID 变错;
- 依赖链过长且无降级:一个非核心服务挂了拖垮整条链路;
- 容量只看均值不看峰值:均值 11.6 QPS 的系统,峰值可能 5000+ QPS(§三);
- 主从延迟未处理:写完立刻读从库读不到 → 写后读走主库,或等延迟窗口;
- 把备份当高可用:从未演练过切换,真正故障时不敢切。
状态与参考
- 状态:已收录(2026-09-02,由后端领域规划清单「系统设计|高并发、高可用架构案例分析」新建)。
- 版本:容量估算、雪花 ID、一致性哈希分布与迁移率均为 Node v22.22.1 实测(输出见文中,可直接复现);架构模式与方案对比表为通用工程结论,与具体版本无关。
- 参考:系统设计入门(System Design Primer)、Google SRE Book、DDIA《数据密集型应用系统设计》、高可用架构(InfoQ 专栏)。
- 阅读联动:限流算法与熔断的实测见 微服务;缓存三大问题与 Redis 实践见 缓存;异步削峰与消费语义见 消息队列;容量落地到副本与资源见 容器与部署;分库分表与索引优化见 数据库。
下一步
- [ ] 补一个完整的"秒杀系统"设计案例(含压测数据与限流阈值推导)
- [ ] 用 Redis + Lua 实测库存扣减与幂等去重,对比乐观锁方案
- [ ] 写一篇"异地多活的数据同步与流量调度"专题
写作规范请参阅领域概览。