侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 135 篇文章
  • 累计收到 7 条评论

翻到第二页,第一页的三行又出现了

2026-9-27 / 0 评论 / 5 阅读

翻到第二页,第一页的三行又出现了

一个后台列表按 id 倒序翻页,每页 10 条。用户翻到第二页,说有三行在第一页见过;另一个用户说某几单在列表里怎么翻都找不到。数据没有重复行,接口也没改过,变的只是翻页那几秒里这张表的写入。

三个场景,两套分页

SQLite 里建一张 20 行的订单表,场景三扩到 60 行。先按 offset 翻两页,再按游标翻两页;写入发生在两次查询之间,不留等待。

-- offset 分页:锚点是第几行
SELECT id FROM orders ORDER BY id DESC LIMIT 10 OFFSET 10;

-- 游标分页:锚点是上一页最后那条的 id
SELECT id FROM orders WHERE id < :last_id ORDER BY id DESC LIMIT 10;

两条 SQL 的差别只有一个词:锚点。offset 的锚点由服务端每页现算,游标那份由客户端带回来。

场景一:翻页时新进了三单

场景一 offset 分页:翻第二页之前又进来 3 单(id 21-23)
  第一页 id: [20, 19, 18, 17, 16, 15, 14, 13, 12, 11]
  第二页 id: [13, 12, 11, 10, 9, 8, 7, 6, 5, 4]
  两页里重复出现的: [11, 12, 13]
  两页都没出现过的: [1, 2, 3, 21, 22, 23]

offset 这一套:第一页拿到 20 到 11,翻页前写进 id 21 到 23,第二页还是从第 11 行开始数,于是 13、12、11 又被捞了一次,两页里重复三行。

新进来的三单排在最前面,属于已经翻过去的位置,这一轮遍历看不到它们,刷新才出现。这一半不算错,倒序列表本来就是这个顺序。

游标这一套:第二页从 id 10 往下取十条,重复为空,漏也为空。

同一个场景,游标分页(WHERE id < 上一页最小的那个)
  第一页 id: [20, 19, 18, 17, 16, 15, 14, 13, 12, 11]
  第二页 id: [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
  两页里重复出现的: 无
  两页都没出现过的: 无

场景二:第一页里撤销了三单

场景二 offset 分页:翻第二页之前撤销了第一页里的 3 单(id 16-18)
  第一页 id: [20, 19, 18, 17, 16, 15, 14, 13, 12, 11]
  第二页 id: [7, 6, 5, 4, 3, 2, 1]
  两页里重复出现的: 无
  两页都没出现过的: [8, 9, 10]

这回漏的是数据。第一页显示过 20 到 11,翻页前把 16、17、18 删掉,表里剩 17 行,第二页仍从第 11 行开始数,正好跨过 10、9、8 三行。用户在页面上再也见不到它们,除非重新从第一页翻起。

两个方向可以合起来记:分母变大(不断有新写入)会造成重复,分母变小(删除或者筛选条件变化)会造成漏行。重复只是看着奇怪,漏行会让对账的人怀疑数据丢了。

场景三:连翻六页,写入一直没停

场景三 offset 分页,连翻 6 页,每翻一页之后写进 3 单:
  六页一共看到 60 行,去重后 45 行
  重复出现过的 id:[23, 24, 25, 30, 31, 32, 37, 38, 39, 44, 45, 46, 51, 52, 53]
同样的写入节奏,游标分页:
  六页一共看到 60 行,去重后 60 行
  重复出现过的 id:无

60 行的表,每翻一页写进三单,六页一共看到 60 行,去重之后只剩 45 行,重复出现过的 id 有 15 个。游标那一套同样是 60 行,去重还是 60 行。

翻的页数越多,漂移累计得越多。对接口设计来说,offset 分页的每一页之间不是一个一致的视图,它只是「此刻表的第 N 段」。

游标分页的代价

游标要求排序键唯一且稳定。按创建时间排序时,同一秒创建的两行需要一个 tie-breaker,通常把 id 一起放进游标条件。往前翻要额外存一份上一页的游标;跳页做不了,因为没有「第 50 页」这个概念。

大表上 offset 还要先跳过前面的行,代价随页数上涨,这一点没在百万行的表上实测过,只是常见的取舍理由。另一种场景游标也应付不了:导出和对账要的是一份完整快照,而写入一直在发生,翻到最后一页时表已经变了。要快照就得先把边界固定住,比如带上一个创建时间上限。

offset 还能用的场合

数据量小、翻页也不频繁的后台列表,offset 够用,SQL 也简单。判断可以压成一句:这张表在用户翻页的那几秒里会不会被写。会写就用游标,不会写 offset 更省事。

我倾向于在写列表接口时默认用游标,理由不是性能,是改动成本:后补一个时间上限容易,把锚点从「第几行」换成「某一条」要重写分页协议,前端也得跟着改。

    🤞 分享