1复合性能现场:P99 飙高
陈远(化名)团队负责 B2B 商户订单中心,核心读接口 GET /api/v1/orders 在大促前全链路压测中 P99 从基线 180 ms 飙到 2.3 s,错误率 0.3% 升至 4.7%。监控显示应用 CPU 仅 45%,MySQL 主库 QPS 翻倍但 CPU 78%,Redis 命中率从 96% 跌到 61%。OnCall 第一反应是「加机器」,扩容后 P99 仅降到 1.9 s——典型的瓶颈不在算力而在路径长度与数据访问模式。
14:02 压测平台:并发 8000,/orders P99 突破 2 s 告警,网关 504 开始出现。
14:08 DBA:SHOW PROCESSLIST 大量 Sending data,慢查询日志单条 SELECT 扫描 420 万行;从库复制 lag 12 s。
14:15 开发:Arthas 显示 OrderAssembler#enrichItems 循环内 Feign 调用商品服务,单请求 37 次 RPC;线程池队列 2000 积压。
14:22 缓存:热点商户 m_1024 大 Key 过期,回源打穿 DB;本地 Caffeine 与 Redis 未联动,击穿窗口约 90 s。
14:40 临时扩容 4 台 Pod 后 P99 仍 1.9 s;陈远暂停压测,决定按层拆解而非继续堆资源。
复合性能故障的可怕之处与复合容灾类似:每一层单独看都「有解释」——DBA 说索引不够、开发说下游慢、运维说流量突增——但缺少统一火焰图与分层归因,优化会陷入「改 A 指标、B 指标恶化」。陈远复盘时画出请求瀑布:网关 8 ms、应用逻辑 120 ms、MySQL 1.6 s、RPC 聚合 380 ms、Redis 45 ms(含击穿),说明主矛盾在 SQL 全表扫描与 N+1 RPC 叠加,扩容只稀释了应用 CPU 却无法缩短 SQL 路径。
与 T15 异地多活类似,性能故障也常呈现观测面分裂:APM 显示应用 RT 高,DBA 面板显示 QPS 正常但 CPU 高,Redis 监控显示命中率尚可但个别 Key 延迟尖刺。没有统一 trace_id 与 digest 级 SQL 聚合,值班会在三个控制台之间往返。陈远要求压测时必须开启 100% Trace 采样 30 min,否则 P99 只能猜。
| 时间 | 观测 | 误判 | 实际根因 |
|---|---|---|---|
| T+0 | P99 > 2 s | 流量太大 | 访问路径过长 |
| T+6 min | MySQL CPU 78% | 库容量不足 | 缺复合索引 + filesort |
| T+13 min | 应用 CPU 45% | 应用无问题 | 线程阻塞等 IO/RPC |
| T+20 min | 扩容无效 | 还需加机器 | 瓶颈在 DB 与 RPC 次数 |
1.2 现场四类误判
- 「CPU 不高就不用优化代码」:IO 阻塞与锁等待不占满 CPU,线程池却在排队;
- 「慢就加索引」:未看执行计划,加错单列索引仍 filesort;
- 「缓存加了就够」:大 Key、无互斥、无本地一级缓存,击穿比无缓存更伤;
- 「P99 靠扩容」:阿姆达尔定律下,串行 SQL 与 N+1 无法线性扩展。
第五类误判在大促前尤其常见:「压测通过一次即可」——陈远团队三月前小流量压测 P99 190 ms 达标,大促前全量商户数据归档策略变更后 cardinality 变陡,同一 SQL 计划退化。性能 invariant 必须绑定数据规模与发布版本,而非某次历史报告。
1.3 架构师前十分钟:三问定层
压测红灯后前十分钟不要改代码,先回答:时间花在哪儿——Trace 瀑布中 DB、RPC、缓存各占多少?次数对吗——单请求 SQL 条数、RPC 次数、Redis 命令数是否随列表长度线性增长?形状变了吗——P99 与 P50 倍率是否突增,指示长尾而非整体负载?三问答案直接映射后续章节:SQL 层、代码层、架构层。
1.4 五层失序叠加
陈远团队在 T+0 至 T+40 回放时,把延迟放大拆成五层失序——与异地多活「三柱分裂」同构,只是这里分裂的是性能观测面:
| 层次 | 设计预期 | 压测实际 | 后果 |
|---|---|---|---|
| L1 网关 | 超时 3 s、限流 1.2 倍峰值 | 未开限流,504 透传 | 重试风暴打穿订单服务 |
| L2 应用 | 列表 RPC batch、线程有界 | 循环 Feign + 队列 2000 | P99 排队 400 ms |
| L3 SQL | 复合索引、主路径 2 条 SQL | filesort 420 万行 | 单请求 DB 1.6 s |
| L4 缓存 | 热点互斥、逻辑过期 | 大 Key 同时过期 | 90 s 击穿窗口 |
| L5 连接 | 全集群连接 < DB 70% | 32×200 潜在连接 | pool wait、从库 lag |
五层任意两层同时恶化,P99 就不是线性相加而是乘法效应:慢 SQL 占满连接池,线程阻塞等连接,Feign 超时重试又放大下游 QPS。陈远在白板上画 dependency chain:订单服务 → MySQL 主库、商品 RPC、Redis、从库 replication——任一环节无预算,整体 SLO 必然虚假。
1.5 OnCall 禁止事项与黄金窗口
- 禁止未采样 Trace 就改 JVM 参数——GC 往往是无效优化;
- 禁止压测峰值下直接
ALTER TABLE ADD INDEX在线 DDL 与满负载叠加——应影子库验证或低峰变更; - 禁止同时调 Hikari、Tomcat、Feign 超时——无法归因;
- 禁止关闭慢查询日志「减负」——失去 CLASSIFY 证据;
- 禁止用平均 RT 汇报优化成果——P99 才是商户体感。
回放显示 T+8~T+25 min 若组合执行:网关 1.2 倍限流、Arthas 确认 N+1 后临时 batch 开关、DBA 在影子库验证索引后低峰上线、Redis 热点 Key 改为永不过期——业务可在有损前提下把 P99 压到 600 ms 以内,而非继续扩容到从库崩溃。缺失的不是工具,而是分层 SOP 与单变量纪律。
复合场景标注:陈远、商户订单、420 万行扫描、P99 2.3 s 均为合成压测叙事,用于训练排障顺序;你的现场可能是 ES 聚合慢或 Kafka 消费积压,但「分层归因 → 单变量验证」不变。
在未完成分层归因前横向扩容,会同时放大 DB 连接数、放大 N+1 下游 QPS、掩盖缓存击穿——陈远团队扩容后从库 lag 从 12 s 恶化到 28 s,几乎触发读写分离雪崩。性能优化第一动作应是限流保活 + 采集 Trace,而非加 Pod。
版本说明:本文机制层以 MySQL 8.0 优化器与 InnoDB 为准,Java 侧以虚拟线程普及前的传统线程池模型为主(便于对照阻塞点);虚拟线程可缓解阻塞但仍无法消除慢 SQL 与 N+1。文中延迟、QPS、行数若无标注均为合成压测示例。阅读前提:你已理解 P99、连接池与索引最左前缀;本文聚焦 SQL、代码、架构三层治理与标准作业程序,面向实战排障,而非 JDBC 或 MyBatis 入门教程。
2性能治理框架与 SOP
极致优化不是技巧堆砌,而是分层 invariant + 可重复 SOP。陈远团队将接口延迟分解为 T_total = T_sql + T_code + T_rpc + T_cache + T_arch,每层有独立预算与验收标准:SQL 层单请求主路径不超过 2 条读、P99 小于 50 ms;代码层禁止循环内外部调用;架构层读写分离与异步化不得引入一致性问题。
框架训练占本篇约一半篇幅,目标是把「感觉慢」变成「哪一层超标、哪条 invariant 被违反」。这与《高性能 MySQL》强调先测量再优化、《Java Performance》强调分配与锁竞争一致:没有基线就没有优化。
三层模型不是物理隔离:SQL 慢会导致连接池满,进而触发代码层线程排队;缓存击穿会放大 SQL 层 QPS。治理顺序上仍建议先缩短单路径(索引、batch),再扩吞吐(读写分离、分片),最后保护(限流、降级)。跳过顺序会在错误层叠补丁——陈远团队第一次加 Redis 时 P99 从 2.3 s 仅到 2.0 s,因为回源 SQL 仍是 1.6 s。
2.2 八步性能优化 SOP
- DEFINE:接口 SLO(P99、错误率、吞吐)与业务峰值系数;
- BASELINE:生产或全链路压测基线,保存 Trace 样本 100 条;
- PROFILE:火焰图 + DB 慢日志 + RPC 依赖矩阵;
- CLASSIFY:瓶颈归入 SQL / 代码 / 架构,排优先级(通常 SQL > N+1 > 缓存 > 架构);
- HYPOTHESIS:单变量假设,例如「复合索引 (merchant_id, created_at) 可消除 filesort」;
- CHANGE:灰度变更,禁止一次改索引又改线程池又改缓存;
- VERIFY:同场景压测,对比 P50/P99/P999 与 DB 行读次数;
- GUARD:CI 门禁(慢 SQL、RPC 次数)、回归压测进发布流水线。
| SOP 阶段 | 交付物 | 通过标准 |
|---|---|---|
| BASELINE | Trace 报告 + 慢 SQL Top10 | 覆盖峰值 1.2 倍流量 |
| CLASSIFY | 延迟瀑布分解表 | 主因占比 > 60% 单层 |
| VERIFY | 前后对比报告 | P99 降幅 ≥ 30% 或达标 SLO |
| GUARD | 压测 Job + 告警规则 | 发布前自动跑核心接口 |
2.3 延迟预算分配
若接口 SLO 为 P99 200 ms,陈远团队采用延迟预算表:网关 10 ms、认证 15 ms、SQL 50 ms、RPC 80 ms、序列化 10 ms、缓冲 35 ms。任一环节超标必须在该层消化,禁止「借用」其他层——例如 SQL 占 1.6 s 时,加缓存只是转移矛盾,击穿后更差。
预算表应写入 API 契约文档,与网关 timeout 对齐:若网关 timeout 3000 ms 而 SLO 200 ms,团队会失去快速失败能力,慢请求占满线程。陈远把 /orders 网关超时改为 800 ms,迫使下游在预算内返回或 503,压测误报的长尾被提前截断。缓冲 35 ms 不是浪费,而是吸收 GC 小暂停与网络 jitter——零缓冲的 SLO 在 Java 栈上几乎不可持续。
P50 与 P99 分开预算:列表接口 P50 目标 80 ms、P99 200 ms,倍率 2.5 以内说明尾部可控;陈远基线 P50 120 ms、P99 2300 ms,倍率 19,典型「少数极慢请求」拖尾,符合 SQL 扫描与 N+1 特征,而非整体负载过高。
MySQL 8.0 官方文档说明 EXPLAIN ANALYZE 可执行语句并输出实际行数与时间;EXPLAIN FORMAT=TREE 展示迭代器执行计划。优化器在 8.0 引入哈希连接与改进的 cost model,但无法替代合适索引——无索引时仍可能选择全表扫描。具体 cost 数值随统计信息与硬件变化,不可跨环境照搬。
2.4 测量工具链与 Trace 标签
框架要落地,工具链必须统一标签:api、merchant_id、sql_hash、downstream。SkyWalking / OpenTelemetry 中,陈远要求每个 Exit span 命名规范为 Feign:/sku/batchGet,便于聚合「每请求 RPC 次数」。DB 侧开启 Performance Schema 的 events_statements_summary_by_digest,按 digest 看 rows examined 总量,比单条慢日志更能发现「不慢但扫很多行」的 SQL。
火焰图采集用 async-profiler wall 模式而非仅 cpu——阻塞在 socket read 的线程 CPU 采样为空,却是 P99 主因。压测报告必须同时给出 P50、P90、P99、P999 与饱和度:线程池 active/max、连接池 pending、DB threads_running。饱和度接近 100% 时,小幅流量增加会把 P99 拉爆,这是排队论而非玄学。
| 工具 | 回答的问题 | 常见误读 |
|---|---|---|
| EXPLAIN ANALYZE | SQL 实际行数与时间 | 只在影子库跑、未覆盖生产数据分布 |
| Arthas trace | Java 方法级耗时 | 未开采样,压测开销反噬 |
| 慢查询 log | 超阈值 SQL | 阈值过高,漏掉 800 ms「不够慢」的 SQL |
| APM Trace | 端到端瀑布 | 采样率过低,P99 靠猜 |
| pt-query-digest | SQL 模式聚合 | 未关联应用 release 版本 |
2.5 优先级决策:何时先 SQL、何时先代码
CLASSIFY 阶段常用占比法则:若 Trace 瀑布中单层耗时超过 P99 的 60%,优先该层。陈远案例 DB 占 70%,故先索引后 RPC。若 DB 仅 30 ms 而 RPC 占 65%,则先 batch。架构层手段(分片、换存储)仅在 SQL 与代码 invariant 已满足仍不达标时启动——过早分片会把优化难度从「加索引」升级为「跨片事务」。
框架训练还包含失败窗口文档化:每次优化须记录「若回滚,谁受影响、如何验证回滚成功」。索引误加可能导致写放大,batch RPC 若下游未限流可能打垮商品服务——优化不是单边行动,而是变更管理。
2.6 与系列 T01/T11 的边界
JVM 层 GC、堆外内存见 T01/T05;多级缓存防击穿见 T11;全链路可观测见 T09。本篇聚焦接口路径上的 SQL、代码与架构,不重复 JVM 调参,但要求 Trace 中 GC 暂停若超过预算 10 ms 须回流 T01。Feign 超时与重试策略与 T10 可靠性模式交叉——重试次数过多会把 P99 尾巴拉长三倍,性能优化与可靠性需同一变更单评审。
3SQL 层:执行计划与索引
陈远案例根 SQL 为商户订单列表:按 merchant_id 过滤、created_at 倒序、分页 20 条,附带 status IN (...)。ORM 生成 SQL 在 merchant_id 单列索引上范围扫描后filesort,EXPLAIN ANALYZE 显示扫描 420 万行、耗时 1.4 s。这不是「库太小」问题,是访问路径与排序键不一致。
3.1 索引设计 invariant
- 等值列在前,范围列在后:
(merchant_id, created_at)复合索引; - 覆盖索引减回表:列表页仅展示 id、status、amount 时可 INCLUDE 列(MySQL 8.0 二级索引含主键);
- 禁止函数包裹列:
WHERE DATE(created_at)=?使索引失效; - 深分页改 seek:
LIMIT 100000,20改为WHERE id < ? ORDER BY id DESC LIMIT 20。
-- 问题 SQL(简化)
SELECT id, status, amount, created_at
FROM orders
WHERE merchant_id = ? AND status IN (1,2,3)
ORDER BY created_at DESC
LIMIT 20;
-- 复合索引
CREATE INDEX idx_merchant_created ON orders (merchant_id, created_at DESC);
-- 验证
EXPLAIN ANALYZE SELECT ...;
加索引后扫描行数降至约 40 行(合成示例),SQL P99 从 1.6 s 到 35 ms。注意:status IN 若选择性低,可考虑将 status 纳入索引第三列或拆查询——优化器可能仍选错计划,需 optimizer_switch 与 histogram 统计信息辅助,但 histogram 不是银弹。
《高性能 MySQL》强调:索引列顺序遵循最左前缀与选择性。陈远曾尝试 (created_at, merchant_id) 希望「顺便」优化按时间全局扫描——对商户列表无效,因 WHERE 先过滤 merchant_id。索引设计必须对齐最常用查询 predicate + order by,而非凭直觉堆列。
MySQL 8.0 降序索引 CREATE INDEX ... (merchant_id, created_at DESC) 可避免 backward index scan,与 ORDER BY created_at DESC 方向一致。8.0 以前用升序索引反向扫描,在大 limit 下仍有额外成本。上线索引后在影子流量对比 optimizer trace(optimizer_trace 会话级开启),确认未出现 range checked for each record 等退化计划。
| 症状 | EXPLAIN 信号 | 动作 |
|---|---|---|
| 列表慢 | type=ALL, Extra=Using filesort | 复合索引对齐 WHERE+ORDER |
| 计数慢 | rows 极大 | 冗余计数表 / 近似 COUNT |
| 偶发慢 | rows 估算偏差大 | ANALYZE TABLE;检查直方图 |
| 锁等待 | trx 长 | 缩短事务;索引减少扫描行锁 |
3.2 统计信息与计划抖动
MySQL 8.0 支持直方图(ANALYZE TABLE ... UPDATE HISTOGRAM),改善 IN 列表与 skew 数据估算。陈远曾遇「周一快、周五慢」:周末批量归档改变数据分布,计划从 index range 切回 full scan。治理手段:计划变更告警(Performance Schema)、关键 SQL 绑定 outline(慎用 SP)。
定期 ANALYZE TABLE 应纳入 DBA 周常,尤其大促后批量导入数据之后。统计信息过期时,优化器可能低估全表扫描成本而选择 ALL——陈远用 optimizer_trace 抓到 rows=4200000 估算仅 50000 的 case,根因是 stats 未更新。RDS 用户须确认 automatic stats 窗口不与压测重叠,避免压测中途计划突变造成误报。
3.3 隐式类型转换与 ORM 陷阱
陈远团队曾遇 merchant_id 列 varchar、Java 传 Long,MySQL 隐式转换导致索引失效,EXPLAIN 显示 type=ALL。ORM 代码审查须对齐列类型与参数类型。MyBatis 动态 SQL 中 ORDER BY ${sort} 若未白名单,还存在 SQL 注入与无法走索引双重风险。
SELECT * 迫使 InnoDB 回表读整行,宽表(含 JSON 扩展字段)放大 IO。列表页应用 DTO 投影,SQL 只选必要列;必要时用覆盖索引避免回表。MySQL 8.0 invisible index 可用于验证「去掉该索引是否变慢」,比直接 DROP 安全。
3.4 事务范围与锁竞争
列表接口本应是只读,但陈远发现 Service 层 @Transactional 包裹整个 enrich 流程,只读事务仍持有 MDL 或与写事务竞争。只读列表应 @Transactional(readOnly=true) 且缩短边界——最好在 DAO 层结束事务,RPC 放在事务外。写路径长事务持有行锁,会拖慢 seemingly 无关的读——Performance Schema 的 data_locks 可关联 waiting thread。
| InnoDB 信号 | 含义 | 接口层动作 |
|---|---|---|
| Lock wait timeout | 行锁等待 | 缩短写事务;读走从库 |
| MDL wait | DDL 或长读阻塞 | 在线 DDL 低峰;禁止 DDL 压测期 |
| History list length 高 | Undo 堆积 | 归档;purge 线程监控 |
| Buffer pool hit 低 | 冷数据或扫描过大 | 减扫描行;升温从库 |
在单表千万级、QPS 万级以下,合理复合索引 + 读写分离通常优于过早分片;分片引入跨片排序与分布式事务,P99 下限反而升高。该推断适用于 B2B 订单列表类场景,日志型超宽表或 TB 级单表需单独评估。若业务坚持 OFFSET 深分页,任何索引都无法消除排序成本,应产品层改为游标分页或搜索引擎承接。
4SQL 层:慢查询与连接池
索引解决「单次 SQL 慢」,连接池与事务模型解决「并发下排队与雪崩」。陈远压测中 HikariCP maximumPoolSize=200,应用 32 Pod × 200 = 6400 潜在连接,超过 MySQL max_connections=3000 的一半,且慢 SQL 占满连接导致池等待——P99 中约 400 ms 花在 getConnection。
4.1 慢查询治理
开启 slow_query_log,long_query_time 动态调为 P95 的 0.5 倍;用 pt-query-digest 或 RDS 慢日志分析聚合。门禁:新增 SQL 必须带 EXPLAIN 审查,rows examined 与返回行比大于 100 拒绝合并。
MySQL 8.0 的 log_slow_extra=ON 可记录 rows examined、全扫描标记,便于 DBA 区分「真慢」与「扫描多」。Performance Schema 表 events_statements_history_long 保留最近语句,OnCall 可查压测尖刺窗口 без 仅依赖文件慢日志。陈远建立 digest 级日报:Top3 SQL 按总 rows_examined 排序,比按 max latency 更能发现潜伏炸弹。
# HikariCP — 连接数与超时(示例)
spring.datasource.hikari.maximum-pool-size: 20
spring.datasource.hikari.minimum-idle: 5
spring.datasource.hikari.connection-timeout: 3000
spring.datasource.hikari.validation-timeout: 2000
spring.datasource.hikari.max-lifetime: 1800000
池大小经验公式:connections = ((core_count * 2) + effective_spindle_count)(《PostgreSQL 连接池》思路可借鉴);对 MySQL IO 密集场景,每 Pod 10–30 连接常优于 200。总连接数须小于 DB max_connections 的 70%,预留 admin 与 replication。
4.2 读写分离与 lag
列表读走从库可降主库 CPU,但陈远从库 lag 12 s 时商户看到旧订单状态。架构层 invariant:读从库必须带最大陈旧度;超 lag 自动切主或返回 503。MySQL 8.0 的 SHOW REPLICA STATUS 中 Seconds_Behind_Source 非严格实时,应用侧应用 heartbeat 表测真实 lag。
| 连接池现象 | 根因 | 修复 |
|---|---|---|
| Pool exhausted | 慢 SQL 占连接 | 修 SQL + 降 pool + 限流 |
| Connection timeout | DB 满或网络 | 总连接预算;熔断 |
| 泄漏 | 未 close / @Transactional 嵌套 | LeakDetection;代码审查 |
| 惊群 | 同时重建连接 | jitter;预热 |
大促前陈远团队建立「连接预算单」:每服务 Pod 数 × 每 Pod 池大小 × 实例数,汇总不得超过 DB 上限 70%。压测若出现 pool wait 指标上升,优先杀慢 SQL 而非加 Pod——加 Pod 在预算已满时只会更快触顶。预算单与架构评审绑定:新服务上线未填连接预算则拒绝发布。
4.3 Prepared Statement 与 ORM 缓存
MySQL 8.0 服务端 prepared statement 与 JDBC useServerPrepStmts 可减少解析开销,但连接池场景下 statement cache 过大占内存。Hikari 建议 pool size modest,配合 MyBatis 二级缓存关闭(见第五章)。批量写入用 rewriteBatchedStatements=true(MySQL Connector/J)可显著降低 insert 往返,但读路径优化收益远大于写——陈远列表接口只读,重点仍在 SELECT。
4.4 从库负载与延迟读策略
读写分离中间件(ShardingSphere、MyCat 或自研)路由规则须可观测:多少 QPS 打主、多少打从。陈远压测中 85% 读走从库,但 promote 窗口从库只读未降级,列表仍路由从库导致读到 eight-minute stale 数据——产品投诉「状态不更新」。invariant:写后读主窗口、lag 超阈切主,须在路由层实现而非靠前端刷新。
从库硬件常弱于主库,同等 SQL 在从库 P99 可能更高。勿假设「读从库 = 给主库减压且无代价」;从库 CPU 打满同样会 replication lag 恶化,形成正反馈。监控 Replica_IO_Running、Replica_SQL_Running 与 Seconds_Behind_Source 三指标,任一异常应自动降从库读比例。
5代码层:N+1 与线程模型
SQL 优化后 P99 降至 800 ms,仍不达标。Arthas trace 显示 OrderAssembler.enrichItems 对每条订单调用商品服务 GET /sku/{id},20 条列表触发 20 次 Feign,串行 380 ms;若列表 50 条则线性恶化。这是典型N+1 RPC,比 N+1 SQL 更隐蔽,因单次 RPC P99 仅 15 ms。
5.1 批量与 JOIN 化
// 反模式:循环 RPC
for (Order o : orders) {
Sku sku = skuClient.getSku(o.getSkuId()); // N 次
o.setSkuName(sku.getName());
}
// 批量:单次 RPC,服务端 IN 查询或 GraphQL batch
Set<Long> skuIds = orders.stream().map(Order::getSkuId).collect(toSet());
Map<Long, Sku> skus = skuClient.batchGet(skuIds);
若商品域无法 batch,可用并行流 + 限流 semaphor,但并行仅隐藏延迟不减少下游 QPS——压测时商品服务 QPS 从 8000×20 降到 8000×1,下游 CPU 从 92% 回到 48%(合成示例)。
GraphQL DataLoader 与 @BatchSize(Hibernate)同属「请求级批量」模式:在同一 HTTP 请求生命周期内合并同类型查询。Feign 无内置 DataLoader,需手写 batch 接口或引入 rpc 框架的 async aggregate。代码审查 invariant:禁止在 for/stream 内出现 RemoteClient 调用,静态分析规则可接入 Sonar 自定义规则。
DTO 组装层是 N+1 高发区:MapStruct 生成映射代码本身不慢,慢在映射前后触发的远程调用。陈远把 enrich 拆成「必选字段 SQL 一次查出」与「可选字段异步 CompletableFuture」,必选路径 P99 优先达标,可选字段超时则丢弃——产品签字接受列表页不展示 SKU 缩略图。
5.2 线程池与阻塞
Tomcat maxThreads=400 在阻塞 IO 模型下,慢 SQL 占满线程导致线程饥饿:健康检查也排队。调优:缩小 Tomcat 线程、扩大 Hikari 等待超时快速失败、网关限流;Java 21 虚拟线程可提升并发但慢 SQL 仍阻塞 carrier。invariant:线程池队列长度应有界,拒绝策略返回 503 而非无限排队——排队会把 P99 tail 拉长数倍。
Little 定律 L = λW 在线程池上直观成立:到达率 λ 固定时,平均驻留时间 W 与队列长度 L 成正比。陈远压测 thread pool queue 从 200 涨到 1800,P99 从 800 ms 非线性升到 2.3 s——不是 SQL 变更了,而是排队。监控须同时看 tomcat.threads.busy 与 hikaricp.connections.pending,任一持续大于零即饱和信号。
| 代码坏味道 | Trace 特征 | 修复模式 |
|---|---|---|
| N+1 RPC | 同 Exit span 重复 N 次 | batch / DataLoader |
| N+1 SQL | MyBatis 循环 select | JOIN / 批量 IN |
| 大 JSON 序列化 | CPU 高、alloc 多 | DTO 裁剪;Protobuf |
| 同步日志 debug | 磁盘 IO 尖刺 | 异步 append;采样 |
5.3 MyBatis 二级缓存陷阱
二级缓存默认关闭是有原因的:跨事务脏读与集群不一致。陈远曾开启 mapper cache 导致 status 更新后列表仍旧——性能「提升」以一致为代价。读多写少且允许秒级延迟的维度表可本地 Caffeine,订单状态必须走 Redis 带 TTL 或直读 DB。
5.4 Feign 超时、重试与舱壁
Feign 默认超时若大于网关超时,线程会长时间阻塞。陈远配置 connectTimeout=500ms、readTimeout=800ms,并重试仅 idempotent GET 且最多 1 次——非幂等 POST 禁止重试。Resilience4j bulkhead 限制对商品服务并发 50,防止订单列表拖垮商品域。压测证明:取消 blind retry 后 P99 尾巴缩短 22%(合成示例),错误率略升但可接受。
5.5 虚拟线程与阻塞语义
Java 21 虚拟线程适合 IO 密集,但 pin 到 carrier 的 synchronized 块或 native 方法仍可能阻塞平台线程。陈远试点虚拟线程后,连接池等待场景改善,但慢 SQL 1.6 s 仍在——虚拟线程不缩短 SQL。架构师应把虚拟线程视为代码层并发模型升级,而非 SQL 替代方案。pin 监控可用 JDK Flight Recorder 事件 jdk.VirtualThreadPinned。
| 线程模型 | 适用 | 不适用 |
|---|---|---|
| 平台线程 + 小池 | CPU 密集、短 IO | 大量阻塞 RPC 易饥饿 |
| 平台线程 + 大池 | 传统 Tomcat | 内存与上下文切换成本高 |
| 虚拟线程 | 高并发阻塞 IO | 慢 SQL 仍占连接;pin 风险 |
| 响应式 WebFlux | 全链路非阻塞 | JDBC 阻塞需 R2DBC 改造 |
6代码层:缓存与序列化
热点商户 m_1024 订单占流量 18%,列表缓存 Key orders:m_1024:page:1 过期瞬间 8000 QPS 打穿 DB——命中率 61%。陈远复盘:无互斥锁、无逻辑过期、无本地一级缓存,击穿窗口 90 s 与慢 SQL 叠加形成双重长尾。
6.1 缓存三板斧
- 互斥:Redis SETNX 或 Redisson 单飞,只允许一个线程回源;
- 逻辑过期:Value 带 expireTime,异步刷新,读始终可用旧值;
- 多级:Caffeine L1 + Redis L2,L1 扛毫秒级击穿(详见 T11)。
缓存 Key 设计须可失效:陈远采用 orders:v3:{merchantId}:p{page},Schema 变更升 v4 即可全量切换,避免逐 Key DEL。Page 缓存的坑是翻页一致性:新订单插入后 page1 变化,若只失效 page1 而 page2 仍旧,商户看到重复或遗漏——列表缓存适合短 TTL + 低页码,深页码直查 DB 或禁用缓存。
Redis 大 Key(单值超过 10 KB)在集群 slot 迁移时阻塞线程,陈远热点 Key 值压缩为 protobuf 后从 180 KB 降到 24 KB(合成示例),GET P99 从 8 ms 到 2 ms。Monitor 使用 redis-cli --bigkeys 与 MEMORY USAGE 定期扫描,bigkey 进入技术债 backlog。
// 逻辑过期骨架(模式示例)
CachePayload payload = redis.get(key);
if (payload.isLogicExpired()) {
threadPool.submit(() -> reload(key)); // 异步刷新
}
return payload.getData(); // 仍返回旧数据
6.2 序列化与 payload 体积
订单列表曾返回 20 条完整嵌套对象 JSON 约 180 KB,gzip 后 42 KB 仍偏大。DTO 投影仅列表字段,体积降到 8 KB(合成示例),网关传输 P99 降 12 ms。Jackson 开启 Afterburner 或换 JSON-B 需压测验证,微优化不应先于 SQL 与 N+1。
缓存得当
- Key 带版本号,Schema 变更可批量失效
- 热点 Key 永不过期 + 逻辑刷新
- 空值缓存防穿透,TTL 短
缓存踩坑
- 大 Key 单值 2 MB+ 阻塞 Redis
- 击穿无互斥,DB 放大 50 倍
- 缓存与 DB 双写无顺序
6.3 穿透、雪崩与布隆过滤器
恶意或异常流量用不存在 merchant_id 查列表,缓存无 Key、DB 也无行,每次穿透 DB。空值缓存 TTL 60 s 可挡穿透;高基数查询前布隆过滤器拦截(见 T17 算法篇)。雪崩指大量 Key 同时过期——陈远改为 TTL 加随机 jitter 300 s ± 60 s,热点 Key 永不过期。布隆有误杀率,须产品接受「极低概率查不到存在商户」或布隆仅作前置粗滤。
6.4 本地缓存与一致性
Caffeine maximumSize 与 expireAfterWrite 须小于 Redis TTL,形成 L1 短、L2 长。集群多 Pod 时本地缓存不一致窗口秒级,订单状态类数据不宜 L1 长 TTL;商户名称类维度可 L1 5 min。发布变更时通过 Redis pub/sub 广播 invalidate,避免等 TTL 自然过期。
6.5 HTTP 压缩与网关缓存
网关开启 gzip/brotli 对大于 1 KB JSON 有效;过小 body 压缩反而增 CPU。ETag 对动态列表慎用——生成 ETag 需读全 body,可能抵消收益。静态配置类 API 可 CDN 缓存,动态订单列表 CDN 不适用,除非业务接受分钟级延迟只读副本。
7架构层:读写分离与异步化
架构层占本篇约一成五,处理 SQL 与代码层无法消化的吞吐与一致性边界。陈远团队在 SQL 与 N+1 治理后,P99 仍 220 ms,距 SLO 200 ms 差一步——读从库、写路径异步化、非核心字段 CQRS 成为最后手段。
7.1 读写分离与 CQRS 轻量版
列表读从库 + 详情读主库;商户后台写后读需读己之写:写后 2 s 内带 read_master=1 头。订单统计类查询迁只读副本或 ES,避免 OLTP 上跑聚合。MySQL 8.0 Group Replication 多主写冲突成本高,B2B 订单仍推荐单写主 + 多从。
读写分离中间件的一坑是事务内读:同一 @Transactional 里先写后读,若读被路由到从库,业务看到写前快照。ShardingSphere 的 hintManager.setWriteRouteOnly() 或 Spring 事务同步绑定连接,须写进编码规范。陈远曾遇「刚改状态列表仍旧」——根因是读写分离 + 长事务,而非缓存。
7.2 异步化边界
导出报表、发送通知、更新宽表可 Kafka 异步;同步接口内禁止为「省事」发 MQ 再等消费。陈远曾把物流轨迹查询嵌在列表接口,P99 被第三方拖死——应改为列表不含轨迹、详情页懒加载。
异步化的性能收益是缩短同步关键路径,代价是最终一致与补偿逻辑。本地消息表(见 T13)保证 at-least-once,消费端须幂等。陈远评估:订单列表若异步补全 SKU 名称,商户后台可接受 3 s 内刷新,但移动端下拉刷新期望同步——产品差异决定能否异步,架构师不能单边拍板。
| 架构手段 | 适用 | 风险 |
|---|---|---|
| 读写分离 | 读多写少列表 | lag 陈旧;写后读 |
| 异步 MQ | 非关键路径 | 一致;重复消费 |
| 分库分表 | 单表瓶颈且无法归档 | 跨片查询;运维成本 |
| CDN / 边缘 | 静态与只读 API | 缓存失效策略 |
7.3 分库分表何时值得
陈远曾提议按 merchant_id 分 16 库——架构评审否决:单表 8000 万行,归档后活跃 2000 万,复合索引后 QPS 余量 3 倍。分库引入跨商户运营报表 join 困难、分布式 ID、扩容 rebalance。条件化结论:当单表索引优化 + 归档 + 读写分离仍无法达标,且 QPS 或存储线性增长无天花板,才启动分片。分片键应高基数且与查询模式一致,避免跨片广播。
7.4 限流与降级作为架构手段
性能优化的反面是过载保护:Sentinel 或网关 token bucket 对 /orders 限制 1.2 倍日常峰值,超出返回 429 与 Retry-After,保护 DB 不被击穿的尾部流量拖死。降级策略:列表不返回 SKU 名称、仅返回 id,详情页再补——牺牲功能完整性换 P99。陈远压测中降级开关从未演练,OnCall 不敢拉——性能架构须包含降级 DAG 与 quarterly drill。
7.5 消息驱动的读模型
若列表需聚合十张表,OLTP 路径再优化也有天花板。CDC 订阅 orders binlog 写入宽表或 ES,列表读搜索索引——这是 CQRS 重量版,一致窗口分钟级。陈远团队短期未上 ES,但把「读模型分离」列入下季度 roadmap,避免在 OLTP 上堆更多 JOIN。架构层 15% 不是篇幅少就不重要,而是最后手段:能 SQL+代码解决就不动拓扑。
8前后对比与决策矩阵
陈远团队按 SOP 逐项变更并同场景压测,形成前后对比(合成示例,非对外承诺):
| 阶段 | 改动 | P99 | MySQL 行读/请求 |
|---|---|---|---|
| 基线 | 无 | 2300 ms | 420 万 |
| +复合索引 | idx_merchant_created | 800 ms | 40 |
| +batch RPC | skuClient.batchGet | 380 ms | 40 |
| +缓存互斥 | 单飞 + 逻辑过期 | 240 ms | 40(击穿消除) |
| +连接池预算 | 200→20/Pod | 165 ms | 40 |
注意:顺序很重要——若先加缓存而 SQL 仍扫 420 万行,击穿时 DB 压力更大。决策矩阵帮助判断「何时停优化」:
优化顺序的另一经验法则:先减 work(扫描行数、RPC 次数),再减 wait(连接、线程排队),最后才加 resource(机器、分片)。陈远若按 resource → wait → work 逆序,会多花两周且从库 lag 几乎触发事故。决策矩阵不是表格装饰,而是避免团队在错误层级投入人力的护栏。
| 信号 | 继续 SQL/代码 | 上架构 |
|---|---|---|
| EXPLAIN 仍有 ALL | 是 | 否 |
| RPC > 5 次/请求 | 是 | 否 |
| 单表 > 5 亿且归档难 | 部分 | 是 |
| P99 已达标且 CPU < 40% | 否 | 否 |
极致优化得当
- 分层归因 + 单变量验证
- 延迟预算表写入 SLO 文档
- CI 压测门禁核心接口
- 连接数与慢 SQL 大盘可审计
反模式
- 未测就改十个参数
- 缓存掩盖慢 SQL
- 扩容代替索引
- 只看平均 RT 不看 P99
当 P99 已低于 SLO 且资源利用率健康,继续微优化 Jackson 或 GC 的参数收益递减;团队应把精力转向 GUARD 阶段与相邻接口扩散。若业务要求 P99 100 ms 以下,可能需要边缘缓存或协议级优化(gRPC、字段裁剪),已超出本篇 SQL/代码主路径范畴。合成对比表中 165 ms 来自同场景同并发复测,不同商户数据分布下不可外推。
8.2 压测方法论与回归
前后对比须同脚本、同数据规模、同预热。陈远压测犯过的错:第一次未预热 Buffer Pool,索引优化后提升被夸大;第二次未清理 Redis,命中率虚高。标准流程:预热 10 min → 稳态 20 min 采样 → 突刺 5 min。记录 release git sha、索引 DDL 版本、Feign 配置 hash,保证可复现。
全链路压测与单接口压测分工:单接口 JMeter/gatling 脚本隔离 /orders,验证本服务 invariant;全链路模拟网关 → 订单 → 商品 → 支付,验证下游 batch 与限流是否联动。陈远单接口 P99 165 ms 达标,但全链路因支付 mock 延迟 P99 280 ms——说明架构层预算仍要跨服务汇总。性能 Owner 应维护依赖矩阵延迟上限,与 T14 微服务治理的 SLA 对齐。
8.3 组织与流程:性能 Owner
接口级 SLO 须指定 Owner:开发对 N+1 负责,DBA 对慢 SQL 负责,SRE 对连接预算与压测门禁负责。陈远建立 weekly perf review,Top5 P99 接口滚动优化,避免大促前集中救火。CR 检查项:新增 Feign 调用须说明 batch 方案;新增 SQL 须附 EXPLAIN 截图。
| 角色 | 性能职责 | 交付 |
|---|---|---|
| 架构师 | SLO、延迟预算、分层决策 | 预算表、决策矩阵 |
| 开发 | N+1、事务边界、DTO | Trace 合规、batch API |
| DBA | 索引、连接、复制 lag | 慢 SQL 周报、DDL 评审 |
| SRE | 压测、限流、观测 | 门禁 Job、大盘 |
9总结、检查表与延伸阅读
极致接口性能是三层 invariant 的工程化:SQL 层缩短访问路径,代码层减少调用次数与阻塞,架构层在一致约束下分担吞吐。陈远案例说明复合瓶颈必须分层拆解,扩容与缓存不能替代执行计划与 batch RPC。带走三句话:先测量瀑布再改;单变量验证;优化结果写进门禁。
性能优化与功能开发共享同一发布列车时,常因「赶需求」跳过 VERIFY。陈远团队在 GUARD 阶段把核心接口压测设为 merge 门禁失败即阻断,与单元测试同级。门禁脚本断言:P99 < 200 ms、单请求 RPC ≤ 3、慢 SQL 0 条——任一失败打印 Trace 链接供作者自查。文化上从「性能是大促前专项」改为「性能是每日 CI 属性」,比任何单点技巧都更极致。
最后提醒:合成示例数字用于训练量级感;你的库表 cardinality、网络 RTT、商户热点分布不同,须在本环境重做 BASELINE。架构师的职责是交付可重复的方法,而非背诵 165 ms 或 420 万行——那些只是陈远叙事中的教具。
| 检查项 | 标准 | 责任人 |
|---|---|---|
| 接口 SLO 与延迟预算 | 文档化 P99;各层有 ms 预算 | 架构师 + 产品 |
| 核心 SQL EXPLAIN | 无 ALL+filesort;rows/examined 比 < 100 | DBA + 开发 |
| RPC/SQL 次数 | 列表接口 RPC ≤ 3;SQL ≤ 2 | 开发 |
| 连接池预算 | 全集群 < DB max 70% | SRE |
| 缓存击穿 | 热点 Key 互斥或逻辑过期 | 开发 |
| 压测门禁 | 发布前核心接口 P99 回归 | QA + SRE |
| Trace 采样 | 生产 1% 永久;故障 100% | SRE |
| 前后对比报告 | 每次优化附 P50/P99 与行读数 | OnCall 负责人 |
系列下一篇 T19 · 大促全链路稳定性:在单接口 P99 达标后,把限流、预案、全链路压测与混沌纳入大促 SOP;本篇的三层框架可嵌入 T19 的接口级检查项。系列 T12 秒杀架构中的热点隔离、队列削峰,与本篇缓存击穿、连接池治理可组合为「峰值接口 playbook」。
9.1 架构师带走的能力模型
读完本篇,你应能在 OnCall 前十分钟画出延迟瀑布并定层;能在 CR 中拒绝无 EXPLAIN 的 SQL 与循环 RPC;能在评审中问「连接预算填了吗」「击穿方案是什么」。性能极致优化不是个人英雄主义,而是 invariant + SOP + 门禁 的组织能力。陈远团队在复盘 PPT 之外,把八步 SOP 写进 Confluence 并与发布系统联动——优化成果不再随人员流动而丢失。
9.2 常见失败窗口复盘
- 索引上线:大表 DDL 锁表 → 低峰 + pt-osc/gh-ost;
- batch RPC 上线:下游未扩容 → 先限流 + 下游压测;
- 缓存永不过期:逻辑过期 bug 导致永不刷新 → 监控 Key 年龄;
- 连接池缩小:瞬时 503 上升 → 灰度 Pod + 告警阈值临时放宽。
每个失败窗口应有 rollback 按钮与验证脚本,性能变更与功能变更同等严肃;灰度观测窗口不少于一个完整业务高峰时段。
- BOOKBaron Schwartz 等,《高性能 MySQL》(第 4 版),O'Reilly —— 索引与执行计划、复制与连接模型。
- BOOKScott Oaks,《Java Performance》(第 2 版),O'Reilly —— JVM 与系统级性能分析方法论。
- BOOK周志明,《凤凰架构:构建可靠的大型分布式系统》—— 性能与可靠性权衡、失败设计。
- DOCMySQL 8.0 Reference Manual — EXPLAIN(2026-08 查阅)。
- DOCMySQL 8.0 — Optimization and Indexes。
- DOCHikariCP — About Pool Sizing。