开始前,会这些就够了
- 能运行 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。
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 降序,该怎样改下一页条件,并验证同值边界?
- 先手写六条初始记录的目标顺序,不运行程序猜结论。
- 复制查询函数作为新实验,同时检查排序和边界比较是否一致。
- 补上同值、最后一页、游标行被删除的测试,再记录插入前后的结果。
做到这些,就算完成
- 能用一条具体记录说明它为何位于边界前或边界后。
- 没有只改 ORDER BY 而沿用未经检查的比较条件。
- 结果记录明确说明是否保持了同一读取快照。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- SQLite:Row Values (新标签页)↗
第 2.1、3.1、4 节:多列逐项比较、滚动窗口查询、3.15.0 起支持的版本界限。
- SQLite:SELECT (新标签页)↗
第 4、5 节:ORDER BY 同值时未指定的顺序,以及 LIMIT
/OFFSET的取行规则。 - Isolation In SQLite (新标签页)↗
WAL 与双连接例子:读取事务沿用快照,结束后重新读取才能看到后续提交。
找一份资料继续练
先选一项,按下面的范围继续学;不必从头读完整门课程。
TiDB 中文:翻页位置与批次边界怎么选 (新标签页)↗
只读 LIMIT 与单字段主键批次区间,对照本地 SQLite 练习区分按位置翻页、按键继续和固定读取时间,跳过 DELETE 示例。
查看学习准备与访问说明 →