作者:Tom哥
公众号:微观技术
博客:https://offercome.cn
人生理念:知道的越多,不知道的越多,努力去学
多线程并发在我们做系统架构设计中经常遇到,例如:抽奖、秒杀、库存 等。
面对多个请求同时对一个共享资源
修改,如何保证数据安全,这里就需要引入 锁
来解决临界资源的访问安全。
JDK 中提供了 synchronized
和 Lock
两种锁。
无论哪一种锁,有一个前提条件,都是解决同一个 JVM 进程下的线程安全问题。
面对当下分布式微服务系统架构,多个系统,多台机器,多个进程,JDK 提供的锁已经无法解决这个问题。
这时,我们需要一个分布式锁
在实现 JVM 锁时,我们将锁的状态保存在 Java 的对象头中,分布式锁也是类似的道理,将锁的状态保存在一个外部存储,比如:MySQL、Redis 等存储服务中。
1、互斥性,这个是锁的最基本要求 2、可重入性,同一个线程可以重复多次获得锁 3、支持阻塞、非阻塞两种特性 4、支持锁超时,为了防止线程意外退出,没有正常释放锁,导致其他线程无法正常获取到锁。加锁时间超过一定时间,会自动释放锁
加锁通常使用 set 命令来实现,伪代码如下
set key value PX milliseconds NX
参数说明:
Spring Data Redis 已经帮我们封装好了现成的方法,拿来开箱直接使用即可。代码如下:
首次加锁,Redis 中 key 是空的,键值对关联成功后,调用结果返回 True,表示加锁成功。
为了防止一些特殊情况出现,导致锁没有正常释放,这里为 Key 设置了一个过期时间,作为一个兜底策略,超时锁会自动释放
删除key,表示释放分布式锁。
简单几行代码,就可以实现一个分布式锁,感觉也没什么复杂的。
根据二八原则,80% 的时间都花费在少数几件重要的事情上。我们做系统开发也是一样道理,功能编码可能只需要几天时间,但是优化其中的性能、稳定性、高可用 等可能要花上一周甚至更长时间。
那我们看看上面的方案有没有瑕疵呢?
加锁和解锁这种通用性操作一般都是以公共组件形式存在,比如封装成一个工具类方法,供上层业务直接调用,避免重复建设。
这就带来一个问题,如果一个新同学没有调用 lock() 方法,上来直接先调用了 unLock() 方法,此时会将别人的锁释放掉,引发数据安全问题。
为了解决这个问题,我们要求哪个线程加的锁,同样必须那个线程才能释放锁。
如何才能达到加锁和释放锁绑定到同一个线程呢?
这里提供了一个简单思路
释放锁有多个操作,为了保证操作的原子性,这里采用 Lua 脚本
演示代码:
现在貌似加锁
和释放锁
基本能满足需求,但是一个方法内部调用逻辑通常是复杂的。
如果上层已经获得了锁,那后面的方法对同一个 key 将无法再次获得锁了,我们要考虑锁的可重入性。
为了便于理解,画了个流程图,在第四步内部业务逻辑处理完后,会把锁释放了。
这时,回到最外层第六步,再释放锁时,已经没有锁可以释放了,虽然删除本身具有幂等性。
但这个期间,由于锁已经被内部方法早早的释放了,其他线程就可以重新拿到锁,从而导致数据安全问题。
参考 ReentrantLock
锁的可重入性设计思路,在加锁、释放锁的方法中加入计数器
释放锁时,除了比较 线程标识
来判断是否当前线程持有的锁外,还增加了一些逻辑
对锁计数器减一,当值变为0时,对分布式锁的 key 做清理动作
结果描述:
返回 1,表示解锁成功。返回 0,表示解锁失败,不是自己的锁
Number count = redisTemplate.execute(RedisScriptConfig.getReentrantScript(), keys, value, 30);
上面提供的都是非阻塞锁
,不管是否能获取锁,都会立即返回。
对于阻塞锁,我们可以参考 JUC 并发包的 Atomic 实现方式,采用自旋锁