概述
如何保持redis和数据库一致呢?也就是双写一致性的问题。
首先要根据业务来选方案,一般分成2种:一种是强一致性的,另一种是允许延迟一致的。
方案主流有以下:延时双删,redisson的读写锁,MQ或Canal中间件实现异步通知
强一致性的话可以常采用redisson的读写锁。
允许延迟一致的可以采用MQ或Canal中间件实现异步通知或者延时双删,保证数据的最终一致性,减少代码侵入。
延时双删

传统使用redis的做法一般是:
读数据的时候先查缓存,命中了直接返回,没有命中查数据库,然后写入缓存,设定超时时间,返回数据。
写数据的时候先删缓存再修改数据库,然后延时一点时间删缓存
2个问题:为什么不采用先删除缓存后修改数据库或者先改数据库后删缓存的方案
先删缓存再修改数据库会在删除完缓存还没更新数据库的时候,读数据库的线程过来了,查到了老数据并更新了缓存,这样就会导致数据和缓存不一致。

如果是先更新数据库,再删缓存呢?那么可能在还没有缓存的时候查了数据库,然后另一个线程更新了数据库,并删除了缓存,这时第一个线程才完成了写如数据库,也会导致不一致。

所以出现了双删,但是为什么双删,主要是为了让数据库完成主从同步,个人认为也可以解决一定的并发问题,防止我更新数据库的时候缓存被写入了旧数据,等一会再删保证数据最新。但是延时多久很难界定,而且无法完全解决一致性问题,可能有很短的时间内不一致。
读写锁
那怎么彻底解决,就是读写都加锁,使得读或者写操作都是串行的,这样肯定没问题,但是效率肯定低,但是也可以优化,就是采用读写锁,读缓存加读锁,数据库更新和删除缓存加写锁,读锁允许别的线程读,写锁不允许读和写,这样就能提搞效率。但是前提是读多写少。

代码示例:

用MQ或Canal中间件实现异步通知
mq方式就是完成数据库操作发送更新缓存的异步操作

canal是基于监听mysql的二进制文件(binlog),当有我们需要的表数据发生变化的时候去更新缓存,(有兴趣的可以搜索详细的方案)

