微服务架构实战笔记--分布式锁问题

业务层面幂等

冗余部署多个进程
存在并发消费的可能性
并发转变成串行消费

发送端会发送很多次,消费端可能会消费很多次,需要做去重,对共享资源做去重,其实也是一个分布式锁的问题

本质
分布式锁问题

分布式锁设计与实践

分布式锁的定义
分布式环境下,锁定全局唯一资源
请求处理串行化
实际表现互斥锁

分布式锁的目的

交易订单锁定
防止重复下单
解决业务层幂等问题

MQ消息消费幂等性
发送消息重复
消息消费端去重
比如手机提现

在用户对商品下单后,订单状态为待支付,在某一时刻用户正在对该订单做支付操作,商家对该订单进行改价操作

状态的修改行为需要做串行处理,避免出现数据错乱

基于Redis 分布式锁

基于Redis 分布式锁方案

1、唯一线程串行处理

2、实现方式
2.1 Redis Setnx(SET if Not eXists) 命令在指定的key不存在时,为key 设置指定的值

SETNX KEY_NAME VALUE Expire_Time
设置成功,返回1 设置失败 返回0

如果Redis 是单机的话很危险,比如T1 在redis上加了一把锁,这时候Redis挂了,然后重启,这时候T2也对相同资源加了一把锁,此时就有两把锁, 当然你对锁做持久化,但是从架构层面上来讲是没有必要的,因为事务的生命周期比较短。

2.2 存在问题
锁时间不可控
无法续租期
redis锁依赖于失效时间 expire,比如失效时间为5秒钟,但是5秒钟我的事务还没执行完,锁失效了,另外一个线程又拿到了这把锁,又得重复去做这个事情。
单点问题
单实例存在进程一旦死掉,会彻底阻塞业务流程
主从方式,主从数据异步,会存在锁失效问题

2.2.1、第一种情况单机如果挂掉的话,如果你的服务强依赖redis,redis 还没起来的话,会阻塞业务流程。

2.2.2、第二情况 T1写到主redis,如果redis 主挂了,还没有同步到从redis ,根据哨兵原理,从redis会切换为主,T2会从新的主去拿,此时T2也会重新生成一把锁,这种情况下,T1、T2都会拿到这把锁。这样就会出现重复锁的问题

那么基于Redis分布式锁到底能不能使用?取决于你的业务场景决定的。
如果你的业务是交易场景,严格要求他的一致性,极端情况下,锁重复问题一定是不能忍的。
如果是社交发消息场景,极端情况下,如果锁重复的话,发消息重新消费一次,也是能接受的。

redis 作为锁
高可用无法保证

问题本质,分布式锁是CP模型,Redis集群是AP模型,Redis是半同步的。 所以要通过CP模型解决 Redis 自己实现了一套RAFT协议:redlock

3、官方建议
Redis 本身建议使用Redlock 算法来保证,但是问题是需要至少三个Redis主从实例来完成,维护成本相对比较高,Redlock等同于自己实现简单的一致性协议,细节繁琐,且容易出错。

高可用分布式锁设计目标

设计目标
1、强一致性
其实是任何锁都要做的事情
2、服务高可用、系统稳健
要保证服务的高可用,不能因为一台机器宕机了就不可用了
3、锁自动续约及其自动释放
要有能力,在业务无感知的情况下,做自动的续约,比如Redis 锁过期时间是5秒钟,当我的事务5秒钟还没有结束的时候,自动再续约5秒钟。
4、代码高度抽象业务接入极简
当然也希望我的代码比较抽象,接入也比较简单,
5、可视化管理后台,监控及管理

高可用分布式锁设计方案对比

存储层产品对比

redis zookeeper etcd
一致性算法 paxos(zab) raft
CAP AP CP CP/AP
高可用 主从 N+1可用 N+1可用
接口类型 客户端 客户端 http/grpc
实现 setNX createEphemeral restful API

zookeeper 为什么是n+1 是因为是奇数个,etcd 客户端比较糟糕

1、由于Redis 无法保证数据一致性
2、Zookeeper对锁实现使用创建临时节点和watch机制,执行效率、扩展能力、社区活跃度等方面低于etcd
3、选择基于etcd 实现 tidb也是基于 etcd来实现的

分布式锁存储选型

etcd
简单KV
强一致
高可用--无单点
数据高可靠--持久化

分布式锁整体方案

分布式Client+etcd
clientTTL模式

