文章目录
一、分布式锁
分布式锁在分布式系统中非常重要,它用来确保多个进程在同一时间只能有一个进程访问共享资源
1、特性
- 互斥性:在任何时刻只有一个节点可以获得锁。
- 避免死锁:如果持有锁的节点崩溃,其他节点可以获得锁,避免死锁情况的发生。
- 公平性:多个节点同时申请锁时,每个节点都有机会获得锁。
- 可重入性:同一个节点可以多次获取锁,不会被阻塞。
- 高可用:锁服务应该是高可用的,不能因为锁服务故障影响系统整体性能。
2、分布式锁的实现原理
- 锁标识的生成:为了区分不同的锁,需要生成唯一的锁标识。这个标识可以是一个随机字符串、一个特定的命名规则或者基于某种算法生成的唯一值。
- 唯一性保证:锁标识必须在整个分布式系统中是唯一的,以确保不同的节点在获取锁和释放锁时能够准确地识别和操作同一个锁对象。
- 竞争与等待:当多个节点同时尝试获取锁时,会产生竞争。未获得锁的节点需要进入等待状态,直到持有锁的节点释放锁后,它们才有机会再次尝试获取锁。
- 原子操作:获取锁的过程通常需要使用原子操作来确保互斥性。例如,在使用Redis实现分布式锁时,可以利用SETNX(Set if Not Exists)命令,这个命令在执行时是原子性的,即要么成功设置键值对并获得锁,要么失败表示锁已被其他节点持有。
- 准确识别释放者:只有持有锁的节点才能释放锁。在释放锁时,需要确保释放操作的准确性,避免误释放其他节点持有的锁。一般通过验证锁标识来确定释放者的合法性。
- 防止锁残留:如果持有锁的节点在释放锁之前发生故障,可能会导致锁无法正常释放,从而造成死锁或资源长时间被占用。为了防止这种情况,可以为锁设置过期时间,当锁超时后自动释放。
3、实现思路
结合jdk自带的 ReentrantLock,我们分析一下应该如何去实现一个分布式锁
- 标记:ReentrantLock的state就是一个标记,获取到这个标记,就是占有了这把锁
- 可见:标记要对所有线程可见,这样才能知道自己是否能占用当前标记
- 原子:并发操作的时候,只能允许一个人成功,而不允许有线程设置标记的同时另一个线程也能进行设置。类似于ReentrantLock里面的CAS操作
- 互斥:任何时候只能有一个地方能获取到锁,如果锁已被抢占,则无法进行抢占
4、redis实现
假设使用redis实现,可以参考ReentrantLock的第3点的思路
- 标记:因为redis都是key-value的结构 ,我们在redis里面设置一个key,通过这个key作为标记
- 可见:Redis的单线程命令执行,能让每个命令执行顺序执行,不会出现我设置这个key到半还没成功,你就能拿到,因为可能设置失败,变成无锁状态
- 原子:Redis的setnx就属于cas的思想,cas是底层lock编码实现,而setnx是单线程执行
- 互斥:setnx返回OK则代表锁成功,否则代表失败,不可获取锁
以上是理想情况,很多情况下,我们需要保证多个指令的原子性,说到这儿,会想到mysql的事务,redis也有事务,但是redis 事务并不完全符合 ACID 属性
二、Redis 事务
1、命令
Redis 事务通过以下三条命令来管理:
- MULTI:开始一个事务。
- EXEC:执行事务。
- DISCARD:放弃事务。
在事务开始时,所有的命令会被放入一个队列,只有当执行 EXEC 命令时,这些命令才会被依次执行。
2、步骤
- 开始事务
使用 MULTI 命令开启事务。当客户端发送 MULTI 命令后,后续的所有命令不会立即执行,而是被放入一个事务队列中。
redisTemplate.multi();
- 执行事务
之后的所有命令会被放入队列中,直到遇到 EXEC 命令。以下是一个示例:
redisTemplate.boundValueOps("key").set("value1");
redisTemplate.boundValueOps("key").set("value2");
- 提交事务
使用 EXEC 命令提交事务,事务队列中的所有命令会被顺序执行。
List<Object> results = redisTemplate.exec();
-放弃事务
如果在事务开始后,发现不需要执行事务中的命令,可以使用 DISCARD 命令放弃事务:
redisTemplate.discard();
3、WATCH机制
在 Redis 事务中,还可以借助 WATCH 命令实现乐观锁。WATCH 命令用于监控一个或多个键,如果在事务执行期间这些键被修改,则事务会被取消。
// 监控键
redisTemplate.watch("key");
redisTemplate.multi();
redisTemplate.boundValueOps("key").set("value3");
// 提交事务
List<Object> results = redisTemplate.exec();
if (results == null) {
// 事务执行失败,可以选择重新尝试
}
redisTemplate.unwatch();
Redis 事务通过 MULTI/EXEC 命令来实现,将多个命令打包成一个事务,确保这些命令的连续执行,同时提供基本的隔离性和一致性支持。
然而,与传统数据库事务相比,Redis 事务并不支持回滚和锁机制,因此不建议使用redis事务来实现原子性
三、Redis Lua
在 Redis 中,Lua 脚本被广泛用于在 Redis 服务器端执行复杂的原子操作。Redis 自版本 2.6 以来内置了 Lua 解释器,并可以通过使用 EVAL 命令执行 Lua 脚本。
1、命令
- EVAL:用于评估并执行 Lua 脚本。
- EVALSHA:通过脚本的 SHA1 哈希值执行脚本以提高执行效率。
- SCRIPT LOAD:将脚本加载到内存中并返回脚本的 SHA1 哈希值。
- SCRIPT EXISTS:检查服务器中是否已经缓存了指定的脚本。
- SCRIPT FLUSH:从服务器缓存中移除所有 Lua 脚本。
- SCRIPT KILL:终止当前正在执行的 Lua 脚本。
2、编写
Lua 脚本通过 redis.call() 和 redis.pcall() 可以与 Redis 交互。主要区别在于前者在命令失败时会抛出一个错误,而后者则会返回一个错误对象。
2.1、获取和设置键的值
local key = KEYS[1]
local value = ARGV[1]
redis.call("SET", key, value)
return redis.call("GET", key)
2.2、脚本参数
- KEYS:包含脚本传递的键列表,使用 redis.call 或 redis.pcall 执行 Redis 命令时,可以通过 KEYS 访问这些键。
- ARGV:包含传递的其他参数,类似于命令行参数。
2.3、执行脚本
通过 EVAL 命令执行脚本
redis-cli EVAL "local key = KEYS[1] local value = ARGV[1] redis.call('SET', key, value) return redis.call('GET', key)" 1 mykey "myvalue"
2.4、脚本缓存
为了提高效率,建议将频繁使用的脚本缓存到 Redis 中:
# 加载脚本并获取其 SHA1 哈希值
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
# 使用 EVALSHA 来执行缓存的脚本
redis-cli EVALSHA <sha1-hash> 1 mykey
2.5、错误处理
在 Lua 脚本中,错误处理是非常重要的。使用 redis.pcall 可以捕获并处理 Redis 命令的错误。
local res, err = redis.pcall('GET', KEYS[1])
if not res then
return {"ERROR", err}
end
return res
通过 Lua 脚本,Redis 能够执行复杂的原子操作,从而提高性能和简化客户端代码逻辑。这为开发者在构建高效和高性能的分布式系统提供了极大的便利。
四、实践
1、分布式锁实现
1.1、加锁
java
private static final String LOCK_PRE = "redis:lock:";
public String acquireLock(String lockName, long acquireTimeout, long lockTimeout) {
String identifier = UUID.randomUUID().toString();
String lockKey = LOCK_PRE + lockName;
try {
long end = System.currentTimeMillis() + acquireTimeout;
while (System.currentTimeMillis() < end) {
if ("OK".equalsIgnoreCase(setnx(lockKey, identifier, lockTimeout))) {
return identifier;
}
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
} catch (Exception ex) {
log.error("Lock failed", ex);
}
return null;
}
public String setnx(String key, String value, long seconds) {
return redisTemplate.execute((RedisCallback<String>) connection -> {
Object nativeConnection = connection.getNativeConnection();
String result = null;
if (nativeConnection instanceof JedisCommands) {
result = ((JedisCommands) nativeConnection).set(key, value, SetParams.setParams().nx().px(seconds));
}
if (!StringUtils.isEmpty(key) && !StringUtils.isEmpty(result)) {
log.debug("lock {} at {}", key, System.currentTimeMillis());
}
return result;
});
}
1.2、释放锁
java
private RedisTemplate<String, Object> redisTemplate;
private static final String LOCK_PRE = "redis:lock:";
private DefaultRedisScript<String> lockScript;
@PostConstruct
public void init() {
String sb = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end ";
this.lockScript = new DefaultRedisScript<>();
this.lockScript.setScriptText(sb);
this.lockScript.setResultType(String.class);
}
public String executeLuaScript(String key, String value) {
return redisTemplate.execute(lockScript, Collections.singletonList(key), value);
}
public String setnx(String key, String value, long seconds) {
return this.redisTemplate.execute((RedisCallback<String>) connection -> {
Object nativeConnection = connection.getNativeConnection();
String result = null;
if (nativeConnection instanceof JedisCommands) {
result = ((JedisCommands) nativeConnection).set(key, value, SetParams.setParams().nx().px(seconds));
}
if (!StringUtils.isEmpty(key) && !StringUtils.isEmpty(result)) {
log.debug("Get lock {} at {}", key, System.currentTimeMillis());
}
return result;
});
}
public void releaseLockWithLua(String lockName, String identifier) {
String lockKey = LOCK_PRE + lockName;
try {
executeLuaScript(lockKey, identifier);
} catch (Exception ex) {
Object value = this.redisTemplate.opsForValue().get(lockKey);
if (value != null && identifier.equals(value.toString())) {
this.redisTemplate.delete(lockKey);
}
}
}