楼层: 首页/ 软件技术/ Spring Boot 3.5/ Boot 缓存与 WebSocket:把重复计算省掉,把变化推出去
16

Boot 缓存与 WebSocket:把重复计算省掉,把变化推出去

Boot Cache · WebSocket Starter

这一章讲两件"性价比很高"的事:缓存让重复查询不再重复算,WebSocket 让服务端能主动把变化告诉客户端。它们在 Spring Boot 里都属于"自动配置帮你搭好了骨架,但生产可用的关键配置还得自己补"的类型——这一章的重点就是那些"必须自己补"的部分。

spring-boot-starter-cache 与 @EnableCaching:自动配置给了什么

Spring 核心页讲过缓存抽象的原理,这一节换 Boot 的视角:自动配置到底帮你做了哪些决定,以及为什么大部分情况下你都得覆盖它。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency> <!-- 分布式缓存再带上这个(同时会带来 RedisCacheManager 的自动配置) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
自动配置行为说明与需要你介入的地方
CacheManager 选择顺序Boot 按类路径上的实现自动挑:有 Redis 且有 spring.cache.type=redis 就用 RedisCacheManager;都不满足则回落到 ConcurrentMapCacheManager(进程内、永不过期)。想知道实际用了哪个,看启动日志或直接注入 CacheManager 打印类名。
spring.cache.type可选 redis、caffeine、simple、none。本地开发建议显式设 simple,避免开发机上 Redis 没起就跑不起来。
spring.cache.cache-names预先声明缓存区域名。不预声明时缓存会在第一次使用时按需创建,所以业务上通常不需要配。
TTL 与序列化这两项 Boot 不会给你生产可用的默认值:TTL 默认永不过期、value 用 JDK 序列化。必须自己覆盖 RedisCacheConfiguration。
@EnableCaching必须手动加。Boot 不会因为类路径有 starter 就自动开启缓存注解处理——这是新人最常踩的一脚,注解写了完全没反应。
"我以为配了"的三种假象

① 以为 starter 就会自动开缓存:不会。@EnableCaching 没加的话,@Cacheable 就是一段普通注释,方法每次都会执行。自检方法:在方法里打一行日志,连调两次看是不是打了两遍。

② 以为引入 Redis starter 就等于用了 Redis 缓存:不一定。如果 spring.cache.type 没写,Boot 的行为随版本和类路径变化,可能悄悄用了 simple(进程内)。多实例部署时每个实例各缓存一份,你排查半天"为什么数据不一致",根因其实是缓存根本没走 Redis。

③ 以为 spring.cache.redis.time-to-live 就够用:它只设全局 TTL,不能按缓存名分别设置。而生产里"字典缓存 12 小时、验证码缓存 5 分钟"是刚需,所以还是得写 RedisCacheConfiguration。

覆盖 Redis 默认序列化:从乱码到可读的 JSON

打开 redis-cli 看到 \xac\xed\x00\x05sr\x00... 这种乱码,就是 JDK 序列化在作祟。它带来三个真实代价:运维看不懂、类一改就反序列化失败、跨语言无法读取。

