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换成游标就结束。

相关推荐
暗夜行者之光1 小时前
LangGraph 实战:构建“自我修正”的代码生成 Agent
后端
IT枫斗者枫哥1 小时前
AI返回合法JSON,字段就可信吗?给抽取结果补一道业务校验
java·人工智能·后端
溪语流沙1 小时前
Django + Vue电商项目第005讲:后端骨架|Django初始化、配置分层与DRF接入
vue.js·后端·python·django
益达是我啊1 小时前
前端工程师的 Java 后端 30 天快速入门(1)
后端
今年下半年1 小时前
【网站连通性检测】java项目telnet、curl命令的使用
java·curl·telnet
摇滚侠1 小时前
《Spring Boot 3:高级与架构设计》第 2 章 IOC 容器的高级机制 编程式驱动 IOC 阅读笔记 7
java·spring boot·笔记
IT枫斗者枫哥1 小时前
UPDATE影响0行,接口却返回成功:把版本冲突接回业务
java·后端
霸道流氓气质1 小时前
AI应用成本优化完全指南:Token压缩、语义缓存与模型路由的Java生产级实战
java·人工智能·缓存
mmm.c1 小时前
Redis 两级缓存 + Pub/Sub 广播:一次为抗高并发做的缓存改造
java