前言

并发编程中,对共享资源进行访问时需要加锁。告诉操作系统这块资源访问操作时需要保证原子操作,其它线程会等待前面线程释放锁才可以继续访问。分布式事务中也会存在操作共享资源,也需要加锁。但是分布式事务是跨主机的,也就是不能使用操作系统的锁机制,因为操作系统的锁机制只能在本主机内生效。所以就需要在主机层面有仲裁机制,可以使用rediszookeeperetcd作为第三方仲裁机制来仲裁是哪个主机获得了锁,从而保证分布式共享资源并发访问的可靠性。

分布式锁需要满足的特点

独占性:锁的最基本特性,同一时刻只能一个事务获得锁。

可用性:保证锁是高可用的,不能因为某个仲裁主机挂了而导致整个锁系统失效。

防死锁:某个事务获取锁后因为程序挂掉,不能让其一直不释放锁,而导致整个分布式系统都不能使用。

不抢占:事务获取锁之后不能在被其它事务释放锁。

可重入:一个主机获取锁后在本线程中应该是还可以重复获取锁的,递归调用的时候不能出现死锁。

自动续期:事务获取锁后在执行业务期间,不能因为执行业务逻辑时间超过过期时间而导致锁释放。

手写分布式锁思路

一开始只是打算写锁的实现部分,但是写的过程中发现只写锁没有场景进行验证。所以实现了一套完整的分布式过程,在多个物理服务器上运行worker执行生产任务,由一台主服务(engine)进行整体调度。数据流程如图所示:

锁流程图

项目源码地址

项目上传到自建Git仓库,项目为goRedisDLM

git仓库地址

git获取源码方法:git clone https://git.zhangshuocauc.cn/redhat/goRedisDLM.git

worker docker镜像地址

worker封装为docker镜像,这样可以方便的在其它物理机上运行。woker镜像自建仓库地址worker

worker镜像地址

镜像支持两个环境变量,REDIS_HOST用于指定redis服务地址和SERVER_PORT用于指定内部worker服务地址(docker运行时通常不需要修改)。

使用方法:

  1. 拉取镜像:docker pull dkr.zhangshuocauc.cn/library/redislockworker:latest
  2. 运行服务:docker run -d -p 6600:6600 --rm -e REDIS_HOST=172.17.0.1:6379 --name worker dkr.zhangshuocauc.cn/library/redislockworker

构建源码:

  1. 源码:Dockerfile源码在上一节项目源码中。
  2. 构建:docker build -t worker:1.0 .

分布式锁开发问题记录

实现接口规范

Go或者java对锁是有标准接口规范的,java要实现juc中的锁接口。Go实现syncLocker接口即可。

初步锁方案

根据官网,分布式锁使用setnx进行加锁,使用lua脚本进行解锁。使用setnx可以解决分布式锁特性的独占、放死锁、不抢占。对于基础使用已经没有问题,如图:

初步锁方案

初始方案使用setnx加锁,使用getdel解锁。图中lock中的1的标记和unlock中的1的标记。此种方案因为getdel不是原子操作,在高并发的时候可能会释放其他人的锁,原因为get时确实是自己的锁,但此时因为系统调度或者其它原因线程阻塞,导致锁过期释放,其它线程加锁成功已经获取锁,后面再执行del就会删除其它人的锁。所以要对unlock进行改造。unlock2的标记为官网推荐的使用lua脚本进行解锁,保证操作的原子性。

考虑可重入性

同线程内如果进行函数的递归调用,就会触发锁重入。如果不对重入进行考虑设计可能会造成死锁。考虑重入则不能再使用setnx,因为重入时lock几次就要unlock几次所以要对lock次数进行标记,就要使用hset。上图中lock中的3标记和unlock中的3标记为使用lua脚本结合hincrby实现可重入设计。

考虑自动续期

为什么要考虑自动续期

分布式中,某个线程获取锁后在执行分布式事务时可能因为其它主机,网络环境,或者自己主机的调度情况。会发生在预期时间内业务没有执行完成的情况,如果没有自动续期或者watchdog去监控线程状态,会出现分布式事务还没有执行完成时导致锁释放后被其它主机获取锁。如果本事务还在操作共享资源可能会出现共享数据安全性问题。

自动续期实现

  1. 要保证当前只有一个续期任务在运行。
  2. 单独线程,定时执行。

考虑第一点保证只有一个续期任务,Go中对于线程控制通常是用channel,所以这里可以使用缓存是1的管道实现。第二点单独线程,这里Go直接启动一个协程即可,在协程中启动一个定时器定期触发,既然启动新的线程就需要对该线程的全声明周期做考虑和监护。

RedisLock结构体中,增加一个lockKeepAliveCh成员来控制只有一个任务运行。如图:

lock结构体增加成员

初始化时指定缓存长度为1

获取锁后非阻塞方式启动续期任务

续期任务

