Java EE 企业应用 15:稳定排序、索引与分页怎样配合
第一页有二十条,第二页为什么又出现同一张申请
采购审批列表按租户和状态筛选,用户在浏览第一页时,另一位申请人又提交一张申请。如果查询只写 LIMIT 20 OFFSET 20 而没有确定的排序,第二页可能重复或漏掉行;即使加了排序,数据在两次请求之间变动也可能改变偏移边界。这是查询的契约问题,不是把页面大小调大就能解决。例子仍以 PostgreSQL 16.15、Java 21 与现有 JDBC 采购工程为背景。本章设计一个尚不存在的列表入口;仓库的 JdbcRequestStore 只有按 ID 和租户读取申请及明细的方法,没有分页接口或可归档的列表请求结果。
本篇源码、迁移、脚本与测试可从固定版本完整归档取得;版本 1b08ada,SHA-256 见源码清单。
列表的单位是申请,不是 JOIN 后的明细行
当前 db/migrations/001-initial.sql 为 purchase_request 建立 (tenant_id, status, id) 索引;purchase_line 按 request_id 外键关联。若业务页面要看“某租户的已提交申请”,先确定列表单位为申请 ID,然后筛选租户和状态并按稳定的唯一键排序。一个按 ID 倒序的候选 SQL 形状是 WHERE tenant_id=? AND status=? AND id<? ORDER BY id DESC LIMIT ?:第一页不加游标条件,后页传上一页最小的 ID,查询参数均由 PreparedStatement 绑定,不能把租户和游标直接拼进 SQL。若 UI 要按创建时间倒序,当前表没有创建时间列;应先提出字段、回填及排序时的 (created_at,id) 唯一组合,不得借用不存在的时间戳举出实测结果。
第一页没有游标时,SQL 应只包含租户、状态、排序和有上限的页大小,而不是用 id < NULL:在 SQL 三值逻辑下,与 NULL 的大小比较得不到 true。后续页不能让客户端随便把租户 ID 塞进游标就重新选择数据集;服务端每页都须重新使用已核验的租户条件与状态过滤,最终身份认证由后续篇章实现。若要加入“审批人只看本人待审批任务”,当前 schema 没有审批任务归属字段,不能用 tenant_id 代替申请人或审批人权限。服务端对过大的页大小设置明确上限,超出时拒绝或截断并文档化,否则单次查询可能把数据库、网络与堆内存一起推向不可控的峰值。
JOIN purchase_line 后一张有三条明细的申请会变成三条结果行。直接在这样的 SQL 上 LIMIT 20,分页限制的是结果行,而非二十张申请。两阶段查询可以先取申请 ID 的一页,再一次性按这批 ID 取明细,按 ID 在应用中组装;参数列表大小须设上限,结果中每张申请的租户条件仍应检查。另一个选项是在合适的查询里聚合明细,但不能把 COUNT(*) 在 JOIN 后的值直接当作申请数。现有 JdbcRequestStore.find 每读一份申请会再执行一条明细 SELECT;如果列表照着这个方法循环,理论上会出现“列表查询 + 每条申请一条明细查询”的增长趋势。工程没有该列表循环代码,也没有 SQL 次数的实测图;只指出这种设计的预期代价。
第二阶段的明细 SQL 如果把 request_id IN (...) 的返回行按明细 ID 排序,结果未必与第一页申请顺序相同;组装时必须用申请 ID 建索引,再按第一页的顺序生成响应。第一页申请没有明细,当前领域对象构造会拒绝空明细;遇到这样的残缺数据不能因为聚合时为空就默默跳过一张申请,让页大小、总数与返回结果不一致。COUNT(*) 从明细 JOIN 后计的是明细行,用 COUNT(DISTINCT request.id) 可以改变计数口径,却仍需要检查查询代价、SQL 参数和权限条件。用于页面展示的总数在并发写入时也有自己的时间点:两个独立查询之间申请状态改变,第一页的行与总数未必来自同一快照;API 应写明这是否可接受,而不是承诺一次请求返回永久不变的列表。
这与先行 JPA 06:抓取与 N+1 的实测范围 讨论的列表关联风险相邻,但那里记录的 Hibernate 三条申请 4 次与 1 次查询只属于它自己的 ORM 实验,不是本 JDBC 工程的统计数字。JPA 的 JOIN FETCH、实体去重和缓存规则不能直接移到 JDBC SQL 上;在这里申请页的 ID、行数和租户谓词都须显式定义。JPA 05:查询与投影边界 中的 JPQL/Criteria/native 查询是另一组实现路径;这一篇只用显式 SQL 规划正确的列表结果。
排序稳定不等于跨请求快照不变
对单次查询而言 ORDER BY id DESC 为同条件的结果定义确定次序;如果按业务状态排序而状态相同,再用 id 作唯一决胜键。OFFSET 需要数据库越过前面的行,页数深时通常代价增长;此外第一页读完后更大的新 ID 插在倒序结果前面,第二页按新的偏移读取可能重复上一页末尾的申请。若坚持 ID 升序且新主键只向末尾追加,上述“新插入造成重复”的具体反例不成立,应改测过滤状态更新或删除引起的页边界移动。按唯一 ID 倒序的游标分页削弱此前缀偏移位移,却仍不能阻止状态在下一次查询之前改变、让某行移出筛选集合。若审计导出要求严格完整的某一时刻行集合,必须另定快照/事务隔离、版本范围或物化结果,并在 PostgreSQL 的实际隔离级别上验证,不可把普通两次 HTTP 请求当成同一个一致性快照。
索引是否起作用,也不是见到 CREATE INDEX 就能宣布。当前 (tenant_id, status, id) 与“租户等值 + 状态等值 + ID 排序/区间”的拟议查询形状有对应关系,但优化器会随行数分布、统计信息与查询条件选择不同执行计划。固定合成数据量、SQL 文本和参数后,在隔离库执行 EXPLAIN (ANALYZE, BUFFERS),记录实际行数、扫描/排序、缓冲区和执行时间;没有这个输出便不能填写“命中索引”或毫秒数。EXPLAIN ANALYZE 会真正执行语句,带副作用的 SQL 不能当作无害分析命令在共享库上尝试。
执行计划的另一种误读是小表顺序扫描。即便联合索引存在,PostgreSQL 也可能认为一次读完整张小表比走索引更划算;这不是索引“失效”的充分证据。要对比两种方案,使用相同合成数据、参数、数据库统计信息和硬件负载,记录计划树中的 rows 估算与实际行数、是否多了一次 Sort、读取了多少页面;仅比较一次查询耗时容易受到缓存和并发会话影响。反过来,大量旧申请使 OFFSET 深翻页耗时增长,游标分页避免扫描前置页面的机制也应经相同条件下的计划与行数验证,不应仅由 SQL 文本断言“性能更好”。当前脚本尚未生成这种数据集。
列表实验需要申请页和失败页两套输入
固定 ID 的临时表实验与尚不存在的 HTTP 列表入口分栏记录在稳定分页实验卡。下列 SQL 只演示页边界,不对当前应用写入新申请。
本章正常实验尚未运行。先在全新隔离库按 001-initial.sql 迁移,录入两个租户的多份申请,每份明确不同的明细数,记录迁移与种子数据、代码/WAR 摘要。新增受管的只读分页入口后请求第一页及第二页,核对每页不同申请 ID 的集合、排序、对应明细、租户过滤、所执行 SQL 条数和执行计划。为检查循环查询增长,固定数据量分别在 3 条与 20 条申请下采集 SQL 次数;若没有 SQL 采集器,不能用 HTTP 耗时反推查询数量。
在列表入口尚不存在时,可先用 PostgreSQL 的会话临时表验证偏移边界反例,它既不改业务 schema,也不证明 Java 接口已经实现。需先通过 README 确认连接目标为本机专用库,运行时提供本地 JAVAEE_LAB_PASSWORD;下面两页都在同一次 psql 会话中,第一轮按 ID 倒序读取 40,30,插入更大的 50 后,第二轮 OFFSET 预期会再次看到 30,而按 id < 30 的游标读到 20,10。以下是预期判据,本批没有运行该命令:
1 | |
记录三次 SELECT 的原始行、退出码、每次写入发生的时刻;会话回滚清理临时表。这个可复跑负向例子只描述 OFFSET 在变化集合上的位置偏移,不演示真实 HTTP 分页是否越权、底层索引是否使用,或两个数据库事务并发时隔离会看到何种快照。若要测“状态改变使游标也漏掉行”,还需在两次读之间改变一条记录的过滤状态,并保存变更前后的集合,不能把上面更大 ID 的插入误用来证明游标永远完整。
失败路径需要在两个页请求之间受控插入一份记录、更新一份申请的 status,对比偏移分页与 ID 游标分页的具体结果;先记录交错顺序及各次查询的事务隔离,最后用独立连接读库确认新增或变更的行。预期“偏移可能重复/漏读”是一条需要给出实际输入排序才能验证的假设,不能只睡一秒然后把偶发结果记为证明。更大的负向输入包括越权租户游标:游标不能让服务端省略租户谓词。当前无列表接口、无种子场景、无两页输出和执行计划,上述正常/失败实验均 NOT_RUN。已有 scenarios/09-lab-procurement.sh 的单份申请顺序结果不支持列表性能或跨页正确性。
将 SQL 层证明提升到应用层,需要至少两个受管请求:第一页响应同时带申请 ID 集合与 nextCursor,第二页带相同筛选条件及前一页最小 ID;在两次请求之间用受控屏障让新申请写入。保存两个请求的原始响应、服务端执行的 SQL/参数和最终数据库行,并检查不同租户申请在任何一页都不可见。若要求“翻页时与第一页的状态一致”,普通 READ COMMITTED 下两次独立事务不能提供这个承诺,应另设计快照边界。当前没有 HTTP 列表路由,不能把以上请求写成已经可复制的 curl 命令;这里的 SQL 临时表实验与后续应用接口实测必须分栏存证。
游标协议也有故障输入:空游标对应第一页,格式不正确、超出页大小上限、或属于不同租户/状态的游标不能偷偷切换查询范围。服务端可明确拒绝不合法输入,或者将游标与筛选条件一起签名并在服务端解码验证;这是将来接口的设计选项,不是现有 JdbcRequestStore.find 的行为。若客户端从另一租户复制一个恰好存在的 ID,却仍把自己的租户传给服务端,SQL 必须继续按自己的租户筛选;即便对方的 ID 数值导致本次返回空页,也不得泄漏对方申请是否存在。这样的失败用例必须用真实受管身份再做权限验证,演示用的 X-Lab-Tenant 可伪造,单测 SQL 谓词不等于安全验收。
对于审批列表,还要考虑状态会迁移:申请在第一页显示为 SUBMITTED,下一刻被批准为 APPROVED,它可能从后续同状态页的集合中消失;此时“没有重复”不等于“遍历了某一时刻全部提交态申请”。如果产品只需展示此刻的待办,逐页读最新集合可以接受,但必须声明对并发变化的容忍度;如果产品要求按某个截止时间导出完整审计清单,应使用可重放的快照或持久化导出任务,并核对最终行数与版本边界。第 20–21 篇将处理数据库隔离及并发审批;本篇既没有列表接口,也没有导出快照证据。
单次查询内部也要固定关系读取策略。若先选出申请 ID,然后查询明细时该申请被另一个事务修改,第二阶段可能读到与第一阶段不同时间点的数据;在只读页面可接受与否须由接口明确说明。若页面需要申请状态、版本和明细属于同一数据库快照,就要在真实事务和 PostgreSQL 的隔离级别下验证两次 SQL 的一致性,而不是假设 JDBC 一次 getConnection() 自动提供快照。对相同申请 ID 逐行读取明细虽然便于编码,却会让查询数随页大小增长,必须把“正确的一页”与“能在有界查询数内拿到一页”同时列为验收条件。
两道练习与答案
练习一:一张申请有四条明细。先 JOIN 再 LIMIT 3,结果返回三行。能称“本页有三张申请”吗?给出一个不改变采购规则的修法。
答案:不能,三个 SQL 行可能都属于同一申请。先按租户、状态及唯一排序键取三张申请的 ID,再按该 ID 集合批量读取明细,核对三张不同申请及其完整明细;或者用等价且可验证的聚合策略。必须记录两阶段的 SQL 条数和输出行,不能拿 JPA 的缓存统计代替。
练习二:已按 ORDER BY id DESC LIMIT 20 读第一页,更大的新申请 ID 在两页之间插入;第二页用 OFFSET 20。为什么仍可能重复?改成 id < last_id 后就能保证导出绝不漏单吗?
答案:偏移会在新的行集合上重新跳过二十条,集合变化可能让原有边界移动。唯一 ID 游标削弱这种位置移动,但状态变化或并发删除仍会改变筛选集,不等于一份跨请求快照。若要保证导出同一时点的全部申请,应增加明确的快照或版本契约并按事务隔离实验,不能从两个独立请求成功推出。
边界与资料
当前唯一实物索引在 examples/javaee-enterprise/db/migrations/001-initial.sql,读取入口在 adapters-jdbc/src/main/java/blog/javaee/jdbc/JdbcRequestStore.java;未来列表 SQL、性能结果、跨页异常均非现有运行事实。参考 PostgreSQL 16 LIMIT/OFFSET、索引使用与计划、EXPLAIN 与 Java SE 21 PreparedStatement。JPA 两篇的真实 post_link 用作概念对照,不为本工程授予未经测量的 SQL 次数或分页正确性。






