扑街前言:之前的分布式锁概述文章中讲到了分布式锁的特性,那么这次就根据这些特性聊一下Redis是如何实现分布式锁的。(认识自己是菜鸟的第九天)
setnx和expire命令:
谈及如何基于Redis如何实现分布式锁或者说简单的锁,那么就一定要涉及setnx和expire两个命令。(这里提供一个网址,个人觉得很好用:Redis 命令参考 — Redis 命令参考)
- setnx:只有键key 值不存的情况下,将键key 值设置为值value。如果键key 值存在,则setnx命令没有任何动作。命令在设置成功时返回 1 ,设置失败时返回 0。
- expire:为给定 key 设置生存时间,当 key 过期时(生存时间为 0 ),它会被自动删除。也可以更新生存时间,对带有生存时间的 key 执行 expire 命令,新指定的生存时间会取代旧的生存时间。而这个命令设置的时间单位为“秒”,这里有个相同的命令pexpire,这个命令的效果是一样的,但是这个的时间单位是“毫秒”。设置成功返回 1 。 当 key 不存在或者不能为 key 设置生存时间时,返回 0 。
根据这两个命令我们可以先用一段伪代码来实现锁的基本流程,如下:
// 判断key值是否已经存入
if (setnx(key, value) == 1) {
// 存入则设置生效时间
expire(key, 30);
try {
/*
* 业务逻辑代码
*/
} finally {
// 释放锁
del(key);
}
}
RedisTemplate:
上面的代码只是一段基本逻辑,那如何用常规的代码实现呢?这里需要提个一个由Spring封装的RedisTemplate对象,这个对象在我看来是同等于Redis提供的RedissonClient(客户端对象)的,下面代码展示一下RedisTemplate对象连接数据库的配置。
可以看出RedisTemplate对象是有一个connectionFactory属性来封装连接Redis数据库的,而connectionFactory属性来源是RedisTemplate对象的父类对象RedisAccessor中的成员属性RedisConnectionFactor对象。
配置和来源说清楚了,那在说一下使用,RedisTemplate.opsForValue.setIfAbsent(key, value),这个方法传入的就是key值和value值,用Redis的setnx命令存储。
还有RedisTemplate.expire(key, time, TimeUnit),key 需要设置的key timeout:key的生存时间 timeuint:时间单位(小时,分钟,秒),用Redis的pExpire命令执行,所以这里执行的是毫秒数。
以及RedisTemplate.delete(key)方法,用Redis的del命令删除。具体代码下面代码段展示。
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:context="http://www.springframework.org/schema/context" xmlns:p="http://www.springframework.org/schema/p"
xmlns:mvc="http://www.springframework.org/schema/mvc" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:tx="http://www.springframework.org/schema/tx" xmlns:util="http://www.springframework.org/schema/util"
xmlns:aop="http://www.springframework.org/schema/aop"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context-3.0.xsd
http://www.springframework.org/schema/mvc
http://www.springframework.org/schema/mvc/spring-mvc-3.0.xsd
http://www.springframework.org/schema/tx
http://www.springframework.org/schema/tx/spring-tx-3.0.xsd
http://www.springframework.org/schema/aop
http://www.springframework.org/schema/aop/spring-aop-3.0.xsd
http://www.springframework.org/schema/util
http://www.springframework.org/schema/util/spring-util-3.0.xsd">
<!--[redis-JedisPoolConfig配置](http://blog.csdn.net/liang_love_java/article/details/50510753)-->
<!-- jedis-2.7.2.jar 依赖jar包 commons-pool2-2.3.jar
jedis基于 commons-pool2-2.3.jar 自己实现了一个资源池。
配置参数 详见 http://blog.csdn.net/liang_love_java/article/details/50510753
-->
<bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig">
<property name="maxIdle" value="1" />
<property name="maxTotal" value="5" />
<property name="blockWhenExhausted" value="true" />
<property name="maxWaitMillis" value="30000" />
<property name="testOnBorrow" value="true" />
</bean>
<bean id="jedisConnectionFactory" class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory">
<property name="hostName" value="10.1.8.200" />
<property name="port" value="6379"/>
<property name="poolConfig" ref="jedisPoolConfig" />
<property name="usePool" value="true"/>
</bean>
<bean id="redisTemplate" class="org.springframework.data.redis.core.RedisTemplate">
<property name="connectionFactory" ref="jedisConnectionFactory" />
<property name="keySerializer">
<bean class="org.springframework.data.redis.serializer.StringRedisSerializer" />
</property>
<property name="valueSerializer">
<bean class="org.springframework.data.redis.serializer.JdkSerializationRedisSerializer" />
</property>
<property name="hashKeySerializer">
<bean class="org.springframework.data.redis.serializer.StringRedisSerializer"/>
</property>
<property name="hashValueSerializer">
<bean class="org.springframework.data.redis.serializer.JdkSerializationRedisSerializer"/>
</property>
</bean>
</beans>
/*
* (non-Javadoc)
* @see org.springframework.data.redis.core.ValueOperations#setIfAbsent(java.lang.Object, java.lang.Object)
*/
@Override
public Boolean setIfAbsent(K key, V value) {
// 转换字节
byte[] rawKey = rawKey(key);
byte[] rawValue = rawValue(value);
// 调用命令执行
return execute(connection -> connection.setNX(rawKey, rawValue), true);
}
/*
* (non-Javadoc)
* @see org.springframework.data.redis.core.RedisOperations#delete(java.lang.Object)
*/
@Override
public Boolean delete(K key) {
// 转换字节
byte[] rawKey = rawKey(key);
// 调用命令执行,返回被删除的数量
Long result = execute(connection -> connection.del(rawKey), true);
// 返回是否成功
return result != null && result.intValue() == 1;
}
/*
* (non-Javadoc)
* @see org.springframework.data.redis.core.RedisOperations#expire(java.lang.Object, long, java.util.concurrent.TimeUnit)
*/
@Override
public Boolean expire(K key, final long timeout, final TimeUnit unit) {
// 转换字节
byte[] rawKey = rawKey(key);
// 转换时间
long rawTimeout = TimeoutUtils.toMillis(timeout, unit);
// 调用命令执行
return execute(connection -> {
try {
return connection.pExpire(rawKey, rawTimeout);
} catch (Exception e) {
// Driver may not support pExpire or we may be running on Redis 2.4
return connection.expire(rawKey, TimeoutUtils.toSeconds(timeout, unit));
}
}, true);
}
以上代码其实就能实现开始时的伪代码内容,但是setnx和expire的命令是非原子性的操作,这样做会很容易出现死锁的问题,那么目前有两种方案解决:Redis2.6版本之前有lua脚本、2.6版本之后可以用set命令,这里的set命令可以传入多个参数来设置,同时RedisTemplate对象将setIfAbsent方法进行了重载,可以用来实现set命令来设置生命时间,如下展示:
/*
* (non-Javadoc)
* @see org.springframework.data.redis.core.ValueOperations#setIfAbsent(java.lang.Object, java.lang.Object, long, java.util.concurrent.TimeUnit)
*/
@Override
public Boolean setIfAbsent(K key, V value, long timeout, TimeUnit unit) {
byte[] rawKey = rawKey(key);
byte[] rawValue = rawValue(value);
Expiration expiration = Expiration.from(timeout, unit);
return execute(connection -> connection.set(rawKey, rawValue, expiration, SetOption.ifAbsent()), true);
}
错误解锁:
上述其实就已经实现了一个基本的锁,但是锁不光只有超时、释放等,现在说一下错误解锁的问题,错误解锁就是当业务执行时间过长,Redis设置的过期时间到了,这个锁由Redis自身解开了,然后由第二个线程拿到业务,进行加锁,然后在第二个线程业务执行时,第一个线程业务执行结束,解除了第二个线程的锁。上篇文章有说到锁的特性中有:加锁解锁必然是同一个线程,这面的这种业务就明显做不到。
解决方案:给锁加一个人为定义的唯一标识来标明当前线程的请求线程唯一ID,用key对应的value值进行存储,当解锁时,判断如果不是当前线程,那么就不解锁,反之解锁。
注意:一定要保证原子性,这个设置不能放在客户端代码中,可以用lua脚本来实现。Redis中也提供lua脚本的执行命令call命令。(这篇文章就不讲这个了,下次再说)
还是刚刚说的场景,线程拿到锁、加锁、加过期时间后,执行业务的过程中,锁的过期时间到了,也就是业务执行时间大于锁的过期时间,这样就出现了问题。
解决方案:为拿到锁的线程,创建一个守护线程,守护线程定时/延迟,判断拿到锁的线程是否还持有锁,是则为锁续期。
这里提供一个思路:创建线程池,创建一个队列(队列中存的就是线程的唯一ID),添加自动任务线程,定时扫描队列,如果队列中存在值则续期,反之跳过。当线程拿到锁的时候,增加队列,让自动任务去扫描续期即可。缺点就是:服务压力过大,资源浪费。
如何可重入:
- 基于本地实现;
- 基于Redis的hash类型来实现。
阻塞/非阻塞:
如何做一个阻塞锁呢?还是两个方案
- 基于客户端轮调,也就是说发现已经有线程拿到了锁,然后就隔一段时间就访问一次,直到拿到锁或者到了一定时间就退出报错。(缺点:浪费资源,服务器压力过大)
- 基于Redis的发布与订阅:简单来说就是A线程先拿到锁,B线程获取不到的时候,订阅锁释放消息,当A线程解锁时,就会将锁释放的消息放在Redis的消息队列中,然后通知B线程过来获取锁。
当实现上诉的所有内容时,一个完整的分布式锁就创建出来了,这个逻辑不光适用于Redis的分布式锁,也适用于其他的中间件来实现分布式锁的原理。下篇文章再说下Redis本身是如何实现分布式锁的,跟一下源码,分析一下。