1. 从一次线上“灵异事件”说起:为什么我的缓存数据“穿越”了?
大概半年前,我们团队负责的物流运单查询系统上线后,平稳运行了几个月。为了扛住高峰期的流量,我们引入了经典的 Redis + Caffeine 多级缓存架构。简单来说,就是用户查询时,先看本地内存里的 Caffeine 有没有(一级缓存),没有就去查 Redis(二级缓存),再没有才去查数据库。上线后效果拔群,接口响应时间从几十毫秒降到了个位数,大家都很开心。
但好景不长,运营同学有一天突然反馈,说有用户投诉查到的物流状态“不对”。比如,用户明明看到快递员已经签收了,但在我们系统的查询页面上,还显示“运输中”。我们一查数据库,记录确实已经更新为“已签收”了。这就怪了,数据明明是对的,为什么查出来是错的?更诡异的是,这个问题不是每次都出现,而是“时好时坏”,同一个运单号,用不同电脑或者刷新几次,可能又正常了。
我们几个人对着监控大盘和日志查了一下午,终于抓到了“鬼”。我们的服务是用 Kubernetes 部署的,平时会有3个实例(Pod)在跑。问题就出在这里:
- 用户A第一次查询,请求打到了 节点1。节点1的 Caffeine 和 Redis 里都没有数据,于是去数据库查,结果写入自己的 Caffeine,也写入了 Redis。此时一切正常。
- 过了一会儿,后台系统更新了这条运单的状态。这个更新请求,碰巧打到了 节点2。节点2处理更新:它先更新了数据库,然后按照缓存更新策略,删除了自己Caffeine里的旧数据,同时也更新了Redis里的数据。
- 用户A再次刷新页面查询,请求又被打到了 节点1。节点1一查,自己本地的 Caffeine 里还有缓存呢!而且没过期,它想都没想,直接就把这个“旧数据”返回给用户了。它根本不知道 Redis 里的数据已经被节点2更新了。
看明白了吗?这就是分布式系统里,多级缓存最经典的“数据一致性”问题。每个服务实例都有自己的“小本本”(本地内存缓存),当数据在某个“小本本”上被修改后,其他实例的“小本本”并不知道,还记着老黄历。数据就这样“穿越”了。这个问题在单机服务里不会出现,一旦服务多实例部署,几乎必然会发生。我们的架构图,就像下面这样,埋下了不一致的种子:
[用户] --> [负载均衡] --> [节点1: Caffeine有旧数据] --> (返回旧数据)
[节点2: Caffeine已更新] --> [Redis: 已更新为新数据]
[节点3: Caffeine为空]
当时我们第一反应是:给 Caffeine 设置一个很短的过期时间,比如5秒,让数据尽快失效。但这无异于因噎废食,频繁的缓存失效会大大增加 Redis 和数据库的压力,我们引入多级缓存的优势就没了。那怎么办呢?核心思路其实很简单:一个节点更新了数据,得想办法通知其他所有节点:“嘿,哥们,你手里的那份数据过期了,赶紧扔了吧!”
2. 通知方案的抉择:为什么不用RabbitMQ而用Redis Pub/Sub?
说到“通知”,咱们程序员脑子里能蹦出好几个方案。当时我们团队内部也争论过,主要候选人是两位:RabbitMQ 和 Redis 的发布订阅(Pub/Sub)。
先说说 RabbitMQ。它是一个功能完备的消息队列,可靠性高,支持复杂的路由、确认机制、持久化,用在金融、订单等对一致性要求极高的场景是没问题的。如果用它来做缓存失效通知,大致的流程是:节点2更新数据后,向一个特定的 Exchange 发送一条消息;节点1和节点3都订阅了这个 Exchange 的队列,收到消息后,删除自己本地对应的缓存。
这方案行不行?当然行。但我们评估后,觉得有点“杀鸡用牛刀”。为什么?
- 复杂度与依赖:引入 RabbitMQ 意味着要维护一套新的中间件集群,增加了系统的复杂度和运维成本。对于“缓存失效通知”这个单一职责来说,它的队列、确认、持久化等高级功能我们可能用不上,反而带来了额外的资源消耗和学习成本。
- 时效性:虽然 RabbitMQ 很快,但它毕竟是一个独立的服务,消息的传递需要经过网络IO、序列化、入队、出队等过程。而我们的目标是让缓存失效尽可能快。
- 轻量级需求:缓存失效通知这个场景,对消息的“可靠性”要求其实是有弹性的。偶尔丢失一条失效通知,最坏的结果就是某个节点的本地缓存多保留了一段时间(直到自然过期),用户可能多看到几秒旧数据。这在很多业务场景(如物流状态、商品库存非核心字段)是可以接受的。我们追求的是 最终一致性,而不是强一致性。
这时候,Redis 发布订阅(Pub/Sub) 就进入了我们的视野。它就像是 Redis 自带的一个“广播喇叭”。工作原理非常直观:
- 发布者(Publisher):某个服务节点(比如节点2),在更新数据后,向一个指定的“频道(Channel)”喊一嗓子:“运单号
TRACK_123456的数据变了!” - 订阅者(Subscriber):所有服务节点(节点1、2、3)都竖着耳朵在听这个频道。只要听到广播,就立刻检查是不是喊的自己关心的数据,如果是,就马上把本地对应的缓存删除。
用 Redis Pub/Sub 的好处显而易见:
- 零额外依赖:缓存用的就是 Redis,复用同一个基础设施,不用引入新的组件。
- 极致轻量与快速:消息直接在 Redis 内存中传递,速度极快,几乎是实时的。
- 足够简单:模型简单,API 简单,理解和实现成本低。
当然,它也有缺点,最主要的就是 消息的“非持久化”。如果某个服务节点在消息广播时刚好重启了,它就错过了这条消息。但对于我们缓存失效的场景,这可以通过结合 设置合理的本地缓存过期时间 来兜底。比如,本地缓存设置30秒过期,即使错过了失效通知,最多30秒后数据也会自动刷新。这种“轻量级通知 + 过期兜底”的组合拳,在实践中非常有效且平衡。
所以,我们最终拍板:就用 Redis 的发布订阅来解决这个多级缓存一致性问题!
3. 手把手实战:三部分代码搞定一致性
光说不练假把式,下面我就把我们项目中核心的三块代码拆开揉碎了讲给你听,保证你跟着做就能实现。
3.1 第一步:配置消息监听器(竖起耳朵)
首先,我们需要让每个 Spring Boot 服务实例启动时,就自动去订阅 Redis 的特定频道。这需要在配置类里完成。
我们创建一个 RedisConfig 类(如果已有,就在里面添加)。关键是要定义一个频道主题名,所有服务节点都用这个主题来收发消息。
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
@Configuration
public class RedisConfig {
// 定义一个公共的频道主题,所有节点都订阅和发布这个主题
public static final String CACHE_INVALIDATE_CHANNEL = "cache:invalidate:transport";
/**
* 创建Redis消息监听容器
* 这个容器就像是一个总机,负责管理谁在听什么频道。
*
* @param connectionFactory Redis连接工厂,Spring会自动注入
* @param listenerAdapter 消息监听器的适配器,我们下面会定义
* @return 配置好的监听容器
*/
@Bean
public RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory,
MessageListenerAdapter listenerAdapter) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
// 设置连接工厂
container.setConnectionFactory(connectionFactory);
// 添加监听器,并指定监听的频道
// 这里的意思是:让 listenerAdapter 去监听 CACHE_INVALIDATE_CHANNEL 这个频道
container.addMessageListener(listenerAdapter, new ChannelTopic(CACHE_INVALIDATE_CHANNEL));
return container;
}
}
3.2 第二步:编写消息处理器(听到后该干嘛)
光竖起耳朵还不行,听到广播后得知道干什么。我们需要一个真正的消息处理器,它负责在收到消息时,执行删除本地 Caffeine 缓存的动作。
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.stereotype.Component;
import com.github.benmanes.caffeine.cache.Cache;
/**
* Redis缓存失效消息监听器
* 实现 MessageListener 接口,专门处理订阅到的消息
*/
@Slf4j
@Component // 声明为Spring组件,让容器管理
public class CacheInvalidateMessageListener implements MessageListener {
// 注入我们业务中使用的Caffeine缓存实例
// 假设我们的缓存叫 `transportInfoCache`,缓存键是运单号,值是运单信息DTO
@Autowired
private Cache<String, TransportInfoDTO> transportInfoCache;
/**
* 这是核心方法,当订阅的频道有消息时,这个方法会被自动调用
*
* @param message Redis传来的原始消息,里面包含我们发布的内容
* @param pattern 匹配到的频道模式,这里我们暂时用不到
*/
@Override
public void onMessage(Message message, byte[] pattern) {
// 1. 解析消息体。我们约定消息内容就是需要失效的缓存键,比如运单号 "TRACK_123456"
String cacheKey = new String(message.getBody());
log.info("收到缓存失效通知,开始清理本地缓存,键: {}", cacheKey);
// 2. 执行本地缓存失效
// 这一步是点睛之笔!直接让本机内存里的旧数据消失
transportInfoCache.invalidate(cacheKey);
log.debug("本地缓存清理完成: {}", cacheKey);
}
}
注意,这里我们直接实现了 MessageListener 接口,比用 MessageListenerAdapter 更直接。还需要修改上一步的配置,将监听器适配器指向我们这个类:
// 在 RedisConfig 中修改或添加这个Bean
@Bean
public MessageListenerAdapter listenerAdapter(CacheInvalidateMessageListener listener) {
// 将我们的监听器实例包装成适配器
return new MessageListenerAdapter(listister);
}
3.3 第三步:在数据更新处发布消息(拿起喇叭喊)
最后,也是最关键的一步:在任何一个会修改数据的地方(比如 update、save 方法),在更新完数据库和 Redis 之后,必须记得“拿起喇叭广播一下”。
我们通常会在 Service 层的数据更新方法里做这件事:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.cache.annotation.CacheEvict;
import javax.annotation.Resource;
@Service
public class TransportInfoServiceImpl implements TransportInfoService {
@Autowired
private TransportInfoMapper transportInfoMapper; // 假设是MyBatis Mapper
// 注入Redis模板,用于发布消息
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 更新运单信息
* @param transportOrderId 运单号
* @param updateInfo 更新内容
* @return 更新后的实体
*/
@Override
// 使用Spring Cache注解,更新数据后清除Redis中的缓存(二级缓存)
// 注意:这个注解只管理Redis缓存,不管Caffeine
@CacheEvict(cacheNames = "transport-info", key = "#transportOrderId")
public TransportInfoEntity updateInfo(String transportOrderId, TransportInfoDetail updateInfo) {
// 1. 更新数据库
TransportInfoEntity entity = transportInfoMapper.selectById(transportOrderId);
// ... 更新entity字段 ...
transportInfoMapper.updateById(entity);
// 2. 发布缓存失效消息(核心步骤!)
// 向我们在Config里定义的频道,发送一条消息,内容就是运单号
stringRedisTemplate.convertAndSend(RedisConfig.CACHE_INVALIDATE_CHANNEL, transportOrderId);
log.info("数据已更新,已发布缓存失效通知,运单号: {}", transportOrderId);
// 注意:我们不需要在这里手动操作Caffeine,因为监听器会处理。
// 也不需要再手动操作Redis,因为 @CacheEvict 注解帮我们做了。
return entity;
}
}
这三段代码就构成了一个完整的闭环。流程再捋一遍:
- 节点2 更新数据 -> 更新DB,
@CacheEvict清理Redis -> 发布消息到cache:invalidate:transport频道。 - Redis 收到发布的消息,立即广播给所有订阅该频道的客户端。
- 节点1 和 节点3 的
CacheInvalidateMessageListener的onMessage方法被触发 -> 根据消息中的运单号,删除自己本地的 Caffeine 缓存。 - 下次查询时,节点1发现本地缓存没了,就会去查Redis(此时已是新数据),从而保证了一致性。
4. 避坑指南:实际部署中遇到的三个“大坑”与优化
方案跑通后,我们兴高采烈地上了预发布环境,结果还是踩了几个坑。这里分享出来,希望你能直接避开。
坑一:消息的“轰炸”与“静默”
一开始,我们任何数据更新都发消息。结果发现,在批量更新运单状态的夜间任务时,Redis频道里瞬间涌进几十万条消息。虽然每条消息很小,但数量巨大,导致网络带宽瞬间飙升,甚至影响了正常的缓存读写。同时,所有服务实例都在疯狂地执行 invalidate 操作,CPU 也有毛刺。
- 优化方案:我们引入了 消息合并与延迟。对于批量操作,不再逐条发送,而是在本地积累一批运单号(比如最多100个,或最多等待100毫秒),合并成一条消息发送,内容是一个JSON数组。监听器收到后,再批量失效。这极大地减少了消息数量。对于非核心实时性要求的数据,甚至可以延迟1-2秒再发送失效消息,平滑流量。
坑二:监听器处理慢,阻塞了其他命令
我们有一次发现,某个服务实例的 Redis 客户端响应变慢了。排查后发现,是在 onMessage 方法里,我们不仅做了 invalidate,还同步记录了一条很详细的审计日志到数据库,这个数据库操作偶尔比较慢。而 Redis 的 Pub/Sub 消息分发是同步的!如果监听器处理消息太慢,会阻塞 Redis 服务器向该客户端发送其他命令(甚至是 PING),可能导致连接超时。
- 优化方案:确保
onMessage方法必须快速返回! 所有耗时的操作(如写数据库、调用外部接口)必须异步化。我们改成了这样:@Override public void onMessage(Message message, byte[] pattern) { String cacheKey = new String(message.getBody()); // 立即失效缓存 transportInfoCache.invalidate(cacheKey); // 将日志记录扔到一个独立的线程池或MQ中异步处理,不阻塞当前线程 asyncTaskExecutor.execute(() -> auditLogService.logInvalidate(cacheKey)); }
坑三:新节点上线与消息丢失 这是 Redis Pub/Sub 的固有缺陷。如果一个服务实例在消息广播期间正在重启,或者新扩容一个实例,它会错过之前以及重启期间的所有消息。这意味着,新实例的本地缓存可能一开始就是旧的。
- 优化方案:我们采用了 “双重保障”策略。
- 兜底过期时间:给 Caffeine 设置的过期时间不再是“永不过期”或很长,而是一个合理的、业务可接受的值,比如 60秒。这样即使错过消息,最坏情况是数据延迟60秒刷新。
- 启动时预热:新的服务实例启动后,在执行第一次查询业务前,可以主动去 Redis 查询一批热点数据,加载到自己的本地缓存中。虽然不能保证全部最新,但能快速建立一份相对较新的缓存视图。
此外,我们还把频道主题设计得更精细,比如按业务模块划分:cache:invalidate:order、cache:invalidate:user 等,避免不相关的消息干扰所有监听器。
5. 效果对比:一张表看清方案优劣
方案上线稳定运行一段时间后,我们做了一次全面的复盘,和最初考虑的几种方案做了个对比,你可以参考下面的表格:
| 对比维度 | 无通知方案 (仅靠过期) | RabbitMQ 通知方案 | Redis Pub/Sub 通知方案 (本文) |
|---|---|---|---|
| 数据一致性 | 弱,不一致窗口期长(等于本地缓存过期时间) | 强,可靠消息保证 | 最终一致,近乎实时,有极低概率消息丢失 |
| 时效性 | 差(依赖过期) | 好(毫秒级) | 极好(亚毫秒级) |
| 系统复杂度 | 极低 | 高(需维护MQ集群) | 低(复用Redis) |
| 运维成本 | 低 | 高 | 低 |
| 资源消耗 | 低 | 中高(MQ服务器资源) | 低(仅增加Redis少量内存和CPU) |
| 适用场景 | 数据变化不频繁,对一致性要求极低的场景 | 金融、交易等对强一致性、可靠性要求极高的场景 | 高并发读、数据变更频率中等、允许秒级最终一致的业务(如商品信息、物流状态、用户资料) |
| 我们的选择理由 | 无法满足业务需求 | 牛刀杀鸡,过度设计 | 轻量、快速、简单、够用,与现有技术栈完美融合 |
从实际监控来看,在采用优化后的 Redis Pub/Sub 方案后,我们物流查询接口的 缓存命中率 依然保持在98%以上(主要命中本地缓存),而 数据不一致的用户投诉 降为了0。系统的整体响应时间中位数在2ms左右,完全达到了预期目标。
6. 进阶思考:这套方案是银弹吗?
当然不是。没有任何技术方案是放之四海而皆准的银弹。基于 Redis Pub/Sub 的多级缓存一致性优化,是一个在 特定约束 下非常漂亮的解决方案。在决定采用它之前,建议你再从以下几个角度审视你的项目:
第一,业务容忍度。 你的业务是否能接受“在极端情况下(如消息丢失、网络分区),用户可能看到几秒到几十秒的旧数据”?对于电商扣库存、支付状态这类场景,这显然是无法接受的。但对于文章阅读量、热搜榜、非核心的商品属性(如颜色描述),这就完全没问题。我们物流状态也属于后者,短暂的状态显示延迟在业务上是可以解释的。
第二,数据变更的维度。 我们上面的例子,失效粒度是单个运单号(缓存键)。如果你的数据更新经常是“批量更新某一类商品”(比如修改了某个分类下所有商品的活动标签),那么发布一条消息,让所有节点失效整个分类的缓存,可能比遍历失效每个商品键更高效。这就需要你设计更灵活的消息协议,比如消息内容可以是 invalidate:category:1024。
第三,与更高级缓存模式的结合。 比如 Caffeine 的 RefreshAfterWrite 策略。你可以配置 Caffeine 在写入后一段时间异步刷新数据,而不是被动失效。这样,即使失效消息延迟,用户请求也可能触发后台异步加载新数据,下次请求就能拿到新的。这可以和 Pub/Sub 失效通知结合,提供更平滑的体验。
第四,监控与告警。 一定要给这套机制加上监控。比如,监控 Redis Pub/Sub 频道的消息生产/消费速率是否正常;监控各个服务实例本地缓存的命中率和失效次数。如果某个实例长时间没有失效操作,但其他实例有,可能意味着它的监听器出问题了。我们当时就通过监控发现过一个因为线程池满导致异步日志任务丢弃,进而使得监听器变慢的案例。
在我个人看来,技术选型就像搭积木,没有最好的,只有最适合的。Redis Pub/Sub 解决多级缓存一致性,就是一块在“性能”、“复杂度”、“一致性”之间取得了很好平衡的积木。它可能不适用于所有宫殿,但用来搭建我们“高并发查询服务”这座高楼,却是结实又趁手的好材料。希望这次分享的实战经验和踩坑记录,能帮你少走弯路,更顺畅地构建出稳定高效的缓存系统。
469



被折叠的 条评论
为什么被折叠?



