对于没有并发的用户请求

  • 先更新缓存,后更新数据库
  • 先更新数据库,后更新缓存

两者第二步没成功,都有问题

  • 如果更新缓存成功,更新数据库没成功,一旦缓存失效,读取的仍是旧值
  • 如果更新数据库成功,更新缓存没成功,则修改结果迟迟看不到

然后就是两者都有并发的情况下:

先更新缓存后更新数据库

  • 用户A更新缓存x=2(x=1)
  • 用户B更新缓存x=1
  • 用户B写入数据库x=1
  • 用户A写入数据库x=2

用户A把用户B的请求覆盖了

先更新数据库后更新缓存

  • 线程 A 更新数据库(X = 1)
  • 线程 B 更新数据库(X = 2)
  • 线程 B 更新缓存(X = 2)
  • 线程 A 更新缓存(X = 1)

线程A的缓存把线程B的缓存覆盖了

显然有问题,以上是并发问题,除此之外从缓存利用率来讲,更新的缓存不一定会马上被读取,可能会导致缓存中有很多没有用的数据,浪费资源。

解决方案:删除缓存

  • 先删除缓存,再更新数据库
  1. 线程 A 要更新 X = 2(原值 X = 1)
  2. 线程 A 先删除缓存
  3. 线程 B 读缓存,发现不存在,从数据库中读取到旧值(X = 1)
  4. 线程 A 将新值写入数据库(X = 2)
  5. 线程 B 将旧值写入缓存(X = 1)

最终 X 的值在缓存中是 1(旧值),在数据库中是 2(新值),发生不一致。

  • 先更新数据库,再删除缓存
  1. 缓存中 X 不存在(数据库 X = 1)
  2. 线程 A 读取数据库,得到旧值(X = 1)
  3. 线程 B 更新数据库(X = 2)
  4. 线程 B 删除缓存
  5. 线程 A 将旧值写入缓存(X = 1)

最终 X 的值在缓存中是 1(旧值),在数据库中是 2(新值),也发生不一致。

然而上述条件很难满足,特别是条件三

  • 缓存刚好已失效
  • 读请求 + 写请求并发
  • 更新数据库 + 删除缓存的时间(步骤 3-4),要比读数据库 + 写缓存时间(步骤 2 和 5)

如何保证两步都执行成功

程序在执行过程中发生异常,最简单的解决办法是什么?

重试

直接重试方案不严谨:

异步重试

把重试请求写到「消息队列」中,然后由专门的消费者来重试,直到成功

或者更直接的做法,为了避免第二步执行失败,我们可以把操作缓存这一步,直接放到消息队列中,由消费者来操作缓存。

问题:

如果在执行失败的线程中一直重试,还没等执行成功,此时如果项目「重启」了,那这次重试请求也就「丢失」了,那这条数据就一直不一致了

解决方法:消息队列特性

  • 消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失(重启项目也不担心)
  • 消息队列保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者(符合我们重试的场景)

至于写队列失败消息队列的维护成本问题:

  • 写队列失败:操作缓存和写消息队列,「同时失败」的概率其实是很小的
  • 维护成本:我们项目中一般都会用到消息队列,维护成本并没有新增很多

redis缓存 remove redis缓存一致性解决方案_更新数据

更简单的方案

订阅数据库变更日志,再操作缓存

们的业务应用在修改数据时,「只需」修改数据库,无需操作缓存。

当一条数据发生修改时,MySQL 就会产生一条变更日志(Binlog),我们可以订阅这个日志,拿到具体操作的数据,然后再根据这条数据,去删除对应的缓存

redis缓存 remove redis缓存一致性解决方案_缓存_02

订阅变更日志,目前也有了比较成熟的开源中间件,例如阿里的 canal,使用这种方案的优点在于:

  • 无需考虑写消息队列失败情况:只要写 MySQL 成功,Binlog 肯定会有
  • 自动投递到下游队列:canal 自动把数据库变更日志「投递」给下游的消息队列