性能反模式
这一课你将能做到
- 识别 Busy Database、Chatty I/O、N+1、Retry Storm 四大反模式的症状与根因
- 亲手修复一处 N+1 查询(循环查询 → 批量 JOIN),并用 SQL 日志验证修复效果
- 为重试配置指数退避 + jitter + 上限,说出「盲目重试如何演变成全网雪崩」的完整链条
为什么要学
架构评审最容易翻车的不是「缺组件」,而是**「能跑但会死」的惯用写法**:开发环境几百条数据一切正常,上线数据一多、流量一来,DB CPU 100%、连接池打满、重试风暴雪崩。反模式清单是设计的负向检查表——知道「别做什么」往往比知道「做什么」更救命。
面试里反模式题是深挖题的入口:「你遇到过的线上性能事故?怎么定位怎么修?」「重试为什么要加退避和 jitter?」「N+1 是什么、怎么发现?」——用事故故事回答,比背概念有说服力。
人话:Busy Database 是让一个人干全公司的活(所有查询压一个库);Chatty I/O 是给同事发消息一页一条连发 50 条(该一封邮件说清的事);Retry Storm 是客户打不通电话就一直狂拨(把占线变成彻底瘫痪)。
先建立图像
四大反模式速查表(评审对照用):
| 反模式 | 症状 | 根因 | 对策 |
|---|
| Busy DB | DB CPU 100%、连接耗尽、慢查询堆积 | 无缓存、N+1、全表扫、报表混部 | 缓存、批量、索引、读写分离 |
| Chatty I/O | P99 高但单次调用都快 | 循环 RPC、每节点一次请求 | 聚合接口、批量 API、BFF |
| N+1 查询 | 一次页面请求 DB 往返几十次 | ORM 懒加载或循环查询 | JOIN / 批量 IN / 预加载 |
| Retry Storm | 下游雪崩、错误率 100%、全网瘫痪 | 无退避、无上限、超时过宽 | 指数退避 + jitter + 熔断 |
本站高危路径画像:
❌ GET /api/problems/1024
→ 查题目 (1 次)
→ 循环 500 个节点,每节点查资源 (500 次) ← N+1,页面打开 2.5 秒
→ 循环 500 个节点,每节点调一次统计服务 (500 次) ← Chatty I/O
✅ 一次 JOIN 拉全题目+资源 (1 次)
✅ 统计走批量接口 / Redis 预聚合 (1 次)
Retry Storm 的死亡链条(背下来,面试直接用):
DB 变慢 → 请求超时 → 客户端/网关重试 → 有效 QPS ×3
→ DB 更慢 → 更多超时 → 更多重试 → ×9
→ 线程池占满 → 相邻无辜接口也超时 → 全站 502
(重试流量放大 + 超时把「慢」变成「死」)
跟着做
步骤 1:发现 N+1——打开 SQL 日志
# 开发/测试环境开启 SQL 日志
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
访问一次题目详情接口,日志里数 SQL 条数:
==> Preparing: SELECT * FROM problem WHERE id = ?
==> Preparing: SELECT * FROM node_resource WHERE node_id = ? ← 出现 500 次
一次用户请求 = 501 次 DB 往返。每往返 1ms 是 500ms 纯网络开销,加上每条查询的解析执行,这就是「详情页 2 秒」的真相。发现手段三个:SQL 日志、APM 链路追踪看 DB 段次数、慢查询之外的「QPS 与 DB TPS 比值异常」(1 QPS 打出 500 TPS)。
步骤 2:修复 N+1——循环改批量
// ❌ 错法:循环单查
for (Node node : nodes) {
node.setResources(resourceMapper.selectByNodeId(node.getId())); // 500 次
}
// ✅ 正法一:一次 JOIN 一起取回来
List<NodeWithResource> rows = nodeMapper.selectAllWithResources(problemId); // 1 次
// MyBatis 用 collection 映射成树
// ✅ 正法二:批量 IN + 内存分组(适合跨服务/跨表场景)
Map<Long, List<Resource>> byNode = resourceMapper
.selectByNodeIds(nodeIds) // WHERE node_id IN (500 个 id) 1 次
.stream()
.collect(Collectors.groupingBy(Resource::getNodeId));
nodes.forEach(n -> n.setResources(byNode.getOrDefault(n.getId(), List.of())));
修复验证:SQL 日志里 501 → 2,接口 P99 从 2000ms → 80ms。优化前后数字都报出来,这是工程汇报的基本功。
步骤 3:修掉 Retry Storm——退避、抖动、上限、熔断
resilience4j:
retry:
instances:
callStatsService:
max-attempts: 3 # 含首次,最多试 3 次
wait-duration: 100ms
enable-exponential-backoff: true # 100ms → 200ms → 400ms
enable-randomized-wait: true # jitter:加随机抖动防同时重试
circuitbreaker:
instances:
callStatsService:
failure-rate-threshold: 50 # 失败率过半,熔断 30 秒
wait-duration-in-open-state: 30s
sliding-window-size: 20
四件套各自的作用:退避让重试间隔拉长给下游喘息;jitter 让百万客户端的重试错开(否则 30 秒熔断结束时所有请求同一瞬间杀回来,第二波雪崩);上限保证单请求最多放大 3 倍流量;熔断在下游已死时直接快速失败,停止送死流量。
重试只该用于幂等且瞬态的失败:网络抖动、503 可以重试;400 参数错误重试一万次也是 400;非幂等写操作(扣款、提交)重试前必须确认幂等(第 3 课)。
步骤 4:反模式走查清单(评审直接抄)
□ 读路径有没有缓存?还是一切直查主库? (第 5 课)
□ 循环里有没有单查/单调用?(N+1 / Chatty) (本课)
□ 重试有没有上限、退避、jitter?非幂等在重试吗? (本课)
□ 所有外部调用都设了超时吗? (第 2 课)
□ 写路径里有没有可以异步的旁路任务在同步等待? (第 6 课)
□ 一次请求的 DB 往返次数是多少?超过 10 次要说明 (本课)
□ 有没有大 key、大事务、大 JSON 全量传输? (第 5/7 课)
□ 监控里能看到 1 QPS 对应多少 DB TPS 吗? (放大倍数 = 反模式探测器)
讲清楚要点
- 反模式的共同根源是「放大」:N+1 放大 DB 往返、Chatty I/O 放大网络次数、Retry Storm 放大请求量。度量放大的通用探针是放大倍数——1 次用户请求背后有多少次 DB/网络调用,超过个位数就要给理由。
- N+1 的本质是 ORM 的甜蜜陷阱:懒加载让开发时看不见 SQL,上线后靠日志/APM 才能暴露。团队的约定应该明确:列表/树形接口禁止循环查询,CI 里可以用 SQL 条数断言测试兜底。
- 重试是把双刃剑,默认不开:每个重试配置都要能回答三个问题——重试安全吗(幂等?)、重试几次(上限)、间隔怎么长(退避+jitter)。答不出就不该有这个重试。
- 超时是反模式放大的帮凶:超时设太宽(30s),一个慢请求占线程 30 秒,线程池 50 线程 25 秒内被拖光;设太窄又误杀慢查询。按「P99 × 2」设初值,再按熔断和舱壁补位。
- 无缓存的读路径等于把 DB 当服务器:读多写少系统(本站 9:1)不加缓存,等于主动选择 DB 做第一个瓶颈。评审看到「直查主库的读接口」就要求给缓存或降级方案。
你可能会懵的地方
-
N+1 和 Chatty I/O 有什么区别?
对象不同:N+1 是对数据库的循环单查(SQL 层),Chatty I/O 是对服务的循环调用(RPC/HTTP 层)。修法同源(批量),但 N+1 用 JOIN/IN,Chatty 用批量 API/BFF 聚合。面试能区分说明有实操。
-
jitter 为什么必须随机,固定退避不行吗?
固定退避让所有客户端同一节奏重试:故障恢复的瞬间,积压的全部重试同时到达,形成「重试同步波」,刚恢复的下游立刻再挂。随机抖动把重试打散成平缓斜坡。这也是第 5 课 TTL 加抖动的同一原理——集中 = 灾难,分散 = 平稳。
-
怎么在 code review 里快速发现这类问题?
三个动作:看循环体里有没有 select/feign/restTemplate 调用;看配置里有没有 retry 注解而没配上限退避;看新接口的 SQL 日志条数与请求数比值。最后可以建「放大倍数」监控项长期盯。
-
前端也能造成 Retry Storm 吗?
能,而且更隐蔽:浏览器插件、自动刷新、移动端 SDK 的自动重试没有退避。服务端对策:429 + Retry-After 明示客户端行为、网关限流兜底、WS 代替轮询从根上减少重试源。
-
压测能提前暴露反模式吗?
大部分能——压测放大流量后 N+1 的 501 次 SQL 会立刻把 P99 顶上天,Retry 配置错误会在人为注入故障时现形(看放大倍数)。所以流程上把「压测看放大倍数」设为上线门禁比评审更可靠。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 循环里逐个查库/调服务 | 往返次数随数据量线性爆炸 | 批量 IN / JOIN / 批量聚合接口 |
| 重试 10 次、间隔固定 1 秒 | 放大 10 倍流量且同步波 | 3 次 + 指数退避 + jitter + 熔断 |
| 对非幂等写操作开启重试 | 重复扣款/重复提交 | 先做幂等(唯一键)再谈重试 |
| 超时统一设 30 秒「保险」 | 线程池被慢请求占死 | 按 P99×2 定,配合熔断舱壁 |
| 树形页面循环建树查询 | 500 节点 = 500 次 SQL | 一次取全量内存组装,或预物化树 JSON 缓存 |
卡点急救
- 线上突然大面积超时但应用 CPU 不高:先看放大倍数——DB TPS / 应用 QPS 是否异常飙升(N+1 在数据量增长后爆发);再看下游是否雪崩(重试风暴)。止血顺序:网关限流 → 关闭可疑重试配置(开熔断)→ 缓存热点读路径。
- 加了缓存还是慢:查缓存命中是否被穿透(空值缓存缺失)、大 key 序列化耗时(单 value 超过 100KB 要拆)、以及是不是「缓存了但每次都 miss」(TTL 太短或主动失效写错了 key)。
- 无法判断该不该给某个调用加重试:三问定夺——失败是瞬态的吗(超时/503 是,400 否)?操作幂等吗?下游被压垮时重试会恶化吗?三问有两个「否」就不加重试,改用降级或告警人工介入。
面试常问
-
讲一次你排查/修复过的性能事故。
套路:现象(详情页 P99 2 秒)→ 度量(APM 发现 1 次请求 501 次 SQL,放大倍数异常)→ 根因(树形节点循环查询 N+1)→ 修复(批量 IN + 内存组装,SQL 501→2)→ 验证(P99 80ms)→ 防复发(SQL 条数断言进 CI、放大倍数监控)。完整闭环比技巧本身更打动面试官。
-
重试风暴是怎么发生的?怎么预防?
按「链条 + 四件套」作答:下游变慢→超时→重试放大流量→更慢→全站雪崩。预防:指数退避 + jitter 防同步波、次数上限防流量放大、熔断防送死流量、非幂等不重试。最后补「超时设窄一点、熔断兜底」体现系统性。
-
你怎么给团队建立反模式的防线?
三层:评审清单(循环查库、重试配置、缓存缺失逐项过)→ CI 门禁(SQL 条数断言、放大倍数压测)→ 运行时监控(1 QPS 对应 DB TPS、429 比例、熔断次数告警)。说明「防线靠机制不靠自觉」。
自测三问
- 一次详情页请求 501 次 DB 往返,用什么手段第一时间发现?说出三个。
- Retry Storm 的死亡链条有哪几步?四件套各打断链条的哪一环?
- 「超时 30 秒 + 重试 10 次」这个配置有哪两个致命问题?
小结一句话
反模式是设计的负向清单——放大倍数(1 次请求背后的 DB/网络调用数)是万能探测器,N+1 用批量修,重试用「退避+jitter+上限+熔断」四件套驯服。
延伸