美文网首页个人学习
Redis(六):缓存与数据库的双写一致性

Redis(六):缓存与数据库的双写一致性

作者: 雪飘千里 | 来源:发表于2019-12-15 23:04 被阅读0次

    只要用到redis,就可能会涉及到缓存与数据库的存储双写,只要是双写,就一定会有数据一致性的问题,因为这是两个原子操作(写数据库,写缓存)。

    方案一

    最经典的缓存+数据库读写的模式,就是 Cache Aside Pattern:
    1、读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
    2、更新的时候,先删除缓存,然后再更新数据库。

    问题一:为啥要先删除缓存呢?
    假如说删除缓存成功了,但是更新数据库失败了,那再查询的时候只是从数据库里查了旧的数据而已,这样就能保持数据库与缓存的一致性。

    假如说删除缓存失败了,那就不要更新数据库,直接返回响应,本次更新失败。这样也能保持数据库与缓存的一致性。

    问题二:更新数据库之后,为啥不更新缓存呢?

    这个是要看场景的,更新缓存是有代价的,对于比较复杂的缓存数据计算的场景,可能一个缓存需要涉及到多张表,假如其中一张表更新后,需要再查询别的表,然后再更新缓存,但是如果这个缓存并不会被频繁的访问到,那就有点浪费资源了,还是等查询用到的时候再去更新缓存。
    举个栗子,一个缓存涉及的表的字段,在 1 分钟内就修改了 20 次,或者是 100 次,那么缓存更新 20 次、100 次;但是这个缓存在 1 分钟内只被读取了 1 次,有大量的冷数据。实际上,如果你只是删除缓存的话,那么在 1 分钟内,这个缓存不过就重新计算一次而已,开销大幅度降低。用到缓存才去算缓存。

    其实删除缓存,而不是更新缓存,就是一个 lazy 计算的思想,不要每次都重新做复杂的计算,不管它会不会用到,而是让它到需要被使用的时候再重新计算。像 mybatis,hibernate,都有懒加载思想。查询一个部门,部门带了一个员工的 list,没有必要说每次查询部门,都把里面的 1000 个员工的数据也同时查出来啊。80% 的情况,查这个部门,就只是要访问这个部门的信息就可以了。先查部门,同时要访问里面的员工,那么这个时候只有在你要访问里面的员工的时候,才会去数据库里面查询 1000 个员工。

    方案二

    上面的方案一在高并发的情况下,还是有问题的,在上亿流量高并发场景下,可能会出现一种问题,那就是数据发生了变更,先删除缓存,然后还没来得及修改数据库,这时候一个请求查询过来了,先读缓存,发现缓存空的,去查询数据库,查到了修改前的旧数据,放到了缓存中,随后数据变更的程序完成了数据库的修改。这时候缓存中和数据库中的值就不一样,下一次读取请求过来,就会读取到不准确的值。

    解决方案:
    1、读的时候,先读缓存,缓存没有的话,就读数据库,
    然后再判断缓存中是否存在key,
    如果不存在,则更新数据库;——说明,没有更新操作
    如果存在,则不作操作;——说明,有更新操作,或者别的查询请求

    2、更新的时候,先删除缓存,然后再更新数据库,最后再更新redis。

    高并发的场景下,数据库资源比缓存资源更加宝贵,尽量要在第一次访问时就能用redis拦截住,所以在数据有更新之后,要同步更新redis。
    这个方案能解决方案一中的数据不一致性,但是当数据库更新之后,如果更新redis失败,则还是会存在数据不一致性,但是方案一是好一点。

    方案三:

    数据库与缓存更新与读取操作进行异步串行化

    方案一的基础上,更新数据库时,根据数据的唯一标识,将数据路由之后,发送到一个jvm内部的队列中,读取数据的时候,如果发现数据不在缓存中,那么将重新读取数据+更新缓存的操作,根据唯一标识路由之后,也发送同一个jvm内部的队列中,一个队列对应一个工作线程,每个工作线程串行拿到对应的操作,然后一条一条的执行,这样的话,一个数据变更的操作,先执行删除缓存,然后再去更新数据库,但是还没完成更新,此时一个读请求过来,读到了空的缓存,那么可以先将缓存更新的请求发送到队列中,此时会在队列中有积压,然后同步等待缓存更新完成。
    队列可以使用双向队列。

    这种方案需要注意以下事项:

    1. 当高并发时,服务会部署多个实例,那么必须保证,对同一个数据,执行数据更新操作以及执行缓存更新操作的请求时,通过nginx服务器路由到相同的服务实例上;

    2. 读请求去重,当读请求过多的时候,队列里面会有多个“更新缓存”操作串在一起,其实是没有意义的,往队列里面塞数据的时候可以先判断一下,有的话就不用再塞进去了;队列可以使用ConcurrentLinkedDeque双向队列,在往队列中添加数据时,可以先getFrist/getLast获取上一个添加进去的数据,然后判断是更新缓存操作,如果是的话,就不用继续添加进去了。

    3. 读请求长时阻塞
      该解决方案,最大的风险点在于说,可能数据更新很频繁,导致队列中积压了大量更新操作在里面,然后读请求会发生大量的超时,最后导致大量的请求直接走数据库。

      因此需要根据自己的业务情况进行测试,可能需要部署多个服务,每个服务分摊一些数据的更新操作,如果一个内存队列里居然会挤压100个商品的库存修改操作,每个库存修改操作要耗费10ms去完成,那么最后一个商品的读请求,可能等待10 * 100 = 1000ms = 1s后,才能得到数据这个时候就导致读请求的长时阻塞;

      一定要做根据实际业务系统的运行情况,去进行一些压力测试,和模拟线上环境,去看看最繁忙的时候,内存队列可能会挤压多少更新操作,可能会导致最后一个更新操作对应的读请求,会等待多少时间,如果读请求要求在200ms内返回,当你计算过后,哪怕是最繁忙的时候,积压10个更新操作,最多等待200ms,那还可以的;

      如果一个内存队列可能积压的更新操作特别多,那么你就要加机器,或者加队列,让每个机器上部署的服务实例处理更少的数据,那么每个内存队列中积压的更新操作就会越少。

      其实根据之前的项目经验,一般来说数据的写频率是很低的,因此实际上正常来说,在队列中积压的更新操作应该是很少的

      针对读高并发,读缓存架构的项目,一般写请求相对读来说,是非常非常少的,每秒的QPS能到几百就不错了,一秒,500的写操作,5份,每200ms,就100个写操作,单机器,20个内存队列,每个内存队列,可能就积压5个写操作,每个写操作性能测试后,一般在20ms左右就完成, 那么针对每个内存队列中的数据的读请求,也就最多等待一会儿,200ms以内肯定能返回了

    事实上,大部分的情况下,应该是这样的,大量的读请求过来,都是直接走缓存取到数据的,少量情况下,可能遇到读跟数据更新冲突的情况,如上所述,那么此时更新操作如果先入队列,之后可能会瞬间来了对这个数据大量的读请求,但是因为做了去重的优化,所以也就一个更新缓存的操作跟在它后面等数据更新完了,读请求触发的缓存更新操作也完成,然后临时等待的读请求全部可以读到缓存中的数据(while循环等待)

    image.png

    相关文章

      网友评论

        本文标题:Redis(六):缓存与数据库的双写一致性

        本文链接:https://www.haomeiwen.com/subject/naeogctx.html