本地缓存一致性保障方案
本地缓存一致性挑战
问题根源
本地缓存通过牺牲一致性来换取性能提升,这是分布式系统CAP理论的实际体现。在选择了AP(可用性+分区容错性)的同时,必然要放弃C(强一致性)。
在集群环境中,每个应用实例都维护独立的本地缓存副本。当某个实例更新数据库后,其他实例的本地缓存仍然保存着旧数据,导致数据不一致:
mermaid
graph TB
DB[(MySQL数据库<br/>商品价格999元)]
Instance1[实例1<br/>本地缓存: 1299元]
Instance2[实例2<br/>本地缓存: 1299元]
Instance3[实例3<br/>本地缓存: 999元✓]
Instance3 -->|更新价格| DB
Instance1 -.->|数据过期| DB
Instance2 -.->|数据过期| DB
User1[用户A] -->|查询| Instance1
User2[用户B] -->|查询| Instance2
style DB fill:#66BB6A,stroke:#2E7D32,stroke-width:2px,rx:10,ry:10
style Instance1 fill:#E57373,stroke:#C62828,stroke-width:2px,rx:10,ry:10
style Instance2 fill:#E57373,stroke:#C62828,stroke-width:2px,rx:10,ry:10
style Instance3 fill:#42A5F5,stroke:#1565C0,stroke-width:2px,rx:10,ry:10核心认知
重要原则: 如果业务对数据一致性有强要求,应直接使用分布式缓存或数据库,不应该选择本地缓存。
本地缓存适用于以下场景:
- 数据变更频率低(如字典表、配置项)
- 业务能容忍短时间内的数据不一致(如商品详情展示)
- 对性能要求极高,一致性要求相对宽松
反模式警告: 库存扣减、余额变更等强一致性场景绝不能使用本地缓存。
一致性保障方案
虽然本地缓存无法做到强一致性,但可以通过技术手段追求最终一致性,将不一致窗口期控制在业务可接受范围内。
方案一:配置中心同步
实现原理
利用配置中心的变更推送能力,实现跨实例的缓存同步:
mermaid
graph TB
Instance1[实例1] -->|1.更新数据库| DB[(数据库)]
Instance1 -->|2.发布配置变更| ConfigCenter[配置中心<br/>Nacos/Apollo]
ConfigCenter -->|3.推送变更通知| Instance1
ConfigCenter -->|3.推送变更通知| Instance2[实例2]
ConfigCenter -->|3.推送变更通知| Instance3[实例3]
Instance1 -.->|4.删除本地缓存| Cache1[Caffeine]
Instance2 -.->|4.删除本地缓存| Cache2[Caffeine]
Instance3 -.->|4.删除本地缓存| Cache3[Caffeine]
style DB fill:#66BB6A,stroke:#2E7D32,stroke-width:2px,rx:10,ry:10
style ConfigCenter fill:#42A5F5,stroke:#1565C0,stroke-width:2px,rx:10,ry:10
style Instance1 fill:#AB47BC,stroke:#6A1B9A,stroke-width:2px,rx:10,ry:10
style Instance2 fill:#AB47BC,stroke:#6A1B9A,stroke-width:2px,rx:10,ry:10
style Instance3 fill:#AB47BC,stroke:#6A1B9A,stroke-width:2px,rx:10,ry:10代码实现
java
@Component
public class ConfigCenterCacheInvalidator {
@Autowired
private LocalCacheManager localCache;
@Autowired
private NacosConfigService nacosConfig;
/**
* 更新商品价格并失效缓存
*/
@Transactional
public void updateProductPrice(String productId, BigDecimal newPrice) {
// 1. 更新数据库
productRepository.updatePrice(productId, newPrice);
// 2. 删除本地缓存
localCache.invalidate("product:" + productId);
// 3. 发布配置变更,通知其他实例
CacheInvalidateEvent event = new CacheInvalidateEvent();
event.setCacheKey("product:" + productId);
event.setTimestamp(System.currentTimeMillis());
nacosConfig.publishConfig(
"cache-invalidate-events",
JSON.toJSONString(event)
);
}
/**
* 监听配置中心的缓存失效事件
*/
@NacosConfigListener(dataId = "cache-invalidate-events")
public void onCacheInvalidateEvent(String eventJson) {
CacheInvalidateEvent event = JSON.parseObject(eventJson, CacheInvalidateEvent.class);
// 删除本地缓存
localCache.invalidate(event.getCacheKey());
log.info("收到缓存失效通知,已删除本地缓存: {}", event.getCacheKey());
}
}优缺点分析
优点:
- 实时性较好,配置推送延迟通常在秒级
- 依赖成熟的配置中心组件,稳定性有保障
缺点:
- 引入额外的基础设施依赖
- 配置中心故障会影响缓存一致性
- 不适合高频变更场景,可能产生配置变更风暴
方案二:MQ广播消息
架构设计
利用消息队列的广播特性,实现缓存失效通知的全局分发:
mermaid
graph TB
Instance1[实例1] -->|1.更新数据库| DB[(数据库)]
Instance1 -->|2.发送广播消息| MQ[RocketMQ<br/>Topic: cache-invalidate]
MQ -->|3.广播消费| CG1[消费者组1]
MQ -->|3.广播消费| CG2[消费者组2]
MQ -->|3.广播消费| CG3[消费者组3]
CG1 -.->|删除缓存| Instance1
CG2 -.->|删除缓存| Instance2[实例2]
CG3 -.->|删除缓存| Instance3[实例3]
style DB fill:#66BB6A,stroke:#2E7D32,stroke-width:2px,rx:10,ry:10
style MQ fill:#E57373,stroke:#C62828,stroke-width:2px,rx:10,ry:10
style Instance1 fill:#42A5F5,stroke:#1565C0,stroke-width:2px,rx:10,ry:10
style Instance2 fill:#42A5F5,stroke:#1565C0,stroke-width:2px,rx:10,ry:10
style Instance3 fill:#42A5F5,stroke:#1565C0,stroke-width:2px,rx:10,ry:10实现代码
java
@Service
public class MqCacheSyncService {
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Autowired
private ProductLocalCache productCache;
/**
* 更新商品信息并发送缓存失效消息
*/
@Transactional
public void updateProductInfo(String productId, ProductUpdateDTO updateDTO) {
// 1. 更新数据库
productService.updateProduct(productId, updateDTO);
// 2. 删除本地缓存
productCache.invalidate(productId);
// 3. 发送MQ广播消息
CacheInvalidateMessage message = CacheInvalidateMessage.builder()
.cacheType("PRODUCT")
.cacheKey(productId)
.operationType("UPDATE")
.timestamp(System.currentTimeMillis())
.build();
rocketMQTemplate.syncSend(
"cache-invalidate-topic",
message,
3000 // 超时时间3秒
);
log.info("已发送商品缓存失效通知: {}", productId);
}
/**
* 消费缓存失效广播消息
*/
@RocketMQMessageListener(
topic = "cache-invalidate-topic",
consumerGroup = "${spring.application.name}",
messageModel = MessageModel.BROADCASTING // 广播模式
)
@Component
public class CacheInvalidateListener implements RocketMQListener<CacheInvalidateMessage> {
@Autowired
private ProductLocalCache productCache;
@Override
public void onMessage(CacheInvalidateMessage message) {
try {
if ("PRODUCT".equals(message.getCacheType())) {
// 删除商品本地缓存
productCache.invalidate(message.getCacheKey());
log.info("已处理商品缓存失效消息: {}", message.getCacheKey());
}
} catch (Exception e) {
log.error("处理缓存失效消息异常", e);
}
}
}
}优势分析
- 解耦性强,业务代码与缓存同步逻辑分离
- 天然支持广播,一条消息通知所有实例
- 消息可靠性有保障,支持重试和死信队列
- 适合中高频变更场景
方案三:自动过期+主动刷新
这是生产环境最常用且最推荐的方案,通过合理设置过期策略,在性能和一致性之间找到平衡点。
自动失效策略
利用Caffeine的过期机制,确保过期数据最终被清理:
java
@Configuration
public class CacheConfiguration {
@Bean
public Cache<String, ProductDetailVO> productCache() {
return Caffeine.newBuilder()
// 写入后8分钟自动过期
.expireAfterWrite(8, TimeUnit.MINUTES)
// 最后访问5分钟后过期
.expireAfterAccess(5, TimeUnit.MINUTES)
// 最大容量
.maximumSize(50000)
.build();
}
}过期时间设定原则:
- 评估业务可容忍的最大不一致时长
- 过期时间应略小于业务容忍度(留出安全边际)
- 热点数据设置较短的过期时间
- 低频变更数据可适当延长过期时间
自动刷新机制
Caffeine支持异步刷新,在过期前主动加载最新数据:
java
@Component
public class ProductCacheLoader {
@Autowired
private ProductRepository productRepository;
@Autowired
private RedisTemplate<String, ProductDetailVO> redisTemplate;
@Bean
public LoadingCache<String, ProductDetailVO> autoRefreshProductCache() {
return Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(10, TimeUnit.MINUTES)
// 写入后5分钟开始异步刷新
.refreshAfterWrite(5, TimeUnit.MINUTES)
.build(new CacheLoader<String, ProductDetailVO>() {
@Override
public ProductDetailVO load(String productId) {
// 缓存未命中时的加载逻辑
return loadProductDetail(productId);
}
@Override
public ProductDetailVO reload(String productId, ProductDetailVO oldValue) {
// 缓存刷新时的加载逻辑
return loadProductDetail(productId);
}
private ProductDetailVO loadProductDetail(String productId) {
// 1. 先查询Redis分布式缓存
ProductDetailVO detail = redisTemplate.opsForValue()
.get("product:detail:" + productId);
if (detail != null) {
return detail;
}
// 2. Redis未命中,查询数据库
detail = productRepository.findById(productId)
.map(this::convertToVO)
.orElse(null);
// 3. 回写Redis
if (detail != null) {
redisTemplate.opsForValue().set(
"product:detail:" + productId,
detail,
30,
TimeUnit.MINUTES
);
}
return detail;
}
});
}
/**
* 查询商品详情(自动刷新)
*/
public ProductDetailVO getProductDetail(String productId) {
return autoRefreshProductCache().get(productId);
}
}工作流程
mermaid
graph TB
Request[查询请求] --> CheckLocal{本地缓存<br/>是否命中}
CheckLocal -->|命中且未过期| Return1[返回数据]
CheckLocal -->|过期或未命中| LoadData[执行CacheLoader]
LoadData --> CheckRedis{Redis缓存<br/>是否命中}
CheckRedis -->|命中| Return2[返回数据+更新本地缓存]
CheckRedis -->|未命中| LoadDB[查询数据库]
LoadDB --> UpdateRedis[更新Redis]
UpdateRedis --> UpdateLocal[更新本地缓存]
UpdateLocal --> Return3[返回数据]
style CheckLocal fill:#42A5F5,stroke:#1565C0,stroke-width:2px,rx:10,ry:10
style CheckRedis fill:#42A5F5,stroke:#1565C0,stroke-width:2px,rx:10,ry:10
style LoadDB fill:#E57373,stroke:#C62828,stroke-width:2px,rx:10,ry:10
style Return1 fill:#66BB6A,stroke:#2E7D32,stroke-width:2px,rx:10,ry:10
style Return2 fill:#66BB6A,stroke:#2E7D32,stroke-width:2px,rx:10,ry:10
style Return3 fill:#66BB6A,stroke:#2E7D32,stroke-width:2px,rx:10,ry:10方案优势
- 零侵入: 无需改造现有业务逻辑
- 自治性强: 每个实例独立完成缓存维护
- 故障隔离: 不依赖外部通知机制,某个实例故障不影响其他实例
- 性能最优: 后台异步刷新,不阻塞查询请求
方案四:Redis Pub/Sub通知(不推荐)
技术方案
利用Redis的键空间通知功能,监听缓存数据的变更事件:
java
@Component
public class RedisCacheEventListener {
@Autowired
private LocalCacheManager localCache;
/**
* 监听Redis键删除事件
*/
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(
RedisConnectionFactory connectionFactory) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
// 订阅键删除事件
container.addMessageListener(new MessageListener() {
@Override
public void onMessage(Message message, byte[] pattern) {
String channel = new String(message.getChannel());
String key = new String(message.getBody());
// 解析事件类型
if (channel.contains("del") || channel.contains("expired")) {
// 同步删除本地缓存
localCache.invalidate(key);
log.info("Redis键变更,已删除本地缓存: {}", key);
}
}
}, new PatternTopic("__keyevent@0__:del"));
return container;
}
}关键问题
消息可靠性差: Redis Pub/Sub是"即发即弃"模式,客户端断线期间的消息会丢失,无法保证通知必达。
性能开销: 需要为每个缓存键订阅事件,大量缓存会产生性能压力。
Redis配置要求: 需要开启键空间通知,可能影响Redis性能。
不推荐理由: 在有更优方案的情况下,不应选择可靠性不足的方案。
最佳实践建议
场景化选型
| 业务场景 | 推荐方案 | 理由 |
|---|---|---|
| 低频变更的字典数据 | 自动过期 | 变更少,简单有效 |
| 商品详情(读多写少) | 自动过期+刷新 | 平衡性能和一致性 |
| 需要实时失效的场景 | MQ广播 | 通知及时,可靠性高 |
| 对一致性要求极高 | 不用本地缓存 | 直接使用分布式缓存 |
关键原则
- 容忍度优先: 选择本地缓存前,必须明确业务对不一致的容忍程度
- 简单为美: 优先选择自动过期等简单方案,避免过度设计
- 防御性设计: 设置合理的过期时间作为最后防线
- 监控告警: 监控缓存命中率和一致性指标,及时发现问题
- 降级准备: 本地缓存故障时,应能自动降级到分布式缓存或数据库
反模式警告
- ❌ 用本地缓存存储库存、余额等强一致性数据
- ❌ 不设置过期时间,期望通过通知机制保证一致性
- ❌ 过度依赖Redis Pub/Sub等不可靠的通知机制
- ❌ 在不一致窗口期内执行关键业务决策
合理使用本地缓存,在性能和一致性之间找到适合业务的平衡点,是架构设计的关键能力。
更新: 2025-12-04 17:41:42
原文: https://www.yuque.com/u22210564/zoxfmt/doc-05-03