# 【NO.500】后端开发【一大波干货知识】Redis的线程模型和异步机制

### 1.文章目录

- Redis 6.0引入多线程
- 异步机制
- Redis pipeline技术
- Redis 事务
- ACID特性分析
- redis 发布订阅

我们通常说，Redis 是单线程，主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的，这也是 Redis 对外提供键值存储服务的主要流程。

但 Redis 的其他功能，比如持久化、异步删除、集群数据同步等，其实是由额外的线程执行的。

### 2.为什么使用单线程:

多线程并发开销大，访问共享资源时，要确保资源的正确性，需要额外的机制保证正确性，额外的操作增加了系统开销。

在Redis 6.0之前，Redis 在处理客户端的请求时，包括获取 (socket 读)、解析、执行、内容返回 (socket 写) 等都由一个顺序串行的主线程处理，这就是所谓的「单线程」。其中执行命令阶段，由于 Redis 是单线程来处理命令的，所有每一条到达服务端的命令不会立刻执行，所有的命令都会进入一个 Socket 队列中，当 socket 可读则交给单线程事件分发器逐个被执行。

![img](https://linuxcpp.0voice.com/zb_users/upload/2022/12/17/20221217162223_61041.jpg)

官方曾做过类似问题的回复：使用Redis时，几乎不存在CPU成为瓶颈的情况， Redis主要受限于内存和网络。例如在一个普通的Linux系统上，Redis通过使用pipelining每秒可以处理100万个请求，所以如果应用程序主要使用O(N)或O(log(N))的命令，它几乎不会占用太多CPU。

使用了单线程后，可维护性高。多线程模型虽然在某些方面表现优异，但是它却引入了程序执行顺序的不确定性，带来了并发读写的一系列问题，增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。Redis通过AE事件模型以及IO多路复用等技术，处理性能非常高，因此没有必要使用多线程。单线程机制使得 Redis 内部实现的复杂度大大降低，Hash 的惰性 Rehash、Lpush 等等 “线程不安全” 的命令都可以无锁进行。

Redis绝大部分操作是基于内存的，而且是纯kv（key-value）操作，所以命令执行速度非常快。我们可以大概理解成，redis中的数据存储在一张大HashMap中，HashMap的优势就是查找和写入的时间复杂度都是O(1)。Redis内部采用这种结构存储数据，就奠定了Redis高性能的基础。根据Redis官网描述，在理想情况下Redis每秒可以提交一百万次请求，每次请求提交所需的时间在纳秒的时间量级。既然每次的Redis操作都这么快，单线程就可以完全搞定了，那还何必要用多线程呢！

### 3.Redis 6.0引入多线程

我们知道，单线程主要在一个CPU核进行工作，但是随着我们硬件的快速升级和大量业务的需求，单个线程处理网络读写的速度跟不上底层网络硬件的速度， 读写网络的read/write系统调用占用了redis执行期间大部分CPU时间，瓶颈主要在于网络的IO消耗，优化主要有两个方向:

- 提高网络 IO 性能，典型的实现比如使用 DPDK来替代内核网络栈的方式、零拷贝技术。
- 使用多线程充分利用多核，提高网络请求读写的并行度，典型的实现比如 Memcached。

零拷贝技术有其局限性，无法完全适配redis这一复杂的网络IO模型。而DPDK技术通过旁路网卡IO绕过内核协议栈的方式又太过于复杂以及需要内核甚至硬件的支持，所以我们只能从后者下手啦。主要注意的是，redis多IO线程模型只用来处理网络读写请求，对于redis的读写命令，依然是单线程处理。这是因为：

- 网络处理经常是瓶颈，需要通过多线程并行处理可提高性能
- 继续使用单线程执行读写命令，不需要为了保证LUA脚本、事务等开发多线程安全机制，实现更简单

![img](https://linuxcpp.0voice.com/zb_users/upload/2022/12/17/20221217162224_88688.jpg)

client： 客户端对象，Redis 是典型的 CS 架构（Client <—> Server），客户端通过 socket 与服务端建立网络通道然后发送请求命令，服务端执行请求的命令并回复。Redis 使用结构体 client 存储客户端的所有相关信息，包括但不限于封装的套接字连接 – *conn，当前选择的数据库指针 – *db，读入缓冲区 – querybuf，写出缓冲区 – buf，写出数据链表 – reply等。

aeApiPoll：I/O 多路复用 API，是基于 epoll_wait/select/kevent 等系统调用的封装，监听等待读写事件触发，然后处理，它是事件循环（Event Loop）中的核心函数，是事件驱动得以运行的基础。

acceptTcpHandler： 连接应答处理器，底层使用系统调用 accept 接受来自客户端的新连接，并为新连接注册绑定命令读取处理器，以备后续处理新的客户端 TCP 连接；除了这个处理器，还有对应的 acceptUnixHandler 负责处理 Unix Domain Socket 以及 acceptTLSHandler 负责处理 TLS 加密连接。

readQueryFromClient：命令读取处理器，解析并执行客户端的请求命令。

beforeSleep： 事件循环中进入 aeApiPoll 等待事件到来之前会执行的函数，其中包含一些日常的任务，比如把 client->buf 或者 client->reply （后面会解释为什么这里需要两个缓冲区）中的响应写回到客户端，持久化 AOF 缓冲区的数据到磁盘等，相对应的还有一个 afterSleep 函数，在 aeApiPoll 之后执行。

sendReplyToClient： 命令回复处理器，当一次事件循环之后写出缓冲区中还有数据残留，则这个处理器会被注册绑定到相应的连接上，等连接触发写就绪事件时，它会将写出缓冲区剩余的数据回写到客户端。

![img](https://linuxcpp.0voice.com/zb_users/upload/2022/12/17/20221217162224_32644.jpg)

### 4.流程简述如下：

1、主线程负责接收建立连接请求，获取 socket 放入全局等待读处理队列

2、主线程处理完读事件之后，通过 RR(Round Robin) 将这些连接分配给这些 IO 线程

3、主线程阻塞等待 IO 线程读取 socket 完毕

4、主线程通过单线程的方式执行请求命令，请求数据读取并解析完成，但并不执行

5、主线程阻塞等待 IO 线程将数据回写 socket 完毕

6、解除绑定，清空等待队列

### 5.该设计有如下特点：

- IO 线程要么同时在读 socket，要么同时在写，不会同时读或写
- IO 线程只负责读写 socket 解析命令，不负责命令处理

### 6.开启多线程后，是否会存在线程并发安全问题？

不存在。从实现机制可以看出，redis的多线程部分只是用来处理网络数据的读写和协议解析，执行命令仍然是单线程顺序执行所以我们不需要去考虑控制 Key、Lua、事务，LPUSH/LPOP 等等的并发及线程安全问题。

### 7.Redis6.0与Memcached多线程模型对比：

相同点：都采用了master线程—worker线程的模型

### 8.不同点：

- memcached执行主逻辑也是在worker线程里面，模型更进简单，实现了真正的线程隔离
- redis把处理逻辑交还给了master线程，虽然在一定程序上增加了模型复杂度，但也解决了线程并发安全等问题

### 9.Redis 6.0 默认是否开启了多线程?

redis 6.0 的多线程默认是禁用的，只使用主线程。如需开启在redis.conf文件进行配置

```
# 在 redis.conf 中
# if you have a four cores boxes, try to use 2 or 3 I/O threads, if you have
a 8 cores, try to use 6 threads.
io-threads 4
# 默认只开启 encode 也就是redis发送给客户端的协议压缩工作；也可开启io-threads-do-reads
yes来实现 decode;
# 一般发送给redis的命令数据包都比较少，所以不需要开启 decode 功能；
# io-threads-do-reads no
```

### 10.异步机制

**创建线程:**

Redis 主线程启动后，会使用操作系统提供的 pthread_create 函数创建 3 个子线程，分别由它们负责 AOF 日志写操作、key-value删除以及文件关闭的异步执行。

主线程通过一个链表形式的任务队列和子线程进行交互。

当Redis实例收到key-value删除和清空数据库的操作时，主线程会把这个操作封装成一个任务，放入到任务队列中，然后给客户端返回一个完成信息，表明删除已经完成。

这个时候删除操作还没有执行，等到后台子线程从任务队列中读取任务后，才开始实际删除键值对，并释放相应的内存空间。因此，我们把这种异步删除也称为惰性删除（lazy free）。此时，删除或清空操作不会阻塞主线程，这就避免了对主线程的性能影响。

同步连接方案采用阻塞io来实现；优点是代码书写是同步的，业务逻辑没有割裂；缺点是阻塞当前线程，直至redis返回结果；通常用多个线程来实现线程池来解决效率问题；异步连接方案采用非阻塞io来实现；优点是没有阻塞当前线程，redis 没有返回，依然可以往redis发送命令；缺点是代码书写是异步的（回调函数），业务逻辑割裂，可以通过协程解决

（openresty，skynet）；配合redis6.0以后的io多线程（前提是有大量并发请求），异步连接池，能更好解决应用层的数据访问性能；

### 11.Redis pipeline技术

**redis pipeline 是一个客户端提供的，而不是服务端提供的；**

当我们使用客户端对 Redis 进行一次操作时，如下图所示，客户端将请求传送给服务器，服务器处理完毕后，再将响应回复给客户端。这要花费一个网络数据包来回的时间。

![img](https://linuxcpp.0voice.com/zb_users/upload/2022/12/17/20221217162225_88477.jpg)

如果连续执行多条指令，那就会花费多个网络数据包来回的时间。

如下图所示。

![img](https://linuxcpp.0voice.com/zb_users/upload/2022/12/17/20221217162226_14744.jpg)

回到客户端代码层面，客户端是经历了读-写-读-写四个操作才完整地执行了两条指令。

现在如果我们调整读写顺序，改成写—写-读-读，这两个指令同样可以正常完成。

两个连续的写操作和两个连续的读操作总共只会花费一次网络来回，就好比连续的 write 操作合并了，连续的 read 操作也合并了一样。

![img](https://linuxcpp.0voice.com/zb_users/upload/2022/12/17/20221217162226_56659.jpg)

这便是管道操作的本质， 服务器根本没有任何区别对待， 还是收到一条消息， 执行一条消息， 回复一条消息的正常的流程。 客户端通过对管道中的指令列表改变读写顺序就可以大幅节省 IO 时间。 管道中指令越多，效果越好。对于request操作，只是将数据写到fd对应的写缓冲区，时间非常快，真正耗时操作在读取response。

### 12.Redis 事务

MULTI 开启事务，事务执行过程中，单个命令是入队列操作，直到调用 EXEC 才会一起执行；

> MULTI 开启事务
> EXEC 提交事务
> DISCARD 取消事务
> WATCH 检测key的变动，若在事务执行中，key变动则取消事务；在事务开启前调用，乐观锁实现（cas）;
> 若被取消则事务返回 nil ;

但是事务在执行的过程中，却不是原子性的，所以我们可以使用lua脚本来实现redis的原子性。

> redis中加载了一个lua虚拟机；用来执行redis lua脚本；redis lua 脚本的执行是原子性的；当某个
> 脚本正在执行的时候，不会有其他命令或者脚本被执行；
> lua脚本当中的命令会直接修改数据状态；
> cat test1.lua | redis-cli script load –pipe
> \# 加载 lua脚本字符串 生成 sha1
> \> script load ‘local val = KEYS[1]; return val’
> “b8059ba43af6ffe8bed3db65bac35d452f8115d8”
> \# 检查脚本缓存中，是否有该 sha1 散列值的lua脚本
> \> script exists “b8059ba43af6ffe8bed3db65bac35d452f8115d8”
> \1) (integer) 1
> \# 清除所有脚本缓存
> \> script flush
> OK
> \# 如果当前脚本运行时间过长，可以通过 script kill 杀死当前运行的脚本
> \> script kill
> (error) NOTBUSY No scripts in execution right now.

### 13.EVAL

> \# 测试使用
> EVAL script numkeys key [key …] arg [arg …]

### 14.EVALSHA

> \# 线上使用
> EVALSHA sha1 numkeys key [key …] arg [arg …]

### 15.ACID特性分析

A 原子性；事务是一个不可分割的工作单位，事务中的操作要么全部成功，要么全部失败；redis

> 不支持回滚；即使事务队列中的某个命令在执行期间出现了错误，整个事务也会继续执行下去，直
> 到将事务队列中的所有命令都执行完毕为止。
> C 一致性；事务使数据库从一个一致性状态到另外一个一致性状态；这里的一致性是指预期的一
> 致性而不是异常后的一致性；所以redis也不满足；
> I 隔离性；事务的操作不被其他用户操作所打断；redis命令执行是串行的，redis事务天然具备隔
> 离性；
> D 持久性；redis只有在 aof 持久化策略的时候，并且需要在 redis.conf 中
> appendfsync=always 才具备持久性；实际项目中几乎不会使用 aof 持久化策略

### 16.redis 发布订阅

> 为了支持消息的多播机制，redis引入了发布订阅模块；disque 消息队列
> \# 订阅频道
> subscribe 频道
> \# 订阅模式频道
> psubscribe 频道
> \# 取消订阅频道
> unsubscribe 频道
> \# 取消订阅模式频道
> punsubscribe 频道
> \# 发布具体频道或模式频道的内容
> publish 频道 内容
> \# 客户端收到具体频道内容
> message 具体频道 内容
> \# 客户端收到模式频道内容
> pmessage 模式频道 具体频道 内容

发布订阅功能一般要区别命令连接重新开启一个连接；因为命令连接严格遵循请求回应模式；而pubsub能收到redis主动推送的内容；所以实际项目中如果支持pubsub的话，需要另开一条连接用于处理发布订阅 。

### 17.缺点:

> 发布订阅的生产者传递过来一个消息，redis会直接找到相应的消费者并传递过去；假如没有消费者，消息直接丢弃；假如开始有2个消费者，一个消费者突然挂掉了，另外一个消费者依然能收到消息，但是如果刚挂掉的消费者重新连上后，在断开连接期间的消息对于该消费者来说彻底丢失了；
> 另外，redis停机重启，pubsub的消息是不会持久化的，所有的消息被直接丢弃；

原文链接：https://zhuanlan.zhihu.com/p/512118401

作者：[Hu先生的Linux](https://www.zhihu.com/people/huhu520-10)