对线面试官-Redis(作为缓存的一致性问题)

121 阅读8分钟

面试官 :前聊到了Redis作为缓存的三大问题,击穿、穿透、雪崩。那么这一次聊一聊Redis作为缓存的其它问题比如:数据一致性问题、内存消耗和缓存更新的延迟问题。

派大星:好的,没问题。

面试官 :好。那你就先聊一聊Redis作为缓存的数据一致性问题。

派大星:好的,首先之所以使用Reids作为缓存是因为,在高并发的场景下,传统的关系型数据库的并发能力是相对比较薄弱的;并且往往在这种情况下数据库都是最后一道防线,是相对比较薄弱的环节。所以可以使用Redis做一个缓存。让用户请求先打到Redis上而不是直接打到数据库上。但是如果出现数据更新操作:数据库与缓存更新,就会出现缓存(Redis)和数据库(MySQL)之间的数据一致性问题。

面试官 :OK,那你说说缓存一致性的问题如何解决呢?

派大星:首先要根据系统不同的架构去设计,

如果是非读写分离架构的话:

解决方案是延时双删的方式 当然我也可以简单说一下其它方式为什么不可以

面试官: 好的,那你可以展开说一说其它方式, 以及延时双删的大致流程

派大星:

首先说下方案一:先更新数据库,再更新缓存。结论:不可行的。原因如下:

原因一:从线程安全角度:

  • 假设同时有请求A、B进行更新操作。执行顺序如下:
    • 线程A更新了数据库
    • 线程B更新了数据库
    • 线程B更新了缓存
    • 线程A更新了缓存

正常情况下A的所有更新操作应该早于B的所有更新操作,结果由于网络等原因导致了更新缓存的时候B的操作早于A的操作,此时数据库虽然是正确的,但是缓存和数据库出现了不一致的问题。所以不能考虑此方案。

原因二:从业务角度

  • 如果某个业务场景是写多读少的情况,就会导致缓存并未被读取就会被频繁的更新,极大的浪费了服务器的性能。会有冷数据的产生(中间值)
  • 如果写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。
方案二:先删除缓存,再更新数据库。结论:不可行

原因一:线程安全问题,和上述一样

  • 请求A进行写操作,删除缓存
  • 请求B查询发现缓存不存在
  • 请求B去数据库查询得到旧值
  • 请求B将旧值写入缓存
  • 请求A更新数据库 此时数据库中的值是新值,缓存的值是旧值,就发生了数据不一致问题
可行方案三:延时双删

线程 A:

  1. 当应用程序需要更新数据时,首先将数据更新到后端数据存储中(如数据库)。
  2. A 线程向 Redis 发送删除缓存的指令,将对应的缓存数据标记为过期。
  3. A 线程等待一定的时间窗口(通常是几十毫秒至几百毫秒),让 B 线程有足够的时间去访问缓存。

线程 B:

  1. 在时间窗口内,当有请求访问过期的缓存数据时,B 线程发现缓存已过期,并触发缓存更新的操作。
  2. B 线程从后端数据存储中获取最新数据,并将其存储到缓存中。
  3. B 线程继续处理请求,返回更新后的缓存数据。

线程 A(续):

  1. 在时间窗口结束后,A 线程再次向 Redis 发送删除缓存的指令,彻底删除缓存数据。
  2. 如果在时间窗口内没有请求访问到过期的缓存数据,A 线程会删除已标记为过期的缓存数据。

通过上述流程,A 线程负责标记缓存过期并等待一段时间,给 B 线程足够的时间去访问缓存并更新。B 线程则负责处理实际的缓存更新操作。这样,即使在缓存更新期间有请求访问过期的缓存数据,也能获取到最新的数据,避免了脏读的问题。

需要注意的是,具体的时间窗口大小和线程的实现方式可以根据实际需求和系统性能进行调整。同时,对于高并发环境,还需要考虑线程安全和并发控制的实现,以确保操作的正确性和性能。

派大星:如果是读写分离架构的话,解决的方案则有所不同

先说结论:

可以采用先更新数据库,再删除缓存,配合上重试机制

同样还是有两个请求请求A进行更新操作,请求B进行查询操作

  • 请求A进行写操作,删除缓存
  • 请求A将数据写入数据库了,
  • 请求B查询缓存发现,缓存没有值
  • 请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值
  • 请求B将旧值写入缓存
  • 数据库完成主从同步,从库变为新值

仍然会出现缓存与数据库数据不一致问题

此时仍然采用延时双删策略,但是延时时间需要在主从同步的延时时间基础上,加几百ms。

双删失败

如果第二次删除缓存失败,仍然会出现缓存与数据库数据不一致的问题

同样还是有两个请求请求A进行更新操作,请求B进行查询操作(单库)

  • 请求A进行写操作,删除缓存
  • 请求B查询发现缓存不存在
  • 请求B去数据库查询得到旧值
  • 请求B将旧值写入缓存
  • 请求A将新值写入数据库
  • 请求A试图去删除请求B写入的缓存值,结果失败了

解决方案:重试机制

先更新数据库,再删除缓存

同样存在并发问题,但是发生几率很低

同样还是有两个请求请求A进行更新操作,请求B进行查询操作(单库)

  • 缓存刚好失效
  • 请求A查询数据库,得一个旧值
  • 请求B将新值写入数据库
  • 请求B删除缓存
  • 请求A将查到的旧值写入缓存

该情况发生的必要条件就是请求B写数据库的操作比请求A读数据库的操作耗时更短,才能使请求B先删除缓存,但是通常来说数据库的读操作是远远快于写操作的,所以这种并发问题很难发生。

如果在极端情况下,这种并发问题仍然发生了

  • 给缓存设置一定的有效时间
  • 采用异步延时双删策略,另起一个线程,异步删除,保证读请求完成以后,再进行删除操作。

重试机制

与先删除缓存,再更新数据一样,如果删除缓存失败,那么仍然会出现数据不一致问题

我们选择靠谱的重试机制,利用消息队列进行删除的补偿

方案一:

  1. 更新数据库数据;
  2. 缓存因为种种问题删除失败
  3. 将需要删除的key发送至消息队列
  4. 自己消费消息,获得需要删除的key
  5. 继续重试删除操作,直到成功

该方案有一个缺点,对业务线代码造成大量的侵入,需要自己在业务代码中额外添加生成消息和消费消息的功能,业务代码变得不再专注于业务需求。改进:启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序(避免业务侵入),获得这个订阅程序传来的信息,进行删除缓存操作

方案二:

  1. 更新数据库数据

  2. 数据库会将操作信息写入binlog日志当中

  3. 订阅程序提取出所需要的数据以及key

  4. 另起一段非业务代码,获得该信息

  5. 尝试删除缓存操作,发现删除失败

  6. 将这些信息发送至消息队列

  7. 重新从消息队列中获得该数据,重试操作订阅binlog程序在mysql中有现成的中间件叫canal,可以完成订阅binlog日志的功能

派大星:以上就是我对Redis作为缓存出现的一致性问题如何解决的理解,和具体解决方案。

面试官 :很好,非常不错。那咱们再说说内存消耗和缓存更新的延迟问题。

派大星:额...... 这次先这样吧。累了🐶

面试官:好的好的,希望你能重点考虑下我们的公司。期待你的加入呀

如有问题,欢迎加微信交流:w714771310,或关注微信公众号【码上遇见你】。