【Redis原理】Redis事务与线程模型
目录
一、Redis 事务
什么是 Redis 事务?
事务生命周期与关键命令
如何解决 Redis 事务的缺陷?
二、Redis 线程模型
Redis 是单线程吗?
Redis 单线程模式是怎样的?
Redis 采用单线程为什么还这么快?
Redis 6.0 之前为什么使用单线程?
Redis 6.0 之后为什么引入了多线程?
一、Redis 事务
什么是 Redis 事务?
你可以将 Redis 中的事务理解为:Redis 事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断。
Redis 事务实际开发中使用的非常少,功能比较鸡肋,不要将其和我们平时理解的关系型数据库的事务混淆了。
除了不满足原子性和持久性之外,事务中的每条命令都会与 Redis 服务器进行网络交互,这是比较浪费资源的行为。明明一次批量执行多个命令就可以了,这种操作实在是看不懂。
因此,Redis 事务是不建议在日常开发中使用的。
事务生命周期与关键命令
MULTI
: 开启事务。
-
执行
MULTI
命令后,客户端连接进入事务状态。 -
后续发送的所有命令(除了
EXEC
,DISCARD
,WATCH
,UNWATCH
以及可能导致入队失败的命令)不会被立即执行,而是被服务器按顺序放入一个队列(事务队列)中。 -
服务器返回
QUEUED
表示命令已成功进入队列。
命令入队:
-
在
MULTI
之后,客户端发送需要一起执行的命令(如SET
,GET
,INCR
,SADD
,HSET
等)。 -
服务器检查命令语法。语法检查发生在入队时。
-
如果命令语法正确,服务器返回
QUEUED
,命令进入队列。 -
如果命令语法错误(例如命令不存在、参数个数错误),整个事务会被标记为失败。此时即使发送
EXEC
,事务中的所有命令也不会被执行,EXEC
会返回错误(例如(error) EXECABORT Transaction discarded because of previous errors.
)。
EXEC
: 执行事务。
-
当客户端发送
EXEC
命令时,服务器按入队顺序依次执行事务队列中的所有命令。 -
执行完成后,退出事务状态。
-
返回值是一个数组,包含事务中每个命令的执行结果,结果的顺序与命令入队的顺序一致。
-
原子性保证: 在执行
EXEC
期间,服务器是单线程处理命令的,因此队列中的所有命令会连续执行,不会被其他客户端的命令打断。这保证了“全做或全不做”的原子性(在命令执行层面,而非错误处理层面)。
DISCARD
: 取消事务。
-
在
MULTI
之后、EXEC
之前,发送DISCARD
命令会清空事务队列,并退出事务状态。 -
客户端放弃执行队列中的所有命令。
-
通常在决定不执行已入队的命令时使用。
Redis 事务在运行错误的情况下,除了执行过程中出现错误的命令外,其他命令都能正常执行。并且,Redis 事务是不支持回滚(roll back)操作的。因此,Redis 事务其实是不满足原子性的。
Redis 事务也不满足持久性。
如何解决 Redis 事务的缺陷?
Redis 从 2.6 版本开始支持执行 Lua 脚本,它的功能和事务非常类似。我们可以利用 Lua 脚本来批量执行多条 Redis 命令,这些 Redis 命令会被提交到 Redis 服务器一次性执行完成,大幅减少了网络开销。
一段 Lua 脚本可以视作一条命令执行,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰。
不过,如果 Lua 脚本运行时出错并中途结束,出错之后的命令是不会被执行的。并且,出错之前执行的命令是无法被撤销的,无法实现类似关系型数据库执行失败可以回滚的那种原子性效果。因此,严格来说的话,通过 Lua 脚本来批量执行 Redis 命令实际也是不完全满足原子性的。
如果想要让 Lua 脚本中的命令全部执行,必须保证语句语法和命令都是对的。
另外,Redis 7.0 新增了 Redis functions 特性,你可以将 Redis functions 看作是比 Lua 更强大的脚本。
二、Redis 线程模型
Redis 是单线程吗?
Redis 单线程指的是「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的,这也是我们常说 Redis 是单线程的原因。
但是,Redis 程序并不是单线程的,Redis 在启动的时候,是会启动后台线程(BIO)的:
-
Redis 在 2.6 版本,会启动 2 个后台线程,分别处理关闭文件、AOF 刷盘这两个任务;
-
Redis 在 4.0 版本之后,新增了一个新的后台线程,用来异步释放 Redis 内存,也就是 lazyfree 线程。例如执行 unlink key / flushdb async / flushall async 等命令,会把这些删除操作交给后台线程来执行,好处是不会导致 Redis 主线程卡顿。因此,当我们要删除一个大 key 的时候,不要使用 del 命令删除,因为 del 是在主线程处理的,这样会导致 Redis 主线程卡顿,因此我们应该使用 unlink 命令来异步删除大 key。
之所以 Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理,是因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求了。
后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的方法即可。
关闭文件、AOF 刷盘、释放内存这三个任务都有各自的任务队列:
-
BIO_CLOSE_FILE,关闭文件任务队列:当队列有任务后,后台线程会调用 close(fd),将文件关闭;
-
BIO_AOF_FSYNC,AOF刷盘任务队列:当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到队列中。当发现队列有任务后,后台线程会调用 fsync(fd),将 AOF 文件刷盘;
-
BIO_LAZY_FREE,lazy free 任务队列:当队列有任务后,后台线程会 free(obj) 释放对象 / free(dict) 删除数据库所有对象 / free(skiplist) 释放跳表对象;
Redis 单线程模式是怎样的?
Redis 采用单线程为什么还这么快?
官方使用基准测试的结果是,单线程的 Redis 吞吐量可以达到 10W/每秒,如下图所示:
Redis 采用单线程(网络 I/O 和执行命令)却如此高效的原因如下:
-
内存操作:Redis 的大部分操作都在内存中完成,并采用高效的数据结构,因此 Redis 的瓶颈通常是内存容量或网络带宽,而非 CPU。既然 CPU 不是瓶颈,采用单线程方案更为合适;
-
避免竞争开销:单线程模型避免了多线程竞争,省去了线程切换的时间和性能开销,同时不会产生死锁问题;
-
I/O 多路复用:Redis 采用 I/O 多路复用机制(如 select/epoll)处理大量客户端 Socket 请求。该机制允许单个线程同时管理多个监听 Socket 和已连接 Socket。内核会持续监听这些 Socket 上的请求,一旦有请求到达,就交给 Redis 线程处理,从而实现单线程高效处理多 I/O 流的效果。
Redis 6.0 之前为什么使用单线程?
CPU 并不是制约 Redis 性能的瓶颈所在,更多情况下是受到内存大小和网络I/O的限制,所以 Redis 核心网络模型使用单线程并没有什么问题。如果你想要利用服务的多核CPU,可以在一台服务器上启动多个节点或者采用分片集群的方式。
使用了单线程后,可维护性高。多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度,同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。
Redis 6.0 之后为什么引入了多线程?
虽然 Redis 的主要工作(网络 I/O 和执行命令)一直是单线程模型,但是在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求,这是因为随着网络硬件性能的提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上。
所以为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理。但是对于命令的执行,Redis 仍然使用单线程来处理,所以大家不要误解 Redis 有多线程同时执行命令。
Redis 官方表示,Redis 6.0 版本引入的多线程 I/O 特性对性能提升至少是一倍以上。
Redis 6.0 版本支持的 I/O 多线程特性,默认情况下 I/O 多线程只针对发送响应数据(write client socket),并不会以多线程的方式处理读请求(read client socket)。