即时通讯项目中怎么实现历史消息的下拉分页加载?

Sherwin.Wei Lv7

即时通讯项目中怎么实现历史消息的下拉分页加载?

业务场景

一般在即时通讯项目(比如聊天室)中,我们会采用下拉分页的方式让用户加载历史消息记录。

区别于标准分页每次只展示当前页面的数据,下拉分页加载是 增量加载 的模式,每次下拉时会请求加载一小部分新数据,并放到已加载的数据列表中,从而形成无限滚动的效果,确保用户体验流畅。

比如用户有 10 条消息记录,以 5 条为单位进行分页,刚进入房间时只会加载最新的 5 条消息:

下拉后,会加载历史的第 6 - 10 条消息:

理解了业务场景后,再看下实现方案,为什么不建议使用传统分页实现下拉加载?

传统分页的问题

在传统分页中,数据通常是 基于页码或偏移量 进行加载的。如果数据在分页过程发生了变化,比如插入新数据、删除老数据,用户看到的分页数据可能会出现不一致,导致用户错过或重复某些数据。

举个例子,对于即时通讯项目,用户可能会持续收到新的消息。如果按照传统分页基于偏移量加载,第一页已经加载了第 1 - 5 行的数据,本来要查询的第二页数据是第 6 - 10 行(对应的 SQL 语句为 limit 5, 5),数据库记录如下:

结果在查询第二页前,突然用户又收到了 5 条新消息,数据库记录就变成了下面这样。原本的第一页,变成了当前的第二页!

这样就导致查询出的第二页数据,正好是之前已经查询出的第一页的数据,造成了消息重复加载。所以不建议采用这种方法。

推荐方案 - 游标分页

为了解决这种问题,可以使用游标分页。使用一个游标来跟踪分页位置,而不是基于页码,每次请求从上一次请求的游标开始加载数据。

一般我们会选择数据记录的唯一标识符(主键)、时间戳、或者具有排序能力的字段作为游标。比如即时通讯系统中的每个消息,通常都有一个唯一自增的 id,就可以作为游标。每次查询完当前页面的数据后,可以将最后一条消息记录的 id 作为游标值传递给前端(客户端)。

当要加载下一页时,前端携带游标值发起查询,后端操作数据库从 id 小于当前游标值的数据开始查询,这样查询结果就不会受到新增数据的影响。

对应的 SQL 语句为:

1
2
3
4
SELECT * FROM messages
WHERE id < :cursorId
ORDER BY id DESC
LIMIT 5;

扩展知识

使用游标的优点

使用游标分页除了能解决数据不一致(数据重复)的问题,还能起到性能优化的作用。

游标分页通常比基于偏移量(Offset)的分页更高效,因为传统的偏移分页需要跳过一定数量的记录,随着偏移量的增加,查询性能会下降。而游标分页只需要查询自上一个游标之后的记录,可以利用数据库索引进行快速定位,直接跳到该值的位置,从而减少了数据库的扫描和处理负担,提高了查询速度。

游标分页的应用场景

游标分页的应用场景很多,特别适用于增量数据加载、大数据量的高性能查询和处理。除了 IM 系统获取历史消息记录之外,常见场景还有社交媒体信息流、内容推荐系统、数据迁移备份等等。

如何选择游标字段

一般游标字段要具备以下特性:

  1. 唯一性:要确保在分页过程中能够准确地定位记录。
  2. 排序稳定性:游标字段的排序结果要相对稳定,避免分页过程中数据的丢失或重复。
  3. 性能:游标字段一般要设置为索引,以确保分页操作的高效。
  4. 避免频繁变化:避免使用频繁变化(实时更新)的字段,减少分页时记录丢失或重复的情况。

对于即时通讯项目的消息记录,除了 ID 之外,还可以选择时间戳作为游标。因为 IM 系统中,消息通常是按照时间顺序排列和查询的,时间戳提供了一种自然的排序方式,能够避免由于数据插入或删除造成的数据不一致问题。但是也要注意,如果时间戳的精度不够(如秒级),高并发情况下可能会出现时间戳重复,从而导致分页结果中的数据丢失或重复。

所以一种更规范的方案是,使用 时间戳加 ID 字段作为复合游标。在时间戳相同的情况下,通过 ID 确保唯一性和稳定性。

示例 SQL 语句如下:

1
2
3
4
SELECT * FROM messages
WHERE (timestamp < :cursorTimestamp OR (timestamp = :cursorTimestamp AND id < :cursorId))
ORDER BY timestamp DESC, id DESC
LIMIT 10;
Comments