Client TTL模式
1、ClientA->etcd->("key","ttl","value","uuid")

2、ClientB->etcd->("key","ttl","value","uuid");

etcd 只需要填 key ttl ,value随便填,对于锁来说value无所谓,uuid不需要填,当你成功拿到锁以后,etcd集群会给你生成一个uuid,这个uuid其实就是你的锁的唯一的凭证,接下来所以对锁的操作,都是基于这个uuid来实现的。

3、ClientA 拿锁成功,ClientB 拿锁失败

4、A服务需要对etcd 保持后台心跳线程,比如key的租期是10ms,后台心跳线程为3ms,心跳线程负责在拿到key之后,每3ms cas 唯一凭证uuid;

比如key的租期是10ms,那么我们的心跳往往会设置租期的1/3的时间,也就是3ms,每次心跳的就是就会续约租期,当心跳到的时候,租期还有10-3=7ms,这时候我会刷新ttl改成10ms,相当于又增加了3ms。
什么时候释放呢,这个时候需要业务方主动去释放这个锁

使用场景一:申请锁

1、业务放申请资源锁,调用时提供key,ttl
2、etcd生成uuid,作为当前锁的唯一凭证,将(key,uuid,ttl)写etcd
3、检查etcd中此key 是否存在,如没有,尝试写入key,写入失败,拿锁失败,写入成功拿到锁。
4、拿到锁后,客户端异步心跳线程启动,心跳线程维持时间为ttl/3,compare and swap uuid ,从而将key 值续租

5、相关etcd API
a、申请锁
curl http://127.0.0.12379/v2/keys/foo -XPUT -d value=bar -d ttl=5 prevExist=false;

b、CAS更新锁租约
curl http://127.0.0.12379/v2/keys/foo?prevValue=prev_uuid -XPUT -d ttl=5 refresh=true prevExist=true;

c、CAS删除锁
curl http://127.0.0.12379/v2/keys/foo?prevValue=prev_uuid -XDELETE;

使用场景二:申请锁,但锁已被持有

1、业务方申请资源锁,调用时提供key.ttl
2、检查etcd中key的存在,若已存在,拿锁失败

使用场景三:锁的清理

1、如果调用方正常结束,通过cas 接口调用delete方法自动清理etcd中的key值
2、如果调用方异常终止,等待原有锁ttl 过期后,锁资源释放。

如果没有调用 delete方法,心跳超时以后,过时间过期后,锁会自动释放。

业务接入

JDK 7 及以上
获取锁示例

try(zzlock=zzlock.getlock("resource_id",ttl)){
  //jobs
  zzlock.isTrue();
}

//释放锁示例
Optional lockItem=getlock("key");

if(lockItem.isPresent()){
   
   System.out.println("获得锁,如果进程被终止,锁会在10s后失效!");
   System.out.println("如果进程继续,后台心跳线程更新3s锁时间");
   
   releaseLock(lockItem.get());
}else{
   System.out.println("获取锁的失败!!!");
}

获取锁平均耗时监控

1、下面是获取锁的平均耗时情况
a、获取锁的平均耗时大概是在2.1ms左右
b、由于etcd的强一致性,根据raft算法,消耗时间稍微长一点

etcd兼容性测试

etcd提供独有的集群管理模式,方便进行极端case下的测试,以三个节点的etcd集群为例
a、单节点停机,不影响持续写入,不影响读,结果有一致性
b、当只有一个节点时,读会停机,写入正常
c、理论上只要不是多节点同时停机,线上服务不会受影响

etcd 恢复/版本

1、etcd 有自有的数据恢复方式,如果服务停机后,可以将所有数据转移重启
2、etcd的增删节点,节点迁移等部署相关,均有相关操作方式
3、etcd 版本选择,选择使用etcd 3.2.9 ,但是因为V3 API 暂时还不够完备,建议用V2 方式实现
a、V3提供gRPC接口
b、天然提供分布式锁功能,只需申请锁,释放锁,不用关注锁的租期问题。

分布式锁特殊场景

1、特殊场景一:分布式锁只是在同一自然时间的互斥锁,本身不解决幂等性问题;接入业务需要完善从获得锁到释放锁中间的数据幂等逻辑。

例如:T1 拿到lock 很快就处理完成了,T2 也拿到lock做同样的操作,也是可以的。所以它不能解决幂等问题,只是同一自然时间的互斥锁。

