浅谈数据一致性
浅谈数据一致性
|0x00 数据不一致产生的原因
互联网的工程开发,与传统软件相比,往往要面临非常复杂多变的业务场景,这是老生常谈的问题了。虽然在工程开发与协同领域已经有了比较多的实践案例,但对于比较底层的一些技术框架的协同,由于选型的原因,往往是比较多元化的,这也就导致了一些基础框架之间的协同会出现一些问题。
举个例子,在搜索领域,往往会采用ES这一类的全文检索引擎进行加速,但由于搜索往往还要带有广告、推荐等信息,很多时候还需要读取具备ACID的RDMS数据库,或者是一些NoSQL数据库,多种数据库组合在一起,才能满足业务上的需求。
这么多异构的数据源组合在一起,虽然能够将系统做的更大和更灵活,但也会带来很多问题,例如:
工程上的实现更加繁琐,没有办法将所有数据库的操作封装到统一的DAL层; 在一些可回滚的业务场景里,数据要在多个数据库之间同步的进行ACID操作。
例如广告业务场景里,有一个业务场景是只计费一次,流程有如下的步骤:
数据写入Mysql;
数据写入ES;
数据写入Redis。
步骤1是为了将数据传递给财务系统,步骤2是为了重新调整检索顺序,步骤3是为了一些事实的推荐场景应用。如果第1步就失败了,那么整个顺序就不需要执行,但如果第1步成功而第2步失败,那么虽然计费成功了,但是在检索的时候就会出现问题,导致出现第二次的计费。
如果业务对于一致性的要求不高,那么在工程侧是可以不考虑一致性问题的,把现场日志记录完整,通过后续的补救操作,比如对第二次计费进行退费操作,依然可以解决问题。但如果业务场景是要求强一致性,显然工程上可能就需要考虑牺牲部分性能,以满足一致性的要求了。
|0x01 本地事务和分布式事务
在展开后续的叙述前,我们先普及一下本地事务和分布式事务的一些特点。
传统软件行业多使用关系型数据库,如Mysql、PostgreSQL等。好处是通过ACID的事务特性,可以在数据库层面保证数据的强一致性,ACID分别指:
原子性(Atomicity):一个事务要么全部提交成功,要么全部失败回滚,不能只执行其中的一部分操作; 一致性(Consistency):事务的执行不能破坏数据库数据的完整性和一致性; 隔离性(Isolation):事务的隔离性是指在并发环境中,并发的事务是相互隔离的,一个事务的执行不能不被其他事务干扰; 持久性(Durability):一旦事务提交,那么它对数据库中的对应数据的状态的变更就会永久保存到数据库中。
虽然ACID确实能够保证强一致性,但随着业务系统的越来越复杂,绝大多数场景里,对于速度的要求是压过了对于一致性的要求,这个时候为了能够解决业务快速跑起来的问题,我们就会考虑牺牲一部分的性能,来满足业务的能力的问题。这时候CAP理论就应运而生了:
一致性(Consistency):在分布式系统中,更新操作执行成功后所有的用户都应该读取到最新值; 可用性(Availability):每一个操作总是能够在一定时间内返回结果; 分区容忍性(Partition Tolerance):是否可以对数据进行分区。
在分布式系统下,为了保证分区容忍性,就必须要在一致性与可用性之间做出选择,这时候“鱼与熊掌不可兼得”。为了能部分程度上弥补这个问题,我们又提出了BASE理论:
基本可用(Basically Available):假设系统,出现了不可预知的故障,但还是能用; 软状态(Soft state):允许系统中的数据存在中间状态,并认为该状态不影响系统的整体可用性; 最终一致性(Eventually Consistent):系统能够保证在没有其他新的更新操作的情况下,数据最终一定能够达到一致的状态,因此所有客户端对系统的数据访问最终都能够获取到最新的值。
BASE与ACID理论不同的是,它是满足CAP理论的,即通过“时间换空间”的思路,通过牺牲强一致性的方式,在处理系统请求的过程里,允许存在短时间的不一致状态,延迟保证数据的一致性。
所以,这里我们可以给“最终一致性”下一个定义,即:系统中的所有数据副本经过一定时间后,最终能够达成一致的状态。
|0x02 解决数据一致性的模式
通过上一阶段理论演进的阐述,可以看出,互联网工程领域往往通过“最终一致性”的方式,来保障数据的一致性。因此接下来提到的解决思路,都是围绕“最终一致性”展开的。接下来主要介绍三种方式:
第一种是“可靠消息”,即通过保障消息传递的方式,来保障下游数据的一致性,这种方式本质上属于事件驱动的方案设计。例如在电商领域用户下单后,后续会发送消息给各个子系统:银行、仓储、物流等,各个子系统根据消息的结果来做下一步的业务逻辑。
这种方案主要考虑的问题是:如何确保消息能够传达,以及如何避免重复消息的传递,用更专业的语言来描述,就是“幂等”。
其实如果感觉到自己设计系统太过于复杂的时候,可以借鉴一些开源系统的实现方案,比如Kafka就支持“幂等性”。Kafka的思路是这样的:设计唯一的ProducerID及一个从0开始单调递增的SeqNum值,下游通过判断SeqNum是否大于1来判断是否接受消息。或者参考一些流式计算引擎,比如Flink和Storm,都有实现exactly-once的方法。
第二种是“TCC两阶段补偿”,TCC是Try-Confirm-Cancel的简称,这是当下比较火的一种柔性事务方案。TCC的概念最早由Pat Helland于2007年发表的一篇名为《Life beyond Distributed Transactions:an Apostate’s Opinion》的论文提出。
TCC主要分为如下三个阶段:
Try阶段:完成所有业务检查(一致性),预留业务资源(准隔离性); Confirm阶段:确认执行业务操作,不做任何业务检查,只使用Try阶段预留的业务资源; Cancel阶段:取消Try阶段预留的业务资源。
以航班的预定为例,很多时候因为价格问题,我们不会直接飞到目的地,而是通过中转的方式抵达,于是我们会预定两张机票。但问题来了,这两张机票不一定都会顺利使用,如果遇到天气、管制、机票预留等问题,其中一张取消了,那么整个行程就不会顺利完成。这个时候,我们把机票的预定修改为三个接口:机票预留接口、确认接口、取消接口,分两次进行操作,如果两段行程任意一段机票预留失败,那么调用两段行程的取消接口,反之调用确认接口。
这个概念与MR的两阶段计算思路比较类似,即通过一种折中的方案,来实现最终一致性。
第三种是“逆向接口补偿”,使用额外的协调服务来保证微服务之间的最终一致性。微服务通常采用接口进行调用,在常规的提供正向业务逻辑的基础上,再要求每个接口提供一个逆向业务逻辑的方案。如果在顺序调用接口的过程中,某个服务出现了错误,那么再重复调用之前已成功的微服务接口的逆向接口,取消本次事务的操作。
这种场景在优惠领域比较常见,比如用户通过优惠券买了一件商品,但商品库存没了,需要退货,那么理论上优惠券是需要返还给用户的,这时候正向接口就是消耗优惠券,而逆向接口就是返还优惠券。
但各个接口之间的调用不一定会100%成功,所以补偿方案也需要一个最终一致性解决方法,即针对单次原子的逆向操作,至少保证被调用一次。这时候最理想的方案就是系统记录Log,通过事后分析再判断进行一次调用。
|0xFF 从全局角度再思考
不论是从数据库层面,还是从工程层面,或者是人工兜底层面,数据一致性总有解决的方法,区别只是场景适用性与成本高低的问题。
随着技术发展的越来越快,解决方案手段的不断增加,技术架构解耦就是一种必然的要求,在不同的场景下选用自己最适合的方案,但由此带来的数据一致性问题也将成为技术融合道路上的一个阻碍。可以预见,未来的技术生态,对于技术点的组合编排创新必然成为主旋律。就像Hadoop的出现是为了解决集群一致性的问题,数据驱动的方法论也终将像框架一样,成为下一代的创新点。