缓存一致性是所有使用Redis做缓存的系统都绕不开的难题。数据库和Redis是两个独立的存储系统,不存在分布式事务层面的强一致保证,任何写操作都需要分别修改两个地方,中间只要出现并发交叉或异常中断,数据就可能不一致。解决这个问题的思路有很多,其中基于订阅通知的方案因为解耦彻底、对业务代码侵入小,被越来越多的团队采用。本文将从问题根源讲起,逐步展开几种常见方案,并重点剖析发布订阅机制的实现细节。

缓存不一致的根源在哪里
要理解订阅通知方案的价值,先得明白不一致是怎么产生的。假设系统采用最常见的Cache Aside模式:读请求先查缓存,缓存未命中则查数据库并回填;写请求先更新数据库,再删除缓存。这个模式在单线程下没有问题,但在并发场景下会出现经典的竞态条件。
举个例子,线程A读请求发现缓存未命中,去数据库查到了旧值V1;此时线程B完成了一次写操作,把数据库更新为V2并删除了缓存;接着线程A拿着V1慢悠悠地回填缓存。结果就是数据库里是V2,缓存里却是V1,而且这个脏数据会一直存在,直到缓存过期。这种问题出现的概率虽然不高,但在高并发系统中几乎必然发生。
另一种情况是写操作本身的不一致:先删缓存再更新数据库时,如果删除成功后更新数据库的动作还没执行完,另一个读请求就会把旧数据重新加载进缓存;先更新数据库再删缓存时,如果删除缓存失败,同样会留下脏数据。可以看到,问题本质在于两个存储的写操作无法原子完成,任何纯靠顺序控制的方案都只能降低不一致的概率,无法彻底消除。所以工程上的目标一般是保证最终一致性,同时让不一致窗口尽可能短。
常见一致性方案对比与选型
先更新数据库再删除缓存,也就是常说的Cache Aside写策略,是目前接受度最高的做法。它的优点是逻辑简单,读多写少场景下不一致概率极低;缺点是删除缓存失败时需要补偿机制,通常配合消息队列重试。代码上很简单:
public void updateProduct(Product product) {
// 先更新数据库
productMapper.updateById(product);
// 再删除缓存,失败则重试
boolean deleted = false;
int retry = 0;
while (!deleted && retry < 3) {
try {
redisTemplate.delete("product:" + product.getId());
deleted = true;
} catch (Exception e) {
retry++;
}
}
if (!deleted) {
// 兜底:投递到消息队列异步删除
mqProducer.send("cache-delete-topic", "product:" + product.getId());
}
}
延迟双删是另一种常见方案:写操作先删一次缓存,更新数据库后延迟几百毫秒再删第二次,目的是清掉并发读请求回填的旧值。它实现成本低,但延迟时间不好把握,设短了没用,设长了脏数据窗口变大,而且延迟逻辑会让接口响应变差,一般只作为过渡方案。
基于binlog的订阅方案则更进一步。通过Canal或Maxwell伪装成MySQL的从库,订阅binlog变更事件,解析出数据变更后统一删除或更新对应缓存。这种方式业务代码零侵入,一致性可靠性高,因为binlog是数据库操作的最终事实。缺点是引入了额外组件,运维成本上升,小团队未必愿意承担。而介于两者之间的,就是本文的重点——基于Redis发布订阅或消息队列的订阅通知方案。
基于Pub/Sub的订阅通知实现
订阅通知的核心思想是:写操作只负责更新数据库,数据库更新成功后发布一条失效消息到指定频道,缓存维护逻辑由独立的订阅者完成。这样业务代码和缓存逻辑彻底解耦,重试、日志、监控都可以集中在订阅端处理。
发布端的代码非常轻量,只需要在事务提交后发一条消息:
@Service
public class OrderService {
@Autowired
private StringRedisTemplate redisTemplate;
@Transactional
public void updateOrder(Order order) {
orderMapper.updateById(order);
// 事务提交后发布缓存失效通知
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
redisTemplate.convertAndSend("cache:invalidate",
"order:" + order.getId());
}
});
}
}
订阅端单独部署一个监听器,收到消息后删除缓存并记录日志:
@Configuration
public class RedisSubscriberConfig {
@Bean
public RedisMessageListenerContainer container(
RedisConnectionFactory factory,
CacheInvalidationListener listener) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(factory);
// 订阅缓存失效频道
container.addMessageListener(listener,
new ChannelTopic("cache:invalidate"));
return container;
}
}
@Component
public class CacheInvalidationListener implements MessageListener {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public void onMessage(Message message, byte[] pattern) {
String key = new String(message.getBody());
try {
redisTemplate.delete(key);
log.info("缓存已失效: {}", key);
} catch (Exception e) {
log.error("缓存删除失败: {}", key, e);
// 失败时可写入重试队列
}
}
}
这里有个细节值得注意:发布动作必须放在事务提交之后。如果在事务内直接发布,订阅者可能先于事务提交收到消息去删缓存,删完之后事务回滚或另一个读请求又把旧数据填回去,白忙一场。使用TransactionSynchronizationManager注册事务同步回调是Spring环境下的标准做法。
Pub/Sub方案也有明显短板:Redis的发布订阅是即发即弃模式,订阅者掉线期间的消息会直接丢失,没有持久化能力。如果订阅服务重启恰好错过了失效消息,脏缓存就没人清理了。因此生产环境更稳妥的做法是用Redis Stream替代Pub/Sub,Stream支持消费者组和消息确认,未确认的消息可以重新消费,可靠性高出一个量级。
高并发下的进阶保障手段
订阅通知解决的是失效消息的可靠传递,但并不能覆盖所有并发竞态。即便消息可靠送达,仍可能出现读请求回填旧值的时序问题,需要额外的保障手段配合。
第一招是给缓存设置兜底过期时间。哪怕所有失效逻辑都失效了,数据不一致的最长时间也被TTL限制住。对于订单、商品这类数据,设置几分钟到几十分钟的过期时间通常足够,这是成本最低的保险丝。第二招是对更新频繁的热点数据引入短时间的互斥控制:读请求回填缓存时,用SET key value NX EX保证只有一个请求能写入,其余请求等待或直接返回数据库结果,减少并发回填的交叉概率。
第三招是消息乱序问题。同一行数据连续更新两次,若两条失效消息乱序到达,理论上没有危害,因为删缓存是幂等操作,删两次和删一次效果相同。但如果订阅端做的是更新缓存而不是删除缓存,就必须处理乱序,可以在消息中携带版本号或时间戳,订阅端只接受更新的版本,旧版本消息直接丢弃。
综合来看,一套可靠的缓存一致性架构通常是多层防线的组合:业务侧先更新数据库再发失效通知,订阅端通过Stream可靠消费并删除缓存,缓存设置兜底TTL,关键链路加监控告警。没有哪一招能单独做到万无一失,但层层叠加后,不一致窗口可以压缩到毫秒级,对绝大多数业务来说已经足够。选型时不必盲目追求复杂方案,先评估业务对不一致的容忍度,再决定用Pub/Sub轻量方案还是引入Canal做binlog级订阅,合适的才是最好的。
Redis缓存一致性发布订阅缓存更新策略修改时间:2026-09-04 02:24:45