T1 拿到订单了进行业务处理,T2也拿到了订单也进行业务处理,要做幂等只需要判断订单的状态,比如这个订单已经支付了,很显然就不能重复支付。这个幂等只能业务方去处理,分布式锁不会帮你去解决这个问题。

2、特殊场景二:锁没有按照预期续租
a、心跳续租没成功
b、马上启动GC,GC时间够长

缺点1:etcd的租期为10秒钟,这时候每次心跳3秒去续租,这时候网络异常,有可能续租没有成功。

缺点2:etcd 租期为10秒,这时候服务器执行FullGC,这个FullGC 是11秒钟,当然GC 要11秒钟,你的服务器可能要优化,每3秒的时候就在执行续租的时候,GC导致业务方的请求暂停了,当你停止以后已经11秒过后了,这时候锁已经被释放掉了。

3、特殊场景三:etcd内部协调发生问题
a、Leader节点挂了,选主中
选主这个过程中,是停止响应的,是没办法拿到锁的

b、Raft日志数据同步发生错误或者不一致问题
当Rfat 发生同步错误,因为它是CP模型,这时候不会让你写或者读。

consul 也是cp模型 consul 生态没有etcd 庞大

为什么k8s 选中etcd 作为它的整个的注册中心?
k8s 没有好的选择,k8s 当你的集群特别大的时候,当你的日志就是你的事件需要写的时候,etcd 就会成为你的瓶颈,这时候要怎么办,要定期清理你的etcd事件的同步。

幂等需要两个层次,一是不并发;二是重复消费结果一样;分布式锁的作用就是“串行”;

Redission里实现的分布式锁初始过期时间也是30秒

一台机器 耗时 2.1ms qps=500

当业务线程假死,ttl线程还在跑,这个只能ttl超时了自动释放

线上锁的最长时间是10秒钟

zk做分布式锁,因为是CP模型,当写入量很大的话会有问题?其实对锁还好,无非就是生成一把锁会写一次,心跳更新租约再写一次,delete锁再写一次;

如果业务阻塞了,假死了无法释放锁,此时心跳还在,心跳还在就会自动续租。
这时候网关发现服务器假死,触发熔断,会发送通知给控制中心,控制中心重启服务,这时候心跳检测程序就没有了;
或者提供一个锁的最长超时时间,定义最大续约次数。

举个例子:有两个进程拿到相同的orderId,进程1拿到了锁,先处理了订单,完事之后更新状态,进程2拿不到锁,或通过检测订单的状态,来避免重复执行相同的操作。

数据一致性定义

任何人
任何时间
任何地点
任何接入方式
任何服务
数据都是一致

数据不一致性产生原因

1、数据分散在多处
a、多个DB;b、DB和缓存
2、电商交易平台案例
用户、商品、交易等功能

分布式事务场景

1、电商下单场景
a、下单
b、发送消息到MQ
2、一致性保证
a、本地事务
下单操作
发送MQ消息操作
放进一个本地事务
上述做法有什么问题?

java 实例代码

try{
    stmt=DriverManager.getConnection("jdbc:mysqlxxxx");
    stmt=conn.createStatement();
    
    //数据库生成订单操作
    stmt.executeUpdate("insert order values(orderId,timestamp,price,state)");
    
    //生成发送的消息内容
    MsgObject MsgContent(orderID);
    
    //发送消息操作
    MQClient.sendMsg(MsgContent);
    //事务提交
    conn.commit();  
}catch(Exception e){
   e.printStackTrace();
   
   try{
        //操作不成功则返回退
        conn.rollback();        
   }catch(Exception ex){
      ex.printStackTrace();
   }
}


分布式事务分类

1、刚性分布式事务
a、强一致性
b、XA 模型
XA模型是完全遵循ACID的高可用事务
c、CAP:CP

2、柔性分布式事务
a、最终一致性
b、CAP、BASE理论 AP

刚性分布式事务

1、满足传统事务特性
ACID(Atomicity-原子性、Consistency-一致性、Isolation-隔离性、Durability-持久性)

2、XA模型
a、XA是X/Open CAE Specification(Distributed Transaction Processing )模型定义,XA规范由AP、RM 、TM组成。
b、其中应用程序(Application Program,简称AP):AP定义事务边界(定义事务开始和结束)并访问事务边界内的资源。
c、资源管理器(Resource Manager,简称RM):RM 管理计算机共享的资源,资源即数据库等。
d、事务管理器(Transaction Manager ,简称TM):负责管理全局事务,分配事务唯一标识,监控事务的执行进度,并负责事务的提交、会滚、失败恢复等。

