分布式阻塞锁
写个分布式阻塞锁吧...
背景
当多个线程会抢占同一资源时,如果为了保证结果的正确需要做排它操作,一般会使用jdk提供的synchronized关键字或Lock类来做加锁操作。但是他们只提供同一jvm内的互斥,对于分布式的情况下的java应用并不能达到期望的效果。我们希望能够设计实现一个能够在多jvm下强占资源的锁。
方案
目前系统上已经有一个能够正常工作的分布式锁,使用mysql作为单点,当申请资源失败时直接通知应用,核心功能可以用伪码表示为
try{如果在应用层对于申请资源失败的异常进行捕获并不断重试,就是最简单的阻塞策略的实现,然而如果资源抢占比较激烈,所有的线程都在不断的重试,会浪费大量的cpu。
mysqlConnection.insert(resource);
app.doExecute();
}catch(ResourceExistsException exp){
throw exp;
}finally{
mysqlConnection.delete(resource);
}
另外一个存在的风险是,当某个节点获取锁成功之后宕机,由于mysql没有超时机制锁并不会被释放,会导致这个资源永远被占有,需要人工介入。对于这个问题可以通过获取锁之前检验锁的创建时间来解决
Date insertTime = mysqlConnection.select(resource);注意当过期时删除锁还需要加上insertTime,否则可能会出现多个线程同时获得锁成功的情况。
if(now - insertTime > 10s){
mysqlConnection.del(resource, insertTime);
}
当然如果使用具有expire语义的单点控制器,如redis,就可以避免这个问题。实际上我的实现也是使用了redis。
实现
单点的set与delete
redis通过set nx指令提供类似于cas(null, val)的语义,通过ex设置过期时间。当某线程设置的锁过期之后,为了防止调用del删除调其他线程设置的值,每次set时会生成一个uuid作为val。删除时使用watch机制,保证原子性的情况下执行以下的操作:[a]如果当前key的val等同于当前线程申请资源时生成的uuid。[b]删除key。
等待线程的处理
每个jvm下会维护一个key为资源唯一标识,值为等待该资源线程的队列的一个哈希表。获取资源的线程通过set操作的返回值来判断自己是否可以继续运行,如果被阻塞则查询获取资源的阻塞队列进行入队操作,同时使当前线程进入休眠。
等待线程的恢复
通知开启键空间通知,可以令redis发布对指定规则的键进行指定操作的消息。每个jvm会订阅资源主键的del和expired事件,当收到消息时通过资源键去查询对应队列中是否有等待的线程,如果有让队首线程出队尝试set,成功则恢复,失败将其重新入队。
消息丢失补偿
假定某一时间redis到应用主机网络异常,一个资源键的删除通知没有成功发送到任一主机,那么到下一次同一资源申请set之前所有主机上阻塞在此资源的线程都无法恢复。为了解决这个问题会启用一个后台进程,每隔10秒钟扫描所有队列检测队首等待时间是否过长,如果过长则尝试让其set。
模块划分
- DistributedLock:暴露给应用程序的api,如lock,unLock,tryLock等。
- CentralController:单点控制器,为DistributedLock提供单点上set和delete的支持。
锁申请流程
遗留问题与后续计划
- 增加本地线程可以不通过redis通知来强占锁的策略。
- 当redis主备切换时,由于写盘无法同步会导致刚刚申请的资源键丢失。
- 提供redis集群的支持,同时如果使用集群可以用类似paxos的机制解决上面的问题。
- 提供可重入的支持。
- 提供读写锁的支持。
仓库地址
https://github.com/ShikiRyougi/cactus
关注 远虑与近忧的矛盾螺旋
微信扫一扫关注公众号