Table of contents
Open Table of contents
TL;DR
可扩展性是”加资源后处理能力能否线性提升”,不是”单次请求多快”(那是性能)。Amdahl 定律决定了串行瓶颈是理论天花板——加再多机器绕不过一把全局锁。正确路径:先垂直扩展 + 缓存(ROI 最高),瓶颈出现后再水平扩展(复杂度最高),每一步都由真实数据驱动而非预测。
可扩展性 vs 性能
| 维度 | 性能 | 可扩展性 |
|---|---|---|
| 关注点 | 单次操作延迟/吞吐 | 负载增长时的行为 |
| 度量 | p50/p99 延迟、QPS | 资源-吞吐量比例 |
| 典型问题 | ”这个接口为什么慢?" | "用户量翻 10 倍还能撑住吗?” |
一个 C++ 单机内存数据库 100 万 QPS 但无法水平扩展 = 高性能低可扩展性。Cassandra 单机性能不如它但可线性扩展到数百节点 = 中性能高可扩展性。
理论天花板
Amdahl’s Law:加速比 = 1 / (S + (1-S)/N),S 是串行比例。即使 N→∞,加速比被 1/S 封死。5% 串行瓶颈 → 最大加速比永远不超过 20 倍。
USL(通用可扩展性定律)加入节点间协调开销:当协调系数 β > 0 时,吞吐量在某个 N 值后下降——加节点反而更慢。这解释了为什么有些系统加到 8 节点后性能反降。
垂直 vs 水平
| 维度 | 垂直扩展 | 水平扩展 |
|---|---|---|
| 成本曲线 | 指数增长(高配硬件溢价) | 近线性(商品化硬件堆叠) |
| 扩展上限 | 硬上限(单机最大配置) | 理论无限(受串行瓶颈限制) |
| 故障影响 | 单点挂 = 全挂 | 局部故障 |
| 复杂度 | 低 | 高(分布式经典难题全来) |
| 延迟 | 更低(进程内通信) | 更高(网络 + 序列化) |
| 一致性 | 天然强一致 | 需要权衡 |
垂直扩展什么时候用:数据库层(水平扩展复杂度极高);负载尚未超出单机能力;有强一致性需求。过早引入分布式是最常见的过度工程。
各层策略
应用层:无状态 + 自动伸缩
核心:应用不保存状态,Session/文件/缓存全外置。每个实例等价,流量可任意分配。
HPA 关键配置:扩容 stabilizationWindow 60s 防抖动;缩容 300s 更保守;设 maxReplicas 防成本失控。VPA 适合不宜水平扩展的场景(有状态服务),两者不建议同 metric 同时使用。
异步处理:不需要同步返回的操作扔消息队列,削峰填谷。消费者必须幂等(at-least-once 意味着消息可能重复)。
数据层:读写分离 → 分片
读写分离适用于读写比 > 10:1 的场景。代价是复制延迟——写后立即读可能读到旧数据。解决:写后短期内读主库。
分片策略对比:
| 策略 | 优势 | 劣势 |
|---|---|---|
| Hash Sharding | 数据分布均匀 | 范围查询需扫所有分片 |
| Range Sharding | 范围查询高效 | 容易数据倾斜 |
| Geo Sharding | 低延迟 + 数据合规 | 跨区域查询复杂 |
分片的代价(经常被低估):
- 跨分片查询:按 user_id 分片,merchant_id 查询要扫所有分片(scatter-gather)
- 分布式事务:跨分片 ACID 需要 2PC/Saga
- Rebalancing:在线数据迁移可能持续数天
- 全局唯一 ID:自增 ID 失效,需要 Snowflake/UUID
- 分片键一旦选错,变更代价接近重建整个数据层
缓存层
Cache-Aside(旁路缓存)最常用:读先查缓存→未命中查 DB→写入缓存。写时删除缓存而非更新(删除是幂等的)。
三大缓存问题:
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 穿透 | 查不存在的数据,每次打 DB | 布隆过滤器 / 缓存空值(短 TTL) |
| 击穿 | 热点 key 过期,并发打 DB | singleflight / 永不过期 + 异步刷新 |
| 雪崩 | 大量 key 同时过期 | TTL 加随机扰动 / 多级缓存 |
关键设计模式
CQRS
读写用不同模型甚至不同数据库。写入范式化,读取反范式化。代价:最终一致性 + 系统复杂度显著增加。绝大多数 CRUD 应用不需要 CQRS。
背压(Backpressure)
上游生产 > 下游消费时,缓冲区无限增长直到 OOM。背压 = 下游告诉上游”慢点”。Go 中 buffered channel 满了 sender 阻塞就是最简单的背压。
Rate Limiting
| 算法 | 特点 |
|---|---|
| Token Bucket | 允许突发,最常用 |
| Leaky Bucket | 输出平滑,不允许突发 |
| Sliding Window Counter | 精确且省内存 |
分布式 Rate Limiting 通常用 Redis INCR + EXPIRE。注意 Redis 本身也可能成为瓶颈——可混合使用本地 + 全局 limiter。
演进路径
阶段 0 (DAU <1万): 单机单体 → 不要过早优化
阶段 1 (1万~10万): 垂直扩展 + Redis 缓存 + 慢查询优化
阶段 2 (10万~100万): 读写分离 + 应用层水平扩展 + Session 外置
阶段 3 (100万~1000万): 服务拆分 + 消息队列 + 分布式追踪
阶段 4 (>1000万): 数据库分片 + 多 Region + 边缘计算
核心原则:每一步都由真实瓶颈驱动,没有性能数据支撑的架构升级就是赌博。
Pitfalls
- 有状态服务直接水平扩展 — Session 在内存里,LB 分到不同实例登录态丢失。对策:Session 外置到 Redis 或 JWT
- 分片键选错导致热点 — 少量超级用户集中在某些分片。对策:分析数据分布,考虑复合分片键
- 缓存雪崩 — 大量 key 同时过期。对策:TTL 加随机抖动
- 同步调用链过长 — A→B→C→D→E,延迟是总和,任一故障全链失败。对策:识别可异步化的调用,不可避免的设超时和熔断
- 数据库连接数耗尽 — 50 台应用 × 20 连接 = 1000,PG 默认 max_connections=100。对策:PgBouncer transaction-level pooling
- 忽略写扩展 — 读写分离只解决读,写入量增长后主库仍是瓶颈。对策:写扩展需要分片,这是最难的部分
- 过早引入分布式 — DAU 1000 就上微服务 + Kafka + 分片,3 人团队 80% 时间维护基础设施。对策:先最简架构跑起来,有瓶颈再升级
生产 Checklist
- 已建立性能基线,知道系统极限(saturation point)
- 容量预留 30%+ buffer
- 应用无状态化已验证(随机杀实例不影响业务)
- 数据库连接池总数 ≤ max_connections,有 PgBouncer
- 缓存 TTL 有随机抖动,热点 key 有防击穿策略
- HPA 有 maxReplicas 上限
- 消费者实现幂等,死信队列已配置,队列积压有告警
- 核心依赖有超时 + 熔断 + 限流