image.png

刚性分布式事务

1、案例:组织爬山

2、过程:
a、二阶段提交,是XA规范标准实现
b、TM 发起prepare投票
c、RM 都同意后,TM再发起commit
d、Commit过程出现宕机等异常,节点服务重启后,根据XA recover再次进行commit补偿

3、缺点
a、同步阻塞模型
b、数据库资源锁定时间过长
它的锁库时间很长
现在是两个库,所以同时要锁定两个库;
如果是100个库,那么就要同时锁定100个库;

c、全局锁(隔离级别串行化),并发低。
基于XA的分布式事务如果要严格保证ACID,实际需要事务隔离级别为SERLALIZABLE。这时候会锁表,这个隔离级别是非常粗的,所以并发也低。

d、不适合长事务场景

长事务就是一个事务里面操作很多步骤,比如大于3;所以2PC 比较适合短事务。

image.png

柔性分布式事务

1、CAP
分布式环境下P一定需要,CA权衡折中

2、BASE理论
a、Basically Available 基本可用
b、Soft state-柔性状态
c、Eventual consistency 最终一致性

3、架构思考
柔性事务是对XA协议的妥协,它通过降低强一致性要求,从而降低数据库资源锁定时间,提升可用性

4、典型架构实现
a、TCC模型
b、Saga模型

TCC模型

1、Try-Confirm-Cancel
2、TCC模型完全交由业务实现,每个子业务都需要实现Try-Confirm-Cancel接口,对业务侵入大

假如:用户购买流程:下单->减库存->支付,那么你每一个步骤都要实现一遍Try-Confirm-Cancel
对业务侵入很大,在业务层实现

a、资源锁定交由业务方
3、Try
尝试执行业务,完成所有业务检查,预留必要的业务资源
每次提交之前,都要去判断是否具备提交的必要条件

4、Confirm
真正执行业务,不再做业务检查
5、Cancel
释放Try阶段预留的业务资源
6、汇款服务、收款服务案例
A用户向B用户汇款500元

汇款服务:

a.Try

1、检查A账户有效性,即查看A账户的状态是否为“转账中”或者“冻结”;
2、检查A账户余额是否充足;
3、从A账户中扣减500元,并将状态置为“转账中”;
4、预留扣减资源,将从A往B账户转账500元这个事件存入消息或者日志中

b.Confirm:

1、不做任何操作

c.Cancel:

1、A账户增加500元;
2、从日志或者消息中,释放扣减资源;

收款服务

a.Try:

1.检查B账户账户是否有效

b.Confirm:

1.读取日志或者消息,B账户增加500元;
2.从日志或者消息中,释放扣减资源;

c.Cancel:

1.不做任何操作;

柔性分布式事务--Saga模型

1、起源于1987年Hector & Kenneth 发表的论文Sagas
2、Saga 模型把一个分布式事务拆分为多个本地事务,每个本地事务都有相应的执行模块和补偿模块(对应TCC的Confirm和Cancel)

2、当Saga 事务中任意一个本地事务出错时,可以通过调用相关的补偿方法恢复之前的事务,达到事务最终一致性。

3、当每个Saga 子事务T1、T2、...Tn 都有对应的补偿定义C1、C2,....Cn-1,那么Saga系统可以保证
a.子事务序列T1,T2,......,Tn得完成(最佳情况)
b.或者序列T1,T2,....Tj,Cj-1,.....C2,C1, 0

逆序串行执行,保证有序

Saga隔离性
1、业务层控制并发
a.在应用层加锁
b.应用层预先冻结资源等

Saga恢复方式
1、向后恢复:补偿所有已经完成的事务,如果任一子事务失败;
2、向前恢复:重试失败的事务,假设每个子事务最终都会成功;

刚性分布式事务VS柔性分布式事务

刚性事务(XA) 柔性事务
业务改造 有(需要改造)
回滚 支持 实现补偿接口
一致性 强一致 最终一致
隔离性 原生支持 实现资源锁定接口
并发性能 严重衰退 略微衰退
适合场景 短事务,并发较低 长事务,高并发

解决思路

问题通用解决思路

1、解决这个问题本身
2、让问题本身消失,圆珠笔笔芯漏油解决

方案一:从业务场景消除分布式事务
1、思路:核心业务先处理,其他业务异步处理

方案二:柔性分布式事务

柔性分布式事务

通用处理思路

