- Redis 6.0引入多线程
- 异步机制
- Redis pipeline技术
- Redis 事务
- ACID特性分析
- redis 发布订阅
我们通常说,Redis 是单线程,主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。
但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。
多线程并发开销大,访问共享资源时,要确保资源的正确性,需要额外的机制保证正确性,额外的操作增加了系统开销。
在Redis 6.0之前,Redis 在处理客户端的请求时,包括获取 (socket 读)、解析、执行、内容返回 (socket 写) 等都由一个顺序串行的主线程处理,这就是所谓的「单线程」。其中执行命令阶段,由于 Redis 是单线程来处理命令的,所有每一条到达服务端的命令不会立刻执行,所有的命令都会进入一个 Socket 队列中,当 socket 可读则交给单线程事件分发器逐个被执行。
官方曾做过类似问题的回复:使用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操作都这么快,单线程就可以完全搞定了,那还何必要用多线程呢!
我们知道,单线程主要在一个CPU核进行工作,但是随着我们硬件的快速升级和大量业务的需求,单个线程处理网络读写的速度跟不上底层网络硬件的速度, 读写网络的read/write系统调用占用了redis执行期间大部分CPU时间,瓶颈主要在于网络的IO消耗,优化主要有两个方向:
- 提高网络 IO 性能,典型的实现比如使用 DPDK来替代内核网络栈的方式、零拷贝技术。
- 使用多线程充分利用多核,提高网络请求读写的并行度,典型的实现比如 Memcached。
零拷贝技术有其局限性,无法完全适配redis这一复杂的网络IO模型。而DPDK技术通过旁路网卡IO绕过内核协议栈的方式又太过于复杂以及需要内核甚至硬件的支持,所以我们只能从后者下手啦。主要注意的是,redis多IO线程模型只用来处理网络读写请求,对于redis的读写命令,依然是单线程处理。这是因为:
- 网络处理经常是瓶颈,需要通过多线程并行处理可提高性能
- 继续使用单线程执行读写命令,不需要为了保证LUA脚本、事务等开发多线程安全机制,实现更简单
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: 命令回复处理器,当一次事件循环之后写出缓冲区中还有数据残留,则这个处理器会被注册绑定到相应的连接上,等连接触发写就绪事件时,它会将写出缓冲区剩余的数据回写到客户端。
1、主线程负责接收建立连接请求,获取 socket 放入全局等待读处理队列
2、主线程处理完读事件之后,通过 RR(Round Robin) 将这些连接分配给这些 IO 线程
3、主线程阻塞等待 IO 线程读取 socket 完毕
4、主线程通过单线程的方式执行请求命令,请求数据读取并解析完成,但并不执行
5、主线程阻塞等待 IO 线程将数据回写 socket 完毕
6、解除绑定,清空等待队列
- IO 线程要么同时在读 socket,要么同时在写,不会同时读或写
- IO 线程只负责读写 socket 解析命令,不负责命令处理
不存在。从实现机制可以看出,redis的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行所以我们不需要去考虑控制 Key、Lua、事务,LPUSH/LPOP 等等的并发及线程安全问题。
相同点:都采用了master线程—worker线程的模型
- memcached执行主逻辑也是在worker线程里面,模型更进简单,实现了真正的线程隔离
- redis把处理逻辑交还给了master线程,虽然在一定程序上增加了模型复杂度,但也解决了线程并发安全等问题
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
创建线程:
Redis 主线程启动后,会使用操作系统提供的 pthread_create 函数创建 3 个子线程,分别由它们负责 AOF 日志写操作、key-value删除以及文件关闭的异步执行。
主线程通过一个链表形式的任务队列和子线程进行交互。
当Redis实例收到key-value删除和清空数据库的操作时,主线程会把这个操作封装成一个任务,放入到任务队列中,然后给客户端返回一个完成信息,表明删除已经完成。
这个时候删除操作还没有执行,等到后台子线程从任务队列中读取任务后,才开始实际删除键值对,并释放相应的内存空间。因此,我们把这种异步删除也称为惰性删除(lazy free)。此时,删除或清空操作不会阻塞主线程,这就避免了对主线程的性能影响。
同步连接方案采用阻塞io来实现;优点是代码书写是同步的,业务逻辑没有割裂;缺点是阻塞当前线程,直至redis返回结果;通常用多个线程来实现线程池来解决效率问题;异步连接方案采用非阻塞io来实现;优点是没有阻塞当前线程,redis 没有返回,依然可以往redis发送命令;缺点是代码书写是异步的(回调函数),业务逻辑割裂,可以通过协程解决
(openresty,skynet);配合redis6.0以后的io多线程(前提是有大量并发请求),异步连接池,能更好解决应用层的数据访问性能;
redis pipeline 是一个客户端提供的,而不是服务端提供的;
当我们使用客户端对 Redis 进行一次操作时,如下图所示,客户端将请求传送给服务器,服务器处理完毕后,再将响应回复给客户端。这要花费一个网络数据包来回的时间。
如果连续执行多条指令,那就会花费多个网络数据包来回的时间。
如下图所示。
回到客户端代码层面,客户端是经历了读-写-读-写四个操作才完整地执行了两条指令。
现在如果我们调整读写顺序,改成写—写-读-读,这两个指令同样可以正常完成。
两个连续的写操作和两个连续的读操作总共只会花费一次网络来回,就好比连续的 write 操作合并了,连续的 read 操作也合并了一样。
这便是管道操作的本质, 服务器根本没有任何区别对待, 还是收到一条消息, 执行一条消息, 回复一条消息的正常的流程。 客户端通过对管道中的指令列表改变读写顺序就可以大幅节省 IO 时间。 管道中指令越多,效果越好。对于request操作,只是将数据写到fd对应的写缓冲区,时间非常快,真正耗时操作在读取response。
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.
# 测试使用 EVAL script numkeys key [key …] arg [arg …]
# 线上使用 EVALSHA sha1 numkeys key [key …] arg [arg …]
A 原子性;事务是一个不可分割的工作单位,事务中的操作要么全部成功,要么全部失败;redis
不支持回滚;即使事务队列中的某个命令在执行期间出现了错误,整个事务也会继续执行下去,直 到将事务队列中的所有命令都执行完毕为止。 C 一致性;事务使数据库从一个一致性状态到另外一个一致性状态;这里的一致性是指预期的一 致性而不是异常后的一致性;所以redis也不满足; I 隔离性;事务的操作不被其他用户操作所打断;redis命令执行是串行的,redis事务天然具备隔 离性; D 持久性;redis只有在 aof 持久化策略的时候,并且需要在 redis.conf 中 appendfsync=always 才具备持久性;实际项目中几乎不会使用 aof 持久化策略
为了支持消息的多播机制,redis引入了发布订阅模块;disque 消息队列 # 订阅频道 subscribe 频道 # 订阅模式频道 psubscribe 频道 # 取消订阅频道 unsubscribe 频道 # 取消订阅模式频道 punsubscribe 频道 # 发布具体频道或模式频道的内容 publish 频道 内容 # 客户端收到具体频道内容 message 具体频道 内容 # 客户端收到模式频道内容 pmessage 模式频道 具体频道 内容
发布订阅功能一般要区别命令连接重新开启一个连接;因为命令连接严格遵循请求回应模式;而pubsub能收到redis主动推送的内容;所以实际项目中如果支持pubsub的话,需要另开一条连接用于处理发布订阅 。
发布订阅的生产者传递过来一个消息,redis会直接找到相应的消费者并传递过去;假如没有消费者,消息直接丢弃;假如开始有2个消费者,一个消费者突然挂掉了,另外一个消费者依然能收到消息,但是如果刚挂掉的消费者重新连上后,在断开连接期间的消息对于该消费者来说彻底丢失了; 另外,redis停机重启,pubsub的消息是不会持久化的,所有的消息被直接丢弃;
原文链接:https://zhuanlan.zhihu.com/p/512118401
作者:Hu先生的Linux