微服务容量规划:SLO、压测、瓶颈识别、扩缩容与容量闭环
容量规划解决的不是“机器够不够”,而是“在明确业务目标和故障余量下,系统每一层能稳定承载多少请求,瓶颈在哪里,怎样扩容才真的有效”。很多线上事故不是 QPS 突然特别高,而是某个慢下游、连接池、线程池、数据库锁、MQ 分区或 Redis 热 Key 先到瓶颈,然后把整个链路拖慢。
本页只讲微服务和分布式生产常用场景,不写泛泛博客实战。相关知识可继续阅读:服务发现、负载均衡、稳定性治理、依赖治理、可观测性、消息堆积。
学习目标
学完本页要能回答:
- QPS、并发、RT、P95、P99、利用率、饱和度、错误率分别代表什么。
- 为什么低 QPS 也可能把线程池打满。
- 怎样从 SLO 推导接口超时、线程池、连接池、实例数和下游容量。
- 压测结果怎样转成生产容量,而不是看一个峰值数字就上线。
- 为什么扩容应用实例后 RT 不降,甚至堆积还会回来。
- HPA 自动扩缩容为什么会抖动,冷启动、预热和优雅下线怎样设计。
- 遇到线上容量问题时按什么顺序定位瓶颈。
一、容量规划为什么不是简单扩机器
微服务调用链不是一台机器的问题,而是一条共享资源链。
flowchart TD
A["用户请求"] --> B["Gateway连接和过滤器"]
B --> C["订单服务Tomcat线程"]
C --> D["Feign连接池"]
D --> E["库存服务线程池"]
E --> F["Redis连接和热点Key"]
E --> G["MySQL连接和锁"]
E --> H["MQ发送和分区"]任何一层先饱和,都会变成全链路瓶颈:
| 瓶颈层 | 表现 | 为什么加应用实例不一定有效 |
|---|---|---|
| Gateway | 入口 503、504、连接等待 | 后端扩了,但入口连接、过滤器或限流仍是瓶颈 |
| 应用线程池 | QPS 不高但线程占满 | 下游慢导致线程等待,扩容会把更多压力打给下游 |
| HTTP 连接池 | Feign 慢、pending connection 高 | 连接上限太小或连接复用旧实例 |
| Redis | 热 Key RT 升高、CPU 单核高 | 所有实例仍访问同一个热点 Key |
| MySQL | 慢 SQL、锁等待、连接池满 | DB 是共享瓶颈,应用扩容会增加并发和锁竞争 |
| MQ | Lag 增长、消费延迟 | 分区数、单分区热、消费事务慢限制吞吐 |
| 第三方 | 超时、限流、配额失败 | 对方配额固定,本方扩容只会更快撞限流 |
容量规划的核心原则:
- 先定义业务目标,再谈机器数量。
- 先找到最小瓶颈,再谈扩容方向。
- 先保护事实源和强依赖,再优化弱依赖体验。
- 扩容必须配合限流、熔断、降级、连接池、缓存和观测。
- 任何容量数字都要说明测试条件、数据分布、依赖状态和安全系数。
二、核心指标
2.1 QPS、TPS、RPS
| 指标 | 含义 | 常见误区 |
|---|---|---|
| QPS | 每秒查询或请求数 | 只适合描述读接口或宽泛请求量 |
| TPS | 每秒事务数 | 更适合下单、支付、入库这类完整业务动作 |
| RPS | 每秒请求数 | 网关、HTTP 服务常用 |
不要只看平均 QPS。订单创建 500 QPS 和商品详情 500 QPS 完全不是一个重量级:前者可能涉及库存、优惠、风控、支付预创建、MQ 和数据库事务;后者可能主要读缓存。
2.2 RT、P95、P99
RT 是响应时间。平均 RT 只能说明“平均用户大概多慢”,不能代表尾部用户体验。
| 指标 | 含义 | 用途 |
|---|---|---|
| Avg RT | 平均响应时间 | 看整体趋势 |
| P50 | 50% 请求小于该耗时 | 看典型用户 |
| P95 | 95% 请求小于该耗时 | 看大多数用户体验 |
| P99 | 99% 请求小于该耗时 | 看尾部延迟和容量拐点 |
| Max | 最大耗时 | 排查个例,容易受异常值影响 |
为什么生产更关注 P95/P99?因为微服务链路会放大尾延迟。一次订单请求可能调用 5 个下游,只要其中一个下游出现 P99 慢调用,入口请求就可能变慢。
2.3 并发和 in-flight
并发不是线程数本身,而是同一时刻正在系统里“停留”的请求数量。请求越慢,同样 QPS 下并发越高。
核心公式是 Little 定律:
系统内平均并发 = 到达率 × 平均停留时间例如:
1000 QPS × 50ms = 50 个并发请求
1000 QPS × 500ms = 500 个并发请求QPS 没变,只是 RT 从 50ms 变成 500ms,并发就放大 10 倍。线程池、连接池、队列都会被占住,所以“QPS 不高为什么线程池满”的答案通常是:请求停留时间太长,in-flight 堆起来了。
2.4 利用率、饱和度、错误率
| 指标 | 看什么 | 危险信号 |
|---|---|---|
| 利用率 | CPU、内存、磁盘、网络、连接池使用比例 | 长期超过安全水位 |
| 饱和度 | 是否出现排队、等待、拒绝 | 队列长度、等待连接、线程池队列堆积 |
| 错误率 | 失败请求占比 | 5xx、超时、熔断、限流、业务失败异常升高 |
| 延迟 | Avg、P95、P99 | P99 先升高通常是容量拐点信号 |
容量不够时,常见顺序不是“先报错”,而是:
flowchart TD
A["并发上升"] --> B["队列和等待增加"]
B --> C["P95和P99升高"]
C --> D["超时和重试增加"]
D --> E["线程池连接池被占满"]
E --> F["错误率升高或雪崩"]三、从 SLO 推导容量
SLO 是服务目标,比如“订单创建 P99 小于 800ms,成功率 99.9%”。容量规划必须从 SLO 出发,而不是从机器数出发。
flowchart TD
A["业务峰值和SLO"] --> B["拆入口预算"]
B --> C["拆下游依赖预算"]
C --> D["压测单实例能力"]
D --> E["乘以安全系数和冗余"]
E --> F["得到实例数和资源池大小"]
F --> G["上线观测和容量复盘"]示例:订单创建链路目标 P99 800ms:
| 层级 | 预算 |
|---|---|
| Gateway 认证和路由 | 50ms |
| 订单服务本地逻辑 | 100ms |
| 库存冻结 | 200ms |
| 优惠计算 | 100ms |
| MySQL 事务提交 | 150ms |
| MQ 发送 | 80ms |
| 网络和序列化余量 | 120ms |
如果库存冻结 P99 已经 500ms,订单接口不可能稳定满足 800ms。此时扩容订单服务没意义,要治理库存服务、数据库锁、缓存热点或库存模型。
四、压测类型和边界
| 压测类型 | 目的 | 适合回答什么 |
|---|---|---|
| 单接口压测 | 看某个接口自身容量 | 单接口 QPS、RT、线程池、SQL瓶颈 |
| 链路压测 | 看完整调用链 | 网关、服务、DB、Redis、MQ共同瓶颈 |
| 容量压测 | 找吞吐拐点 | 单实例安全容量和最大容量 |
| 稳定性压测 | 长时间运行 | 内存泄漏、连接泄漏、GC、日志膨胀 |
| 峰值压测 | 模拟秒杀、批量采集 | 瞬时峰值、限流、削峰、降级 |
| 混合流量压测 | 模拟真实业务比例 | 热点接口互相影响 |
| 影子压测 | 线上真实链路验证 | 生产容量,但必须隔离数据和副作用 |
压测不能只看“最高 QPS”。要记录:
- 数据量是否接近生产。
- 热点分布是否真实。
- 缓存命中率是否真实。
- 下游依赖是 Mock 还是真实。
- 数据库是否有锁冲突。
- MQ 分区数和消费者数是否一致。
- 日志级别、采样率、监控是否接近生产。
- 是否包含登录鉴权、网关过滤、序列化和网络成本。
五、容量曲线怎样读
典型压测曲线不是线性上升。
flowchart TD
A["低并发"] --> B["吞吐随并发上升"]
B --> C["达到平台区"]
C --> D["P95和P99明显上升"]
D --> E["队列等待和超时增加"]
E --> F["吞吐下降且错误率升高"]真正的安全容量通常在拐点之前,而不是最大吞吐点。
| 区间 | 表现 | 策略 |
|---|---|---|
| 健康区 | QPS 上升,RT 稳定,错误率低 | 可作为日常运行区 |
| 临界区 | P99 开始升高,队列等待增加 | 设置告警和限流阈值 |
| 饱和区 | 吞吐不再增长,超时增加 | 不能继续加压,要找瓶颈 |
| 崩溃区 | 错误率升高,重试放大 | 熔断、降级、扩容或止血 |
生产容量建议用:
生产安全吞吐 = 单实例拐点前吞吐 × 实例数 × 安全系数 × 冗余系数常见取值:
| 系数 | 示例 | 说明 |
|---|---|---|
| 安全系数 | 0.6 到 0.8 | 给 GC、抖动、数据倾斜、发布留余量 |
| N-1 冗余 | 少算 1 台实例 | 任一实例宕机或发布下线仍能承载 |
| 下游折扣 | 按最小下游容量限制 | DB、Redis、MQ、第三方配额可能更小 |
例如单实例在 P99 稳定前能承载 300 QPS,有 6 个实例,安全系数 0.7,按 N-1 计算:
安全容量 = 300 × (6 - 1) × 0.7 = 1050 QPS这不是绝对真理,而是容量估算起点。上线后还要用真实流量持续校准。
六、容量预算怎样拆
6.1 线程池预算
线程池容量不能只看 CPU 核数。IO 型接口大部分时间在等下游,线程数可能比 CPU 核数多;CPU 型任务线程过多会增加上下文切换。
简单估算:
需要并发线程数 ≈ 峰值QPS × 平均RT秒数如果订单接口 500 QPS,平均 RT 200ms:
500 × 0.2 = 100 个并发请求这说明入口线程、业务线程、下游连接池都要能承载约 100 个 in-flight,还要留发布、GC、慢请求和突发余量。
6.2 HTTP 连接池预算
Feign 或 WebClient 最终要通过 HTTP Client 发请求。连接池过小会让请求卡在“等连接”,不是下游真的慢。
| 参数 | 关注点 |
|---|---|
| 最大总连接数 | 所有下游共享连接上限 |
| 每路由最大连接数 | 单个下游服务可用连接上限 |
| 连接获取超时 | 等连接多久算失败 |
| 连接超时 | TCP 建连多久失败 |
| 读取超时 | 请求发出后多久没响应失败 |
| 连接最大存活时间 | 防止旧连接长期指向旧实例 |
6.3 数据库连接池预算
数据库连接不是越多越好。连接太少会上游排队,连接太多会把 DB 线程、锁和 CPU 打爆。
正确思路:
- 先压测数据库单 SQL、单事务能力。
- 看慢 SQL、锁等待、buffer pool、redo flush、CPU 和 IO。
- 应用连接池上限不能超过数据库能稳定处理的并发。
- 多个服务共享同一个库时,要给每个服务分配连接预算。
- 批处理和在线请求最好隔离连接池或错峰。
6.4 MQ 消费容量预算
MQ 消费能力由 Broker、Topic 分区或队列、消费者实例、单条处理耗时、下游写入能力共同决定。
消费能力 ≈ 分区数或队列数 × 每分区单消费者处理能力Kafka 场景下,同一个 Consumer Group 内,一个分区同一时刻只能被一个消费者消费。消费者实例数超过分区数后,额外实例不会提升该 Topic 的并行度。
RocketMQ 和 RabbitMQ 也要看队列数、消费者并发、ACK、重试、死信和下游数据库能力。消费者扩容后短暂有效,后面又堆积,常见原因见消息堆积扩容后再次堆积。
七、为什么扩容不一定有效
扩容只有在瓶颈位于可横向扩展层时才有效。应用 CPU 满且无共享下游瓶颈,扩容通常有效;如果瓶颈在共享数据库、热点 Key、单分区、锁、第三方配额,扩容应用只会把更多请求推到瓶颈上。
| 现象 | 常见原因 | 处理方向 |
|---|---|---|
| 加实例后 QPS 不涨 | DB、Redis、MQ、第三方是瓶颈 | 治理共享下游,不能只扩应用 |
| 加实例后 RT 仍高 | 慢 SQL、锁等待、连接池等待 | 看 P95/P99、锁、连接池、线程堆栈 |
| 加消费者后 Lag 短暂下降又上升 | 分区限制、单条处理慢、下游写入慢、重试死循环 | 增分区、批量、幂等、死信、优化下游 |
| 新实例流量少 | 本地服务列表未刷新、权重、慢启动、长连接 | 查注册中心、LB候选、连接池 |
| 流量不均 | 长连接、热租户、灰度、实例性能不同 | 按实例看 QPS、RT、CPU、错误率 |
| HPA 来回抖动 | 指标滞后、冷启动慢、缩容太快 | 稳定窗口、预热、冷却时间、最小副本 |
八、HPA 和自动扩缩容
Kubernetes HPA 常按 CPU 扩容,但微服务瓶颈不一定体现在 CPU。
flowchart TD
A["指标采集"] --> B["HPA计算期望副本"]
B --> C["创建新Pod"]
C --> D["镜像拉取和启动"]
D --> E["Readiness通过"]
E --> F["注册中心和Endpoint传播"]
F --> G["调用方逐步拿到新实例"]
G --> H["连接池和缓存预热"]HPA 的坑:
| 问题 | 原因 | 方案 |
|---|---|---|
| CPU 不高但接口慢 | 阻塞在 DB、Redis、第三方、锁或连接池 | 用 in-flight、P99、队列长度、连接等待等指标 |
| 扩容太慢 | 镜像拉取、JIT、类加载、缓存预热、注册传播 | 预留最小副本、镜像预拉取、慢启动 |
| 新实例一上来就被打爆 | Ready 只证明可接请求,不代表缓存和连接已热 | 低权重慢启动、预热接口、观察 P99 |
| 缩容导致请求失败 | Pod 被杀时仍有在途请求或消费者任务 | Readiness 摘流、preStop、graceful shutdown |
| 扩缩容抖动 | 指标延迟和阈值太敏感 | 设置稳定窗口、冷却时间和限速 |
九、商业场景
9.1 医疗数据采集批量入库
场景:每晚从多家医院拉取检验、检查、患者和资产数据,清洗后写入 MySQL,同时同步 ES。
容量设计:
- 按医院、接口、数据类型拆任务,不让单医院慢接口拖垮全局。
- 采集线程池、清洗线程池、入库线程池分开。
- MySQL 批量写入要控制 batch size,避免大事务和锁等待。
- ES 同步异步化,失败进入重试表或 MQ 死信。
- 以
hospitalId + sourceId + batchNo + bizId做幂等。 - 监控每个医院的 RT、错误率、积压量和入库 TPS。
9.2 订单创建高峰
场景:活动开始后订单创建峰值升高。
容量设计:
- Gateway 先按用户、活动、接口限流。
- 订单创建强依赖库存,库存失败不能伪成功。
- 营销、推荐、画像是弱依赖,短超时快速降级。
- 下单接口必须有幂等号,超时后查询订单事实。
- MQ 用于异步通知、积分、ES 同步,不要让非核心动作阻塞下单。
- 压测要覆盖真实 SKU 热点,否则上线遇到热库存行会崩。
9.3 ES 查询容量
场景:资产平台按关键字、科室、状态、时间范围搜索。
容量设计:
- 控制深分页,使用
search_after或滚动导出。 - 为常用过滤条件建 keyword 字段和合适 mapping。
- 避免高基数字段大聚合直接打在线集群。
- 搜索接口设置超时和降级,不能拖垮主业务。
- 观察分片级慢查询、CPU、Heap、GC、segment merge。
9.4 MQ 堆积恢复
场景:下游数据库慢导致消费 Lag 上升。
容量设计:
- 先确认是生产太快、消费太慢、Broker 慢还是下游慢。
- 消费者扩容前看分区数或队列数。
- 单条处理慢时优先批量、减少远程调用、优化 SQL。
- 毒消息进入死信,不要无限重试阻塞队列。
- 恢复期间控制生产速率,避免边恢复边继续放大。
十、JDK 8 Demo:Little 定律容量估算器
下面 Demo 不依赖框架,用来理解“RT 变慢为什么会让并发膨胀”。
public class CapacityCalculator {
public static void main(String[] args) {
print("健康状态", 1000, 50);
print("下游变慢", 1000, 500);
print("低QPS慢接口", 100, 3000);
}
private static void print(String name, double qps, double rtMs) {
double rtSeconds = rtMs / 1000.0;
double concurrency = qps * rtSeconds;
double safeThreads = concurrency * 1.5;
System.out.println("场景: " + name);
System.out.println("QPS: " + qps);
System.out.println("平均RT(ms): " + rtMs);
System.out.println("估算in-flight并发: " + concurrency);
System.out.println("建议预估线程或连接余量: " + Math.ceil(safeThreads));
System.out.println();
}
}输出含义:
1000 QPS、50ms RT,系统里约 50 个请求同时停留。
1000 QPS、500ms RT,系统里约 500 个请求同时停留。
100 QPS、3000ms RT,也会有约 300 个请求同时停留。这就是为什么“QPS 不高但线程池满”并不矛盾。慢请求会占住资源,队列继续堆积,最后 P99、超时、重试和错误率一起上升。
十一、生产 Runbook
11.1 QPS 不高但线程池满
排查顺序:
- 看入口 QPS、Avg RT、P95、P99 是否同时变化。
- 看线程池 active、queue、reject。
- 打线程栈,看线程在等 DB、Redis、HTTP、锁还是本地 CPU。
- 看连接池 pending、active、idle。
- 看下游 P99、错误率、慢 SQL、锁等待。
- 看是否有重试放大。
- 对弱依赖降级,对强依赖限流和保护。
11.2 扩容后 RT 不降
排查顺序:
- 按实例看 QPS 是否真的分摊。
- 看注册中心是否发现新实例,LoadBalancer 候选是否包含新实例。
- 看连接池是否还集中连旧实例。
- 看瓶颈是不是共享 DB、Redis、MQ 或第三方。
- 看新实例是否冷启动,JIT、缓存、连接是否未预热。
- 看是否因为扩容导致下游锁竞争更严重。
11.3 压测没问题,上线崩了
常见原因:
| 原因 | 说明 |
|---|---|
| 数据不真实 | 测试库小,生产数据大 |
| 热点不真实 | 压测随机分散,生产集中打热 SKU 或热租户 |
| 依赖被 Mock | 压测没经过真实 DB、Redis、MQ、第三方 |
| 缓存命中率不真实 | 压测缓存全热,生产大量冷数据 |
| 日志和监控不同 | 生产日志、Trace、审计带来额外开销 |
| 发布窗口未考虑 | 滚动发布时少一部分实例,容量下降 |
| 重试未计入 | 生产超时后重试放大流量 |
11.4 HPA 扩容后又缩回去
排查顺序:
- 看 HPA 使用的是 CPU、内存还是自定义指标。
- 看指标采集周期和 HPA 计算周期。
- 看是否有 stabilization window。
- 看新 Pod Ready 后是否被瞬间打满又触发熔断。
- 看缩容是否过快,导致刚恢复的容量又被拿掉。
- 增加最小副本、冷却时间、慢启动和按 P99/in-flight 扩容的指标。
十二、面试标准回答
容量规划不能只说“加机器”。我会先明确业务峰值和 SLO,例如 P99、成功率和允许错误预算;然后画出完整调用链,拆 Gateway、应用线程池、HTTP 连接池、Redis、MySQL、MQ 和第三方依赖的容量预算。压测时找单实例拐点,不用最大吞吐当生产容量,而是在拐点前乘以安全系数,并考虑 N-1 冗余、发布下线、冷启动和下游共享瓶颈。线上扩容后仍慢时,要按实例 QPS、P99、线程池、连接池、DB 锁、Redis 热 Key、MQ Lag 和重试放大逐层定位,确认瓶颈是否真的在应用层。
十三、本章小结
容量规划的本质是把业务目标翻译成每一层资源的可承载边界。QPS 只是入口数字,RT 决定请求停留时间,P99 暴露尾部风险,队列和连接池说明饱和度,错误率说明保护是否失效。扩容只是手段,不是答案;真正可靠的系统必须同时具备容量预算、压测验证、限流熔断、优雅上下线、慢启动、观测告警和故障 Runbook。
