翻页翻到一半,同一条帖子又冒出来了。再往下滑,昨天明明看到过的那条,怎么找都找不到了。用户不会截图来投诉,测试环境也永远复现不了——因为你的测试库里,没人在你翻页的间隙往表里插数据。

问题出在那句被复制粘贴进无数代码库的查询上:LIMIT 20 OFFSET 40。它读起来很顺,能过代码评审,能跑通你写的每一个测试。但在任何有人正在写入的表上,它会让一部分用户看到重复的内容,同时悄悄把另一些内容藏起来。

打开网易新闻 查看精彩图片

深分页慢这件事,大家都知道。真正少有人注意的是:它在活跃表上本身就是错的。而那个被当成标准答案的修法——游标,也藏着自己在并发下的漏洞,只是博客里的基准测试照不出来。

OFFSET 是一个位置,而位置会移动

OFFSET 40 的意思不是"第三页"。它的意思是:把整个查询从头再跑一遍,在当前结果里走过前 40 行,然后把接下来的 20 行给我。页边界是一个数字,而这个数字每次请求都会对着表的当前状态重新算一遍。

拿一个按时间倒序的信息流走一遍:

你打开第一页(OFFSET 0),看到的是第 100 条到第 81 条。

你正在读的时候,两条新帖发布了:101 和 102。

你点"下一页"(OFFSET 20)。查询现在从 102 开始数,第 1 到 20 位是 102 到 83,第 21 到 40 位是 82 到 63。

第二页开头是 82 和 81——这两条你刚才已经看过了。

插入发生在你的位置之前,行就被推向你,于是你看到重复。删除则相反:如果点下一页之前第一页有两条被删掉,所有内容上移两位,原本排在第 21、22 位的两条会滑进你已经离开的第一页,你永远看不到它们。

Use The Index, Luke 把它总结成一句话:插入新数据时页码会漂移,因为编号永远是从头算的。微软 EF Core 的文档讲 Skip/Take 时说的是同一件事:一旦发生并发更新,分页可能跳过某些条目,或者把它们显示两次。Slack 在 API 规模增长时撞上的正是这个,他们那篇从 offset 迁移的工程文章里写道,当条目被频繁添加时,页窗口会变得不可靠,可能跳过或返回重复结果。

重复很烦,跳过才危险,因为它看起来什么都没坏。用户刷信息流不会察觉。一个每晚翻页把订单推进数仓的定时任务同样不会察觉——这就是为什么你最后会得到一份每天差几行的对账报告,而没人说得清差在哪。

你忘写的 ORDER BY 是另一个 bug

在谈并发之前,还有同一个问题的安静版本。Postgres 文档在这点上措辞罕见地直白:使用 LIMIT 时,重要的是用一个能把结果约束成唯一顺序的 ORDER BY 子句,否则你拿到的是查询行的一个不可预测的子集。

紧接着还有一句:查询规划器在生成计划时会把 LIMIT 考虑进去,所以你给不同的 LIMIT 和 OFFSET,很可能得到不同的计划,从而产生不同的行顺序。改一下 offset,可能换一个计划,可能换一个顺序。文档补充说,这不是 bug——SQL 从来没有承诺过你没要求的顺序。

所以光写 ORDER BY created_at DESC 不够。只要两条帖子共享同一个时间戳(批量导入、种子数据、任何在同一事务里用 now() 写入的东西),它们的相对顺序就交给规划器决定,而一个正好落在它们之间的页边界,可能给你其中一条、两条、或者一条都不给。EF Core 文档还补了一个经常被搞错的细节:关系型数据库默认不施加任何排序,主键上也不施加。

修法无聊但必须做:排序的末尾永远加一个唯一列。

ORDER BY created_at DESC, id DESC

这个决胜列对 offset 和游标都重要。对游标来说它根本不是可选项。

深 offset 为什么慢,机制值得用一句话说清。Postgres 文档的说法是:被 OFFSET 子句跳过的行仍然要在服务器内部被计算出来,因此一个很大的 OFFSET 可能效率低下。

用 B 树绕不开这一点。你可能会想,索引直接跳到"第 40000 行"不就行了——它做不到。CedarDB 团队指出,B 树的每个分支并不包含固定数量的元组,所以没有东西可以用来计算这个跳跃。数据库从索引起点开始走,把 offset 之前的每一行都丢掉。Slack 的说法是:数据库仍然要从磁盘读取最多 offset + count 行。第一页读 20 行,第 2000 页为了返回 20 行要读 40000 行。

伤害有多大取决于你的数据、索引,以及行是否被缓存,所以这里不给一个编造的毫秒数。Markus Winand 在 Use The Index, Luke 上的基准图表显示,offset 和 seek 方法的差距大约从第 20 页开始变得清晰可见,之后曲线只会更陡。

Keyset:记住那一行,而不是那个数字

两个问题的修法是同一个思路。不要告诉数据库跳过多少行,告诉它你停在哪里。这就是 keyset 分页,也叫 seek 方法。

第一页照常查,按 created_at 和 id 倒序取 20 条。第二页把第一页最后一行的 (created_at, id) 传进去,用 WHERE (created_at, id) < ($1, $2) 过滤,再取 20 条。

这个行值比较是字典序的:先比 created_at,相等才比 id。有了 (created_at, id) 上的索引,Postgres 可以直接定位到那个点,不需要从头走过被跳过的行。

代价是游标不能跳页,只能一页一页往下。对信息流和导出任务来说,这通常不是问题;对需要"跳到第 37 页"的后台表格来说,它是个真实的限制。但至少,它不会在你翻页的时候,把别人刚写进去的数据变成你眼前的重复和消失。