写在前面
本人在学习Redis过程中学习到分布式锁时太多困惑和疑难杂点 需要总结梳理思路 以下思路都是最简单最基本的思路 主要用到Redission工具类 会涉及到看门狗机制等 本文内容部分引自Javaguide,小林coding等热门八股 用于个人学习用途
分布式锁介绍
对于单机多线程来说 通常用ReetrantLock类 synchronized关键字这类JDK自带的本地锁来控制一个JVM进程内多线程对本地共享资源的访问
图解引自JavaGuide:
线程访问共享资源是 互斥 的!也就是同一时刻只有单线程可获取本地锁访问共享资源
在分布式系统下 不同服务或者客户端通常运行在多个独立的JVM进程上 若多个JVM进程共享同份资源 使用本地锁就没办法实现资源的互斥访问了 为了解决这种问题 分布式锁 诞生了
举例子:下单服务一共部署 3 份 都对外提供服务 用户下订单之前后台要查库存 为了防止超卖 就需要加锁来实现对检查库存操作的同步访问 由于订单服务位于不同JVM进程中 本地锁在这种情况下就无法正常工作 因此要用到分布式锁 才能解决多线程不在同个JVM进程下也能获取同把锁 劲儿实现共享资源的互斥访问
图解引自JavaGuide:
可以看出来两者区别就在于 分布式锁就是联系起不同的JVM进程并保证所有JVM都可以对共享资源进行同步操作 基本的要求还是大体一致的 同样 独立进程里的线程访问共享资源是互斥的 某一时刻只有单线程可获取到分布式锁访问共享资源
分布式锁最基本要满足:
1.互斥:任何一个时刻 锁只能被一个线程持有(保证原子操作以免造成数据不一致问题)
2.高可用:锁的服务是高可用的 即使客户端释放锁的代码逻辑出现问题 锁最后还是会被释放 不影响其他线程中进程对共享资源的访问(一定可以释放锁 无论是异常还是出错 锁的效率很高)
3.可重入:一个节点获取了锁以后还可以再次获取锁(多次使用客户端一个功能 增删改)
实现分布式锁:
Redis 或 ZooKeeper实现分布式锁 以Redis为例
how to 实现?首先 从分布式锁的定义出发 无论是本地锁还是分布式锁 都有共性--“互斥”
在Redis里 setnx命令可以实现互斥 setnx(set if Not eXist)对应Java里的setIfAbsent方法 如果key不存在 才会设置key的值 若key存在就什么都不做
> SETNX lockKey uniqueValue
(integer) 1
> SETNX lockKey uniqueValue
(integer) 0
释放锁 用del删除key就行
> DEL lockKey
(integer) 1
但是这里有很多可能 在自学过程中 想到过若在当前线程已经获取锁的情况下
如果使用多个命令(如 setnx
和 expire设置过期时间),可能会出现:
1.成功获取锁 但是设置过期时间失败 导致锁无法正常释放
2.释放锁时 误删其他线程的锁(如锁已过期,但当前线程误删了新线程的锁)
为了解决特殊情况 需要保证数据前后操作一致性 也就是原子性 最常见的就是用lua脚本通过key对应的value(唯一值)来判断
lua脚本基于c语言 保证了解锁操作的原子性 因为Redis在执行lua脚本时可以用原子性的方式执行 从而保证锁释放操作的原子性
// 释放锁时,先比较锁对应的 value 值是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] thenreturn redis.call("del",KEYS[1])
elsereturn 0
end
图解引自JavaGuide:
这样做有两个好处 用value判断是否为对应线程的锁 是则释放 否则error 防止误删其他锁
同时 用lua脚本保持原子操作 确保锁释放不会被其他线程打断
技术往往都是双刃剑 同样这种锁也有一些问题 要是程序遇到问题 比如说释放锁的逻辑突然挂掉可能导致锁无法正常释放 进而造成共享资源无法被其他进程或者线程访问 所以 为了避免这种问题 需要给锁设置一个过期时间
分布式锁设置过期时间
为了避免这种因外部原因或者突然断电异常 可以给这个key(这个分布式锁)设置一个过期时间
127.0.0.1:6379> SET lockKey uniqueValue EX 3 NX
OK
lockKey:加锁的锁名 uniqueValue 能够唯一标识锁的随机字符串(可以用UUID)
NX:只有当lockKey对应的key值不存在时才能set成功
EX:过期时间设置(秒为单位) EX 3 表示这个锁有三秒的自动过期时间 与EX对应的是PX(毫秒)两个都是过期时间设置
一定需要保证设置指定key的值和过期时间是原子操作!!!!!!否则锁无法被释放
但是我怎么知道要设置多长过期时间 我写完这个文章我难道要给自己设置个过期时间 到九点前我写完了 那我网页还挂着为啥不关机 九点之前没写完 我电脑直接关机了我怎么写
对于这种分布式锁也是一样的道理 一般正常的操作都是毫秒级别 过期时间小于线程对共享资源的操作时间 就回出现锁提前过期的问题 进而导致分布式锁直接失效 过期时间设置过长又会浪费性能
要是能自动设置就好了!
如何实现优雅的续期....?
总会有人想到的 这里就引入Redis里现成的方案 Redission
Redission自动续期机制
Redission就可以做到自动续期机制 其底层实现上 使用了看门狗机制(Watch Dog)
在共享资源的线程操作还未完成的情况下 看门狗会一直延长过期时间(默认情况下存活30s
每10s更新一次过期时间)进而保证锁不会因为超时而被释放
多好的看门狗 多希望自己学习也是这
图解引自JavaGuide:
看门狗名字的由来于
getLockWatchdogTimeout()
方法,此方法返回看门狗给锁续期的过期时间(默认30s)
//默认 30秒,支持修改
private long lockWatchdogTimeout = 30 * 1000;public Config setLockWatchdogTimeout(long lockWatchdogTimeout) {this.lockWatchdogTimeout = lockWatchdogTimeout;return this;
}
public long getLockWatchdogTimeout() {return lockWatchdogTimeout;
}
方法内用lua脚本保证操作原子性 renewExpiration方法包含主要逻辑
private void renewExpiration() {//......Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {@Overridepublic void run(Timeout timeout) throws Exception {//......// 异步续期,基于 Lua 脚本CompletionStage<Boolean> future = renewExpirationAsync(threadId);future.whenComplete((res, e) -> {if (e != null) {// 无法续期log.error("Can't update lock " + getRawName() + " expiration", e);EXPIRATION_RENEWAL_MAP.remove(getEntryName());return;}if (res) {// 递归调用实现续期renewExpiration();} else {// 取消续期cancelExpirationRenewal(null);}});}// 延迟 internalLockLeaseTime/3(默认 10s,也就是 30/3) 再调用}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);ee.setTimeout(task);}
JavaGuide的源码分析很到位 我之前做过看门狗机制的笔记 比较凌乱 基本上是这样的流程
有ttl和time(ttl 锁有效时间 time获取锁的等待时间) 需要确保业务正常运行释放锁 不能因为阻塞异常释放锁 引出看门狗机制
这里的ee对象封装了当前线程id与当前定时的任务(该定时任务里用lua脚本对redis有效期定时更新 在看门狗机制30s的情况下 每30/3 = 10秒后会重启任务 通过内部递归重复调用方法执行)
Watch Dog 通过调用 renewExpirationAsync()
方法实现锁的异步续期:
protected CompletionStage<Boolean> renewExpirationAsync(long threadId) {return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,// 判断是否为持锁线程,如果是就执行续期操作,就锁的过期时间设置为 30s(默认)"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +"redis.call('pexpire', KEYS[1], ARGV[1]); " +"return 1; " +"end; " +"return 0;",Collections.singletonList(getRawName()),internalLockLeaseTime, getLockName(threadId));
}
这里的renewExpirationAsync方法实现了续期 用lua脚本主要是保证操作原子性
JavaGuide里用Redission分布式 可重入锁RLock来说明如何使用Redission实现分布式锁
// 1.获取指定的分布式锁对象
RLock lock = redisson.getLock("lock");
// 2.拿锁且不设置锁超时时间,具备 Watch Dog 自动续期机制
lock.lock();
// 3.执行业务
...
// 4.释放锁
lock.unlock();
// 手动给锁设置过期时间,不具备 Watch Dog 自动续期机制
lock.lock(10, TimeUnit.SECONDS);
注意 不设置锁过期时间时 才会出发看门狗自动续期机制
如何实现可重入锁?
什么是可重入锁?
单线程可以多次获取同一把锁 例如一个线程在执行一个带锁的方法 在方法中又调用了另一个同样需要相同锁的方法 则改成可以直接执行调用的方法 即可重入 相同线程多次进方法内的锁
Java中 synchronized(JVM内置监视器锁)与ReentrantLock(Lock实现类锁)都是可重入锁
不可重入的分布式锁基本可以满足绝大部分场景 特殊情况下还是要用可重入分布式锁
可重入分布式锁实现核心思路是 线程在获取锁时判断是否为自己的锁 如果是 就不用重新获取
所以 可以给每个锁关联一个可重入计数器 和 一个 占有他的线程(给线程上标识)当可重入计数器
大于0时 则锁被占有 需要判断占有该所的线程和请求获取锁的线程是否一致(是否有相同标识)
实际上 总有人替你负重前行 Redission内部内置了多种类型的锁比如可重入锁(ReentrantLock)自旋锁(SpinLock) 公平锁(Fair Lock)多重锁(MultiLock)红锁(RedLock)读写锁(ReadWriteLock)
要是都用一个Redis服务 Redis爆炸了怎么办?所以一般都是集群服务 所以..
集群模式下如何实现分布式锁?如何保证分布式锁可靠性?
Redis集群数据同步到每个节点 会是异步还是同步? 一定是异步操作 同步太慢 那如果Redis主节点获取到锁后 在未能同步到其他节点时主服务宕机了.. 此时 新的Redis主节点还是能获取锁 变成新老大 多个应用服务还是可以同时获取锁
图解引自JavaGuide:
针对这种突然崩溃的情况 还是有人替我负重前行 用RedLock(红锁)来解决
RedLock算法思想 是 让客户端向Redis集群中多个独立的Redis实例依次请求申请加锁 若客户端能和多半数实例成功完成加锁操作 那么就宏观上认为:客户端成功获得分布式锁 否则加锁失败
即使部分Redis节点出现问题 只要保证Redis集群中有多半数Redis节点可用 分布式锁服务就ok
RedLock是直接操作Redis节点的 并不是通过Redis集群操作的 这样才可以避免Redis集群主从切换导致锁丢失问题
贴上锁优化时的问题
哨兵会让从节点变为主节点 但是原锁就会失效 可以在新主节点进新锁 会引发线程安全问题
以及从开始到现在我遇到的分布式锁的优缺点和优化过程 今天又看到RedLock
基本上到这里也是个人项目的内容
面试拷打:如何在项目中实现分布式锁?采用了什么方法?
Redission实现分布式锁..后续后面补上
参考资料
[1]Redisson: https://github.com/redisson/redisson
[2]redisson-3.17.6: https://github.com/redisson/redisson/releases/tag/redisson-3.17.6
[3]Redlock 算法: https://redis.io/topics/distlock
[4]How to do distributed locking - Martin Kleppmann - 2016: https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
[5]JavaGuide:原文链接:支付宝一面:如何基于Redis实现分布式锁?