这种实现方式可以实现基本续期使用。但是这样实现会有一些问题:

  1. unlock之后续期任务还在执行,也就是说续期任务退出的条件是自己检测到锁失效之后才退出。
  2. 续期任务会进行多次无效续期。
  3. 本主机的其它线程获取到锁之后无法启动续期任务,原因为本线程unlock之后锁已经失效,可以被其它线程获取,因为上一次的续期任务还没有结束,所以新获取锁的线程在select时是不成功的会导致续期启动失败。

增加通知机制,让续期线程快速结束

和上面思想一致,协程之间的通信还是要通过channel实现。在RedisLock结构体中继续增加lockKeepDoneCh来实现通知的功能。如图:

结构体增加done成员

解锁成功后通知续期任务

续期任务获取done消息后结束

出现死锁问题

经过上面的修改之后运行代码则会出现死锁问题,甚至还有panic报错,资深小伙伴可以一眼看出问题吗?

解决panic

在unlock中,对于接口的类型断言没有判断是否断言成功直接判断为int64类型,如果返回数据不是int64则会出现断言错误,系统会报panic错误。

通过分析定位死锁问题

Go中出现死锁,尤其是对于新手来说很是头疼。死锁通常会发生在在管道中写数据但是却没有线程接收该数据。我们的实现中实际干活的worker是由rpc启动的,rpc每一个请求都会启动一个协程,在worker内部有一个主协程,和一个续期协程,考虑死锁应该是从主协程和续期协程的两个管道考虑。查看代码可以发现在续期线程内部,如果续期失败线程是会结束的,续期线程结束之后主线程在unlock时便会发生死锁。修改很简单将续期协程中定时器续期失败返回注释掉即可(在并发的场景,主线程执行脚本进行释放锁,此时续期线程进行续期就会出错退出,主线程继续执行判断所释放成功就会写管道):

死锁原因

使用工具进行死锁定位

可以使用pprof工具进行帮助分析,使用方式为在workermain中导入pprof包,_ "net/http/pprof"然后启动http的监听端口:

引入pprof

启动pprof

启动后浏览器访问:http://ip:6700/debug/pprof,点击goroutine可以查看协程信息:

pprof调试

可以看到所有worker都停在unlock中往管道写入数据的地方。和分析结果是一样的。

出现worker续期线程不能结束问题

解决了死锁问题,程序是可以正常启动了,但是又出现续期线程无法结束问题,分析代码可以看到一开始我们写的是unlock成功才会结束续期,如果unlock失败是不结束的,这里需要改成unlock的时候就要结束:

束续期逻辑

解决锁结束后还可以执行业务问题

原来业务程序处理比较简单,获取锁后便进行业务操作,但是如果考虑获取锁后当前主机系统调度问题导致续期没有完成,锁已经失效此时还可以执行业务的话会出现资源竞争访问问题所以:

原业务直接执行

一开始考虑的是使用lua脚本进行判断,这样是不可取的,因为锁的redis业务的redis真实使用环境可能不是一个主机,而且对于锁的封装业务应该也不能拿到内部的数据,所以此种方式被废弃了。但是思想还是挺重要的,就是操作分布式业务时,即使已经获取到锁也要再判断一下(类似缓存双写一致性中的双检加锁机制),进一步修改在lock中增加一个判断当前锁状态的方法,业务中使用此方法来判断:

锁接口实现查询

使用锁接口判断有效性

解决业务执行失败主调度进程无法重试问题

在上面的修改中,执行错误后我们给rpc调用进行返回了错误,所以主业务可以获取返回状态根据业务需求做重试或者放弃:

engin重新调度

我这里是检测到错误后重新进行调度,重新分发给scheduler,这里不用考虑线程死锁问题,因为scheduler里面是无阻塞的消息队列,可以一直接收消息加入队列,然后挑选合适的worker进行执行。

woker并发数量是不是越多越好

业务主进程中创建对象时指定了worker的并发数,如图:

worker并发数量

这里的worker数量是不是设置的越多越好,这里同样都是5000个任务的,做了统计如下(单位为ms):

worker数量第一次第二次第三次第四次第五次平均值
1174051765517141181501687717445.6
2168792095416890165671764717787.4
3178991715817482195471704417826.0
4180441739218834180641773518013.8
5190691768817998189622016518776.4
10203691826623311215541921220542.4
15217771970521804200152090820841.8
20209212253122557212742175921808.4
50342783250830313300713027231488.4
10047720 47720
200125010 125010

可以看出并不是worker越多并发执行的越快,对于这个分布式任务,因为所有的事务都要去获取同一把锁,而且每个任务执行时间很短,可能从统计上看一个线程是最快的。但是对于其它业务比如爬虫并发数量是有质的提升的。

还需要完善

该算法版本还没有实现redlock,后面还需要继续完善。

总结

满天飞的理念,总有落地的实现。对于任何技术,听理论可能理解的很透彻,但是一旦自己真正动手实现就会有很多想不到的问题或者坑,比如棘手的死锁问题,所以还是要多动手。

--EOF

最后修改:2025 年 02 月 18 日
如果觉得我的文章对你有用,请随意赞赏