再看分布式锁

       分布式锁保证获取锁的实例(或线程)能够看清锁的资源状态,并依赖存储服务提供具备正确性和可用性的锁服务。由于分布式环境存在网络分区且不可靠,所以分布式锁无法将正确性和可用性同时推向极致,因为它们之间存在矛盾。不论是为了正确性而选择推模式,还是为了性能而选择拉模式,比选择更重要的是需要确保同步逻辑具备面向失败的设计,拥有一定的自愈能力。

       随着应用分拆以及微服务的不断落地,在分布式环境下遇到并发问题的概率越来越高,而开发者应对这类问题的解法却很单一,即使用分布式锁。通过引入分布式锁,将一个容易出问题的并发场景串行化,使之降低思考难度,从而便于理解和实现,但这么做是真的好吗?答案是不一定的,通过深入分析问题场景,也许不使用分布式锁就能够更好的解决该问题。

比选择推与拉更重要的是什么?

       分布式锁根据等待获取锁的实例被唤醒方式的不同而分为拉模式和推模式,由于二者对存储服务的诉求不同,锁的(获取与释放)流程不同,所以它们在性能、正确性、可用性和成本上也不尽相同。一般来说,拉模式的性能较好且成本不高,适合面向用户操作的链路,也就是业务系统的核心(流量)链路,因为该链路对性能有要求,更短的RT,更高的TPS,往往是该链路所追求的。推模式在正确性上有优势,比较适合一些后台关键场景,这些场景可能会存在一些复杂计算或者高耗时的操作,因为推模式不需要机械的设置锁的占据时间,所以遇到同步逻辑耗时长短不一的情况也能从容应对。

       将如何选择推与拉放到一边,先再看一下分布式锁的使用流程,如下图所示:

       如上图所示,在使用分布式锁之前会先进行前置查询检验,此时会调用获取多方数据进行逻辑判断,如果符合要求,再进行加锁。锁的粒度一般是以具体的业务实体来设计的,比如:会员、商品或者订单粒度。被分布式锁保护的同步逻辑不一定只是位于一个系统中,因为并发冲突往往不是在相同的业务场景(或系统)中产生的。多个不相干的业务场景中时常会产生并发冲突,比如:签到场景会更新用户积分,而用户确认收货也会更新用户积分,当系统进行自动确认收货时,用户又刚好做了签到操作,此时对用户的积分更新就会产生并发冲突,需要依赖用户维度的分布式锁保证多方逻辑的正确执行。成功获取锁后,就会执行同步逻辑,而同步逻辑中一般会先进行查询检验,不会直接进行操作更新。

       分布式锁虽然被用来处理并发问题,但实际生产环境中的并发度并没有想象的那么高,就算在排名前列的互联网公司也不例外。因为虽然访问量巨大,但是如果按照用户维度进行流量分组,会发现用户级别的并发度并不高,因此分布式锁承担的职责更多是对正确性的保证。虽然用户级别的并发度不高,但出现了问题却影响严重,轻则损害用户体验,重则导致资损,开发人员会疲于应对。

       那解决方案就是在推和拉中做出正确选择吗?答案是否定的,解决之道不是选择,而是在于如何编写同步逻辑。只有保证同步逻辑是健壮、可信和容错的,才能在锁失效或同步逻辑出现问题时表现的镇定自若,而提升同步逻辑可靠性的方式有许多种,这里举几个例子,如下表所示:

名称 描述 目的
数据约束 数据结构(比如:关系数据库的表)需要根据自身业务要求建立适当的唯一约束 避免出现异常数据,保证最低限度的正确性
访问日志 对于锁的获取与释放,以及同步逻辑中的关键步骤需要打印结构化日志。日志中需要包含关键的业务信息,比如:相关业务主键,关键业务数据等 便于掌握分布式锁的工作情况,以及同步逻辑的执行情况,包括:调用数量、耗时以及正确率等
监控报警 对结构化日志进行监控,发现错误或异常后会进行报警,比如:通过短信通知应用负责人 防止遗漏问题,能够在问题发生的第一时间得到通知,进行处理
后台处理 对同步逻辑中的错误进行捕获,并将其记录到数据库中,通过后台可以查看出错记录,也可以触发重试 系统化处理问题,避免出现高风险的人工数据订正
自动补偿 同步逻辑执行出现错误后,会将错误记录下来,随后进行定时重试,而重试逻辑会进行相应的检查以及补偿操作 对于问题能够做到系统自愈,减少人工干预

