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 线程仅负责读取和解析客户端命令,并不会真正去执行命令,客户端的命令执行最终还是在主线程上完成的
相关推荐
Kagol10 小时前
macOS 和 Windows 操作系统下如何安装和启动 MySQL / Redis 数据库
redis·后端·mysql
hzulwy11 小时前
Redis常用的数据结构及其使用场景
数据库·redis
ashane131412 小时前
Redis 哨兵集群(Sentinel)与 Cluster 集群对比
redis
Y第五个季节13 小时前
Redis - HyperLogLog
数据库·redis·缓存
Justice link14 小时前
企业级NoSql数据库Redis集群
数据库·redis·缓存
爱的叹息17 小时前
Spring Boot 集成Redis 的Lua脚本详解
spring boot·redis·lua
morris1311 天前
【redis】redis实现分布式锁
数据库·redis·缓存·分布式锁
爱的叹息1 天前
spring boot集成reids的 RedisTemplate 序列化器详细对比(官方及非官方)
redis
weitinting1 天前
Ali linux 通过yum安装redis
linux·redis
纪元A梦1 天前
Redis最佳实践——首页推荐与商品列表缓存详解
数据库·redis·缓存