找回密码
 立即注册
首页 业界区 业界 R2M分布式锁原理及实践

R2M分布式锁原理及实践

乙荒 2025-6-6 08:56:39
作者:京东科技 张石磊
1 案例引入

名词简介:
资源:可以理解为一条内容,或者图+文字+链接的载体。
档位ID: 资源的分类组,资源必须归属于档位。
问题描述:当同一个档位下2条资源同时审批通过时,收到擎天审批系统2条消息,消费者应用部署了2台机器,此时正好由2台机器分别消费,在并发消费时,先更新资源状态,然后写缓存,每次取前100条资源,类似select * from resource where gear_id=xxx limit 100 order by id desc;
在写档位缓存,此时事务未提交,并发查询时根据档位Id查询时查询不到对方的数据,全量写缓存时导致后写的缓存覆盖了先写的缓存,即缓存被覆盖,导致投放资源缺失。
方案思考 :
方案1:一台机器消费mq–单点问题
方案2:将同档位ID的资源路由到同一个queue,需要审批系统配合根据档位Id做路由,审批系统发的消息不只是cms审批数据,此方案不适用。
方案3:在档位级别加分布式锁。
经比较,最终采用方案3是合适的方案.
2 锁说明和分布式锁选择

synchronized锁的粒度是JVM进程维度,集群模式下,不能对共享资源加锁,此时需要跨JVM进程的分布式锁。
分布式锁方式核心实现方式优点缺点分析
1 数据库:
悲观锁,lock
乐观锁,通过版本号实现version
实现简单,不依赖中间件
数据库IO瓶颈,性能差
单实例存在单点问题,主从架构存在数据不一致,主从切换时其他客户端可重复加锁。
2 zookeeper
创建临时节点
CP模型,可靠性高,不存在主从切换不一致问题
频繁创建和销毁临时节点,且
集群模式下,leader数据需要同步到follower才算加锁成功,性能不如redis
主从切换服务不可用
3 redis集群
setnx+expire
性能高
有封装好的框架redission
支持超时自动删除锁
集群支持高可用,AP模型
主从切换时其他客户端可重复加锁。
R2M是基于开源的Redis cluster(Redis 3.0以上版本)构建的高性能分布式缓存系统,我们系统一直在使用,3.2.5版本开始支持分布式锁。
3 r2m分布式锁原理

示例代码:
  1. String lockKey = CacheKeyHelper.getGearLockKey(EnvEnum.getEnvFlagEnum(envCode),resource.getGearId());
  2. R2mLock lock = (R2mLock) r2mClusterClient.getLock(lockKey);
  3. //获取锁,锁的默认有效期30s,获取不到锁就阻塞
  4. lock.lock();
  5. try {
  6.     //业务代码
  7.     resourceService.afterApprovedHandle(resource);
  8. }  finally {
  9.     //释放锁
  10.     lock.unlock();
  11. }
复制代码
1 加锁核心流程:

加锁流程图:
1.png

1):尝试获取锁,通过执行加锁Lua脚本来做;
2):若第一步未获取到锁,则去redis订阅解锁消息
3):一旦持有锁的线程释放了锁,就会广播解锁消息,其他线程自旋重新尝试获取锁。
核心加锁原理:使用lua脚本封装了hset和pexpire命令,保证是一个原子操作, KEYS[1]是加锁的key,argv[2]是加锁的客户端ID(UUID+线程ID),ARGV[1]是锁的有效期,默认30s.
  1. private Object acquireInternal(List<String> args) {
  2. if (!this.setLocked() && this.getHolderId() != Thread.currentThread().getId()) {
  3. return -1L;
  4. } else {
  5. try {
  6. //hash结构,hash的key是加锁的key,键值对的key为客户端的UUID+线程id,value为锁的重入计数器值。
  7. return this.lockSha() != null ? this.executor.evalR2mLockSha(this.lockSha(),
  8. "if (redis.call('exists', KEYS[1]) == 0) then redis.call('hset', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end;
  9. if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end;
  10. return -2;", Collections.singletonList(this.lockName), args) : this.executor. == 0) then redis.call('hset', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; return -2;", Collections.singletonList(this.lockName), args);
  11. } catch (Exception var3) {
  12. this.setUnlocked();
  13. throw new R2mLockException("Failed to acquire lock " + this.lockName + ".", var3);
  14. }
  15. }
  16. }
