跳到主内容
创建时间: 2026-09-16最后更新: 2026-09-16

前面的 Testcontainers 集成测试已经用真实 PostgreSQL 验证了数据保存路径,数据库工程化章节也补齐了连接池与生产边界。随着 health_check_history 中的记录不断增加,接口不能再一次返回全部历史;客户端还需要按页读取、切换时间顺序,并选择是否只看某个服务。

这些需求表面上只是给 SQL 增加 LIMIT、OFFSET 和 ORDER BY,实际还涉及稳定排序、参数校验、动态 SQL 安全与分页元数据。本章继续使用 JdbcClient 手写 SQL,把这些规则逐层放进现有 Repository、Service、DTO 和 Controller。

1. 分页不只是截取结果

本章约定页码从 0 开始。第 page 页跳过的记录数可以写成:

offset-formula.txt
1
offset = page * size
2
3
page = 0, size = 20 -> offset = 0
4
page = 1, size = 20 -> offset = 20
5
page = 2, size = 20 -> offset = 40

一页查询至少要明确三个部分:

部分作用当前实现
筛选条件决定哪些记录进入结果集可选的 service
排序规则决定记录先后顺序checked_at 加 id
窗口范围决定本次返回哪一段LIMIT 加 OFFSET

只有 ORDER BY checked_at DESC 还不够稳定。多条记录可能具有相同时间,尤其是同一个事务中的 current_timestamp 通常相同;数据库可以用任意顺序返回这些并列行,导致同一行在翻页时重复或遗漏。因此再用唯一的 id 打破平局:

stable-order.sql
1
order 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:

src/main/java/cn/usehook/spring/health/HealthCheckRecord.java
01
package cn.usehook.spring.health;
02
03
import java.time.Instant;
04
05
public record HealthCheckRecord(
06
long id,
07
String service,
08
String status,
09
Instant checkedAt
10
) {}

分页结果除了当前页数据,还要告诉客户端页码、每页数量和总数:

src/main/java/cn/usehook/spring/health/PageResult.java
01
package cn.usehook.spring.health;
02
03
import java.util.List;
04
05
public record PageResult<T>(
06
List<T> items,
07
int page,
08
int size,
09
long totalElements,
10
long totalPages
11
) {
12
public PageResult {
13
items = List.copyOf(items);
14
}
15
}

PageResult<T> 可以装任意类型的分页数据。紧凑构造器用不可变副本保存列表,防止调用方在结果创建后继续修改内部内容。

排序方向只开放业务允许的两种选择:

src/main/java/cn/usehook/spring/health/HistorySort.java
1
package cn.usehook.spring.health;
2
3
public enum HistorySort {
4
NEWEST,
5
OLDEST
6
}

客户端不能直接提交任意 SQL 排序片段。SQL 参数只能绑定值,不能把列名或 ASC、DESC 当成普通参数绑定;使用 enum 和 switch 可以把外部选项转换成代码中预先写好的 SQL 结构。

3. 在 Repository 组合查询

在 HealthCheckHistoryRepository 中增加行映射器和分页方法:

