qinghuan
← 回到知识探索

后端现场 / 可下载小项目

翻到第二页,为什么又看见刚才那条记录?

用 SQLite 亲眼比较按位置翻页与按最后一条记录翻页,再证明游标本身不会冻结数据。

展开本文目录
  1. 下载完整练习包
  2. 先排清楚谁前谁后,再谈第几页
  3. “跳过两个”会随着名单一起移动
  4. 记住最后一人的排序值,而不是已读人数
  5. 每次只改一种情况,输出才解释得清
  6. 游标只保存位置,不保存过去的整份名单
  7. 需要固定导出时,另外决定读取哪一版
  8. 记住这三点
  9. 别把例子用过头
  10. 自己试一小步
  11. 查原始资料
  12. 找一份资料继续练
开始前,会这些就够了
  • 能运行 Python 3.10+ 文件
  • 认识表、记录和简单 SELECT 查询
遇到不熟悉的词,先看这 3 个解释
事务
把一组数据库操作放进同一个提交范围,让它们作为一件事被确认或撤销,并按数据库规则控制其他事务看到的变化;它帮助处理做到一半时的失败。
分页游标
这里的游标保存上一页最后一条记录的排序值。下一页从这个边界之后继续找,而不是再数一遍应跳过多少行。排序必须完整且一致,同值时也要有确定先后。
读取快照
可以先理解为:一段读取过程按某个确定的数据版本继续看。以后提交的变化是否可见,取决于数据库模式和事务规则,不能只从翻页参数猜出来。
打开完整术语小抄 ↗

假设你在看活动报名名单,每页两人。第一页刚显示完,后台有人插队进入这份排序,第二页竟再次出现刚才的人。不是数据库忘了翻页,而是“跳过前两人”面对的名单已经变了。

这次练习让每一个变化都能被看见:先保持名单不动,再分别插入、删除和改动排序值。程序使用 Python 自带的 SQLite,不联网,也不连接你的业务数据库。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,内置 SQLite 3.15.0 或更新版本。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 6 个文件
包里有什么
  • pagination.py:两种分页查询
  • demo.py:插入、删除与快照实验
  • verify.py:16 项边界检查
  • README.md:中文操作步骤
  • demo.expected.txt:演示输出对照
  • verification-result.json:制作时的验证记录

在这个文件夹打开 PowerShell,逐行运行

python --version
python demo.py
python verify.py

解压后在含 demo.py 的文件夹打开终端。每个场景从示例名单开始;程序只创建内存数据库和临时文件,不连接业务数据库。先看返回了哪些编号,再运行验证。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

先排清楚谁前谁后,再谈第几页

本例的记录有 id、created 和 title。created 是人为写入的整数排序值,不是真实时间测量。我们先按 created 从小到大排,同值再按唯一 id 从小到大排。这样编号 2 和 3 即使都写着 20,也有明确先后。

只写 ORDER BY created,同值记录之间仍缺少约定;某次看起来按 id 排,不代表 SQL 保证如此。想连续走完固定数据,就要为每条记录确定位置。本项目把排序列限定为非空整数,避免空值、时区和文字排序规则掺进第一轮实验。

“跳过两个”会随着名单一起移动

原名单按编号看是 1、2、3、4、5、6。第一页读到 1、2。如果插入一条排序值为 15 的编号 7,名单就变成 1、7、2、3、4、5、6;第二页仍跳过两个,读到的是 2、3。编号 2 重复出现了。

重新开始实验,只删除已经读过的编号 1,名单变成 2、3、4、5、6。第二页跳过两个后从 4 开始,尚未读过的 3 被越过去了。LIMIT 与 OFFSET 没有算错,它们在每次查询时都按当时的位置执行。

记住最后一人的排序值,而不是已读人数

第一页最后一条是 created=20、id=2。下一页要求 (created, id) 大于 (20, 2),再使用完全相同的排序。比较时先看 created,相等再看 id,因此编号 3 不会因为也写着 20 而被跳过。

这两个值就是本例的复合游标,像夹在排序边界上的书签。它不需要边界那一行一直存在:即使编号 2 被删除,保存的两个值仍能用于比较。只保存 created,再查 created>20,会漏掉尚未看过的编号 3。

每次只改一种情况,输出才解释得清

下载配套项目并解压,在含 demo.py 和 verify.py 的文件夹打开 PowerShell。先执行 python --version,确认 Python 3.10 或更新版本;再运行 python demo.py。输出中的 offset、cursor 分别对应按位置和按边界翻页。

