“流量涨了,就把应用从一台扩到四台”没有回答瓶颈在哪。如果应用实例尚有余量,但每个请求都在等同一处数据库写入,加应用机器只会让更多请求同时排队。扩容先要有一份能被负载实验推翻的容量假设。

沿用访问模式与游标分页里的分享元数据服务:创建、按 ID 读取、按所有者列表读取。但以下流量是新的教学输入,不是 03 的实测流量,更不是某家服务的生产配置。

单机版本先说清楚承诺

服务包含 HTTP 应用和一个权威数据库;POST /shares 写分享与幂等键映射,GET /shares/{id} 查主键,GET /users/{owner}/shares?after=... 按 (owner_id,created_at,id) 读有序索引。应用实例可重启,不在实例内保存权限、游标或写入归属;授权检查仍发生在每次读之前。已确认的撤销请求不能再返回公开内容;这是数据不变量,不可通过增加只读节点放松。本文排除对象上传、跨地域容灾和多主写入,暂不把读副本当成默认选项。

目标先限定为合法读取服务端 p99 小于 200 ms、十秒峰值窗口的请求拒绝率小于 0.1%;写入确认还需要单独记录成功率与延迟。没有延迟样本时不能声称达成 p99;拒绝也不应靠无限排队掩盖。接口形状和键选择依照 03,本文只问“哪个资源在什么负载下先耗尽”。

算量输入:每天 200 万次读、10 万次写,读写比 20:1;读峰 150 次/秒、写峰 10 次/秒,合计 160 次/秒(分别是日均的 6.48 和 8.64 倍)。读列表按 20 项、每项 1 KiB 算,峰值有效载荷 150 × 20 KiB = 2.93 MiB/s ≈ 24.58 Mbit/s;写元数据每次 1 KiB,峰值约 0.0098 MiB/s。这里没有计 TLS、网关、重试及缓存。

元数据与索引按每次写共 1000 B、保存 365 天且三份,容量 100000 × 1000 × 365 × 3 = 109.5 GB。读写每次各产生一条 200 B 的诊断日志,7 天、一份:(2000000+100000) × 200 × 7 = 2.94 GB,合计 112.44 GB。练习单价为每 GB·月 0.02 货币单位,只有这两项的稳态存储预算约 2.25 货币单位/月;计算、网络、备份、真实索引放大和复制带宽都没入账。若读峰单项翻倍至 300 次/秒,总峰值变成 310 次/秒,必须重新测读路径和连接占用,而不能沿用单机结论。

扩容是诊断结果,不是结构图的起点

先在目标请求比例与峰值持续时间下做阶梯负载,分别记录已完成吞吐、读取与写入 p50/p99、错误率、入站连接、应用 CPU/内存、数据库活跃事务及等待、队列深度、连接池等待和磁盘吞吐。看到应用 CPU 饱和且数据库仍有可测余量,才尝试多一个无状态应用实例;看到数据库等待或连接数先到上限,先检查访问模式、索引与慢查询,再评估连接池、读副本或分片。数据库指标不能被应用 CPU 替代;相关实际组件均尚未在本篇测量。

为让“加节点不一定有用”有一个可运行的反例,examples/system-design/labs/04/bottleneck.py 只做排队算术模型:每秒来 160 次、单应用预算 120 次/秒、等待队列上限 160。数据库预算 240 次/秒时,十秒内单实例完成 1200、拒绝 240,加到两实例后完成 1600、拒绝 0;若数据库预算变为 90 次/秒,应用从一台到两台均只完成 900、拒绝 540。正常命令退出 0,--expect-scaling 故意坚持“加实例必然有效”,遇到反例退出 2;原始输出与条件见仓库 examples/system-design/evidence/04/bottleneck.md。这并非对实际数据库吞吐或 p99 的测量。

flowchart LR
    C[客户端] -->|读写请求 / 记录峰值| G[入口与限流]
    G -->|路由到健康实例| A[应用实例 1]
    G -->|测出应用瓶颈后再扩| B[候选应用实例 2]
    A -->|查询 / 写入| D[(单一权威数据库)]
    B -->|查询 / 写入,仍共享瓶颈| D
    D -->|提交或等待及错误| A
    D -->|提交或等待及错误| B
    A -->|响应与阶段耗时| C
    B -->|响应与阶段耗时| C

这个最小结构保留一个写入事实源;实例数只是可调变量。流量分配、健康检查和连接状态交给下一章详谈,不把“在图上多画一台应用”冒充已经解决读写路径。

十秒突发里的失败和恢复

假设真实测量将来证实数据库安全吞吐只有 90 次/秒,160 次/秒持续到来。模型里的队列很快满,十秒有 540 次被拒;如果没有上限,排队和超时会向调用方转移,不能靠隐藏拒绝达成延迟目标。入口可对不能立刻承接的请求返回 503 Service Unavailable,需要表达预计恢复时间时可携带 Retry-After;规范允许但不保证客户端一定按其等待(RFC 9110:503、Retry-After)。写请求重试仍须用幂等键,读请求需设置有限的超时和重试预算。拒绝发生时应同时看已接收请求的尾延迟,不能只看吞吐。

sequenceDiagram
    participant C as 客户端
    participant G as 入口
    participant A as 应用实例
    participant D as 权威数据库
    C->>G: 峰值 160 次/秒
    G->>A: 已授权读写
    A->>D: 查询 / 写入
    Note over A,D: 若数据库仅 90 次/秒,等待增长
    D-->>A: 完成或等待
    A-->>G: 完成数与等待指标
    G-->>C: 队列满时 503,可给 Retry-After
    Note over G,D: 单纯增加应用实例并不改变数据库处理预算
    G->>A: 恢复期维持有界流量,再验证队列回落

两条候选路径要依据瓶颈互斥判断:应用真的饱和时,加无状态应用实例并重新测入口和数据库;数据库先饱和时,先验证查询计划、批处理与数据热点,必要时才评估改索引或分片,并另行证明一致性和迁移路径。扩容回退也要可测:停止把流量分给新实例后,核对未完成请求、幂等结果和队列是否清零;数据库改动则需独立的回滚/双读验证,不能靠缩应用节点数撤销数据变更。

面试中可用五分钟给目标和排除项、十分钟算读写与容量、十分钟画单机读写路径、十五分钟追问“应用与数据库分别饱和时,加应用会怎样”、五分钟说明限流与回退。这里可迁移的不是“先单机”这句口号,而是从访问路径列出资源预算,用实测指标区分瓶颈,再只扩大真正受限的环节。本篇模型没有测 HTTP、数据库、连接池、故障切换或浏览器图表渲染;这些缺口不能用固定输入断言补齐。

参考资料