1、本地事务->短事务
2、分布式事务->长事务
3、转变成多个短事务
4、案例
A[下单]->B[减库存]->C[支付]
A->DB1
B->DB2
C->DB3
A/B/C都成功
A/B 成功,C失败 补偿

业务场景

1、异步场景
基于MQ驱动分布事务

2、同步场景
基于异步补偿分布

异步场景分布式事务设计

异步场景
商品交易
下单、支付

image.png
方案一:业务方提供本地操作成功回查功能

1、事务消息:MQ提供类似X/Open XA 的分布式事务功能,通过MQ事务消息能达到分布式事务的最终一致
2、半消息:暂不能投递的消息,发送方已经将消息成功发送到了MQ服务端,但是服务端未收到生产者对该消息的二次确认,此时该消息被标记成“暂不投递”状态,处于该种状态下的消息即半消息

3、消息回查:由于网络闪断、生产者应用重启等原因,导致某条事务消息二次确认丢失,MQ服务端通过扫描发现某条消息长期处于“半消息”时,需要主动向消息生产者询问该消息的最终状态(Coommit或者是Rollback),即消息回查

MQ分布式事务设计方案图:

image.png

异步场景分布式事务设计

方案一:业务方提供本地操作成功回查功能

MQ分布式事务消息设计

a、MQ事务消息设计事务消息作为一种异步确保型事务,将两个事务分支通过MQ进行异步解耦,MQ事务消息的设计流程同样借鉴了两阶段提交理论,整体交互流程如下图所示:


image.png

1、事务发起方首先发送prepare消息到MQ;
2、在发送prepare消息成功后执行本地事务;
3、根据本地事务执行结果返回commit或者是rollback;
4、如果消息是rollback,MQ将删除该prepare消息不进行下发,如果是commit消息,MQ将会消息发送给consumer端;
5、如果执行本地事务过程中,执行端挂掉,或者超时,MQ服务器端将不停的询问producer来获取事务状态;
6、Consumer端的消费成功机制有MQ保证;

该方案的优缺点:

优点:通用
缺点:
1、业务方需提供回查接口,对业务侵入大;
2、发送消息非幂等;
3、消费端需要处理幂等;

消息不幂等的意思就是:
1、发送Half消息到MQ Server 成功;
2、第二步的时候Half 消息 ack MQ 发送方失败! 此时生产者会重复第一步,再次发送Half消息到MQ Server;

3、发送多次就会处理多次,所以存在消息不幂等的问题。

方案二

本地操作和发送消息通过本地事务强一致性

1、本地事务操作表
2、本地事务消息表
mqMessages(msgid,content,topic,status);

操作表和消息表放在同一个库,所以是Local Transaction 事务

image.png

比如:下订单流程,我们将下订单和下订单产生的消息放到同一个本地事务里面执行。

方案二优缺点:
1、发送端消息不幂等
At least once (至少一次,肯定都是这个,下面两个不要用)
Only once(只有一次)
At more once (最多一次)

2、消费端处理消息幂等
a、分布式锁

3、A->B->C
3.1、A/B成功,C失败;

比如: A->DB1 并写MQ;然后 MQ->B->DB2; MQ->C->DB3;
1、如果很幸运,B成功了,C也成功了,事务就成功了。
2、如果B成功了,C失败了,这时候C发一条会滚消息MQ;
3、然后这个补偿消息MQ要被A和B消费,这时候又回到了1的步骤。这样就会出现死循环。如下图所示


image.png

a、记录错误日志
b、报警
c、人工介入

4、优点:
业务侵入小;

其实现在大部分走的是这种模式。

实际异步场景使用较多的是方案二:本地事务消息表

基于半消息实现的“方案一”使用的比较少,现在开源的RockectMQ就是半消息的实现方案。

会滚MQ需要串行吗?
如果MQ的处理结果要告诉别人就要串行,否则无需串行,可以同时处理多个MQ会滚。

转钱有冻结状态,所以不一定最后一步做转钱动作,转钱冻结状态是无法提现的。

如果用户将平台的库存占住而不支付,怎么处理?
不支付是有时间限制的,比如30分钟,或者2个小时,这时候可以发送一个延迟消息,如果不支付就会被清空掉。
或者定时作业,定时扫描未支付的订单。

对于分布式事务,要尽可能得减少网络交互,网络交互越多,发生事务处理失败或者事务会滚的概率越大。

你可能感兴趣的:(微服务架构实战笔记--分布式锁问题)