保证缓存和数据库的一致性是一个经典难题,主要因为两者独立更新且无法实现原子操作。在高并发场景下,如果不加控制,可能出现缓存与数据库数据不一致的问题。
用 Java 代码 完整说明缓存与数据库一致性方案。所有示例代码均采用 Java(Spring Boot + RedisTemplate 风格),并保留了方案对比与避坑指南。
一、最常用方案:旁路缓存(Cache Aside Pattern)
核心规则:
- 读:先查缓存,命中返回;未命中查数据库,再写入缓存。
- 写:先更新数据库,然后删除缓存(而不是更新缓存)。
为什么删除而不是更新?
避免并发写导致缓存脏数据,更新缓存可能因并发写导致缓存与数据库值不一致,且多次更新会浪费性能。
删除操作幂等且简单。删除缓存则让下次读时重新加载,简单可靠。
Java 代码示例(基于 Spring Boot + RedisTemplate)
java
@Service
public class UserService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private UserRepository userRepository;
private static final String USER_CACHE_PREFIX = "user:";
// 读操作:旁路缓存
public User getUser(Long id) {
String cacheKey = USER_CACHE_PREFIX + id;
// 1. 查缓存
User user = (User) redisTemplate.opsForValue().get(cacheKey);
if (user != null) {
return user;
}
// 2. 缓存未命中,查数据库
user = userRepository.findById(id).orElse(null);
if (user != null) {
// 3. 写入缓存(可设置合理过期时间)
redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES);
}
return user;
}
// 写操作:先更新数据库,再删除缓存
@Transactional
public void updateUser(User user) {
// 1. 更新数据库
userRepository.save(user);
// 2. 删除缓存
String cacheKey = USER_CACHE_PREFIX + user.getId();
redisTemplate.delete(cacheKey);
}
}
该方案的并发风险与"缓存双删"
极低概率下会出现:读线程读到旧数据后,写线程删除了缓存,但读线程又把旧数据写回缓存。
解决方案:延迟双删 -- 更新数据库后,先删缓存,等待一小段时间(大于一次并发读写的耗时),再次删除。
java
@Transactional
public void updateUserWithDoubleDelete(User user) {
String cacheKey = USER_CACHE_PREFIX + user.getId();
// 1. 第一次删除缓存
redisTemplate.delete(cacheKey);
// 2. 更新数据库
userRepository.save(user);
// 3. 延迟一段时间(例如 500ms),再次删除缓存
new Thread(() -> {
try {
Thread.sleep(500); // 实际应使用更优雅的调度线程池
redisTemplate.delete(cacheKey);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
}
生产建议 :不要直接用
new Thread,可使用ScheduledExecutorService或消息队列延时消息。
二、解耦方案:订阅数据库变更日志(Canal + MQ)
流程:业务代码只操作数据库 → Canal 监听 binlog → 发 MQ → 消费者删除缓存。
优点:完全解耦,即使缓存删除失败,MQ 重试机制保证最终一致。
Java 消费者示例(Spring Boot + RocketMQ)
java
@Component
@RocketMQMessageListener(topic = "user-update-topic", consumerGroup = "cache-consume-group")
public class CacheDeleteConsumer implements RocketMQListener<String> {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Override
public void onMessage(String userIdStr) {
Long userId = Long.valueOf(userIdStr);
String cacheKey = "user:" + userId;
redisTemplate.delete(cacheKey);
log.info("删除缓存成功,key: {}", cacheKey);
}
}
对应业务代码只需更新数据库,无需触碰缓存:
java
@Transactional
public void updateUser(User user) {
userRepository.save(user);
// 可选:发送一条MQ消息作为保险(Canal 负责主要删除,这里可省略)
// mqProducer.send("user-update-topic", String.valueOf(user.getId()));
}
Canal 配置方法(MySQL binlog 开启 ROW 模式)
三、兜底方案:设置缓存过期时间(TTL)
无论用哪种方案,永远给缓存设置一个合理的 TTL (如 30 分钟)。
即使所有删除机制都失败,数据最终也会自动从数据库重新加载。
java
// 写入缓存时统一设置过期时间
redisTemplate.opsForValue().set(cacheKey, user, 30, TimeUnit.MINUTES);
四、强一致性方案:分布式锁(读写串行化)
适用场景 :极少数对一致性要求极高、且并发量不高的场景(如金融库存)。
代价:吞吐量大幅下降。
Java 示例(Redisson 分布式锁)
java
@Autowired
private RedissonClient redissonClient;
public User updateUserWithLock(User user) {
String lockKey = "lock:user:" + user.getId();
RLock lock = redissonClient.getLock(lockKey);
try {
lock.lock(3, TimeUnit.SECONDS);
// 1. 更新数据库
userRepository.save(user);
// 2. 删除缓存
redisTemplate.delete("user:" + user.getId());
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
return user;
}
public User getUserWithLock(Long id) {
String lockKey = "lock:user:" + id;
RLock lock = redissonClient.getLock(lockKey);
try {
lock.lock(1, TimeUnit.SECONDS);
// 1. 查缓存
User user = (User) redisTemplate.opsForValue().get("user:" + id);
if (user != null) return user;
// 2. 查数据库
user = userRepository.findById(id).orElse(null);
if (user != null) {
redisTemplate.opsForValue().set("user:" + id, user, 30, TimeUnit.MINUTES);
}
return user;
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
五、不同场景的方案选择(决策表)
| 业务场景 | 推荐方案 | 可容忍不一致时间 |
|---|---|---|
| 商品信息、用户资料(读多写少) | 旁路缓存 + TTL | 几分钟 |
| 库存、账户余额(写较多) | 延迟双删 / Canal+MQ | 秒级 |
| 排行榜、计数器(允许误差) | 只写缓存,异步回写 DB | 分钟~小时 |
| 支付、订单状态(强要求) | 直接读数据库,不用缓存 | 0 容忍 |
六、避坑指南(两个致命错误)
❌ 错误 1:先删缓存,再更新数据库
java
// 错误示范
redisTemplate.delete(cacheKey);
userRepository.save(user); // 此时若另一个线程读并写回旧数据,缓存永久脏
后果:高并发下缓存长时间为旧值,直到 TTL 过期。
❌ 错误 2:写请求中直接更新缓存(而非删除)
java
// 错误示范
userRepository.save(user);
redisTemplate.opsForValue().set(cacheKey, user); // 可能导致并发顺序错乱
后果:两个写请求乱序,缓存与数据库值相反。
七、最终推荐架构(Java 生产实践)
java
// 1. 写操作:延迟双删 + 事务注解
@Transactional
public void updateProduct(Product product) {
String cacheKey = "product:" + product.getId();
// 第一次删除
redisTemplate.delete(cacheKey);
// 更新数据库
productRepository.save(product);
// 延迟删除(使用线程池)
scheduledExecutor.schedule(() -> redisTemplate.delete(cacheKey), 500, TimeUnit.MILLISECONDS);
}
// 2. 读操作:旁路缓存 + TTL
public Product getProduct(Long id) {
String cacheKey = "product:" + id;
Product p = (Product) redisTemplate.opsForValue().get(cacheKey);
if (p != null) return p;
p = productRepository.findById(id).orElse(null);
if (p != null) {
redisTemplate.opsForValue().set(cacheKey, p, 10, TimeUnit.MINUTES);
}
return p;
}
// 3. 全局兜底:在配置类中为所有 Redis 缓存设置默认 TTL
@Bean
public RedisCacheManagerBuilderCustomizer customizer() {
return builder -> builder
.withDefaultCacheConfiguration(RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30)));
}
总结:
- 核心:Cache Aside(先 DB 后删缓存)。
- 加强:延迟双删 或 Canal+MQ。
- 底线:永远设置 TTL。
- 原则:不追求绝对强一致性,否则应放弃缓存。