
若需文中图片,请关注公众号:404P 获取。 前言 在微服务架构体系下,服务注册中心致力于解决微服务之间服务发现的问题。在服务数量不多的情况下,服务注册中心集群中每台机器都保存着全量的服务数据,但随着蚂蚁金服海量服务的出现,单机已无法存储所有的服务数据,数据分片成为了必然的选择。数据分片之后,每台机器只保存一部分服务数据,节点上下线就容易造成数据波动,很容易影响应用的正常运行。本文通过介绍 SOFARegistry 的分片算法和相关的核心源码来展示蚂蚁金服是如何解决上述问题的。 服务注册中心简介 在微服务架构下,一个互联网应用的服务端背后往往存在大量服务间的相互调用。例如服务 A 在链路上依赖于服务 B,那么在业务发生时,服务 A 需要知道服务 B 的地址,才能完成服务调用。而分布式架构下,每个服务往往都是集群部署的,集群中的机器也是经常变化的,所以服务 B 的地址不是固定不变的。如果要保证业务的可靠性,服务调用者则需要感知被调用服务的地址变化。 图1 微服务架构下的服务寻址 既然成千上万的服务调用者都要感知这样的变化,那这种感知能力便下沉成为微服务中一种固定的架构模式:服务注册中心。 图2 服务注册中心 服务注册中心里,有服务提供者和服务消费者两种重要的角色,服务调用方是消费者,服务被调方是提供者。对于同一台机器,往往兼具两者角色,既被其它服务调用,也调用其它服务。服务提供者将自身提供的服务信息发布到服务注册中心,服务消费者通过订阅的方式感知所依赖服务的信息是否发生变化。 SOFARegistry 总体架构 SOFARegistry 的架构中包括4种角色:Client、Session、Data、Meta,如图3所示: 图3 SOFARegistry 总体架构 Client 层 应用服务器集群。Client 层是应用层,每个应用系统通过依赖注册中心相关的客户端 jar 包,通过编程方式来使用服务注册中心的服务发布和服务订阅能力。 Session 层 Session 服务器集群。顾名思义,Session 层是会话层,通过长连接和 Client 层的应用服务器保持通讯,负责接收 Client 的服务发布和服务订阅请求。该层只在内存中保存各个服务的发布订阅关系,对于具体的服务信息,只在 Client 层和 Data 层之间透传转发。Session 层是无状态的,可以随着 Client 层应用规模的增长而扩容。 Data 层 数据服务器集群。Data 层通过分片存储的方式保存着所用应用的服务注册数据。数据按照 dataInfoId(每一份服务数据的唯一标识)进行一致性 Hash 分片,多副本备份,保证数据的高可用。下文的重点也在于随着数据规模的增长,Data 层如何在不影响业务的前提下实现平滑的扩缩容。 Meta 层 元数据服务器集群。这个集群管辖的范围是 Session 服务器集群和 Data 服务器集群的服务器信息,其角色就相当于 SOFARegistry 架构内部的服务注册中心,只不过 SOFARegistry 作为服务注册中心是服务于广大应用服务层,而 Meta 集群是服务于 SOFARegistry 内部的 Session 集群和 Data 集群,Meta 层能够感知到 Session 节点和 Data 节点的变化,并通知集群的其它节点。 SOFARegistry 如何突破单机存储瓶颈 在蚂蚁金服的业务规模下,单台服务器已经无法存储所有的服务注册数据,SOFARegistry 采用了数据分片的方案,每台机器只保存一部分数据,同时每台机器有多副本备份,这样理论上可以无限扩容。根据不同的数据路由方式,常见的数据分片主要分为两大类:范围分片和 Hash(哈希)分片。 图4 数据分片 范围分片 每一个数据分片负责存储某一键值区间范围的值。例如按照时间段进行分区,每个小时的 Key 放在对应的节点上。区间范围分片的优势在于数据分片具有连续性,可以实现区间范围查询,但是缺点在于没有对数据进行随机打散,容易存在热点数据问题。 Hash (哈希)分片 Hash 分片则是通过特定的 Hash 函数将数据随机均匀地分散在各个节点中,不支持范围查询,只支持点查询,即根据某个数据的 Key 获取数据的内容。业界大多 KV(Key-Value)存储系统都支持这种方式,包括 cassandra、dynamo、membase 等。业界常见的 Hash 分片算法有哈希取模法、一致性哈希法和虚拟桶法。 哈希取模 哈希取模的 Hash 函数如下: H(Key) = hash(key) mod K; 这是一个 key-machine 的函数。key 是数据主键,K 是物理机数量,通过数据的 key 能够直接路由到物理机器。当 K 发生变化时,会影响全体数据分布。所有节点上的数据会被重新分布,这个过程是难以在系统无感知的情况下平滑完成的。 图5 哈希取模 一致性哈希 分布式哈希表(DHT)是 P2P 网络和分布式存储中一项常见的技术,是哈希表的分布式扩展,即在每台机器存储部分数据的前提下,如何通过哈希的方式来对数据进行读写路由。其核心在于每个节点不仅只保存一部分数据,而且也只维护一部分路由,从而实现 P2P 网络节点去中心化的分布式寻址和分布式存储。DHT 是一个技术概念,其中业界最常见的一种实现方式就是一致性哈希的 Chord 算法实现。 哈希空间 一致性哈希中的哈希空间是一个数据和节点共用的一个逻辑环形空间,数据和机器通过各自的 Hash 算法得出各自在哈希空间的位置。 图6 数据项和数据节点共用哈希空间 图7是一个二进制长度为5的哈希空间,该空间可以表达的数值范围是0~31(2^5),是一个首尾相接的环状序列。环上的大圈表示不同的机器节点(一般是虚拟节点),用 Ni 来表示,i 代表着节点在哈希空间的位置。例如,某个节点根据 IP 地址和端口号进行哈希计算后得出的值是7,那么 N7 则代表则该节点在哈希空间中的位置。由于每个物理机的配置不一样,通常配置高的物理节点会虚拟成环上的多个节点。 图7 长度为5的哈希空间 环上的节点把哈希空间分成多个区间,每个节点负责存储其中一个区间的数据。例如 N14 节点负责存储 Hash 值为8~14范围内的数据,N7 节点负责存储 Hash 值为31、0~7区间的数据。环上的小圈表示实际要存储的一项数据,当一项数据通过 Hash 计算出其在哈希环中的位置后,会在环中顺时针找到离其最近的节点,该项数据将会保存在该节点上。例如,一项数据通过 Hash 计算出值为16,那么应该存在 N18 节点上。通过上述方式,就可以将数据分布式存储在集群的不同节点,实现数据分片的功能。 节点下线 如图8所示,节点 N18 出现故障被移除了,那么之前 N18 节点负责的 Hash 环区间,则被顺时针移到 N23 节点,N23 节点存储的区间由19~23扩展为15~23。N18 节点下线后,Hash 值为16的数据项将会保存在 N23 节点上。 图8 一致性哈希环中节点下线 节点上线 如图9所示,如果集群中上线一个新节点,其 IP 和端口进行 Hash 后的值为17,那么其节点名为 N17。那么 N17 节点所负责的哈希环区间为15~17,N23 节点负责的哈希区间缩小为18~23。N17 节点上线后,Hash 值为16的数据项将会保存在 N17 节点上。 图9 一致性哈希环中节点上线 当节点动态变化时,一致性哈希仍能够保持数据的均衡性,同时也避免了全局数据的重新哈希和数据同步。但是,发生变化的两个相邻节点所负责的数据分布范围依旧是会发生变化的,这对数据同步带来了不便。数据同步一般是通过操作日志来实现的,而一致性哈希算法的操作日志往往和数据分布相关联,在数据分布范围不稳定的情况下,操作日志的位置也会随着机器动态上下线而发生变化,在这种场景下难以实现数据的精准同步。例如,上图中 Hash 环有0~31个取值,假如日志文件按照这种哈希值来命名的话,那么 data-16.log 这个文件日志最初是在 N18 节点,N18 节点下线后,N23 节点也有 data-16.log 了,N17 节点上线后,N17 节点也有 data-16.log 了。所以,需要有一种机制能够保证操作日志的位置不会因为节点动态变化而受到影响。 虚拟桶预分片 虚拟桶则是将 key-node 映射进行了分解,在数据项和节点之间引入了虚拟桶这一层。如图所示,数据路由分为两步,先通过 key 做 Hash 运算计算出数据项应所对应的 slot,然后再通过 slot 和节点之间的映射关系得出该数据项应该存在哪个节点上。其中 slot 数量是固定的,key - slot 之间的哈希映射关系不会因为节点的动态变化而发生改变,数据的操作日志也和slot相对应,从而保证了数据同步的可行性。 图10 虚拟桶预分片机制 路由表中存储着所有节点和所有 slot 之间的映射关系,并尽量确保 slot 和节点之间的映射是均衡的。这样,在节点动态变化的时候,只需要修改路由表中 slot 和动态节点之间的关系即可,既保证了弹性扩缩容,也降低了数据同步的难度。 SOFARegistry 的分片选择 通过上述一致性哈希分片和虚拟桶分片的对比,我们可以总结一下它们之间的差异性:一致性哈希比较适合分布式缓存类的场景,这种场景重在解决数据均衡分布、避免数据热点和缓存加速的问题,不保证数据的高可靠,例如 Memcached;而虚拟桶则比较适合通过数据多副本来保证数据高可靠的场景,例如 Tair、Cassandra。 显然,SOFARegistry 比较适合采用虚拟桶的方式,因为服务注册中心对于数据具有高可靠性要求。但由于历史原因,SOFARegistry 最早选择了一致性哈希分片,所以同样遇到了数据分布不固定带来的数据同步难题。我们如何解决的呢?我们通过在 DataServer 内存中以 dataInfoId 的粒度记录操作日志,并且在 DataServer 之间也是以 dataInfoId 的粒度去做数据同步(一个服务就由一个 dataInfoId 唯标识)。其实这种日志记录的思想和虚拟桶是一致的,只是每个 datainfoId 就相当于一个 slot 了,这是一种因历史原因而采取的妥协方案。在服务注册中心的场景下,datainfoId 往往对应着一个发布的服务,所以总量还是比较有限的,以蚂蚁金服目前的规模,每台 DataServer 中承载的 dataInfoId 数量也仅在数万的级别,勉强实现了 dataInfoId 作为 slot 的数据多副本同步方案。 DataServer 扩缩容相关源码 注:本次源码解读基于 registry-server-data 的5.3.0版本。 DataServer 的核心启动类是 DataServerBootstrap,该类主要包含了三类组件:节点间的 bolt 通信组件、JVM 内部的事件通信组件、定时器组件。 图11 DataServerBootstrap 的核心组件 外部节点通信组件:在该类中有3个 Server 通信对象,用于和其它外部节点进行通信。其中 httpServer 主要提供一系列 http 接口,用于 dashboard 管理、数据查询等;dataSyncServer 主要是处理一些数据同步相关的服务;dataServer 则负责数据相关服务;从其注册的 handler 来看,dataSyncServer 和 dataSever 的职责有部分重叠;JVM 内部通信组件:DataServer 内部逻辑主要是通过事件驱动机制来实现的,图12列举了部分事件在事件中心的交互流程,从图中可以看到,一个事件往往会有多个投递源,非常适合用 EventCenter 来解耦事件投递和事件处理之间的逻辑;定时器组件:例如定时检测节点信息、定时检测数据版本信息; 图12 DataServer 中的核心事件流转 DataServer 节点扩容 假设随着业务规模的增长,Data 集群需要扩容新的 Data 节点。如图13,Data4 是新增的 Data 节点,当新节点 Data4 启动时,Data4 处于初始化状态,在该状态下,对于 Data4 的数据写操作被禁止,数据读操作会转发到其它节点,同时,存量节点中属于新节点的数据将会被新节点和其副本节点拉取过来。 图13 DataServer 节点扩容场景 转发读操作 在数据未同步完成之前,所有对新节点的读数据操作,将转发到拥有该数据分片的数据节点。 查询服务数据处理器 GetDataHandler: public Object doHandle(Channel channel, GetDataRequest request) { String dataInfoId = request.getDataInfoId(); if (forwardService.needForward()) { // ... 如果不是WORKING状态,则需要转发读操作 return forwardService.forwardRequest(dataInfoId, request); } } 转发服务 ForwardServiceImpl: public Object forwardRequest(String dataInfoId, Object request) throws RemotingException { // 1. get store nodes List<DataServerNode> dataServerNodes = DataServerNodeFactory .computeDataServerNodes(dataServerConfig.getLocalDataCenter(), dataInfoId, dataServerConfig.getStoreNodes());
纲要 文章目的:本文旨在提炼一套分布式幂等问题的思考框架,而非解决某个具体的分布式幂等问题。在这个框架体系内,会有一些方案举例说明。文章目标:希望读者能通过这套思考框架设计出符合自己业务的完备的幂等解决方案。文章内容:(1)背景介绍,为什么会有幂等。(2)什么是幂等,这个定义非常重要,决定了整个思考框架。(3)解决幂等问题的三部曲,也是作者的思考框架。(4)总结 一 背景 分布式系统由众多微服务组成,微服务之间必然存在大量的网络调用。下图是一个服务间调用异常的例子,用户提交订单之后,请求到A服务,A服务落单之后,开始调用B服务,但是在A调用B的过程中,存在很多不确定性,例如B服务执行超时了,RPC直接返回A请求超时了,然后A返回给用户一些错误提示,但实际情况是B有可能执行是成功的,只是执行时间过长而已。 用户看到错误提示之后,往往会选择在界面上重复点击,导致重复调用,如果B是个支付服务的话,用户重复点击可能导致同一个订单被扣多次钱。不仅仅是用户可能触发重复调用,定时任务、消息投递和机器重新启动都可能会出现重复执行的情况。在分布式系统里,服务调用出现各种异常的情况是很常见的,这些异常情况往往会使得系统间的状态不一致,所以需要容错补偿设计,最常见的方法就是调用方实现合理的重试策略,被调用方实现应对重试的幂等策略。 二 什么是幂等 对于幂等,有一个很常见的描述是:对于相同的请求应该返回相同的结果,所以查询类接口是天然的幂等性接口。举个例子:如果有一个查询接口是查询订单的状态,状态是会随着时间发生变化的,那么在两次不同时间的查询请求中,可能返回不一样的订单状态,这个查询接口还是幂等接口吗? 幂等的定义直接决定了我们如何去设计幂等方案,如果幂等的含义是相同请求返回相同结果,那实际上只需要缓存第一次的返回结果,即可在后续重复请求时实现幂等了。但问题真的有这么简单吗? 笔者更赞同这种定义:幂等指的是相同请求(identical request)执行一次或者多次所带来的副作用(side-effects)是一样的。 引自:https://developer.mozilla.org/en-US/docs/Glossary/IdempotentAn HTTP method is idempotent if an identical request can be made once or several times in a row with the same effect while leaving the server in the same state. In other words, an idempotent method should not have any side-effects (except for keeping statistics). 这个定义有一定的抽象,概括性比较强,在设计幂等方案时,其实就是将抽象部分具化。例如:什么是相同的请求?哪些情况会有副作用?该如何避免副作用?且看三部曲。 三 解决方案三部曲 不少关于幂等的文章都称自己的方案是通用解决方案,但笔者却认为,不同的业务场景下,相同请求和副作用都是有差异性的,不同的副作用需要不同的方案来解决,不存在完全通用的解决方案。而三部曲旨在提炼出一种思考模式,并举例说明,在该思考模式下,更容易设计出符合业务场景的幂等解决方案。 第一部曲:识别相同请求 幂等是为了解决重复执行同一请求的问题,那如何识别一个请求有没有和之前的请求重复呢?有的方案是通过请求中的某个流水号字段来识别的,同一个流水号表示同一个请求。也有的方案是通过请求中某几个字段甚至全部字段进行比较,从而来识别是否为同一个请求。所以在方案设计时,明确定义具体业务场景下什么是相同请求,这是第一部曲。 方案举例:token机制识别前端重复请求 在一条调用链路的后端系统中,一般都可以通过上游系统传递的reqNo+source来识别是否是为重复的请求。如下图,B系统是依赖于A系统传递的reqNo+source来识别相同请求的,但是A系统是直接和前端页面交互的系统,如何识别用户发起的请求是相同的呢?比如用户在支付界面上点击了多次,A系统怎么识别这是一次重复操作呢? 前端可以在第一次点击完成时,将按钮设置为disable,这样用户无法在界面上重复点击第二次,但这只是提升体验的前端解决方案,不是真正安全的解决方案。 常见的服务端解决方案是采用token机制来实现防重复提交。如下图, (1)当用户进入到表单页面的时候,前端会从服务端申请到一个token,并保存在前端。(2)当用户第一次点击提交的时候,会将该token和表单数据一并提交到服务端,服务端判断该token是否存在,如果存在则执行业务逻辑。(3)当用户第二次点击提交的时候,会将该token和表单数据一并提交到服务端,服务端判断该token是否存在,如果不存在则返回错误,前端显示提交失败。 这个方案结合前后端,从前端视角,这是用于防止重复请求,从服务端视角,这个用于识别前端相同请求。服务端往往基于类似于redis之类的分布式缓存来实现,保证生成token的唯一性和操作token时的原子性即可。核心逻辑如下。 // SETNX keyName value: 如果key存在,则返回0,如果不存在,则返回1 // step1. 申请token String token = generateUniqueToken(); // step2. 校验token是否存在 if(redis.setNx(token, 1) == 1){ // do business } else { // 幂等逻辑 } 第二部曲:列出并减少副作用的分析维度 相同的请求重复执行业务逻辑,如果处理不当,会给系统带来副作用。那什么是副作用?从技术的角度理解就是返回结果后还导致某些“系统状态”发生变化,无副作用的函数称之为纯函数,体现到业务的角度就是业务无法接受的非预期结果。最常见的有重复入库、数据被错误变更等,大多数幂等方案就是围绕解决这类问题来设计的。而系统往往可能在多个维度都存在副作用,例如:(1)调用下游维度:重复调用下游会怎样?如果下游没有幂等,重复调用会带来什么副作用?(2)返回上游维度:例如第一次返回上游异常,第二次返回上游被幂等了?会给上游带来什么副作用?(3)并发执行维度:并发重复执行会怎样?会有什么副作用?(4)分布式锁维度:引入分布式锁来防止并发执行?但是如果锁出现不一致性,会有什么副作用?(5)交互时序维度:有没有异步交互,是否存在时序问题?会有什么副作用?(6)客户体验维度:从数据不一致到最终一致,必须在多少时间内完成?如果该时间内没有完成,会有什么副作用?例如大量客诉(秉承客户第一的原则,在支付宝,客诉量太大会定级为生产环境故障)。(7)业务核对维度:重复调用是否存在覆盖核对标识的情况,带来无法正常核对的副作用?在金融系统中,资金链路无法核对是无法接受的。(8)数据质量维度:是否存在重复记录?如果存在会有什么副作用? 上面是一些常见的分析维度,不同行业的系统中会存在不一样的维度,尽可能地总结出这些维度,并列入系统分析时的checklist中,能够更好地完善幂等解决方案。没有副作用才算是完备的幂等解决方案,但是副作用的维度太多,会提高幂等方案的复杂度。所以在能够达成业务的前提下,减少一些分析维度,能够使得幂等方案实现起来更加经济有效。例如:如果有专门的幂等表存储返回给上游的幂等结果,第(2)维度不用考虑了,如果用锁来防止并发,第(3)个维度不考虑了,如果用单机锁代替分布式锁,第(4)个维度不考虑了。 这是解决幂等问题的第二部曲:列出并减少副作用的分析维度。在这部曲中,涉及的解决方案往往是解决某一个维度的副作用问题,适合以通用组件的形式存在,作为团队内部的一个公共技术套路。 方案举例:加锁避免并发重复执行 很多幂等解决方案都和防并发有关,那么幂等和并发到底有什么关联呢?两者的联系是:幂等解决的是重复执行的问题,重复执行既有串行重复执行(例如定时任务),也有并发重复执行。如果重复执行的业务逻辑没有共享变量和数据变更操作时,并发重复执行是没有副作用的,可以不考虑并发的问题。对于包含共享变量、涉及变更操作的服务(实际上这类服务居多),并发问题可能导致乱序读写共享变量,重复插入数据等问题。特别是并发读写共享变量,往往都是发生生产故障后才被感知到。 所以在并发执行的维度,将并发重复执行变成串行重复执行是最好的幂等解决方案。支付宝最常见的方法就是:一锁二判三更新,如下图。当一个请求过来之后:一锁,锁住要操作的资源;二判,识别是否为重复请求(第一部曲要定义的问题)、判断业务状态是否正常;三更新:执行业务逻辑。 Q&A 小A:锁可能造成性能影响,先判后锁再执行,可以提升效能。大明:这样可能会失去防并发的效果。还记得double check实现单例模式吗?在加锁前判断了下,那加锁后为啥还要判断下?实际上第二次check才是必须的。想想看?小A画图思考中...小A:明白了,一锁二判三更新,锁和判的顺序是不能变的,如果锁冲突比较高,可以在锁之前判断下,提高效率,所以称之为double check。大明:是的,聪明。这两个场景不一样,但并发思路是一样的。 private volatile static Girl theOnlyGirl; // 实现单例时做了 double check public static Girl getTheOnlyGirl() { if (theOnlyGirl == null) { // 加锁前check synchronized (Girl.class) { if (theOnlyGirl == null) { // 加锁后check theOnlyGirl = new Girl(); // 变更执行 } } } return theOnlyGirl; } 锁的实现可以是分布式锁,也是可以是数据库锁。分布式锁本身会带来锁的一致性问题,需要根据业务对系统稳定性的要求来考量。支付宝的很多系统是通过在业务数据库中新建一个锁记录表来实现业务锁组件,其分表逻辑和业务表的分表逻辑一致,就可以实现单机数据库锁。如果没有锁组件,悲观锁锁住业务单据也是可以满足条件的,悲观锁要在事务中用select for update来实现,要注意死锁问题,且where条件中必须命中索引,否则会锁表,不锁记录。 并发维度几乎是一个分布式幂等的通用分析维度,所以一个通用的锁组件是很有必要的。但这也只是解决了并发这一个维度的副作用。虽然没有了并发重复执行的情况,但串行重复执行的情况依旧存在,重复执行才是幂等核心要解决的问题,重复执行如果还存在其它副作用,幂等问题就是没有解决掉。 加锁后业务的性能会降低,这个怎么解决?笔者认为,大多数情况下架构的稳定性比系统性能的优先级更高,况且对于性能的优化有太多地方可以去实现,减少坏代码、去除慢SQL、优化业务架构、水平扩展数据库资源等方式。通过系统压测来实现一个满足SLA的服务才是评估全链路性能的正确方法。 第三部曲:识别细粒度副作用,针对性设计解决方案 在解决了部分维度的副作用之后,就需要针对剩余维度存在的细粒度副作用进行逐一识别并解决了。在数据质量维度上,最大的一个副作用是重复数据。在交互维度上,最大的一个副作用是业务乱序执行。一般这类问题不设计成通用组件,可以开发人员自由发挥。本节用两个常见方案做为例子。 方案举例1:唯一性约束避免重复落库 在数据表设计时,设计两个字段:source、reqNo,source表示调用方,seqNo表示调用方发送过来的请求号。source和reqNo设置为组合唯一索引,保证单据不会重复落两次。如果调用方没有source和reqNo这两个字段,可以根据业务实际情况将请求中的某几个业务参数生成一个md5作为唯一性字段落到唯一性字段中来避免重复落库。 核心逻辑如下: try { dao.insert(entity); // do business } catch (DuplicateKeyException e) { dao.select(param); // 幂等返回 } 这里直接insert单据,若果成功则表示没请求过,举行执行业务逻辑,如果抛出DuplicateKeyException异常,则表示已经执行过,做幂等返回,简单的服务通过这种方式也可以识别是否为重复请求(第一部曲)。 利用数据库唯一索引来避免重复记录,需要注意以下几个问题:(1)因为存在读写分离的设计,有可能insert操作的是主库,但select查询的却是从库,如果主备同步不及时,有可能select查出来也是空的。(2)在数据库有Failover机制的情况下,如果一个城市出现自然灾害,很可能切换到另外一个城市的备用库,那么唯一性约束可能就会出现失效的情况,比如并发场景下第一次insert是在杭州的库,然后此时failover将库切到上海了,再一次同样的请求insert也是成功的。(3)数据库扩容场景下,因为分库规则发生变化,有可能第一次insert操作是在A库,第二次insert操作是在B库,唯一索引同样不起作用。(4)有的系统catch的是SQLIntegrityConstraintViolationException,这个是完整性约束,包含了唯一性约束,如果未给一个必填字段设值,也会抛这个异常,所以应该catch键重复异常DuplicateKeyException。对于第(1)个问题,将insert 和select放在同一个事务中即可解决,对于(2)和(3),支付宝内部为了应对容量暴涨和FO,设计了一套基于数据复制技术的分布式数据平台,这个case笔者了解不深,后续有机会再讨论。 小A:如果我用唯一性约束来保证不会落重复数据,是不是可以不加锁防并发了? 大明:两者没有直接关系,加锁防并发解决的是并发维度的副作用问题,唯一性约束只是解决重复数据这单个副作用的问题。如果没有唯一性约束,串行重复执行也会导致insert重复落数据的问题,唯一性约束本质上解决的是重复数据问题,不是并发问题。 方案举例2:状态机约束解决乱序问题 一个业务的生命周期往往存在不同的状态,用状态机来控制业务流程中的状态转换是不二之选。在实际业务中单向的状态机是比较常用的,当状态机处于下一个状态时,是不能回到前面的状态的。以下场景经常会用到状态机做校验:(1)调用方调用超时重试。(2)消息投递超时重试。(3)业务系统发起多个任务,但是期待按照发起顺序有序返回。 对于这种类问题,一般是在处理前先判断状态是否符合预期,如果符合预期再执行业务。当业务执行完成后,变更状态时还会采取类似于于乐观锁的方式兜底校验,例如,M状态只能从N状态转换而来,那么更新单据时,会在sql中做状态校验。 update apply set status = 'M' where status = 'N' 如果状态被设计成可逆的,就有可能产生ABA问题。即在update之前,状态有可能做过这样的变更:N -> M -> N。所以状态机设成单向流转是比较合理的。 四 总结 本文首先引出了幂等的定义:相同请求无副作用,然后提出了设计幂等方案的三部曲,并举例说明。设计者要能够清晰地定义相同请求,并且采用通用组件减少一些副作用的分析维度,再针对具体的副作用设计相应的解决方案,直至没有任何副作用,才是真正完备的幂等解决方案。在实际业务中,实现三部曲不一定是严格的先后顺序,但只要按照这三部曲来构思方案,必能开拓思路,化繁为简。