第一页返回 [3, 2],第二页却可能得到 [4],也可能得到 [2, 1]。两种现象都能出现在按时间倒序的订单列表里,但原因不同:前一种是游标漏掉同一时刻的订单,后一种是翻页之间新增记录,让OFFSET的位置移动了。
这次只查一张订单表,不涉及一对多JOIN。把每页缩成两条,用JDK21和H2 2.3.232复现,更容易逐条核对。
先把顺序和游标说完整
初始数据:订单1、2、3都在10:00,订单4在09:00。排序固定为 created_at desc, id desc,因此是3、2、1、4,第一页拿到3、2。
只按 created_at desc,相同时间之间没有唯一顺序。数据库可能碰巧给出稳定结果,但不能把这种观察当作排序约定。PostgreSQL的LIMIT文档也明确提醒分页需要可预测的唯一顺序。本文实际执行引擎是H2,未把官方提醒说成PostgreSQL实测结果。
即使排序补了ID,下一页条件若只写:
sql
where created_at < '2026-09-28 10:00:00'
也只会拿到订单4。同一时刻还没返回的订单1,被严格小于时间的条件排除了。
要从订单2后面继续,需要把时间相同的分支也纳入:
sql
select id from orders
where created_at < ?
or (created_at = ? and id < ?)
order by created_at desc, id desc
limit 2;
三个参数分别是上一页最后一条的时间、同一个时间、ID。降序对应小于,别把比较方向写反。真实接口还要带原来的业务筛选与权限条件;OR分支应整体加括号,再与这些限制做 AND,避免条件优先级扩大数据范围。
排序唯一,不代表OFFSET不受新增影响
第一页读完以后,新插入订单5,时间11:00。顺序变成5、3、2、1、4。
LIMIT 2 OFFSET 2 跳过5、3,返回2、1,于是2重复出现。复合游标仍以10:00、ID2为边界,拿到1、4。程序分别断言了这两组结果。

这不是在宣称游标可以替代所有页码分页。直接跳到第10页、显示总页数,与不断加载下一页,是不同产品需求。游标也不是数据库快照:已存在记录的排序值被修改、记录被删除或业务筛选变化,仍会影响后续观察。
把边界带进接口
这个实验把 created_at 定义为非空,ID唯一,避免把NULL排序也混进来。业务如果允许NULL,需要单独定义位置和比较规则。
游标应绑定相同的筛选条件和排序版本,参数用预编译语句绑定;解码后重新验证数据类型与权限。不要让客户端游标决定当前用户能看哪个租户。游标编码是否需要签名,要结合是否允许篡改翻页范围判断,不能把Base64当加密。
索引可以再按实际过滤和排序设计,但本文没有执行计划或大数据性能结果,不给"提升多少倍"的结论。
最小复现
按下方创建 pom.xml、src/main/java/demo/CursorLab.java,运行:
shell
mvn -q compile exec:java -Dexec.mainClass=demo.CursorLab
预期:
text
first=[3, 2]; timeOnly=[4]; offsetAfterInsert=[2, 1]; tupleCursor=[1, 4]; checks=6
完整程序使用固定SQL和教学数据,便于观察;接用户输入时改用参数绑定。它不依赖相同时间记录的偶然返回顺序,而是在每次查询中明确两列排序。
pom.xml:
xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion><groupId>demo</groupId><artifactId>weekly-evidence</artifactId><version>1.0</version>
<properties><maven.compiler.release>21</maven.compiler.release><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding></properties>
<dependencies><dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><version>2.3.232</version></dependency></dependencies>
<build><plugins><plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.13.0</version></plugin><plugin><groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.5.0</version></plugin></plugins></build>
</project>
src/main/java/demo/CursorLab.java:
java
package demo;
import java.sql.*;
import java.util.*;
public class CursorLab {
static int checks;
static List<Integer> ids(Connection c,String sql) throws SQLException {
var out=new ArrayList<Integer>();
try(var s=c.createStatement();var r=s.executeQuery(sql)){while(r.next())out.add(r.getInt(1));}
return out;
}
static void eq(Object e,Object a){if(!e.equals(a))throw new AssertionError(e+" != "+a);checks++;}
public static void main(String[] args) throws Exception {
try(var c=DriverManager.getConnection("jdbc:h2:mem:pages")){
try(var s=c.createStatement()){
s.execute("create table orders(id int primary key,created_at timestamp not null)");
s.execute("insert into orders values(1,'2026-09-28 10:00:00'),(2,'2026-09-28 10:00:00'),(3,'2026-09-28 10:00:00'),(4,'2026-09-28 09:00:00')");
}
var first=ids(c,"select id from orders order by created_at desc,id desc limit 2");eq(List.of(3,2),first);
// 只用时间作游标,漏掉同一时刻尚未返回的订单1。
eq(List.of(4),ids(c,"select id from orders where created_at<'2026-09-28 10:00:00' order by created_at desc,id desc limit 2"));
String seek="select id from orders where created_at<'2026-09-28 10:00:00' or (created_at='2026-09-28 10:00:00' and id<2) order by created_at desc,id desc limit 2";
eq(List.of(1,4),ids(c,seek));
try(var s=c.createStatement()){s.execute("insert into orders values(5,'2026-09-28 11:00:00')");}
var offset=ids(c,"select id from orders order by created_at desc,id desc limit 2 offset 2");eq(List.of(2,1),offset);
eq(List.of(1,4),ids(c,seek));
eq(List.of(),ids(c,"select id from orders where created_at<'2026-09-28 09:00:00' or (created_at='2026-09-28 09:00:00' and id<4) order by created_at desc,id desc limit 2"));
System.out.println("first="+first+"; timeOnly=[4]; offsetAfterInsert="+offset+"; tupleCursor=[1, 4]; checks="+checks);
}
}
}
你们的订单列表是否允许翻页过程中出现新数据?如果要求"按某一刻导出完整结果",需要另外讨论快照范围,不能只把OFFSET换成游标就结束。