对于没有并发的用户请求
- 先更新缓存,后更新数据库
- 先更新数据库,后更新缓存
两者第二步没成功,都有问题
- 如果更新缓存成功,更新数据库没成功,一旦缓存失效,读取的仍是旧值
- 如果更新数据库成功,更新缓存没成功,则修改结果迟迟看不到。
然后就是两者都有并发的情况下:
先更新缓存后更新数据库
- 用户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的缓存覆盖了
显然有问题,以上是并发问题,除此之外从缓存利用率来讲,更新的缓存不一定会马上被读取,可能会导致缓存中有很多没有用的数据,浪费资源。
解决方案:删除缓存
- 先删除缓存,再更新数据库
- 线程 A 要更新 X = 2(原值 X = 1)
- 线程 A 先删除缓存
- 线程 B 读缓存,发现不存在,从数据库中读取到旧值(X = 1)
- 线程 A 将新值写入数据库(X = 2)
- 线程 B 将旧值写入缓存(X = 1)
最终 X 的值在缓存中是 1(旧值),在数据库中是 2(新值),发生不一致。
- 先更新数据库,再删除缓存
- 缓存中 X 不存在(数据库 X = 1)
- 线程 A 读取数据库,得到旧值(X = 1)
- 线程 B 更新数据库(X = 2)
- 线程 B 删除缓存
- 线程 A 将旧值写入缓存(X = 1)
最终 X 的值在缓存中是 1(旧值),在数据库中是 2(新值),也发生不一致。
然而上述条件很难满足,特别是条件三
- 缓存刚好已失效
- 读请求 + 写请求并发
- 更新数据库 + 删除缓存的时间(步骤 3-4),要比读数据库 + 写缓存时间短(步骤 2 和 5)
如何保证两步都执行成功
程序在执行过程中发生异常,最简单的解决办法是什么?
重试
直接重试方案不严谨:
异步重试
把重试请求写到「消息队列」中,然后由专门的消费者来重试,直到成功
或者更直接的做法,为了避免第二步执行失败,我们可以把操作缓存这一步,直接放到消息队列中,由消费者来操作缓存。
问题:
如果在执行失败的线程中一直重试,还没等执行成功,此时如果项目「重启」了,那这次重试请求也就「丢失」了,那这条数据就一直不一致了
解决方法:消息队列特性
- 消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失(重启项目也不担心)
- 消息队列保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者(符合我们重试的场景)
至于写队列失败和消息队列的维护成本问题:
- 写队列失败:操作缓存和写消息队列,「同时失败」的概率其实是很小的
- 维护成本:我们项目中一般都会用到消息队列,维护成本并没有新增很多
更简单的方案
订阅数据库变更日志,再操作缓存
们的业务应用在修改数据时,「只需」修改数据库,无需操作缓存。
当一条数据发生修改时,MySQL
就会产生一条变更日志(Binlog
),我们可以订阅这个日志,拿到具体操作的数据,然后再根据这条数据,去删除对应的缓存。
订阅变更日志,目前也有了比较成熟的开源中间件,例如阿里的 canal
,使用这种方案的优点在于:
- 无需考虑写消息队列失败情况:只要写
MySQL
成功,Binlog
肯定会有 - 自动投递到下游队列:canal 自动把数据库变更日志「投递」给下游的消息队列