文章收录在我的 GitHub 仓库,欢迎Star/fork:
Java-Interview-Tutorial

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中命令的执行过程有关。

4 I/O多路复用会影响对命令原子性吗?

I/O多路复用机制是在readQueryFromClient执行前发挥作用的。在事件驱动框架中调用aeApiPoll函数,获取一批已就绪的socket描述符。然后执行一个循环,针对每个就绪描述符上的读事件,触发执行readQueryFromClient函数。

如此,即使I/O多路复用机制同时获取了多个就绪的socket描述符,但实际处理时,Redis主线程仍是针对每个事件逐一调用回调函数进行处理。且针对写事件,I/O多路复用机制也是针对每个事件逐一处理。

I/O多路复用机制通过aeApiPoll获取一批事件,然后逐一处理:

redis保证数据的原子性 redis多实例如何保证原子性_java

这表明,即使使用I/O多路复用,命令的整个处理过程仍可由I/O主线程完成,也就仍保证命令执行的原子性。如下就是I/O多路复用机制和命令处理过程的关系:

redis保证数据的原子性 redis多实例如何保证原子性_分布式_02

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执行命令:

redis保证数据的原子性 redis多实例如何保证原子性_redis_03

这样,等所有I/O线程都解析完了第一个命令后,I/O主线程中执行的handleClientsWithPendingReadsUsingThreads会再调用processCommandAndResetClient执行命令及调用processInputBuffer解析剩余命令。

所以,即使使用多I/O线程,其实命令执行阶段也是由主I/O线程完成,所有命令执行的原子性仍得到保证,即不会破坏分布式锁的原子性。

写回数据流程

该阶段,addReply是将客户端写回操作推迟执行的,而此时Redis命令已完成执行,所以,即使有多个I/O线程在同时将客户端数据写回,也只是把结果返给客户端,并不影响命令在Redis Server的执行结果。因此,即使用了多I/O线程写回,Redis同样不会破坏命令执行的原子性。

使用多I/O线程机制后,命令处理过程各个阶段是由什么线程执行:

redis保证数据的原子性 redis多实例如何保证原子性_java_04

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切片集群提升处理效率。