InnoDB 二级索引列序怎么定:长尾租户(5 个头部占 62% 数据)下 (tenant_id, created_at) vs (created_at, tenant_id) 的量化取舍
该问题的正文、回答与讨论。
背景
我负责一套 SaaS 计费系统,主库 MySQL 8.0.32(InnoDB,16C64G,innodb_buffer_pool_size=48G),核心表 t_billing_item 约 1.8 亿行。表上有 5 个二级索引,其中最关键的一个是 idx_tenant_time (tenant_id, created_at)——当初按「90% 查询都带 tenant_id」的假设建的。
最近 3 个月,业务从 100 个租户涨到 4.2 万个租户,而且分布严重长尾:5 个头部租户占全表约 62% 的数据,剩下 4.2 万个小租户瓜分剩下的 38%。我们开始观察到一批「说不清为什么」的慢查询。
现象
SQL-A:头部租户近 7 天账单(约 1100 万行命中)
SELECT item_id, amount, status
FROM t_billing_item
WHERE tenant_id = <big_tenant> AND created_at BETWEEN ? AND ?
ORDER BY created_at DESC LIMIT 50;
EXPLAIN 走 idx_tenant_time,type=range, rows≈1100 万,P99 从 3 个月前的 8ms 涨到 680ms。
SQL-B:小租户最近 20 条(约 200 行命中)
SELECT item_id, amount, status
FROM t_billing_item
WHERE tenant_id = <small_tenant>
ORDER BY created_at DESC LIMIT 20;
EXPLAIN 走 idx_tenant_time,rows≈200,P99 稳定 2ms——同一个索引、同一张表,两种截然不同的成本量级。
我试过把 idx_tenant_time 反过建成 idx_time_tenant (created_at, tenant_id),单测「近 7 天所有租户的账单」(不带 tenant_id 过滤)这条 SQL 从 1.2s 降到 40ms,但「大租户近 7 天」反而从 680ms 涨到 2.4s——列序一改,谁快谁慢完全反过来了。
已尝试的排查与结果
ANALYZE TABLE t_billing_item跑过,information_schema.INNODB_TABLES里N_DB_STATS是新的,但n_diff_p000 / n_diff_p050 / n_diff_p999这几个分布指标我没系统看过——我怀疑优化器的 rows 估算在长尾分布下严重偏低,但没验证。EXPLAIN FORMAT=JSON对比两个列序:SQL-A 走(tenant, time)cost≈4200,走(time, tenant)cost≈18000;SQL-B 反过来(tenant, time)cost≈30,(time, tenant)cost≈9500。cost 差 4~6 倍,但我不知道这个 cost 在长尾分布下是不是可信的。- 参考了社区里讲「选择性分析」的几篇(陈若衡《给 status 加索引反而更慢》id=89、林砚舟《加了索引还是慢:索引选择性、覆盖索引与回表成本的一次量化拆解》id=732),都是单条 SQL 视角讲「该不该加、加哪个字段」,没讲「同一张表上不同租户的选择性差 3 个数量级时,列序该听谁的」。
- 也想过是不是 buffer pool 被大租户的热页挤掉小租户的——查了
Innodb_buffer_pool_readsdelta 和SHOW ENGINE INNODB STATUS的 hit rate,没有明显异常(hit rate 0.9987),所以基本排除是缓存问题,更像是索引结构本身在长尾分布下不再匹配业务查询。
期望得到的答案形态
问题 1:有没有一套可量化的方法判断「长尾分布下 (A, B) vs (B, A) 该选哪个」? 我期望的答案里最好包含:
- 具体的 SQL /
performance_schema视图,能拉出「每个 tenant 的行数分布」和「每个 tenant 在最近 N 天内的行数分布」——我目前只知道sys.schema_index_statistics给的是索引级的平均,没有索引值级的分位数; - 一个阈值或规则(比如「头部 1% 租户占比 > X% 且查询模式以「单租户 + 时间窗」为主时,(tenant, time) 优先;否则 (time, tenant) 优先」),而不是「看情况」;
- 如果两个列序都不能满足,该不该拆成两个索引 / 建一个覆盖索引 / 上 RANGE 分区——各自的前提条件、写入放大、查询 P99 账本。
问题 2:优化器在长尾分布下,rows 估算失真到什么程度算「不可信」? 有没有可观测的信号能帮我判断「这个 cost 是假的」?比如:
performance_schema.table_io_waits_summary_by_index_usage里COUNT_READ和SUM_TIMER_WAIT的比值,跟 EXPLAIN 的 rows 估算对比,偏差超过多少倍就该怀疑?- 或者
HANDLER_READ_RND_NEXT/HANDLER_READ_KEY的比值,能不能作为「实际扫描行数 vs 估算行数」的代理指标?
问题 3:有没有实战案例——同一张表上,两个列序的索引并存 vs 只留一个 vs 拆成多个? 我特别想看到带数字的账本:写入 QPS 多少、每个索引的维护成本、查询 P99 分别多少。我自己在 id=878 那篇里算过「覆盖索引的 buffer pool 代价」,但列序选择的代价账还没人算过。
我倾向的答案形态:一个可复现的判断流程(SQL + 阈值 + 决策树),而不是「建议加个索引」或「建议拆表」。我 15 年里最怕的就是「看情况」三个字——这次想逼一个具体答案。