什么要实现分布式path锁(服务的多实例及并发下触发path不一致的场景,包括同级目录下不能重名)
什么并发场景会触发path不一致
(1)更新并发冲突(两个用户同时更新的时候)
比如存在目录/d1,/d1/d2_1,/d1/d2_2,/d1/d2_3
用户同时修改目录d1的名称
目前的逻辑:通过/d1前缀匹配获取d1的所有子目录,假设两者几乎同步,然后修改上诉子目录的path,假设甲修改为d1_甲,已修改为d1_已
最后可能出现的现象:/d1_甲, /d1_甲/d2_1, /d1_已/d2_2, /d1_已/d2_3
现在的path是有问题的
(2)更新插入并发冲突
比如存在目录/d1,/d1/d2_1,/d1/d2_2,/d1/d2_3
用户甲修改d1为d1_甲,已用户在/d1/d2_3下添加新目录d3_1
可能会出现的现象/d1_甲, /d1_甲/d2_1, /d1_甲/d2_2, /d1_甲/d2_3, /d1/d2_3/d3_1
这样的最后一个path是有问题的
(3)并发插入目录冲突
比如用户甲,已同时在/dir1 下插入新目录dir2
两种几乎同时判断db是否重名(同时通过,因为dir2还没入库),出现path重名
解决思路:
(1)分布式悲观锁,假设上面的树是测试计划plan1的树(testplanId:plan-001,全局唯一)
只有对上面的树操作->获取锁plan-001->成功->更新
但是上述思路会造成大量的伪冲突,比如说同时修改dir2_1和dir3_5是不会冲突的
(2)分布式乐观锁
什么情况下会冲突,当两个节点的更新修改的节点有重合部分(对应1),或者插入更新节点有重合部分(对应2)同一个父测试套节点的更新才会用冲突(对应3)
下面设置一个结构体来实现:借用读写锁的思路
我们给path节点状态标识:重入或者独占
rule1:若当前节点为无状态,也就是重入数为0,而且也不是独占状态,则改节点为无状态,无状态节点可独占,可重入(重入数+1)
rule2:若当前path节点状态为重入,则该节点可继续重入,重入数+1,但是不可独占
rule3:若当前path节点状态为独占,则改节点不可独占,也不可重入
可以用数据结构表示为
type Path struct {
Path string //路径
ReentrantNum //重入数
IsSync //是否被独占
}
下面我们对节点做独占或者重入表示为获取节点资源
1)更新场景:当我们要对dir3_1节点做更新操作,path为:/dir1/dir2_1/dir3_1
获取节点资源:
尝试对当前节点path占用/dir1/dir2_1/dir3_1(防止1,2,3),对父亲节点path占用/dir1/dir2_1(防止2,3),对其他节点/dir1重入(防止伪冲突,也防止了1和3)
有一个节点资源获取失败,报冲突,释放以获取资源,都成功了,执行更新操作
2)插入场景:当我们要在dir3_1节点下插入新节点,path为/dir1/dir2_1/dir3_1
获取节点资源:
尝试对父亲节点/dir1/dir2_1/dir3_1独占,对其他节点/dir1/dir2_1, /dir1重入
有一个节点资源获取失败,报冲突,释放以获取资源,都成功了,执行插入操作
因为服务是多实例部署的,因此需要保证上述操作的进程并发安全性,原子性
实现上述方案可用的工具(借助第三方存储redis,zookeeper)
要借助第三方存储实现的功能:
(1)获取节点资源,也就是执行rule1,rule2,rule3时需要保证并发安全性(reids或者zookeeper提供上述原子性操作,或者通过分布式锁来实现)
(2)要考虑服务重启(宕机带来的问题),比如某个服务实例获取节点资源(重入/dir1),然后服务挂了,宕机了,要保证之前重入的资源要释放掉
--------------------------------------------------------------------------------------------------------------------------------------------------------------------
zookeeper实现方案:
zookeeper的数据模型也是树,完美切合path场景,而且具有同级目录不能同名的优点,一次只能创建一级目录
1.测试套及目录同级目录不能同名以及并发下导致path不一致的现象
(1)针对修改的场景:
比如dir3: 当前path:/dir1/dir2/dir3, 把dir3修改成dir3_new
尝试创建目录/dir1,/dir1/dir2,/dir1/dir2/dir3(最大深度为15(O(1)时间操作))
都能成功,抢占锁资源成功
失败,释放掉获取的锁资源(也就是被别人占用了),提示并发修改冲突
获取资源成功的场景:判断同级目录重名->执行修改操作->释放锁资源(删除目录)
当然上诉还需要加一个CAS(Compare and Swap逻辑),因为存在可能通过id获取到path时后一瞬间,另外一个用户更新成功了,因此获取到锁资源后,判断path是否在获取锁资源过程中被修改,如果修改了,释放资源,获取新path,获取资源,判断path是否修改
(显然这是一个while(true)循环,直到成功,可以设置尝试次数)
(2)针对插入的场景,同修改
比如在dir2,path为/dir1/dir2 添加dir3,处理流程如同修改
上述方案会造成一点伪冲突现象(插入的冲突应该是互不影响的,但是按照上述方案确冲突了,应该只考虑插入和更新的相互影响,更新和更新的相互影响,优化方案后续完善)
考虑不同测试计划(不同分支下一定不会相互影响),所有要加前缀路径作为命名空间,而且是统一的命名规范,用来实现分段锁逻辑,比如
/project1(testcenterdev)/master/dirlock(代码创建) 后面才是具体的路径(只是示例,后面具体讨论)
2.测试计划在一个项目下(工作区下)不能同名,用例库分支名(一个用例库不能同名),这个只要做同级目录不能同名的分布式锁
原理如同1,但是命名空间不一样
如测试计划:
/testplan/planlock 后面加工作区下
比如修改测试计划plan1,工作区为testcenterdev
代码流程:zookeeper尝试创建目录/testplan/planlock/testcenterdev(乐观式控制并发)->判断重名(数据库层面)->测试套修改逻辑->创建测试计划->释放锁资源删除目录
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
redis实现方案
整体方案(1) 采用哈希表(hash)数据结构,map[string][string]value(其中value值在优化时会有作用),最外层的key是命名空间,用于区分设计计划,用例库等不同服务及不同测试计划和不同用例库(实现分段锁逻辑),采用命令HSETNX:key值不存在才创建
为什么命名空间包含测试计划Id,因为不同测试计划下是不会相互影响的,就是为了减少冲突,把锁资源分段
(1)命名空间规划(只是实例,具体怎么命名待讨论)
比如测试计划(id为testcenter-1200)的测试套的修改
testplan_suit_update_testcenter-1200(测试计划id)
比如测试计划(项目Id也就是工作区为testcenterdev)自身名称的修改,第二级key为工作区(testcenterdev)
testplan_plan_update
(2)针对修改场景,实现方案类似zookeeper,测试计划id为testplan_01
比如dir3: 当前path:/dir1/dir2/dir3, 把dir3修改成dir3_new(最多15层,最多发送15次,优化考虑一次发送15次请求,待研究)
1)HSETNX testplan_suit_update_testplan_01 /dir1 1
2)HSETNX testplan_suit_update_testplan_01 /dir1/dir2 1
3)HSETNX testplan_suit_update_testplan_01 /dir1/dir2/dir3 1
如果都设置成功了,意味着获取资源成功,其中某一步失败了,要把获取的资源释放掉
比如说1),2)成功了,但是3)失败了,提示并发修改冲突, 执行HDEL testplan_suit_update_testplan_01 /dir1 /dir1/dir2 释放资源
同理,这里也要加一个CAS
获取资源成功后->判断重名校验(走db)—> 修改->释放资源
(3)插入场景类似修改
(4)测试计划(工作区为testcenterdev)自身名称修改(会伴随测试套更新走1逻辑)
测试计划自身逻辑为
1)HSETNX testplan_plan_update testcenterdev 1
成功后—>重名校验->走测试套更新逻辑->修改->释放资源
整体方案(2)不推荐(HGETALL会拿到大量数据,造成一定的阻塞,rula脚本通过)
采用哈希表,通过rula脚本及命令HGETALL获取所有的key,value,在通过path前缀匹配来判断资源冲突(rula脚本是为了实现操作的并发下安全,不一定能行,只是一个实现思路,待讨论并且性能上会带有一定的阻塞)
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
优化伪冲突
按照上诉redis的方案1,会有伪冲突
比如:
插入和插入的伪冲突:
比如在/dir1/dir2 和/dir1/dir3下同时插入新目录,这是不会相互影响的,但是按照上述方案冲突了
更新和更新的伪冲突:
比如在/dir1/dir2/dir3 的dir3修改为dir3_new, 在/dir1/dir2/dir4/dir5 的dir5修改为dir5_new ,这是不会相互影响的,按照上述方案冲突了
为了解决这个问题引入两个概念:
独占节点:当节点为独占时,不可重入,不可在被占用
可重入节点:当节点为重入时,可重入,不可独占
value值作用及含义:
0:表示没有占用
-1:表示独占
>1:表示重入了多少次
优化redis方案1
当把/dir1/dir2/dir3 的dir3修改为dir3_new
需要把当前节点设置为独占/dir1/dir2/dir3(并发写冲突), 把父亲节点设置为独占(同级目录不能重名),剩下的节点设置为重入(避免伪冲突)
因此释放资源就不是上述的直接删除节点了,独占的可以删除,重入的是把value值减1,当value值为0时可以删除
上诉优化方案意味着要保证判断节点是否存在,对节点的value值做处理及判断等,这些操作也是要保证并发安全性的(redis没有提供复杂的原子性命令,或许可以通过rula脚本,pipline或者事务实现,下面介绍第三中实现)
具体采用哪一种需要一起讨论下,下面介绍第三种实现
对一个节点做是否存在,对其value值做操作,删除key节点(避免key值太多,当value为0时是可以删除的)等一系列操作,需要先获取改节点权限(也就是锁)
这个而通过zookeeper的临时节点实现(和会话绑定,会话关闭则节点自动删掉,避免死锁),也可以通过redis实现(设置超时,避免服务崩溃导致的死锁),下面介绍redis的实现方案
类似上面redis方案1
采用set数据结构(当然也可以采用hash)
Redis Setnx(SET if Not eXists) 命令在指定的 key 不存在时,为 key 设置指定的值。
利用setnx命令实现分布式锁
为了区分与上面资源容器的区别,命名空间需要好好设计
如果要修改测试计划(plan-01)下,/plan1/suit1/suit2 的suit2改为suit2_new
要把/plan1/suit1/suit2,/plan1/suit1的节点设置为独占,把/plan1/suit1/suit2节点的重入数+1
设置/plan1/suit1/suit2节点状态之前,需要获取设置该节点的锁
(1)命名空间(举列子,后面需要好好设计)
Setnx testplan_suit_common_lock_plan-01 /plan1/suit1/suit2
设置成功了表示获取到了锁,尝试修改节点状态
(2)冲突情况
当节点为独占时, 在对该节点独占或者重入则冲突,提示同时修改冲突
当节点为重入时,在对该节点独占则冲突,对该节点重入则value值+1
上述方案如何避免某个实例宕机(导致资源锁没释放)或者redis某个实例宕机,重新选举master(使得某个获取锁的写数据没有同步到slave节点等问题)
需要在讨论下
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
redis实现方案总结:
1.redis分布式锁+资源状态标记实现(需要考虑某个实例宕机导致的死锁问题)
2.redis lua脚本+资源状态标记实现
3.redis事务+watch+资源状态标记实现(待讨论,redis的事务有坑,而且能否实现需求有待讨论研究)
综上所述:也就两种方案
(1)(分支锁(测试计划级别的锁),会有伪冲突)
当某个分支下的目录并发更新或者插入,都悲观的认为会有冲突
redis 的setNx + 超时机制 + 客户端(随机码)来实现
链接:https://baijiahao.baidu.com/s?id=1623086259657780069&wfr=spider&for=pc
(2)redis分布式锁(lua脚本)+资源状态标记实现(需要考虑某个实例宕机导致的锁资源不释放问题),但是目前没法解决服务宕机问题(或者服务重新部署带来的问题)
场景分析:修改dir3_1
独占节点dir3_1,重入节点dir2_1和dir1,为啥dir2_1和dir1需要重入,就是为了实现dir3_1 和dir1的并发修改冲突,但是dir3_1和dir3_4的并发修改不冲突
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
zookeeper实现方案总结(通过临时节点解决实例宕机导致的锁资源不释放的问题)(推荐)
资源标记(重入+独占)+分布式锁(对资源操作的并发安全性)+临时节点实现
zookeeper临时节点特性:会话关闭,节点就删掉(可以用这个特性实现资源释放及防止宕机)
testplanId:plan-0001
suitId:suit-001,name:dir3 修改为dir2_new
通过suitId获取path,当前path:/dir1/dir2/dir3, 把dir3修改成dir3_new
按照上述可以避免伪冲突的情况,需要把节点/dir1/dir2/dir3独占,把节点/dir1/dir2独占,把节点/dir1重入
考虑命名空间:节点后可以添加测试计划ID,变成/dir1/dir2/dir3_plan-0001独占,把节点/dir1/dir2_plan-0001独占,把节点/dir1_plan-0001重入
如何用zookeeper实现重入和独占逻辑(zookeeper节点名称不支持/,可以把节点处理下,比如_dir1_dir2_dir3_plan-0001)
/testcenter/testplan/suitnode/sync
用于保存独占节点
/testcenter/testplan/suitnode/reentrant
用于保存重入几点
独占场景:对节点_dir1_dir2_dir3_plan-0001做独占:
(1)判断节点是否重入,重入了报冲突(判断/testcenter/testplan/suitnode/reentrant/_dir1_dir2_dir3_plan-0001是否存在,不存在或者子节点的数量为0表示没有重入)
获取子节点数量 ls /testcenter/testplan/suitnode/reentrant/_dir1_dir2_dir3_plan-0001 返回的是列表,判断列表长度
(2)create -e /testcenter/testplan/suitnode/sync/_dir1_dir2_dir3_plan-0001
直接在/testcenter/testplan/suitnode/sync节点下创建临时节点_dir1_dir2_dir3_plan-0001,创建成功则成功独占,失败意味着已经被其他用户独占
重入场景:这个比较复杂和麻烦,应为节点可以被多个客户端重入,对节点_dir1_dir2_dir3_plan-0001重入,这里需要用到临时顺序节点,重入编号让zookeeper给我们生成
当然也可以使用临时节点,自己生成重入编号(保证不会重复并全局唯一就行)
(1)判断该节点是否已经被独占,如果已经被独占,冲突,如果
(2)判断目录是否存在/testcenter/testplan/suitnode/reentrant/_dir1_dir2_dir3_plan-0001,不存在则创建
(3)创建重入编号 create -es /testcenter/testplan/suitnode/reentrant_dir1_dir2_dir3_plan-0001/(改命令会自动创建一个节点/testcenter/testplan/suitnode/reentrant_dir1_dir2_dir3_plan-0001/00001)
当然上诉方案有两个问题:
(1)定时删除 重入节点/testcenter/testplan/suitnode/reentrant/_dir1_dir2_dir3_plan-0001什么时候可以删除,防止节点数过多(凌晨启动定时任务,当/testcenter/testplan/suitnode/reentrant/下面的节点的子接点数为空,删除)
ls /testcenter/testplan/suitnode/reentrant 获取到子节点列表 [_dir1_dir2_plan-0001, _dir1_dir2_plan-0002]
遍历上述列表:
获取节点锁(_dir1_dir2_plan-0001)->判断该节点是否存在(stat /testcenter/testplan/suitnode/reentrant/_dir1_dir2_plan-0001)->判断该节点下是否存在子节点(ls /testcenter/testplan/suitnode/reentrant/_dir1_dir2_plan-0001 返回的列表)->判断列表长度是否为0->是->删除该节点->释放锁节点
(2)需要分布式锁
保证重入场景(1)(2)(3)的原子性也就是并发安全性,对资源节点的操作是并发安全的,因此还需要加分布式锁(也通过zookeeper实现)
也就是前面说的获取节点锁,下面说的是用临时节点实现的非阻塞的场景,也能减少节点数量
在目录/testcenter/testplan/suitlock下创建临时节点
获取节点锁(_dir1_dir2_plan-0001): create -e /testcenter/testplan/suitlock/_dir1_dir2_plan-0001 成功则获取锁,失败可以尝试一定的次数+时间(比如0.5s试一次,尝试4次),如果失败返回并发冲突
当然获取锁的流程需要加上CAS操作 获取成功->通过suitId获取path->判断path是否变更(有可能在获取_dir1_dir2_plan-0001操作权限的一瞬间另外一个用户更新完毕了)->变更(/dir1/dir2_new)->释放锁资源_dir1_dir2_plan-0001(delete)->尝试获取_dir1_dir2_new_plan-0001的操作权限(原始节点)
显然上面是一个while(true),也就是CAS逻辑,可以设置最大尝试次数
1.测试套更新场景描述:
实现思路:对当前path及父path独占(如果是顶层节点,则对当前path独占),其他节点重入
testplanId:p-1
testsuitId:s-1
name:suit3
path:/suit1/suit2/suit3(通过testsuitId获取)
按上述规则转换:_suit1_suit2_suit3_p-1
将上诉测试套name改成suit3_new
代码流程:获取节点_suit1_suit2_suit3_p-1锁->尝试对该节点独占->获取_suit1_suit2_p-1节点锁->尝试对该节点独占->获取_suit1_p-1节点锁->尝试对该节点重入
如果都成功了,成功获取资源,进行更新操作
2.测试套插入场景描述:
类似更新:
实现思路,对父亲节点独占,其他节点重入
以上就能实现测试套所有插入修改导致的并发一致性问题
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
下面讨论测试计划同一个项目下不能重名
这边简单通过分布式锁实现(类似上面的测试套分布式锁,只是区分开会好点,可扩展,原理是一样的,只是在不同的目录下,也就是命名空间不同)
不同的是没有CAS操作,英文工作区ID获取项目ID不会变
租户为:root(目前是没有多租户的,加进去可扩展)
项目ID为:testcenterdev
根目录为:/testcenter/testplan/planlock
获取锁操作:create -e /testcenter/testplan/planlock/root_testcenterdev ,失败可以尝试一定的次数+时间(比如0.5s试一次,尝试4次),如果失败返回并发冲突
3.新建测试计划场景
租户为:root
项目ID为:testcenterdev
获取计划锁(create -e /testcenter/testplan/planlock/root_testcenterdev)->成功则进行插入
4.修过测试计划名称(需要递归修过测试计划所在树的path)
租户为:root
项目ID为:testcenterdev
测试计划名称:plan1 修过为plan_new
获取计划锁(create -e /testcenter/testplan/planlock/root_testcenterdev)->1测试套更新场景(修过顶层虚拟套)->都成功了则更新测试计划的名称
(这里有个问题,就是path更新成功了,但是测试计划更新接口失败了,还有一个回退操作)
如何解决这个问题:
(1)目前测试计划这边name path并没有多大的作用->转成id path(转成id path后几乎解决了所有问题,只需要考虑测试计划锁)
(2)获取像用例库一样,测试计划对应虚拟套(顶层套)的为path"",而不是/+测试计划名称(这个改动意味着所有通过path做递归查询的接口需要加测试计划ID做过滤)
(3)保持不变,需要考虑回退操作
---------------------------------------------------------------------------------------------------------------------------------------------------------------------
特别注意:不能使用池子,但是要控制并发的数量(防止并发量太大把zookeeper的连接用完了)
这个实现后面再完善,可以先不考虑
因为上述方案都是用临时节点实现的,通过会话的关闭来实现锁的释放或者重入次数的释放,所有每一个请求如果涉及到上诉对zookeeper的操作,应该调用一个接口获取一个zookeeper会话
这个接口要实现的功能是:(比如)