列表分页为什么会重复:理解游标和稳定排序
卷中目录
列表翻页翻出两条一样的笔记,或者下一页跳过了刚发表的那条,调用方通常会先怀疑接口“不稳定”。多数时候排序并没有随机,是分页窗口在数据变化时被平移了。偏移量分页把“跳过前若干条”当成位置。数据一插入、一删除,这个数字后面的内容就换了一批。游标分页改成“从上一页最后看见的那条之后继续”,但前提是排序本身是全序,而且用来比较的字段不会让同一条记录来回跳动。这篇把重复和遗漏拆开,放到同一种查询上看。

偏移量在变更里会平移
假设笔记按创建时间从新到旧,每页二十条。读者停在前一页时,库里的顺序是 A 到 T。正在看的时候,有人插入了比 A 还新的 U。读者请求“跳过二十条”。数据库现在的前二十条是 U 加上原来的 A 到 S,跳过之后从 T 开始。原来的 T 已经在前一页见过,这一页又出现,读者以为重复。若在两页之间删掉 A,跳过二十条会从原来更靠后的位置开始,中间有一条被跳过,读者以为丢了。
偏移量不是锚,是一个会随插入删除改变含义的整数。数据静止时它能工作。时间线、订单、任务队列、任何人会往前头插入的列表,都不静止。页面越大、停留越久,撞上插入的概率越高。再把总数一并返回,总数和各页之和也对不上,因为总数和每一页不是同一次快照。
深偏移还有成本。跳过两万条,数据库仍要找出这两万条再丢掉。索引能缓解一部分,但语义问题还在。所以换游标不只是为了快,首先是为了让“下一页”在插入之后仍然指得清。快是附带的结果。若排序列上没有合适的索引,游标一样会慢,只是慢得更诚实:它慢在查找位置,而不是慢在把前面的页全部数一遍。
偏移量还有一个更隐蔽的重复来源:调用方自己把页码算错。上一页返回了不足一页的条数,调用方仍按满页去加偏移,下一页就会跳过真实存在的行,再上一页又可能退回到已经看过的行。服务端无法从“跳过四十”里看出客户端到底看见了哪一条。游标把“看见了哪一条”直接交给服务端,页大小的变化才不会改写位置的含义。
游标要钉在上一页的最后一条上
游标分页不传跳过的条数,只传一个位置。位置来自上一页最后一条的排序键。下一页的条件是:排序键严格排在这个位置之后,再取二十条。新插入的记录若比当前页更新,它出现在前面的页,不会把当前页的窗口整体往后推。删除一条已经读过的记录,也不改变“这个位置之后”的含义。
实现时容易写成按主键小于上一页末尾,同时排序却按创建时间。比较的字段和排序的字段不是同一套,窗口就会歪。排序按创建时间,比较也得按创建时间。只有一个字段又不够,因为创建时间会重复。
取游标时要取这一页排序之后的最后一条,不是取这一页主键最大或最小的那条。若数据库返回的顺序已经是最终顺序,最后一条就是数组末尾。若之后又在内存里重排过,游标必须来自重排后的末尾,否则下一页会从旧顺序的位置切开,中间一段会重复或开洞。内存重排是游标实现里很常见的事故:查询是对的,返回前为了把置顶项挪到前面,又把顺序打乱了。
排序必须是稳定的全序
同一秒里可以有两篇笔记,创建时间相同。若只按创建时间排序,数据库不保证这两条的相对顺序在下一次查询里不变。这一次 B 排在 C 前,下一次 C 排在 B 前。游标停在 B 上,条件写成“创建时间早于 B”。C 的创建时间和 B 相同,不满足“早于”,于是 C 被跳过。若条件写成“创建时间早于或等于 B”,C 和 B 都会再次出现。等于带来重复,严格小于带来遗漏。只靠一个会重复的列,没有第三条路。
补一个不会重复的列做次序。创建时间相同就再比主键,主键是唯一的。排序写成创建时间从新到旧,时间相同则主键从大到小。这是全序:任意两条都能比出先后,同一次逻辑里不会并列。游标里带上两个值:上一页末尾的创建时间和主键。下一页的条件是:创建时间更旧,或者创建时间相同且主键更小。写成元组比较更不容易写错一侧。
select id, created_at
from notes
where account_id = $1
and (created_at, id) < ($2, $3)
order by created_at desc, id desc
limit 20;方向要和比较一起反转。从旧到新时,不等号和排序同时反过来,游标仍是上一页最后一条,不是随意取页里的最大值。只反转排序、不反转比较,会把同一页反复返回。客户端若同时提供“上一页”和“下一页”,两个方向是两套比较,不要共用一个不等号再在内存里反转数组充数。反转数组可以改变展示顺序,改变不了你已经用错的窗口。
空值要有定义。若按“置顶时间”排序,大量空值的次序必须固定,例如空值永远排在有值之后,并且仍然用主键打破平局。空值和空值之间若没有主键,又会回到并列。不要让排序依赖数据库默认的空值位置而不写出来,换一种数据库时默认会变。把空值换成一个明确的哨兵值再参与排序,也可以,但哨兵不能和真实时间重叠,并且要写进索引表达式,否则规划器用不上你以为存在的索引。
唯一列必须真的唯一。用“时间加用户编号”当次序,同一个用户在同一毫秒的两篇笔记仍然并列。用自增主键或随机但唯一的标识都可以,关键是全表唯一,而不是“通常不撞”。通常不撞的列,会在导入历史数据时撞上,分页只在导入那天出错,很难复现。
记录自己改排序键时,仍会重复或消失
全序解决的是并列,解决不了“同一条记录的排序键在翻页期间变了”。按更新时间排序时,读者看完前一页,前一页里某条被别人编辑,更新时间变成最新,它会跑到列表前头。读者继续向后翻,不会再见到它,这像是遗漏。另一条若从后面被改到当前窗口之前,也可能在后面的页里不再出现。按创建时间排序时,创建时间不变,这个问题小得多。所以时间线用创建时间加主键,不用更新时间,除非产品就是要“最近有动静的排前面”,并且接受条目在页间搬家。
搬家是产品语义,不是分页能偷偷抹平的。真要“最近有动静”又要少重复,可以让翻页期间使用一个快照:开头请求拿到一个可见的水位,后续页面都带上这个水位,只显示水位之前的数据。新写入留到下一次刷新。这会让列表看起来不那么实时,换来页间稳定。把这个取舍告诉客户端,不要一边承诺实时、一边用会搬家的字段做游标。
已经返回过的游标,在记录被删除后仍然要能用。下一页从该位置继续,被删的那条不再出现,这是可解释的。不要因为游标指向的主键已经不存在就报错。游标是位置,不是外键。按位置比较不需要那一行还活着。报错会逼着客户端回到列表开头重拉,重拉时如果又有新插入,用户会看到一整屏已经看过的内容,比静默跳过一条更像故障。
游标对客户端应当是不透明的
客户端会存储、重放、甚至手工拼接游标。若把创建时间和主键明文放在查询参数里,调用者可能改大时间,探测更前面的数据,也可能拼出一个落在索引缺口上的位置。把排序键编码成不透明的字符串,服务端解码并校验格式。校验失败就拒绝,不要悄悄退回偏移量。
不透明不是加密意义上的保密。列表本身仍受授权约束。游标只是让调用者难以依赖内部列名。编码里可以带上排序方案的名字。哪天排序从“创建时间加主键”改成别的组合,旧游标能被认出来,返回一个明确的“请从头重新拉取”,而不是按新规则曲解旧数字,造出难以复现的重复。
不要把偏移量塞进名叫游标的参数。有的接口开头一页走偏移、后面走游标,两套语义在同一参数里切换,客户端重试时会把偏移当成位置。选定一种。管理后台若必须跳到任意页,可以单独提供按偏移的接口,并写明它在并发写入下会重复或遗漏。不要让时间线悄悄调用那个接口。
编码里不要放可被用户改动后仍“看起来合法”的过滤条件。有人把账号编号也放进游标,下一页只信游标、不再信请求里的账号。攻击者改不了不透明字符串时尚可;一旦编码只是简单拼接,改掉账号编号就会读到别人的下一页。账号和权限过滤永远来自服务端对当前主体的判断,游标只提供位置。位置和解码失败是格式问题,账号不匹配是授权问题,两者的原因码不要混用。
同一页里的重复还来自连接和重试
分页查询若先按笔记连接标签表,一条笔记两个标签就会占两行。限制二十行可能只得到十四条笔记,其中两条出现两次。调用方若按行展示,就是重复;若按笔记去重,页大小又不稳。去重要发生在限制条数之前,或者改成先按笔记分页,再取这些笔记的标签。在内存里对一页结果去重,只能去掉页内重复,去不掉“因为重复行占位而少返回的笔记”。
客户端超时重试同一游标,应得到同一页,这是正常的。若服务端每次重试都顺便推进游标,就会跳页。推进游标是客户端确认“这页我已经收下”之后的事,不是查询本身的副作用。查询不写状态,重试才安全。把“已读到哪里”存在服务端会话里,看起来省了参数,实际上让重试、多端同步和刷新都变得难解释:另一个设备不知道这个隐式位置,同一设备重试却会往前走一页。
读副本的延迟会让新插入在下一页时隐时现。前一页读到了主库上刚提交的行,下一页打到延迟副本,比较用的位置还在,行却暂时看不见,再下一页又出现。若产品不能接受,分页读主库,或者接受一个“新内容可能晚一页”的说明。不要为了分页去把副本的延迟当成逻辑错误重试,重试会把同一页打得更重。
边界
倒序和正序都要测试,只测“从新到旧”会把不等号写反留到以后。创建时间相同的至少三条记录要测,只有这条能抓住平局。翻页过程中插入一条更“新”的、删除一条已读的、修改一条未读的排序键,三种都要有断言:插入不造成旧页重复,删除不导致接口报错,修改排序键时的行为符合你选的语义。
页大小要有上限。客户端传一个很大的限制,会把游标退化为一次全表扫描,超时后再重试,看起来像分页不稳定。上限拒绝掉,比截断成默认值更清楚。截断会让客户端以为自己拿到了全量。默认页大小也要写明。不传页大小时,服务端选一个固定值,并且在响应里返回实际使用的限制,避免客户端用另一个默认值去推断“还有没有下一页”。
没有下一页的信号要用“本页条数不足限制”或单独的标记,不要再去查一条探路记录却把那条的游标返回给客户端。探路记录若被客户端当成最后一条,下一次就会跳过它。若确实多查了一条来判断有没有下一页,返回前要把多出来的那条拿掉,游标仍取可见的最后一条。
搜索结果的分页更脆。相关性排序在索引重建后会变,同一条的分数变了,游标就失效。搜索页要么使用短时快照,要么在分数相同时仍用唯一键打破平局,并接受重建后旧游标需要作废。不要把数据库时间线的游标写法原样套到搜索分数上,还承诺页间不重复。分数是浮点时,相等判断本身就不可靠,打破平局的唯一键更不能省。
权限过滤若在分页之后做,一页二十条里删掉无权的七条,客户端只看到十三条,并且下一次游标已经越过了那七条的位置。过滤要写进查询条件,让限制条数限制的是“有权看见的行”。这和归属约束是同一件事,分页会把它放大:页变小,用户会以为是重复或丢失,实际是无权行占过位又被扔掉。过滤条件若会变,例如用户刚被移出某个账号,下一页的集合和上一页不同,这也是可解释的变化,不是游标坏了。响应里不必假装两次看到的世界是同一张快照,除非你真的提供了快照。
收束
重复不一定是返回了两次同一条主键,更多时候是窗口在插入时被平移,或平局的次序不固定,或排序键自己在搬家。把下一页定义成“上一页最后一条之后”,给排序补上唯一列,让比较和排序使用同一组列,游标才能在插入和删除下保持含义。排序键会变、分数会变、副本会延迟,这些要单独写成语义,而不是指望游标魔术般消掉。页间稳定和实时全貌很少同时成立,选一个写进接口,另一个用刷新而不是用翻页来完成。客户端记住的是位置,不是页码;服务端记住的是全序,不是“跳过多少条”。两边都不再猜,重复才有地方可查。
见字如晤