结构化日志,即非自由格式日志,由事先定义好的数据格式来输出日志,目的是使日志的后续处理、分析或查询变得方便高效。

       如上表所示,表格中由上至下这些方式分别着重于可观测性、健壮性和可恢复性,同时这些特性对技术的要求以及成本会随之变高。因此,在不同的场景中,也需要根据重要程度来运用这些方式,比如:对于核心业务场景,肯定需要不计成本,做到最好,而对于一般场景,至少需要确保做到可观测和有监控。可观测以及有监控之所以是必要的,是因为系统出现问题往往不是偶然,而是由于长时间业务需求不断上线,系统缺少维护劣化所致。面对这种必然,有效的指标观测以及报警,会使得问题被提早暴露出来并得以修复加固。

解锁胜于用锁

       在分布式环境中遇到并发问题时,选择使用分布式锁能够很快且直接的解决问题,但是随着锁的引入,会对性能造成长久的影响。除了降低一定的系统性能,还需要系统的维护者在未来不仅要关注同步逻辑的执行情况,还要对依赖的锁服务状况进行持续监控,在任何业务活动来临时,都要提前给足容量。

       正因为分布式锁的引入会带来一些问题,增加一些维护成本,所以在识别出具体场景中存在并发问题后,需要思考是否存在不使用分布式锁就能解决并发问题的实现方案,而这种解锁的方案更值得推荐。由于解锁是建立在具体场景上的,所以没有直接可以套用的模式和成熟的方法论,需要开发者能够仔细分析场景问题,不是只聚焦在一个点上,而是从数据的产生到消费全链路的思考和推演,只有这样才可能找到好的解法,下面举两个解锁的例子。

       场景一:多个使用方都会调用会员的属性更新接口,当并发冲突发生时,会出现由于数据覆盖而产生脏数据。该场景是分布式环境下,基础服务经常面临的问题。如果使用分布式锁来解决,可以在接口实现上增加会员粒度的分布式锁,这样多方并行的更新请求就会在会员维度上排队,从而避免产生脏数据的问题。

       那假设不使用锁呢?可以通过在会员属性的存储结构中增加数据版本来解决。使用方在调用会员属性更新接口前,一般都会查询出会员相关属性,然后传入目标值调用接口进行更新,而数据版本的解决思路就是要求更新参数需要携带之前获取的会员数据版本。假设会员微服务使用关系数据库作为存储实现,该过程如下图所示:

       如上图所示,使用方查询出会员属性,然后将其数据版本与更新参数一并发往会员微服务。会员微服务在处理更新请求时,使用where条件和数据版本,能够保证只有符合数据版本要求的更新请求才会被处理,这样使用过时数据版本的更新请求会被忽略,同时数据版本也会在更新成功后自增。使用方得到更新结果后,可以选择返回错误或者重试。通过增加数据版本,使得会员属性更新在没有引入分布式锁的情况下做到了线程安全。

       场景二:异步接受商品变更消息,将商品信息查询出来并同步到其他系统,当消费端并发收到消息时,有概率会出现旧数据覆盖新数据的情况。当消费端并行处理消息时,先收到消息的线程查出了旧有商品数据,而后收到消息的线程可能已经完成了同步处理,此时先收到消息的线程会将旧数据再次同步,从而导致产生了脏数据。这种后发先至的问题也经常在分布式环境下遇到,而该问题往往在测试环境中难以复现,并且在生产环境中也只是随着流量变高而偶现,是一种具备隐蔽性的问题。如果使用分布式锁来解决,可以在消费端处理逻辑中增加商品粒度的分布式锁,这样数据同步就会在商品维度顺序执行,从而不会出现脏数据的问题了。

       那假设不使用锁呢?可以将商品变更消息的类型改为顺序消息,消息路由策略可以选为按照商品ID取模。这样同一个商品变更消息的处理会被控制在相同的消费线程中,使得该处理逻辑从宏观上看是并行的,但从(具体商品)微观上看又是串行的。通过引入顺序消息,不使用分布式锁,也可以解决该场景并发冲突的问题。

顺序消息是消息中间件提供的一种严格按照顺序进行发布和消费的消息类型,比如:RocketMQKafka就具备这种特性。

       由上述解锁的例子可以看出,对业务场景问题进行仔细分析,通过一定的技术方案调整或实施,是可以做到不使用分布式锁就能解决并发问题的。当然,并不是每个存在并发问题的场景都有无锁的解法,只是开发者不要放弃对无锁的追求,同时解锁的经验也需要开发者在工作中多加思考和练习。

results matching ""

    No results matching ""