分布式锁的实现三种方式
数据库乐观锁
基于Redis实现的分布式锁
基于Zookeeper实现的分布式锁
实现分布式锁应该保证的四要素
互斥性
加锁和解锁的操作应该具备原子性操作。同一时刻,只能有一个线程加锁成功。
不会出现死锁问题
即使一个线程在持有锁期间因为崩溃而没有主动释放锁的,也应该按照超时一段时间后释放锁,保证后续的线程可以持有锁。
具有容错性
解锁人必须为加锁人
加锁和解锁必须是同一个线程(客户端),不能是其他的线程解锁了当前线程的锁。
代码实现
package com.ly.ybg.utils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import redis.clients.jedis.Jedis;
import java.util.Collections;
/**
* Redis 分布式锁实现
* 如有疑问可参考 @see <a href="https://www.cnblogs.com/linjiqin/p/8003838.html">Redis分布式锁的正确实现方式</a>
*
*
*/
@Service
public class RedisLockUtil {
private static final Long RELEASE_SUCCESS = 1L;
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
// 当前设置 过期时间单位, EX = seconds; PX = milliseconds
private static final String SET_WITH_EXPIRE_TIME = "EX";
// if get(key) == value return del(key)
private static final String RELEASE_LOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 该加锁方法仅针对单实例 Redis 可实现分布式加锁
* 对于 Redis 集群则无法使用
*
* 支持重复,线程安全
*
* @param lockKey 加锁键
* @param clientId 加锁客户端唯一标识(采用UUID)
* @param seconds 锁过期时间
* @return
*/
public boolean tryLock(String lockKey, String clientId, long seconds) {
return redisTemplate.execute((RedisCallback<Boolean>) redisConnection -> {
Jedis jedis = (Jedis) redisConnection.getNativeConnection();
String result = jedis.set(lockKey, clientId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, seconds);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
});
}
/**
* 与 tryLock 相对应,用作释放锁
*
* @param lockKey
* @param clientId
* @return
*/
public boolean releaseLock(String lockKey, String clientId) {
return redisTemplate.execute((RedisCallback<Boolean>) redisConnection -> {
Jedis jedis = (Jedis) redisConnection.getNativeConnection();
Object result = jedis.eval(RELEASE_LOCK_SCRIPT, Collections.singletonList(lockKey),
Collections.singletonList(clientId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
});
}
}
加锁—代码解析
public boolean tryLock(String lockKey, String clientId, long seconds) {
return redisTemplate.execute((RedisCallback<Boolean>) redisConnection -> {
Jedis jedis = (Jedis) redisConnection.getNativeConnection();
String result = jedis.set(lockKey, clientId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, seconds);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
});
}
# jedis.set(final String key, final String value, final String nxxx, final String expx,final long time);
# 整个jedis.set的过程是原子性的,同时通过SET_IF_NOT_EXIST可以满足同一时刻只有一个线程持有锁,满足了第一要素:互斥性。
# - key: 使用lockKey作为键,该键名必须唯一。
# - value: clientId - 每个客户端的唯一标识。可以通过该值知道是哪个请求发送的。后面才能判断加锁和解锁是不是同一个请求,即通过该值可以满足第四个要素。
# - nxxx: SET_IF_NOT_EXIST(NX) -- 只有满足了当前key不存在的时候,才会进行set操作,如果key存在,则不做任何操作。
# - expx: 设置过期时间的单位(EX=seconds, PX=millseconds)。
# - time: 过期的时长,结合expx可以确定具体时长。 -- 通过expx和time参数可以满足第二个要素,即避免死锁的问题。
加锁-错误代码示例
public static void wrongGetLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
Long result = jedis.setnx(lockKey, requestId);
if (result == 1) {
// 若在这里程序突然崩溃,则无法设置过期时间,将发生死锁
jedis.expire(lockKey, expireTime);
}
}
# jedis.setnx 和 jedis.expire 是两个操作,不满足原子性。
# 如果系统在执行 if(result == 1) 的判断时崩溃了,就导致加锁了却没有设置过期时间,可能会造成死锁问题。
解锁—代码解析
private static final String RELEASE_LOCK_SCRIPT =
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
public boolean releaseLock(String lockKey, String clientId) {
return redisTemplate.execute((RedisCallback<Boolean>) redisConnection -> {
Jedis jedis = (Jedis) redisConnection.getNativeConnection();
Object result = jedis.eval(RELEASE_LOCK_SCRIPT, Collections.singletonList(lockKey),
Collections.singletonList(clientId));
if (RELEASE_SUCCESS.equals(result)) {
return true;
}
return false;
});
}
# jedis.eval(String lua_script, List<String> keys, List<String> args); -- 主要用来执行lua脚本
# - lua_script: lua脚本
# - keys: 键名参数列表
# - args: 附件参数列表,键值参数列表 (这里是clientId -- 表示的是客户端的ID标识)
# --> redis 127.0.0.1:6379> EVAL script numkeys key [key ...] arg [arg ...]
# --> script: lua脚本
# --> numkeys: 指定键名参数的个数
# --> key[key ...]: 第三个参数开始的键名参数, 在lua脚本里面用 KEYS[1]、KEYS[2]表示
# --> arg[arg ...]: 附加参数,在lua脚本里通过全局变量 ARGV数组访问,ARGV[1]、ARGV[2]表示。
# -- "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"
# 如果通过redis.call执行获取当前KEYS[1]的键值等于ARGV[1]的情况,那么就执行删除KEYS[1]的键。
# 1: 删除成功 0:删除失败或无删除
# lua脚本的执行,必须是先完整的执行了lua脚本,redis才能进行后续的操作。
解锁—错误代码示例
public static void wrongReleaseLock1(Jedis jedis, String lockKey) {
jedis.del(lockKey);
}
# 这种操作无法判断当前的客户端标识,会导致任何的客户端都可以释放锁。
public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {
// 判断加锁与解锁是不是同一个客户端
if (requestId.equals(jedis.get(lockKey))) {
// 若在此时,这把锁突然不是这个客户端的,则会误解锁
jedis.del(lockKey);
}
}
# 这种操作里,判断加锁与解锁是否为同一个客户端的操作和删除锁的操作是两步的操作,不具备原子性。
# 如果此时在调用jedis.del()方法的时候,这把锁已经不属于当前客户端的时候,会解除其他持有该锁的客户端的锁。
# 比如客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行了jedis.del()方法,则就将客户端B的锁给解除了。