• 缓存与数据库一致性



    转至元数据结尾

     

    转至元数据起始

     

    一般来说,一个业务系统一般会经历以下几个阶段。本系列文章,是针对缓存与数据库一致性场景,提出实用可行的技术方案。

    阶段1:单库阶段

        此时系统的读写流量很小,这个时候所有的读写操作都在主库;此时,从库的角色只是作为灾备。

        风险分析:从数据一致性的角度来看没有任何问题,所有读写操作都在主库

    阶段2:多库分片阶段

    阶段2.1: 分库分表阶段

        随着业务的前进和流量的激增,会出现大表和数据库写入性能下降的问题。我们可以通过分库的方式,提升数据库单机的QPS压下来;通过分表的方式,降低单表的数据量,提升查询性能

        风险分析:从数据一致性的角度来看没有任何风险,所有操作仍然走主库

    阶段2.2:读写分离阶段

        随着业务的进一步发展,在阶段2.1时我们已经解决了写性能的问题;但业务发展到此时,读问题也会逐渐成为性能瓶颈。这时,我们需要把从库利用起来;进行读写分离,写走主库,读走从库

        风险分析:读写分离意味着,读到的数据很有可能不是最新的。这对实时性要求较高的交易类场景是不合适的,但是足以应对90%的业务场景;关于数据库主从同步的延时问题,我们之后再进行深入讨论。

    阶段3:数据库+缓存

        对于大多数业务场景来说,我们都面临着读多写少的情况;核心交易类应用,更多是面临写多读少的场景(此时将核心流程全部走写库,避免了为了处理数据一致性的复杂性)。而数据库的资源是很宝贵的,疯狂增加从库节点,会带来资源成本激增;同时,分库分表也会带来系统设计的复杂度上升和数据迁移的困难。

        所以,此时我们会通过添加缓存的方式,来缓解数据库的读写压力。

        风险分析:数据库与缓存数据一致性问题。

    上面的问题,引发出之后的思考与讨论的问题:缓存与数据库一致性如何保证?在提出方案之前,我们需要分析自己业务系统的架构与设计方案,可以接受什么粒度的数据不一致?不同的方案,实现难度和设计是不一样的

    我们的业务系统目标是怎样的?

    • 要求最终一致性,还是强一致性?
    • 对缓存一致性的时间要求到底有多高?1ms?1min?
    • 当前有没有做分库分表,读写分离?是不是可以经受住当前及未来业务的增长
    • 缓存数据结构是怎么样的?是否有多表合并的数据结构
    • 如何灾备?更新、删除缓存失败的话,系统能否容忍?写入数据库失败怎么办?
    • 如果删除缓存失败,还更不更新数据库?
    • 。。。。。

    只有我们自己清楚了现状、知道自己想要什么,才能设计出最合理缓存数据库一致性方案。

     
     
    转至元数据起始
     

    在本章节,我们分析一下缓存与数据库数据一致性场景中,被使用最多的方案:

    写流程:

        当我们更新缓存key时,首先做删除缓存操作;当删除完成之后再更新数据库;最后再进行异步(或同步)刷新缓存操作

    读流程:

        首先读取缓存的key,如果存在即返回;否则读取DB,再异步(或同步)刷新缓存

    方案分析

    优点分析

    1.实现简单

        这个。。没什么好说的,逻辑简单,代码实现也不会很复杂

    2.“先淘汰缓存,再写数据库”合理

        我们试想一下,将上述的写流程做一下改造:

        数据变化后,首先更新缓存,再更新DB。如果缓存更新成功,数据库更新失败,会导致DB中的数据完全是错误的,这个错误是绝大多数业务系统完全不能接受的。业务系统也许可以接收数据的延迟,但是绝对不能接收数据的错误。

        数据变化后,不更新缓存,先更新DB,最后更新缓存。如果缓存更新失败,会导致缓存中的数据一直是旧数据(其实也是一种错误),而且数据无法达到最终一致性的要求

        所以,我们首先将缓存淘汰,更新DB后再刷新缓存的方案,是比较合理的。

    3.异步刷新,补缺补漏

        在大多数业务系统中,缓存是做辅助工作而不是完全做存储角色。所以在很多场景中,缓存的读写失败不能影响到主流程。其实我们可以在每次写或者读操作后,同步刷新缓存;但异步刷新,可以进一步在写流程的步骤1(DEL缓存)失败后的补偿,保证数据一致性。

    缺点分析

    1.容灾不足

        如果del缓存失败,整个流程是否还需要继续?这个需要针对每一个不同场景进行不同的考量;如果异步刷新的过程失败,会导致缓存中的数据一致保持一个旧状态,这个问题就相对比较严重了

    2.并发问题

    写写并发:

        如上述的流程,Server A和Server B在写流程先后更新数据库记录;之后刷新缓存,因为在分布式场景下,我们没有办法保证顺序(无论是同步刷新还是异步刷新)。这时,如果Server B优先于Server A完成缓存的更新,会造成最后缓存中的数据是Server A的旧数据。也就是,不能排除先更新的DB操作,反而会很晚刷新缓存,这时,数据也是错的。

    读写并发:

    T
    Server A(写操作)
    Server B(读操作)
    T1 del 缓存  
    T2   查询缓存key,不存在
    T3   查询数据库
    T4 更新数据库  
    T5 刷新缓存成功  
    T6   刷新缓存成功

        如上述的流程,如果读操作查询的时间早于写操作、刷新缓存的时间晚于写操作。会造成最终写入到缓存的数据仍然是旧数据。

    方案总结

        本章节介绍的方案,适合绝大部分业务场景,实现起来也比较简单。适用于并发量、一致性要求都不是很高的情况。但是这个方案最大的问题在于更新可能会失败,失败的话缓存中的数据一直是错误的,不能保证最终一致性;在并发量较高的时候,数据也很难保证一致性。

        是否可以解决?答案是肯定的,我们在下一章节继续设计与分析。

     

    转至元数据结尾
     
    转至元数据起始
     

    在上一篇文章中,我们的方案虽然实现起来简单,而且可能绝大多数场景都可以适用,但是有一个最大的缺陷:当缓存异步刷新失败时,缓存中的数据永远无法与数据库保持一致性。

    在分布式系统中,我们经常使用最终一致性的方案来解决数据一致性问题。我们可以基于MQ,将读数据库+写数据的流程串行化,进而解决并发问题和实现数据的最终一致性。

    写操作:

        第一步先删除缓存,删除之后再更新数据库;将数据标识写入MQ,接下来Consumer消费MQ,从读库中查询数据并刷新到缓存。(在本文中,我们默认主从同步延时忽略)

    MQ实现消息顺序化,可以参考RocketMQ中,同一个queue中的消息是有序的;KafKa中,同一个分区的消息是有序的机制来实现。

    读操作:

        首先读取缓存,如果缓存中不存在,则读取DB主库(或从库)。之后与写操作一致,下传标志位到MQ,Consumer消费MQ,从读库中刷新数据到缓存中。

    方案分析

    1.容灾机制

    写流程容灾分析:

    • del缓存的流程:如果失败,可以靠最后的数据更新覆盖
    • MQ发送失败:基于中间件的重试机制来保证消息投递
    • 消费MQ失败:重新消费即可

    读流程容灾分析:

    • 查询完成后写MQ失败:缓存中没有数据,每次都查询数据库,可以保证一致性

    2.并发问题

        该方案将“读数据库+刷新缓存”的过程串行化,这样就不存在老数据覆盖刷新新数据的问题

    3.方案缺点

    • 最终一致性方案,如果数据变动频繁会有一定的数据库查询压力;且缓存查询时候,会有查询到旧数据的可能
    • 引入MQ中间件,系统健壮性降低;但是可以通过集群、灾备等运维方式来避免

    方案总结

        经过前面由浅入深的讨论,我们已经实现了“最终一致性”。这个方案的优点还是比较明显的,解决了我们之前方案的“容灾问题”和“并发问题”,整体思路也是将并行的问题串行化解决。保证了缓存和数据库中的数据在最后是一致的。如果你的业务只需要达到最终一致性的话,这个方案已经是比较合理得了。

        那我们再进一步,如何实现“强一致性”?下一篇文章继续讨论。

     
     

    转至元数据结尾
     
    转至元数据起始
     

    经过前面三篇文章的讨论,我们已经实现了缓存与数据库的“最终一致性”。在本文中,我们再进一步,在上文的基础上实现“最终一致性”。

    什么是强一致性?

    • 缓存和数据库中的数据永远一致
    • 缓存中没有数据,始终查询数据库

    首先,我们先分析一下,“强一致性”和“最终一致性”的区别在哪里?关键点在于“时间差”

    “强一致性”=“最终一致性”+“时间差”

    那我们的工作,就是在“最终一致性”的基础上,加上“时间差”。实现方式:我们增加一个缓存key值,将近期要被修改的数据进行标记锁定(类似于分布式锁);读取的时候,如果数据处于被标记的状态,则强行走DB;没有锁定的话,则先走缓存。

    写流程

    当我们更新数据时,首先写入缓存标记位以锁定该记录。如果标记成功,则流程继续往下走即可。如果标记失败,则放弃本次修改。

    如何标记锁定?

    比如你可以设定一个有效期为10s的key,key存在即为锁定。一般来说,10s对于后面的同步操作时间基本够用。这个时间可以根据业务系统的忍耐度来配置

    读流程

    先读缓存锁定标志位,看一下要读取的记录是否已经锁定。如果多锁定,则直接查询数据库(我们默认主从延迟忽略,查询走从库);如果没有被标记,则流程继续往下走。

    方案分析

    优点分析

    1. 容灾完备

    我们一步一步来分析,该方案如何容灾完备:

    写流程容灾分析:

    • 写入标记锁定位失败:没关系,放弃修改即可
    • 删除缓存key失败:没关系,后面的流程会覆盖
    • 写MQ失败:基于中间件的重试和ack机制,保证消息至少一次投递成功
    • MQ消费失败:理由同上,重新消费即可

    读流程容灾分析:

    • 读取标记锁定位失败:直接查询数据库
    • 写MQ失败:没关系,反正缓存值为空,下次还走数据库即可

    2.无并发问题

    所有流程基本全部串行化,不存在老数据覆盖新数据的问题

    缺点分析

    1.增加对缓存标记锁定位的强依赖

    其实这个问题是没有办法的,实现强一致性,一定要牺牲一些性能和稳定性的。毕竟架构是一门妥协的艺术。如同分布式系统的CAP定律,无法三者同时满足一样。

    但是呢,可以用热点key的思路来解决这个问题。将缓存标记位分片至多个节点上,即使部分节点挂了,也只有很少的流量进入到数据库查询。

    2.复杂度增加

    毕竟引入了这么多逻辑,编程复杂度一定会上升的。世界上没有完美的事情

    方案总结

    至此,“缓存与数据库”的强一致性已经实现。本文也是基于在可实现和尽量简单的基础上,完成了这么一次架构方案的设计。如果大家有更好的思路,可以一起交流。

     

  • 相关阅读:
    JavaWeb学习笔记(十四)—— 使用JDBC处理MySQL大数据
    JavaWeb学习笔记(十三)—— JDBC时间类型的处理
    JavaWeb学习笔记(十二)—— JDBC的基本使用
    JavaWeb学习笔记(十一)—— JavaWeb开发模式【转】
    JavaWeb学习笔记(十)—— JavaBean总结【转】
    JavaWeb学习笔记(九)—— JSTL标签库
    【转】org.apache.jasper.JasperException: The absolute uri: http://java.sun.com/jsp/jstl/core cannot be res
    JavaWeb学习笔记(八)—— EL表达式
    JavaWeb学习笔记(七)—— JSP
    负载均衡,分布式,集群的理解,多台服务器代码如何同步
  • 原文地址:https://www.cnblogs.com/dushenzi/p/13435056.html
Copyright © 2020-2023  润新知