ORDER BY时间还会漏单?用相同时间和插入记录测一次分页

第一页返回 [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换成游标就结束。

相关推荐
apcipot_rain8 小时前
C++转Java日记(03)
java·开发语言·c++
皮皮林5518 小时前
Java实现微信扫码登录
java
陈随易10 小时前
纯 Go 写的 JS 引擎,跑分超过了 C 语言的 QuickJS
前端·后端·程序员
Yyyyyy~10 小时前
【Java】Day4Web基础知识
java·spring boot
卷无止境10 小时前
RAGFlow 项目全景报告:从文档解析引擎到 Agent 原生数据库
后端·python
小Z先生11 小时前
我用 Go + 系统 WebView 写了个纯本地 Postman:不装 Electron、不连云端
后端·github
IT_陈寒11 小时前
Redis连接池泄漏那次,我差点把生产环境搞崩了
前端·人工智能·后端
灿飞屋12 小时前
Kotlin与Java虚拟机三:控制流
android·java·开发语言·后端·python·kotlin·安卓
石小石Orz12 小时前
AI 正在蚕食我们的编码能力
前端·后端·算法
NE_STOP12 小时前
Spring AI Alibaba - MCP入门
java