分布式系统技术原理 - 一致性(1)

最近工作中,在解决配送核心系统的一个热点问题。为了问题解决,需要对核心业务数据库进行散表/拆表优化。过程中,发现大家对一致性问题的认知不完全对齐,所以想着还是有必要整理归纳一下分布式系统环境中一致性问题出现的场景、原因、一致性问题的定义及对应方案或者说‘套路’ --- 最近重新翻看《万万没想到 - 用理工科思维理解世界》,其中就讲到了要‘掌握套路’,通过不断‘练习’,在头脑中形成针对问题及技能的‘神经网络结构’ 。当碰到类似场景或者问题时,能够一眼识别出‘问题’模式、并采取对应方案来解决)。

一致性问题的场景

先来看看几个一致性问题的场景。

场景1 - 一个用户通过web 服务上传一个全尺寸图片并保存到一个文件存储当中。而一般文件存储为提供数据可靠性,往往都会对上传的内容进行备份。另外一个应用-‘图片调整模块’通过监听消息队列中’图片上传‘事件,会从文件存储当中读取文件,并进行图片尺寸调整,然后将调整后的图片再保存回文件存储中。

这个场景下,可能的一致性问题是:‘图片调整模块’收到有文件发布消息后,就会从文件存储中尝试读取该消息中对应地址的图片。而由于文件存储是分布式的多副本系统,有可能会返回这个图片的一个旧的副本,然后开始进行图片调整操作,并将调整完后的图片写回保存。这样一来,就会使得真正需要进行调整的新图片被覆盖。

相信这类场景,在其他日常的业务/系统需求中,很常见。如上游服务创建了一个订单写入到数据库中,然后通过MQ消息通知下游服务,并通过RPC接口来调用获取订单信息(一般的数据库都采用主从集群,而服务端如不加以区分处理,则有可能读从库----数据延迟,而读到旧数据),下游服务获得后修改,然后再写回。这一类场景中,如果不关注一致性相关问题,可能就会出现数据更新丢失的业务错误。

场景1

场景2 - 一个专门用于对用户进行运营奖励的业务服务A,基于产品同学新的产品需求---- “对在某个时间范围内购买了某些商品且订单金额大于某个下限值的用户进行发券奖励”,RD基于这个需求开发、上线。但却忽略了商品订单可能会被用户退货/申请退款(而这个业务场景很可能并不会被负责用户运营的研发RD和产品同学知道)的场景。那么,一旦这样的情况发生,业务服务A之前发送给用户的奖励优惠券的条件就相当于失效了,但优惠券却已经发送了出去。更有甚者,如果被黑产盯上,撞到了这个漏洞,就可能引发业务安全事件,恶意刷券。

这个不一致的场景,虽然不会造成数据上的丢失,但却造成了业务错误,而且这类错误往往在业务复杂的系统中会出现,尤其产品或研发负责不同的业务服务/产品方向,一个新的产品需求依赖某个对象数据而又没有考虑之前’历史悠久‘的’会改变这个对象数据‘的场景,就会出现这种状态不一致的问题。

场景3 - 与场景2 类似,但引起问题的原因不同。 相信大家都用过Redis的分布式锁,来对某些竞争资源/数据状态进行加锁控制,以避免多服务/多线程并发进行修改,且一个分布式锁为了最终能被释放,除了在程序中通过在finally 代码块儿中显示进行锁的释放之外,都会加一个系统自动过期时间,且一般较长(如10 ~ 20 秒),以防止锁不能被释放、资源/数据状态一直被占用。这样的一个场景会有什么一致性的问题呢?

本场景中,正常情况下不会有什么问题。但由于分布式系统的不确定性,就会导致系统出现’意料之外‘的问题 ---- 由于系统依赖外部数据异常(如,所需要处理的数据量突然变多)或系统bug,导致该服务GC时间特别长(如前段时间亲身经历过的线上核心服务就发生了一次超过1分钟的GC),还没有等到服务应用执行finally 代码进行锁释放,Redis 系统就自动触发了锁过期释放,而此时另外一个服务实例开始抢占相同的这把’锁‘,并开始执行其处理逻辑。但也就在此时,从长时间GC恢复过来的线程开始执行finally 代码块儿中锁释放操作,就会把刚刚被另外一个线程抢占的锁释放掉,造成系统和业务错误。

场景4 - 两个用户先后对数据库的counter 字段进行加一操作。方式是进行‘read-modify-write’操作,由于时间差,用户2在读取counter 数据后进行加一操作(而此时用户1先于用户2进行了数据库更新),然后再写回。这样一来导致counter并没有依次进行累加,而是覆盖,导致数据丢失。

场景4

