前言
在多线程环境中,如果多个线程同时访问共享资源(例如多机进行缓存预热,购买商品库存等),会发生数据竞争,可能会导致出现脏数据或者系统问题,威胁到程序的正常运行。
因此,需要在同一时刻只有一个线程能够访问共享资源。
一般可以基于Redis或者ZooKeeper来实现分布式锁。使用MySQL的方式由于数据库性能受限,一般不考虑。
Redis的实现较为通用简易,这里主要介绍Redis的实现方式。
一、简易的分布式锁
1.分布式锁的基本要求
要实现锁,首先要确保锁在同一时间内只能被一个线程拥有,即互斥。
其次,该线程修改共享资源的操作结束后需要将锁释放,避免产生死锁。
2.加锁
在Redis中,可以使用SETNX(SET IF NOT EXISTS)来实现简单的互斥。
该方式可以在仅当key不存在时才设置key的值。
也就是只有当锁不存在时,才会尝试加锁。
SETNX lockKey value
(integer) 1
SETNX lockKey value
(integer) 0
Key 值应该是唯一的,以避免不同的锁之间发生冲突。通常使用业务相关的信息来构造 key 值。
例如需要锁定一个订单,可以使用 lock:order:<order_id> 作为 key 值。
Value 值应该是一个唯一标识符,用于标识持有该锁的客户端。这样可以确保在释放锁时,只有持有该锁的客户端才能释放它。
比如可以使用 UUID.randomUUID().toString() 生成一个唯一的值。
3.释放锁
在释放锁时,使用DEL删除对应的key即可。
DEL lockKey
(integer) 1
到这里,一个最简单的分布式锁就实现完成了。虽然实现简单,但是这种方式显然存在一些问题。
比如当服务器出现问题,一个线程获取到锁后,执行代码的过程中遇到错误,导致无法进行释放锁的操作。此时其它线程也无法获取锁,就产生了死锁。
二、给锁增加过期时间
针对上面的问题,就需要给锁增加一个过期时间。让锁无论如何最终都会释放。
1.具体操作
SET lockKey value EX 3 NX
lockKey:加锁的锁名;
uniqueValue:能够唯一标识锁的随机字符串;
NX:只有当 lockKey 对应的 key 值不存在的时候才能 SET 成功;
EX:过期时间设置(秒为单位)EX 3 标示这个锁有一个 3 秒的自动过期时间。
与 EX 对应的是 PX(毫秒为单位),这两个都是过期时间设置。
这段命令确保了设置指定 key 的值和过期时间是一个原子操作,如果分开设置,仍然有可能出现死锁情况。
2.操作时间和过期时间不匹配
这个时候,就会引出一个新的问题:
如果设置过期时间过短,操作共享资源的时间要大于过期时间,就无法保证互斥。
但如果设置过期时间过长,操作早已完成,却仍然占用锁,就会影响性能。
有没有什么两全其美的办法?
有,就是如果操作共享资源的操作还未完成,就进行自动续期。
三、看门狗机制
1.机制原理
为了做到自动续期,就需要用到看门狗机制。
这个机制的使用前提是未指定锁超时时间
在Redisson中提供的renewExpiration()方法包含了看门狗机制的主要逻辑。
private void renewExpiration() {
//......
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public 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);
}
在默认情况下,看门狗会每10秒进行一次续期操作。
首先判断是否需要续期,如果需要,则将锁的过期时间设置为30秒。
2.最终具体实现
看门狗机制的分布式锁可以直接基于Redisson来实现。
在配置好Redisson后,正常进行加锁和释放锁的操作,自动续期操作会由Redisson代为实现。
1.初始化 Redisson 客户端:
从配置文件 redisson.yaml 中加载配置并创建 RedissonClient 实例。
2.获取锁:
使用 RLock 对象的 tryLock 方法尝试获取锁,并指定租约时间。如果获取成功,锁的租约时间会自动续期,默认每 30 秒续期一次,防止任务长时间执行时锁被意外释放。
3.释放锁:
使用 unlock 方法释放锁,确保只有持有锁的线程可以释放锁。
public class DistributedLock {
private static final RedissonClient redissonClient = RedissonManager.getRedissonClient();
public boolean acquireLock(String lockKey, long leaseTime, TimeUnit unit) {
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试加锁,指定租约时间,自动续期时间为 30 秒
boolean isLocked = lock.tryLock(0, leaseTime, unit);
if (isLocked) {
System.out.println("Lock acquired");
}
return isLocked;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
public void releaseLock(String lockKey) {
RLock lock = redissonClient.getLock(lockKey);
if (lock.isHeldByCurrentThread()) {
lock.unlock();
System.out.println("Lock released");
}
}
public static void main(String[] args) {
DistributedLock distributedLock = new DistributedLock();
String lockKey = "myLock";
long leaseTime = 10;
TimeUnit unit = TimeUnit.SECONDS;
if (distributedLock.acquireLock(lockKey, leaseTime, unit)) {
try {
// 执行业务逻辑
System.out.println("Executing business logic");
// 模拟长时间任务
Thread.sleep(15000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
distributedLock.releaseLock(lockKey);
}
}
}