复制代码
args参数
  1. private List<String> acquireArgs(long leaseTime) {
  2.         List<String> args = new ArrayList();
  3.         if (leaseTime != -1L) {
  4.             args.add(String.valueOf(leaseTime));
  5.         } else {
  6.              //默认30s
  7.             args.add(String.valueOf(this.internalLockLeaseTime()));
  8.         }
  9.         //UUID+当前线程id
  10.         args.add(this.currentThreadLockId(Thread.currentThread().getId()));
  11.         return args;
  12.     }
  13. 
复制代码
获取锁失败订阅锁的channel
  1. //获取锁失败,订阅释放锁的消息
  2. private boolean failedAcquire() {
  3.             this.subLock();
  4.             return false;
  5.         }
  6. private void subLock() {
  7.             CompletableFuture<Void> cf = R2mLock.this.executor.lockSubscribe(R2mLock.this.lockPubSub(), R2mLock.this.getLockChannelName(), R2mLock.this);
  8.             if (cf != null) {
  9.                 cf.handleAsync(this::reSubIfEx);
  10.             }
  11.         }
复制代码
锁释放后,订阅者通过自旋尝试获取锁。
  1. //tryAcquire获取锁,!tryAcquire就是获取锁失败,锁释放后,通知线程唤醒后返回false,然后通过自旋,尝试获取锁,
  2. public final void acquire(long arg) {
  3.         if (!tryAcquire(arg) &&
  4.             acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
  5.             selfInterrupt();
  6.     }
  7. final boolean acquireQueued(final Node node, long arg) {
  8.         boolean failed = true;
  9.         try {
  10.             boolean interrupted = false;
  11.             //内部自旋获取锁
  12.             for (;;) {
  13.                 final Node p = node.predecessor();
  14.                 if (p == head && tryAcquire(arg)) {
  15.                     setHead(node);
  16.                     p.next = null; // help GC
  17.                     failed = false;
  18.                     return interrupted;
  19.                 }
  20.                 if (shouldParkAfterFailedAcquire(p, node) &&
  21.                     parkAndCheckInterrupt())
  22.                     interrupted = true;
  23.             }
  24.         } finally {
  25.             if (failed)
  26.                 cancelAcquire(node);
  27.         }
  28.     }
复制代码
2 释放锁核心逻辑:

1)删除分布式锁key(如果可重入锁计数为0)

  • 发释放锁的广播消息
3)取消watchdog
  1. private Object unlockInternal(List<String> args) {
  2.         logger.debug("{} trying to unlock.", Thread.currentThread().getId());
  3.         Object var2;
  4.         try {
  5.      //判断锁 key 是否存在,如果存在,然后递减hash的value值,当value值为0时再删除锁key,并且广播释放锁的消息
  6.             if (this.unlockSha() == null) {
  7.                 var2 = this.executor. == 0) then return nil;end; local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); if (counter > 0) then return 0; else redis.call('del', KEYS[1]); redis.call('publish', KEYS[2], ARGV[1]); return 1; end; return nil;", Arrays.asList(this.lockName, this.getLockChannelName()), args);
  8.                 return var2;
  9.             }
  10.             var2 = this.executor.evalR2mLockSha(this.unlockSha(), "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then return nil;end; local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); if (counter > 0) then return 0; else redis.call('del', KEYS[1]); redis.call('publish', KEYS[2], ARGV[1]); return 1; end; return nil;", Arrays.asList(this.lockName, this.getLockChannelName()), args);
  11.         } catch (Exception var6) {
  12.             throw new R2mLockException("Failed to unlock " + this.lockName + ".", var6);
  13.         } finally {
  14.             this.finalizeRelease();
  15.         }
  16.         return var2;
  17.     }
  18. //取消当前线程的watchdog
  19. private void finalizeRelease() {
  20.         long threadId = Thread.currentThread().getId();
  21.         R2mLock.ExpirableEntry entry = (R2mLock.ExpirableEntry)this.entryCache.get(threadId);
  22.         if (entry != null) {
  23.             entry.release(threadId);
  24.             if (entry.isReleased()) {
  25.                 //取消这个线程watchdog定时任务
  26.                 entry.getExtTask().cancel();
  27.                 this.expEntry.compareAndSet(entry, (Object)null);
  28.                 //从缓存watchdog线程的map中删除该线程
  29.                 this.entryCache.remove(threadId);
  30.             }
  31.         }
  32.     }
