Day 1 Easy Scalability Load Balancing Foundations

Scalability 基础 — 从单机到千万 QPSScalability Fundamentals: Vertical vs Horizontal, Load Balancing, Statelessness

问题场景

你的产品上线第一天 1k DAU,单台 EC2 跑 Postgres + Node 服务一切都好。半年后涨到 100w DAU、3k QPS,p99 延迟从 80ms 飙到 2.4s,DB 偶尔挂掉。这是几乎每个产品的必经之路——Instagram 2010 年只有 3 个工程师扛 1400w 用户,Notion 2019 年从单 Postgres 走到分片,都是同一个故事。

本期不教"加机器"这种废话,而是搞清楚:什么样的负载该垂直扩,什么时候必须横向扩,横向扩的隐藏成本是什么,以及为什么"无状态服务"是横扩的入场券。

需求与约束(面试必问)

高层架构(从 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:
现实案例: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 在后分层。

负载算法选型:
# 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。

扩展与优化(增长后的瓶颈)

  1. 第一瓶颈通常是 DB 写:读可以加 replica 缓解,写要么 vertical scale,要么 sharding。看到 p99 写延迟 > 100ms 时该考虑了。
  2. 第二瓶颈是缓存击穿:热 key 把 Redis 单分片打满,需要本地 cache + 多副本。
  3. 第三瓶颈是网络:跨 AZ 流量按 GB 收费(AWS $0.01/GB),微服务粒度太细会被传输费 + RTT 反复教育。
  4. 多区域:当用户分布在 > 2 大洲,引入 GeoDNS + 区域内闭环 + 异步全局复制。

常见坑

面试问题示例

  1. "现在系统 5k QPS p99 200ms,要扩到 50k QPS p99 100ms,你的 5 步计划是什么?"(期望先问读写比、瓶颈在哪,再谈方案)
  2. "为什么不直接用一台 192 核机器?"(考察对 vertical scale 上限、blast radius、SPOF 的理解)
  3. "L4 和 L7 LB 各放哪、为什么不直接全用 L7?"
  4. "如果让 session 改成 stateless,JWT 和 Redis session 你怎么选?"(考察 revocation、size、安全性权衡)
  5. "Round robin 在长连接场景下会怎么坏?怎么修?"(引出 least conn / P2C)

关键资源

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 倍)、监控告警门槛临时调严。