拓展:
问:在保证redis数据和数据库数据一致的方案中,如果需要强一致性,采用redisson的读写锁,在读数据使用读锁,写锁来锁更新数据库的操作,但是一般读数据的时候可能数据正好过期了,那么读的操作是不是要去查数据库,并写到redis缓存中,那么这个写是要再次加锁吗?
(deepseek)答:
这是一个很好的问题,涉及分布式环境下强一致性缓存方案的细节设计。让我详细分析一下。
核心问题分析
是的,**读缓存时如果数据过期或不存在,需要去查数据库并写入缓存,这个写操作确实需要再次加锁**。否则会出现数据不一致的问题。
典型流程设计
以下是采用 Redisson 读写锁保证强一致性的完整流程:
1. **读操作流程**
```java
public Object readData(String key) {
RReadWriteLock lock = redisson.getReadWriteLock("lock:" + key);
RLock readLock = lock.readLock();
try {
readLock.lock();
// 1. 先尝试读缓存
Object value = redisTemplate.opsForValue().get(key);
// 2. 缓存存在且未过期,直接返回
if (value != null && !isExpired(key)) {
return value;
}
// 3. 缓存不存在或已过期,需要降级为写锁
readLock.unlock();
RLock writeLock = lock.writeLock();
writeLock.lock();
try {
// 4. 再次检查(双重检查锁模式)
value = redisTemplate.opsForValue().get(key);
if (value != null && !isExpired(key)) {
return value;
}
// 5. 查询数据库
value = queryFromDatabase(key);
// 6. 写入缓存
redisTemplate.opsForValue().set(key, value, TTL, TimeUnit.SECONDS);
return value;
} finally {
writeLock.unlock();
}
} finally {
// 确保读锁被释放(如果还在持有的话)
if (readLock.isHeldByCurrentThread()) {
readLock.unlock();
}
}
}
```
2. **写操作流程**
```java
public void updateData(String key, Object newValue) {
RReadWriteLock lock = redisson.getReadWriteLock("lock:" + key);
RLock writeLock = lock.writeLock();
try {
writeLock.lock();
// 1. 更新数据库
updateDatabase(key, newValue);
// 2. 删除或更新缓存(建议删除)
redisTemplate.delete(key);
// 或者:redisTemplate.opsForValue().set(key, newValue, TTL, TimeUnit.SECONDS);
} finally {
writeLock.unlock();
}
}
```
关键设计要点
1. **锁升级机制**
-
从读锁升级到写锁:必须先释放读锁,再获取写锁
-
这是必要的,因为读写锁不允许直接升级(防止死锁)
-
在释放读锁和获取写锁之间有短暂的无锁期
2. **双重检查(Double Check)**
在获取写锁后,需要再次检查缓存,防止:
-
多个线程同时发现缓存失效
-
第一个线程已经完成数据库查询和缓存写入
-
后续线程不需要重复查询
3. **缓存失效策略选择**
通常有几种方案:
| 方案 | 优点 | 缺点 | 适用场景 |
|------|------|------|----------|
| **主动失效** | 一致性最好 | 实现复杂,耦合度高 | 强一致性要求 |
| **定时刷新** | 简单,缓存命中率高 | 数据可能过期 | 允许短暂不一致 |
| **延迟双删** | 减少不一致时间窗 | 仍有短暂不一致可能 | 弱一致性要求 |
4. **推荐方案:读写锁 + 主动失效**
```java
/**
* 完整的强一致性缓存方案
*/
@Service
public class StrongConsistencyCacheService {
@Autowired
private RedissonClient redisson;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final long CACHE_TTL = 300; // 5分钟
/**
* 读操作:读锁 + 必要时升级写锁
*/
public Object getWithConsistency(String key) {
String lockKey = "lock:" + key;
RReadWriteLock rwLock = redisson.getReadWriteLock(lockKey);
// 第一阶段:尝试用读锁读取
RLock readLock = rwLock.readLock();
readLock.lock();
try {
CacheWrapper wrapper = (CacheWrapper) redisTemplate.opsForValue().get(key);
if (wrapper != null && !wrapper.isExpired()) {
return wrapper.getData();
}
} finally {
readLock.unlock();
}
// 第二阶段:获取写锁,重建缓存
RLock writeLock = rwLock.writeLock();
writeLock.lock();
try {
// 双重检查
CacheWrapper wrapper = (CacheWrapper) redisTemplate.opsForValue().get(key);
if (wrapper != null && !wrapper.isExpired()) {
return wrapper.getData();
}
// 查询数据库
Object data = loadFromDb(key);
// 写入缓存
wrapper = new CacheWrapper(data, System.currentTimeMillis());
redisTemplate.opsForValue().set(key, wrapper, CACHE_TTL, TimeUnit.SECONDS);
return data;
} finally {
writeLock.unlock();
}
}
/**
* 写操作:写锁 + 删除缓存
*/
public void updateWithConsistency(String key, Object value) {
String lockKey = "lock:" + key;
RReadWriteLock rwLock = redisson.getReadWriteLock(lockKey);
RLock writeLock = rwLock.writeLock();
writeLock.lock();
try {
// 1. 更新数据库
updateDatabase(key, value);
// 2. 删除缓存(让下次读取重建)
redisTemplate.delete(key);
// 3. 可选:设置一个短暂的标记,防止缓存击穿
redisTemplate.opsForValue().set(
"rebuilding:" + key,
"1",
5, TimeUnit.SECONDS
);
} finally {
writeLock.unlock();
}
}
// 缓存包装类,包含数据和时间戳
@Data
@AllArgsConstructor
private static class CacheWrapper implements Serializable {
private Object data;
private long timestamp;
public boolean isExpired() {
return System.currentTimeMillis() - timestamp > CACHE_TTL * 1000;
}
}
}
```
性能优化建议
1. **避免缓存雪崩**
```java
// 在缓存重建时添加随机过期时间
long ttl = CACHE_TTL + ThreadLocalRandom.current().nextInt(60);
redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS);
```
2. **防止缓存击穿**
```java
// 使用互斥锁标记
Boolean absent = redisTemplate.opsForValue()
.setIfAbsent("mutex:" + key, "1", 30, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(absent)) {
// 只有第一个线程去查数据库
// 其他线程等待或返回旧数据
}
```
3. **批量操作优化**
对于批量读取,可以使用:
```java
// 批量获取读锁
List<RLock> readLocks = keys.stream()
.map(key -> redisson.getReadWriteLock("lock:" + key).readLock())
.collect(Collectors.toList());
RLock multiLock = redisson.getMultiLock(readLocks.toArray(new RLock[0]));
multiLock.lock();
```
总结
-
**必须加锁**:缓存失效时的数据库查询和缓存写入操作必须使用写锁
-
**锁升级**:需要从读锁释放后重新获取写锁
-
**双重检查**:避免重复查询数据库
-
**权衡考虑**:强一致性会牺牲性能,需根据业务需求选择
-
**异常处理**:确保锁最终被释放,避免死锁
在实际应用中,除非有严格的强一致性要求,否则可以考虑使用**最终一致性**方案(如异步更新、延迟双删等),这样在性能和一致性之间能达到更好的平衡。