欢迎光临
我们一直在努力

聊天记录翻页为什么会重复或漏消息?复合游标与实时数据边界

向上加载历史消息时,第一批是 105、104、103,第二批却又出现 103。另一种情况更隐蔽:几条消息时间相同,翻页后其中一条再也没出现。

这两类现象未必来自界面渲染。即使前端没有重复追加,请求之间变化的数据和不完整的排序规则,也能让分页发生偏移。本文用一个可运行的 SQLite 示例,分开解释位置偏移、同时间记录与实时数据的边界。

米米商聊的手机端产品资料描述了文字聊天记录搜索与上下文定位,这为讨论历史记录的连续读取提供了背景。本文不推断其分页协议或数据库;下文的表结构、游标和代码均为独立教学示例。

1. OFFSET 记的是当前位置,新增和删除会改变这个位置

假设按消息从新到旧排列,每次读取三条。第一次得到 105、104、103。此时新消息 106 插到列表前面,第二次跳过三条后得到 103、102、101,103 就被读了两次。

如果第一次之后删除最前面的 105,当前列表变为 104、103、102、101、100。仍然跳过三条,下一批从 101 开始,原本未读取的 102 被跳过了。

这不是 SQL 把 OFFSET 算错了,而是两次请求看到的列表不同。固定数据集上的 OFFSET 仍然适合明确的页码跳转;实时变化列表需要另行决定连续读取的规则。

页间变化继续跳过三条的结果原因
前面插入一条新消息 第二批可能重复已读记录 原有记录的位置向后移动
前面删除一条消息 第二批可能跳过未读记录 后续记录的位置向前移动
数据不变但只按非唯一时间排序 页边界缺少确定顺序 同时间记录之间没有最后的排序依据

SQLite 对 ORDER BY 的说明指出,全部排序表达式都相等时,记录间顺序未定义。分页前应先把顺序定义完整。

2. 用完整排序键说明“从哪里继续”

本例为每条消息保存一个不为空的整数时间 sent_at_us,再用唯一且不复用的正整数 id 打破同时间记录的平局。两列均按降序排列,且约定入库后不修改这两个排序值。

例如同一时间有 104、103、102,第一页停在 (时间, 103)。只用“时间小于该时间”会漏掉 102;用“时间小于等于该时间”又会包含已经读取的 104、103。

继续条件应是:时间更早,或者时间相同且编号更小。在 SQLite 中可以写成 (sent_at_us, id) < (?, ?)。这是与双列降序相匹配的严格边界。Row Values 解释了从左到右比较的语义。

复合游标与OFFSET页间新增对照

图:复合游标保留完整的排序边界;同时间记录按编号继续读取。此图为独立技术示意,不是产品界面或实际协议。

游标还要绑定本次查询的会话。本例直接包含 conversation_id;若切换会话,旧游标应被拒绝。实际系统如果增加搜索词、筛选条件或排序方式,也需要将这些条件与游标版本绑定。

3. 一个可以复现的最小实现

下面选择 SQLite STRICT 表保证列类型,时间采用非负整数微秒值,示例数据的 1000、2000、3000 只是便于观察的合成值。id 在本例中全表唯一,但大小仅用于同时间排序,不代表消息到达或提交先后。

CREATE TABLE messages (
id INTEGER PRIMARY KEY CHECK (id > 0),
conversation_id INTEGER NOT NULL CHECK (conversation_id > 0),
sent_at_us INTEGER NOT NULL CHECK (sent_at_us >= 0),
body TEXT NOT NULL
) STRICT;

CREATE INDEX messages_page
ON messages (conversation_id, sent_at_us DESC, id DESC);

STRICT 表需要 SQLite 3.37.0 或更新版本,参见 STRICT Tables。该联合索引与会话过滤、两列排序对应;索引是否被采用仍应检查实际查询计划与数据规模,不把建索引等同于性能测试。

