问题场景
你的产品上线第一天 1k DAU,单台 EC2 跑 Postgres + Node 服务一切都好。半年后涨到 100w DAU、3k QPS,p99 延迟从 80ms 飙到 2.4s,DB 偶尔挂掉。这是几乎每个产品的必经之路——Instagram 2010 年只有 3 个工程师扛 1400w 用户,Notion 2019 年从单 Postgres 走到分片,都是同一个故事。
本期不教"加机器"这种废话,而是搞清楚:什么样的负载该垂直扩,什么时候必须横向扩,横向扩的隐藏成本是什么,以及为什么"无状态服务"是横扩的入场券。
需求与约束(面试必问)
- 读写比:是 100:1(社交 feed)还是 1:1(IM)?决定了你优先扩读副本还是分片写。
- QPS 量级:100 / 10k / 1M?10k QPS 以下基本不需要分布式;1M QPS 强制走 sharding + CDN。
- 数据量与增长曲线:1TB 还是 1PB?单库上限大概 10TB(Postgres 实操舒适区)。
- 延迟 SLO:p99 是 100ms(搜索)还是 10ms(广告竞价)?后者就别想跨区调用了。
- 一致性要求:转账级(强一致)还是点赞级(最终一致)?
- 预算:m5.large 月费几十刀,c7gn.16xlarge 几千刀。一个被忽视的约束。
高层架构(从 1 台到 N 层)
graph TD
C[Client]
DNS["DNS / GeoDNS"]
L4["L4 LB · LVS
~1M QPS 入口"]
L7["L7 LB · Nginx/Envoy
路由 · TLS · 限流"]
A1["App-1
stateless"]
A2["App-2
stateless"]
AN["App-N
stateless"]
R[("Redis
Cluster")]
PG[("PG Primary")]
R1[("Replica-1
读副本")]
R2[("Replica-2
读副本")]
C --> DNS --> L4 --> L7
L7 --> A1 & A2 & AN
A1 & A2 & AN --> R
A1 & A2 & AN --> PG
PG -.复制.-> R1
PG -.复制.-> R2
classDef edge fill:#1a2530,stroke:#64c8ff,color:#e8eef5
classDef app fill:#0e2030,stroke:#5eead4,color:#e8eef5
classDef data fill:#1a1a30,stroke:#ffb450,color:#e8eef5
class C,DNS,L4,L7 edge
class A1,A2,AN app
class R,PG,R1,R2 data
关键技术点
1. Vertical vs Horizontal Scaling
原理:垂直扩=换更大的机器(更多 CPU/RAM/NVMe),横向扩=加更多机器。垂直扩在 x86 内存带宽 / NUMA 触顶前线性收益,单机现在能干到 192 vCPU / 2TB RAM(c7g.metal、x2idn.32xlarge),所以别太早走分布式。
Trade-off:
- 垂直扩:✅ 不改代码、ACID 不破坏、运维简单;❌ 单点故障、价格非线性(96 → 192 vCPU 价格 2.3 倍)、有物理上限。
- 横向扩:✅ 线性价格、容错;❌ 强迫无状态、引入网络分区、调试 N 倍困难、需要分布式锁/事务。
现实案例:Stack Overflow 2016 年公布架构——9 台 web server + 4 台 SQL Server(96 核 1.5TB RAM 一台),扛 6000 req/s。他们的哲学:"Hardware is cheap, programmers are expensive",垂直扩到 2024 年才开始认真分库。反例:Pinterest 2012 年坚持 MySQL 垂直扩到 Master 撑不住,被迫紧急做 sharding,迁移花了 6 个月。
2. Load Balancer:L4 vs L7
原理:L4(TCP/UDP)只看四元组,转发不解包,单机能 200w+ QPS(LVS/IPVS、AWS NLB)。L7(HTTP)能基于 path / header / cookie 路由,能做 TLS 卸载、压缩、限流,代价是 CPU 重得多(Envoy 单核 ~5w QPS)。生产里常 L4 在前 + L7 在后分层。
负载算法选型:
- Round Robin:简单,但忽略后端实际负载,长连接场景容易倾斜。
- Least Connections:长连接(WebSocket、gRPC)首选。
- Consistent Hashing:需要 session affinity 或缓存亲和(CDN、Redis proxy 必用)。
- EWMA / P2C:"Power of Two Choices",随机选两个挑负载低的,Envoy 默认,极简且接近最优。
# P2C 伪代码(Envoy 默认)
def pick_backend(backends):
a, b = random.sample(backends, 2)
return a if a.active_requests <= b.active_requests else b
# 数学上:N 个后端,最大负载从 O(log N) 降到 O(log log N)
现实案例:Cloudflare 用 LVS + Unimog(自研 L4 LB)扛全球流量,单机房百 Gbps。AWS ALB 是托管的 L7,背后是 Envoy 改的;NLB 是 L4,走 hyperplane,延迟 100μs 内。
3. Stateless Services(横扩的入场券)
原理:服务实例不持有任何"下次请求需要的状态"——所有 session / cache / 计数器外置到 Redis/DB。这样 LB 可以把任何请求打到任何实例,crash 一台也无人受伤。
Trade-off:无状态 ≠ 无缓存。本地缓存(Caffeine / in-process LRU)依然能用,但要接受"实例间缓存不一致"。完全外置到 Redis 则增加 1 跳 ~0.5ms 延迟和单点风险。折中:本地 cache + TTL 30s + 通过 pub/sub 失效。
# 反模式:把 session 存在内存里
sessions = {} # ❌ 重启丢、扩容用户掉线、A 实例的 session B 看不见
# 正模式:JWT(无状态) or Redis(共享状态)
def auth(req):
token = req.headers["Authorization"]
return jwt.verify(token, PUBLIC_KEY) # 无任何服务端状态
现实案例:Netflix 所有 microservice 强制无状态,发布走 Spinnaker 红黑部署——直接起一组新实例、流量切过去、老的全删。Discord 反例:语音服务必须有状态(一个 voice channel 锚在一台 server),他们用 consistent hashing + 状态迁移协议解决,2020 年公开演讲承认这是最难的部分。
4. 容量规划与"够用就好"
原理:别上来就 K8s + 微服务。Shopify 黑五扛 7600w QPS 用的是单体 Rails + 大量 Pod。能上单体绝不上微服务,能加机器绝不分库。Back-of-envelope 算法是面试核心:
# 例:设计 Twitter timeline
# 3 亿 DAU × 平均 100 次刷新/天 = 3e10 reads/day
# = 3e10 / 86400 ≈ 350k QPS 平均、峰值 ~3x = 1M QPS
# 一台 4 核机能扛 ~5k QPS 静态 read
# → 至少需要 200 台 read 实例(含 2x headroom)
面试经验:面试官几乎一定会让你估算 QPS、存储、带宽。背三个数:1 天 ≈ 1e5 秒、1 QPS × 1 年 ≈ 30M 条、1Gbps ≈ 125MB/s。
扩展与优化(增长后的瓶颈)
- 第一瓶颈通常是 DB 写:读可以加 replica 缓解,写要么 vertical scale,要么 sharding。看到 p99 写延迟 > 100ms 时该考虑了。
- 第二瓶颈是缓存击穿:热 key 把 Redis 单分片打满,需要本地 cache + 多副本。
- 第三瓶颈是网络:跨 AZ 流量按 GB 收费(AWS $0.01/GB),微服务粒度太细会被传输费 + RTT 反复教育。
- 多区域:当用户分布在 > 2 大洲,引入 GeoDNS + 区域内闭环 + 异步全局复制。
常见坑
- 过早分布式:1k QPS 上 K8s + 8 个微服务,结果调试时间是单体的 10 倍。
- Sticky session 的陷阱:用 cookie hash 绑定实例,扩容/重启时大批用户掉线,且热实例无法解热。
- 无 health check 的 LB:实例假死(连接能建、不响应)依然分发流量,所有 RTT 飙升。一定配置 active health check + outlier ejection(Envoy 的 P2C 也会自动 eject)。
- 不做容量演练:黑五前不压测,等真流量来了才发现连接池上限。Netflix 的 Chaos Monkey 文化就是为此。
- 忽视 thundering herd:缓存失效瞬间,1000 个实例同时打 DB。解决:单飞(singleflight)、互斥锁、概率性提前刷新。
面试问题示例
- "现在系统 5k QPS p99 200ms,要扩到 50k QPS p99 100ms,你的 5 步计划是什么?"(期望先问读写比、瓶颈在哪,再谈方案)
- "为什么不直接用一台 192 核机器?"(考察对 vertical scale 上限、blast radius、SPOF 的理解)
- "L4 和 L7 LB 各放哪、为什么不直接全用 L7?"
- "如果让 session 改成 stateless,JWT 和 Redis session 你怎么选?"(考察 revocation、size、安全性权衡)
- "Round robin 在长连接场景下会怎么坏?怎么修?"(引出 least conn / P2C)
关键资源
- 📕 Designing Data-Intensive Applications(Kleppmann)Ch.1:Reliable, Scalable, and Maintainable Applications。
- 📝 Stack Overflow Architecture(2016, Nick Craver):垂直扩到极致的范本,
nickcraver.com/blog/2016/02/17/stack-overflow-the-architecture-2016-edition/
- 📝 High Scalability blog:"Power of Two Random Choices" 经典文章。
English Summary
Scaling means surviving load growth without rewriting from scratch. Vertical scaling (bigger box) is underrated — Stack Overflow runs SO at billions of pageviews on a handful of fat SQL servers. Horizontal scaling demands stateless services, smart load balancing (L4 for throughput, L7 for routing; P2C beats round-robin under skewed load), and capacity planning via back-of-envelope math. The cardinal sin is premature distribution: complexity grows superlinearly, debuggability collapses. Scale when you must, not when you can.
深入思考(点击展开答案)
1. 服务从 1k QPS 扩到 100k QPS,每加一台机器 p99 反而变高。瓶颈可能在哪?至少列出 3 个不同层的可能原因。
- 共享瓶颈:DB 连接池、Redis 单点、文件锁——加 app 服务器只是让排队队伍更长。典型表现:DB CPU 100% / connection pool 耗尽。
- LB 本身:L7 LB(Envoy/Nginx)单实例 ~50k QPS 顶;TLS 握手或 keepalive 配置错会让 LB 成为瓶颈。要看 LB 的 CPU、文件描述符。
- 分布式协调成本:分布式锁(Redlock)、跨节点 cache invalidation、service discovery 的 gossip 流量。机器越多,协调成本是 O(N²)。
- 下游放大效应:每个 app 实例对 DB 都开 20 connection,100 台 = 2000 连接,Postgres 默认 max_connections=100 直接拒绝。
- 跨 AZ 网络:横扩后请求路径从同 AZ 变成跨 AZ,每跳 +0.5ms。10 跳就是 5ms 增量。
诊断顺序:先看哪些指标随机器数线性增长(CPU 应该降,但延迟上升 = 共享资源饱和)。
2. 为什么 stateless 是横扩的入场券?业务必须保持会话(游戏房间、长事务),有哪些办法既保留状态又能扩缩容?
为什么:stateless 意味着任一请求可以打到任一实例,LB 不用记忆、实例挂掉不丢数据、扩容秒级生效。有状态意味着 LB 必须 sticky session,扩缩容时状态要迁移,节点故障 = 用户掉线。
有状态服务的解法:
- 状态外置:把会话状态搬到 Redis / DynamoDB,服务本身依然 stateless。最常见。
- Sticky routing + 状态分片:consistent hashing 路由(user_id → node),node 挂掉时局部影响。游戏房间通常这样——一个房间永远落在同一台。代价:扩缩容要 rehash 一部分用户。
- Actor model:Erlang/Akka/Orleans,每个 actor 自己管状态,框架管迁移。Discord 用 Elixir,每个频道一个 process。
- 分布式状态机 + Raft:少量需要强一致的状态用 Raft 集群(etcd / TiKV 模式),多副本 + 选主,节点挂掉自动 failover。
实战经验:90% 业务用『外置 Redis』就够;游戏/IM 用 sticky;金融账本才上 Raft。
3. L4 LB 和 L7 LB 一定要二选一吗?大厂典型『L4 在外、L7 在内』为什么不反过来?
不是二选一,大厂典型『L4 → L7 → App』两层组合。
为什么 L4 在外:
- L4(LVS / AWS NLB / Google Maglev)只看 TCP 五元组,单机能扛 数百万 QPS / 百 Gbps 流量,是抗 DDoS 第一道墙。
- L4 不解 TLS、不看 HTTP,CPU 几乎为零,适合做 anycast 入口。
- L4 用 DSR(Direct Server Return)可以让响应直接从 backend 回客户端,不经过 LB——上行流量减半。
为什么 L7 在内:
- L7(Envoy / Nginx)要解 TLS、parse HTTP header、做路由/限流,单机 ~50k QPS,扛不住外网洪水。
- L7 提供丰富功能(path routing、retry、circuit breaker),但前提是流量已经过滤过。
反过来会怎样:L7 直接对外,第一次 SYN flood 就把它的连接表打爆,TLS 握手 CPU 飙到 100%。
4. 一台 c7g.metal(192 vCPU)vs 16 台 c7g.4xlarge(16 vCPU)——总价相同。各自适合什么 workload?故障爆炸半径怎么对比?
1 台大机器适合:
- 内存密集且不易分片的:内存数据库(Redis / Memcached)、大 buffer pool 的 OLTP DB(Postgres / MySQL)。
- 需要 NUMA-local 访问的低延迟服务:高频交易、广告竞价。
- 共享缓存命中率高的:单 JVM 大堆比 16 个小堆,热点 cache 共享更好。
16 台小机器适合:
- 无状态 web/API 服务、CPU bound 但易并发的:渲染、转码、ML inference。
- 需要高可用:滚动发布时只摘一台,影响 6.25% 流量;摘一台大机器影响 100%。
爆炸半径:1 台大机器挂 = 100% 服务中断(除非有热备,那就翻倍成本);16 台中挂 1 台 = 6.25% 容量损失,剩余 15 台能否扛?要预留 ~10% headroom。
结论:CPU bound 且无状态 → 多台小的;内存/IO 密集且需 locality → 大机器(但至少 2 台做 HA)。
5. 黑五大促预期流量平日 5 倍。容量规划怎么做?headroom 留多少?为什么不能直接『加 5 倍机器』完事?
headroom 一般留 30-50%:因为流量不是均匀分布,p95 峰值可能是均值的 2-3 倍;秒杀场景峰值 / 均值可达 10 倍。所以『5 倍流量』实操要按 7-8 倍 peak 准备。
为什么不能直接加机器:
- DB 不是线性扩:app 加 5 倍,DB QPS 也 5 倍,但 DB 通常只能纵向扩或加 read replica(replication lag 风险)。
- 缓存命中率会变:新用户/新商品意味着 cache miss 增加,DB 压力比预期更大。
- 下游依赖:支付、风控、第三方 API 都有自己的 rate limit,你的流量打过去会被拒。
- 冷启动:新加的实例 JIT 没 warm、connection pool 没满、cache 是空的——刚上线那几分钟反而更慢。要慢启动放量。
- 成本失控:平日 5 倍容量持续跑等于平日成本 5 倍。要能 auto-scaling 在大促结束后秒级缩回去。
实操:提前 1 周做 5 倍流量压测(线下 + 影子流量)、降级开关就位(关掉非核心功能腾资源)、上 CDN/缓存预热、扩容预案脚本化(一键扩 N 倍)、监控告警门槛临时调严。