前面的 Testcontainers 集成测试已经用真实 PostgreSQL 验证了数据保存路径,数据库工程化章节也补齐了连接池与生产边界。随着 health_check_history 中的记录不断增加,接口不能再一次返回全部历史;客户端还需要按页读取、切换时间顺序,并选择是否只看某个服务。
这些需求表面上只是给 SQL 增加 LIMIT、OFFSET 和 ORDER BY,实际还涉及稳定排序、参数校验、动态 SQL 安全与分页元数据。本章继续使用 JdbcClient 手写 SQL,把这些规则逐层放进现有 Repository、Service、DTO 和 Controller。
1. 分页不只是截取结果
本章约定页码从 0 开始。第 page 页跳过的记录数可以写成:
1offset = page * size23page = 0, size = 20 -> offset = 04page = 1, size = 20 -> offset = 205page = 2, size = 20 -> offset = 40
一页查询至少要明确三个部分:
| 部分 | 作用 | 当前实现 |
|---|---|---|
| 筛选条件 | 决定哪些记录进入结果集 | 可选的 service |
| 排序规则 | 决定记录先后顺序 | checked_at 加 id |
| 窗口范围 | 决定本次返回哪一段 | LIMIT 加 OFFSET |
只有 ORDER BY checked_at DESC 还不够稳定。多条记录可能具有相同时间,尤其是同一个事务中的 current_timestamp 通常相同;数据库可以用任意顺序返回这些并列行,导致同一行在翻页时重复或遗漏。因此再用唯一的 id 打破平局:
1order by checked_at desc, id desc
前一章的 Flyway 迁移已经建立 (checked_at desc, id desc) 和 (service, checked_at desc, id desc) 两个索引,分别支持无服务条件和有服务条件的查询。PostgreSQL 的 B-tree 索引也可以反向扫描,所以同一组索引还能支持两个字段同时升序。
OFFSET 适合页数不深、允许直接跳页的管理列表。偏移量很大时,数据库仍要定位并跳过前面的行,性能会逐渐下降;持续滚动或海量记录更适合以后使用基于最后一条 (checked_at, id) 的游标分页。
2. 定义查询结果与排序
先给历史记录建立业务模型。它与表示最新状态的 HealthStatus 含义不同,因此使用独立 record:
01package cn.usehook.spring.health;0203import java.time.Instant;0405public record HealthCheckRecord(06long id,07String service,08String status,09Instant checkedAt10) {}
分页结果除了当前页数据,还要告诉客户端页码、每页数量和总数:
01package cn.usehook.spring.health;0203import java.util.List;0405public record PageResult<T>(06List<T> items,07int page,08int size,09long totalElements,10long totalPages11) {12public PageResult {13items = List.copyOf(items);14}15}
PageResult<T> 可以装任意类型的分页数据。紧凑构造器用不可变副本保存列表,防止调用方在结果创建后继续修改内部内容。
排序方向只开放业务允许的两种选择:
1package cn.usehook.spring.health;23public enum HistorySort {4NEWEST,5OLDEST6}
客户端不能直接提交任意 SQL 排序片段。SQL 参数只能绑定值,不能把列名或 ASC、DESC 当成普通参数绑定;使用 enum 和 switch 可以把外部选项转换成代码中预先写好的 SQL 结构。
3. 在 Repository 组合查询
在 HealthCheckHistoryRepository 中增加行映射器和分页方法:
01package cn.usehook.spring.health;0203import java.time.OffsetDateTime;04import java.util.HashMap;05import java.util.List;06import java.util.Map;07import java.util.Objects;08import org.springframework.jdbc.core.RowMapper;09import org.springframework.jdbc.core.simple.JdbcClient;10import org.springframework.stereotype.Repository;1112@Repository13public class HealthCheckHistoryRepository {1415private static final RowMapper<HealthCheckRecord> ROW_MAPPER = (resultSet, rowNumber) ->16new HealthCheckRecord(17resultSet.getLong("id"),18resultSet.getString("service"),19resultSet.getString("status"),20resultSet.getObject("checked_at", OffsetDateTime.class).toInstant()21);2223private final JdbcClient jdbcClient;2425public HealthCheckHistoryRepository(JdbcClient jdbcClient) {26this.jdbcClient = jdbcClient;27}2829public void append(HealthStatus status) {30Objects.requireNonNull(status, "status must not be null");3132jdbcClient.sql("""33insert into health_check_history (service, status)34values (:service, :status)35""")36.param("service", status.service())37.param("status", status.status())38.update();39}4041public PageResult<HealthCheckRecord> findPage(42String service,43int page,44int size,45HistorySort sort46) {47String whereSql = service == null ? "" : " where service = :service";48String orderSql = switch (sort) {49case NEWEST -> " order by checked_at desc, id desc";50case OLDEST -> " order by checked_at asc, id asc";51};52long offset = Math.multiplyExact((long) page, size);5354Map<String, Object> parameters = new HashMap<>();55if (service != null) {56parameters.put("service", service);57}5859String dataSql = """60select id, service, status, checked_at61from health_check_history62""" + whereSql + orderSql + " limit :limit offset :offset";6364List<HealthCheckRecord> items = jdbcClient.sql(dataSql)65.params(parameters)66.param("limit", size)67.param("offset", offset)68.query(ROW_MAPPER)69.list();7071String countSql = """72select count(*)73from health_check_history74""" + whereSql;7576long totalElements = jdbcClient.sql(countSql)77.params(parameters)78.query(Long.class)79.single();80long totalPages = (totalElements + size - 1) / size;8182return new PageResult<>(items, page, size, totalElements, totalPages);83}84}
只有 whereSql 和 orderSql 被拼进 SQL,而且它们完全由代码中的分支生成。真正来自客户端的 service、size 和 offset 仍然使用命名参数绑定。不要把请求中的字段直接拼进查询字符串。
数据查询与总数查询必须使用相同的筛选条件,否则页内容和 totalElements 会描述两个不同的结果集。当前实现执行两条 SQL;在 PostgreSQL 默认的 READ COMMITTED 隔离级别下,它们之间如果恰好有其他事务提交,总数和当前页可能出现短暂差异。普通列表通常可以接受这种变化,需要严格一致的快照时才考虑更高隔离级别及其并发代价。
4. Service 处理业务输入
Repository 只接受已经确定的查询条件,服务名称规范化继续由 HealthService 负责:
01@Transactional(readOnly = true)02public PageResult<HealthCheckRecord> findHistory(03String service,04int page,05int size,06HistorySort sort07) {08String normalizedService = service == null ? null : normalizeService(service);09return historyRepository.findPage(normalizedService, page, size, sort);10}
service 是可选条件:没有传递时查询全部服务,传递后仍复用原来的 normalizeService(),所以空白名称不会悄悄变成无筛选查询。页码与每页数量属于 HTTP 参数规则,稍后由 Controller 的校验注解限制。
这个方法只读数据库,因此使用 @Transactional(readOnly = true)。它还让数据查询和总数查询处于同一个事务边界,但 readOnly 本身并不会把默认隔离级别提升为快照读取。
5. DTO 与查询接口
业务模型不直接充当响应格式。创建一个分页响应 DTO,并在边界处完成转换:
01package cn.usehook.spring.controller.dto;0203import cn.usehook.spring.health.HealthCheckRecord;04import cn.usehook.spring.health.PageResult;05import java.time.Instant;06import java.util.List;0708public record HealthHistoryPageResponse(int code, String message, Data data) {0910public static HealthHistoryPageResponse success(PageResult<HealthCheckRecord> page) {11List<Item> items = page.items().stream()12.map(Item::from)13.toList();1415Data data = new Data(16items,17page.page(),18page.size(),19page.totalElements(),20page.totalPages()21);22return new HealthHistoryPageResponse(0, "success", data);23}2425public record Data(26List<Item> items,27int page,28int size,29long totalElements,30long totalPages31) {}3233public record Item(long id, String service, String status, Instant checkedAt) {34private static Item from(HealthCheckRecord record) {35return new Item(36record.id(),37record.service(),38record.status(),39record.checkedAt()40);41}42}43}
在 HealthController 中增加 GET 接口:
01@GetMapping("/history")02public HealthHistoryPageResponse history(03@RequestParam(required = false) String service,04@RequestParam(defaultValue = "0") @Min(0) int page,05@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,06@RequestParam(defaultValue = "NEWEST") HistorySort sort07) {08PageResult<HealthCheckRecord> result = healthService.findHistory(09service,10page,11size,12sort13);14return HealthHistoryPageResponse.success(result);15}
需要补充这些 import:
1import cn.usehook.spring.controller.dto.HealthHistoryPageResponse;2import cn.usehook.spring.health.HealthCheckRecord;3import cn.usehook.spring.health.HistorySort;4import cn.usehook.spring.health.PageResult;5import jakarta.validation.constraints.Max;6import jakarta.validation.constraints.Min;7import org.springframework.web.bind.annotation.RequestParam;
page 不能小于 0,size 被限制在 1 到 100,避免除零和一次查询过多数据。字符串会按 enum 名称转换,因此 sort=NEWEST 和 sort=OLDEST 有效,其他值会在进入业务方法前被 Spring MVC 拒绝。「统一异常处理」可以继续把参数绑定和方法校验异常转换成统一错误响应。
6. 请求并验证结果
先调用检查接口生成若干历史记录,再请求第一页:
1curl 'http://localhost:8080/api/v1/health/history?page=0&size=5&sort=NEWEST'
只查询 PostgreSQL 服务并按时间正序返回:
1curl 'http://localhost:8080/api/v1/health/history?service=postgresql&page=0&size=5&sort=OLDEST'
可以沿用 Testcontainers 章节的容器配置编写集成测试:
01package cn.usehook.spring.health;0203import cn.usehook.spring.TestcontainersConfiguration;04import org.junit.jupiter.api.Test;05import org.springframework.beans.factory.annotation.Autowired;06import org.springframework.boot.test.context.SpringBootTest;07import org.springframework.context.annotation.Import;08import org.springframework.transaction.annotation.Transactional;0910import static org.assertj.core.api.Assertions.assertThat;1112@SpringBootTest13@Import(TestcontainersConfiguration.class)14@Transactional15class HealthHistoryQueryTests {1617private final HealthService healthService;1819@Autowired20HealthHistoryQueryTests(HealthService healthService) {21this.healthService = healthService;22}2324@Test25void filtersAndPaginatesHistory() {26for (int index = 0; index < 12; index++) {27healthService.check("pagination");28}2930PageResult<HealthCheckRecord> result = healthService.findHistory(31"pagination",321,335,34HistorySort.OLDEST35);3637assertThat(result.items()).hasSize(5);38assertThat(result.items()).allMatch(item -> item.service().equals("pagination"));39assertThat(result.page()).isEqualTo(1);40assertThat(result.totalElements()).isEqualTo(12);41assertThat(result.totalPages()).isEqualTo(3);42}43}
测试在同一事务中快速插入记录时,多个 checked_at 可能完全相同;id 作为第二排序字段仍然能给出确定顺序,这正好验证了复合排序的必要性。分页接口至此形成完整路径:Controller 校验请求,Service 规范化业务条件,Repository 安全组合 SQL,DTO 固定对外结构。
下一篇会在同一张历史表上引入 JPA,从手写 SQL 转向实体映射,并比较两种持久化方式各自负责的边界。