src/main/java/cn/usehook/spring/health/HealthCheckHistoryRepository.java
01
package cn.usehook.spring.health;
02
03
import java.time.OffsetDateTime;
04
import java.util.HashMap;
05
import java.util.List;
06
import java.util.Map;
07
import java.util.Objects;
08
import org.springframework.jdbc.core.RowMapper;
09
import org.springframework.jdbc.core.simple.JdbcClient;
10
import org.springframework.stereotype.Repository;
11
12
@Repository
13
public class HealthCheckHistoryRepository {
14
15
private static final RowMapper<HealthCheckRecord> ROW_MAPPER = (resultSet, rowNumber) ->
16
new HealthCheckRecord(
17
resultSet.getLong("id"),
18
resultSet.getString("service"),
19
resultSet.getString("status"),
20
resultSet.getObject("checked_at", OffsetDateTime.class).toInstant()
21
);
22
23
private final JdbcClient jdbcClient;
24
25
public HealthCheckHistoryRepository(JdbcClient jdbcClient) {
26
this.jdbcClient = jdbcClient;
27
}
28
29
public void append(HealthStatus status) {
30
Objects.requireNonNull(status, "status must not be null");
31
32
jdbcClient.sql("""
33
insert into health_check_history (service, status)
34
values (:service, :status)
35
""")
36
.param("service", status.service())
37
.param("status", status.status())
38
.update();
39
}
40
41
public PageResult<HealthCheckRecord> findPage(
42
String service,
43
int page,
44
int size,
45
HistorySort sort
46
) {
47
String whereSql = service == null ? "" : " where service = :service";
48
String orderSql = switch (sort) {
49
case NEWEST -> " order by checked_at desc, id desc";
50
case OLDEST -> " order by checked_at asc, id asc";
51
};
52
long offset = Math.multiplyExact((long) page, size);
53
54
Map<String, Object> parameters = new HashMap<>();
55
if (service != null) {
56
parameters.put("service", service);
57
}
58
59
String dataSql = """
60
select id, service, status, checked_at
61
from health_check_history
62
""" + whereSql + orderSql + " limit :limit offset :offset";
63
64
List<HealthCheckRecord> items = jdbcClient.sql(dataSql)
65
.params(parameters)
66
.param("limit", size)
67
.param("offset", offset)
68
.query(ROW_MAPPER)
69
.list();
70
71
String countSql = """
72
select count(*)
73
from health_check_history
74
""" + whereSql;
75
76
long totalElements = jdbcClient.sql(countSql)
77
.params(parameters)
78
.query(Long.class)
79
.single();
80
long totalPages = (totalElements + size - 1) / size;
81
82
return new PageResult<>(items, page, size, totalElements, totalPages);
83
}
84
}

只有 whereSql 和 orderSql 被拼进 SQL,而且它们完全由代码中的分支生成。真正来自客户端的 service、size 和 offset 仍然使用命名参数绑定。不要把请求中的字段直接拼进查询字符串。

数据查询与总数查询必须使用相同的筛选条件,否则页内容和 totalElements 会描述两个不同的结果集。当前实现执行两条 SQL;在 PostgreSQL 默认的 READ COMMITTED 隔离级别下,它们之间如果恰好有其他事务提交,总数和当前页可能出现短暂差异。普通列表通常可以接受这种变化,需要严格一致的快照时才考虑更高隔离级别及其并发代价。

4. Service 处理业务输入

Repository 只接受已经确定的查询条件,服务名称规范化继续由 HealthService 负责:

src/main/java/cn/usehook/spring/health/HealthService.java
01
@Transactional(readOnly = true)
02
public PageResult<HealthCheckRecord> findHistory(
03
String service,
04
int page,
05
int size,
06
HistorySort sort
07
) {
08
String normalizedService = service == null ? null : normalizeService(service);
09
return historyRepository.findPage(normalizedService, page, size, sort);
10
}

service 是可选条件:没有传递时查询全部服务,传递后仍复用原来的 normalizeService(),所以空白名称不会悄悄变成无筛选查询。页码与每页数量属于 HTTP 参数规则,稍后由 Controller 的校验注解限制。

这个方法只读数据库,因此使用 @Transactional(readOnly = true)。它还让数据查询和总数查询处于同一个事务边界,但 readOnly 本身并不会把默认隔离级别提升为快照读取。

5. DTO 与查询接口

业务模型不直接充当响应格式。创建一个分页响应 DTO,并在边界处完成转换:

src/main/java/cn/usehook/spring/controller/dto/HealthHistoryPageResponse.java
01
package cn.usehook.spring.controller.dto;
02
03
import cn.usehook.spring.health.HealthCheckRecord;
04
import cn.usehook.spring.health.PageResult;
05
import java.time.Instant;
06
import java.util.List;
07
08
public record HealthHistoryPageResponse(int code, String message, Data data) {
09
10
public static HealthHistoryPageResponse success(PageResult<HealthCheckRecord> page) {
11
List<Item> items = page.items().stream()
12
.map(Item::from)
13
.toList();
14
15
Data data = new Data(
16
items,
17
page.page(),
18
page.size(),
19
page.totalElements(),
20
page.totalPages()
21
);
22
return new HealthHistoryPageResponse(0, "success", data);
23
}
24
25
public record Data(
26
List<Item> items,
27
int page,
28
int size,
29
long totalElements,
30
long totalPages
31
) {}
32
33
public record Item(long id, String service, String status, Instant checkedAt) {
34
private static Item from(HealthCheckRecord record) {
35
return new Item(
36
record.id(),
37
record.service(),
38
record.status(),
39
record.checkedAt()
40
);
41
}
42
}
43
}

