翻页翻到一半,同一条帖子又冒出来一次;再往下翻,有几条却像从没存在过。这不是前端渲染的锅,问题出在那句被复制粘贴进无数代码库的 SQL 上。
它读起来很顺,代码评审能过,测试也全绿——因为测试里没人会在你翻到第二页和第三页之间往表里插数据。可一旦表是活的,有人正在写,它就会让一部分用户看到重复内容,同时悄悄把另一些内容藏起来。
![]()
第二种失败更值得说。大家都知道 OFFSET 翻到深页会变慢,但很少有人注意到它在活跃表上本身就是错的;更少人知道那个流行的修法——游标——也有自己的并发漏洞,而且这个漏洞在博客的基准测试里根本不会露头。
OFFSET 是一个位置,而位置会移动
LIMIT 20 OFFSET 40 的意思不是“帖子的第三页”。它的意思是:把整个查询从头再跑一遍,走过此刻结果集里的前 40 行,给我接下来的 20 行。页边界是一个数字,而这个数字每次请求都会对着表的当前状态重新算一遍。
拿一个按时间倒序的 feed 走一遍:
你加载第一页(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 迁移的工程文章写道,在频繁新增条目的场景下,“页面窗口变得不可靠,可能跳过或返回重复结果”。
重复很烦,跳过才危险,因为它看起来什么都没坏。用户刷 feed 不会察觉;一个每晚分页扫订单、往仓库推数据的任务也不会察觉——于是你每天的对账报告都差那么几行,没人说得清为什么。
你忘写的 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 DESC, id DESC 排序取 20 条。第二页把第一页最后一行的 (created_at, id) 传进去,用 WHERE (created_at, id) < ($1, $2) 过滤。这是一个行值比较,按字典序:先比 created_at,相等才比 id。有了 (created_at, id) 上的索引,Postgres 能直接定位到那个点。
但游标不是免费的午餐。它同样依赖那个唯一决胜列,否则边界落在相同时间戳之间时,你依然会漏行或重复。它把“位置会移动”换成了“记住具体那一行”,代价是不能再直接跳到第 N 页,只能一页页往前翻。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.