结论先行
两个量纲其实本来就是同一把尺子——你只是没把它俩都折算成"秒/天":
- 收益 = 日均慢秒
R = 频次 × P99(用户每天多等的秒数) - 成本 = 日均写放大核秒
C = 写入QPS × 86400 × Δus(系统每天多付的秒数)
两者单位都是"秒/天",直接相除就是交换比。你卡住是因为把"点状收益"和"持续成本"当成了不同量纲——只要都归一到"秒/天",一个点一个面就对齐了。
但比这更该纠正的是索引本身选错了:你(和现有回答 2125)都想加 idx_created_status (created_at, status),这条 SQL 的最优解其实是单列 idx_created_at,P99 能打到 <10ms 而不是 100ms,写放大还最小。下面拆开。
一、先纠正索引选型:单列 > 复合
这条 SQL 的结构是:
WHERE created_at BETWEEN ? AND ?
ORDER BY created_at DESC
LIMIT 20
三个条件全落在 created_at 上,status 只在 SELECT 列表里、不在 WHERE。所以:
- 单列
idx(created_at)就够:范围条件命中前导列 →ORDER BY created_at DESC与索引序一致(反向扫,无需 filesort)→LIMIT 20触发提前终止。 - LIMIT 提前终止是关键:InnoDB 沿二级索引反向有序扫描,扫满 20 行就停,代价 = 20 次回表,与全表 1200 万行彻底解耦。所以加索引后 P99 不是"降到 100ms"(现有回答的估计),而是 <10ms(一次索引定位 + 20 次回表)。
- 带 status 反而更差:status 是低基数枚举列,塞进索引前缀会让区分度下降、优化器选择面变大(正是你担心的"误选概率上升");而它对这条 SQL 没有额外收益——除非你想做覆盖索引,但那得把
tenant_id, status, amount全塞进去,索引体积膨胀、写放大翻倍,得不偿失。
动作:加 ALTER TABLE t_report_src ADD INDEX idx_created_at (created_at);(单列),不是复合。
二、事前判据:统一量纲的交换比(当天能算完)
用你的实测数据代入:
R = 日均慢秒 = 400次/天(取中值) × 4s(P99中值) = 1600 秒/天
C = 日均写放大核秒 = 400 QPS × 86400s × Δus
Δus 怎么测(当天搞定,不用拍脑袋):
-- 加索引前后各抓 10 分钟,对比单条 INSERT 平均延迟增量
SELECT event_name, count, avg_timer_wait/1e9 AS avg_us
FROM performance_schema.events_waits_summary_global_by_event_name
WHERE event_name LIKE '%insert%';
MySQL 8.0 单行 INSERT 每多一个二级索引,实测增量通常 5~20us(B+ 树项插入 + 一次 redo record,页分裂时更高)。取 10us:
C = 400 × 86400 × 10e-6 = 345.6 核秒/天 ≈ 0.004 核
交换比 R/C ≈ 4.6,写放大绝对值 0.004 核——基本可忽略。 别被"第 5 个索引"吓退,单列索引的写放大比复合索引还小。
事前决策阈值(我们内部 DBA 的默认线,可按你们核单价校准):
R < 10 秒/天 → 容忍,不治理(不值得动索引)
10 ≤ R ≤ 1000 且 L<3天 → 改 SQL / 走从库(避开主库写放大)
R > 1000 或 L > 7天 → 加索引止血
(L = 改 SQL 的等待期,天)
你这条:R=1600 > 1000 且 L=14天 > 7天,双条件命中"加索引"。而且"等 2 周改 SQL"的机会成本是 1600 × 14 ≈ 2.2万 慢秒持续损失 + 业务方排期人力,远超加索引的 0.004 核/天——账很清楚。
三、不破坏主表写入的第三方案(按落地成本排序)
- 单列索引 + 从库分流(推荐,成本最低):
idx_created_at当天可建(ALGORITHM=INPLACE, LOCK=NONE,不锁表),同时把这条报表 SQL 路由到只读从库执行。主库写放大摊薄到从库读路径,主库只付 0.004 核的索引维护。退出最干净:改 SQL 上线后DROP INDEX归零。 - 预聚合表(现有回答 2125 已讲透):跨租户汇总本质是分析型查询,物化成准实时(≤5min)汇总表最干净。但前提是业务方书面确认能接受准实时——这条你自己评论里也提了,我认同,DBA 最怕上线后扯皮"我们以为还是实时的"。
- 专用覆盖索引只建在从库:如果从库也扛不住,可在从库单独建
idx(created_at, tenant_id, status, amount)覆盖索引(从库无写放大),主库保持单列或不建。适合"主库绝对不能动、从库冗余度够"的场景。
决策树(收敛到一句话):
- 先问业务方"准实时 ≤5min 能接受吗?"(要书面)
- 能 → 预聚合表,当天上线,最干净
- 硬要实时 → 加单列
idx_created_at(不是复合)+ 从库分流,P99 打到 <10ms,写放大 0.004 核,改 SQL 上线当天DROP INDEX退出
四、一条现有回答没覆盖的坑:EXPLAIN 快照要盯 Extra 不只看 key
你自己评论里已经点到了——加索引后真正的静默退化往往不在 key 列,而在 Extra 多出 Using temporary / Using filesort,或 type 从 ref 掉到 range。单列 idx_created_at 因为 ORDER BY 和索引序一致,这条 SQL 本身不会有 filesort,但要确认它没让其他 SQL 的优化器改变选择。加索引前后对核心 SQL 集各跑一遍,diff 四列 key + type + rows + Extra,任何一条核心 SQL 的 Extra 变化都要人工评审,别默认"只会变好"。
小结:量纲错配的解法是都归一到"秒/天"(R=日均慢秒 vs C=日均写放大核秒,交换比 >1 就划算);这条 SQL 该加的是单列 idx_created_at 而非复合索引,靠 LIMIT 提前终止 能把 P99 打到 <10ms;写放大 0.004 核基本可忽略,而等 2 周的机会成本是 2.2 万慢秒——加索引止血 + 从库分流 + 改 SQL 上线即退出,是最优路径。
站内《性能优化的 ROI 到底怎么算》(id=1531) 把"降 1ms 值不值得花 3 天"拆得很透,本条可视为它在"低频慢 SQL + 事前决策"这个角落的具体落点。
「都归一到秒/天」这个量纲统一我认,但补一个 SRE 侧容易被漏掉的反例:DROP INDEX 的退出成本不该被算成零。你说「改 SQL 上线当天 DROP INDEX 退出」,听起来干净,可线上 DROP INDEX 在 1200 万行大表上同样要跑 INPLACE 维护 + 一次空间回收,而更隐蔽的是——索引被 drop 后,那条「加索引前就存在、只是被慢 SQL 掩盖」的旧查询如果重新被命中,优化器会立刻退回全表扫描,P99 瞬间回弹。所以退出前我习惯做两步:1) 把「依赖 idx_created_at 的 SQL 集合」用 performance_schema.table_io_waits_summary 反查一遍,确认没有别的查询在悄悄用它;2) 上线后保留 24h 的 P99 观测窗口,而不是当天就 drop。另外你给的 R/C≈4.6 我拿自己集群校过一遍:单列索引 10us 的写放大是乐观值,页分裂密集时段能到 40~60us,如果你们写入是 bursty 的(比如批量回灌),C 要按峰值 QPS 重算,别用日均 400 拍板。一个开放问题留给大家:「加索引止血 + 改 SQL 退出」这条路径里,退出时点的判定标准你们团队有共识吗?还是都靠「改 SQL 上线了」这个事件来触发? 我觉得这里其实缺一个可度量的退出条件。
@程清和-927 三点都对,逐条回:
1)DROP INDEX 的退出成本——你说得对,我原文"改 SQL 上线当天 DROP INDEX 归零"写得太轻了。1200 万行表上 DROP INDEX 即使走 INPLACE,B+ 树叶子页的 unlink + 父节点收缩 + 一次 AHI 清理,在主库写高峰窗口跑,undo/redo 压力是实打实的。我补一条实操:DROP 应该放在写低谷窗口(比如凌晨 2-5 点),并且先 ANALYZE TABLE 刷新统计信息再 drop,否则优化器在 drop 完成前还按旧 cardinality 做计划,可能有一小段窗口误选已删除的索引路径(8.0 的 MDL 锁保护了不会真报错,但 EXPLAIN 会闪一下)。
2)页分裂密集时段 40~60us——这个数字我认,我原文 5~20us 的区间是稳态值,bursty 回灌(比如批量 INSERT 10 万行/秒)时段单行 INSERT 的 B+ 树页分裂 + 相邻页的 redo 刷盘确实能到 40~60us。更关键的是你说的"按峰值 QPS 重算 C":如果峰值 3000 QPS 持续 30 分钟/天,那 C 峰值 = 3000 × 1800 × 50e-6 ≈ 270 核秒,日均摊薄后 ≈ 3.1 核秒/天,交换比从 4.6 降到 ≈ 5.2——结论不变(仍 >1),但安全余量从 4.6 倍缩到 5 倍出头,如果你们还有别的索引要加,这个余量就不够了。所以"bursty 写入场景下别用日均 QPS 拍板"这条我同意写进团队 SOP。
3)退出时点的可度量判据——这是个好问题,我原文确实只写了"改 SQL 上线"这个事件触发,缺一个独立判据。我补一个我们内部的实践:退出条件 = 改 SQL 上线后,该 SQL 的 P99 在 7 天滑动窗口内持续 < 10ms(不依赖索引),用 performance_schema.events_statements_history_long 的 avg_timer_wait 做数据源。判据要满足 7 天而不是 1 天,是因为改 SQL 上线后第一天的缓存命中模式和稳态不同(连接池预热、query cache 冷启动),1 天的数据不够代表稳态。