关于 Redis 缓存一致
为了提升系统性能,常常会引入 Redis 作为缓存。数据通常会存储在持久化的数据源(如 MySQL 数据库)中,同时在 Redis 中保存一份副本。当数据源中的数据发生变化时,如果不能及时同步到 Redis 缓存,或者缓存中的数据更新操作出现异常,就会导致缓存和数据源的数据不一致。这种不一致可能会给业务带来严重影响,比如展示给用户的信息是过时的,或者业务逻辑基于错误的数据进行处理。
缓存不一致的原因:
- 并发读写问题:在高并发场景下,多个客户端可能同时对缓存和数据源进行读写操作,这就容易造成数据不一致。例如,客户端 A 更新了数据源,但还没来得及更新缓存,此时客户端 B 读取了缓存中的旧数据。
- 缓存更新失败:在更新数据源后,尝试更新缓存时可能会因为网络问题、Redis 服务异常等原因导致更新失败,从而使缓存和数据源的数据不一致。
- 缓存过期策略:若缓存设置了过期时间,在缓存过期但还未重新加载数据时,也会出现数据不一致的情况。
缓存模式
Cache - Aside(旁路缓存)
- 读流程
- 应用程序首先尝试从 Redis 缓存中读取所需数据。
- 若缓存命中(即缓存中存在该数据),则直接返回缓存中的数据,避免了访问数据源的开销,提高了读取性能。
- 若缓存未命中,应用程序会从数据源(如数据库)中读取数据,然后将读取到的数据存入 Redis 缓存,以便后续的读请求可以直接从缓存获取,最后将数据返回给应用程序。
- 写流程
- 应用程序先更新数据源中的数据,确保数据源中的数据是最新的。
- 然后删除 Redis 缓存中对应的数据。这样做的目的是避免缓存中的旧数据影响后续的读操作。当下次有读请求时,由于缓存中没有该数据(缓存未命中),会重新从数据源加载最新数据到缓存中,保证了缓存数据的一致性。
- 一致性问题及解决
- 问题:在高并发场景下,可能会出现更新数据库和删除缓存这两个操作不是原子性的问题。例如,线程 A 更新数据库后,在删除缓存之前,线程 B 读取缓存未命中,从数据库读取到旧数据并更新到缓存中,之后线程 A 才删除缓存,此时缓存中又变成了旧数据。
- 解决:可以使用分布式锁来保证更新数据库和删除缓存这两个操作的原子性,或者采用延迟双删策略,即更新数据库后先删除缓存,然后在一段时间后再次删除缓存,以尽量避免上述问题。
延时双删:在更新数据库之前,先将 Redis 中对应的缓存数据删除。这样做是为了避免在更新数据库的过程中,有其他线程读取到旧的缓存数据。执行数据库的更新操作,将最新的数据写入数据库。
在更新数据库之后,等待一段时间(这个时间要根据业务场景和系统的实际情况来确定),然后再次删除 Redis 中的缓存。延时的目的是为了确保在更新数据库和第一次删除缓存之后,可能存在的读取旧数据并更新到缓存的操作已经完成,再次删除缓存可以避免缓存中残留旧数据。
Read/Write - Through(读写穿透)
- 读流程
- 应用程序向缓存系统发起读请求。
- 若缓存命中,直接返回缓存中的数据。
- 若缓存未命中,缓存系统会自动从数据源中读取数据,并将数据更新到缓存中,然后再将数据返回给应用程序。这种模式下,应用程序只与缓存系统交互,不需要关心数据是从缓存还是数据源获取的。
- 写流程
- 应用程序将数据写入缓存系统。
- 缓存系统负责将数据同步更新到数据源中。这样可以保证缓存和数据源的数据始终保持一致。
- 一致性问题及解决
- 问题:缓存系统更新数据源失败时,会导致数据不一致。
- 解决:可以采用重试机制,当更新数据源失败时,进行多次重试;也可以引入消息队列,将更新操作发送到消息队列中,由专门的消费者进行处理,确保数据最终一致性。
Write - Behind(异步写回)
- 写流程
- 应用程序直接将数据写入缓存系统。
- 缓存系统会异步地将数据更新到数据源中。这种模式可以显著提高写操作的性能,因为应用程序不需要等待数据写入数据源的操作完成。
- 一致性问题及解决
- 问题:由于数据是异步更新到数据源的,在更新完成之前,如果有读请求,可能会读取到缓存中的新数据和数据源中的旧数据,导致数据不一致。
- 解决:可以设置一个较短的缓存过期时间,让缓存中的数据尽快过期,从而促使后续的读请求从数据源获取最新数据;也可以在缓存系统中记录数据的更新状态,在读请求时进行判断,如果数据正在更新到数据源中,则从数据源读取数据。
解决缓存一致性问题的具体策略
缓存失效策略
- 更新数据源后删除缓存:这是 Cache - Aside 模式常用的方法。在更新数据源后,立即删除对应的缓存数据,让后续的读请求重新从数据源加载最新数据。例如,在一个电商系统中,当商品的价格更新时,先更新数据库中的商品价格,然后删除 Redis 中该商品的缓存信息。
- 分布式锁保证删除操作原子性:在高并发场景下,为了避免多个线程同时操作缓存和数据源导致的数据不一致问题,可以使用分布式锁。例如,使用 Redis 的
SETNX
(SET if Not eXists)命令实现简单的分布式锁,确保在更新数据库和删除缓存这两个操作期间,不会有其他线程干扰。
缓存更新策略
- 消息队列异步更新:在更新数据源后,发送一条消息到消息队列(如 Kafka、RabbitMQ)中,由专门的消费者从消息队列中读取消息,并更新 Redis 缓存。这样可以将缓存更新操作异步化,提高系统的吞吐量和性能。例如,在一个社交系统中,当用户的个人信息更新时,更新数据库后发送一条消息到消息队列,消费者接收到消息后更新 Redis 中该用户的缓存信息。
- Redis 事务或 Lua 脚本:对于一些复杂的缓存更新操作,如需要同时更新多个缓存键的情况,可以使用 Redis 的事务或 Lua 脚本来保证操作的原子性。例如,在一个游戏系统中,当玩家升级时,需要同时更新玩家的等级、经验值等多个缓存信息,可以使用 Lua 脚本来保证这些更新操作的原子性。
缓存过期策略
- 合理设置过期时间:根据数据的更新频率和业务需求,合理设置缓存的过期时间。对于更新频繁的数据,设置较短的过期时间,如几分钟;对于不经常更新的数据,设置较长的过期时间,如几小时甚至几天。例如,在一个新闻系统中,新闻的标题和摘要更新频率较低,可以设置较长的过期时间;而新闻的评论数量更新频率较高,设置较短的过期时间。
- 主动刷新缓存:在数据更新后,主动刷新缓存中的数据。可以通过定时任务或者在数据更新的业务逻辑中添加刷新缓存的代码来实现。例如,在一个金融系统中,每天收盘后,通过定时任务刷新 Redis 中股票的收盘价等缓存信息。
分布式锁
- 使用分布式锁:在高并发场景下,使用分布式锁(如 Redis 的 Redlock)来保证对缓存和数据源的操作是线程安全的。例如,在多个服务实例同时对同一个缓存键进行读写操作时,通过 Redlock 保证只有一个服务实例能够获取锁并进行操作,避免出现数据不一致的问题。