下面的函数返回消息、下一页游标和 has_more。多查询一条判断当次是否还有结果,游标取实际返回批次的最后一条,不能取那条用于探测的额外记录。

import sqlite3

MAX_I64 = (1 << 63) – 1

def require_int(value, minimum=0, maximum=MAX_I64):
if type(value) is not int or not minimum <= value <= maximum:
raise ValueError("integer out of range")
return value

def fetch_older(conn, conversation_id, *, cursor=None, limit=3):
# 调用方须先验证当前用户可以读取该会话。
cid = require_int(conversation_id, 1)
require_int(limit, 1, 100)
params = [cid]
where = "conversation_id = ?"

if cursor is not None:
keys = {"conversation_id", "sent_at_us", "id"}
if type(cursor) is not dict or set(cursor) != keys:
raise ValueError("invalid cursor shape")
cursor_cid = require_int(cursor["conversation_id"], 1)
stamp = require_int(cursor["sent_at_us"])
message_id = require_int(cursor["id"], 1)
if cursor_cid != cid:
raise ValueError("cursor belongs to another conversation")
where += " AND (sent_at_us, id) < (?, ?)"
params.extend([stamp, message_id])

rows = conn.execute(
"SELECT id, conversation_id, sent_at_us, body FROM messages "
"WHERE " + where + " ORDER BY sent_at_us DESC, id DESC LIMIT ?",
[*params, limit + 1],
).fetchall()
has_more = len(rows) > limit
items = rows[:limit]
next_cursor = None
if has_more:
last = items[–1]
next_cursor = {
"conversation_id": last[1],
"sent_at_us": last[2],
"id": last[0],
}
return {"items": items, "next_cursor": next_cursor,
"has_more": has_more}

查询值全部通过占位符绑定;字符串组合只用于代码内固定的 SQL 片段,依据 Python sqlite3 参数绑定说明。校验用 type(value) is int,避免将 Python 布尔值当作整数接受,并限制 SQLite 整数范围。

这里的字典是教学游标,未实现网络传输、签名或权限检查。编码成字符串不会使它自动成为可信凭证;服务端每次请求都应核验会话访问权,并绑定相同的查询条件。若前端采用 JavaScript,传递可能超过安全整数范围的标识和微秒值时,还需定义无损的十进制字符串解析规则,不能直接依赖浮点 Number。

4. 用相同时间和新消息复现差异

将第一段 SQL 原样赋给 Python 字符串 SCHEMA,执行第二段的函数定义,再运行下面的 Python 代码。连接使用 isolation_level=None,每条写入立即提交;各分页查询读取当时的已提交状态,没有跨页保持同一事务快照。

conn = sqlite3.connect(":memory:", isolation_level=None)
conn.executescript(SCHEMA) # SCHEMA 为上面的建表 SQL 字符串
conn.executemany("INSERT INTO messages VALUES (?, ?, ?, ?)", [
(105, 7, 3000, "最新"),
(104, 7, 2000, "同时间 A"),
(103, 7, 2000, "同时间 B"),
(102, 7, 2000, "同时间 C"),
(101, 7, 1000, "更早 A"),
(100, 7, 1000, "更早 B"),
])

first = fetch_older(conn, 7)
assert [row[0] for row in first["items"]] == [105, 104, 103]
assert first["next_cursor"] == {
"conversation_id": 7, "sent_at_us": 2000, "id": 103
}

conn.execute("INSERT INTO messages VALUES (?, ?, ?, ?)",
(106, 7, 4000, "页间新增"))
offset_ids = [row[0] for row in conn.execute(
"SELECT id FROM messages WHERE conversation_id = ? "
"ORDER BY sent_at_us DESC, id DESC LIMIT 3 OFFSET 3", (7,)
)]
assert offset_ids == [103, 102, 101]

second = fetch_older(conn, 7, cursor=first["next_cursor"])
assert [row[0] for row in second["items"]] == [102, 101, 100]
assert second["has_more"] is False
assert second["next_cursor"] is None
conn.close()

