Reids和数据库的一致性问题
当Redis作为缓存或辅助存储与关系型数据库(或其他持久化存储)一起使用时,保持两者数据的一致性是一个常见的挑战。由于数据在两个不同的存储系统之间存在冗余,并且更新路径可能不同,因此很容易出现数据不一致的问题。
为什么会出现一致性问题?
- 数据冗余: 同一份数据同时存在于Redis和数据库中。
- 更新路径不同: 应用程序通常会直接与Redis和数据库交互。如果更新只作用于其中一个,或者更新顺序、时机不同,就会导致数据不一致。
- 分布式环境: 在分布式系统中,网络延迟、节点故障、并发访问等都可能加剧一致性问题。
常见的一致性问题
-
缓存与数据库数据不一致(最常见)
- 脏读/幻读: 当数据库中的数据已经更新,但Redis中仍然保留着旧的(stale)数据,此时如果请求从Redis获取数据,就会读到旧值。
- 写丢失: 在特定并发场景下,如果更新操作处理不当,可能导致数据库或Redis中的某次更新丢失。
-
缓存穿透 (Cache Penetration)
- 查询一个在Redis和数据库中都不存在的key。每次这样的查询都会直接打到数据库,导致数据库压力增大,甚至被打垮。
-
缓存击穿 (Cache Breakdown)
- 某个热点key在Redis中过期,此时大量并发请求同时查询这个key。所有这些请求都会穿透缓存,直接打到数据库,造成数据库瞬间压力过大。
-
缓存雪崩 (Cache Avalanche)
- Redis中大批量的key在同一时间过期。这会导致大量的请求同时穿透缓存,打到数据库,数据库的负载瞬间飙升。
解决Redis与数据库一致性问题的策略
主要策略围绕着读写操作进行,以确保Redis中的数据与数据库中的数据尽可能保持同步。
1. 缓存更新策略
这些策略主要解决 "缓存与数据库数据不一致" 的问题。
-
1.1 Cache Aside (旁路缓存) 这是最常用也是最灵活的策略。
- 读操作:
- 先从Redis中读取数据。
- 如果Redis中存在(命中),则直接返回。
- 如果Redis中不存在(未命中),则从数据库中读取数据。
- 将从数据库读取的数据写入Redis,并设置过期时间。
- 返回数据。
- 写操作(关键点):
- 先更新数据库,再删除/失效Redis中的缓存。
- 优点: 简单实用。如果删除缓存失败,后续读操作会从数据库加载新数据并更新缓存,最终会一致。
- 问题:
- 先删除缓存,再更新数据库: 如果删除缓存成功,但更新数据库失败,那么缓存中就是旧数据(此时没有缓存),数据库是旧数据,下次读请求会直接从数据库读到旧数据,并写入缓存,造成数据不一致。
- 删除缓存与更新数据库的原子性问题: 在并发场景下,可能出现以下情况导致不一致:
- 线程A删除缓存。
- 线程B读取缓存未命中,从数据库读取旧值。
- 线程A更新数据库。
- 线程B将旧值写入缓存。 此时缓存中是旧数据,数据库中是新数据,产生不一致。
- 推荐方案:先更新数据库,再删除缓存。 虽然理论上仍有低概率出现不一致(在更新数据库成功后,删除缓存失败,且在这之间有读请求命中缓存),但这种不一致是暂时的,并且可以通过 重试机制(如将待删除的key放入消息队列,异步重试删除)或 双删策略(先删除缓存,再更新数据库,最后再次删除缓存,以应对上面说的并发问题)来降低风险。
- 先更新数据库,再删除/失效Redis中的缓存。
- 读操作:
-
1.2 Write Through (直写式)
- 写操作: 数据同时写入Redis和数据库。
- 读操作: 只从Redis读取数据。
- 优点: 保证了Redis和数据库之间的数据强一致性。
- 缺点: 写入性能受限于数据库的写入速度;当Redis作为独立缓存层时,实现复杂(需要Redis自身具备将数据写入数据库的能力,或者通过应用层逻辑模拟)。常用于对数据一致性要求非常高,且写入操作较少的场景。
-
1.3 Write Back (回写式)
- 写操作: 数据优先写入Redis,然后异步地批量写入数据库。
- 读操作: 只从Redis读取数据。
- 优点: 写入性能极高。
- 缺点: 数据可能丢失(如果Redis在数据未回写到数据库之前崩溃),数据一致性是最终一致性而非强一致性。适用于对写入性能要求极高,且允许一定数据丢失或延迟一致性的场景(如日志、计数器)。
2. 解决缓存穿透
-
2.1 缓存空值 (Cache Null Objects)
- 当从数据库查询结果为空时,也将这个空结果(例如一个特殊标记或空字符串)缓存到Redis中,并设置一个较短的过期时间。
- 优点: 后续对同一个不存在key的查询会直接从Redis返回空值,避免再次查询数据库。
- 缺点: 额外的内存开销;需要为业务判断空值。
-
2.2 布隆过滤器 (Bloom Filter)
- 在Redis之前加一层布隆过滤器。将所有可能存在的key的哈希值存放在布隆过滤器中。
- 查询流程:
- 请求先经过布隆过滤器。
- 如果布隆过滤器判断key不存在,则直接返回,不查询Redis和数据库。
- 如果布隆过滤器判断key可能存在(有误判率),则继续查询Redis。
- 优点: 内存效率高,能够快速判断key是否不存在,大幅减少数据库压力。
- 缺点: 有一定的误判率(即布隆过滤器可能判断key存在,但实际不存在,导致多一次Redis查询);无法删除元素。适用于对查询速度和内存要求高的场景。
3. 解决缓存击穿
-
3.1 互斥锁 (Mutex Lock / Distributed Lock)
- 当一个热点key过期时,第一个请求去重建缓存,同时获取一个分布式锁。
- 其他并发请求在获取锁失败后,进行等待(例如使用
sleep或自旋),直到持有锁的线程重建缓存完成,然后重新尝试从Redis读取。 - 优点: 保证只有一个线程去重建缓存,减轻数据库压力。
- 缺点: 引入锁的开销和复杂性,可能增加等待时间。
-
3.2 永不过期 / 逻辑过期
- 不给热点key设置物理过期时间,或者设置一个非常长的过期时间。
- 在Redis中存储数据时,额外带上一个逻辑过期时间字段。
- 读操作:
- 从Redis读取数据和其逻辑过期时间。
- 如果逻辑过期时间已到,则异步启动一个后台线程去数据库加载新数据并更新Redis,同时立即返回Redis中的旧数据(暂时提供旧数据)。
- 如果逻辑过期时间未到,则直接返回Redis数据。
- 优点: 始终有数据返回,不会击穿数据库。
- 缺点: 缓存数据可能短暂不一致,需要后台异步更新机制。
4. 解决缓存雪崩
-
4.1 错开缓存过期时间
- 为Redis中的key设置不同的过期时间,避免大量key同时过期。可以在原有过期时间基础上加上一个随机值。
- 优点: 简单有效。
- 缺点: 无法完全避免,只是降低同时过期的概率。
-
4.2 缓存预热
- 在系统启动时或业务高峰期到来之前,提前将热点数据加载到Redis中,并设置合适的过期时间。
- 优点: 避免了启动时的缓存未命中问题。
-
4.3 限流与熔断
- 在应用层面进行限流,当Redis不可用或大量缓存未命中时,限制对数据库的访问频率,保护数据库。
- 当数据库压力过大时,可以对部分服务进行熔断,返回默认值或错误信息,避免雪崩效应。
-
4.4 多级缓存
- 引入多级缓存,例如在Redis之前再增加一层本地缓存(如Guava Cache)。当Redis发生雪崩时,本地缓存仍然可以承担一部分流量。