Boot 缓存与 WebSocket:把重复计算省掉,把变化推出去
这一章讲两件"性价比很高"的事:缓存让重复查询不再重复算,WebSocket 让服务端能主动把变化告诉客户端。它们在 Spring Boot 里都属于"自动配置帮你搭好了骨架,但生产可用的关键配置还得自己补"的类型——这一章的重点就是那些"必须自己补"的部分。
spring-boot-starter-cache 与 @EnableCaching:自动配置给了什么
Spring 核心页讲过缓存抽象的原理,这一节换 Boot 的视角:自动配置到底帮你做了哪些决定,以及为什么大部分情况下你都得覆盖它。
| 自动配置行为 | 说明与需要你介入的地方 |
|---|---|
| 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 序列化在作祟。它带来三个真实代价:运维看不懂、类一改就反序列化失败、跨语言无法读取。
论为什么 @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 项目里最容易出问题的部分。
观测:把缓存命中率接进 Actuator,别靠猜
| 自检动作 | 怎么做 / 为什么 |
|---|---|
| 确认真的走了缓存 | 在方法体里打一行日志,连调两次;只打一次说明生效,打两次说明注解没被处理(多半是缺 @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,容器参数可以通过配置文件调整。
三步跑通:端点 + 处理器 + 握手鉴权
① @EnableWebSocket 与 @EnableWebSocketMessageBroker 二选一:两个注解对应两套体系,同时加会冲突。原生处理器用前者,STOMP 用后者。
② 握手阶段必须鉴权:WebSocket 握手是一次 HTTP 请求,但它绕过了你的 Controller,所以 URL 级安全规则不一定拦得住。必须在 HandshakeInterceptor 里校验 Origin + 令牌,否则任何知道端点地址的人都能连上。
③ 别把状态挂在 Session 对象上:重连会产生新会话,旧会话的订阅和临时状态全丢。用户状态放 Redis,会话只用来发消息。
@MessageMapping 与 SimpMessagingTemplate:STOMP 的服务端推送
原生 WebSocket 只给你一条"能发字符串的管道",路由和订阅要自己写。STOMP 把这件事变成声明式:@MessageMapping 收消息,SimpMessagingTemplate 推消息,中间由 broker 负责按目的地分发。
论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 头都配了。
④ 消息体要有版本和幂等标识:客户端重连后会重新订阅,可能重复收到消息。推送的消息里带一个递增序号,客户端做去重,比在服务端做"恰好一次"语义便宜得多。
① 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 帧鉴权不能漏。
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 的消息帧。