场景5 - Alice在银行有1000美元的存款,分为两个账户,每个500美元。现在有这样一笔转账交易从其账户 1 转100美元到账户2 。如果在她提交转账请求之后而银行数据库系统执行转账的过程中间,来查看两个账户的余额,她有可能会看到账号2在收到转账之前的余额(500 美元)和账户1在完成转账之后的余额(400 美元)。对于 Alice来说,貌似她的账户总共只有900美元,有100美元消失了。

场景5

场景6 - 对于由数据库和缓存组成的订单系统(数据库用于存储订单全量数据,缓存用于存储热点数据),订单每次更新,先更新数据库再更新缓存,若缓存更新失败则进行重试。在这样的架构下,若发生前后两次对订单标签字段(一般数据库中都会设计一个以json 格式存放各类扩展信息的字段)的更新,由于网络抖动致使第一次更新失败,而第二次更新成功,但第一次缓存更新在经过重试一次后,成功写入缓存,但却也覆盖了第二次更新的数据,造成数据丢失。

一致性问题出现的原因

综合上述场景,我们可以从中归纳发生不一致性问题的规律和原因:

(1)数据状态由一个操作促使其发生改变,而另外一个操作读到的数据是旧的状态,并基于旧数据进行修改。而在这个过程中,让另外一个操作读取旧数据状态的原因又可以演变成常见的如下几种:

    场景1 - 一个操作更新完成后,通过另外一个通道告知下一个操作去读数据并操作,而所读数据是一个存储中的旧的副本(并非原有被真正改写的数据),由于多副本间数据更新延迟,导致读取到旧数据。因此后续的操作会依据旧数据进行修改而导致不一致。

    场景2 - 一个操作读到数据后并据此进行后续处理,但另外一个操作改变了原有数据状态,使得之前的操作不再符合其逻辑约束。这里依赖同一份数据的不同操作分属于不同的业务系统甚至不同的时间周期,难以在系统间通过‘并发锁’等方式进行有效控制。

    场景3 - 一个操作通过原子API获得了锁,但是被另外一个操作释放,数据状态发生了改变。而第一个操作由于系统异常,发生了意料之外的长时间GC超过了预期内释放锁的时间,执行顺序错乱,使得其从故障中恢复后再对其操作时,已不是之前的那个锁。

    场景4 - 两个并发的针对同一份数据的‘read-modify-write’操作是非原子性操作,使得两个操作读到了相同的数据并发进行了修改,从而数据被覆盖而丢失。

(2)数据的正确状态由多个对象(与之对应的是单体对象/一份数据)的完整性约束关系确定。而针对多个对象的多个多步操作并不能进行很好的隔离(如数据库的事务隔离级别),从而导致整体上数据不一致,如:

    场景5 - 一个读事务(分别读取同一个用户的两个账号),一个更新事务(先给账号1加100,再给账号2减100),每个事务都有两步操作。但由于缺乏了类似‘快照隔离’的事务隔离机制,在读事务中尝试读取第二个账号时,读到了晚于读事务创建的更新事务提交后的数据,而非快照数据,使得数据整体上变成了900,导致‘不可重复读’

    (注:数据库事务隔离级别(Isolation)是数据库事务4个属性(特性、ACID)中的一个,主要用于保障当有多个并发事务发生时避免竟态条件的出现使得数据错误。而对于一致性的保障,更多的是由应用程序通过借助数据库的事务原子性和事务隔离型,来达到数据状态一致性。一致性问题不源于数据库而是应用程序的“预期状态”。因此,不同的数据库事务级别也只能解决与其对应的不同层次的数据一致性问题。如场景5中的‘不可重复读’问题可以通过设置‘快照隔离级别’来解决,而‘脏读’和‘脏写’问题可以通过‘读-提交’隔离级别来解决。这里就不对‘脏读’和‘脏写’的场景进行赘述了,其本质上与该原因(1)解释的‘读到旧数据’类似,它是读到了事务过程中的‘临时数据’并进行了修改)

(3)数据状态本应由多个操作按照逻辑正确的顺序进行改变以达到最终正确的状态,但执行时由于异步等原因,使得顺序不再一致,从而导致状态不一致,如场景6

在列举了上述典型不一致场景及产生的原因之后,我们其实可以进一步地总结分布式系统下一致性问题产生的‘套路’了,并在碰到如下类似模式/场景的时候,加以小心检查与分析:

- 单体对象的多副本更新延迟,读写并发

- 针对同一数据都有依赖,但却在不同业务场景的跨领域业务系统有变更

- 网络抖动、系统异常假死等因素,导致针对同一数据(多数据)的多操作执行顺序错乱

- 多对象的跨业务服务/跨数据库的操作,由于异步操作而导致顺序错乱



本文先整理归纳一致性问题出现的场景及原因,下一篇则重点在分布式系统中一致性问题的定义和解决方案层面。

你可能感兴趣的:(分布式系统技术原理 - 一致性(1))