接着运行 python verify.py。十六项测试覆盖同值排序、页尾、空表、边界记录删除、无效参数和真实的双连接读取。制作时验证记录只证明制作环境通过;你自己的运行结果还要看末尾是否为 OK。

小实验 · text
python demo.py

怎样运行:Python 3.10+,内置 SQLite 3.15.0+;在解压后的 pagination-workshop 文件夹执行。

运行后对照结果
SQLite lesson; no network; no timing benchmark.
initial first page: [1, 2]
cursor (created, id): (20, 2)
time-only cursor: [4, 5]
composite cursor: [3, 4]
insert before / offset: [2, 3]
insert before / cursor: [3, 4]
delete before / offset: [4, 5]
delete before / cursor: [3, 4]
new row after boundary / cursor: [3, 8]
seen row moves after boundary: [3, 4, 1, 5]
unseen row moves before boundary: [3, 5, 6]
same WAL read transaction: [3, 4]
after read transaction ends: [3, 8]
temporary database cleaned up: True

游标只保存位置,不保存过去的整份名单

读完第一页后,若插入 created=25 的编号 8,它位于游标后面,下一页可以立刻读到它。若后来新增的数据排在游标前面,本轮向后翻页又可能永远不遇到它。游标没有承诺“精确重现第一次打开页面时的所有记录”。

排序值可修改时,问题更明显:已经看过的编号 1 移到游标后面,可以再次出现;未看过的编号 4 移到游标前面,可以被漏过。即使排序值不变,标题等内容也可能变。要解释结果,必须把允许哪些更新写进约定。

需要固定导出时,另外决定读取哪一版

项目最后用临时文件和两个连接演示 SQLite WAL:读者显式开始事务并读完第一页,写者随后提交新记录;读者继续在原事务里翻页,仍读旧版本。结束这个读取事务后再查,才看到新记录。保持版本的是该读取事务,不是那两个游标数字。

网页停留多久往往不受服务端控制,因此不能把这个短实验直接改成“每个用户打开页面就一直留着事务”。导出可以在受控的读取范围内生成文件,页面浏览则明确采用实时列表等规则。具体选择还要考虑数据库、资源占用和业务允许的数据变化;本项目没有实现生产导出服务。

记住这三点

  • 先定义唯一、完整且一致的排序,再保存最后一条的全部排序值。
  • 前面的记录增删后,同一个 OFFSET(跳过条数)可能读到不同记录;排序值保持稳定时,游标可避开这类位置变化。
  • 游标与快照解决不同问题,排序键修改、内容修改和新记录仍要规定。

别把例子用过头

  • 本例只实现非空整数、双列升序、向后翻页;不能直接套到升降混排或空值排序。
  • 游标不是任意跳到第几页的页码,也不是权限凭证;项目没有 HTTP 接口。
  • 测试检查返回数据,没有测量查询加速倍数或模拟断电。

自己试一小步

如果把列表改成先按 created 降序、再按 id 降序,该怎样改下一页条件,并验证同值边界?

  1. 先手写六条初始记录的目标顺序,不运行程序猜结论。
  2. 复制查询函数作为新实验,同时检查排序和边界比较是否一致。
  3. 补上同值、最后一页、游标行被删除的测试,再记录插入前后的结果。

做到这些,就算完成

  • 能用一条具体记录说明它为何位于边界前或边界后。
  • 没有只改 ORDER BY 而沿用未经检查的比较条件。
  • 结果记录明确说明是否保持了同一读取快照。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。

  • SQLite:Row Values (新标签页)↗SQLite · 英文

    第 2.1、3.1、4 节:多列逐项比较、滚动窗口查询、3.15.0 起支持的版本界限。

    已打开并阅读对应正文;索引效率讨论只作为原理背景,本项目没有性能测试。

  • SQLite:SELECT (新标签页)↗SQLite · 英文

    第 4、5 节:ORDER BY 同值时未指定的顺序,以及 LIMIT/OFFSET 的取行规则。

    公开 SQL 文档;本文显式补充唯一 id,不依赖碰巧出现的物理顺序。

  • Isolation In SQLite (新标签页)↗SQLite · 英文

    WAL 与双连接例子:读取事务沿用快照,结束后重新读取才能看到后续提交。

    已阅读 WAL 段和 X/Y 示例;本文只实测 SQLite WAL,不推广为所有数据库的规则。

找一份资料继续练

先选一项,按下面的范围继续学;不必从头读完整门课程。