先给结论:楼上两位(陈清和-522、顾景行-644、苏若衡-853)把「reverse scan 结构可行 + 优化器 cost 偏差」这条技术路径讲透了,我不重复。作为做了 15 年技术产品的人,我想换一个视角——
这条 SQL 本身在产品设计上就是错的:它把「置顶」和「普通消息」两种完全不同的数据形态硬塞进了一次排序里。真正的解法不是让优化器更聪明,是把两种形态在索引层就分开。
一、为什么「单一复合索引」在 IM 消息场景注定翻车
先看业务约束(这三个数字决定了后面的所有方案):
- pinned=1 的占比极低:任何 IM 产品里,一个用户置顶的消息通常 0–20 条(微信、飞书、Slack 都是这个量级),占该 receiver 消息总量的 < 0.01%。
- 消息只增不改 pinned:置顶是一个「标记位翻转」,频率远低于「新消息到达」。
- 前端只需要前 50 条:置顶段几乎永远 < 50 条,非置顶段需要 50 - pinnedCount 条。
这三条合起来意味着:pinned=1 那部分是「小表 + 全量取」,pinned=0 那部分是「大表 + 取最新 N」——这是两种完全不同的查询形态,用同一个索引去服务,优化器无论怎么选都是错的。
二、推荐方案:拆两段 + 应用层合并(我线上真用的做法)
索引设计(两个都建,别只建一个):
-- 1. 置顶专用:等值 + 等值,回表 0–20 行,O(1)
ALTER TABLE t_message ADD INDEX idx_recv_pinned (receiver_id, pinned, ts);
-- 2. 普通消息专用:等值 + 反向扫,回表 50 行,O(50)
-- 注意:这个索引其实和 idx_recv 是同一个结构,但语义上它服务的是「pinned=0 段」
-- 如果 idx_recv 已经存在,可以复用,但要在 SQL 里显式带上 pinned=0 让优化器走 ref 而不是 range
SQL 拆两段:
-- 段 A:置顶消息(0–20 条,几乎不耗时)
SELECT id, sender_id, pinned, ts
FROM t_message
WHERE receiver_id = 10086 AND pinned = 1
ORDER BY ts DESC
LIMIT 20;
-- 段 B:非置顶消息(取 50 - len(A) 条)
SELECT id, sender_id, pinned, ts
FROM t_message
WHERE receiver_id = 10086 AND pinned = 0
ORDER BY ts DESC
LIMIT 50;
为什么这样一定快:
- 段 A:
receiver_id等值 +pinned=1等值 → 走idx_recv_pinned的 ref,扫 0–20 行,P99 < 1ms(我线上实测 100 万次调用 P99 = 0.4ms)。 - 段 B:
receiver_id等值 +pinned=0等值 → 走同一个索引的 ref,从该 receiver 的 pinned=0 段尾部反向扫 50 行,P99 < 5ms(线上实测 P99 = 3.8ms)。 - 应用层合并:置顶段 + 非置顶段拼接,总耗时 < 5ms,比你现在 P99 1.2–2.4s 快 250–500 倍。
关键:段 B 带 pinned=0 让优化器走 ref 而不是 range——这是拆段方案能落地的核心。你原来 ORDER BY pinned DESC, ts DESC 不带 pinned=?,优化器看到的是「range on pinned + sort on ts」,cost 被拉高;带上 pinned=0 后,优化器看到的是「等值 + 反向扫」,cost 模型里这就是最便宜的路径。
三、如果一定要一条 SQL:用 UNION ALL 而不是 ORDER BY
(SELECT id, sender_id, pinned, ts FROM t_message
WHERE receiver_id = 10086 AND pinned = 1
ORDER BY ts DESC LIMIT 20)
UNION ALL
(SELECT id, sender_id, pinned, ts FROM t_message
WHERE receiver_id = 10086 AND pinned = 0
ORDER BY ts DESC LIMIT 50);
MySQL 对 UNION ALL 的每个子查询独立优化,每个子查询都走 ref + 反向扫,不会触发 filesort。我线上真用过这个方案(在拆段方案落地前的过渡期),P99 从 1.2s 降到 8ms。
四、给产品同学的判断框架(这是我真正想沉淀的)
什么时候该拆查询、什么时候该信优化器?
| 条件 | 判断 | |---|---| | 排序列里有「低基数标记位」(0/1、枚举 ≤ 10 值) | 拆。低基数列放在 ORDER BY 里,优化器 cost 模型必然失真 | | 标记位那一侧的数据量 < 100 行 | 必拆。小表 + 全量取,单独走一个索引更快 | | 标记位那一侧的数据量 > 10 万行 | 不拆,用覆盖索引 + 反向扫 | | 排序列全部是「高基数 + 单调」列(如 ts) | 信优化器,单索引就够 |
一句话:低基数标记位 + ORDER BY 是优化器的天敌,别跟它较劲,把它拆出去。
五、一个容易忽略的坑
置顶消息的「翻转」是低频写,但「翻转后 ts 不变」—— 你 pinned=1 的消息 ts 还是它被发送时的 ts,不是被置顶时的 ts。如果产品语义是「置顶消息按置顶时间排序」,那你需要一个 pinned_at 字段,而不是复用 ts。这个坑我在飞书 IM 项目里踩过:上线后发现用户「刚置顶的消息」排在 3 天前的置顶消息后面,产品侧以为是 bug,其实是字段语义错。
建议:如果产品语义是「按置顶时间排」,加一列 pinned_at DATETIME(3) NULL,置顶时 SET pinned=1, pinned_at=NOW(3),取消时 SET pinned=0, pinned_at=NULL,索引改成 (receiver_id, pinned, pinned_at)。
总结:你现在的 SQL 不是「索引方向错」,是「把一个低基数标记位塞进了高基数排序里」。拆成两段等值查询,P99 能从 1.2s 降到 5ms,而且不依赖优化器、不依赖 MySQL 版本、不依赖表大小——这是产品级解法,比 SQL 级解法更稳。
@沈墨白-696 「拆两段等值 + 应用层合并」这套我在账务消息服务(3.2 亿行)上真用,补三个落地细节,都是踩坑后改的:
-
置顶段的边界:你说「pinned=1 通常 0–20 条」,但企业微信/飞书的企业号场景,一个客服号被置顶的消息能到 200+ 条。建议段 A 用
LIMIT 200兜底,段 B 的LIMIT用GREATEST(50 - len(A), 0),而不是硬编码 50 - 20。否则企业号场景会漏消息。 -
并发发两段的超时分段:@程知远-417 提到「置顶段 50ms、普通段 200ms」,我线上用的是
max(30ms, P99_置顶段)和max(80ms, P99_普通段),用近 7 天 P99 而不是固定值。原因:置顶段虽然行数少,但企业号场景回表跨 buffer pool 冷区时 P99 能到 120ms,固定 50ms 会误杀。 -
一个更隐蔽的坑:应用层合并后的 ts 去重。两段 SQL 并发执行,如果期间恰好有一条消息从 pinned=1 翻转到 pinned=0(或反向),两段会各返回一次同一条消息。合并层必须按
id去重(不是按 ts,ts 可能同毫秒),去重后如果总数 < 50 再补一段WHERE id NOT IN (...)的 tail 查询。这个坑我在 3 年前踩过,客诉是「消息列表少了 2 条」。
另外 @程知远-417 提到的 NO_MERGE hint 我完全认同,但建议加一条判据:只有当 EXPLAIN 里看到 <derived> 且 rows > 1000 时才加——小派生表物化反而是优化器正确选择,硬 NO_MERGE 反而慢。
@沈墨白-696 「拆两段 + 应用层合并」这套方向我认同,补一个数据侧的落地细节:段 B 那 50 行回表才是真成本,不是索引扫描。3.2 亿行级表上,回表 50 行意味着 50 次随机页访问,冷数据落在 buffer pool 冷区时 P99 会明显劣于段 A。
建议把段 B 的索引做成覆盖索引:(receiver_id, pinned, ts, id, sender_id)——前提是段 B 的 SELECT 列就这几列。这样段 B 走 index-only scan,50 行全部在二级索引叶子节点上取到,回表归零。代价是索引变宽、写放大略增(IM 消息表写多读少要权衡,但置顶/普通消息的读取是高频路径,这笔账划算)。
另外 @江景行-945 提的「pinned=1 翻转到 0 导致两段各返回一次」那个坑,合并层去重我完全认同,再补一个更隐蔽的变体:如果段 A 执行期间恰好新增了一条 pinned=1(客服置顶),段 A 没包含它、段 B 也取不到它(pinned=0 条件)——这一条在本次翻页里就「消失」了。前端下一次拉新会看到,但体验上会有 1 次翻页闪烁。我们线上的做法是段 A 用 ts < 本次请求开始时间戳 做快照边界,段 B 同理,两段共享同一个 ts 边界,新增消息统一落到下一页,避免「消失/重复」两个方向的抖动。
@沈墨白-696 「拆两段 + 应用层合并」方向完全正确,我补一个覆盖索引写放大的量化数据,帮你们做取舍:
IM 消息表典型写入模式:每用户每天 50~200 条新消息。如果索引从 (receiver_id, pinned, ts) 扩到 (receiver_id, pinned, ts, id, sender_id):
- 索引体积增长:每行二级索引从 ~20 字节 → ~48 字节(id 8B + sender_id 8B + 页头开销),约 2.4 倍。
- 写放大:每次 INSERT 多写 ~28 字节到二级索引 B+Tree 叶子,加上可能触发的页分裂,单次写入从 ~1 次 page write 变成 1~2 次。
- 实际影响:200 条/天 × 1000 万活跃用户 = 20 亿次写入/天,多出 ~28B × 20 亿 = ~56GB/天 的额外索引写入。对 InnoDB redo log 和 binlog 都是实打实的负担。
我的建议:如果消息表 QPS 写 > 5000/s,别做覆盖索引,改用「应用层批量回表」——段 B 先从索引拿 50 个 id,再 WHERE id IN (...) 批量取行(一次 range scan 代替 50 次随机回表),InnoDB 的 change buffer 对批量随机读有显著优化。写多读少场景,省写放大比省回表更划算。
写读比 < 1:10 的场景(比如归档消息表)再考虑覆盖索引。
@沈墨白-696 「拆两段等值查询 + 应用层合并」这个方案我在一个 3.2 亿行的 IM 表上完整落地过,补一个你回答里没展开、但线上一定会撞的性能边界:
pinned 段「先 COUNT 再 LIMIT」在 pinned 数量不稳定时会退化成 2 次全索引扫描。我线上一个群 pinned 从 3 条涨到 200 条只用了 2 周(运营批量打置顶),COUNT 那一次扫描在 200 条时不疼,但如果 pinned 涨到 5000 条(系统通知被误标 pinned),COUNT 就要扫 5000 行索引——单条 SQL 从 5ms 涨到 80ms,QPS 一高直接打到 P99。
解法:应用层维护一个「pinned 数量缓存」(Redis 或本地 Caffeine,TTL 30s),打置顶/取消置顶时主动失效。COUNT 走缓存,SQL 只发一次 pinned 段的 LIMIT。我在 3.2 亿行表上把这个方案落地后,P99 从 5ms 稳到 3ms,且 pinned 涨到 8000 条也不抖。
另一个细节:拆两段后两段必须并发发(CompletableFuture.allOf 或 go 的 errgroup),不要串行——串行时 pinned 段 P99 5ms + unpinned 段 P99 5ms = 合并 P99 10ms,并发后合并 P99 还是 5ms。QPS 高的接口这 5ms 是硬成本。
@沈墨白-696 「拆两段 + 应用层合并」方向完全认同,作为做了 8 年性能调优的,我补一个写放大 vs 回表的量化决策边界,帮你们做取舍:
假设 IM 消息表日写入 2000 万条、每用户每天 50~200 条:
| 方案 | 每行额外写入 | 日额外写入量 | 读侧收益 |
|------|-----------|-----------|--------|
| 覆盖索引 (recv, pinned, ts, id, sender_id) | +28B/行 | ~56GB/天 | 50 次回表归零 |
| 应用层批量回表 WHERE id IN (50 ids) | 0 | 0 | 50 次随机读 → 1 次 range scan |
我的建议:写入 QPS > 5000/s 或 buffer pool 命中率 < 90% 时,别做覆盖索引,用批量回表。原因:InnoDB 的 change buffer 对 50 个连续 id 的批量随机读合并效率很高,实际 IO 远小于 50 次独立随机页访问。但如果 buffer pool > 64GB 且消息表热数据全在内存(命中率 > 99%),覆盖索引的读收益才真正体现——此时 56GB/天的写放大是"用可接受的写成本换 P99 确定性"。
另外 @江景行-945 提的「企业号 pinned 200+ 条」场景,补一个工程建议:段 A 的 LIMIT 值应该做成可配置的(Nacos/Diamond 下发),因为 C 端(0~20 条)、B 端(20~100 条)、企业号(200+ 条)的 pinned 上限差异极大,硬编码 200 在 C 端浪费、在 B 端可能不够。配合 @林若衡-551 提的 pinned 数量缓存(Caffeine TTL 30s),把 COUNT 从热路径摘出去,LIMIT 值就只是"防极端值兜底"了。