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

监控系统每隔几十秒追加一次检查记录。运行时间越长,health_check_history 越大:第一页依然很快,翻到很深的页却需要数据库跳过大量记录;保留所有历史又会持续占用存储。

本章接着分页、排序与动态查询,新增按游标读取的接口,并实现有上限的历史清理任务。继续使用 PostgreSQL、JdbcClient、V1 的历史表和 V2 的复合索引,不替换原来的页码接口,也不增加另一套持久化框架。

1. 从页码切换到位置

页码回答“跳过多少行”,游标回答“从哪一行之后继续”。监控历史固定按时间和主键倒序:

next-history-window.sql
1
select id, service, status, checked_at
2
from health_check_history
3
where service = :service
4
and (checked_at, id) < (:checkedAt, :id)
5
order by checked_at desc, id desc
6
limit :limit;

PostgreSQL 按元组从左到右比较:先比较时间,相同时再比较 ID。两个排序字段都非空,且 ID 唯一,因此可以明确表达上一页最后一行的位置。

设计本章约定
查询范围必须指定一个服务名称
排序checked_at DESC, id DESC,不允许翻页时改变
每页大小1 到 100 条
后续页使用上一页返回的 nextCursor
是否还有下一页多查一条判断,不再执行总数查询
游标中的 ID按字符串编码,避免浏览器大整数精度损失

V2 已有 (service, checked_at DESC, id DESC) 索引,不需要为同一个查询重复建索引。游标也不是数据库快照:新插入的较新记录通常不会挤入下一页,但迟提交的旧时间记录、历史更新和清理仍可能改变后续结果。导出严格一致的数据,需要单独设计快照或固定数据集。

2. 定义游标协议

游标采用 URL 安全的 Base64 编码,内容包含版本、服务名、时间和 ID。服务名也放进去,是为了拒绝把服务 A 的翻页位置误用于服务 B。

src/main/java/cn/usehook/spring/history/HistoryCursor.java
01
package cn.usehook.spring.history;
02
03
import java.nio.charset.StandardCharsets;
04
import java.time.Instant;
05
import java.time.format.DateTimeParseException;
06
import java.util.Base64;
07
08
public record HistoryCursor(String service, Instant checkedAt, long id) {
09
private static final Instant MIN_TIME = Instant.parse("0001-01-01T00:00:00Z");
10
private static final Instant MAX_TIME = Instant.parse("9999-12-31T23:59:59.999999999Z");
11
12
public HistoryCursor {
13
if (service == null || !service.matches("[a-z0-9][a-z0-9_-]{0,49}")
14
|| checkedAt == null || checkedAt.isBefore(MIN_TIME)
15
|| checkedAt.isAfter(MAX_TIME) || id <= 0) {
16
throw new IllegalArgumentException("Invalid history cursor");
17
}
18
}
19
20
public String encode() {
21
String value = "v1|" + service + "|" + checkedAt + "|" + id;
22
return Base64.getUrlEncoder().withoutPadding()
23
.encodeToString(value.getBytes(StandardCharsets.UTF_8));
24
}
25
26
public static HistoryCursor decode(String token) {
27
if (token == null || token.isBlank() || token.length() > 512) {
28
throw new IllegalArgumentException("Invalid history cursor");
29
}
30
try {
31
String value = new String(
32
Base64.getUrlDecoder().decode(token), StandardCharsets.UTF_8
33
);
34
String[] parts = value.split("\\|", -1);
35
if (parts.length != 4 || !parts[0].equals("v1")) {
36
throw new IllegalArgumentException("Invalid cursor version");
37
}
38
return new HistoryCursor(
39
parts[1], Instant.parse(parts[2]), Long.parseLong(parts[3])
40
);
41
} catch (IllegalArgumentException | DateTimeParseException exception) {
42
throw new IllegalArgumentException("Invalid history cursor", exception);
43
}
44
}
45
}

Base64 只是编码,不是签名或加密。客户端可以修改游标,所以它只能提供位置,不能携带可信的用户身份、权限或任意 SQL。即使以后增加签名,资源授权也必须在服务器重新执行。

时间直接来自数据库读取的 Instant,不要先转换成毫秒再编码,否则 PostgreSQL 的微秒精度可能丢失,造成边界记录重复或遗漏。本例约定历史时间位于公元 1 到 9999 年之间,拒绝 Instant 能解析、但后续日期转换或数据库无法处理的极端年份;写入和导入历史时也应遵守这一范围。游标协议发生变化时增加版本,不应在不改变版本的情况下重新解释旧字段。

3. 查询一页并生成下一页位置

创建独立查询类,复用之前定义的 HealthCheckRecord。它只处理查询,不承担探测、保存状态或触发通知。