复制代码
3 锁的健壮性思考

1 业务没执行完,锁超时过期怎么办?
客户端加锁默认有效期30s,超过有效期后如果业务没执行完,还需要持有这把锁,r2m客户端提供了续期机制,也就是watchdog机制。
watchdog原理:客户端线程维度(UUID+线程ID,客户端维护一个MAP,key就是UUID+线程ID)的后台定时线程,获取锁成功后,如果客户端还持有当前锁,每隔10s(this.internalLockLeaseTime() / 3L),去延长锁的有效期(internalLockLeaseTime)
  1. //watchdog核心机制 ,internalLockLeaseTime默认30s
  2. private void extendLock(long holderId) {
  3.         if (this.expEntry.get() != null) {
  4.             R2mLock.ExpirableEntry holderEntry = (R2mLock.ExpirableEntry)this.entryCache.get(holderId);
  5.             if (holderEntry != null) {
  6.                  //每隔10s,如果当前线程持有锁,则续期30s
  7.                 if (this.expEntry.compareAndSet(holderEntry, holderEntry)) {
  8.                     Timeout task = this.timer().newTimeout((timeout) -> {
  9.                         if (this.extendLockInternal(holderId)) {
  10.                             this.extendLock(holderId);
  11.                         }
  12.                     }, this.internalLockLeaseTime() / 3L, TimeUnit.MILLISECONDS);
  13.                     if (this.expEntry.get() != null) {
  14.                         ((R2mLock.ExpirableEntry)this.expEntry.get()).setExtTask(task);
  15.                     }
  16.                 }
  17.             }
  18.         }
  19.     }
  20. //执行续期lua脚本
  21. private boolean extendLockInternal(long threadId) {
  22.         Object result;
  23.         try {
  24.            //只续期
  25.             if (this.extendLockSha() != null) {
  26.                 result = this.executor.evalR2mLockSha(this.extendLockSha(), "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('pexpire', KEYS[1], ARGV[1]); return 1; end; return 0;", Collections.singletonList(this.lockName), this.extendLockArgs(threadId));
  27.             } else {
  28.                 result = this.executor. == 1) then redis.call('pexpire', KEYS[1], ARGV[1]); return 1; end; return 0;", Collections.singletonList(this.lockName), this.extendLockArgs(threadId));
  29.             }
  30.         } catch (Exception var5) {
  31.             return false;
  32.         }
  33.         return Long.parseLong(result.toString()) == 1L;
  34.     }
复制代码
2 客户端宕机,锁如何释放?
分布式锁是有效期的,客户端宕机后,watchdog机制失效,锁过期自动失效。
3 redis分布式锁集群模式下缺陷
r2m集群模式,极端情况,master加锁成功,宕机,还未来得及同步到slave,主从切换,slave切换成master,可以继续加锁,对于非及其严格加锁场景,该方案可满足,属于AP;对于严格场景下的分布式锁,可采用基于zookeeper的分布式锁,属于CP,leader宕机,folllower选举时不可用。性能上redis更优。
4 锁的释放问题
注意锁的释放在finally中释放,必须由锁的持有者释放,不能由其他线程释放别人的锁,示例代码中lock放到try的外面。

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
您需要登录后才可以回帖 登录 | 立即注册