Skip to content

系统设计

系统设计不是背标准答案,而是在明确约束下做可解释的取舍:量级决定架构形态,一致性要求决定数据方案,可用性目标决定冗余与降级策略。本文给出一套可复用的分析框架(估算 → 积木 → 一致性 → 高并发 → 高可用),并用短链服务做一次完整的容量估算实战。

一、方法论:五步法

步骤做什么产出
1. 澄清需求与量级日活、读写比例、QPS、存储年限、延迟要求可计算的数字,而非"高并发"这类形容词
2. 明确约束与假设一致性 vs 可用性、成本上限、团队规模、合规取舍的优先级
3. 顶层设计画出主干链路(客户端 → 接入 → 服务 → 存储)一张能讲清楚的架构草图
4. 深入关键组件对瓶颈部件给出方案与理由存储选型、缓存策略、分片键
5. 收尾与瓶颈单点、扩容路径、故障场景、监控已知短板与下一步

估算速查表

text
时间: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 位):

text
虚拟节点   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 年,短码长度待定。

text
① 总量: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 连续性有要求
js
// 雪花 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):

text
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 秒杀:把流量挡在链路最外层

text
① 前端:按钮置灰、验证码、答题  → 拦掉重复点击与脚本
② 网关:按用户/接口限流、黑名单  → 拦掉超额流量(限流算法见 微服务篇)
③ 缓存:Redis 原子预扣库存(Lua) → 库存判断不落库
④ 队列:扣减成功后发消息,异步创建订单  → 削峰,DB 按自身能力消费
⑤ 兜底:库存售罄标记、超时未支付回滚
lua
-- 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%。每多一个强依赖,可用性就掉一档——因此降级策略的本质是"把强依赖变成弱依赖"。

九、检查清单

  1. 需求已量化:QPS、存储、延迟、一致性要求、增长预期;
  2. 有容量估算,且能说清单机扛不扛得住、瓶颈在哪;
  3. 关键路径无单点,每个组件都有明确的故障影响与降级方案;
  4. 缓存策略明确:更新方式、过期策略、穿透/击穿/雪崩的应对;
  5. 分片键选择合理,避免跨片事务与热点分片;
  6. 分布式事务方案与业务一致性要求匹配(优先避免,其次 Saga/事务消息);
  7. 有幂等设计:重试、消息重投、用户重复提交;
  8. 有限流与熔断,且明确限流维度(用户/接口/租户/IP);
  9. 有监控与告警:黄金四指标(延迟、流量、错误、饱和度)+ 业务指标;
  10. 有压测数据与容量水位,扩容路径可执行(加机器 or 加分片)。

十、常见坑速查

  1. 不做估算直接上分布式:日活几千的系统搞分库分表,复杂度白付;
  2. 分片键拍脑袋:用时间戳分片 → 写入全压在一个分片(热点);用会变更的字段分片 → 数据要迁移;
  3. 缓存与 DB 更新顺序搞反:先删缓存再更新 DB,会留下长期脏数据;
  4. 缓存 TTL 完全一致:同时过期 → 雪崩(加随机抖动);
  5. 热点 key 无保护:过期瞬间全量回源,DB 被打垮(单飞重建 + 逻辑过期);
  6. "先查库存再扣减":并发下必然超卖,判断与写入必须原子(§7.1);
  7. 重试非幂等接口:重复扣款/重复下单——幂等键是重试的前置条件;
  8. ID 用 number 传输:雪花 ID 超过 2^53,JS 后端/前端精度丢失导致 ID 变错;
  9. 依赖链过长且无降级:一个非核心服务挂了拖垮整条链路;
  10. 容量只看均值不看峰值:均值 11.6 QPS 的系统,峰值可能 5000+ QPS(§三);
  11. 主从延迟未处理:写完立刻读从库读不到 → 写后读走主库,或等延迟窗口;
  12. 把备份当高可用:从未演练过切换,真正故障时不敢切。

状态与参考

下一步

  • [ ] 补一个完整的"秒杀系统"设计案例(含压测数据与限流阈值推导)
  • [ ] 用 Redis + Lua 实测库存扣减与幂等去重,对比乐观锁方案
  • [ ] 写一篇"异地多活的数据同步与流量调度"专题

写作规范请参阅领域概览

基于 VitePress 构建 · 内容以知识共享方式沉淀