本文已参与「新人创作礼」活动,一起开启掘金创作之路。
分布式锁
对于单进程 ( 单个 JVM,即单个项目部署 ) 的并发, 可以使用 Java 提供的锁机制. 但是对于多 JVM 进程的分布式系统, 必须使用 分布式锁 了.
分布式锁可以保证同一时间只有一个 Java 客户端可以对共享资源进行操作.
分布式锁的实现方式
常见的实现方式:
- Memcached 分布式锁. 利用 Memcached 的
add 命令. 此命令是原子性操作, 只有在 key 不存在的情况下, 才能 add 成功, 也就意味着线程得到了锁. - Redis 分布式锁. 利用 Redis 的
setnx命令. 此命令同样是原子性操作, 只有在 key 不存在的情况下, 才能 set 成功. set 成功,就意味着得到了锁,set 失败,则是抢锁失败. - Zookeeper 分布式锁. 利用 Zookeeper 的
顺序临时节点, 实现分布式锁和等待队列. Zookeeper 设计的初衷, 就是为了实现分布式锁服务的.
几种实现方式的比较:
Zookpper 的可靠性比 Redis 高, 只是效率低了点, 如果并发量不是特别大, 追求可靠性, 首选 Zookpeer. 追求效率, 则首选 Redis 实现. 而 Memcached 现在也很少使用,不再推荐.
选择 Redis 实现的话,可以自己去实现,也可以直接使用开源Redisson 的工具包.
自己实现 Redis 分布式锁
基本原理
多个客户端同时向 Redis 中 set 指定名称的 key, 哪个客户端存入成功, 就代表哪个客户端拿到了分布式锁, 其他的客户端则加锁失败.
(1) 加锁流程
加锁使用原子命令 setnx 命令. 其中 key 是锁的唯一标识, 可以按照业务来决定命名.
- 比如想要给一种商品的秒杀活动加锁 lock_sale_goodID.
- 比如同一个卡券的使用, 加锁的 key 是 lock_union_vip_ticketId
而 value 值一般设置为加锁的线程的 ID, 可以防止其他线程误删此锁,也就是只有加锁的线程才可以解锁.
当一个线程执行 setnx 返回 1, 说明 key 不存在, 这个线程成功得到了锁. 返回 0, 说明 key 已经存在(锁已经被其他线程抢走了), 这个线程抢锁失败.
(2) 解锁流程
解锁使用 del 命令即可。
当得到锁的线程执行完任务, 需要释放锁, 也就是删除 key. 释放锁之后, 其他线程就可以继续执行 setnx 命令来获得锁.
一般在删除锁时,需要进行一个判断,就是只有加锁的线程才能解这个锁. 避免锁误删.
(3) 锁超时
使用 expire 命令设置超时时间.
如果得到锁的线程在执行任务的过程中挂掉, 没有来得及释放锁, 那么这个锁将会一直存在于 redis 中, 其他线程将永远无法获取到这个锁了.
为了避免这种情况的发生, 一般都需要对锁设置超时时间, 以保证即使没有被显式释放, 也要在一定时间后自动释放.
对锁超时时间的理解: 假如超时时间为 30s, 不是说这个锁必须要 存在 30s,只要任务执行完毕, 随时可以解锁.设置超时时间只是为了防止死锁.
(4) 锁续期
锁续期解决的问题是,锁的过期时间太短,任务还没完成, 锁就要被释放掉了,如何延长锁的生命周期.
锁续期多采用 看门狗机制,其实就是启动一个守护线程,当锁到了过期时间还没被释放锁时,就自动延长一段时间,直到主动释放.
❓❓❓疑问?
锁超时和锁续期似乎是相斥的。当一个持有锁的线程挂掉以后,如果使用了锁超时机制,可以防止死锁,但是无法解决过期时间太短,导致任务未执行完毕就解锁的问题。而如果使用了锁续期机制,那么似乎无法防止死锁,锁将永远不会过期.
✔️✔️✔️解答
当持有锁的线程挂掉后,其相应的守护线程也就停掉了,也就不会再续期,所以不会存在死锁问题.
Redis 分布式锁 v1 版
通过上面的分析, 可以得到 v1 版本的分布式锁, 实现如下 :
if(setnx(key,1) == 1){ //获取到了锁
expire(key,30) //设置过期时间
try {
// 业务代码
} finally {
del(key) //释放锁, 放在 finally 块中, 保证即使发生异常, 也能成功解锁.
}
}else{
log.info("没有获取到锁")
}
上面的代码看似实现了需求,但是存在着几个致命的问题:
① 问题 1:加锁和设置超时时间的组合操作不是原子性的
setnx 和 expire 都是原子的, 但是它们的组合却是非原子的.
考虑一下这种场景: 线程 A 获取到了锁, 但是还没有来得及设置锁的过期时间时, 线程 A 就挂掉了, 这个 key 变成了无过期时间的 key, 其他线程也将永远获取不到锁, 这个锁也永远不会超时,变为了死锁.
② 问题 2: 锁误删
锁超时了,在解锁时导致误删.
考虑一下这种场景: 假如节点 1 的线程 A 得到了锁, 设置了超时时间是 30 秒. 但是这个线程 A 执行的很慢很慢, 过了 30 秒都没执行完, 这时候锁的超时时间到了, 锁被自动释放, 然后节点 2 的线程 B 抢到了锁, 并执行他的代码. 随后, 线程 A 执行完了任务, 线程 A 接着执行 del 去释放锁. 但这时候线程 A 加的锁其实早就释放了, 线程 A 实际上删除的是线程 B 加的锁.
不过,如果有锁续期机制, 那么误删的情况应该就不会发生了.
③ 问题 3: 锁续期
考虑一下这种场景: 一个任务花费的时间根据数据库中的数据量有关, 所以不能确定其执行时间, 当设置的锁的超时时间比较短, 而任务没完成时, 锁已经释放掉了,这种情况怎么解决呢?
其实还是问题 2, 线程 A 执行的任务没结束, 锁的超时时间到了, key 被删除了,也就是锁被自动释放了, 其他线程就可以来获取锁了, 导致同一段代码有多个线程在访问.
Redis 分布式锁 v2 版
针对 v1 版本存在的问题, 如下方式进行解决 :
① 解决加锁原子问题
可以使用 set(key, value, time, NX) 命令取代 setnx, 这个命令可以实现设置 key 的同时设置过期时间, 这是一个原子性操作. time 是锁的过期时间, 单位秒, NX 表示当 key 不存在时, 设置.
(2) 解决锁误删问题
在解锁之前进行判断: 判断当前的锁是不是自己加的.
具体的实现: 在加锁的时候把当前的线程 ID 当做 value, 在解锁前, 先判断 value 是不是自己线程的 ID, 如果是, 才能释放该锁. 需要注意的是, 判断和释放锁是两个独立操作, 不是原子性的. 所以这一块要用 Lua 脚本来实现.
(3) 解决锁续期问题
对任务还没结束 ( 锁还没有释放, 说明任务还没结束 ), 但锁即将到期的锁, 再给这个锁延长过期时间. 可以让获得锁的线程开启一个守护线程, 用来给快要过期的锁续航, 延长其过期时间.
假设节点 1 的 线程 A 获取到了锁, 超时时间是 30 秒, 当过去了 29 秒, 线程 A 还没执行完, 这时候节点 1 的守护线程会执行 expire 指令, 为这把锁续命 20 秒. 守护线程从第 29 秒开始执行, 每间隔 20 秒执行一次. 当线程 A 执行完任务, 释放锁, 同时需要显式关掉守护线程.
另一种情况, 如果节点 1 忽然断电, 由于线程 A 和守护线程在同一个进程, 守护线程也会停下. 这把锁到了超时的时候, 没人给它续命, 也就自动释放了.
通过上面的分析, 我们可以得到 v2 版本的分布式锁实现如下 :
private static final String LOCK_SUCCESS = "OK"; //表示加锁成功
private static final Long RELEASE_SUCCESS = 1L; //释放锁成功
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
/**
* 尝试获取分布式锁
* @param jedis 客户端
* @param lockKey 锁的 key
* @param requestId 锁的 value
* @param expireTime 超期时间
* @return 是否获取成功
*/
public static boolean tryGetLock(Jedis jedis, String lockKey,String requestId, int expireTime) {
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST,SET_WITH_EXPIRE_TIME, expireTime);
return LOCK_SUCCESS.equals(result);
}
/**
* 释放分布式锁, 使用 lua 脚本释放
* @param jedis 客户端
* @param lockKey 锁的 key
* @param requestId 锁的 value
* @return 是否释放成功
*/
public static boolean releaseLock(Jedis jedis, String lockKey, String requestId) {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] " + "then return redis.call('del', KEYS[1]) else return 0 end";
Object result = jedis.eval(script, Collections.singletonList(lockKey),Collections.singletonList(requestId));
return RELEASE_SUCCESS.equals(result);
}
基本上此时的代码已经是很完善的了, 绝大数可以应对. 注意这里没有解决锁续期问题.
Redis 分布式锁 v3 版
还可以对 v2 版本的锁进行优化, 让它支持 如果没有获取到锁时, 循环获取锁. 也就是实现自旋.
/**
* @param jedis 客户端
* @param lockKey 锁的 key
* @param requestId 锁的 value
* @param acquireTimeout 在获取锁之前的超时时间,也就是可以循环获取锁的时间
* @param timeOut 在获取锁之后的超时时间
*/
public String tryGetLock(Jedis jedis, String lockKey,String requestId,Long acquireTimeout, Long timeOut) {
try {
// 使用循环机制 如果没有获取到锁, 要在规定acquireTimeout时间 保证重复进行尝试获取锁
Long endTime = System.currentTimeMillis() + acquireTimeout;
while (System.currentTimeMillis() < endTime) {
// 获取锁
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST,SET_WITH_EXPIRE_TIME, expireTime);
return LOCK_SUCCESS.equals(result);
}
} catch (Exception e) {
e.printStackTrace();
}
return false; //在 acquireTimeout 时间内, 依旧没获取到锁
}
public void releaseLock(Jedis jedis, String lockKey, String requestId) {
// 同 v2
}
Redisson 分布式锁
Redisson 是 Java 的 redis 客户端之一, 提供了一些 api 方便操作 Redis. 其中便实现了分布式锁, 我们上面考虑的这些问题, 它都进行了解决, 只需要直接使用即可.
Redission 实现的锁支持 Redis Cluster 模式, Master-Slave 模式, Redis 哨兵模式和 Redis 单机模式, 功能非常强大.
中文文档:
在项目中使用
1.引入依赖
<!-- 如果是 Spring,则引入 -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.17.5</version>
</dependency>
<!-- 如果是 SpringBoot,则引入 -->
<!-- redisson-springboot -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.11.4</version>
</dependency>
2.Redisson 配置类
import com.huanxi.pay.util.RedisPropertyUtils;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* @author hao.li
* @Title:
* @Description: Redisson 配置类
* @date 2022/7/2510:24
*/
@Configuration
public class RedissonConfig {
private static final String HOST = RedisPropertyUtils.getString("redis.hostName");
private static final String PART = RedisPropertyUtils.getString("redis.port");
private static final String PASSWORD = RedisPropertyUtils.getString("redis.password");
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
String redisUrl = String.format("redis://%s:%s", HOST, PART); // 注意redis的地址写法,要加redis:://
config.useSingleServer().setAddress(redisUrl).setPassword(PASSWORD);
config.useSingleServer().setDatabase(0);
return Redisson.create(config);
}
}
3.在业务代码中使用
@Autowired
private RedissonClient redissonClient;
@Transactional(rollbackFor = Exception.class)
public void exchangeUnionVip() {
String lockKey = String.format("UNION_VIP_%s_%s", uid, param.getUnionId());
// 使用 Redisson 分布式锁
RLock lock = redissonClient.getLock(lockKey);
if (!lock.tryLock(10, TimeUnit.MILLISECONDS)) { // 10ms抢不到锁,就立刻抢锁失败
ResponseUtils.echoJson(response, 5000, "操作太频繁");
return;
}
try {
System.out.println("获取到锁了,时间:" + System.currentTimeMillis() + ",线程:" + Thread.currentThread().getName());
// 业务逻辑
} finally {
try {
lock.unlock();
System.out.println("释放掉锁了,时间:" + System.currentTimeMillis() + ",线程:" + Thread.currentThread().getName());
}catch(Exception e){
// redisson 解锁时,不是同一个线程加的锁,会报错,或者锁超时了,也会报错
// 所以这里捕获一下,
// 但不需要做任何操作,让 <操作太频繁> 的提示返回给钱前端
}
}
}
可以看到加的锁如下:
使用中遇到的问题
(1) 注意 lock 和 tryLock 的区别
- lock() 方法拿不到锁时会一直等待,而 tryLock() 方法是去尝试获取锁, 拿不到就返回 false, 拿到返回 true.
- tryLock() 方法是可以被打断的, 被中断的, 而 lock() 方法是不可以.
(2) Redisson 报错
Redisson 报错:attempt to unlock lock, not locked by current thread by node id: 1bfd77bb-3ce7-4919-a532-db95d6e3d57e thread-id: 256
两个原因:
- 在解锁时, 不是持有锁的线程来解锁,而是其他线程解锁.
- 没开启锁续期, 过期时间默认 30s,超过 30s,即使用同一个线程解锁也会报这个异常,因为锁已经不存在了.
看门狗机制
面试官:请问你用 Redis 做分布式锁的时候,如果指定过期时间到了,把锁给释放了。但是任务还未执行完成,导致任务再次被执行,这种情况你会怎么处理呢?
正确的答案就是:看门狗,或者一种类似于看门狗的机制。
错误的答案:这个问题我遇到过,但是我就是把过期时间设置的长一点。但是时间到底设置多长,是一个非常主观的判断,设置的长一点,能一定程度上解决这个问题,但是不能完全解决。或者不设置过期时间,由程序调用 unlock 来保证。这是在程序层面可控、可保证的。但是如果程序运行的服务器刚好还没来得及执行 unlock 就宕机了,这个锁是不是就死锁了。
为了解决过期时间不好设置,以及一不小心死锁的问题,Redisson 内部基于时间轮,针对每一个锁都搞了一个定时任务(守护线程),这个定时任务,就是看门狗。这个机制通过定时任务不断的延长锁的有效期。
看门狗机制很好的解决了上面的两个问题:
① 过期时间不好设置问题
有了看门狗,根本就不需要再设置过期时间,这从根本上解决了过期时间不好设置的问题。
默认情况下,看门狗的检查锁的超时时间是 30s 钟,也可以通过修改参数来另行指定。
② 有可能死锁问题
如果很不幸,节点宕机了导致没有及时释放锁,那么在默认的配置下,最长30s的时间后,这个锁就自动释放了. 因为程序宕机后,定时任务不存在了,无法再对锁进行续期操作,所以就可以自动释放。
总结:
- 如果加锁的时候
指定了过期时间,那么 Redission 不会给你开启看门狗的机制. - 看门狗
默认续期时间是 10s,即每经过 10s 都会将锁的过期时间设置为 默认的 30s.
Redisson 的缺点
如果是 Redis Cluster 模式, 这个 key 会被异步复制给其他节点. 但是在复制的过程中, 如果主节点挂了, 还没来得及复制给子节点. 虽然客户端 1 以为加锁成功了, 但其实这个 key 已经丢失.
在完成主备切换后, 客户端 2 也来加锁, 它也可以加锁成功, 这样就导致了多个客户端对一个分布式锁完成了加锁, 可能会造成脏数据. 这也是 Redisson 在 master-slave, redis cluster 的缺陷. 即没来得及将锁同步给其他从节点.
实现原理
TODO
RedLock 分布式锁
RedLock 是一种算法,它很好的解决了上面说的 Redission 的缺点. Redisson 中也包含了 Redlock 算法封装的分布式锁.
TODO
Spring 提供的分布式锁
Spring 也提供了 Redis 的分布式锁, 相关的代码被迁移在 Spring Integration 的子项目 spring-integration-redis 中.
- 引入依赖
- 配置 连接 redis
- 编写 RedisLock 的配置类
- 使用