分布式锁一般的实现方法有三种:基于数据库锁。(行级锁如唯一约束或乐观锁的版本号方式),基于redis 的分布式锁
和基于zookeeper 的分布式锁。网上有很多关于 redis 分布式锁的实现,本文介绍的是基于 redis 锁的一种简单易用实现方式:基于 spring-data-redis 的RedisTemplate 的实现方式。
实现分布式锁要满足的几个条件:
@Bean
public RedisTemplate<String, Serializable> redisTemplateDemo(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, Serializable> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
// 使用 Jackson2JsonRedisSerializer 替换默认序列化
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);
// 字符串序列化器
StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
// 普通Key设置为字符串序列化器
template.setKeySerializer(stringRedisSerializer);
// Hash结构的key设置为字符串序列化器
template.setHashKeySerializer(stringRedisSerializer);
// 普通值和hash的值都设置为jackson序列化器
template.setValueSerializer(jackson2JsonRedisSerializer);
template.setHashValueSerializer(jackson2JsonRedisSerializer);
template.afterPropertiesSet();
return template;
}
@SuppressWarnings("unchecked")
@PostConstruct
public void init() {
getLockRedisScript = new DefaultRedisScript<String>();
getLockRedisScript.setResultType(String.class);
releaseLockRedisScript = new DefaultRedisScript<String>();
releaseLockRedisScript.setResultType(String.class);
//初始化装载 lua 脚本
getLockRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("script/getLock.lua")));
releaseLockRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("script/releaseLock.lua")));
}
这里用的是 set 命令加{NX|PX参数}, 有同学喜欢用 setnx,但setnx 不支持过期时间,需要与 expire一起用。命令详见:redis doc(很奇怪setnx命令为什么不支持过期时间 ?)
脚本调用set命令返回OK,加锁成功,若此时再有线程用相同的 key调用 set ,Redis 会返回为空,确保了锁只能被一个线程占有。用request id 作为 value 值,可用UUID.randomUUID()作为线程锁的标识,这样就能保证只有加锁的线程才能解锁。
if redis.call('set',KEYS[1],ARGV[1],'NX','PX',ARGV[2]) then
return '1'
else
return '0'
end
参数解释:
KEYS【1】:key值是为要加的锁定义的字符串常量ARGV【1】:value值是 request id, 用来防止解除了不该解除的锁. 可用 UUID
ARGV【2】: 过期时间,锁占用时间一般不会太长,业务处理占用锁时间太长会造成其他线程阻塞太久。
注:在 Redis 2.6.12 版本以前,SET 命令总是返回 OK。我用的是redis 版本是 3.0的。
if redis.call('get',KEYS[1]) == ARGV[1] then
return tostring(redis.call('del',KEYS[1]))
else
return '0'
end
这里 redisTemplateDemo.execute()方法用到的是RedisConnection 的 eval()方法, eval方法可以确保原子性。源于 redis 的特性:eval命令执行Lua代码的时候,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。
/**
* 加锁操作
* @param key Redis 锁的 key 值
* @param requestId 请求id,防止解了不该由自己解的锁 (随机生成)
* @param expireTime 锁的超时时间(毫秒)
* @param retryTimes 获取锁的重试次数
* @return true or false
*/
@SuppressWarnings("unchecked")
public boolean lock(String key, String requestId, String expireTime, int retryTimes) {
if (retryTimes <= 0)
retryTimes = 1;
try {
int count = 0;
while (true) {
Object result = redisTemplateDemo.execute(getLockRedisScript, stringSerializer, stringSerializer, Collections.singletonList(key),
requestId, expireTime);
if (EXEC_RESULT.equals(String.valueOf(result))) {
return true;
} else {
count++;
if (retryTimes == count) {
log.warn("has tried {} times , failed to acquire lock for key:{},requestId:{}", count, key, requestId);
return false;
} else {
log.warn("try to acquire lock {} times for key:{},requestId:{}", count, key, requestId);
Thread.sleep(100);
continue;
}
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return false;
}
/**
* 解锁操作
* @param key Redis 锁的 key 值
* @param requestId 请求 id, 防止解了不该由自己解的锁 (随机生成)
* @return true or false
*/
@SuppressWarnings("unchecked")
public boolean unLock(String key, String requestId) {
Object result = redisTemplateDemo.execute(releaseLockRedisScript, stringSerializer, stringSerializer, Collections.singletonList(key),
requestId);
if (EXEC_RESULT.equals(String.valueOf(result))) {
return true;
}
return false;
}
测试场景:100 个线程同时修改一个静态常量值 NUM(初始值为0),每个线程加1,如果不加锁会看到打印结果不都是100。
每个线死循环确保一定获取锁且执行+1操作。这样的锁竞争是比较大了。
/**
* 测试多线程竞争锁场景
* 100 个线程同时修改一个静态常量值 NUM(初始值为0)
* 每个线程加1,最终结果 100
* (如果不加锁不能确定结果是100)
*/
@Override
public void testLockAndUnlock() {
String key = "prefix_key123456"; // redis 锁的 key 值
String expireTime = "5000";// 锁的超时时间(毫秒),评估任务时间,建议任务的时间不要太长
int retryTimes = 3;// 获取锁的重试次数
NUM = 0;// 共享变量
int threasCount = 100;// 线程任务个数
List<Thread> list = new ArrayList<Thread>();
for (int i = 0; i < threasCount; i++) {
list.add(new Thread(new Runnable() {
@Override
public void run() {
// request id, 防止解了不该由自己解的锁。
String requestId = UUID.randomUUID().toString();
while (true) { //这里循环操作,以确保该线程一定获得锁并执行线程任务
if (redisUtil.lock(key, requestId, expireTime, retryTimes)) {
try {
// 调用业务逻辑
doSomething();
} finally {
redisUtil.unLock(key, requestId);
}
break;
}
}
}
}));
}
//启动所有任务线程
for (Thread t : list) {
t.start();
}
//轮询状态,等待所有子线程完成
while (true) {
int aliveThreadCount = 0;
for (Thread t : list) {
if (t.isAlive()) {
++aliveThreadCount;
}
}
if (aliveThreadCount == 0) {
log.debug("All Threads are completed!");
break;
} else {
log.debug("Threads have not yet completed, sleep 5s!");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
//打印最终结果值: NUM
log.info("Completed! The final value of NUM is : {}", NUM);
}
运行结果:测试多次结果可以看到都是等于 100。
另注:代码只在单机redis环境测试过,多机redis 及集群部署环境没有测试过,欢迎大家提供测试结果和解决方案,有兴趣的同学可以看看Redisson实现。
GitHub源码地址: demo-redis
参考资料:
https://blog.csdn.net/u014495560/article/details/82531046
https://www.cnblogs.com/linjiqin/p/8003838.html
http://redisdoc.com
http://www.redis.cn/topics/distlock.html