在 HealthController 中增加 GET 接口:

src/main/java/cn/usehook/spring/controller/HealthController.java
01
@GetMapping("/history")
02
public 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 sort
07
) {
08
PageResult<HealthCheckRecord> result = healthService.findHistory(
09
service,
10
page,
11
size,
12
sort
13
);
14
return HealthHistoryPageResponse.success(result);
15
}

需要补充这些 import:

health-controller-imports.java
1
import cn.usehook.spring.controller.dto.HealthHistoryPageResponse;
2
import cn.usehook.spring.health.HealthCheckRecord;
3
import cn.usehook.spring.health.HistorySort;
4
import cn.usehook.spring.health.PageResult;
5
import jakarta.validation.constraints.Max;
6
import jakarta.validation.constraints.Min;
7
import org.springframework.web.bind.annotation.RequestParam;

page 不能小于 0,size 被限制在 1 到 100,避免除零和一次查询过多数据。字符串会按 enum 名称转换,因此 sort=NEWEST 和 sort=OLDEST 有效,其他值会在进入业务方法前被 Spring MVC 拒绝。「统一异常处理」可以继续把参数绑定和方法校验异常转换成统一错误响应。

6. 请求并验证结果

先调用检查接口生成若干历史记录,再请求第一页:

terminal.bash
1
curl 'http://localhost:8080/api/v1/health/history?page=0&size=5&sort=NEWEST'

只查询 PostgreSQL 服务并按时间正序返回:

terminal.bash
1
curl 'http://localhost:8080/api/v1/health/history?service=postgresql&page=0&size=5&sort=OLDEST'

可以沿用 Testcontainers 章节的容器配置编写集成测试:

src/test/java/cn/usehook/spring/health/HealthHistoryQueryTests.java
01
package cn.usehook.spring.health;
02
03
import cn.usehook.spring.TestcontainersConfiguration;
04
import org.junit.jupiter.api.Test;
05
import org.springframework.beans.factory.annotation.Autowired;
06
import org.springframework.boot.test.context.SpringBootTest;
07
import org.springframework.context.annotation.Import;
08
import org.springframework.transaction.annotation.Transactional;
09
10
import static org.assertj.core.api.Assertions.assertThat;
11
12
@SpringBootTest
13
@Import(TestcontainersConfiguration.class)
14
@Transactional
15
class HealthHistoryQueryTests {
16
17
private final HealthService healthService;
18
19
@Autowired
20
HealthHistoryQueryTests(HealthService healthService) {
21
this.healthService = healthService;
22
}
23
24
@Test
25
void filtersAndPaginatesHistory() {
26
for (int index = 0; index < 12; index++) {
27
healthService.check("pagination");
28
}
29
30
PageResult<HealthCheckRecord> result = healthService.findHistory(
31
"pagination",
32
1,
33
5,
34
HistorySort.OLDEST
35
);
36
37
assertThat(result.items()).hasSize(5);
38
assertThat(result.items()).allMatch(item -> item.service().equals("pagination"));
39
assertThat(result.page()).isEqualTo(1);
40
assertThat(result.totalElements()).isEqualTo(12);
41
assertThat(result.totalPages()).isEqualTo(3);
42
}
43
}

测试在同一事务中快速插入记录时,多个 checked_at 可能完全相同;id 作为第二排序字段仍然能给出确定顺序,这正好验证了复合排序的必要性。分页接口至此形成完整路径:Controller 校验请求,Service 规范化业务条件,Repository 安全组合 SQL,DTO 固定对外结构。

下一篇会在同一张历史表上引入 JPA,从手写 SQL 转向实体映射,并比较两种持久化方式各自负责的边界。