Redis中数据一致性问题

183 阅读7分钟
theme: juejin

缓存的使用

数据库的并发能力是相对较弱的,在高并发场景中,数据库往往都是用户并发访问最为薄弱的环节。所以可以使用Redis做一个缓存操作,让用户请求先访问Redis缓存而不是直接访问数据库。

image.png

  • 用户发起请求,首先访问缓存,如果缓存中有数据则直接返回即可
  • 如果缓存中没有数据,系统就会访问数据库,从数据库中加载数据
  • 如果数据库中也没有数据,说明用户请求查询为空,直接返回空
  • 如果数据库有数据,则将数据返回,并将数据写入缓冲中

但是如果出现数据更新操作:数据库与缓存更新,就会出现缓存(Redis)和数据库(Mysql)之间的数据一致性问题。

解决一致性问题

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

先更新数据库再更新缓存,方案不可行

原因一:线程安全角度

同时有请求A和B进行更新操作

  • 线程A更新了数据库
  • 线程B更新了数据库
  • 线程B更新了缓存
  • 线程A更新了缓存

image.png 正常情况下请求A的更新缓存操作应该早于请求B的更新缓存,但是可能因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,即数据库中的数据与缓存中的数据不一致了,所以不考虑此方案

原因二:业务场景角度

  • 如果某个业务场景是写多读少的情况,就会导致缓存并未被读取就会被频繁的更新,极大的浪费了服务器的性能。

    比如在数据库中有一个值为 1 的值,此时我们有 1000 个请求对其每次加一的操作,但是这期间并没有读操作进来,如果用了先更新数据库的办法,那么此时就会有十个请求对缓存进行更新,会有大量的冷数据(中间值)产生。如果采用删除缓存策略,在有读请求来的时候那么就会只更新缓存一次。

  • 如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。

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

先删除缓存再更新数据,方案不可行

该方案仍然会出现线程安全问题

同时有请求A进行更新操作,请求B进行查询操作,会出现以下情况:

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

image.png

解决方式:延时双删

延时双删

  • 线程A删除缓存,然后去更新数据库
  • 线程B来读缓存,发现缓存已经被删除,所以直接从数据库中读取,这时候由于线程1还没有更新完成,所以读到的是旧值,然后把旧值写入缓存
  • 线程A延时 1s (具体延时时间需要根据具体项目读数据业务耗时而定,以确保写请求写请求可以删除读请求造成的缓存脏数据)
  • 再删除缓存(将这1s内造成的缓存脏数据删除)
  • 如果还有其他线程来读取缓存的话,就会再次从数据库中读取到最新值

image.png

读写分离架构

如果此时系统采用了Mysql读写分离架构(主库负责写,从库负责读)

同样还是有两个请求请求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. 继续重试删除操作,直到成功

image.png

该方案有一个缺点,对业务线代码造成大量的侵入,需要自己在业务代码中额外添加生成消息和消费消息的功能,业务代码变得不再专注于业务需求。

改进:启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序(避免业务侵入),获得这个订阅程序传来的信息,进行删除缓存操作

方案二:

  1. 更新数据库数据

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

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

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

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

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

  7. 重新从消息队列中获得该数据,重试操作

    订阅binlog程序在mysql中有现成的中间件叫canal,可以完成订阅binlog日志的功能

image.png

总结

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

参考文献:

分布式之数据库和缓存双写一致性方案解析

缓存更新的套路