列表翻页时为什么重复:用 SQLite 实测复合游标分页
用户读完列表第一页,另一位用户新增一条记录。继续按 OFFSET 20 取第二页,上一页最后一条可能再次出现。按倒序浏览时,把上一页最后一行的 (created_at, id) 交给下一次查询,从这个位置继续读。只传时间戳会跳过和它同一时刻写入的记录。
1. 同一个排序,同时用于索引和游标
这个例子按租户过滤,再按创建时间和唯一 ID 倒序。created_at 是非空整数,使用 Unix 时间戳。演练数据用小整数表示先后顺序。
CREATE INDEX events_feed ON events (tenant_id, created_at DESC, id DESC);
SELECT id, created_at
FROM events
WHERE tenant_id = ? AND (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT ?;
第一页省略游标条件。下一页用上一页最后一条的时间戳和 ID,按 (tenant_id, 上页时间, 上页 ID, 页大小) 绑定参数。元组比较先比时间,时间相同时再比 ID。严格小于使上一页边界行只出现一次。
2. 在两页之间插入一行
保存为 pagination_lab.py,运行 python pagination_lab.py。第一组用 6 行复现错误,第二组对 10 万行深页查询做对照。两组都在内存数据库执行。
import sqlite3
from contextlib import closing
FIRST = """SELECT id, created_at FROM events WHERE tenant_id = ?
ORDER BY created_at DESC, id DESC LIMIT ?"""
OFFSET = FIRST + " OFFSET ?"
AFTER = """SELECT id, created_at FROM events
WHERE tenant_id = ? AND (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC LIMIT ?"""
TIME_ONLY = """SELECT id, created_at FROM events
WHERE tenant_id = ? AND created_at < ?
ORDER BY created_at DESC, id DESC LIMIT ?"""
def ids(db, sql, params):
return [row[0] for row in db.execute(sql, params)]
def measure(db, sql, params):
plan = db.execute("EXPLAIN QUERY PLAN " + sql, params).fetchone()[3]
callbacks = 0
def progress():
nonlocal callbacks
callbacks += 1
return 0
db.set_progress_handler(progress, 1000)
try:
rows = db.execute(sql, params).fetchall()
finally:
db.set_progress_handler(None, 0)
return rows, plan, callbacks
with closing(sqlite3.connect(":memory:")) as db:
db.executescript("""
CREATE TABLE events (
id INTEGER PRIMARY KEY,
tenant_id INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX events_feed ON events (tenant_id, created_at DESC, id DESC);
INSERT INTO events VALUES (1,1,180),(2,1,190),(3,1,190),
(4,1,200),(5,1,200),(6,1,200);
""")
page = db.execute(FIRST, (1, 2)).fetchall()
last_id, last_ts = page[-1]
db.execute("INSERT INTO events VALUES (7, 1, 210)")
variants = {
"first": [row[0] for row in page],
"offset": ids(db, OFFSET, (1, 2, 2)),
"time only": ids(db, TIME_ONLY, (1, last_ts, 2)),
"cursor": ids(db, AFTER, (1, last_ts, last_id, 2)),
}
assert list(variants.values()) == [[6, 5], [5, 4], [3, 2], [4, 3]]
for label, values in variants.items():
print(f"{label}: {values}")
db.executemany("INSERT INTO events VALUES (?, 2, ?)",
((i, i // 10) for i in range(8, 100008)))
boundary_id, boundary_ts = db.execute(OFFSET, (2, 1, 89999)).fetchone()
offset_rows, offset_plan, offset_ticks = measure(db, OFFSET, (2, 20, 90000))
cursor_rows, cursor_plan, cursor_ticks = measure(db, AFTER, (2, boundary_ts, boundary_id, 20))
assert offset_rows == cursor_rows and len(cursor_rows) == 20
print(f"deep offset: {offset_plan} | callbacks={offset_ticks}")
print(f"deep cursor: {cursor_plan} | callbacks={cursor_ticks}")
本地实测,SQLite 3.45.1:
| 查询 | 得到的 ID | 解释 |
|---|---|---|
| 第一页 | [6, 5] | 页尾是 (200, 5) |
插入后按 OFFSET 2 | [5, 4] | ID 7 插在最前面,ID 5 移到了第二页 |
只按 created_at < 200 | [3, 2] | 把同一时间的 ID 4 一起跳过了 |
按 (created_at, id) < (200, 5) | [4, 3] | 同一时间内继续按 ID 读 |
深页对照让两种查询返回同样的 20 行。两条计划都使用覆盖索引 events_feed,但 OFFSET 的搜索条件只显示 tenant_id=?,游标查询多了 created_at<? 范围。脚本每约 1,000 条 SQLite 虚拟机指令计一次回调。本轮 OFFSET 触发 270 次,游标为 0 次。0 表示此次查询未达到回调间隔。回调次数反映本次执行的工作量,不能用来推导生产延迟。
3. API 要保留哪些信息
- 游标来自最后一条实际返回的行。 查询
page_size + 1条,多出的那条只用于判断是否还有下一页。如果拿它生成游标,下一页会跳过该行。 - 绑定排序与筛选。 游标要与租户、过滤条件、排序方式和游标版本匹配。租户来自服务端鉴权。把 JSON 编码成 Base64 只能方便传输。需要防篡改时签名,或使用服务端保存的游标句柄。
- 规定翻页期间的数据语义。 上面的查询每次读取当前数据。新行插到游标之后仍可能进入下一页,修改排序字段也可能让行跨过边界。需要可重复的全量导出时,使用数据库快照或预先固定导出数据集。创建时间和 ID 尽量保持不变。
小结
游标、ORDER BY 和索引使用同一套排序字段。用重复时间戳和翻页期间插入两种用例验证正确性,再用执行计划确认深页查询能从索引范围继续读取。
数据来源与检索时间
检索时间 2026-11-12。
| 来源 | URL | 对应内容 |
|---|---|---|
| SQLite Row Values | https://www.sqlite.org/rowvalue.html#scrolling_window_queries | 元组比较和滚动窗口查询 |
| SQLite Query Planning | https://www.sqlite.org/queryplanner.html | 复合索引、覆盖索引及按索引顺序读取 |
SQLite EXPLAIN QUERY PLAN | https://www.sqlite.org/eqp.html | 索引搜索条件和计划输出的适用范围 |
Python 3.11 sqlite3 | https://docs.python.org/3.11/library/sqlite3.html#sqlite3.Connection.set_progress_handler | 虚拟机进度回调 |
核验说明
- 在 Python 3.11.9 / SQLite 3.45.1 中验证重复、漏项和复合游标结果,并比较 10 万行数据上两种查询的计划和进度回调。
- 实验使用内存数据库和固定数据。生产表的字段分布、索引覆盖情况和并发写入需要用实际查询再核验。