高性能(二):异步削峰与资源池调优
高性能(二):异步削峰与资源池调优
导语:MQ 篇讲的是「消息不丢、重复消费、幂等、积压处理」(见《消息队列通用》),Java 并发与 JVM 篇讲的是「线程池原理、GC 原理」。本篇只谈架构决策:一个调用该不该异步、削峰填谷怎么设计、异步后一致性/排障/体验怎么补、线程池为什么要按业务隔离、容器里 JVM 与线程池怎么调、容量怎么算,共 10 题。
一、异步化与削峰
1. 架构上如何判断一个调用该不该异步化?
答: 用五个问题做决策,全部满足才适合异步:
| 判断维度 | 适合异步 | 不适合异步 |
|---|---|---|
| 是否在核心链路 | 非核心(通知、积分、索引、统计) | 下单扣款、库存实扣 |
| 是否要求强一致 | 可最终一致 | 要求同步强一致 |
| 用户是否等待结果 | 不需立即返回(返回"受理中") | 需立即看到结果 |
| 失败是否可补偿 | 可重试 / 可对账 / 可人工兜底 | 不可补偿(钱、票) |
| 是否幂等 | 天然或可设计幂等 | 无法保证幂等 |
收益模型:
同步串行:TP99 ≈ RT1 + RT2 + RT3 + ...
异步解耦:TP99 ≈ max(RT1, RT2, RT3, ...) —— 但引入了"最终一致"的成本关键认知:异步化不消灭失败,只是把"同步失败"换成了"异步不一致"。核心链路的正确做法是"同步保住一致性,异步做旁路动作",而不是把核心步骤丢进队列。
2. 异步化有哪几种架构形态?各适合什么场景?
答:
| 形态 | 载体 | 可靠性 | 延迟 | 适用 |
|---|---|---|---|---|
| 进程内异步 | 线程池 / CompletableFuture | 低(进程宕机丢任务) | 微秒级提交 | 并行调用多个下游、日志/埋点 |
| 进程内事件 | Spring Event / Disruptor(无锁环形队列) | 低 | 纳秒~微秒 | 高吞吐、极致低延迟的内部解耦 |
| 消息队列 | Kafka / RocketMQ / RabbitMQ | 高(可持久化、可重放) | 毫秒~秒 | 跨进程解耦、削峰、最终一致 |
| 调度任务 | XXL-JOB / 定时任务 | 高 | 分钟级 | 兜底补偿、对账、离线批处理 |
决策路径:
需要"跨进程 + 可靠 + 可重放" → MQ
只是"并行等待多个下游" → CompletableFuture
需要"进程内高吞吐解耦" → Disruptor / 事件总线
需要"兜底最终一致" → 定时任务补偿加分点:能指出"进程内异步没有背压和持久化"——线程池队列被打满就是 OOM 或拒绝,而 MQ 天然有积压缓冲,这是选型的关键区别。
3. 削峰填谷的架构怎么设计?(大促 / 秒杀)
答: 经典四段式,核心是把"瞬时高峰"摊平成"平均水位":
① 入口限流 ② 异步排队 ③ 匀速消费 ④ 异步通知
挡住超量请求 → MQ 缓冲峰值 → 消费端按 DB 能力消费 → 结果推送/轮询
(拒绝/降级) (持久化) (限流/批量/组提交) (不阻塞用户)每段的关键设计:
| 阶段 | 要点 |
|---|---|
| ① 入口限流 | 网关 / 应用层限流,把超量请求快速失败或排队(见《高可用(一)》);热点前置校验(重复请求、无效请求先过滤) |
| ② 异步排队 | MQ 要能扛峰值写入:分区数足够、顺序写、批量发送;队列要有容量上限 + 超时淘汰(不能让用户等 10 分钟) |
| ③ 匀速消费 | 消费端限速(prefetch / 消费并发 / 令牌桶)保证 DB 不被打爆;批量写 + 组提交提升吞吐 |
| ④ 异步通知 | 用户侧返回"排队中",通过轮询 / 长轮询 / WebSocket 推送获取结果 |
秒杀落法:请求先过"限流 + 去重 + 校验",库存预扣减放在 Redis(Lua 原子),只有少量请求落到 DB / 队列;DB 只承担"最终落库"。
一句话:削峰的本质是"用延迟换吞吐"——把"必须立刻处理"变成"可以稍后处理",前提是用户能接受异步结果。
4. 削峰时 MQ 积压、DB 承压,该怎么协同处理?
答: 先定位瓶颈,再对症下药(切忌盲目扩消费者把 DB 打爆):
| 瓶颈位置 | 手段 |
|---|---|
| 消费线程慢 | 水平扩消费者(受分区数上限约束,见《消息队列通用》第 13 题)、批量消费、消费内部 IO 异步化 |
| DB 是瓶颈 | 必须降速:消费端限流(prefetch/令牌桶)、批量写、合并写(组提交)、临时关闭非核心索引 |
| 上游生产过快 | 生产端限流 + 背压反馈,必要时直接拒绝 |
背压(Backpressure)链路——这是架构题的高分回答:
DB 扛不住 → 消费端降速 → MQ 积压上升 → 生产端感知(发送阻塞/失败) → 入口限流兜底与运维:
- 积压超阈值告警 + 临时扩容 + 应急预案(跳过非核心消息 / 延迟处理 / 降级为同步慢查)
- 关键业务消息不能被丢弃:先保证"不丢",再谈"多久处理完"
5. 异步链路的数据一致性怎么保证?
答: 引用《分布式(二)》的柔性事务体系,架构上要收口三件事:
| 问题 | 方案 |
|---|---|
| 业务成功但消息没发 | 本地消息表 / 事务消息(RocketMQ 事务消息) |
| 消息重复消费 | 幂等:唯一索引 / 状态机 / SETNX 去重(《消息队列通用》第 8 题) |
| 消息丢失 / 消费失败 | 重试 + 死信队列 + 定时补偿 + 对账 |
架构原则:
- 业务与消息同事务(本地事务里插入"待发送"记录,再异步投递)——这是"可靠消息最终一致"的标准做法。
- 消费侧必须幂等,因为投递语义是"至少一次"(两军问题决定了"恰好一次"不可能纯靠协议实现,见《分布式(一)》第 6 题)。
- 必须有对账兜底——再完善的异步链路都会出现"数据不一致",对账是最后的防线。
一句话:异步不消灭一致性,它只是把"同步强一致"换成了"最终一致 + 补偿 + 对账"。
二、资源池与 JVM 调优
6. 线程池在架构上为什么必须按业务隔离?
答: 因为共享线程池 = 共享故障。典型故障链:
某依赖(如第三方接口)变慢 → 其任务占满共享线程池队列
→ 同池的核心业务任务排队 → 整个应用"假死"(CPU 不高,但请求全部超时)隔离手段(由细到粗):
| 粒度 | 手段 |
|---|---|
| 线程池级 | 按业务/依赖拆池(下单池、查询池、通知池、第三方调用池) |
| 调用级 | Hystrix 线程池隔离 vs 信号量隔离(见《高可用(一)》) |
| 连接级 | 每个下游独立连接池 / HTTP 客户端连接数限制 |
| 资源级 | 容器化后按 K8s 副本 + resources.limits 做进程级隔离 |
必须避开的坑(《Java并发(五)》已讲原理):
Executors.newFixedThreadPool/newCachedThreadPool的无界队列 / 无界线程会在压力下 OOM 或线程爆炸。- 生产必须用
ThreadPoolExecutor显式指定有界队列 + 明确拒绝策略。
架构原则:核心链路"小而快"(短队列 + 快速失败),批处理"大而有界";任何线程池都应有名称前缀,便于排障与监控(
jstack一看就知道是谁的池)。
7. 线程池参数如何科学计算?
答: 公式只是起点,压测才是终点。
Little's Law(利特尔法则):
并发数 L = 到达率 λ × 平均停留时间 W
→ 线程数 ≈ QPS × 单次任务平均耗时(RT)按任务类型估算(汤普森经验公式):
| 类型 | 线程数 | 说明 |
|---|---|---|
| CPU 密集 | 核数 + 1 | 再多只增加上下文切换开销 |
| IO 密集 | 核数 × (1 + 等待时间 / 计算时间) | 等待占比越高,线程越多 |
| 混合 | 按业务拆分,分别按上述公式配置 | 不要用一个大池扛所有类型 |
架构级的四个要点:
- 队列必须有界——无界队列会让延迟无限增长(任务排队,TP99 恶化)且内存膨胀。
- 线程数不是越多越好——上下文切换、栈内存占用、以及对下游(DB/第三方)的并发压力都会上升。
- 先测单机能力:测得"单线程 RT 与资源占用"→ 反推线程数 → 压测验证。
- 核心链路与批处理分离,避免批处理抢占核心链路资源。
8. 线程池的监控、拒绝与降级怎么做?
答:
监控指标(必须上报 Prometheus/监控系统):
| 指标 | 含义 / 告警意义 |
|---|---|
activeCount / poolSize | 活跃线程、当前线程数 |
queueSize | 队列长度(持续增长 = 积压) |
rejectedCount | 拒绝次数(最关键的告警项) |
| 任务耗时分布(P99) | 任务本身是否变慢 |
拒绝策略选择:
| 策略 | 适用 |
|---|---|
AbortPolicy(默认) | 核心业务:快速失败 + 触发降级,不阻塞不丢弃 |
CallerRunsPolicy | 可接受回压的场景;但在 Web 线程调用时要小心"把 Tomcat 线程也拖慢" |
DiscardPolicy / DiscardOldestPolicy | 可丢弃的旁路任务(埋点、统计) |
动态调整:把核心/最大线程数与队列容量接入配置中心(Nacos/Apollo)动态修改,避免高峰期重启扩容(大厂实践)。
降级闭环:拒绝数超阈值 → 告警 + 自动/手动降级(关闭非核心任务、返回兜底),与《高可用(一)》的降级预案打通。
9. 容器化(K8s)下 JVM 与线程池要特别注意什么?
答: 容器把"JVM 以为自己独占一台机器"的假设彻底打破了:
| 坑 | 表现 | 解法 |
|---|---|---|
| 内存不感知 cgroup | 老 JDK 按宿主机内存设默认堆 → 超过 limit → OOMKilled | JDK 8u191+/10+ 已感知;统一用 -XX:MaxRAMPercentage=70 而非固定 -Xmx |
| CPU 不感知 | Runtime.availableProcessors() 读到宿主核数 → 默认线程池/GC 线程过多 → CPU 节流 | 显式 -XX:ActiveProcessorCount=N 与 CPU limit 对齐 |
| 线程数超标 | 各框架默认池按核数创建,容器内实际可用核数更少 | 统一配置线程池参数基线 |
| 参数漂移 | 各副本 JVM 参数不一致 → 行为不一致、难排障 | 参数纳入发布基线 / 镜像统一 |
架构建议:
- 内存与 CPU 的
requests/limits要和 JVM 参数一起规划,留出堆外内存(元空间、直接内存、线程栈、Netty 缓冲)的安全余量。 - 用
MaxRAMPercentage+ActiveProcessorCount组合,保证"容器变规格时 JVM 自适应"。
10. GC 选型与调优如何服务 SLA?
答: 先由 SLA 定 GC 目标,再选收集器,而不是反过来。
| 场景 | 目标 | 收集器 |
|---|---|---|
| 延迟敏感(P99 < 100ms 的在线服务) | 低停顿 | G1(-XX:MaxGCPauseMillis)/ ZGC(亚毫秒停顿,JDK 15+ 生产可用) |
| 吞吐优先(离线计算、批处理) | 高吞吐 | Parallel GC |
| 中等堆、老版本 | 平衡 | G1 为默认 |
调优的正确顺序(架构视角):
- 先监控再调参:开启 GC 日志(
-Xlog:gc*)+ 采集停顿、频率、晋升速率、Full GC 次数。 - 定位问题类型:
- Full GC 频繁 → 堆太小 / 内存泄漏 / 大对象(先查泄漏,别急着调参)
- 对象过早晋升 → 新生代偏小 / 大对象直接进老年代
- 元空间 / 直接内存 OOM → 类的动态生成、Netty/文件映射未释放
- 架构手段优先于参数手段:减少对象分配(缓存、对象池、批量、减少装箱/字符串拼接)与水平扩容,往往比调 GC 参数更有效。
- 不要迷信"换 ZGC 就快":ZGC 有额外 CPU/内存开销,要看业务 SLA 与资源成本。
一句话:JVM 调优的目标不是"GC 更快",而是"稳定满足 SLA"——凡是能用架构解决(缓存、异步、批量、扩容)的,就不要交给 JVM 参数。
本章小结:异步化的本质是「用延迟换吞吐、用复杂度换解耦」,代价必须由"幂等 + 补偿 + 对账 + 链路追踪"补齐;资源池调优的本质是「隔离 + 有界 + 可监控 + 可动态调整」;JVM 与线程池的调优,永远先服从于 SLA 与容量规划,而不是某个"最佳参数"。