结果展示了两个关键点:同时间的 102 没有被漏掉;插在游标之前的新消息 106 没有推移继续读取的起点。这里的“之前”指降序列表中更靠前的位置。游标值本身就能参与比较,即使边界消息后来被删除,也不需要先查找那条消息才能继续。

本次验证从文章原样提取一个 SQL 和两个 Python 代码块,在本地执行,另覆盖空会话、页大小、末页、同时间记录、会话隔离、边界删除、OFFSET 新增与删除偏移、非法游标、整数边界和排序键变动等情况。验证结果与具体范围见下一节;这些是本地教学数据库结果,不是产品客户端测试。

5. 复合游标解决了位置偏移,没有冻结数据

稳定排序键下,游标查询不会因为列表前面新增或删除记录而重复计数位置,但每次查询仍然能看到新的已提交数据。

变化本例后续读取的行为需要另外决定的策略
新记录排在当前游标之前 继续读旧记录时不会看到它 通过新消息入口、刷新或增量同步补齐
补录记录排在当前游标之后 后续可能读到它 接受实时补录,或使用真正的快照
删除已读边界记录 游标仍可继续比较 界面另行同步删除状态
未读记录被移到已读区域 继续读时可能漏掉它 排序键保持不变,或重建读取范围
已读记录被移到未读区域 后续可能再次读到它 排序键保持不变,并按标识合并

因此“完全不重不漏”必须带条件:固定数据集、唯一完整顺序和不变的排序键。客户端按消息标识去重可以减少重复展示,但不能修补服务端从未返回的记录。

首次记住一个最大时间或最大排序键,只能挡住某一侧的新增;晚到消息仍可能带更早的时间,删除和修改也没有被隔离。要求导出某一时点的完整记录时,需要数据库快照或保存确定的结果集合,并考虑持续时间与资源成本,不能把一个上界字段叫作完整快照。SQLite 的事务快照语义可参考 Isolation In SQLite。

has_more=False 也只代表本次查询没有探测到额外结果。后续新增更早记录时,这个结论可能改变;本例末页不返回继续游标,重新加载由调用方显式发起。分页函数不承担实时同步。

另一个常见错位来自展示顺序:数据库从新到旧返回一批历史数据,聊天界面可能需要将该批反转后插到旧消息区域。继续游标应保留数据库返回批次的最后一条,不能在反转后重新用数组尾部计算。保持滚动锚点和防止重复请求属于界面层问题,也应独立处理。

6. 验证范围与实现检查项

本次执行环境为 Python 3.12.14、SQLite 3.53.1,共完成 17 类本地验证;其中包含两个真实数据库连接之间提交新增记录后,另一连接按游标继续查询的检查。静态数据全量遍历结果逐项等于一次完整降序查询,且无重复标识。查询计划检查确认本例分页查询采用 messages_page 索引;没有做吞吐量、延迟或大数据基准测试。

实际接入时,先明确四件事:排序键是否唯一且不变,游标是否绑定相同查询条件,分页读取是否允许页间变化,以及界面如何接收新消息并保存滚动位置。本例没有验证真实接口、网络重试、前端滚动、权限服务、跨设备同步或产品内部实现。

创作说明:本文由 AI 辅助阅读官方技术资料、起草和制作示意图;示例代码与本地验证由 AI 编写并执行。产品事实仅采用已有手机端资料,通用方案和验证结果不代表产品使用这些技术。

参考资料:

  • SQLite Row Values:行值比较与滚动窗口查询
  • SQLite SELECT:ORDER BY 与 LIMIT
  • SQLite STRICT Tables:类型与版本要求
  • Python 3.12 sqlite3:连接、参数绑定与事务控制
赞(0)
未经允许不得转载:171主机测评 » 聊天记录翻页为什么会重复或漏消息?复合游标与实时数据边界
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址