监控页面可能反复读取同一项服务的名称、分组和启用状态。这些元数据变化较少,可以用缓存减少数据库查询。但是,探测目标是否存活、应用是否就绪,都需要反映当下的事实,不能用缓存中的 UP 代替实际检查。
本章在 Java 21、Spring Boot 4.1.1 的项目中为 monitored_service 增加 Redis 缓存,沿用 JPA 关联、审计与乐观锁章节的实体和 V1 至 V5 迁移。Redis 保存可重建的副本,PostgreSQL 仍然是事实来源,不新增数据库迁移。
1. 先定义允许缓存的边界
| 数据 | 本章是否缓存 | 原因 |
|---|---|---|
| 服务名称、分组编号、启用状态 | 是 | 页面展示可以接受短暂旧值 |
| 实时探测结果 | 否 | 缓存会掩盖刚发生的故障 |
| Actuator readiness/liveness | 否 | 平台需要根据当前状态决定流量和重启 |
| 用户是否拥有分组访问权 | 否 | 撤销权限应立即影响下一次请求 |
这里的 enabled 只供页面展示。调度器决定是否执行探测时继续读取数据库,不能因为缓存尚未过期而继续检查已停用的目标。权限校验也必须在访问缓存之前独立执行,每次命中缓存不能跳过资源归属检查。
采用的读取路径是:先查 Redis,未命中再查 PostgreSQL,把查询结果存入 Redis,最后返回 DTO。缓存最多保留 30 秒,更新提交后主动删除对应条目。这个约定是允许短暂旧值的缓存方案,不是数据库和 Redis 之间的强一致事务。
Spring Cache 负责方法拦截,Redis 负责存储。@Cacheable 命中时方法体不会执行,这也是不能把权限校验和缓存查询一起放在被缓存方法内部的原因。Spring Boot 缓存文档
2. 依赖与连接配置
保留现有 JDBC、JPA、Web MVC 和 PostgreSQL 依赖,再加入两个 Starter,版本仍交给 Boot Parent 管理:
1<dependency>2<groupId>org.springframework.boot</groupId>3<artifactId>spring-boot-starter-cache</artifactId>4</dependency>5<dependency>6<groupId>org.springframework.boot</groupId>7<artifactId>spring-boot-starter-data-redis</artifactId>8</dependency>
在现有 Compose 文件的 services 下合并 Redis 服务,不要覆盖 PostgreSQL 配置:
1services:2redis:3image: redis:7.4-alpine4ports:5- "127.0.0.1:6379:6379"
这个服务只用于本地学习,不开放到公网。生产环境使用私有网络、认证和必要的 TLS;缓存可重建,因此本例没有为 Redis 配置业务数据卷。
1spring:2data:3redis:4host: ${REDIS_HOST:localhost}5port: ${REDIS_PORT:6379}6connect-timeout: 500ms7timeout: 500ms
这两个超时限制 Redis 故障对正常请求的拖累,最终数值应结合部署网络测量。已有 spring 配置应合并到同一节点,不重复声明顶层键。
3. 用明确类型保存 JSON
不要直接缓存 JPA 实体。实体可能含延迟代理、双向关联和持久化上下文状态,跨请求反序列化之后也不再是受管理实体。为这个缓存单独定义一个小型读取结果:
1package cn.usehook.spring.monitoring;23public record MonitoredServiceMetadata(4long id, String name, long groupId, boolean enabled, long version5) {}
独立配置类启用缓存,并指定键前缀、有效期和序列化方式:
01package cn.usehook.spring.config;0203import cn.usehook.spring.monitoring.MonitoredServiceMetadata;04import java.time.Duration;05import java.util.Map;06import org.springframework.cache.annotation.EnableCaching;07import org.springframework.context.annotation.Bean;08import org.springframework.context.annotation.Configuration;09import org.springframework.data.redis.cache.RedisCacheConfiguration;10import org.springframework.data.redis.cache.RedisCacheManager;11import org.springframework.data.redis.cache.RedisCacheWriter;12import org.springframework.data.redis.connection.RedisConnectionFactory;13import org.springframework.data.redis.serializer.JacksonJsonRedisSerializer;14import org.springframework.data.redis.serializer.RedisSerializationContext;15import tools.jackson.databind.json.JsonMapper;1617@Configuration(proxyBeanMethods = false)18@EnableCaching19public class MetadataCacheConfiguration {20@Bean21RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {22var serializer = new JacksonJsonRedisSerializer<>(23JsonMapper.builder().build(), MonitoredServiceMetadata.class24);25var config = RedisCacheConfiguration.defaultCacheConfig()26.entryTtl(Duration.ofSeconds(30))27.disableCachingNullValues()28.prefixCacheNameWith("spring:metadata:v1:")29.serializeValuesWith(30RedisSerializationContext.SerializationPair.fromSerializer(serializer)31);32var writer = RedisCacheWriter.create(33connectionFactory, options -> options.immediateWrites()34);35return RedisCacheManager.builder(writer)36.withInitialCacheConfigurations(Map.of("service-metadata", config))37.disableCreateOnMissingCache()38.build();39}40}
缓存名称只有一个,禁止拼错名称时自动创建新缓存。默认的字符串键序列化器会把编号转成字符串,例如编号 77 对应 spring:metadata:v1:service-metadata::77。部署多个环境时,应使用不同 Redis 实例或不同环境前缀;改变 DTO 的不兼容结构时升级 v1,避免新代码读取旧结构。
本例使用 Jackson 3 的 tools.jackson 类型和 JacksonJsonRedisSerializer,不是旧教程中的 Jackson2JsonRedisSerializer。指定目标 record 后无需开启任意类型反序列化,也不需要缓存实体类名。序列化器 API
Spring Data Redis 4 的部分默认写操作可以异步完成。这里显式使用 immediateWrites(),使写入错误能够在当前调用中处理,便于理解后面的故障策略。Redis 4 迁移说明
4. 缓存读取,提交后删除
创建独立查询 Bean,由已有 Controller 或通过权限校验的 Service 调用;不能通过同一对象的 this.get() 调用绕过代理:
01package cn.usehook.spring.monitoring;0203import org.springframework.cache.annotation.Cacheable;04import org.springframework.http.HttpStatus;05import org.springframework.stereotype.Service;06import org.springframework.transaction.annotation.Transactional;07import org.springframework.web.server.ResponseStatusException;0809@Service10public class MetadataQueryService {11private final MonitoredServiceJpaRepository repository;1213public MetadataQueryService(MonitoredServiceJpaRepository repository) {14this.repository = repository;15}1617@Cacheable(cacheNames = "service-metadata", key = "#p0")18@Transactional(readOnly = true)19public MonitoredServiceMetadata get(long id) {20var entity = repository.findById(id)21.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,22"Monitoring resource not found"));23return new MonitoredServiceMetadata(24entity.getId(), entity.getName(), entity.getGroup().getId(),25entity.isEnabled(), entity.getVersion()26);27}28}
#p0 指第一个参数,不依赖编译器是否保留参数名。查询不到时抛出带 404 状态的 ResponseStatusException,不缓存空值。前文统一异常处理器会重新抛出实现了 ErrorResponse 的异常,由问题详情处理器保留这个状态;普通 IllegalArgumentException 会进入兜底 500,不能用它表示资源不存在。大量随机编号造成的数据库访问仍需限流,不能只靠“缓存空值”抵挡所有异常流量。
写入服务继续使用 JPA 脏检查和乐观锁,在同一事务里发布“元数据已修改”事件:
1package cn.usehook.spring.monitoring;23public record MetadataChanged(long serviceId) {}
01package cn.usehook.spring.monitoring;0203import org.springframework.context.ApplicationEventPublisher;04import org.springframework.http.HttpStatus;05import org.springframework.stereotype.Service;06import org.springframework.transaction.annotation.Transactional;07import org.springframework.web.server.ResponseStatusException;0809@Service10public class MonitoringService {11private final MonitoredServiceJpaRepository repository;12private final ApplicationEventPublisher events;1314public MonitoringService(15MonitoredServiceJpaRepository repository, ApplicationEventPublisher events16) {17this.repository = repository;18this.events = events;19}2021@Transactional22public void changeEnabled(long id, boolean enabled) {23var entity = repository.findById(id)24.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,25"Monitoring resource not found"));26entity.changeEnabled(enabled);27events.publishEvent(new MetadataChanged(id));28}29}
这是审计与乐观锁章节 MonitoringService 的替换版本,仅展示受信任的内部写入。对用户开放的入口继续使用资源级权限章节的 AuthorizedMonitoringService.changeEnabled,在它完成成员检查的事务中追加 MetadataChanged 发布,不要用这里的无身份参数方法替换受保护入口。重命名、修改分组、删除等影响 DTO 的路径也应发布相同事件。不要在写事务里调用缓存读取方法,它可能观察并缓存尚未提交的修改。
01package cn.usehook.spring.monitoring;0203import java.util.Objects;04import org.slf4j.Logger;05import org.slf4j.LoggerFactory;06import org.springframework.cache.Cache;07import org.springframework.cache.CacheManager;08import org.springframework.stereotype.Component;09import org.springframework.transaction.event.TransactionPhase;10import org.springframework.transaction.event.TransactionalEventListener;1112@Component13public class MetadataCacheInvalidator {14private static final Logger log = LoggerFactory.getLogger(MetadataCacheInvalidator.class);15private final Cache cache;1617public MetadataCacheInvalidator(CacheManager manager) {18this.cache = Objects.requireNonNull(manager.getCache("service-metadata"));19}2021@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)22public void onChanged(MetadataChanged event) {23try {24cache.evictIfPresent(event.serviceId());25} catch (RuntimeException failure) {26log.warn("Metadata cache eviction failed, serviceId={}", event.serviceId(), failure);27}28}29}
必须在提交成功后删除。提前删除会让并发请求从尚未更新的数据库读出旧值,再把它放回 Redis;事务回滚时也不应该因为一次未生效的写入改变缓存策略。AFTER_COMMIT 不会让 Redis 删除与 PostgreSQL 提交组成一个原子操作;进程在二者之间退出时仍可能遗漏删除,TTL 是本例的最终兜底。
5. 明确 Redis 故障时的行为
默认缓存异常可能让业务请求失败。本章允许元数据展示退回数据库,因此只把缓存读写异常降级;数据库查询失败仍直接向上抛出:
01package cn.usehook.spring.config;0203import org.slf4j.Logger;04import org.slf4j.LoggerFactory;05import org.springframework.cache.Cache;06import org.springframework.cache.annotation.CachingConfigurer;07import org.springframework.cache.interceptor.CacheErrorHandler;08import org.springframework.cache.interceptor.SimpleCacheErrorHandler;09import org.springframework.context.annotation.Configuration;1011@Configuration(proxyBeanMethods = false)12public class CacheFailureConfiguration implements CachingConfigurer {13private static final Logger log = LoggerFactory.getLogger(CacheFailureConfiguration.class);1415@Override16public CacheErrorHandler errorHandler() {17return new SimpleCacheErrorHandler() {18@Override19public void handleCacheGetError(RuntimeException error, Cache cache, Object key) {20log.warn("Cache read failed, cache={}", cache.getName(), error);21}2223@Override24public void handleCachePutError(25RuntimeException error, Cache cache, Object key, Object value26) {27log.warn("Cache write failed, cache={}", cache.getName(), error);28}29};30}31}
手动调用 cache.evictIfPresent() 不经过缓存注解的错误处理器,所以前一节在监听器内部单独捕获了失效错误。失败必须进入告警和监控,不能只吞掉异常。数据库已经提交,不能向用户承诺“更新失败已回滚”;此时页面可能暂时读取旧缓存。
Redis 长时间故障时,每次请求都可能等待超时并访问数据库。生产环境应限制入口并发、监控数据库压力,并根据故障率临时禁用缓存访问。不要无限重试 Redis,更不要自动切换为每个实例各自永久保存的一份本地缓存。
6. 验证命中、回滚与提交失效
验证应经过 Spring 代理和真实 Redis,直接 new MetadataQueryService() 无法验证 @Cacheable。沿用 PostgreSQL 的 TestcontainersConfiguration,另外导入下面的 Redis 容器配置;容器依赖已经由 Testcontainers 章节提供:
01package cn.usehook.spring;0203import org.springframework.boot.test.context.TestConfiguration;04import org.springframework.boot.testcontainers.service.connection.ServiceConnection;05import org.springframework.context.annotation.Bean;06import org.testcontainers.containers.GenericContainer;07import org.testcontainers.utility.DockerImageName;0809@TestConfiguration(proxyBeanMethods = false)10public class RedisTestConfiguration {11@Bean12@ServiceConnection(name = "redis")13GenericContainer<?> redisContainer() {14return new GenericContainer<>(DockerImageName.parse("redis:7.4-alpine"))15.withExposedPorts(6379);16}17}
这个测试隔离 JPA 数据读取,只验证真实缓存与真实事务提交之间的边界;JPA 更新本身仍由前文数据库测试覆盖:
01package cn.usehook.spring.monitoring;0203import cn.usehook.spring.RedisTestConfiguration;04import cn.usehook.spring.TestcontainersConfiguration;05import java.util.Optional;06import org.junit.jupiter.api.Test;07import org.springframework.beans.factory.annotation.Autowired;08import org.springframework.boot.test.context.SpringBootTest;09import org.springframework.context.ApplicationEventPublisher;10import org.springframework.context.annotation.Import;11import org.springframework.security.oauth2.jwt.JwtDecoder;12import org.springframework.test.context.bean.override.mockito.MockitoBean;13import org.springframework.transaction.PlatformTransactionManager;14import org.springframework.transaction.support.TransactionTemplate;15import static org.assertj.core.api.Assertions.assertThat;16import static org.mockito.Mockito.*;1718@SpringBootTest(properties = {19"app.monitoring.initial-delay=1h",20"app.monitoring.jobs.enabled=false",21"app.alerts.enabled=false",22"app.history.retention.enabled=false"23})24@Import({TestcontainersConfiguration.class, RedisTestConfiguration.class})25class MetadataCacheTests {26@Autowired MetadataQueryService query;27@Autowired ApplicationEventPublisher events;28@Autowired PlatformTransactionManager transactions;29@MockitoBean MonitoredServiceJpaRepository repository;30@MockitoBean JwtDecoder jwtDecoder;3132@Test33void onlyCommittedChangesInvalidateTheCache() {34var entity = mock(MonitoredServiceEntity.class);35var group = mock(MonitoringGroupEntity.class);36when(entity.getId()).thenReturn(77L);37when(entity.getName()).thenReturn("inventory");38when(entity.getGroup()).thenReturn(group);39when(group.getId()).thenReturn(3L);40when(entity.isEnabled()).thenReturn(true);41when(repository.findById(77L)).thenReturn(Optional.of(entity));42assertThat(query.get(77).enabled()).isTrue();43assertThat(query.get(77).enabled()).isTrue();44verify(repository, times(1)).findById(77L);4546when(entity.isEnabled()).thenReturn(false);47var tx = new TransactionTemplate(transactions);48tx.executeWithoutResult(status -> {49events.publishEvent(new MetadataChanged(77));50status.setRollbackOnly();51});52assertThat(query.get(77).enabled()).isTrue();53tx.executeWithoutResult(status -> events.publishEvent(new MetadataChanged(77)));54assertThat(query.get(77).enabled()).isFalse();55verify(repository, times(2)).findById(77L);56}57}
本测试不要添加覆盖整个方法的 @Transactional,否则事件始终等不到真正提交。测试延后原有调度任务,同时关闭后续数据库任务调度器和告警投递;只调整 initial-delay 不会影响新调度器独立声明的轮询时间。已有 @MockitoBean JwtDecoder 使测试无需访问真实身份提供方,保留它以隔离外部认证边界。可在本机具备 Docker 时运行 ./mvnw -Dtest=MetadataCacheTests test。
另做一次故障演练:停止本地 Redis 后读取元数据,预期仍从数据库返回;更新元数据时数据库应提交并留下失效失败日志。恢复 Redis 后,旧条目应随 TTL 消失,不能永久保持旧值。
7. 多实例的一致性边界
共享 Redis 使实例使用同一缓存空间,但并不自动解决“旧查询晚到”的竞争:请求 A 从数据库读出旧值,请求 B 提交更新并删除缓存,A 最后把旧值写回 Redis。这种旧值可在写回后的 30 秒内继续存在;如果 A 本身运行很慢,总体陈旧时间还包含这段查询时间。
因此,TTL 不是从数据库提交时刻起严格计算的最大延迟,@Cacheable(sync = true) 也不是跨实例的业务分布式锁。权限判断、扣款和停用控制等需要立即生效的路径应直接读数据库。必须提高元数据缓存的一致性时,再采用带版本条件的写入、可靠失效任务或数据库变更订阅,并验证乱序处理。
后面的应用事件与告警章节会介绍 Outbox,可把“必须最终执行的缓存失效”也保存成事务内任务。即便如此,缓存仍存在传播窗口,不能把消息重试误称为强一致。Redis Cache 配置与过期语义