一、多级缓存架构与一致性核心挑战
在分布式高并发系统中,典型的“Caffeine(L1本地缓存)+ Redis(L2分布式缓存)+ MySQL(数据库源)”三级架构能显著提升性能。
其核心优势在于:Caffeine提供纳秒级访问速度,拦截90%以上热点请求;Redis作为共享缓存层,保证多节点数据共享;MySQL作为唯一真实数据源。
然而,多级缓存引入了复杂的数据一致性问题:
-
写后读不一致:更新数据时,若各层级缓存更新顺序或时机不当,会导致线程读取到旧数据。
-
多节点同步难题:分布式环境下,单个节点更新本地缓存(Caffeine)后,其他节点无法自动感知。
-
更新失败风险:任何一层级缓存更新失败(如网络问题),都会导致数据不一致。
二、主流数据一致性方案及优缺点对比
方案一:Cache-Aside(旁路缓存) + 延迟双删 + 广播通知(推荐综合方案)
这是目前最主流且平衡性最好的方案。
核心流程:
-
读请求:
Caffeine -> Redis -> MySQL依次查询,结果回填至上层缓存。 -
写请求:
-
先更新MySQL数据库。
-
再删除Redis中的缓存(而非更新)。
-
通过Redis Pub/Sub或消息队列(如RocketMQ)广播缓存失效消息。
-
所有消费节点(包括本节点)收到消息后,清除本地Caffeine缓存。
-
(可选)执行“延迟双删”:在删除Redis缓存后,延迟数百毫秒再次删除,以清除可能由并发读请求写入的旧缓存。
-
方案二:基于Binlog日志的最终一致性方案
核心流程:业务代码只更新MySQL。通过中间件(如Canal、Debezium)订阅MySQL的Binlog,解析变更日志后,自动删除或更新Redis缓存,并广播清理本地缓存。
优点:对业务代码零侵入,解耦彻底。
缺点:架构复杂,同步有延迟,运维成本高。
方案三:强一致性方案(分布式锁)
核心流程:在更新数据时,使用分布式锁(基于Redis)确保同一时间只有一个线程执行“查库-更新缓存”操作。
优点:保证强一致性。
缺点:性能损耗大,并发度低,存在死锁风险。
各方案优缺点对比表
| 对比维度 | 方案一:Cache-Aside+广播 | 方案二:Binlog同步 | 方案三:分布式锁 |
|---|---|---|---|
| 一致性强度 | 最终一致性(短时间窗口) | 最终一致性(有延迟) | 强一致性 |
| 性能影响 | 较小(异步广播,读性能极高) | 很小(异步解析) | 大(串行化处理) |
| 业务侵入性 | 中(需在业务代码中调用缓存删除) | 无 | 高(需在代码中加锁) |
| 架构复杂度 | 中(需实现消息广播) | 高(需维护中间件) | 低 |
| 可靠性 | 高(可结合消息队列重试) | 高(依赖Binlog,可靠) | 中(锁故障影响大) |
| 适用场景 | 绝大多数高并发互联网业务 | 对一致性要求高、业务复杂的系统 | 对一致性要求极高的金融、交易核心 |
文字对比总结:
方案一在性能、复杂度和一致性之间取得了最佳平衡,是多数场景的首选。方案二适合架构成熟、追求业务纯净度的团队。方案三仅适用于不容忍任何不一致的极端场景。
三、方案一详细配置与代码实现(Spring Boot)
以下实现整合了Cache-Aside、延迟双删(可选)和Redis Pub/Sub广播。
1. 依赖与基础配置
xml
<dependencies>
<!-- Caffeine 本地缓存 -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>
<!-- Spring Data Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Apache Commons Pool 2 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
</dependencies>
yaml
# application.yml
spring:
redis:
host: ${REDIS_HOST}
port: 6379
lettuce:
pool:
max-active: 8
2. 缓存组件配置
java
@Configuration
@Slf4j
public class CacheConfig {
/**
* 配置Caffeine本地缓存
*/
@Bean
public Cache<String, Object> caffeineCache() {
return Caffeine.newBuilder()
.initialCapacity(1000) // 初始容量
.maximumSize(10000) // 最大缓存条目数
.expireAfterWrite(Duration.ofMinutes(10)) // 写入10分钟后过期
.recordStats() // 开启统计
.build();
}
/**
* 配置RedisTemplate,使用JSON序列化
*/
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper om = new ObjectMapper();
om.activateDefaultTyping(om.getPolymorphicTypeValidator(), ObjectMapper.DefaultTyping.NON_FINAL);
serializer.setObjectMapper(om);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
}
3. 两级缓存管理器与广播监听实现
java
@Component
@Slf4j
public class TwoLevelCacheManager {
@Autowired
private Cache<String, Object> caffeineCache;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private RedisTemplate<String, String> stringRedisTemplate;
private static final String CACHE_EVICT_TOPIC = "cache:evict";
/**
* 核心读方法:L1 -> L2 -> DB
*/
public <T> T get(String key, Class<T> type, Supplier<T> dbLoader) {
// 1. 查L1本地缓存
Object value = caffeineCache.getIfPresent(key);
if (value != null) {
log.debug("L1命中: {}", key);
return type.cast(value);
}
// 2. 查L2 Redis缓存
value = redisTemplate.opsForValue().get(key);
if (value != null) {
log.debug("L2命中,回填L1: {}", key);
caffeineCache.put(key, value);
return type.cast(value);
}
// 3. 查数据库(Caffeine的get方法可原子性防击穿)
return type.cast(caffeineCache.get(key, k -> {
T data = dbLoader.get();
if (data == null) {
// 缓存空值防穿透,设置较短TTL
cacheNullValue(k);
return null;
}
// 写入Redis,设置随机过期时间防雪崩
redisTemplate.opsForValue().set(k, data,
Duration.ofMinutes(10 + ThreadLocalRandom.current().nextInt(-2, 3)));
return data;
}));
}
/**
* 核心写方法:更新DB,删除缓存,广播失效
* @param enableDelayDoubleDelete 是否开启延迟双删
*/
@Transactional
public void updateThenEvict(String key, Runnable dbUpdater, boolean enableDelayDoubleDelete) {
// 1. 更新数据库
dbUpdater.run();
// 2. 删除Redis缓存
redisTemplate.delete(key);
// 3. (可选) 延迟双删
if (enableDelayDoubleDelete) {
CompletableFuture.runAsync(() -> {
try {
// 延迟时间建议根据业务读耗时设定,通常500ms-1s[citation:8]
Thread.sleep(500);
redisTemplate.delete(key);
log.info("延迟双删执行: {}", key);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
// 4. 立即清除本地缓存,并广播
evictLocalAndBroadcast(key);
}
/**
* 清除本地缓存并广播失效消息
*/
private void evictLocalAndBroadcast(String key) {
// 清除本机L1缓存
caffeineCache.invalidate(key);
// 通过Redis Pub/Sub广播,让其他节点也清除本地缓存[citation:4]
stringRedisTemplate.convertAndSend(CACHE_EVICT_TOPIC, key);
}
/**
* 缓存空值,防止缓存穿透[citation:7]
*/
private void cacheNullValue(String key) {
String nullMarker = "";
caffeineCache.put(key, nullMarker);
redisTemplate.opsForValue().set(key, nullMarker, Duration.ofSeconds(60));
}
}
/**
* 监听广播,清除其他节点的本地缓存[citation:4]
*/
@Component
@Slf4j
public class CacheEvictionListener implements MessageListener {
@Autowired
private Cache<String, Object> caffeineCache;
@Override
public void onMessage(Message message, byte[] pattern) {
String channel = new String(message.getChannel());
String evictedKey = message.toString();
if (CACHE_EVICT_TOPIC.equals(channel)) {
log.info("收到缓存失效广播,清除本地缓存: {}", evictedKey);
caffeineCache.invalidate(evictedKey);
}
}
}
配置监听容器:
java
@Configuration
public class RedisPubSubConfig {
@Bean
public RedisMessageListenerContainer container(RedisConnectionFactory factory,
CacheEvictionListener listener) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(factory);
container.addMessageListener(listener, new ChannelTopic("cache:evict"));
return container;
}
}
4. 业务层使用示例
java
@Service
@Slf4j
public class UserService {
@Autowired
private TwoLevelCacheManager cacheManager;
@Autowired
private UserMapper userMapper;
public User getUserById(Long id) {
String key = "user:" + id;
return cacheManager.get(key, User.class, () -> {
log.info("查询数据库获取用户: {}", id);
// 模拟数据库查询
return userMapper.selectById(id);
});
}
public void updateUser(User user) {
String key = "user:" + user.getId();
cacheManager.updateThenEvict(key,
() -> userMapper.updateById(user), // 数据库更新操作
true // 开启延迟双删
);
}
public void deleteUser(Long id) {
String key = "user:" + id;
// 删除操作同样需要清除缓存
cacheManager.updateThenEvict(key,
() -> userMapper.deleteById(id),
false
);
}
}
四、高级优化与生产建议
-
监控与调优:
-
监控Caffeine命中率(
cache.stats().hitRate()),目标 > 90%。 -
监控Redis内存及网络带宽,防止热点数据打满。
-
-
一致性分级策略:
-
极高一致性要求(如库存):采用方案三(分布式锁),或方案一结合极短的本地缓存TTL(如秒级)。
-
普通一致性要求(如用户信息):采用方案一,本地缓存TTL设置为分钟级。
-
低一致性要求(如商品浏览量):可仅使用本地缓存 + 较长TTL,接受一定延迟。
-
-
失败补偿与重试:
-
对于广播消息,可集成Spring Retry或消息队列(RocketMQ/Kafka)保证至少一次送达。
-
实现一个后台任务,定期比对热点数据的缓存与数据库版本,进行兜底修复。
-
总结:
实现Caffeine、Redis与MySQL集群数据一致性的最实用方案是 Cache-Aside模式结合主动删除与广播失效。它通过在写操作时“先更新数据库,再删除Redis缓存,最后广播清除所有本地缓存”的流程,在性能与一致性之间取得了最佳平衡。对于绝大多数互联网应用,这已能满足需求。选择方案时,务必根据业务对一致性、性能和复杂度的实际容忍度进行决策。

931

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



