监控系统每隔几十秒追加一次检查记录。运行时间越长,health_check_history 越大:第一页依然很快,翻到很深的页却需要数据库跳过大量记录;保留所有历史又会持续占用存储。
本章接着分页、排序与动态查询,新增按游标读取的接口,并实现有上限的历史清理任务。继续使用 PostgreSQL、JdbcClient、V1 的历史表和 V2 的复合索引,不替换原来的页码接口,也不增加另一套持久化框架。
1. 从页码切换到位置
页码回答“跳过多少行”,游标回答“从哪一行之后继续”。监控历史固定按时间和主键倒序:
1select id, service, status, checked_at2from health_check_history3where service = :service4and (checked_at, id) < (:checkedAt, :id)5order by checked_at desc, id desc6limit :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。
01package cn.usehook.spring.history;0203import java.nio.charset.StandardCharsets;04import java.time.Instant;05import java.time.format.DateTimeParseException;06import java.util.Base64;0708public record HistoryCursor(String service, Instant checkedAt, long id) {09private static final Instant MIN_TIME = Instant.parse("0001-01-01T00:00:00Z");10private static final Instant MAX_TIME = Instant.parse("9999-12-31T23:59:59.999999999Z");1112public HistoryCursor {13if (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) {16throw new IllegalArgumentException("Invalid history cursor");17}18}1920public String encode() {21String value = "v1|" + service + "|" + checkedAt + "|" + id;22return Base64.getUrlEncoder().withoutPadding()23.encodeToString(value.getBytes(StandardCharsets.UTF_8));24}2526public static HistoryCursor decode(String token) {27if (token == null || token.isBlank() || token.length() > 512) {28throw new IllegalArgumentException("Invalid history cursor");29}30try {31String value = new String(32Base64.getUrlDecoder().decode(token), StandardCharsets.UTF_833);34String[] parts = value.split("\\|", -1);35if (parts.length != 4 || !parts[0].equals("v1")) {36throw new IllegalArgumentException("Invalid cursor version");37}38return new HistoryCursor(39parts[1], Instant.parse(parts[2]), Long.parseLong(parts[3])40);41} catch (IllegalArgumentException | DateTimeParseException exception) {42throw new IllegalArgumentException("Invalid history cursor", exception);43}44}45}
Base64 只是编码,不是签名或加密。客户端可以修改游标,所以它只能提供位置,不能携带可信的用户身份、权限或任意 SQL。即使以后增加签名,资源授权也必须在服务器重新执行。
时间直接来自数据库读取的 Instant,不要先转换成毫秒再编码,否则 PostgreSQL 的微秒精度可能丢失,造成边界记录重复或遗漏。本例约定历史时间位于公元 1 到 9999 年之间,拒绝 Instant 能解析、但后续日期转换或数据库无法处理的极端年份;写入和导入历史时也应遵守这一范围。游标协议发生变化时增加版本,不应在不改变版本的情况下重新解释旧字段。
3. 查询一页并生成下一页位置
创建独立查询类,复用之前定义的 HealthCheckRecord。它只处理查询,不承担探测、保存状态或触发通知。
01package cn.usehook.spring.history;0203import cn.usehook.spring.health.HealthCheckRecord;04import java.time.OffsetDateTime;05import java.time.ZoneOffset;06import java.util.HashMap;07import java.util.List;08import java.util.Map;09import org.springframework.jdbc.core.simple.JdbcClient;10import org.springframework.stereotype.Service;11import org.springframework.transaction.annotation.Transactional;1213@Service14public class HistoryWindowReader {15private final JdbcClient jdbc;1617public HistoryWindowReader(JdbcClient jdbc) {18this.jdbc = jdbc;19}2021@Transactional(readOnly = true)22public Window read(String service, int size, HistoryCursor cursor) {23if (service == null || !service.matches("[a-z0-9][a-z0-9_-]{0,49}")24|| size < 1 || size > 100) {25throw new IllegalArgumentException("Invalid history query");26}27if (cursor != null && !cursor.service().equals(service)) {28throw new IllegalArgumentException("Cursor belongs to another service");29}3031Map<String, Object> parameters = new HashMap<>();32parameters.put("service", service);33parameters.put("limit", size + 1);34String boundary = "";35if (cursor != null) {36boundary = " and (checked_at, id) < (:checkedAt, :id)";37parameters.put("checkedAt", cursor.checkedAt().atOffset(ZoneOffset.UTC));38parameters.put("id", cursor.id());39}4041List<HealthCheckRecord> rows = jdbc.sql("""42select id, service, status, checked_at43from health_check_history44where service = :service45""" + boundary + " order by checked_at desc, id desc limit :limit")46.params(parameters)47.query((rs, row) -> new HealthCheckRecord(48rs.getLong("id"), rs.getString("service"), rs.getString("status"),49rs.getObject("checked_at", OffsetDateTime.class).toInstant()50))51.list();5253boolean hasMore = rows.size() > size;54List<HealthCheckRecord> items = List.copyOf(55rows.subList(0, Math.min(size, rows.size()))56);57String nextCursor = null;58if (hasMore) {59HealthCheckRecord last = items.getLast();60nextCursor = new HistoryCursor(61last.service(), last.checkedAt(), last.id()62).encode();63}64return new Window(items, nextCursor);65}6667public record Window(List<HealthCheckRecord> items, String nextCursor) {}68}
SQL 中拼接的只有程序自己选择的固定条件,外部值全部绑定为参数。第一页没有游标,后续页使用严格小于,不能使用小于等于,否则上一页最后一行会再次出现。
多查的那一条只用于判断 hasMore。下一页游标必须取“实际返回的最后一条”,不能取多查出来的行,否则会漏掉一条记录。
4. 新增 HTTP 接口
新路径放在现有 /api/v1/health/** 下,不需要修改原来的页码查询方法。只完成 JWT 入门篇时,该路径使用 SCOPE_monitor;按本组顺序完成资源级权限后,整个全局历史接口应已收紧为 SCOPE_monitor.admin,仅供运维管理使用。
01package cn.usehook.spring.controller;0203import cn.usehook.spring.history.HistoryCursor;04import cn.usehook.spring.history.HistoryWindowReader;05import jakarta.validation.constraints.Max;06import jakarta.validation.constraints.Min;07import jakarta.validation.constraints.Pattern;08import java.time.Instant;09import java.util.List;10import org.springframework.http.HttpStatus;11import org.springframework.web.bind.annotation.GetMapping;12import org.springframework.web.bind.annotation.RequestParam;13import org.springframework.web.bind.annotation.RestController;14import org.springframework.web.server.ResponseStatusException;1516@RestController17public class HistoryCursorController {18private final HistoryWindowReader reader;1920public HistoryCursorController(HistoryWindowReader reader) {21this.reader = reader;22}2324@GetMapping("/api/v1/health/history/cursor")25public 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 cursor29) {30HistoryWindowReader.Window window;31try {32window = reader.read(33service, size, cursor == null ? null : HistoryCursor.decode(cursor)34);35} catch (IllegalArgumentException exception) {36throw new ResponseStatusException(HttpStatus.BAD_REQUEST, exception.getMessage());37}38List<Item> items = window.items().stream().map(row -> new Item(39Long.toString(row.id()), row.service(), row.status(), row.checkedAt()40)).toList();41return new Response(0, "success", new Data(items, window.nextCursor()));42}4344public record Response(int code, String message, Data data) {}45public record Data(List<Item> items, String nextCursor) {}46public 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 原样用于下一次请求:
01curl --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'0506curl --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 条,下一次再继续;数据积压明显时,应先观察清理速率和数据库负载,再调整批次与间隔。
01package cn.usehook.spring.history;0203import org.slf4j.Logger;04import org.slf4j.LoggerFactory;05import org.springframework.beans.factory.annotation.Value;06import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;07import org.springframework.jdbc.core.simple.JdbcClient;08import org.springframework.scheduling.annotation.Scheduled;09import org.springframework.stereotype.Component;1011@Component12@ConditionalOnProperty(13prefix = "app.history.retention", name = "enabled", havingValue = "true"14)15public class HistoryRetention {16private static final Logger log = LoggerFactory.getLogger(HistoryRetention.class);17private final JdbcClient jdbc;18private final int days;19private final int batchSize;2021public HistoryRetention(22JdbcClient jdbc,23@Value("${app.history.retention.days:90}") int days,24@Value("${app.history.retention.batch-size:500}") int batchSize25) {26if (days < 1 || batchSize < 1 || batchSize > 5000) {27throw new IllegalArgumentException("Invalid history retention settings");28}29this.jdbc = jdbc;30this.days = days;31this.batchSize = batchSize;32}3334@Scheduled(35fixedDelayString = "${app.history.retention.delay:60s}",36initialDelayString = "${app.history.retention.initial-delay:60s}"37)38public void removeExpiredBatch() {39int removed = jdbc.sql("""40with expired as (41select id42from health_check_history43where checked_at < current_timestamp - (:days * interval '1 day')44order by checked_at, id45limit :batchSize46for update skip locked47)48delete from health_check_history history49using expired50where history.id = expired.id51""")52.param("days", days)53.param("batchSize", batchSize)54.update();55log.info("Removed {} expired history rows", removed);56}57}
复用已有 TaskConfiguration 的 @EnableScheduling。默认不创建清理 Bean;确认保留策略后才在运行环境启用:
1app:2history:3retention:4enabled: true5days: 906batch-size: 5007delay: 60s8initial-delay: 60s
选择、锁定与删除发生在同一条 SQL 中。多个实例同时执行时,SKIP LOCKED 会跳过其他实例正在处理的行;每条语句由数据库事务保证原子性,不需要额外使用应用层分布式锁。这里适合清理队列,不应把跳过锁定行的行为用作普通历史查询,否则会漏读。
清理只影响历史表,不删除 health_status 最新状态,也不改变告警任务。删除后 PostgreSQL 还需要依靠 VACUUM 回收行版本供后续写入复用;它不保证立即缩小操作系统看到的文件大小。
6. 验证边界
延续 Testcontainers 配置,先验证最容易被忽略的“相同时间戳”场景:
01package cn.usehook.spring.history;0203import cn.usehook.spring.TestcontainersConfiguration;04import java.nio.charset.StandardCharsets;05import java.util.ArrayList;06import java.util.Base64;07import java.util.List;08import org.junit.jupiter.api.Test;09import org.springframework.beans.factory.annotation.Autowired;10import org.springframework.boot.test.context.SpringBootTest;11import org.springframework.context.annotation.Import;12import org.springframework.jdbc.core.simple.JdbcClient;13import org.springframework.security.oauth2.jwt.JwtDecoder;14import org.springframework.test.context.bean.override.mockito.MockitoBean;15import org.springframework.transaction.annotation.Transactional;1617import static org.assertj.core.api.Assertions.assertThat;18import static org.assertj.core.api.Assertions.assertThatThrownBy;1920@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@Transactional28class HistoryWindowReaderTests {29@Autowired JdbcClient jdbc;30@Autowired HistoryWindowReader reader;31@MockitoBean JwtDecoder jwtDecoder;3233@Test34void rejectsExtremeTimestampBeforeQueryingTheDatabase() {35String token = Base64.getUrlEncoder().withoutPadding().encodeToString(36"v1|cursor-test|+1000000000-01-01T00:00:00Z|1"37.getBytes(StandardCharsets.UTF_8)38);39assertThatThrownBy(() -> HistoryCursor.decode(token))40.isInstanceOf(IllegalArgumentException.class)41.hasMessage("Invalid history cursor");42}4344@Test45void pagesEqualTimestampsWithoutRepeatingTheBoundaryRow() {46jdbc.sql("delete from health_check_history where service = 'cursor-test'").update();47List<Long> ids = new ArrayList<>();48for (int i = 0; i < 3; i++) {49ids.add(jdbc.sql("""50insert into health_check_history (service, status, checked_at)51values ('cursor-test', 'UP', timestamptz '2026-01-01 00:00:00+00')52returning id53""").query(Long.class).single());54}55var first = reader.read("cursor-test", 2, null);56var second = reader.read(57"cursor-test", 2, HistoryCursor.decode(first.nextCursor())58);59assertThat(first.items()).extracting(row -> row.id())60.containsExactly(ids.get(2), ids.get(1));61assertThat(second.items()).extracting(row -> row.id())62.containsExactly(ids.get(0));63assertThat(second.nextCursor()).isNull();64assertThatThrownBy(() -> 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。