在分布式系统中,Redis通常用作缓存层,而数据库负责数据的持久化。为了确保两者之间的数据一致性,需要采取一系列策略来应对可能的问题。
数据不一致情况
读取缓存步骤一般没有什么问题,但是一旦涉及到数据更新:数据库和缓存更新,就容易出现缓存(Redis)和数据库(MySQL)间的数据一致性问题。
不管是先写MySQL数据库,再删除Redis缓存;还是先删除缓存,再写库,都有可能出现数据不一致的情况。举一个例子:
1.如果删除了缓存Redis,还没有来得及写库MySQL,另一个线程就来读取,发现缓存为空,则去数据库中读取数据写入缓存,此时缓存中为脏数据。
2.如果先写了库,在删除缓存前,写库的线程宕机了,没有删除掉缓存,则也会出现数据不一致情况。
因为写和读是并发的,没法保证顺序,就会出现缓存和数据库的数据不一致的问题。
————————————————
此处参考文章:如何保证Redis与数据库的数据一致性
保障 Redis 与数据库的数据一致性:多方案解析
在分布式系统中,保障 Redis 缓存与数据库的数据一致性是一项具有挑战性的任务。在实际项目中,我们可以结合多种策略来处理不同场景下的数据一致性问题。以下是一些常见的解决方案:
1. 延时双删策略
在更新数据库后,不立即删除 Redis 缓存,而是设置一个延时任务,在一定时间后再删除缓存。这样可以确保在这段时间内,数据库更新完成,避免了瞬时的数据不一致。
// 伪代码
public void updateAndDelayedDelete(String key, Object newData) {
database.update(key, newData);
delayedDeleteCache(key, delayTime);
}
private void delayedDeleteCache(String key, long delayTime) {
// 设置延时任务,在 delayTime 后删除缓存
scheduleExecutor.schedule(() -> cache.delete(key), delayTime, TimeUnit.MILLISECONDS);
}
或者在写入数据库前后进行两次删除缓存。
延时双删策略是为了解决缓存与数据库一致性问题而设计的一种方案。具体步骤如下:
-
先删除缓存: 在写入数据库之前,首先删除 Redis 缓存中的对应数据。这确保了下一次查询时,会触发数据库查询并重新加载数据到缓存。
redis.del(key);
-
写入数据库: 执行实际的数据库写入操作。
database.update(key, newData);
-
休眠一段时间: 为了确保数据库更新完成,引入一定的休眠时间,通常为几百毫秒。这个时间需要根据具体情况进行调整,确保数据库有足够的时间完成写入。
Thread.sleep(500);
-
再次删除缓存: 为了确保缓存中不存有脏数据,再次删除 Redis 缓存。这一步是为了应对数据库写入和休眠时间中可能的并发查询。
redis.del(key);
这种策略的关键在于休眠的引入,以确保在删除缓存后给予数据库足够的时间完成写入。需要注意的是,这里的时间是一个经验值,实际的等待时间可能需要根据具体的业务情况和数据库性能进行调整。
这样设计的好处在于,在写入数据库之前和之后都进行了缓存的删除操作,确保了在写操作的过程中缓存不包含脏数据。然而,也需要注意的是,这种方法可能会引入一定的性能开销,特别是在高并发的场景下。
2. 异步更新缓存
技术整体思路:MySQL binlog增量订阅消费+消息队列+增量数据更新到redis
使用异步机制,当数据库发生变更时,通过订阅数据库的 Binlog(二进制日志)来触发更新缓存的操作。这样可以在数据库更新的同时,异步地更新 Redis 缓存,确保两者的一致性。
// 伪代码
public void updateDatabaseAndNotifyCache(String key, Object newData) {
database.update(key, newData);
// 发送消息通知更新缓存
messageQueue.sendUpdateMessage(key, newData);
}
// 消费者端
public void onMessageReceived(String key, Object newData) {
cache.put(key, newData);
}
3. 读写锁(强一致性)
在写入数据时获取写锁,此时不允许读操作,确保写入数据库的同时更新缓存。在读操作时获取读锁,确保读操作不受写操作的影响,从而保证强一致性。
// 伪代码
public void writeWithReadWriteLock(String key, Object newData) {
writeLock.lock();
try {
database.update(key, newData);
cache.put(key, newData);
} finally {
writeLock.unlock();
}
}
public Object readWithReadWriteLock(String key) {
readLock.lock();
try {
return cache.get(key);
} finally {
readLock.unlock();
}
}
4. 异步通知(延时一致性)
通过消息中间件实现异步通知,当数据库发生变更时,发送消息通知到订阅者。订阅者在收到消息后,更新 Redis 缓存。
// 伪代码
public void updateAndNotifyAsync(String key, Object newData) {
database.update(key, newData);
messageQueue.sendUpdateMessage(key, newData);
}
// 消费者端
public void onMessageReceived(String key, Object newData) {
// 延时处理,确保数据库已经更新
scheduleExecutor.schedule(() -> cache.put(key, newData), delayTime, TimeUnit.MILLISECONDS);
}
5. 串行化
解决方案
- 写请求过来,将写请求缓存到缓存队列中,并且开始执行写请求的具体操作(删除缓存中的数据,更新数据库,更新缓存)。
- 如果在更新数据库过程中,又来了个读请求,将读请求再次存入到缓存队列(可以搞n个队列,采用key的hash值进行队列个数取模hash%n,落到对应的队列中,队列需要保证顺序性)中,顺序性保证等待队列前的写请求执行完成,才会执行读请求。之前的写请求删除缓存失败,直接返回,此时数据库中的数据是旧值,并且与缓存中的数据是一致的,不会出现缓存一致性的问题。
- 写请求删除缓存成功,则更新数据库,如果更新数据库失败,则直接返回,写请求结束,此时数据库中的值依旧是旧值,读请求过来后,发现缓存中没有数据, 则会直接向数据库中请求,同时将数据写入到缓存中,此时也不会出现数据一致性的问题。
- 更新数据成功之后,再更新缓存,如果此时更新缓存失败,则缓存中没有数据,数据库中是新值 ,写请求结束,此时读请求还是一样,发现缓存中没有数据,同样会从数据库中读取数据,并且存入到缓存中,其实这里不管更新缓存成功还是失败, 都不会出现数据一致性的问题。
上面这方案解决了数据不一致的问题,主要是使用了串行化,每次操作进来必须按照顺序进行。如果某个队列元素积压太多,可以针对读请求进行过滤,提示用户刷新页面,重新请求。
结论
以上方案各有优缺点,可以根据具体业务场景和性能需求选择适当的方案。延时双删和异步更新缓存通常用于弱一致性的场景,而读写锁和异步通知则更适用于强一致性要求较高的场景。在实际应用中,可以根据具体业务需求进行取舍和组合,以达到最佳的性能和一致性。