@Configuration @EnableCaching public class CacheConfig { @Bean public RedisCacheManager cacheManager( RedisConnectionFactory factory, ObjectMapper objectMapper) { // 1) JSON 序列化器:复用 Spring Boot 已经配好的 ObjectMapper // 自己 new 一个会丢掉 JavaTimeModule,LocalDateTime 直接序列化失败 GenericJackson2JsonRedisSerializer valueSer = new GenericJackson2JsonRedisSerializer(objectMapper); RedisCacheConfiguration base = RedisCacheConfiguration .defaultCacheConfig() // key 一定用 String,否则 redis-cli 里 key 也是乱码 .serializeKeysWith(SerializationPair .fromSerializer(RedisSerializer.string())) .serializeValuesWith(SerializationPair .fromSerializer(valueSer)) .computePrefixWith(name -> "app:cache:" + name + ":") .entryTtl(Duration.ofMinutes(30)) // 不缓存 null。要防穿透就删掉这行,反过来缓存空值 .disableCachingNullValues(); // 2) 按区域定制 TTL:这是 spring.cache.redis.* 做不到的事 Map<String, RedisCacheConfiguration> perCache = new HashMap<>(); perCache.put("dict", base.entryTtl(Duration.ofHours(12))); perCache.put("product", base.entryTtl(Duration.ofMinutes(30))); perCache.put("captcha", base.entryTtl(Duration.ofMinutes(5))); return RedisCacheManager.builder(factory) .cacheDefaults(base) .withInitialCacheConfigurations(perCache) .enableStatistics() // 打开命中率统计,指标才会上报 .build(); } }
# 纯配置方式:只适合"全局一个 TTL"的简单场景 spring: cache: type: redis redis: time-to-live: 30m # 全局 TTL cache-null-values: false # 不写 null(反过来说,防穿透要设 true) key-prefix: "app:cache:" use-key-prefix: true data: redis: timeout: 2s # 一定要设,否则 Redis 抖动会拖死业务线程

论为什么 @class 既是优点也是隐患

① 优点:能自动还原类型。GenericJackson2JsonRedisSerializer 会在 JSON 里写入 "@class":"com.example.ProductVO",读的时候按这个类名反序列化,所以你不用为每种类型单独配序列化器——对一个缓存里存多种对象的应用来说非常方便。

② 隐患一:包名就是契约。你把 ProductVO 从 com.example 挪到 com.example.product,线上已有的缓存全部反序列化失败,抛 ClassNotFoundException。所以缓存的 DTO 不要随便挪包,或者缓存 key 里带上版本号(app:cache:v2:product:)。

③ 隐患二:它信任 @class 指定的类。如果攻击者能往你的 Redis 里写数据(比如 Redis 未授权访问),配合某些类型就可能触发反序列化风险。这也是"Redis 绝对不能暴露公网"的原因之一。

④ 隐患三:体积。每个 value 都多一段类名字符串,缓存小对象时开销占比不低。如果某个缓存只存一种类型,可以用 Jackson2JsonRedisSerializer<ProductVO> 省掉这部分。

Boot 里的缓存实战:注解、观测与三个自检动作

注解的语义在 Spring 核心页已经讲透,这里只讲 Boot 项目里最容易出问题的部分。

@Service public class DictService { // 1) 显式 key + 不缓存 null:查不到的字典项每次都回源(简单直接) @Cacheable(cacheNames = "dict", key = "'type:' + #p0", unless = "#result == null || #result.isEmpty()") public List<DictItem> listByType(String type) { return dictMapper.selectByType(type); } // 2) 删除用 allEntries:一个类型的数据改了,整块区域作废最省心 @CacheEvict(cacheNames = "dict", key = "'type:' + #type") public void refresh(String type) { // 该类型的缓存已删,下次访问自动重建 } // 3) 组合:一次操作影响两块缓存 @Caching(evict = { @CacheEvict(cacheNames = "dict", allEntries = true), @CacheEvict(cacheNames = "dictTree", allEntries = true) }) public void refreshAll() { } }

观测:把缓存命中率接进 Actuator,别靠猜

# 打开缓存指标(依赖 RedisCacheManager 的 enableStatistics 或 Caffeine 的 recordStats) management: metrics: enable: cache: true endpoints: web: exposure: include: health,info,metrics,prometheus
# 查询某个缓存的命中次数与未命中次数 curl -s localhost:8081/actuator/metrics/cache.gets?tag=cache:dict # 返回示例:{"measurements":[{"statistic":"COUNT","value":10240}, # {"statistic":"MISS","value":210}]} # 用 Prometheus 的话直接看这几条 # cache_gets_total{cache="dict",result="hit"} # cache_gets_total{cache="dict",result="miss"}
自检动作怎么做 / 为什么
确认真的走了缓存在方法体里打一行日志,连调两次;只打一次说明生效,打两次说明注解没被处理(多半是缺 @EnableCaching)或走了自调用。
确认用的是哪个 CacheManager注入 CacheManager 并 log.info(cm.getClass().getName())。看到 ConcurrentMapCacheManager 就说明你的分布式缓存没生效。
去 Redis 里看一眼真数据SCAN 0 MATCH "app:cache:dict:*" COUNT 100,再 GET 一个看是不是可读 JSON。能读懂 = 序列化配对了;乱码 = 还是 JDK 序列化。
确认 TTL 生效TTL app:cache:product:1001 返回正数表示有 TTL;返回 -1 表示永不过期——这是个需要立刻修的高危配置。

spring-boot-starter-websocket 快速起步

Boot 把 WebSocket 的依赖和自动配置都收进了一个 starter。它带来的核心变化是:你不用再手动注册 ServletServerContainerFactoryBean,容器参数可以通过配置文件调整。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>
spring: websocket: # 消息帧大小上限:默认较大,公网服务务必收紧,防大帧打爆内存 max-text-message-buffer-size: 64KB max-binary-message-buffer-size: 64KB max-session-idle-timeout: 300s # 空闲超时,兜住"僵尸连接"

三步跑通:端点 + 处理器 + 握手鉴权

@Configuration @EnableWebSocket public class RawWsConfig implements WebSocketConfigurer { private final NoticeHandler noticeHandler; private final TicketHandshakeInterceptor interceptor; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(noticeHandler, "/ws/notice") .addInterceptors(interceptor) // 只允许自己的域名。写成 "*" 等于让任何站点都能连上并带走用户身份 .setAllowedOrigins("https://app.example.com"); } } @Component public class NoticeHandler extends TextWebSocketHandler { private static final Map<String, WebSocketSession> ONLINE = new ConcurrentHashMap<>(); @Override public void afterConnectionEstablished(WebSocketSession session) { String uid = (String) session.getAttributes().get("userId"); ONLINE.put(uid, session); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { ONLINE.values().removeIf(s -> !s.isOpen()); // 清理,防内存泄漏 } public void pushTo(String userId, String json) throws IOException { WebSocketSession s = ONLINE.get(userId); if (s != null && s.isOpen()) { // 并发发送同一 session 会抛 IllegalStateException,必须串行化 synchronized (s) { s.sendMessage(new TextMessage(json)); } } } }
原生端点的三个必查项

① @EnableWebSocket 与 @EnableWebSocketMessageBroker 二选一:两个注解对应两套体系,同时加会冲突。原生处理器用前者,STOMP 用后者。

② 握手阶段必须鉴权:WebSocket 握手是一次 HTTP 请求,但它绕过了你的 Controller,所以 URL 级安全规则不一定拦得住。必须在 HandshakeInterceptor 里校验 Origin + 令牌,否则任何知道端点地址的人都能连上。

③ 别把状态挂在 Session 对象上:重连会产生新会话,旧会话的订阅和临时状态全丢。用户状态放 Redis,会话只用来发消息。

@MessageMapping 与 SimpMessagingTemplate:STOMP 的服务端推送

原生 WebSocket 只给你一条"能发字符串的管道",路由和订阅要自己写。STOMP 把这件事变成声明式:@MessageMapping 收消息,SimpMessagingTemplate 推消息,中间由 broker 负责按目的地分发。

@Configuration @EnableWebSocketMessageBroker public class StompConfig implements WebSocketMessageBrokerConfigurer { @Override public void configureMessageBroker(MessageBrokerRegistry registry) { // 出口前缀:/topic 广播、/queue 点对点;心跳必须配 TaskScheduler registry.enableSimpleBroker("/topic", "/queue") .setHeartbeatValue(new long[]{10000, 10000}) .setTaskScheduler(hbScheduler()); // 入口前缀:客户端发 /app/xxx 才能到 @MessageMapping("/xxx") registry.setApplicationDestinationPrefixes("/app"); registry.setUserDestinationPrefix("/user"); } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws") .setAllowedOriginPatterns("https://*.example.com") // SockJS 降级:不支持 WebSocket 的客户端走 xhr-streaming 等替代方案 .withSockJS().setHeartbeatTime(10000L); } @Bean public TaskScheduler hbScheduler() { var s = new ThreadPoolTaskScheduler(); s.setPoolSize(2); s.setThreadNamePrefix("ws-hb-"); s.initialize(); return s; } }
@RestController public class NoticeController { private final SimpMessagingTemplate messaging; public NoticeController(SimpMessagingTemplate messaging) { this.messaging = messaging; } // ① 普通 HTTP 接口触发一次推送:新订单广播给所有订阅者 @PostMapping("/api/orders") public Order create(@RequestBody Order order) { orderService.save(order); // 目的地要和前端 subscribe 的路径完全一致,大小写都不能差 messaging.convertAndSend("/topic/orders", order); return order; } // ② 点对点:只推给某个用户。目的地写成 /user/{username}/queue/notice @PostMapping("/api/notify/{username}") public void notify(@PathVariable String username, @RequestBody String text) { messaging.convertAndSendToUser(username, "/queue/notice", text); } // ③ 客户端消息入口(STOMP SEND 帧),返回值自动发到 @SendTo 指定的目的地 @MessageMapping("/chat.send") @SendTo("/topic/chat") public ChatMsg chat(@Payload ChatMsg msg, Principal principal) { msg.setFrom(principal.getName()); return msg; } }

论STOMP 里最容易搞混的三组"前缀"

① 入口前缀(/app):客户端 send() 时用的前缀。默认 /app,所以客户端要发 /app/chat.send 才能命中 @MessageMapping("/chat.send")。少写前缀是最常见的"消息发出去了但服务端没反应"。

② 出口前缀(/topic、/queue):客户端 subscribe() 用的前缀,也是服务端 convertAndSend() 的目的地前缀。两边必须严格一致。改了配置忘了改前端,表现为"订阅成功但收不到消息"。

③ 用户前缀(/user):只有点对点推送才用。服务端写 convertAndSendToUser(username, "/queue/notice", msg),实际目的地会被拼成 /user/{username}/queue/notice。这里的 username 必须和 STOMP 会话的 Principal.getName() 一致——如果你的 JWT 里 sub 是用户 ID 而这里传的是登录名,消息就永远送不到。

生产环境必查的四项

① 集群下用 SimpleBroker 会丢消息:它是进程内 broker,只知道连在本实例上的会话。用户在实例 A,推送代码在实例 B 执行,消息就丢了。解法:enableStompBrokerRelay(...) 接 RabbitMQ/ActiveMQ,或用 Redis Pub/Sub 在实例间转发。

② 必须给 STOMP 单独做鉴权:SecurityFilterChain 管的是 HTTP 请求,STOMP 的 CONNECT 帧不经过它。需要注册 ChannelInterceptor,在 preSend 里校验 CONNECT 帧的 Authorization 头并设置用户身份,同时校验 SUBSCRIBE 的目的地是否属于该用户。

③ 心跳与代理超时要匹配:服务端心跳 10 秒,Nginx proxy_read_timeout 默认 60 秒,看起来够用;但如果流量低谷、心跳帧也没发,仍会被掐断。生产建议把代理超时设到 300 秒以上,并确认 Upgrade/Connection 头都配了。

④ 消息体要有版本和幂等标识:客户端重连后会重新订阅,可能重复收到消息。推送的消息里带一个递增序号,客户端做去重,比在服务端做"恰好一次"语义便宜得多。

记
Boot 缓存与 WebSocket 一句话总结

① starter 不会自动开启缓存注解,@EnableCaching 必须自己加,否则注解形同虚设。

② Boot 不给生产可用的缓存默认值:TTL 默认永不过期、value 默认 JDK 序列化,两项都要用 RedisCacheConfiguration 覆盖。

③ spring.cache.redis.time-to-live 只能设全局 TTL,按区域定制必须写 Java 配置。

④ 缓存要能被观测:确认 CacheManager 类型、看 Redis 里的真实 key 与 TTL、打开 management.metrics.enable.cache 看命中率。

⑤ spring-boot-starter-websocket 简化了容器配置,但端点、Origin 白名单、握手鉴权仍要自己写。

⑥ 原生处理器用 @EnableWebSocket,STOMP 用 @EnableWebSocketMessageBroker,两者不能同开。

⑦ STOMP 三组前缀(入口/出口/用户)必须两端对齐;集群要用外部 broker 或 Redis 广播,且 CONNECT 帧鉴权不能漏。

章末面试 · Boot 缓存与 WebSocket(4 题)

1.(排错题)接口加了 @Cacheable,本地测发现每次都会执行方法,Redis 里也没有对应 key。按顺序列出你的排查步骤。

查看答案

排查顺序:① 有没有 @EnableCaching——没有它注解完全不生效,这是最高频原因;② 调用方是不是走代理——同类内部 this.method() 调用会绕过缓存拦截;③ 方法是 public 吗(非 public 不生效);④ 注入 CacheManager 打印类名,如果是 ConcurrentMapCacheManager,说明你要的 Redis 缓存没生效,去检查 spring.cache.type 与 Redis 连接配置;⑤ condition 是不是把这次调用挡掉了;⑥ 去 Redis 用 SCAN MATCH "前缀*" 找找 key 到底写没写、写在哪。解析:先确认"注解有没有被处理",再确认"缓存落在哪",最后才怀疑业务逻辑,这个顺序能省掉大量时间。

2.(理解题)为什么 RedisCacheConfiguration 里的 key 必须用 String 序列化器?只用 value 的 JSON 序列化器行不行?

查看答案

答案:不行。如果 key 也走 JDK 序列化,Redis 里存的就是二进制字节串,redis-cli 里显示为乱码,运维无法用 KEYS/SCAN 找到和清理,排障时只能靠猜;跨语言客户端也读不出 key。而 value 用 JSON 解决的是可读性与类型兼容问题,两件事互不替代,所以 key 用 String、value 用 JSON 是最小可用组合。解析:缓存是给人看的运维资产,可读性的价值在出故障时体现得最明显。

3.(场景题)用 SimpleBroker 部署 3 个实例,用户收到的推送时有时无。为什么?怎么修?

查看答案

原因:SimpleBroker 是进程内 broker,各实例的订阅关系互不可见。用户连在实例 1 上,而 convertAndSend 在实例 2 上执行时,实例 2 的 broker 里没有该会话,消息被丢弃——表现为"取决于请求落到哪个实例"的随机丢消息,所以看上去"时有时无"。修法:① 用 registry.enableStompBrokerRelay("/topic","/queue") 接 RabbitMQ 或 ActiveMQ,让所有实例共享一个 broker;② 保持 SimpleBroker,但用 Redis Pub/Sub(或 MQ)把消息广播到所有实例,每个实例收到后再推给本机会话。解析:这是 WebSocket 集群化的第一课,也是"本地全对、上线丢消息"的标准答案。

4.(安全题)已经配了 SecurityFilterChain 保护 HTTP 接口,WebSocket 还需要额外做鉴权吗?为什么?

查看答案

需要。原因:① WebSocket 的握手虽然是一次 HTTP 请求,但握手成功后的通信走的是 WebSocket 帧,STOMP 的 CONNECT、SUBSCRIBE、SEND 帧根本不是 HTTP 请求,不经过 SecurityFilterChain;② WebSocket 不受同源策略限制,任何站点的脚本都能尝试连接并自动带上 Cookie;③ 不做鉴权的话,任何知道端点地址的人都能订阅任意目的地,直接监听别人的业务消息。正确做法:在 HandshakeInterceptor 里校验 Origin 与一次性票据,同时注册 ChannelInterceptor 校验 CONNECT 帧的令牌并设置用户身份,并校验 SUBSCRIBE 的目的地是否属于该用户。解析:记住一句话——HTTP 层的安全配置保护不了 WebSocket 的消息帧。