消息一多接口就慢:把 offset 分页换成游标分页

SailTrack
2026-06-11
点 赞
0
热 度
4
评 论
0
  1. 首页
  2. 后端开发
  3. 消息一多接口就慢:把 offset 分页换成游标分页

写在前面

IM 的消息列表是我项目里最先"长大"的表。测试阶段几千条消息,什么写法都很快;等到灰度数据灌进去几十万条,第一个疼的就是分页接口——翻到后面的页,越来越慢,而且偶尔出现"翻一页,有条消息出现过两次"。

排查清楚原因后,我把 IM 里所有列表接口都换成了游标分页。这篇把"为什么换"和"怎么换"一次说清。

一、offset 分页的两个原罪

LIMIT 20 OFFSET 10000 的写法,问题不在语法,在语义:

性能:offset 是"扫过再扔掉"。 数据库要真正读出前 10020 行、扔掉前 10000 行、返回最后 20 行。offset 越大,浪费越多——第一页 1 毫秒,第一百页可能 300 毫秒,接口性能随页深线性劣化,而用户恰恰最爱翻到底。

一致性:offset 踩在一个会动的地板上。 按时间倒序排,你看完第 1 页(第 1~20 条),这时来了 3 条新消息,原来的第 21 条被挤到了第 24 条。你翻到"第 2 页"(OFFSET 20),看到的是第 21~23 条——上一页已经看过的内容又出现了。反过来,删除消息会让某些行"消失",直接跳页。对于聊天记录这种"边看边变"的场景,这是致命的。

二、游标的核心思想:不数行,认行号

游标分页不问"第几页",而是问"比这条更早的 20 条":

第一页:按时间倒序取 20 条,记下最后一条的位置
下一页:WHERE 位置 < 上页最后一条  ORDER BY 位置 DESC LIMIT 20

每页的边界是一条具体数据的位置,而不是一个会漂移的行数。数据怎么增删,都不影响"比这条更早"这个谓词的判断——一致性问题从根上消失。性能上,(位置字段, 主键) 上的索引让这个 WHERE 变成一次精确的范围扫描,扫多少行就返回多少行,没有丢弃。

关键是"位置"用什么。要求很苛刻:单调、可比、稳定。IM 消息表里现成答案是 seq_no——会话内递增的序列号(它的生成是另一篇文章的话题)。

三、消息列表:seq_no 游标

完整实现(简化了逐用户删除的过滤):

SELECT * FROM (
    SELECT id, conversation_id, seq_no, sender_type, sender_id,
           message_type, content_json, sent_at
    FROM im_message
    WHERE conversation_id = ?
      AND seq_no < ?            -- 游标:上一页最后一条的 seq_no,首页传 NULL
      AND status <> 'DELETED'
    ORDER BY seq_no DESC
    LIMIT ?
) previous_page
ORDER BY seq_no ASC

三层意图都在这段 SQL 里:

seq_no < ? 是游标谓词。 首页 beforeSeq 传空,走"最新 N 条"分支;后续每页传上一页响应里带回来的最小 seq_no。因为 seq_no 在会话内严格递增,且永不复用,这个谓词的边界绝对稳定。

ORDER BY seq_no DESC LIMIT ? 是取数:倒序找"比游标早"的 N 条。

外层再 ORDER BY seq_no ASC 是归还。 这是个容易被忽略的体验细节:聊天记录在界面上是从旧到新显示的,取数必须倒序(新的在前才好分页),展示必须正序。内层倒序取、外层正序还,一次查询解决两个矛盾的需求。第一版我取回来在 Java 里 reversed(),后来发现数据库里排一次的成本约等于零,就删掉了那段尴尬的内存操作。

四、会话列表:三元组复合游标

消息列表的游标是单字段的,会话列表就复杂了。会话的排序规则是"置顶的在前,其余按最后一条消息时间倒序"——排序键有三个:pin_flag、last_message_at、id。游标必须完整复刻这个排序:

-- 游标 = (上一页最后一条的 pin_flag, last_message_at, conversation_id)
WHERE m.member_type = 'USER'
  AND m.member_id = ?
  AND m.status = 'ACTIVE'
  AND (
      m.pin_flag < ?                                      -- 从置顶区跨到普通区
      OR (
          m.pin_flag = ?
          AND (
              COALESCE(c.last_message_at, c.created_at) < ?
              OR (
                  COALESCE(c.last_message_at, c.created_at) = ?
                  AND c.id < ?                            -- 同一秒的并列,用 id 决胜负
              )
          )
      )
  )
ORDER BY m.pin_flag DESC, COALESCE(c.last_message_at, c.created_at) DESC, c.id DESC
LIMIT ?

三个教训都在这嵌套的条件里:

时间戳必须配一个决胜字段。 last_message_at 精度再高也有并列——两条消息同一秒落地太常见了。并列时 = 分支接不上,那一页边界就会丢数据或重复。所以游标永远是 (时间, 主键) 成对出现:时间排不出先后,就按 id 排。主键是所有排序的最后一块拼图。

游标的每个字段都要跟着排序走。 pin_flag 也进了游标——如果只记时间和 id,从"置顶区"翻页到"普通区"的瞬间,游标谓词就失效了。排序键有几个,游标就得有几个,一个都不能少。

边界情况也要有确定行为。 游标指向的会话被取消了置顶、甚至被删了怎么办?我的处理是取游标时顺带查它的最新 pin 状态:查不到了就返回空列表,让前端重新从第一页拉。游标失效时宁可返回空,也不要"猜一个近似位置"——猜错的结果就是重复或漏数据,比空列表难解释一百倍。

五、接口层的纪律

public List<ImConversationSummaryResponse> listCurrentEmployeeConversations(
        String beforeLastMessageAt, Long beforeConversationId, Integer size) {
    LocalDateTime beforeTime = parseConversationCursorTime(beforeLastMessageAt);
    if ((beforeTime == null) != (beforeConversationId == null)) {
        throw new BizException(400, "会话分页游标必须同时包含时间和会话ID");
    }
    ...
}

复合游标的参数必须成对出现——只传时间不传 id,等于让并列关系裸奔。这种校验放在接口入口,把 400 明确地打给调用方,比默默接受然后返回错乱的第二页友好得多。

另外注意响应里没有 total。游标分页天然不知道总数(要总数就得全表数一遍,性能优势归零),但这不是损失:无限滚动的界面根本不显示"共 10086 条",用户要的是"往下划还有没有"。做技术选型时先问交互形态——瀑布流用游标,后台报表的分页器才需要 offset。选型错配比技术本身更常见。

六、小结

三点体会:

  1. offset 的两个原罪都源于"行数是漂移的"。 游标把定位锚从"第 N 行"换成"某条具体数据之后",性能和一致性同时解决——这类一石二鸟的替换值得优先做。
  2. 游标的完整性等于排序键的完整性。 排序用了几个字段,游标就带几个字段,最后永远用主键兜底并列;游标指向的数据没了,宁可空页也不要猜。
  3. 选型跟着交互走。 无限滚动配游标,页码器配 offset,谁也别越界。先有交互设计,再有接口设计。

改造之后,几十万消息的会话翻到最深处,接口稳定在个位数毫秒——因为每一页的成本只和这一页的 20 条有关,和它是第几页无关。


让我们忠于理想,让我们面对显示

SailTrack

entp 辩论家

站长

不具版权性
不具时效性

文章内容不具时效性。若文章内容有错误之处,请您批评指正。

目录

欢迎来到SailTrack的站点,为您导航全站动态

65 文章数
11 分类数
5 评论数
60标签数