持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第9天,点击查看活动详情
渐进开发分布式锁
在分布式的这个大环境下,本地锁
synchronized, Lock等已经不能满足我们的需要.我们急需一种对实例进行上锁的机制.
本文将从分布式环境下产生的问题出发,解释为什么需要分布式锁,然后解释什么是分布式锁与分布式锁的特点.最后通过实践,循序渐进完成分布式锁,实现锁的续期,实现可重入锁,解决redis命令不满足原子性的问题.
一. 分布式环境下问题分析
**产生现象:**本地锁在多节点下失效(集群/分布式)
分析原因:本地锁它只能锁住本地JVM进程中的多个线程,对于多个JVM进程的不同线程间是锁不住的
**解决办法:**分布式锁(在分布式环境下提供锁服务,并且达到本地锁的效果)
1.1. 什么是分布式锁
什么时候需要分布式锁: 需要利用锁的技术控制某一时刻修改数据的进程数。
- 商品抢购,有限的商品在同一时刻产生大量的争抢
- 某个业务在某一个时刻只允许一个人在执行
分布式锁锁作用跟单机锁完全一样, 只是它通常需要借助第三方服务(redis,zookeeper,mysql等性能好的存储中间件)来实现。
1.2. 分布式锁特点
**互斥性:**不仅要在同一jvm进程下的不同线程间互斥,更要在不同jvm进程下的不同线程间互斥
**锁超时:**支持锁的自动释放,防止死锁 (redis中设置过期时间实现)
**高可用:**加锁和解锁必须是同一个线程,加锁和解锁操作一定要高效,提供锁的服务要具备容错性
**可重入:**如果一个线程拿到了锁之后继续去获取锁还能获取到,我们称锁是可重入的(方法的递归调用时) **阻塞/非阻塞:**如果获取不到直接返回视为非阻塞的,如果获取不到会等待锁的释放直到获取锁或者等待超时,视为阻塞的
**公平/非公平:**按照请求的顺序获取锁视为公平的
二. 基于Redis实现分布式锁
1.1. 基本原理‼️
SETNX key value
SETNX 是 SET if Not eXists (如果不存在,则 SET)的简写。
- 将 key 的值设为 value ,当且仅当 key 不存在.
- 若给定的 key 已经存在,则 SETNX 不做任何动作.
- 返回值: 设置成功,返回 1 设置失败,返回 0
使用SETNX完成同步锁的流程及事项如下:
通过判断是否设置成功,来检查是否持有了锁.
- 设置成功: 拿到锁
- 设置失败: 锁已经被其他实例持有
获取锁: 使用SETNX命令获取锁,若返回0(key已存在,锁已存在)则获取失败,反之获取成功
锁超时: 为了防止获取锁后程序出现异常,导致其他线程/进程调用SETNX命令总是返回0而进入死锁状态,需要为该key设置一个“合理”的过期时间
释放锁: 使用DEL命令将锁数据删除
基础环境搭建不再详细讲解,核心技术
Springboot,redis
1.2. 小试牛刀 (最简单的分布式锁)
Lockv1.class
public class Lockv1 {
private static final StringRedisTemplate redisTemplate = SpringUtil.getBean(StringRedisTemplate.class);
public static boolean tryLock(String lockName, Integer release) {
// setIfAbsent : 设置成功返回true,设置失败返回false
boolean lock = redisTemplate.opsForValue().setIfAbsent(lockName, "1");
// 检查是否设置成功
if (Boolean.TRUE.equals(lock)){
// 设置过期时间.避免异常导致死锁
redisTemplate.expire(lockName,release, TimeUnit.SECONDS);
return true;
}
return false;
}
public static boolean unlock(String lockName) {
// 删除锁,即释放
return redisTemplate.delete(lockName);
}
}
TestLockController.class
@RestController
@RequestMapping("/test/lock")
public class TestLockController {
@GetMapping("/v1/lock")
public String lock_v1(){
// 锁的名称,可根据业务任意定制
String lockName = "kock_1";
// 获取到锁返回 true
boolean lock = Lockv1.tryLock(lockName, 5);
if (lock){
try {
System.out.println(Thread.currentThread().getName()+" - 获取锁成功");
doSomething(5);
}catch (Exception e){
}finally {
Lockv1.unlock(lockName);
System.out.println(Thread.currentThread().getName()+" - 释放锁成功");
}
}else {
System.out.println(Thread.currentThread().getName()+" - 获取锁失败");
}
return lockName;
}
private void doSomething(Integer time) throws InterruptedException {
System.out.println("开始执行业务方法-->");
TimeUnit.SECONDS.sleep(time);
System.out.println("结束执行业务方法--<");
}
}
// 一个请求正常执行
http-nio-8080-exec-2 - 获取锁成功
开始执行业务方法-->
结束执行业务方法--<
http-nio-8080-exec-2 - 释放锁成功
// 当一个线程持有锁时另一个线程获取锁失败不执行业务方法
http-nio-8080-exec-9 - 获取锁成功
开始执行业务方法-->
http-nio-8080-exec-10 - 获取锁失败
结束执行业务方法--<
http-nio-8080-exec-9 - 释放锁成功
v1 版本 问题分析:Lockv1.class
-
setnx和expire是非原子性操作,如果获取到锁之后,设置过期时间之前出现异常,那么锁不会过期,就会导致死锁的发生. -
错误解锁,目前的锁只要知道
lockName的线程都可以解,需要保证自己只能解自己的锁. -
手动设置的过期时间
expire,可能会出现锁已经过期但是业务还在执行的情况,需要为锁续期.
1.3. 初入殿堂
解决: 问题1 - setnx 和 expire 是非原子性操作.
可以借助lua脚本实现原子性操作
lockv2.lua
local key = KEYS[1]
local value = ARGV[1]
local expire = ARGV[2]
-- setnx 设置成功返回 1 失败返回 0
if redis.call('setnx',key,value) == 1 then
redis.call('expire',key,expire);
return true;
else
return false;
end
public class Lockv2 {
private static final StringRedisTemplate redisTemplate = SpringUtil.getBean(StringRedisTemplate.class);
// #############初始化lock脚本##############
private static final DefaultRedisScript<Boolean> LOCK_SCRIPT;
static {
// 加载脚本
LOCK_SCRIPT = new DefaultRedisScript<>();
LOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("lockv2.lua")));
LOCK_SCRIPT.setResultType(Boolean.class);
}
// ###########初始化lock脚本结束############
public static boolean tryLock(String lockName, Integer release) {
Boolean result = redisTemplate.execute(
LOCK_SCRIPT,
Collections.singletonList(lockName), // KEYS[0]
"1", release.toString() // ARGV[0,1] args是String类型
);
return result;
}
public static boolean unlock(String lockName) {
// 删除锁,即释放
return redisTemplate.delete(lockName);
}
}
解决: 问题2 - 保证自己只能解自己的锁.
通过把 value 设置成一个唯一性标识实现
lockv3.lua
local key = KEYS[1]
local value = ARGV[1]
local expire = ARGV[2]
-- setnx 设置成功返回 1 失败返回 0
if redis.call('get',key,value) == 1 then
redis.call('expire',key,expire);
return true;
else
return false;
end
unlockv3.lua
local key = KEYS[1]
local value = ARGV[1]
-- 判断是不是自己的锁
if redis.call('get',key) == value then
redis.call('del',key)
return true;
else
return false;
end
Lockv3.class
public class Lockv3 {
private static final StringRedisTemplate redisTemplate = SpringUtil.getBean(StringRedisTemplate.class);
// #############初始化lock脚本##############
private static final DefaultRedisScript<Boolean> LOCK_SCRIPT;
private static final DefaultRedisScript<Boolean> UNLOCK_SCRIPT;
static {
LOCK_SCRIPT = new DefaultRedisScript<>();
LOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("lockv3.lua")));
LOCK_SCRIPT.setResultType(Boolean.class);
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("unlockv3.lua")));
UNLOCK_SCRIPT.setResultType(Boolean.class);
}
// ###########初始化lock脚本结束############
public static boolean tryLock(String lockName,String lockValue, Integer release) {
Boolean result = redisTemplate.execute(
LOCK_SCRIPT,
Collections.singletonList(lockName), // KEYS[0]
lockValue, release.toString() // ARGV[0,1] args是String类型
);
return result;
}
public static boolean unlock(String lockName, String lockValue) {
Boolean result = redisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(lockName),
lockValue
);
return result;
}
}
TestLockController.class
@RestController
@RequestMapping("/test/lock")
public class TestLockController {
@GetMapping("/v3/lock")
public String lock_v3(){
// 锁的名称,可根据业务任意定制
String lockName = "kock_3";
String lockValue = UUID.randomUUID().toString();
// 获取到锁返回 true
boolean lock = Lockv3.tryLock(lockName, lockValue,5);
if (lock){
try {
System.out.println(Thread.currentThread().getName()+" - 获取锁成功");
doSomething(5);
}catch (Exception e){
}finally {
// 根据 锁 和 上锁标识 解锁
Lockv3.unlock(lockName,lockValue);
System.out.println("尝试解别人的锁: "+Lockv3.unlock(lockName,"xxx"));
System.out.println(Thread.currentThread().getName()+" - 释放锁成功");
}
}else {
System.out.println(Thread.currentThread().getName()+" - 获取锁失败");
}
return lockName;
}
private void doSomething(Integer time) throws InterruptedException {
System.out.println("开始执行业务方法-->");
TimeUnit.SECONDS.sleep(time);
System.out.println("结束执行业务方法--<");
}
}
http-nio-8080-exec-1 - 获取锁成功
开始执行业务方法-->
结束执行业务方法--<
尝试解别人的锁: false
http-nio-8080-exec-1 - 释放锁成功
解决: 问题3 - 锁续期.
拿到锁之后执行业务,业务的执行时间超过了锁的过期时间,这样这个业务释放了锁,但是业务没有结束,导致其他业务涌入.
续期逻辑
- 获取锁成功就入守护线程
- 守护线程定期为加入的锁续期 (已经可以省略获取锁的过期时间参数了)
- 释放锁时将锁从守护线程移除
lock_renewalv4.lua
local key = KEYS[1]
local value = ARGV[1]
local expire = ARGV[2]
-- get 并和 value 比较
if redis.call('get',key) == value then
redis.call('expire',key,expire);
return true;
else
return false;
end
Lockv4.class
@Component
public class Lockv4 {
// redis
private static final StringRedisTemplate redisTemplate = SpringUtil.getBean(StringRedisTemplate.class);
// script
private static final DefaultRedisScript<Boolean> LOCK_SCRIPT;
private static final DefaultRedisScript<Boolean> UNLOCK_SCRIPT;
private static final DefaultRedisScript<Boolean> LOCK_RENEWAL_SCRIPT;
// 锁续期
private ScheduledExecutorService executorService;//守护线程池
private static ConcurrentHashMap<String,String> locks = new ConcurrentHashMap<>(); // 锁 map
static {
// 加载脚本
LOCK_SCRIPT = new DefaultRedisScript<>();
LOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("lockv3.lua")));
LOCK_SCRIPT.setResultType(Boolean.class);
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("unlockv3.lua")));
UNLOCK_SCRIPT.setResultType(Boolean.class);
LOCK_RENEWAL_SCRIPT = new DefaultRedisScript<>();
LOCK_RENEWAL_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("lock_renewalv4.lua")));
LOCK_RENEWAL_SCRIPT.setResultType(Boolean.class);
}
// 续期核心方法
@PostConstruct
public void init(){
executorService = Executors.newScheduledThreadPool(1);
// 时间等于或超过time首次执行task,之后每隔period毫秒重复执行task
executorService.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
Enumeration<String> keys = locks.keys();
while (keys.hasMoreElements()) {
String key = keys.nextElement();
// 每次续期 1s
Boolean result = redisTemplate.execute(
LOCK_RENEWAL_SCRIPT,
Collections.singletonList(key), // KEYS[0]
locks.get(key), "1" // ARGV[0,1] args是String类型
);
System.out.println("续期:"+ key+" ## "+locks.get(key) + (result?" - 成功":" - 失败"));
}
}
// 延时 0 ms,每隔 500ms 运行一次任务
},0,500, TimeUnit.MILLISECONDS);
}
public static boolean tryLock(String lockName,String lockValue) {
Boolean result = redisTemplate.execute(
LOCK_SCRIPT,
Collections.singletonList(lockName), // KEYS[0]
lockValue, "1" // ARGV[0,1] args是String类型
);
// 获取到锁,加入守护线程
if (result){
locks.put(lockName,lockValue);
}
return result;
}
public static boolean unlock(String lockName, String lockValue) {
locks.remove(lockName);
Boolean result = redisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(lockName),
lockValue
);
return result;
}
}
TestLockController.class
@RestController
@RequestMapping("/test/lock")
public class TestLockController {
@GetMapping("/v4/lock")
public String lock_v4(){
// 锁的名称,可根据业务任意定制
String lockName = "kock_4";
String lockValue = UUID.randomUUID().toString();
// 获取到锁返回 true
boolean lock = Lockv4.tryLock(lockName, lockValue);
if (lock){
try {
System.out.println(Thread.currentThread().getName()+" - 获取锁成功");
doSomething(1);
}catch (Exception e){
}finally {
// 根据 锁 和 上锁标识 解锁
Lockv4.unlock(lockName,lockValue);
System.out.println(Thread.currentThread().getName()+" - 释放锁成功");
}
}else {
System.out.println(Thread.currentThread().getName()+" - 获取锁失败");
}
return lockName;
}
private void doSomething(Integer time) throws InterruptedException {
System.out.println("开始执行业务方法-->");
TimeUnit.SECONDS.sleep(time);
System.out.println("结束执行业务方法--<");
}
}
http-nio-8080-exec-1 - 获取锁成功
开始执行业务方法-->
续期:kock_4 ## 19b60f5d-d786-45e0-ad3f-1526689b37c5 - 成功
续期:kock_4 ## 19b60f5d-d786-45e0-ad3f-1526689b37c5 - 成功
结束执行业务方法--<
http-nio-8080-exec-1 - 释放锁成功
到了这里,我们基本完成了一个简单的 非阻塞 锁的开发,实现的功能有:
- 获取锁
- 释放锁
- 避免了错误解锁
- 加锁解锁过程中的原子性
- 锁的自动刷新
接下来我们再在之前的代码上优化一下,完成 阻塞 锁的开发(基于Lockv4.class)
Lockv5.class
// TODO
1.4. 可从入锁
重入锁:也叫做递归锁,指的是在同一线程内,外层函数获得锁之后,内层递归函数仍然可以获取到该锁。换一种说法:同一个线程再次进入同步代码时,可以使用自己已获取到的锁。可重入锁可以避免因同一线程中多次获取锁而导致死锁发生。
获取锁的步骤:
存储在锁中的信息就必须包含:lock_key、hashkey、重入次数。
- 判断lock是否存在 EXISTS lock_5
- 不存在,则自己获取锁,记录重入层数为1.
- 存在,说明有人获取锁了,下面判断是不是自己的锁,即判断hashKey是否存在:HEXISTS lock_5 hashKey
- 不存在,说明锁已经有了,且不是自己获取的,锁获取失败.
- 存在,说明是自己获取的锁,重入次数+1: HINCRBY lock_5 hashKey 1 ,最后更新锁自动释放时间, EXPIRE lock_5 1
释放锁的步骤:
- 判断当前hashKey是否存在: HEXISTS lock_5 hashKey
-
不存在,说明锁已经失效,不用管了
-
存在,说明锁还在,重入次数减1: HINCRBY lock_5 hashKey -1
1.获取新的重入次数,判断重入次数是否为0,为0说明锁全部释放,删除key: DEL lock
-
reentrant_lockv5.lua 获取锁的lua脚本
local key = KEYS[1]
local value = ARGV[1]
local expire = ARGV[2]
if(redis.call('exists', key) == 0) then
redis.call('hset', key, value, '1');
redis.call('expire', key, expire);
return true;
end;
if(redis.call('hexists', key, value) == 1) then
redis.call('hincrby', key, value, '1');
redis.call('expire', key, expire);
return true;
end;
return false;
reentrant_unlockv5.lua 释放锁的lua脚本
local key = KEYS[1]
local value = ARGV[1]
if (redis.call('HEXISTS', key, value) == 0) then
return false; -- 如果已经不是自己,则直接返回
end;
local count = redis.call('HINCRBY', key, value, -1);
if (count == 0) then
redis.call('DEL', key);
return true;
end;
lock_refresh.lua 刷新锁的lua脚本
local key = KEYS[1]
local value = ARGV[1]
local expire = ARGV[2]
if redis.call('hget',key,value) ~= nil then
redis.call('expire',key,expire);
return true;
else
return false;
end
Lockv6.class 可重入锁实现
@Component
public class Lockv6 {
// redis
private static final StringRedisTemplate redisTemplate = SpringUtil.getBean(StringRedisTemplate.class);
// script
private static final DefaultRedisScript<Boolean> LOCK_SCRIPT;
private static final DefaultRedisScript<Boolean> UNLOCK_SCRIPT;
private static final DefaultRedisScript<Boolean> LOCK_RENEWAL_SCRIPT;
// 锁续期
private ScheduledExecutorService executorService;//守护线程池
private static ConcurrentHashMap<String,String> locks = new ConcurrentHashMap<>(); // 锁 map
static {
// 加载脚本
LOCK_SCRIPT = new DefaultRedisScript<>();
LOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("reentrant_lockv5.lua")));
LOCK_SCRIPT.setResultType(Boolean.class);
UNLOCK_SCRIPT = new DefaultRedisScript<>();
UNLOCK_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("reentrant_unlockv5.lua")));
UNLOCK_SCRIPT.setResultType(Boolean.class);
LOCK_RENEWAL_SCRIPT = new DefaultRedisScript<>();
LOCK_RENEWAL_SCRIPT.setScriptSource(new ResourceScriptSource(new ClassPathResource("lock_refresh.lua")));
LOCK_RENEWAL_SCRIPT.setResultType(Boolean.class);
}
@PostConstruct
public void init(){
executorService = Executors.newScheduledThreadPool(1);
// 时间等于或超过time首次执行task,之后每隔period毫秒重复执行task
executorService.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
Enumeration<String> keys = locks.keys();
while (keys.hasMoreElements()) {
String key = keys.nextElement();
// 每次续期 1s
Boolean result = redisTemplate.execute(
LOCK_RENEWAL_SCRIPT,
Collections.singletonList(key), // KEYS[0]
locks.get(key), "1" // ARGV[0,1] args是String类型
);
System.out.println("续期:"+ key+" ## "+locks.get(key) + (result?" - 成功":" - 失败"));
}
}
// 延时 0 ms,每隔500ms运行一次任务
},0,500, TimeUnit.MILLISECONDS);
}
public static boolean tryLock(String lockName,String lockValue) {
Boolean result = redisTemplate.execute(
LOCK_SCRIPT,
Collections.singletonList(lockName), // KEYS[0]
lockValue, "1" // ARGV[0,1] args是String类型
);
// 获取到锁,加入守护线程
if (result){
if (!locks.containsKey(lockName)){
locks.put(lockName,lockValue);
}
}
return result;
}
public static boolean unlock(String lockName, String lockValue) {
locks.remove(lockName);
Boolean result = redisTemplate.execute(
UNLOCK_SCRIPT,
Collections.singletonList(lockName),
lockValue
);
return result;
}
}
TestLockController.class
@RestController
@RequestMapping("/test/lock")
public class TestLockController {
@GetMapping("/v5/lock/{lockValue}")
public String lock_v5(@PathVariable("lockValue") String lockValue){
// 锁的名称,可根据业务任意定制
String lockName = "kock_5";
// String lockValue = UUID.randomUUID().toString();
// 获取到锁返回 true
boolean lock = Lockv6.tryLock(lockName, lockValue);
if (lock){
try {
System.out.println(Thread.currentThread().getName()+" - 获取锁成功");
doSomething(3);
}catch (Exception e){
}finally {
// 根据 锁 和 上锁标识 解锁
Lockv6.unlock(lockName,lockValue);
System.out.println(Thread.currentThread().getName()+" - 释放锁成功");
}
}else {
System.out.println(Thread.currentThread().getName()+" - 获取锁失败");
}
return lockName;
}
private void doSomething(Integer time) throws InterruptedException {
System.out.println("开始执行业务方法-->");
TimeUnit.SECONDS.sleep(time);
System.out.println("结束执行业务方法--<");
}
}
可以看到 kock_5 获取了两次也释放了两次
http-nio-8080-exec-6 - 获取锁成功
开始执行业务方法-->
续期:kock_5 ## hashKey - 成功
续期:kock_5 ## hashKey - 成功
续期:kock_5 ## hashKey - 成功
续期:kock_5 ## hashKey - 成功
http-nio-8080-exec-7 - 获取锁成功
开始执行业务方法-->
续期:kock_5 ## hashKey - 成功
续期:kock_5 ## hashKey - 成功
结束执行业务方法--<
http-nio-8080-exec-6 - 释放锁成功
结束执行业务方法--<
http-nio-8080-exec-7 - 释放锁成功
三. 总结
在本篇文章,我们从0出发,一步一步完成了一个功能较为完善的分布式锁,实现了锁的动态刷新和可重入锁.
但是,本篇文章只是对分布式锁的一次探索,还存在许多的不足,比如不支持阻塞与非阻塞态的切换,不支持公平锁等.你可以使用从本篇文章中学习到的核心思想,再结合 Redission 等主流的分布式锁学习,相信你一定会有意想不到的收获.