Redis的IO多路复用和多线程特性会破坏分布式锁的原子性吗?
Posted JavaEdge.
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Redis的IO多路复用和多线程特性会破坏分布式锁的原子性吗?相关的知识,希望对你有一定的参考价值。
文章收录在我的 GitHub 仓库,欢迎Star/fork:
Java-Interview-Tutorial
1 为什么使用分布式锁?
当有多个客户端并发访问某个共享资源时,比如要修改DB某条记录,为避免记录修改冲突,可将所有客户端从Redis获取分布式锁,拿到锁的客户端才能操作共享资源。
分布式锁实现的关键就是保证加锁、解锁都是原子操作,才能保证多个客户端访问时锁的正确性。而Redis能通过事件驱动框架同时捕获多个客户端的可读事件(命令请求)。在Redis 6.x,还会有多个I/O线程并发读取或写回数据。
那事到如今,分布式锁的原子性,还能被保证吗?
那就得研究一条命令在Redis Server的执行过程,同时看看有I/O多路复用和多I/O线程情况下,分布式锁的原子性是否会被影响。
2 实现分布式锁
分布式锁的加锁操作使用 Redis的SET命令,其提供如下可选参数:
-
NX
- 当操作的K不存在时,Redis会直接创建
- 当操作的K已存在,则返回NULL,Redis对K也不会做任何修改
-
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。
接着根据读取数据的情况,进行异常处理,如:
-
数据读取失败
-
或客户端连接关闭等
若当前客户端是主从复制中的主节点,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进一步处理读取内容
该函数的基本流程:
3.2 命令解析:processInputBuffer函数
根据当前客户端是否有CLIENT_MASTER标记,执行如下分支:
-
Case1
对应客户端无CLIENT_MASTER标记,即当前客户端不属于主从复制中的Master。那processInputBufferAndReplicate函数会直接调用processInputBuffer(在networking.c文件中)函数,对客户端输入缓冲区中的命令和参数进行解析。所以在这里,实际执行命令解析的函数就是processInputBuffer函数。我们一会儿来具体看下这个函数。
-
Case2
对应客户端有CLIENT_MASTER标记,即当前客户端属于主从复制中的Master。processInputBufferAndReplicate除了会调用processInputBuffer函数,解析客户端命令,还会调用replicationFeedSlavesFromMasterStream函数,将主节点接收到的命令同步给从节点。
最终命令解析实际是在processInputBuffer执行的。
首先,processInputBuffer函数会执行一个while循环,不断地从客户端的输入缓冲区中读取数据。然后,它会判断读取到的命令格式,是否以“*”开头。
- 若命令以
*
开头,表明该命令是 PROTO_REQ_MULTIBULK 类型的请求,即符合RESP协议(Redis客户端与服务器端的标准通信协议)的请求。processInputBuffer会进一步调用processMultibulkBuffer解析读取到的命令 - 不是以
*
开头,说明该命令是PROTO_REQ_INLINE类型的请求,并非RESP协议请求。这类命令也被称为管道命令,命令和命令间用换行符\\r\\n
分隔的。如使用Telnet发给Redis的命令就属该类型命令。此时,processInputBuffer会调用processInlineBuffer解析命令。
当命令解析完成后,processInputBuffer就会调用processCommand,开始进入命令处理的第三个阶段,也就是命令执行阶段。
processInputBuffer函数的基本执行流程:
好,那么下面,我们接着来看第三个阶段,也就是命令执行阶段的processCommand函数的基本处理流程。
3.3 命令执行:processCommand
实现在server.c,实际执行命令前的主要逻辑:
-
processCommand调用moduleCallCommandFilters,将Redis命令替换成module想替换的命令
-
processCommand判断当前命令是否为quit命令并做相应处理
-
processCommand调用lookupCommand,在全局变量server的commands成员变量中查找相关命令
全局变量server的commands成员变量是个哈希表,定义在redisServer结构体:
commands成员变量的初始化是在initServerConfig,调用dictCreate完成哈希表创建,再调用populateCommandTable将Redis提供的命令名称和对应的实现函数,插入哈希表。
而这其中的populateCommandTable使用了redisCommand结构体数组redisCommandTable。
redisCommandTable数组是在server.c文件中定义的,它的每一个元素是一个redisCommand结构体类型的记录,对应了Redis实现的一条命令。也就是说,redisCommand结构体中就记录了当前命令所对应的实现函数是什么。
如下代码展示GET、SET等命令信息,实现函数分别是getCommand,setCommand:
所以lookupCommand会根据解析的命令名称,在commands对应的哈希表中查找相应命令。
那么,一旦查到对应命令后,processCommand函数就会进行多种检查,比如命令的参数是否有效、发送命令的用户是否进行过验证、当前内存的使用情况,等等。这部分的处理逻辑比较多,你可以进一步阅读processCommand函数来了解下。
这样,等到processCommand对命令做完各种检查后,就开始执行命令,会判断当前客户端是否有CLIENT_MULTI标记:
-
若有,说明要处理Redis事务相关命令
就要按事务要求,调用queueMultiCommand:将命令入队保存,等待后续再一把梭处理。
-
若无,无关事务特性
processCommand调用call:实际执行命令。call函数执行命令是通过调用命令本身,即redisCommand结构体中定义的函数指针完成。每个redisCommand结构体中都定义了其对应实现函数,在redisCommandTable数组可查到。
分布式锁的加锁操作就是使用SET命令实现的,所以来看SET命令为例,来看一个命令实际执行过程。
SET命令对应实现函数setCommand:首先会判断命令参数,如是否带有NX、EX、XX、PX等可选项,若有,就会记录这些标记。
然后,setCommand会调用setGenericCommand:根据setCommand记录的命令参数标记,进行相应处理。如命令参数中有NX,则setGenericCommand会调用lookupKeyWrite,查找要执行SET命令的key是否已存在。
若K已存在,则setGenericCommand会调用addReply,返回NULL,正符合分布式锁的语义。
若SET命令可正常执行,即:
-
命令带NX选项但K并不存在
-
或带有XX选项但K已存在
这样setGenericCommand就会调用setKey完成KV对的实际插入:
setKey(c->db,key,val);
然后,若命令设置了TTL,setGenericCommand还会调用setExpire函数设置过期时间。最后,setGenericCommand函数会调用addReply函数,将结果返回给客户端,如下所示:
addReply(c, ok_reply ? ok_reply : shared.ok);
SET命令执行流程:
无论:
- 在命令执行过程中,发现不符合命令的执行条件
- 或是命令能成功执行
addReply函数都会被调用以返回结果。所以,这就进入命令处理过程的最后一个阶段:结果返回阶段。
3.4 结果返回阶段:addReply
调用prepareClientToWrite,并在prepareClientToWrite中调用clientInstallWriteHandler,将待写回客户端加入到全局变量server的clients_pending_write列表。
然后,addReply会调用_addReplyToBuffer等函数,将要返回的结果添加到客户端的输出缓冲区。
至此,这就是一条命令如何从读取,经过解析、执行等步骤,最终将结果返给客户端,该过程以及涉及的主要函数:
若在前面命令处理过程中,都由I/O主线程处理,则命令执行的原子性肯定能得到保证,分布式锁的原子性也相应得到保证。
但若这个处理过程配合上了I/O多路复用机制和多IO线程机制,那这俩机制是在这个过程的什么阶段发挥作用的?会不会影响命令执行的原子性?
所以现在就要明确,这俩机制到底参与了什么流程,才能知道是否对原子性保证有副作用。
4 I/O多路复用会影响对命令原子性吗?
I/O多路复用机制是在readQueryFromClient执行前发挥作用的。在事件驱动框架中调用aeApiPoll函数,获取一批已就绪的socket描述符。然后执行一个循环,针对每个就绪描述符上的读事件,触发执行readQueryFromClient函数。
如此,即使I/O多路复用机制同时获取了多个就绪的socket描述符,但实际处理时,Redis主线程仍是针对每个事件逐一调用回调函数进行处理。且针对写事件,I/O多路复用机制也是针对每个事件逐一处理。
I/O多路复用机制通过aeApiPoll获取一批事件,然后逐一处理:
这表明,即使使用I/O多路复用,命令的整个处理过程仍可由I/O主线程完成,也就仍保证命令执行的原子性。如下就是I/O多路复用机制和命令处理过程的关系:
5 多I/O线程会破坏命令原子性吗?
多I/O线程可执行读操作或写操作。对读操作,readQueryFromClient在执行过程中,会调用 postponeClientRead 将待读客户端加入 clients_pending_read 等待列表。
然后,待读客户端会被分配给多I/O线程执行,每个IO线程执行的函数就是 readQueryFromClient,它会读取命令=》调用processInputBuffer解析命令,该过程和Redis 6.0前代码一致。
而Redis 6.0 processInputBuffer新增了个判断条件:若客户端有CLIENT_PENDING_READ标识,则解析完命令后,processInputBuffer只会把客户端标识改为CLIENT_PENDING_COMMAND,就退出命令解析的循环流程。
此时,processInputBuffer只是解析了第一个命令,不会实际调用processCommand执行命令:
这样,等所有I/O线程都解析完了第一个命令后,I/O主线程中执行的handleClientsWithPendingReadsUsingThreads会再调用processCommandAndResetClient执行命令及调用processInputBuffer解析剩余命令。
所以,即使使用多I/O线程,其实命令执行阶段也是由主I/O线程完成,所有命令执行的原子性仍得到保证,即不会破坏分布式锁的原子性。
写回数据流程
该阶段,addReply是将客户端写回操作推迟执行的,而此时Redis命令已完成执行,所以,即使有多个I/O线程在同时将客户端数据写回,也只是把结果返给客户端,并不影响命令在Redis Server的执行结果。因此,即使用了多I/O线程写回,Redis同样不会破坏命令执行的原子性。
使用多I/O线程机制后,命令处理过程各个阶段是由什么线程执行:
6 总结
加锁和解锁操作分别可以使用SET命令和Lua脚本与EVAL命令来完成。那么,分布式锁的原子性保证,就主要依赖SET和EVAL命令在Redis server中执行时的原子性保证了。
Redis中命令处理的整个过程在Redis 6.0版本前都是由主IO线程来执行完成的。虽然Redis使用了IO多路复用机制,但是该机制只是一次性获取多个就绪的socket描述符,对应了多个发送命令请求的客户端。而Redis在主IO线程中,还是逐一来处理每个客户端上的命令的,所以命令执行的原子性依然可以得到保证。
使用Redis 6.0版本后,命令处理过程中的读取、解析和结果写回,就由多IO线程处理。不过多IO线程只是完成解析第一个读到的命令,命令实际执行还是由主IO线程处理。当多IO线程在并发写回结果时,命令就已执行完,不存在多IO线程冲突问题。所以,使用了多IO线程后,命令执行原子性仍可得到保证。
多IO线程实际并不会加快命令的执行,只会将读取解析命令并行化执行,写回结果并行化执行,且读取解析命令还是针对收到的第一条命令。这一设计考虑还是由于网络IO需加速处理。如命令执行本身成为Redis运行时瓶颈,其实可考虑使用Redis切片集群提升处理效率。
以上是关于Redis的IO多路复用和多线程特性会破坏分布式锁的原子性吗?的主要内容,如果未能解决你的问题,请参考以下文章
REDIS01_单线程的概述多线程的引入概述IO多路复用如何开启多线程