分布式锁是用于控制分布式系统中的多个节点对共享资源的访问。由于分布式系统中的节点可能位于不同的机器甚至不同的地理位置,因此分布式锁的实现比线程锁和进程锁要复杂得多。分布式锁需要在网络中的多个节点之间进行协调,以保证锁的唯一性和一致性。
分布式锁就像是一个网络版的门卫,确保在多台计算机上运行的程序不会同时操作同一个数据。想象一下,每台计算机都要先拿到这个门卫的钥匙,才能操作数据。这样,就能防止数据混乱,确保每次只有一个程序在使用数据。
如果你觉得我分享的内容或者我的努力对你有帮助,或者你只是想表达对我的支持和鼓励,请考虑给我点赞、评论、收藏。您的鼓励是我前进的动力,让我感到非常感激。
1 分布式锁简介
1.1 什么是分布式锁
分布式锁是一种在分布式系统环境下,通过多个节点对共享资源进行访问控制的一种同步机制。它的主要目的是防止多个节点同时操作同一份数据,从而避免数据的不一致性。
- 线程锁:
也被称为互斥锁(Mutex),主要用于控制同一进程中的多个线程对共享资源的访问。 - 进程锁:
进程锁是用于控制同一台机器上的多个进程对共享资源的访问。进程锁可以是系统级的,如文件锁,也可以是用户级的,如信号量(Semaphore)。 - 分布式锁:
分布式锁是用于控制分布式系统中的多个节点对共享资源的访问。由于分布式系统中的节点可能位于不同的机器甚至不同的地理位置,因此分布式锁的实现比线程锁和进程锁要复杂得多。分布式锁需要在网络中的多个节点之间进行协调,以保证锁的唯一性和一致性。
1.2 分布式锁的特性
分布式锁主要有以下几个特性:
- 互斥性: 在任何时刻,只有一个节点可以持有锁。
- 不会发生死锁: 如果一个节点崩溃,锁可以被其他节点获取。
- 公平性: 如果多个节点同时申请锁,系统应该保证每个节点都有获取锁的机会。
- 可重入性: 同一个节点可以多次获取同一个锁,而不会被阻塞。
- 高可用: 锁服务应该是高可用的,不能因为锁服务的故障而影响整个系统的运行。
2 分布式锁的基本原理
2.1 分布式锁的基本步骤
分布式锁的基本原理可以分为以下几个步骤:
- 请求锁: 当一个实例需要访问共享资源时,它会向分布式锁系统发送一个请求,试图获取一个锁。
- 锁定资源: 分布式锁系统会检查是否有其他实例已经持有这个锁。如果没有,那么这个实例就会获得锁,并且有权访问共享资源。如果有,那么这个实例就必须等待,直到锁被释放。
- 访问资源: 一旦实例获取了锁,它就可以安全地访问共享资源,而不用担心其他实例会同时访问这个资源。
- 释放锁: 当实例完成对共享资源的访问后,它需要通知分布式锁系统释放锁。这样,其他正在等待的实例就可以获取锁,访问共享资源。
2.2 分布式锁实现的关键点
在实现分布式锁时,通常会有一个中心节点(或者称为锁服务),所有需要获取锁的节点都需要向这个中心节点申请。
当一个节点申请锁时,中心节点会检查当前是否有其他节点持有锁,如果没有,则将锁分配给申请的节点;如果有,则拒绝申请。当持有锁的节点完成操作后,会向中心节点归还锁,此时其他的节点可以再次申请锁。
3 基于Redis的分布式锁
3.1 基本介绍
Redis是一个开源的,内存中的数据结构存储系统,它可以用作数据库、缓存和消息代理。
Redis 提供了多种命令和能力来支持实现分布式锁
- SETNX 命令: SETNX(Set if Not Exists)命令用于在 key 不存在时设置值。这是实现分布式锁的关键命令,因为它能确保在同一时间只有一个客户端能够获得锁。
- EXPIRE 命令: EXPIRE 命令用于为 key 设置过期时间。这对于避免死锁非常重要,因为即使某个客户端崩溃,锁也会在一定时间后自动释放。
- DEL 命令: DEL 命令用于删除 key。在释放锁时,需要使用此命令删除对应的 key。
- Lua 脚本: Redis 支持使用 Lua 脚本来执行一系列原子操作。这对于实现安全的分布式锁非常有用,因为它可以确保在释放锁时检查锁的持有者。
- RedLock 算法: Redis 官方推荐了一种名为 RedLock 的分布式锁算法。RedLock 是一种基于多个 Redis 实例的分布式锁算法,旨在提供更高的安全性和容错能力。
一般,在实现Redis分布式锁时,不分开使用SETNX和EXPIRE命令,而是使用SETNX的拓展命令 SET NX EX
3.2 Redis实现分布式锁的基本实现
3.2.1 请求锁
setIfAbsent( key, value)
- 如果键不存在则新增,存在则不改变已经有的值。
- 存在返回 false,不存在返回 true。
boolean lock = redisTemplate.opsForValue().setIfAbsent("testKey","123");
一开始是不存在key “testKey” 的,所以lock 为 true;但是后面创建并添加了值“123”。
redisTemplate.opsForValue().get("testKey");
是有值的,为“123”。
lock = redisTemplate.opsForValue().setIfAbsent("testKey","321");
已存在key “testKey”,所以无法赋值,lock 为 false;
redisTemplate.opsForValue().get("testKey");
值没变,还是“123”。
public boolean tryLock(String lockKey, String lockValue, Long lockOutTime) {
return lockKey != null && lockValue != null && lockOutTime != null ? this.redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, lockOutTime, TimeUnit.SECONDS) : false;
}
3.2.2 锁续期
为了防止锁过早地因为过期而被释放,可以在锁快到期时进行续期操作。这可以通过定期检查锁的剩余时间,并在必要时使用 EXPIRE 命令来更新过期时间来实现。
RedisTemplate里的方法:
- 获取过期时间
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public Long getExpire(String key) {
return redisTemplate.getExpire(key, TimeUnit.SECONDS);
}
- 设置过期时间
@Override
public Boolean expire(K key, final long timeout, final TimeUnit unit) {...
3.2.3 释放锁
当客户端完成需要加锁保护的操作后,应该释放锁。为了确保只有锁的持有者才能释放锁,可以使用 Lua 脚本来执行释放操作。
public void releaseLock(String lockKey, String lockValue) {
List<String> keys = new ArrayList();
keys.add(lockKey);
List<String> args = new ArrayList();
args.add(lockValue);
Long result = (Long)this.redisTemplate.execute(new LongRedisCallback(keys, args));
}
3.2.4 使用案例
工具类:
RedisUtils
/**
* Gets distribute lock.
*
* @param lockKey the lock key
* @param lockValue the lock value
* @param retryTime the retry time
* @param timeOut the time out 锁超时时间,超过这个时间后自动释放
*/
public void getDistributeLock(String lockKey, String lockValue, int retryTime, long timeOut) {
try {
int count = 0;
while (!distributeLock.tryLock(lockKey, lockValue, timeOut)) {
redisTemplate.expire(lockKey, timeOut, TimeUnit.SECONDS);
count++;
if (count > retryTime) {
log.warn("Get lock fail.");
throw new CommonServiceException(AIModelError.REDIS_GET_LOCK_EXCEPTION, " lockKey: " + lockKey);
}
Thread.sleep(300);
}
} catch (InterruptedException exp) {
log.error("Get lock error.");
throw new CommonServiceException(AIModelError.REDIS_GET_LOCK_EXCEPTION, " lockKey: " + lockKey, exp);
}
}
/**
* Release lock.
*
* @param lockKey the lock key
* @param lockValue the lock value
*/
public void releaseLock(String lockKey, String lockValue) {
distributeLock.releaseLock(lockKey, lockValue);
}
/**
* 模糊删除指定key
*
* @param key <description>
* @return Boolean <description>
*/
public Boolean dimDelete(String key) {
// redis开关判断
if (!isOpen) {
return false;
}
try {
Set<String> keys = redisUtil.scan(key + "*");
if (CollectionUtils.isEmpty(keys)) {
return false;
}
return redisTemplate.delete(keys) > 0;
} catch (Exception e) {
log.error("key:{}", key);
log.error("RedisUtils.dimDelete error.", e);
}
return false;
}
RedisDistributeLock
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.FutureTask;
import java.util.concurrent.TimeUnit;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import redis.clients.jedis.Jedis;
public class RedisDistributeLock {
private static final Long SLEEPTIME_SECONDS = 2L;
private static final Long FREQUENCY = 3L;
private static final String UNLOCK_LUA;
private StringRedisTemplate redisTemplate;
public RedisDistributeLock() {
}
private String getValue(String key) {
return key == null ? null : (String)this.redisTemplate.opsForValue().get(key);
}
private void expire(String key, long timeout, TimeUnit timeUnit) {
if (key != null) {
this.redisTemplate.expire(key, timeout, timeUnit);
}
}
public boolean tryLock(String lockKey, String lockValue, Long lockOutTime) {
return lockKey != null && lockValue != null && lockOutTime != null ? this.redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, lockOutTime, TimeUnit.SECONDS) : false;
}
public void releaseLock(String lockKey, String lockValue) {
List<String> keys = new ArrayList();
keys.add(lockKey);
List<String> args = new ArrayList();
args.add(lockValue);
Long result = (Long)this.redisTemplate.execute(new LongRedisCallback(keys, args));
}
public void doDaemon(String lockKey, String lockValue, Long lockOutTimeSeconds) throws InterruptedException {
Callable<Integer> callable = this.setDaemon(lockKey, lockValue, lockOutTimeSeconds);
FutureTask<Integer> futureTask = new FutureTask(callable);
(new Thread(futureTask)).start();
TimeUnit.SECONDS.sleep(SLEEPTIME_SECONDS);
}
private Callable<Integer> setDaemon(String lockKey, String lockValue, Long lockOutTimeSeconds) {
return () -> {
long waitTime = lockOutTimeSeconds / FREQUENCY;
while(true) {
TimeUnit.SECONDS.sleep(waitTime);
String value = this.getValue(lockKey);
if (value == null || !value.equals(lockValue)) {
return 0;
}
this.expire(lockKey, lockOutTimeSeconds, TimeUnit.SECONDS);
}
};
}
public void setRedisTemplate(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
static {
StringBuilder sb = new StringBuilder();
sb.append("if redis.call(\"get\",KEYS[1]) == ARGV[1] ");
sb.append("then ");
sb.append("return redis.call(\"del\",KEYS[1]) ");
sb.append("else ");
sb.append("return 0 ");
sb.append("end");
UNLOCK_LUA = sb.toString();
}
private static class LongRedisCallback implements RedisCallback<Long> {
private final List<String> keys;
private final List<String> args;
public LongRedisCallback(List<String> keys, List<String> args) {
this.keys = keys;
this.args = args;
}
public Long doInRedis(RedisConnection connection) {
Object nativeConnection = connection.getNativeConnection();
return (Long)((Jedis)nativeConnection).eval(RedisDistributeLock.UNLOCK_LUA, this.keys, this.args);
}
}
}
使用
正常使用
@GetMapping(value = "/redisTest")
public String redisTest() {
// 首先,获取锁,不能放在下面的try代码块里,方式获取锁失败后,把别人正在使用的锁释放
redisUtils.getDistributeLock(Constants.RedisConstants.UPDATE_TASK_LOCK_KEY_PREFIX,
Constants.RedisConstants.UPDATE_TASK_LOCK_VALUE, Constants.RedisConstants.UPDATE_TASK_LOCK_RETRY_TIME, 60l);
try {
// TODO 业务代码【在处理业务时,可以查询过期时间,也可以延续过期时间】
} finally {
// 释放锁
redisUtils.releaseLock(Constants.RedisConstants.UPDATE_TASK_LOCK_KEY_PREFIX,
Constants.RedisConstants.UPDATE_TASK_LOCK_VALUE);
}
return "OK";
}
项目启动后清理锁
private void clearRedisCache() {
try {
redisUtils.dimDelete(Constants.RedisConstants.UPDATE_TASK_LOCK_KEY_PREFIX);
} catch (Exception exp) {
log.error("clean redis cache error!");
ExceptionUtils.printExceptionInfo(exp);
}
}
3.3 Redis分布式锁的使用场景
Redis分布式锁可以用于所有需要在分布式环境中同步访问共享资源的场景。例如,电商秒杀活动中,为了防止超卖,可以使用Redis分布式锁来保证同一时刻只有一个请求可以操作库存。又如,在分布式计算中,为了防止重复计算,可以使用Redis分布式锁来保证同一时刻只有一个节点可以进行计算。
3.4 Redis分布式锁的优点和缺点
优点:
- 性能高:由于Redis是基于内存的,因此Redis分布式锁的性能非常高。
- 实现简单:Redis提供的命令可以很容易地实现分布式锁。
缺点:
- 不可重入:Redis分布式锁默认是不可重入的,如果需要可重入,需要额外的逻辑来实现。
- 非阻塞:Redis分布式锁是非阻塞的,如果获取锁失败,需要自己进行重试。
- 安全性:如果Redis服务器出现故障,可能会导致锁无法正常工作。
4 其他分布式锁的实现方式
4.1 基于数据库的分布式锁
- 数据库分布式锁是通过在数据库中创建一个锁表,表中包含锁的名称和锁的状态等信息。
- 当一个节点需要获取锁时,它会在这个表中插入一条记录,如果插入成功,那么这个节点就获取到了锁。当节点使用完锁后,会删除这条记录,从而释放锁。
- 这种方式的优点是实现简单,缺点是性能较低,且如果数据库出现故障,可能会影响到锁的功能。
4.2 基于Zookeeper的分布式锁
- Zookeeper是一个开源的分布式协调服务,它提供了一种高效且可靠的分布式锁实现方式。
- 在Zookeeper中,可以创建一个临时节点作为锁,当一个节点需要获取锁时,它会尝试创建这个临时节点,如果创建成功,那么这个节点就获取到了锁。
- 当节点使用完锁后,会删除这个临时节点,从而释放锁。如果节点崩溃,Zookeeper会自动删除这个临时节点,从而避免了死锁的问题。
4.3 基于Etcd的分布式锁
- Etcd是一个开源的分布式键值存储系统,它也提供了一种分布式锁的实现方式。
- Etcd的分布式锁是通过创建一个带有TTL(Time To Live)的键值对来实现的,当一个节点需要获取锁时,它会尝试创建这个键值对,如果创建成功,那么这个节点就获取到了锁。
- 当节点使用完锁后,会删除这个键值对,从而释放锁。如果节点崩溃,Etcd会自动删除这个键值对,从而避免了死锁的问题。
5 各种实现方式的比较
在选择分布式锁的实现方式时,需要根据具体的应用场景和需求来决定。
6 分布式锁的常见问题和解决方案
6.1 死锁问题
问题:
当一个客户端获取了锁,但由于某些原因(如程序崩溃、异常等)无法释放锁时,会导致其他客户端永远无法获取锁。
解决方案:
设置锁的过期时间。当锁的持有者未能在过期时间内执行完毕并释放锁时,锁将自动过期,从而允许其他客户端获取锁。
6.2 锁续命问题
问题:
如果一个操作需要的时间可能超过锁的过期时间,那么在操作执行过程中锁过期会导致其他客户端获取到锁,从而产生并发问题。
解决方案:
使用锁续命机制。在锁持有者执行操作期间,可以定期检查锁是否即将过期,并在适当的时候对锁进行续命,即重新设置锁的过期时间。
6.3 锁释放问题
问题:
为确保数据的一致性,只有锁的持有者才能释放锁。但在实际应用中,可能会出现误解锁的情况。
解决方案:
在设置锁时,为锁关联一个唯一的值(如UUID)。在释放锁时,先检查锁的值是否与当前客户端的值匹配,如果匹配则释放锁,否则不做任何操作。注意,锁持有人的判断和锁的释放应该在一个原子操作内完成。
6.4 锁的公平性问题
问题:
在高并发环境中,如果多个节点同时请求获取锁,可能会出现“饥饿”现象,即某些节点长时间无法获取到锁。
解决方案:
引入队列,将请求锁的节点按照顺序排队。例如,在Zookeeper中,可以使用顺序节点来实现公平锁。
6.5 锁的可重入性问题
问题:
在某些场景中,一个节点可能需要多次获取同一个锁,如果锁不支持重入,可能会导致死锁。
解决方案:
为锁添加一个拥有者的概念,只有锁的拥有者才能再次获取到锁。例如,在Redis中,可以将锁的值设置为节点的唯一标识,获取锁时检查锁的值是否为自己的标识。
6.6 锁的安全性问题
问题:
如果分布式锁的存储系统(如Redis、Zookeeper等)出现故障,可能会导致锁无法正常工作。
解决方案:
使用高可用的存储系统,如使用Redis集群或Zookeeper集群。另外,可以使用心跳机制来检测存储系统的状态,如果检测到故障,可以及时进行切换。