ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

聊天记录翻页为什么会重复或漏消息?复合游标与实时数据边界 向上加载历史消息时第一批是105、104、103第二批却又出现103。另一种情况更隐蔽几条消息时间相同翻页后其中一条再也没出现。这两类现象未必来自界面渲染。即使前端没有重复追加请求之间变化的数据和不完整的排序规则也能让分页发生偏移。本文用一个可运行的 SQLite 示例分开解释位置偏移、同时间记录与实时数据的边界。米米商聊的手机端产品资料描述了文字聊天记录搜索与上下文定位这为讨论历史记录的连续读取提供了背景。本文不推断其分页协议或数据库下文的表结构、游标和代码均为独立教学示例。1. OFFSET 记的是当前位置新增和删除会改变这个位置假设按消息从新到旧排列每次读取三条。第一次得到105、104、103。此时新消息106插到列表前面第二次跳过三条后得到103、102、101103就被读了两次。如果第一次之后删除最前面的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 解释了从左到右比较的语义。图复合游标保留完整的排序边界同时间记录按编号继续读取。此图为独立技术示意不是产品界面或实际协议。游标还要绑定本次查询的会话。本例直接包含conversation_id若切换会话旧游标应被拒绝。实际系统如果增加搜索词、筛选条件或排序方式也需要将这些条件与游标版本绑定。3. 一个可以复现的最小实现下面选择 SQLite STRICT 表保证列类型时间采用非负整数微秒值示例数据的1000、2000、3000只是便于观察的合成值。id在本例中全表唯一但大小仅用于同时间排序不代表消息到达或提交先后。CREATETABLEmessages(idINTEGERPRIMARYKEYCHECK(id0),conversation_idINTEGERNOTNULLCHECK(conversation_id0),sent_at_usINTEGERNOTNULLCHECK(sent_at_us0),bodyTEXTNOTNULL)STRICT;CREATEINDEXmessages_pageONmessages(conversation_id,sent_at_usDESC,idDESC);STRICT 表需要 SQLite 3.37.0 或更新版本参见 STRICT Tables。该联合索引与会话过滤、两列排序对应索引是否被采用仍应检查实际查询计划与数据规模不把建索引等同于性能测试。下面的函数返回消息、下一页游标和has_more。多查询一条判断当次是否还有结果游标取实际返回批次的最后一条不能取那条用于探测的额外记录。importsqlite3 MAX_I64(163)-1defrequire_int(value,minimum0,maximumMAX_I64):iftype(value)isnotintornotminimumvaluemaximum:raiseValueError(integer out of range)returnvaluedeffetch_older(conn,conversation_id,*,cursorNone,limit3):# 调用方须先验证当前用户可以读取该会话。cidrequire_int(conversation_id,1)require_int(limit,1,100)params[cid]whereconversation_id ?ifcursorisnotNone:keys{conversation_id,sent_at_us,id}iftype(cursor)isnotdictorset(cursor)!keys:raiseValueError(invalid cursor shape)cursor_cidrequire_int(cursor[conversation_id],1)stamprequire_int(cursor[sent_at_us])message_idrequire_int(cursor[id],1)ifcursor_cid!cid:raiseValueError(cursor belongs to another conversation)where AND (sent_at_us, id) (?, ?)params.extend([stamp,message_id])rowsconn.execute(SELECT id, conversation_id, sent_at_us, body FROM messages WHERE where ORDER BY sent_at_us DESC, id DESC LIMIT ?,[*params,limit1],).fetchall()has_morelen(rows)limit itemsrows[:limit]next_cursorNoneifhas_more:lastitems[-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_levelNone每条写入立即提交各分页查询读取当时的已提交状态没有跨页保持同一事务快照。connsqlite3.connect(:memory:,isolation_levelNone)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),])firstfetch_older(conn,7)assert[row[0]forrowinfirst[items]][105,104,103]assertfirst[next_cursor]{conversation_id:7,sent_at_us:2000,id:103}conn.execute(INSERT INTO messages VALUES (?, ?, ?, ?),(106,7,4000,页间新增))offset_ids[row[0]forrowinconn.execute(SELECT id FROM messages WHERE conversation_id ? ORDER BY sent_at_us DESC, id DESC LIMIT 3 OFFSET 3,(7,))]assertoffset_ids[103,102,101]secondfetch_older(conn,7,cursorfirst[next_cursor])assert[row[0]forrowinsecond[items]][102,101,100]assertsecond[has_more]isFalseassertsecond[next_cursor]isNoneconn.close()结果展示了两个关键点同时间的102没有被漏掉插在游标之前的新消息106没有推移继续读取的起点。这里的“之前”指降序列表中更靠前的位置。游标值本身就能参与比较即使边界消息后来被删除也不需要先查找那条消息才能继续。本次验证从文章原样提取一个 SQL 和两个 Python 代码块在本地执行另覆盖空会话、页大小、末页、同时间记录、会话隔离、边界删除、OFFSET 新增与删除偏移、非法游标、整数边界和排序键变动等情况。验证结果与具体范围见下一节这些是本地教学数据库结果不是产品客户端测试。5. 复合游标解决了位置偏移没有冻结数据稳定排序键下游标查询不会因为列表前面新增或删除记录而重复计数位置但每次查询仍然能看到新的已提交数据。变化本例后续读取的行为需要另外决定的策略新记录排在当前游标之前继续读旧记录时不会看到它通过新消息入口、刷新或增量同步补齐补录记录排在当前游标之后后续可能读到它接受实时补录或使用真正的快照删除已读边界记录游标仍可继续比较界面另行同步删除状态未读记录被移到已读区域继续读时可能漏掉它排序键保持不变或重建读取范围已读记录被移到未读区域后续可能再次读到它排序键保持不变并按标识合并因此“完全不重不漏”必须带条件固定数据集、唯一完整顺序和不变的排序键。客户端按消息标识去重可以减少重复展示但不能修补服务端从未返回的记录。首次记住一个最大时间或最大排序键只能挡住某一侧的新增晚到消息仍可能带更早的时间删除和修改也没有被隔离。要求导出某一时点的完整记录时需要数据库快照或保存确定的结果集合并考虑持续时间与资源成本不能把一个上界字段叫作完整快照。SQLite 的事务快照语义可参考 Isolation In SQLite。has_moreFalse也只代表本次查询没有探测到额外结果。后续新增更早记录时这个结论可能改变本例末页不返回继续游标重新加载由调用方显式发起。分页函数不承担实时同步。另一个常见错位来自展示顺序数据库从新到旧返回一批历史数据聊天界面可能需要将该批反转后插到旧消息区域。继续游标应保留数据库返回批次的最后一条不能在反转后重新用数组尾部计算。保持滚动锚点和防止重复请求属于界面层问题也应独立处理。6. 验证范围与实现检查项本次执行环境为 Python 3.12.14、SQLite 3.53.1共完成 17 类本地验证其中包含两个真实数据库连接之间提交新增记录后另一连接按游标继续查询的检查。静态数据全量遍历结果逐项等于一次完整降序查询且无重复标识。查询计划检查确认本例分页查询采用messages_page索引没有做吞吐量、延迟或大数据基准测试。实际接入时先明确四件事排序键是否唯一且不变游标是否绑定相同查询条件分页读取是否允许页间变化以及界面如何接收新消息并保存滚动位置。本例没有验证真实接口、网络重试、前端滚动、权限服务、跨设备同步或产品内部实现。创作说明本文由 AI 辅助阅读官方技术资料、起草和制作示意图示例代码与本地验证由 AI 编写并执行。产品事实仅采用已有手机端资料通用方案和验证结果不代表产品使用这些技术。参考资料SQLite Row Values行值比较与滚动窗口查询SQLite SELECTORDER BY 与 LIMITSQLite STRICT Tables类型与版本要求Python 3.12 sqlite3连接、参数绑定与事务控制
返回列表