Redis的IO多路复用和多线程特性会破坏分布式锁的原子性吗?(上)

1 为什么使用分布式锁?

当有多个客户端并发访问某个共享资源时,比如要修改DB某条记录,为避免记录修改冲突,可将所有客户端从Redis获取分布式锁,拿到锁的客户端才能操作共享资源。


分布式锁实现的关键就是保证加锁、解锁都是原子操作,才能保证多个客户端访问时锁的正确性。而Redis能通过事件驱动框架同时捕获多个客户端的可读事件(命令请求)。在Redis 6.x,还会有多个I/O线程并发读取或写回数据。


那事到如今,分布式锁的原子性,还能被保证吗?


那就得研究一条命令在Redis Server的执行过程,同时看看有I/O多路复用和多I/O线程情况下,分布式锁的原子性是否会被影响。

2 实现分布式锁

分布式锁的加锁操作使用 Redis的SET命令,其提供如下可选参数:

  1. NX
    • 当操作的K不存在时,Redis会直接创建
    • 当操作的K已存在,则返回NULL,Redis对K也不会做任何修改
  1. EX:设置K的过期时间


可让客户端发送如下命令执行加锁:

  • lockKey,锁的名称
  • uid,客户端用于唯一标记自己的ID(优化后的雪花算法)
  • expireTime,该K所代表的锁的过期时间,当这过期时间到达后,该K会被删除,相当于释放锁,这就避免锁一直无法释放问题(当客户端所在机器宕机时)。
SET lockKey uid EX expireTime NX

加锁

而若还没客户端创建过锁,假设客户端A发送了这个SET命令给Redis:

SET stockLock 1033 EX 30 NX

Redis就会创建对应K=stockLock,V=客户端的ID 1033。此时,假设另一客户端B也发了SET,要把K=stockLock对应的V改为客户端B的ID 2033,即加锁。

SET stockLock 2033 EX 30 NX

由于NX参数,若stockLock的K已存在,客户端B就无法对其进行修改,即无法获得锁,这就实现了加锁效果。

解锁

使用Lua脚本完成,会以EVAL命令形式在Redis Server执行。客户端会使用GET命令读取锁对应K的V,并判断V是否等于客户端自身ID:

  • 若相等,表明当前客户端正拿着锁
    此时可执行DEL命令删除K,即释放锁
  • 若value不等于客户端自身ID
    则该脚本会直接返回。
if redis.call("get",lockKey) == uid then
   return redis.call("del",lockKey)
else
   return 0
end

这样客户端就不会误删除别的客户端获得的锁,保证了锁的安全性。


无论是加锁的SET命令,还是解锁的Lua脚本和EVAL命令,在I/O多路复用下会被同时执行吗?或者当使用多I/O线程后,会被多个线程同时执行吗?即I/O多路复用引入的多个并发客户端及多I/O线程是否会破坏命令的原子性。


这就和Redis中命令的执行过程有关。

3 一条命令在Redis是如何完成执行的?

Redis Server一旦和某一客户端建立连接后,就会在事件驱动框架中注册可读事件,对应客户端的命令请求。整个命令处理的过程可分为如下阶段:

  • 命令解析,对应processInputBufferAndReplicate
  • 命令执行,对应processCommand
  • 结果返回,对应addReply

3.1 命令读取阶段:readQueryFromClient函数

会从客户端连接的socket中,读取最大为readlen长度的数据,readlen大小为宏定义PROTO_IOBUF_LEN,默认16KB。

Redis的IO多路复用和多线程特性会破坏分布式锁的原子性吗?(上)

Redis的IO多路复用和多线程特性会破坏分布式锁的原子性吗?(上)

接着根据读取数据的情况,进行异常处理,如:

  • 数据读取失败
  • 或客户端连接关闭等


若当前客户端是主从复制中的主节点,readQueryFromClient会把读取的数据,追加到用于主从节点命令同步的缓冲区中。

最后,调用processInputBuffer,进入命令解析阶段。

void readQueryFromClient(aeEventLoop *el, int fd, void *privdata, int mask) {
   ...
   readlen = PROTO_IOBUF_LEN;  //从客户端socket中读取的数据长度,默认为16KB
   ...
   c->querybuf = sdsMakeRoomFor(c->querybuf, readlen);  //给缓冲区分配空间
   nread = read(fd, c->querybuf+qblen, readlen);  //调用read从描述符为fd的客户端socket中读取数据
    ...
    processInputBufferAndReplicate(c);  //调用processInputBufferAndReplicate进一步处理读取内容
}

该函数的基本流程:

Redis的IO多路复用和多线程特性会破坏分布式锁的原子性吗?(上)

3.2 命令解析:processInputBuffer函数

根据当前客户端是否有CLIENT_MASTER标记,执行如下分支:

  • Case1

对应客户端无CLIENT_MASTER标记,即当前客户端不属于主从复制中的Master。那processInputBufferAndReplicate函数会直接调用processInputBuffer(在networking.c文件中)函数,对客户端输入缓冲区中的命令和参数进行解析。所以在这里,实际执行命令解析的函数就是processInputBuffer函数。我们一会儿来具体看下这个函数。

  • Case2

对应客户端有CLIENT_MASTER标记,即当前客户端属于主从复制中的Master。processInputBufferAndReplicate除了会调用processInputBuffer函数,解析客户端命令,还会调用replicationFeedSlavesFromMasterStream函数,将主节点接收到的命令同步给从节点。

Redis的IO多路复用和多线程特性会破坏分布式锁的原子性吗?(上)

最终命令解析实际是在processInputBuffer执行的

首先,processInputBuffer函数会执行一个while循环,不断地从客户端的输入缓冲区中读取数据。然后,它会判断读取到的命令格式,是否以“*”开头

  • 若命令以*开头,表明该命令是 PROTO_REQ_MULTIBULK 类型的请求,即符合RESP协议(Redis客户端与服务器端的标准通信协议)的请求。processInputBuffer会进一步调用processMultibulkBuffer解析读取到的命令
  • 不是以*开头,说明该命令是PROTO_REQ_INLINE类型的请求,并非RESP协议请求。这类命令也被称为管道命令,命令和命令间用换行符\r\n分隔的。如使用Telnet发给Redis的命令就属该类型命令。此时,processInputBuffer会调用processInlineBuffer解析命令。

Redis的IO多路复用和多线程特性会破坏分布式锁的原子性吗?(上)

当命令解析完成后,processInputBuffer就会调用processCommand,开始进入命令处理的第三个阶段,也就是命令执行阶段。

processInputBuffer函数的基本执行流程:

Redis的IO多路复用和多线程特性会破坏分布式锁的原子性吗?(上)

好,那么下面,我们接着来看第三个阶段,也就是命令执行阶段的processCommand函数的基本处理流程。

上一篇:Redis的IO多路复用和多线程特性会破坏分布式锁的原子性吗?(中)


下一篇:Java及JVM是如何识别重载、重写方法的?(上)