Redis高级(十三)、缓存双写一致性的更新策略探讨

224 阅读8分钟

觉得对你有益的小伙伴记得点个赞+关注

后续完整内容持续更新中

希望一起交流的欢迎发邮件至javalyhn@163.com

缓存双写一致性,就是指缓存与数据库数据的一致问题。别看就这么几个字,其实这里的学问是很多的。

1. 缓存的优点与缺点

1.1 缓存的优点

  1. 缩短服务的响应时间,给予用户更好的的体验
  2. 增大系统的吞吐量,效果同上
  3. 减轻数据库压力,防止高峰期数据被压垮

1.2 缓存的缺点

  1. 缓存有很多的类型,memcached、redis.....不同的选型会增加不同的维护难度,因为这原本就是一个单独的数据库
  2. 缓存要考虑分布式,在分布式的情况下,比如redis有很多坑,增加了系统的复杂度
  3. 对于缓存的准确性有高要求时,就必须考虑下文要讲的 「缓存双写一致性」

2. 只读缓存与读写缓存

只读缓存与读写缓存是缓存按照操作来分的两种结果。

2.1 只读缓存

每次修改直接写入后端数据库,如果Redis缓存不命中,则什么都不用操作,如果Redis缓存命中,则删除缓存中的数据,待下次读取时从后端数据库中加载最新值到缓存中。

2.2 读写缓存

读写缓存:若要对数据进行增删改,需要在Cache进行。 同时根据采取的写回策略,决定是否同步写回DB。

3. 同步直写策略与异步缓写策略

3.1 同步直写策略

写缓存的时候也同步数据库,缓存与数据库中的数据一致。

对于读写缓存来说,要想保证缓存和数据库中的一致性,就要采用同步直写策略

3.2 异步缓写策略

写缓存与写数据库不同步,而是异步的在一段时间过后写数据库(写缓存),中途会出现数据库与缓存数据不一致

3.3 什么时候同步直写什么时候异步缓写

  • 小数据,某一小段热点数据,要求立刻变更,可以前台服务降级一下,后台马上同步直写

  • 正常业务,马上更新了,可以在业务上容许出现1h后Reids起效,可以用异步缓写

  • 出现异常后,不得不将失败的动作重新修补,不得不借助kafka或者rabbitmq等消息中间件,实现解耦后重写

4. 缓存双写一致性的两个要点

  1. 如果缓存中没有数据,数据库中的数据要是最新值
  2. 如果缓存中有数据,缓存中的数据一定是最新值

5. 数据库和缓存一致性的几种更新策略

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

这个更新策略会出现头疼的脏数据问题

下面给出问题出现过程

  1. 先更新MySQL的某个商品库存,将当前100个库存商品更新为99个
  2. MySQL商品库存更新为99成功,接着去更新Redis
  3. 此时假设出现异常,导致Reids更新失败,导致MySQL商品库存为99个,Redis仍为100
  4. 上述发生,会让数据库里面和缓存redis里面数据不一致,读到脏数据

这种很少用,不推荐

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

public void deleteOrderData(Order order){
    try(Jedis jedis = RedisUtils.getJedis()){
        //1 线程A成功删除缓存
        jedis.del(order.getId() + "");
        //2 线程A更新mysql
        orderDao.update(order); `假设花费了30s完成`
    }catch(Exception e){
        e.printStackTrace();
    }
}

捋一下上述过程

  1. 此时有两个并发线程,线程A先进入方法,线程A先删除缓存数据,然后去更新mysql,由于网络延时,mysql一直在更新,线程A阻塞
  2. 接着线程B进入方法要去读取缓存数据
public Order selectOrderData() {Order order
    try(Jedis jedis = RedisUtils.getJedis()){
        //1 线程B先去Reids里面找,找到返回数据,否则去数据库查找
        String result = jedis.get(order.getId() + "");
        if(result != null) {
            return (Order) JSON.parse(result);
        }else{
            order = orderDao.getOrderById(order.getId());
            //2 线程B会将读取到的旧数据写入Reids
            jedis.set(order.getId() + "",order.toString());
            return order;
        }
    }catch(Exception e) {
        e.printStackTrace();
    }
    return null;
}
  1. 线程B此时读取到的Reids里面的数据是空的(被线程A删除),此时发生了如下问题

    • 线程B从mysql中获得旧值 B线程发现Reids没有(缓存缺失)马上去redis里面获取,得到旧值
    • 线程B将旧值写回Reids 获得旧值数据后返回前台并回写进redis(刚被A线程删除的旧数据有极大可能又被写回了)。
  2. 线程A更新完mysql,发现redis里面缓存的还是脏数据,白干了。。。两个并发操作,一个是更新操作,一个是读取操作,A更新操作删除缓存后,B查询操作没有命中缓存,B先把老数据读出来后放到缓存中,然后A更新操作更新了数据库。于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了。

总结流程: (1)请求A进行写操作,删除缓存后,工作正在进行中......A还么有彻底更新完

(2)请求B开工,查询redis发现缓存不存在

(3)请求B继续,去数据库查询得到了myslq中的旧值

(4)请求B将旧值写入redis缓存

(5)请求A将新值写入mysql数据库

如果数据库更新失败,导致B线程请求再次访问缓存时,发现redis里面没数据,缓存缺失,再去读取mysql时,从数据库中读取到旧值

5.3 针对先删缓存再更新数据库出现问题的解决方案

  1. 加锁(采用DCL双端检索机制)

image.png

  1. 延时双删

image.png

image.png

  1. 怎么确定延时双删所需要延迟的时间呢 在业务程序运行的时候,统计下线程读数据和写缓存的操作时间,自行评估自己的项目的读数据业务逻辑的耗时, 以此为基础来进行估算。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上加百毫秒即可。 这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

  2. 如果是mysql主从读写分离架构呢

(1)请求A进行写操作,删除缓存

(2)请求A将数据写入数据库了,

(3)请求B查询缓存发现,缓存没有值

(4)请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值

(5)请求B将旧值写入缓存

(6)数据库完成主从同步,从库变为新值上述情形,就是数据不一致的原因。还是使用双删延时策略。

只是,睡眠时间修改为在主从同步的延时时间基础上,加几百ms

  1. 这种同步淘汰策略吞吐量低怎么办?

延时删除用异步任务解决

image.png

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

问题如下

image.png

假如缓存删除失败或者来不及,导致请求再次访问redis时缓存命中,读取到的是缓存旧值。

针对这个问题,老外写过一篇论文 docs.microsoft.com/en-us/azure…

知名社交网站facebook也在论文《Scaling Memcache at Facebook》中提出 www.usenix.org/system/file…

5.5 针对先更新数据库再删除缓存出现问题的解决方案

image.png

  1. 可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用Kafka/RabbitMQ等)。
  2. 当程序没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
  3. 如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了,否则还需要再次进行重试
  4. 如果重试超过的一定次数后还是没有成功,我们就需要向业务层发送报错信息了,通知运维人员。

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

杜绝!!!

6. 到底用5.2 还是 5.3

在大多数业务场景下,我们会把Redis作为只读缓存使用。假如定位是只读缓存来说, 理论上我们既可以先删除缓存值再更新数据库,也可以先更新数据库再删除缓存,但是没有完美方案,两害相衡趋其轻的原则

个人建议是,优先使用先更新数据库,再删除缓存的方案。理由如下:

1 先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力,严重导致打满mysql。

2 如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置

多补充一句,如果使用先更新数据库,再删除缓存的方案

如果业务层要求必须读取一致性的数据,那么我们就需要在更新数据库时,先在Redis缓存客户端暂存并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性。

image.png