redis使用范围广泛,分布式锁就是其中之一,面试官也最喜欢问的装逼问题之一。今天通过简单剖析源码,分析为啥redis可以用作分布式锁的实现
锁。根据维基百科上的说明:每个线程在访问对应资源前都需获取锁的信息,再根据信息决定是否可以访问。若访问对应信息,锁的状态会改变为锁定,因此其他线程此时不会访问该资源,当资源结束后,会恢复锁的状态,允许其他线程的访问。
为啥需要锁?
一段代码(甲)正在分步修改一块数据。这时,另一条线程(乙)由于一些原因被唤醒。如果乙此时去读取甲正在修改的数据,而甲碰巧还没有完成整个修改过程,这个时候这块数据的状态就处在极大的不确定状态中,读取到的数据当然也是有问题的。更严重的情况是乙也往这块地方写数据,这样的一来,后果将变得不可收拾。因此,多个线程间共享的数据必须被保护。达到这个目的的方法,就是确保同一时间只有一个临界区域处于运行状态,而其他的临界区域,无论是读是写,都必须被挂起并且不能获得运行机会。
说白了就是应用进行逻辑处理时经常会遇到并发问题,如果不对共享数据进行保护,那么将导致数据不一致。在同一个JVM中,通过JUC包下lock或者Java类库自带的synchronized可以实现加锁操作。但对于不同的JVM的,这种方式就不适合了,所以就引出分布式锁的概念。
分布式锁:是控制分布式系统之间同步访问共享资源的一种方式
实现分布式锁的方式有很多,比如通过redis、zookeeper、数据库乐观锁等。网上提到最多的就是前面两个,由于今天主题是redis,所以今天着重分析redis是如何实现分布式锁的以及为啥redis适合分布式锁实现
1、实现分布式锁
客户端。网上扒的一段代码,代码都差不多
//加锁
public String lock(String key) {
Jedis jedis = null;
try {
jedis = redisConnection.getJedis();
jedis.select(dbIndex);
key = KEY_PRE + key;
String value = fetchLockValue();
if (SET_SUCCESS.equals(jedis.set(key, value, "NX", "EX", lockExpirseTime))) {
return value;
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (jedis != null) {
jedis.close();
}
}
return null;
}
//解锁
public boolean unLock(String key, String value) {
Long RELEASE_SUCCESS = 1L;
Jedis jedis = null;
try {
jedis = redisConnection.getJedis();
jedis.select(dbIndex);
key = KEY_PRE + key;
String command = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
if (RELEASE_SUCCESS.equals(jedis.eval(command, Collections.singletonList(key), Collections.singletonList(value)))) {
return true;
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (jedis != null) {
jedis.close();
}
}
return false;
}
- 加锁使用setnx或者setex命令,这两个命令可以确保set和expire指令原子执行。setnx与setex命令有些不同:前者是键存在不做任何操作,后者是键存在就覆盖。所以加锁最好用setnx
- 解锁时先判断该锁是不是当前线程持有的,如果是才调用del指令删掉键。注意这里也必须需要原子性执行。因为判断和del是两个指令,假如不原子执行,线程1刚好执行判断完,过期时间到期了,线程2抢到锁,结果线程1执行删除键操作,导致把线程2加的锁给删掉了
以上就是加锁解锁的逻辑,很简单。但是背后的redis执行过程可是不简单,经过一序列复杂的执行流程。下面简单过一遍源码
setnx
这个指令包含两个指令:set和expire。具体执行过程如下:
- 1、当多路复用函数监听到客户端文件事件,交给事件分派器进行分派,最终交给文件事件读readQueryFromClient处理器处理
- 2、readQueryFromClient收到响应后,开始从缓冲区里读数据进行分析,提取出命令请求中包含的命令参数, 以及命令参数的个数,然后分别将参数和参数个数保存到客户端状态的argv属性和 argc属性里面
- 3、调用命令执行器,执行客户端指定的命令
setnx命令的命令执行器是setnxCommand,具体实现在t_string.c中
void setGenericCommand(redisClient *c, int flags, robj *key, robj *val, robj *expire, int unit, robj *ok_reply, robj *abort_reply) {
long long milliseconds = 0; /* initialized to avoid any harmness warning */
// 取出过期时间
if (expire) {
// 取出 expire 参数的值
// T = O(N)
if (getLongLongFromObjectOrReply(c, expire, &milliseconds, NULL) != REDIS_OK)
return;
// expire 参数的值不正确时报错
if (milliseconds <= 0) {
addReplyError(c,"invalid expire time in SETEX");
return;
}
// 不论输入的过期时间是秒还是毫秒
// Redis 实际都以毫秒的形式保存过期时间
// 如果输入的过期时间为秒,那么将它转换为毫秒
if (unit == UNIT_SECONDS) milliseconds *= 1000;
}
// 如果设置了 NX 或者 XX 参数,那么检查条件是否不符合这两个设置
// 在条件不符合时报错,报错的内容由 abort_reply 参数决定
if ((flags & REDIS_SET_NX && lookupKeyWrite(c->db,key) != NULL) ||
(flags & REDIS_SET_XX && lookupKeyWrite(c->db,key) == NULL))
{
addReply(c, abort_reply ? abort_reply : shared.nullbulk);
return;
}
// 将键值关联到数据库
setKey(c->db,key,val);
// 将数据库设为脏
server.dirty++;
// 为键设置过期时间
if (expire) setExpire(c->db,key,mstime()+milliseconds);
// 发送事件通知
notifyKeyspaceEvent(REDIS_NOTIFY_STRING,"set",key,c->db->id);
// 发送事件通知
if (expire) notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC,
"expire",key,c->db->id);
// 设置成功,向客户端发送回复
// 回复的内容由 ok_reply 决定
addReply(c, ok_reply ? ok_reply : shared.ok);
}
从源码中可以看到,
1、键值是保存到dict字典数据结构中
2、过期时间也专门用一个dict字典数据结构来保存:key是锁的键,v是过期时间。为啥要用专门的一个dict来保存这些过期时间?
其实这里的key和第一点提到的key是同一个指针,也就是共享同一片内存,所以把过期时间单独抽出来保存到另一个dict里跟保存到当前dict,占用的空间是一样的。但是查询效率却不一样了,假如想获取所有添加了过期时间的key,这种方式则只需要o(1)就可以实现。
3、当使用setnx指令时,首先根据key到dict中查询是否已经存在,如果已经存在,则根据abort_reply参数报错指定的内容。setnx指令abort_reply参数是shared.czero,也就是-1。所以当加锁不成功就返回-1,这个值的具体实现在server.c#createSharedObjects函数中
//是一个为-1的字符串
shared.cnegone = createObject(REDIS_STRING,sdsnew(":-1\r\n"));
以上就是redis加锁的简单过程。解锁也差不多,就不分析了
总结
1、在上一篇文章中提到过,redis执行大部分指令是单线程执行的。除了个别磁盘IO比较多的指令外。setnx是纯内存操作,所以在redis中自然是单线程执行,这种执行有先后顺序,自然最适合实现分布式锁了
2、redis在集群条件下,上面所说的方法是有缺陷的。因为redis的主从同步是异步执行的,无法保证从节点及时更新。当主节点挂掉时,如果从节点的数据不是最新的,还有同步线程1的加锁信息,那么另外一个线程来加锁,立即就被批准了。这样就会导致系统中同样一把锁被两个线程持有,导致不安全。
不过redis也提供了redlock算法用于解决这个问题。这个用起来有点复杂,具体原理我暂时也不太清楚,待后面看源码再分析
3、总体来说,单机分布式锁采用redis是比较简单的,但容易有不安全问题,这种不安全也仅仅是在主从发生 failover 的情况下才会产生,而且持续时间极短,业务系统多数情况下可以容忍。涉及到集群时,采用redis实现起来就有点麻烦了
网友评论