性能优化全链路实战:定位瓶颈、控制放大与验证收益
性能优化不是将 MySQL、Redis、ES、MongoDB 各自调一次参数,而是在同一工作负载和正确性约束下,提高有效完成量、缩短用户等待并控制成本。各产品实现分别见 MySQL 索引、Redis 缓存、ES 执行与恢复、MongoDB 复制与恢复。本页负责串联证据和决策,不重复产品教材;容量与排队的通用方法见工作负载的形状与队列网络、扇出与瓶颈迁移。
场景与统一成功定义
以下均为教学假设,非实测业绩:商品列表接口入口 2000 请求/秒,每次返回 20 项;每项单独向数据库补详情,另查一次列表,形成 21 次 SQL。缓存初始命中率 90%,但先只缓存列表而不缓存详情。结果即使列表全部命中,详情仍可能产生 40000 次 SQL/秒。先修 N+1 并按实际访问模式批量获取,通常比盲目扩充连接池更值得验证;同时必须检查批量大小、传输字节、执行计划和结果顺序。
统一成功定义为:在 300ms deadline 内返回满足租户、权限和版本约束的响应。超时后继续执行的 SQL 不算用户成功,降级结果单列,错误权限的数据绝不能算优化收益。记录入口 offered、实际 admitted、按时完成 good、超时与拒绝,而不是只看服务内部 completed。
建立端到端预算,避免错误加减
沿入口排队→线程执行→连接池等待→数据库执行→结果反序列化→网络发送记录同一请求。串行区间可相加;并行分支取关键路径,不能把所有 span 时间加起来;父 span 已包含子 span 时不能重复计费。各阶段 P99 不是同一批请求,不能直接相加当端到端 P99(推导见延迟预算与尾部放大)。
假设数据库平均持有连接 15ms,优化后每请求一次数据库往返,实际进入该阶段为 2000/s,则平均连接占用约 30;这是稳定条件下 L=λW 的估算,不是"连接池设 30 就够"。若故障时持有变 150ms,则需求变 300,单纯把池扩到 300 可能把数据库压垮。要以数据库可持续并发、排队预算、实例总连接和失败降载共同确定上限。
再假设缓存同时覆盖完整详情,正常 miss 10%,每 miss 合并为一次 SQL,则流量约 200 SQL/s;冷启动全部 miss 时增至 2000 SQL/s,为 10 倍。设正常命中率时得出的安全值不能用于冷缓存。回源并发限制、热点单键合并、预热和错峰过期是不同控制,需分别验证,不能把"加缓存"当作数据库扩容替代品。
从请求证据追到执行器源码
固定基线为 MySQL 8.0.39、Spring Framework 6.1.12、MyBatis 3.5.16;入口线程与排队见服务时间、吞吐与并行度。典型链路是 Controller→Service→MapperProxy→MapperMethod→SqlSession→Executor→StatementHandler→JDBC→服务端优化器与执行器。一次逻辑 Mapper 方法也可能因嵌套查询产生多次 SQL,或被一级缓存命中而没有 JDBC 执行,必须数实际 statement 而不是方法次数。
MyBatis 的 BaseExecutor 查询分支先构造 CacheKey、处理本地缓存,再决定 queryFromDatabase;缓存 key 包括语句、参数、分页等上下文,业务上看似相同的两个请求不必同 key。相关源码解析和 L1/L2 失效反例见 MyBatis 单元。这是定位 N+1 与缓存作用范围的入口,不代表缓存总能正确覆盖跨会话外部写入。
MySQL 的 timing_iterator.h,mysql-8.0.39 中,TimingIterator 包装真实迭代器的 Init / Read:记录初始化和读取耗时、返回行及循环次数。第一次读取与后续读取分别计时,再把信息提供给执行分析。这解释了为何必须结合 rows、loops、首行和总耗时观察嵌套循环,而不是只盯 EXPLAIN 的 cost。
关键边界:EXPLAIN ANALYZE 会实际执行被分析语句;读取和计时本身有开销,父迭代器耗时包含子工作,loops 也会改变总工作量。不要将每个节点时间相加,也不要把估算 rows 当实测 rows。生产重查询先用普通 EXPLAIN、限量或只读副本等受控方式评估风险。官方 EXPLAIN 说明给出了 TREE 与 ANALYZE 的字段含义。
原码:EXPLAIN ANALYZE计时包装真实迭代器
原码连续摘录(仅统一公共缩进,截取方法局部):timing_iterator.h,mysql-8.0.39,L173–L179。
int Read() override {
const IteratorProfilerImpl::TimeStamp start_time =
IteratorProfilerImpl::Now();
int err = m_iterator.Read();
m_profiler.StopRead(start_time, err == 0);
return err;
}先记录时间,再执行m_iterator.Read,最后将开始时间及是否返回一行交给profiler,原错误/EOF状态仍返回调用者。它确实驱动真实读取,不是仅估算cost;父迭代器包含下游工作,不能把每层总时间简单相加当成端到端耗时。
一个可检查的索引优化假设
查询为租户内已发布商品按时间和 ID 倒序取 20 个,只需 id、created_at。候选索引为 (tenant_id,status,created_at,id),前两列等值,后两列支持稳定排序。以下使用新建的独立实验表,不针对现有业务库:
CREATE TABLE perf_item (
id BIGINT PRIMARY KEY,
tenant_id BIGINT NOT NULL,
status TINYINT NOT NULL,
created_at DATETIME(6) NOT NULL,
payload VARCHAR(1000),
KEY ix_feed (tenant_id,status,created_at,id)
);
EXPLAIN ANALYZE
SELECT id,created_at FROM perf_item
WHERE tenant_id=42 AND status=1
ORDER BY created_at DESC,id DESC LIMIT 20;实验前写清:某些租户占 40% 数据、状态分布偏斜、时间有重复、payload 较大。比较有/无候选索引的扫描行、排序、回表、首行时间和缓存冷热;删除或禁用索引仅在隔离实验表进行。预期该访问模式可沿复合索引定位并倒序读取,而非先扫描全租户再排序,是否选择该路径仍以实测计划为准。
第二页用稳定游标 (created_at,id) 做 keyset 条件,而非无限增长 OFFSET;可以先用 OR 展开比较,检查版本优化器实际范围,不承诺所有行构造表达式都会生成理想范围。若改成任意相关性搜索、动态过滤或跨租户聚合,索引契约已改变,不能复用同一"覆盖索引最优"的结论。索引还会增加写放大、磁盘和 DDL 风险,要把写入回归纳入实验。
跨存储优化不能牺牲业务正确性
MySQL 可以是交易事实源,Redis 是可丢失缓存,ES 是可重建搜索投影,MongoDB 可以是独立文档事实源;这是场景分工,不是必须同时部署四种产品。先问查询和原子更新单位,再选产品。
ES 返回写入确认不表示立刻可搜索:需区分权威状态、事件投影位点、别名、目标文档版本、refresh 与缓存。refresh 控制搜索可见,translog 持久性和副本确认是另一些边界;本轮固定 ES 8.15.1,详细实现见独立页。强制每条 refresh 可能提高小段与 merge 成本,用降低吞吐换时效前应量化;用户提交后的状态确认可读事实源,而非等待全局检索立即一致。
MongoDB 的嵌入让相关字段落在一个文档原子边界内,并减少组合读取;但大数组、高频更新、重复字段和无界增长可能产生更高代价。引用利于独立生命周期与高基数关联,却增加读取、协调和跨文档一致性设计;不要为少一次网络往返塞进无界大文档。官方 7.0 数据建模说明按访问与更新模式讨论这种取舍。
并行化、批量、缓存和读副本都可能引入新边界:并行 fan-out 增加峰值并发;批量放大单次工作量;缓存增加旧值窗口;副本可能读不到刚提交结果。性能验收必须保留租户隔离、授权撤销、库存/余额等业务不变量。
负载模型、实验矩阵与停止条件
闭环客户端固定并发,上一请求完成才发下一次。服务变慢时发送率随之下降,很容易观察到"延迟稳定、吞吐变低",却没覆盖真实入口持续到达的积压。开放到达模型按计划速率发请求,压测器本身仍需足够容量;要记录计划发送、实际发送、调度迟滞及未发送量,不能把发不出的请求消失掉。两种模型各有适用场景,不能只换工具选项就声称避免全部测量偏差。
执行顺序为:校验正确性→固定数据与负载→建立基线→一次只改一个主要因素→相同条件复测→故障与恢复→小流量灰度。至少覆盖热/冷缓存、均匀/热点租户、正常/慢依赖、目标负载/超载/恢复,记录有效完成、错误/拒绝、P50/P95/P99、队列年龄、连接 pending、SQL rows/loops、CPU、分配、GC 与每成功请求成本。
明确停止条件,例如实验环境 P99 超过 300ms 连续 30 秒、错误超过 1%、数据库连接等待持续上升;数值为教学假设,应按环境调整。超载时优先限制昂贵操作、入口并发和重试预算,保留健康查询与恢复流量,不用无界队列"吸收"无限负载。
本轮未运行端到端负载实验,不提供虚构的优化百分比。数据库复现步骤见 MySQL 实验、Redis 实验、ES 实验、MongoDB 实验。本地已运行的教学模型不证明这些产品的真实复制和压力行为;容量算例可用 capacity_models.py 复算。
联合放大与安全并发:一次故障如何把三层预算同时击穿
全链路案例把 N+1、缓存冷热与连接持有分开讲,但生产事故往往是三者同时发生。本页把三个放大源叠在同一条时间线上,算清"连接池该配多大"与"数据库安全并发"的关系,避免把池子当作容量。
基准:入口 2,000 请求/秒,每次返回 20 项且每项单独查详情、另查一次列表,共 21 次 SQL,最坏 2,000×21=42,000 SQL/s。修正 N+1 并按详情缓存后,正常 miss 10%、每 miss 一次 SQL,回源约 200/s;冷启动全 miss 时回到 2,000/s,是正常值的 10 倍。若数据库每次查询平均占用连接 15ms,正常时在途约 2,000×0.015=30 个连接;故障时持有升到 150ms,在途变成 300 个。此时若要"把池配到 300 让请求都进去",就把 300 个并发查询压给数据库。
数据库的安全并发不是池子大小,而是它能在目标延迟内同时处理的查询数。假设(需由压测实测确认的)安全并发 60、每次查询 15ms,可持续吞吐 60/0.015=4,000/秒,对 2,000/s 有 2 倍余量;持有升到 150ms 后,可持续吞吐降到 60/0.150=400/秒,2,000/s 的输入有 1,600/s 无处可去。正确动作是把连接池限制在 60~72(安全并发加少量排队),让超出部分在应用侧排队并受 300ms deadline 约束,同时降级非关键查询、限制回源并发;扩池只会把过载从应用搬到数据库,并因锁竞争进一步拉长持有时间,形成正反馈。
执行 python3 labs/capacity_models.py stack_amplification:输入入口 2,000、扇出 21、缓存 miss 10%、持有 15ms、故障持有 150ms、安全并发 60。预期输出 sql_unfixed=42000、sql_cold=2000、connections_normal=30、connections_fault=300、db_capacity_normal=4000、db_capacity_fault=400、unserved_fault=1600。脚本只做稳态算术,不模拟排队与超时;真实安全并发需压测曲线。
两种方案:A 按故障态配池(300),让请求都进数据库,短期看似不拒绝,实际把尾延迟与错误率推向全站;B 按安全并发配池(60~72)加限流与降级,拒绝一部分保核心。本例选 B,并要求把"安全并发"作为数据库契约写进容量表——它和实例数、连接池上限一样,是必须被测量与复查的输入,而不是从池子大小反推出来的结果。
架构师变化条件
去掉一半应用实例、让 ES 恢复与在线查询争用磁盘、将数据库事务持有从 15ms 改为 150ms。先重算可持续完成量和过载暴露,再比较减并发、降级检索、延迟异步投影和临时扩容的取舍;必须给出恢复次序、退出条件和成本。没有真实数据时提出测量计划,不伪造精确参数。全局优化目标是受正确性与资源约束的用户有效完成,而不是某个组件的最高吞吐。
闭卷验收
本页 8 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。