src/main/java/cn/usehook/spring/history/HistoryWindowReader.java
01
package cn.usehook.spring.history;
02
03
import cn.usehook.spring.health.HealthCheckRecord;
04
import java.time.OffsetDateTime;
05
import java.time.ZoneOffset;
06
import java.util.HashMap;
07
import java.util.List;
08
import java.util.Map;
09
import org.springframework.jdbc.core.simple.JdbcClient;
10
import org.springframework.stereotype.Service;
11
import org.springframework.transaction.annotation.Transactional;
12
13
@Service
14
public class HistoryWindowReader {
15
private final JdbcClient jdbc;
16
17
public HistoryWindowReader(JdbcClient jdbc) {
18
this.jdbc = jdbc;
19
}
20
21
@Transactional(readOnly = true)
22
public Window read(String service, int size, HistoryCursor cursor) {
23
if (service == null || !service.matches("[a-z0-9][a-z0-9_-]{0,49}")
24
|| size < 1 || size > 100) {
25
throw new IllegalArgumentException("Invalid history query");
26
}
27
if (cursor != null && !cursor.service().equals(service)) {
28
throw new IllegalArgumentException("Cursor belongs to another service");
29
}
30
31
Map<String, Object> parameters = new HashMap<>();
32
parameters.put("service", service);
33
parameters.put("limit", size + 1);
34
String boundary = "";
35
if (cursor != null) {
36
boundary = " and (checked_at, id) < (:checkedAt, :id)";
37
parameters.put("checkedAt", cursor.checkedAt().atOffset(ZoneOffset.UTC));
38
parameters.put("id", cursor.id());
39
}
40
41
List<HealthCheckRecord> rows = jdbc.sql("""
42
select id, service, status, checked_at
43
from health_check_history
44
where service = :service
45
""" + boundary + " order by checked_at desc, id desc limit :limit")
46
.params(parameters)
47
.query((rs, row) -> new HealthCheckRecord(
48
rs.getLong("id"), rs.getString("service"), rs.getString("status"),
49
rs.getObject("checked_at", OffsetDateTime.class).toInstant()
50
))
51
.list();
52
53
boolean hasMore = rows.size() > size;
54
List<HealthCheckRecord> items = List.copyOf(
55
rows.subList(0, Math.min(size, rows.size()))
56
);
57
String nextCursor = null;
58
if (hasMore) {
59
HealthCheckRecord last = items.getLast();
60
nextCursor = new HistoryCursor(
61
last.service(), last.checkedAt(), last.id()
62
).encode();
63
}
64
return new Window(items, nextCursor);
65
}
66
67
public record Window(List<HealthCheckRecord> items, String nextCursor) {}
68
}

SQL 中拼接的只有程序自己选择的固定条件,外部值全部绑定为参数。第一页没有游标,后续页使用严格小于,不能使用小于等于,否则上一页最后一行会再次出现。

多查的那一条只用于判断 hasMore。下一页游标必须取“实际返回的最后一条”,不能取多查出来的行,否则会漏掉一条记录。

4. 新增 HTTP 接口

