Redis 线程模型

Redis 线程模型

  • 背景
  • 简介
  • [Redis 单线程](#Redis 单线程)
  • [Redis 多线程](#Redis 多线程)
    • [Redis v4.0 多线程命令](#Redis v4.0 多线程命令)
    • [Redis v6.0 多线程网络模型](#Redis v6.0 多线程网络模型)
  • 总结

背景

随着年龄的增长,很多曾经烂熟于心的技术原理已被岁月摩擦得愈发模糊起来,技术出身的人总是很难放下一些执念,遂将这些知识整理成文,以纪念曾经努力学习奋斗的日子。本文内容并非完全原创,大多是参考其他文章资料整理所得,感谢每位技术人的开源精神。

简介

本文介绍 Redis 线程模型的原理及演进,主要介绍 Redis 核心网络模型。

Redis 单线程

早期的 Redis 版本(准确点说是 Redis v4.0 之前的版本)使用的是单线程模型,Redis 官方给出的解释是:Redis 的瓶颈通常不会是 CPU ,因为大多数请求都是 I/O 密集型而非 CPU 密集型,如果不考虑 RDB/AOF 等持久化方案,Redis 是完全的纯内存操作,执行速度非常快。Redis 真正的性能瓶颈在网络 I/O ,也就是客户端与服务器之间的网络传输延迟。除此之外,单线程也可避免过多的上下文切换开销及线程同步机制开销,对 Redis 的设计、开发及维护也更加简单。

Redis v6.0 版本之前,核心网络模型一直是一个典型的 单 Reactor 模型,利用 select / epoll / kqueue 等 I/O 多路复用技术,在单线程事件循环中不断去处理客户端请求事件,最后回写响应数据到客户端。

核心概念:

  • client:客户端对象,Redis 是典型的 CS 架构(Client - Server),客户端通过 Socket 与服务器建立连接并发送请求命令,服务器执行请求的命令并回写响应。Redis 使用结构体 client 存储客户端所有相关信息,包括套接字连接(*conn)、当前选择的数据库指针(*db)、读入缓冲区(querybuf)、写出缓冲区(buf)、写出数据链表(reply)等。
  • aeApiPoll:I/O 多路复用 API,是基于 select / epoll_wait / kevent 等系统调用的封装,监听等待读写事件触发,然后处理,它是事件循环(Event Loop)中的核心函数,是事件驱动得以运行的基础。
  • acceptTcpHandler:连接应答处理器,底层使用系统调用 accept 接受来自客户端的新连接,并为新连接注册绑定命令读取处理器,以备后续处理新的客户端 TCP 连接;除了这个处理器,还有对应的 acceptUnixHandler 负责处理 Unix Domain Socket 以及 acceptTLSHandler 负责处理 TLS 加密连接。
  • readQueryFromClient:命令读取处理器,解析并执行客户端的请求命令。
  • beforeSleep:事件循环中进入 aeApiPoll 等待事件到来之前会执行的函数,其中包含一些日常的任务,比如把 client->buf 或者 client->reply 中的响应写回到客户端,持久化 AOF 缓冲区的数据到磁盘等,相对应的还有一个 afterSleep 函数,在 aeApiPoll 之后执行。
  • sendReplyToClient:命令回复处理器,当一次事件循环之后写出缓冲区中还有数据残留,则这个处理器会被注册绑定到相应的连接上,等连接触发写就绪事件时,它会将写出缓冲区剩余的数据回写到客户端。

Redis 内部实现了一个高性能的事件库 --- AE,基于 epoll / select / kqueue / evport 四种事件驱动技术,实现 Linux / MacOS / FreeBSD / Solaris 多平台的高性能事件循环模型。Redis 的核心网络模型正式构筑在 AE 之上,包括 I/O 多路复用、各类处理器的注册绑定,都是基于此才得以运行。

客户端发起 Redis 请求命令的工作原理

  1. Redis 服务器启动,开启主线程事件循环(Event Loop),注册 acceptTcpHandler 连接应答处理器到用户配置的监听端口对应的文件描述符,等待新连接带来;
  2. 客户端和服务器建立网络连接;
  3. accpetTcpHandler 被调用,主线程使用 AE 的 API 将 readQueryFromClient 命令读取处理器绑定到新连接对应的文件描述符上,并初始化一个 client 绑定这个客户端连接;
  4. 客户端发送请求命令,触发读就绪事件,主线程调用 readQueryFromClient 通过 Socket 读取客户端发送过来的命令存入 client->querybuf 读入缓冲区;
  5. 调用 processInputBuffer,在其中使用 processInlineBuffer 或者 processMultibulkBuffer 根据 Redis 协议解析命令,最后调用 processCommand 执行命令;
  6. 根据请求命令的类型(SET / GET / DEL / EXEC / ...),分配响应的命令执行器去执行,最后调用 addReply 函数族的一系列函数将响应数据写入到对应 client 的写出缓冲区:client->buf 或者 client->replyclient->buf 是首选的写出缓冲区,固定大小16KB,一般来说可以缓冲足够多的响应数据,但是如果客户端在时间窗口内需要响应的数据非常大,那么则会自动切换到 client->reply 链表上去,使用链表理论上能够保存无限大的数据(受限于机器的物理内存),最后把 client 添加进一个 LIFO 队列 clients_pending_write
  7. 在事件循环(Event Loop)中,主线程执行 beforeSleep -> handleClientsWithPendingWrites,遍历 clients_pending_write 队列,调用 writeToClientclient 的写出缓冲区理的数据回写到客户端,如果写出缓冲区还有数据遗留,,则注册 sendReplyToClient 命令回写处理器到该连接的写就绪事件,等待客户端可写时在事件循环中再继续回写残余的响应数据。

单线程面临的挑战及问题

Redis 中存在一些非常耗时的命令,这些命令的执行可能会阻塞单线程的事件循环,譬如 Redis 的 DEL 命令可用于删除一个或多个 key 存储的值,这是个阻塞命令,大多数情况下需要一次性删除的 key 中存储的值不会特别多,但如果遇到需要删除一个超大键值对的场景时,DEL 命令执行可能会阻塞,又因为事件循环是单线程的,所以也会同步阻塞后续的其他事件,导致吞吐量下降。

Redis 多线程

Redis v4.0 多线程命令

Redis v4.0 版本引入多线程,将一些可能耗时阻塞单线程事件循环的命令执行异步化,增加了一些非阻塞命令,如:UNLINKFLUSHALL ASYNCFLUSHDB ASYNC。其中 UNLINK 命令其实就是 DEL 的异步版本,UNLINK 不会同步删除数据,知识将 key 从 keyspace 中暂时移除,然后将任务添加到一个异步队列,最后由后台线程去删除,但使用 UNLINK 去删除一个很小的 key 时开销反而更大,所以删除前会先计算一个开销阈值,只有当这个值大于 64 才会使用异步删除,对于基本数据类型,如 ListSetHash,阈值就是其中存储的对象数量。

不过需要注意的是,Redis v4.0 的核心网络模型仍然是单线程的。

Redis v6.0 多线程网络模型

Redis v6.0 版本开始在核心网络模型中引入多线程,原因是 Redis 的网络 I/O 瓶颈已愈加明显。Redis 单线程模式导致系统消耗很多 CPU 时间在网络 I/O 上,从而降低了吞吐量,要提升 Redis 性能可以考虑两个方向:

  • 优化网络 I/O 模块
  • 提高机器内存读写速度(依赖于硬件发展)

Redis v6.0 版本引入了 I/O Threading 多线程,利用多核的优势优化网络 I/O。

  1. Redis 服务器启动,开启主线程事件循环(Event Loop),注册 acceptTcpHandler 连接应答处理器到用户配置的监听端口对应的文件描述符,等待新连接请求;
  2. 客户端和服务器建立网络连接;
  3. acceptTcpHandler 被调用,主线程使用 AE 的 API 将 readQueryFromClient 命令读取处理器绑定到新连接对应的文件描述符上,并初始化一个 client 绑定这个客户端连接;
  4. 客户端发送请求命令,触发读就绪事件,服务器主线程不会通过 Socket 去读取客户端的请求命令,而是先将 client 放入一个 LIFO 队列 clients_pending_read
  5. 在事件循环(Event Loop)中,主线程执行 beforeSleep -> handleClientsWithPendingReadsUsingThreads,利用 Round-Robin 轮询负载均衡策略,把 clients_pending_read 队列中的连接均匀地分配给 I/O 线程各自的本地 FIFO 任务队列 io_threads_list[id] 和主线程自己,I/O 线程通过 Socket 读取客户端的请求命令,存入 client->querybuf 并解析第一个命令,但不执行命令,主线程忙轮询,等待所有 I/O 线程完成读取任务;
  6. 主线程和所有 I/O 线程都完成了读取任务,主线程结束忙轮询,遍历 clients_pending_read 队列,执行所有客户端连接的请求命令,先调用 processCommandAndResetClient 执行第一条已经解析好的命令,然后调用 processInputBuffer 解析并执行客户端连接的所有命令,在其中使用 processInlineBuffer 或者 processMultibulkBuffer 根据 Redis 协议解析命令,最后调用 processCommand 执行命令;
  7. 根据请求命令的类型(SET / GET / DEL / EXEC / ...),分配相应的命令执行器去执行,最后调用 addReply 函数族的一系列函数将响应数据写入到对应 client 的写出缓冲区:client->buf 或者 client->replyclient->buf 是首选的写出缓冲区,固定大小 16 KB,一般来说可以缓冲足够多的响应数据,但是如果客户端在时间窗口内需要响应的数据非常大,那么则会自动切换到 client->reply 链表上去,使用链表理论上能够保存无限大的数据(受限于机器的物理内存),最后把 client 添加进一个 LIFO 队列 clients_pending_write
  8. 在事件循环(Event Loop)中,主线程执行 beforeSleep -> handleClientsWithPendingWritesUsingThreads,利用 Round-Robin 轮询负载均衡策略,把 clients_pending_write 队列中的连接均匀地分配给 I/O 线程各自的本地 FIFO 任务队列 io_threads_list[id] 和主线程自己,I/O 线程通过调用 writeToClientclient 的写出缓冲区里的数据回写到客户端,主线程忙轮询,等待所有 I/O 线程完成写出任务;
  9. 主线程和所有 I/O 线程都完成了写出任务, 主线程结束忙轮询,遍历 clients_pending_write 队列,如果 client 的写出缓冲区还有数据遗留,则注册 sendReplyToClient 到该连接的写就绪事件,等待客户端可写时在事件循环中再继续回写残余的响应数据。

总结

  • Redis v6.0 版本之前的核心网络模型都是单线程 ;
  • Redis v4.0 为解决一些耗时命令阻塞单线程事件循环的问题,引入多线程并增加了一些非阻塞命令,但此时核心网络模型仍然为单线程;
  • Redis v6.0 版本开始,核心网络模型使用多线程,以解决网络 I/O 方面的瓶颈问题;
  • Redis 单线程模型和多线程模型的区别在于,多线程模型把读取客户端请求命令和回写响应数据的逻辑异步化,交给 I/O 线程去完成,但是 I/O 线程仅负责读取和解析客户端命令,并不会真正去执行命令,客户端的命令执行最终还是在主线程上完成的
相关推荐
Dlwyz1 小时前
问题: redis-高并发场景下如何保证缓存数据与数据库的最终一致性
数据库·redis·缓存
飞升不如收破烂~2 小时前
redis的List底层数据结构 分别什么时候使用双向链表(Doubly Linked List)和压缩列表(ZipList)
redis
吴半杯4 小时前
Redis-monitor安装与配置
数据库·redis·缓存
会code的厨子5 小时前
Redis缓存高可用集群
redis·缓存
尽兴-6 小时前
Redis模拟延时队列 实现日程提醒
java·redis·java-rocketmq·mq
Karoku06611 小时前
【企业级分布式系统】ELK-企业级日志分析系统
运维·数据库·redis·mysql·elk·缓存
是店小二呀11 小时前
【C++】右值引用与移动语义详解:如何利用万能引用实现完美转发
c++·redis
一直要努力哦1 天前
Redis的高可用性
数据库·redis·缓存
吃着火锅x唱着歌1 天前
Redis设计与实现 学习笔记 第十八章 发布与订阅
redis·笔记·学习
冷瞳1 天前
Redis的特性
数据库·redis·缓存