新路径放在现有 /api/v1/health/** 下,不需要修改原来的页码查询方法。只完成 JWT 入门篇时,该路径使用 SCOPE_monitor;按本组顺序完成资源级权限后,整个全局历史接口应已收紧为 SCOPE_monitor.admin,仅供运维管理使用。

src/main/java/cn/usehook/spring/controller/HistoryCursorController.java
01
package cn.usehook.spring.controller;
02
03
import cn.usehook.spring.history.HistoryCursor;
04
import cn.usehook.spring.history.HistoryWindowReader;
05
import jakarta.validation.constraints.Max;
06
import jakarta.validation.constraints.Min;
07
import jakarta.validation.constraints.Pattern;
08
import java.time.Instant;
09
import java.util.List;
10
import org.springframework.http.HttpStatus;
11
import org.springframework.web.bind.annotation.GetMapping;
12
import org.springframework.web.bind.annotation.RequestParam;
13
import org.springframework.web.bind.annotation.RestController;
14
import org.springframework.web.server.ResponseStatusException;
15
16
@RestController
17
public class HistoryCursorController {
18
private final HistoryWindowReader reader;
19
20
public HistoryCursorController(HistoryWindowReader reader) {
21
this.reader = reader;
22
}
23
24
@GetMapping("/api/v1/health/history/cursor")
25
public Response history(
26
@RequestParam @Pattern(regexp = "[a-z0-9][a-z0-9_-]{0,49}") String service,
27
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
28
@RequestParam(required = false) String cursor
29
) {
30
HistoryWindowReader.Window window;
31
try {
32
window = reader.read(
33
service, size, cursor == null ? null : HistoryCursor.decode(cursor)
34
);
35
} catch (IllegalArgumentException exception) {
36
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, exception.getMessage());
37
}
38
List<Item> items = window.items().stream().map(row -> new Item(
39
Long.toString(row.id()), row.service(), row.status(), row.checkedAt()
40
)).toList();
41
return new Response(0, "success", new Data(items, window.nextCursor()));
42
}
43
44
public record Response(int code, String message, Data data) {}
45
public record Data(List<Item> items, String nextCursor) {}
46
public record Item(String id, String service, String status, Instant checkedAt) {}
47
}

这里提供的是运维全局历史接口,普通 monitor 用户不能访问。若为普通用户增加历史查询,应放在 /api/v1/monitoring/services/{id}/history 下,从 JWT 创建 MonitoringActor,调用资源权限篇的 AuthorizedMonitoringService.get(id, actor) 后,使用返回的 ServiceView.name() 查询历史。客户端只能提供资源 ID,不能绕过归属校验另传一个服务名;列表查询也必须按成员关系过滤。成员权限可能随时变化,不能把曾经签发的游标当作持续授权。

请求第一页,并将响应中的 data.nextCursor 原样用于下一次请求:

terminal.bash
01
curl --get 'http://localhost:8080/api/v1/health/history/cursor' \
02
-H "Authorization: Bearer ${MONITOR_TOKEN}" \
03
--data-urlencode 'service=inventory' \
04
--data-urlencode 'size=20'
05
06
curl --get 'http://localhost:8080/api/v1/health/history/cursor' \
07
-H "Authorization: Bearer ${MONITOR_TOKEN}" \
08
--data-urlencode 'service=inventory' \
09
--data-urlencode 'size=20' \
10
--data-urlencode "cursor=${NEXT_CURSOR}"

MONITOR_TOKEN 是具备运维 monitor.admin Scope 的访问令牌,NEXT_CURSOR 是第一条响应中的字符串。没有后续数据时,nextCursor 为 null,客户端应停止继续请求。

5. 历史保留与小批量清理

先定义业务保留策略,例如明细保存 90 天。这个值应由产品需求决定;如果历史用于审计,必须先完成归档和可恢复性确认,不能直接套用下面的删除任务。

一个无限循环的大事务会长时间占用连接和锁。这里每次调度最多删除 500 条,下一次再继续;数据积压明显时,应先观察清理速率和数据库负载,再调整批次与间隔。

src/main/java/cn/usehook/spring/history/HistoryRetention.java
01
package cn.usehook.spring.history;
02
03
import org.slf4j.Logger;
04
import org.slf4j.LoggerFactory;
05
import org.springframework.beans.factory.annotation.Value;
06
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
07
import org.springframework.jdbc.core.simple.JdbcClient;
08
import org.springframework.scheduling.annotation.Scheduled;
09
import org.springframework.stereotype.Component;
10
11
@Component
12
@ConditionalOnProperty(
13
prefix = "app.history.retention", name = "enabled", havingValue = "true"
14
)
15
public class HistoryRetention {
16
private static final Logger log = LoggerFactory.getLogger(HistoryRetention.class);
17
private final JdbcClient jdbc;
18
private final int days;
19
private final int batchSize;
20
21
public HistoryRetention(
22
JdbcClient jdbc,
23
@Value("${app.history.retention.days:90}") int days,
24
@Value("${app.history.retention.batch-size:500}") int batchSize
25
) {
26
if (days < 1 || batchSize < 1 || batchSize > 5000) {
27
throw new IllegalArgumentException("Invalid history retention settings");
28
}
29
this.jdbc = jdbc;
30
this.days = days;
31
this.batchSize = batchSize;
32
}
33
34
@Scheduled(
35
fixedDelayString = "${app.history.retention.delay:60s}",
36
initialDelayString = "${app.history.retention.initial-delay:60s}"
37
)
38
public void removeExpiredBatch() {
39
int removed = jdbc.sql("""
40
with expired as (
41
select id
42
from health_check_history
43
where checked_at < current_timestamp - (:days * interval '1 day')
44
order by checked_at, id
45
limit :batchSize
46
for update skip locked
47
)
48
delete from health_check_history history
49
using expired
50
where history.id = expired.id
51
""")
52
.param("days", days)
53
.param("batchSize", batchSize)
54
.update();
55
log.info("Removed {} expired history rows", removed);
56
}
57
}

复用已有 TaskConfiguration 的 @EnableScheduling。默认不创建清理 Bean;确认保留策略后才在运行环境启用:

src/main/resources/application-prod.yaml
1
app:
2
history:
3
retention:
4
enabled: true
5
days: 90
6
batch-size: 500
7
delay: 60s
8
initial-delay: 60s

选择、锁定与删除发生在同一条 SQL 中。多个实例同时执行时,SKIP LOCKED 会跳过其他实例正在处理的行;每条语句由数据库事务保证原子性,不需要额外使用应用层分布式锁。这里适合清理队列,不应把跳过锁定行的行为用作普通历史查询,否则会漏读。

清理只影响历史表,不删除 health_status 最新状态,也不改变告警任务。删除后 PostgreSQL 还需要依靠 VACUUM 回收行版本供后续写入复用;它不保证立即缩小操作系统看到的文件大小。

6. 验证边界

延续 Testcontainers 配置,先验证最容易被忽略的“相同时间戳”场景:

src/test/java/cn/usehook/spring/history/HistoryWindowReaderTests.java
01
package cn.usehook.spring.history;
02
03
import cn.usehook.spring.TestcontainersConfiguration;
04
import java.nio.charset.StandardCharsets;
05
import java.util.ArrayList;
06
import java.util.Base64;
07
import java.util.List;
08
import org.junit.jupiter.api.Test;
09
import org.springframework.beans.factory.annotation.Autowired;
10
import org.springframework.boot.test.context.SpringBootTest;
11
import org.springframework.context.annotation.Import;
12
import org.springframework.jdbc.core.simple.JdbcClient;
13
import org.springframework.security.oauth2.jwt.JwtDecoder;
14
import org.springframework.test.context.bean.override.mockito.MockitoBean;
15
import org.springframework.transaction.annotation.Transactional;
16
17
import static org.assertj.core.api.Assertions.assertThat;
18
import static org.assertj.core.api.Assertions.assertThatThrownBy;
19
20
@SpringBootTest(properties = {
21
"app.monitoring.initial-delay=1h",
22
"app.monitoring.jobs.enabled=false",
23
"app.alerts.enabled=false",
24
"app.history.retention.enabled=false"
25
})
26
@Import(TestcontainersConfiguration.class)
27
@Transactional
28
class HistoryWindowReaderTests {
29
@Autowired JdbcClient jdbc;
30
@Autowired HistoryWindowReader reader;
31
@MockitoBean JwtDecoder jwtDecoder;
32
33
@Test
34
void rejectsExtremeTimestampBeforeQueryingTheDatabase() {
35
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(
36
"v1|cursor-test|+1000000000-01-01T00:00:00Z|1"
37
.getBytes(StandardCharsets.UTF_8)
38
);
39
assertThatThrownBy(() -> HistoryCursor.decode(token))
40
.isInstanceOf(IllegalArgumentException.class)
41
.hasMessage("Invalid history cursor");
42
}
43
44
@Test
45
void pagesEqualTimestampsWithoutRepeatingTheBoundaryRow() {
46
jdbc.sql("delete from health_check_history where service = 'cursor-test'").update();
47
List<Long> ids = new ArrayList<>();
48
for (int i = 0; i < 3; i++) {
49
ids.add(jdbc.sql("""
50
insert into health_check_history (service, status, checked_at)
51
values ('cursor-test', 'UP', timestamptz '2026-01-01 00:00:00+00')
52
returning id
53
""").query(Long.class).single());
54
}
55
var first = reader.read("cursor-test", 2, null);
56
var second = reader.read(
57
"cursor-test", 2, HistoryCursor.decode(first.nextCursor())
58
);
59
assertThat(first.items()).extracting(row -> row.id())
60
.containsExactly(ids.get(2), ids.get(1));
61
assertThat(second.items()).extracting(row -> row.id())
62
.containsExactly(ids.get(0));
63
assertThat(second.nextCursor()).isNull();
64
assertThatThrownBy(() -> reader.read(
65
"another-service", 2, HistoryCursor.decode(first.nextCursor())
66
)).isInstanceOf(IllegalArgumentException.class);
67
}
68
}

测试用 JwtDecoder 替身隔离外部身份服务,并关闭后续章节的后台任务;这里验证数据库查询与游标,不验证 JWT 签名。继续覆盖以下条件,测试数据与断言都应由测试创建,不能依赖开发数据库里恰好有多少行:

场景预期
非法、超长或未知版本的游标400,而不是 500
刚好一整页,没有多出来的一条nextCursor 为 null
两页之间插入更晚的记录下一页不重复上一页边界;刷新第一页才能看到新记录
清理 600 条过期记录,批次为 500第一次删除 500 条,第二次删除余下 100 条
过期历史与新历史混合保留期限内的记录和最新状态表不被删除
没有身份或不具备服务读取权限在返回数据之前拒绝

验证清理 SQL 时可以直接构造 HistoryRetention(jdbc, 90, 500) 并调用一次方法,避免等待真实定时器。需要验证多个清理者时,让两条数据库连接使用独立事务;单个带 @Transactional 的测试方法不能证明跨连接锁竞争。

接下来可以为长期统计增加按天聚合表,或在数据规模确实需要时使用时间分区。分区会影响主键、约束和归档流程,应当作为新的迁移设计,而不是在现有表上直接复制一条 DDL。

7. 参考资料