-
2022-05-18 10:11:28
铺垫
首先介绍一下今天要使用到的工具
Lettuce
,它是一个可伸缩线程安全的redis客户端。多个线程可以共享同一个RedisConnection
,利用nio框架Netty
来高效地管理多个连接。放眼望向现在常用的redis客户端开发工具包,虽然能用的不少,但是目前率先拥抱redis6.0,支持客户端缓存功能的却不多,而lettuce就是其中的领跑者。
我们先在项目中引入最新版本的依赖,下面正式开始实战环节:
<dependency> <groupId>io.lettuce</groupId> <artifactId>lettuce-core</artifactId> <version>6.1.8.RELEASE</version> </dependency>
实战
在项目中应用lettuce,开启并使用客户端缓存功能,只需要下面这一段代码:
public static void main(String[] args) throws InterruptedException { // 创建 RedisClient 连接信息 RedisURI redisURI= RedisURI.builder() .withHost("127.0.0.1") .withPort(6379) .build(); RedisClient client = RedisClient.create(redisURI); StatefulRedisConnection<String, String> connect = client.connect(); Map<String, String> map = new HashMap<>(); CacheFrontend<String,String> frontend=ClientSideCaching.enable(CacheAccessor.forMap(map), connect, TrackingArgs.Builder.enabled().noloop()); String key="user"; while (true){ String value = frontend.get(key); System.out.println(value); TimeUnit.SECONDS.sleep(10); } }
上面的代码主要完成了几项工作:
- 通过
RedisURI
配置redis连接的标准信息,并建立连接 - 创建用于充当本地缓存的
Map
,开启客户端缓存功能,建立一个缓存访问器CacheFrontend
- 在循环中使用
CacheFrontend
,不断查询同一个key对应的值并打印
启动上面的程序,控制台会不断的打印
user
对应的缓存,在启动一段时间后,我们在其他的客户端修改user
对应的值,运行的结果如下:可以看到,在其他客户端修改了key所对应的值后,打印结果也发生了变化。但是到这里,我们也不知道
lettuce
是不是真的使用了客户端缓存,虽然结果正确,但是说不定是它每次都重新执行了get
命令呢?所以我们下面来看看源码,分析一下具体的代码执行流程。
分析
在上面的代码中,最关键的类就是
CacheFrontend
了,我们再来仔细看一下上面具体实例化时的语句:CacheFrontend<String,String> frontend=ClientSideCaching.enable( CacheAccessor.forMap(map), connect, TrackingArgs.Builder.enabled().noloop() );
首先调用了
ClientSideCaching
的enable()
方法,我们看一下它的源码:解释一下传入的3个参数:
CacheAccessor
:一个定义对客户端缓存进行访问接口,上面调用它的forMap
方法返回的是一个MapCacheAccessor
,它的底层使用的我们自定义的Map
来存放本地缓存,并且提供了get
、put
、evict
等方法操作Map
StatefulRedisConnection
:使用到的redis连接TrackingArgs
:客户端缓存的参数配置,使用noloop
后不会接收当前连接修改key后的通知
向redis服务端发送开启
tracking
的命令后,继续向下调用create()
方法:这个过程中实例化了一个重要对象,它就是实现了
RedisCache
接口的DefaultRedisCache
对象,实际向redis执行查询时的get
请求、写入的put
请求,都是由它来完成。实例化完成后,继续向下调用同名的
create()
方法:在这个方法中,实例化了
ClientSideCaching
对象,注意一下传入的两个参数,通过前面的介绍也很好理解它们的分工:- 当本地缓存存在时,直接从
CacheAccessor
中读取 - 当本地缓存不存在时,使用
RedisCache
从服务端读取
需要额外注意一下的是返回前的两行代码,先看第一句(行号114的那行)。
这里向
RedisCache
添加了一个监听,当监听到类型为invalidate
的作废消息时,拿到要作废的key,传递给消费者。一般情况下,keys
中只会有一个元素。消费时会遍历当前
ClientSideCaching
的消费者列表invalidationListeners
:而这个列表中的所有,就是在上面的第二行代码中(行号115的那行)添加的,看一下方法的定义:
而实际传入的方法引用则是下面
MapCacheAccessor
的evict()
方法,也就是说,当收到key作废的消息后,会移除掉本地缓存Map
中缓存的这个数据。客户端缓存的作废逻辑我们梳理清楚了,再来看看它是何时写入的,直接看
ClientSideCaching
的get()
方法:可以看到,
get
方法会先从本地缓存MapCacheAccessor
中尝试获取,如果取到则直接返回,如果没有再使用RedisCache
读取redis中的缓存,并将返回的结果存入到MapCacheAccessor
中。图解
源码看到这里,是不是基本逻辑就串联起来了,我们再画两张图来梳理一下这个流程。先看
get
的过程:再来看一下通知客户端缓存失效的过程:
怎么样,配合这两张图再理解一下,是不是很完美?
其实也不是…回忆一下我们之前使用两级缓存
Caffeine+Redis
时,当时使用的通知机制,会在修改redis缓存后通知所有主机修改本地缓存,修改成为最新的值。目前的lettuce看来,显然不满足这一功能,只能做到作废删除缓存但是不会主动更新。扩展
那么,如果想实现本地客户端缓存的实时更新,我们应该如何在现在的基础上进行扩展呢?仔细想一下的话,思路也很简单:
- 首先,移除掉
lettuce
的客户端缓存本身自带的作废消息监听器 - 然后,添加我们自己的作废消息监听器
回顾一下上面源码分析的图,在调用
DefaultRedisCache
的addInvalidationListener()
方法时,其实是调用的是StatefulRedisConnection
的addListener()
方法,也就是说,这个监听器其实是添加在redis连接上的。如果我们再看一下这个方法源码的话,就会发现,在它的附近还有一个对应的
removeListener()
方法,一看就是我们要找的东西,准备用它来移除消息监听。不过再仔细看看,这个方法是要传参数的啊,我们明显不知道现在里面已经存在的
PushListener
有什么,所以没法直接使用,那么无奈只能再接着往下看看这个pushHandler
是什么玩意…通过注释可以知道,这个
PushHandler
就是一个用来操作PushListener
的处理工具,虽然我们不知道具体要移除的PushListener
是哪一个,但是惊喜的是,它提供了一个getPushListeners()
方法,可以获取当前所有的监听器。这样一来就简单了,我上来直接清除掉这个集合中的所有监听器,问题就迎刃而解了~
不过,在
StatefulRedisConnectionImpl
中的pushHandler
是一个私有对象,也没有对外进行暴露,想要操作起来还是需要费上一点功夫的。下面,我们就在分析的结果上进行代码的修改。魔改
首先,我们需要自定义一个工具类,它的主要功能是操作监听器,所以就命名为
ListenerChanger
好了。它要完成的功能主要有三个:- 移除原有的全部消息监听
- 添加新的自定义消息监听
- 更新本地缓存
MapCacheAccessor
中的数据
首先定义构造方法,需要传入
StatefulRedisConnection
和CacheAccessor
作为参数,在后面的方法中会用到,并且创建一个RedisCommands
,用于后面向redis服务端发送get
命令请求。public class ListenerChanger<K, V> { private StatefulRedisConnection<K, V> connection; private CacheAccessor<K, V> mapCacheAccessor; private RedisCommands<K, V> command; public ListenerChanger(StatefulRedisConnection<K, V> connection, CacheAccessor<K, V> mapCacheAccessor) { this.connection = connection; this.mapCacheAccessor = mapCacheAccessor; this.command = connection.sync(); } //其他方法先省略…… }
移除监听
前面说过,
pushHandler
是一个私有对象,我们无法直接获取和操作,所以只能先使用反射获得。PushHandler
中的监听器列表存储在一个CopyOnWriteArrayList
中,我们直接使用迭代器移除掉所有内容即可。public void removeAllListeners() { try { Class connectionClass = StatefulRedisConnectionImpl.class; Field pushHandlerField = connectionClass.getDeclaredField("pushHandler"); pushHandlerField.setAccessible(true); PushHandler pushHandler = (PushHandler) pushHandlerField.get(this.connection); CopyOnWriteArrayList<PushListener> pushListeners = (CopyOnWriteArrayList) pushHandler.getPushListeners(); Iterator<PushListener> it = pushListeners.iterator(); while (it.hasNext()) { PushListener listener = it.next(); pushListeners.remove(listener); } } catch (NoSuchFieldException | IllegalAccessException e) { e.printStackTrace(); } }
添加监听
这里我们模仿
DefaultRedisCache
中addInvalidationListener()
方法的写法,添加一个监听器,除了最后处理的代码基本一致。对于监听到的要作废的keys
集合,另外启动一个线程更新本地数据。public void addNewListener() { this.connection.addListener(new PushListener() { @Override public void onPushMessage(PushMessage message) { if (message.getType().equals("invalidate")) { List<Object> content = message.getContent(StringCodec.UTF8::decodeKey); List<K> keys = (List<K>) content.get(1); System.out.println("modifyKeys:"+keys); // start a new thread to update cacheAccessor new Thread(()-> updateMap(keys)).start(); } } }); }
本地更新
使用
RedisCommands
重新从redis服务端获取最新的数据,并更新本地缓存mapCacheAccessor
中的数据。private void updateMap(List<K> keys){ for (K key : keys) { V newValue = this.command.get(key); System.out.println("newValue:"+newValue); mapCacheAccessor.put(key, newValue); } }
至于为什么执行这个方法时额外启动了一个新线程,是因为我在测试中发现,当在
PushListener
的onPushMessage
方法中执行RedisCommands
的get()
方法时,会一直取不到值,但是像这样新启动一个线程就没有问题。测试
下面,我们来写一段测试代码,来测试上面的改动。
public static void main(String[] args) throws InterruptedException { // 省略之前创建连接代码…… Map<String, String> map = new HashMap<>(); CacheAccessor<String, String> mapCacheAccessor = CacheAccessor.forMap(map); CacheFrontend<String, String> frontend = ClientSideCaching.enable(mapCacheAccessor, connect, TrackingArgs.Builder.enabled().noloop()); ListenerChanger<String, String> listenerChanger = new ListenerChanger<>(connect, mapCacheAccessor); // 移除原有的listeners listenerChanger.removeAllListeners(); // 添加新的监听器 listenerChanger.addNewListener(); String key = "user"; while (true) { String value = frontend.get(key); System.out.println(value); TimeUnit.SECONDS.sleep(30); } }
可以看到,代码基本上在之前的基础上没有做什么改动,只是在创建完
ClientSideCaching
后,执行了我们自己实现的ListenerChanger
的两个方法。先移除所有监听器、再添加新的监听器。下面我们以debug模式启动测试代码,简单看一下代码的执行逻辑。首先,在未执行移除操作前,
pushHandler
中的监听器列表中有一个监听器:移除后,监听器列表为空:
在添加完自定义监听器、并且执行完第一次查询操作后,在另外一个redis客户端中修改
user
的值,这时PushListener
会收到作废类型的消息监听:启动一个新线程,查询redis中
user
对应的最新值,并放入cacheAccessor
中:当循环中
CacheFrontend
的get()
方法再被执行时,会直接从cacheAccessor
中取到刷新后的值,不需要再次去访问redis服务端了:总结
到这里,我们基于
lettuce
的客户端缓存的基本使用、以及在这个基础上进行的魔改就基本完成了。可以看到,lettuce
客户端已经在底层封装了一套比较成熟的API,能让我们在将redis升级到6.0以后,开箱即用式地使用客户端缓存这一新特性。在使用中,不需要我们关注底层原理,也不用做什么业务逻辑的改造,总的来说,使用起来还是挺香的。更多相关内容 - 通过
-
redis6.0 window版本
2020-10-21 23:10:36csdn上的redis版本需要30-50积分,看到十分痛心,自己编译了一个最新版redis6.0,回馈大家,减少找redis的win版本的麻烦,节省时间 -
redis6.0重启sh脚本
2020-12-27 09:28:11redis6.0重启sh脚本,哨兵重启脚本,主要是根据pid文件判断是否在运行 -
Redis 6.0的Windows版本(64位)
2021-08-20 17:57:06Redis只有Linux版本,在Windows下使用cgywin64编译的,实测可用 -
Redis6.0-SourceCodeNote:Redis6.0源码阅读笔记-redis source code
2021-03-25 04:32:04Redis6.0-SourceCodeNote 长期更新Redis6.0原始阅读笔记 目前进度: 内存管理,源文件为zmalloc.h,zmalloc.c SDS(简单动态字符串),源文件为sdsalloc.h,sds.h,sds.c 链表,源文件为adlist.h,adlist.c ... -
redis6.0.rar
2021-07-16 17:05:47最新 redis6.0 windows 版本。 解压出文件,修改 redis-start.bat 里面的路径为 redis 路径。 通过 gpedit.msc 设置开机脚本 可实现开机自动启动 redis 。 -
Redis 6.0 快速入门课程
2021-06-22 05:34:541、学习redis的安装与配置2、学习redis常见数据类型的API详细使用方法以及相应数据类型的使用场景3、学习redis数据持久化的方式,以及实战场景演示4、学习redis主从复制原理与优化,以及实战演示5、学习redis的高... -
redis6.0基本数据结构源码.h
2021-08-04 14:49:47总结《Redis深度历险:核心原理和应用实践》的基本数据结构源码 —— 对象结构 -
Redis 6.0多线程模型总结
2022-01-24 00:06:10前言:Redis 6.0.1 于 2020 年 5 月 2 日正式发布了,如 Redis ...1、Redis6.0之前的版本真的是单线程吗? Redis在处理客户端的请求时,包括获取 (socket 读)、解析、执行、内容返回 (socket 写) 等都由一个顺序串行的前言:Redis 6.0.1 于 2020 年 5 月 2 日正式发布了,如 Redis 作者 antirez 所说,这是迄今为止最“企业”化的版本,也是有史以来改动最大的一个 Redis 版本。这个版本提供了诸多令人心动的新特性及功能改进,比如新网络协议RESP3,新的集群代理,ACL等,其中关注度比较高的应该是多线程模型了。
1、Redis6.0之前的版本真的是单线程吗?
Redis在处理客户端的请求时,包括获取 (socket 读)、解析、执行、内容返回 (socket 写) 等都由一个顺序串行的主线程处理,这就是所谓的“单线程”。但如果严格来讲从Redis4.0之后并不是单线程,除了主线程外,它也有后台线程在处理一些较为缓慢的操作,例如清理脏数据、无用连接的释放、大 key 的删除等等。
其中执行命令阶段,由于 Redis 是单线程来处理命令的,所有每一条到达服务端的连接不会立刻执行,所有的连接都会进入一个 Socket 队列中,当 socket 可读则交给单线程事件分发器逐个被执行。如下图所示:
2、Redis6.0之前为什么一直不使用多线程?
官方曾做过类似问题的回复:使用Redis时,几乎不存在CPU成为瓶颈的情况, Redis主要受限于内存和网络。例如在一个普通的Linux系统上,Redis通过使用pipelining每秒可以处理100万个请求,所以如果应用程序主要使用O(N)或O(log(N))的命令,它几乎不会占用太多CPU。
使用了单线程后,可维护性高。多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。Redis通过AE事件模型以及IO多路复用等技术,处理性能非常高,因此没有必要使用多线程。单线程机制使得 Redis 内部实现的复杂度大大降低,Hash 的惰性 Rehash、Lpush 等等 “线程不安全” 的命令都可以无锁进行。
为什么说Redis的瓶颈不在CPU?
Redis绝大部分操作是基于内存的,而且是纯kv(key-value)操作,所以命令执行速度非常快。我们可以大概理解成,redis中的数据存储在一张大HashMap中,HashMap的优势就是查找和写入的时间复杂度都是O(1)。Redis内部采用这种结构存储数据,就奠定了Redis高性能的基础。根据Redis官网描述,在理想情况下Redis每秒可以提交一百万次请求,每次请求提交所需的时间在纳秒的时间量级。既然每次的Redis操作都这么快,单线程就可以完全搞定了,那还何必要用多线程呢!
3、Redis6.0为什么要引入多线程呢?
Redis将所有数据放在内存中,内存的响应时长大约为100纳秒,对于小数据包,Redis服务器可以处理80,000到100,000 QPS,这也是Redis处理的极限了,对于80%的公司来说,单线程的Redis已经足够使用了。
但随着越来越复杂的业务场景,有些公司动不动就上亿的交易量,因此需要更大的QPS。常见的解决方案是在分布式架构中对数据进行分区并采用多个服务器,但该方案有非常大的缺点,例如要管理的Redis服务器太多,维护代价大;某些适用于单个Redis服务器的命令不适用于数据分区;数据分区无法解决热点读/写问题;数据偏斜,重新分配和放大/缩小变得更加复杂等等。
从Redis自身角度来说,因为读写网络的read/write系统调用占用了Redis执行期间大部分CPU时间,瓶颈主要在于网络的 IO 消耗, 优化主要有两个方向:
• 提高网络 IO 性能,典型的实现比如使用 DPDK 来替代内核网络栈的方式
• 使用多线程充分利用多核,典型的实现比如 Memcached。协议栈优化的这种方式跟 Redis 关系不大,支持多线程是一种最有效最便捷的操作方式。所以总结起来,redis支持多线程主要就是两个原因:
• 可以充分利用服务器 CPU 资源,目前主线程只能利用一个核
• 多线程任务可以分摊 Redis 同步 IO 读写负荷
4、Redis6.0默认是否开启了多线程?
Redis6.0的多线程默认是禁用的,只使用主线程。如需开启需要修改redis.conf配置文件:io-threads-do-reads yes
5、Redis6.0多线程开启时,线程数如何设置?
开启多线程后,还需要设置线程数,否则是不生效的。同样修改redis.conf配置文件
关于线程数的设置,官方有一个建议:4核的机器建议设置为2或3个线程,8核的建议设置为6个线程,线程数一定要小于机器核数。还需要注意的是,线程数并不是越大越好,官方认为超过了8个基本就没什么意义了。
6、Redis6.0采用多线程后,性能的提升效果如何?
Redis 作者 antirez 在 RedisConf 2019分享时曾提到:Redis 6 引入的多线程 IO 特性对性能提升至少是一倍以上。国内也有大牛曾使用unstable版本在阿里云esc进行过测试,GET/SET 命令在4线程 IO时性能相比单线程是几乎是翻倍了。
测试环境:
Redis Server: 阿里云 Ubuntu 18.04,8 CPU 2.5 GHZ, 8G 内存,主机型号 ecs.ic5.2xlarge
Redis Benchmark Client: 阿里云 Ubuntu 18.04,8 2.5 GHZ CPU, 8G 内存,主机型号 ecs.ic5.2xlarge
测试结果:
详见:正式支持多线程!Redis 6.0与老版性能对比评测 - 知乎
说明1:这些性能验证的测试并没有针对严谨的延时控制和不同并发的场景进行压测。数据仅供验证参考而不能作为线上指标。
说明2:如果开启多线程,至少要4核的机器,且Redis实例已经占用相当大的CPU耗时的时候才建议采用,否则使用多线程没有意义。所以估计80%的公司开发人员看看就好。
7、Redis6.0多线程的实现机制?
流程简述如下:
1、主线程负责接收建立连接请求,获取 socket 放入全局等待读处理队列
2、主线程处理完读事件之后,通过 RR(Round Robin) 将这些连接分配给这些 IO 线程
3、主线程阻塞等待 IO 线程读取 socket 完毕
4、主线程通过单线程的方式执行请求命令,请求数据读取并解析完成,但并不执行
5、主线程阻塞等待 IO 线程将数据回写 socket 完毕
6、解除绑定,清空等待队列该设计有如下特点:
-
IO 线程要么同时在读 socket,要么同时在写,不会同时读或写
-
IO 线程只负责读写 socket 解析命令,不负责命令处理
8、开启多线程后,是否会存在线程并发安全问题?
从上面的实现机制可以看出,Redis的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行。所以我们不需要去考虑控制 key、lua、事务,LPUSH/LPOP 等等的并发及线程安全问题。
9、Linux环境上如何安装Redis6.0.1(6.0的正式版是6.0.1)?
这个和安装其他版本的redis没有任何区别,整个流程跑下来也没有任何的坑,所以这里就不做描述了。唯一要注意的就是配置多线程数一定要小于cpu的核心数,查看核心数量命令:
[root@centos7.5 ~]# lscpu Architecture: x86_64 CPU op-mode(s): 32-bit, 64-bit Byte Order: Little Endian CPU(s): 4 On-line CPU(s) list: 0-3
10、Redis作者是如何点评 “多线程”这个新特性的?
关于多线程这个特性,在6.0 RC1时,Antirez曾做过说明:
Redis支持多线程有2种可行的方式:第一种就是像“memcached”那样,一个Redis实例开启多个线程,从而提升GET/SET等简单命令中每秒可以执行的操作。这涉及到I/O、命令解析等多线程处理,因此,我们将其称之为“I/O threading”。另一种就是允许在不同的线程中执行耗时较慢的命令,以确保其它客户端不被阻塞,我们将这种线程模型称为“Slow commands threading”。
经过深思熟虑,Redis不会采用“I/O threading”,redis在运行时主要受制于网络和内存,所以提升redis性能主要是通过在多个redis实例,特别是redis集群。接下来我们主要会考虑改进两个方面:
-
Redis集群的多个实例通过编排能够合理地使用本地实例的磁盘,避免同时重写AOF。
-
提供一个Redis集群代理,便于用户在没有较好的集群协议客户端时抽象出一个集群。
补充说明一下,Redis和memcached一样是一个内存系统,但不同于Memcached。多线程是复杂的,必须考虑使用简单的数据模型,执行LPUSH的线程需要服务其他执行LPOP的线程。
我真正期望的实际是“slow operations threading”,在redis6或redis7中,将提供“key-level locking”,使得线程可以完全获得对键的控制以处理缓慢的操作。
详见:An update about Redis developments in 2019 - <antirez>
11、Redis线程中经常提到IO多路复用,如何理解?
这是IO模型的一种,即经典的Reactor设计模式,有时也称为异步阻塞IO。
多路指的是多个socket连接,复用指的是复用一个线程。多路复用主要有三种技术:select,poll,epoll。epoll是最新的也是目前最好的多路复用技术。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求,且Redis在内存中操作数据的速度非常快(内存内的操作不会成为这里的性能瓶颈),主要以上两点造就了Redis具有很高的吞吐量。
12、Redis多线程模型缺陷
随着互联网的飞速发展,互联网业务系统所要处理的线上流量越来越大,Redis 的单线程模式会导致系统消耗很多 CPU 时间在网络 I/O 上从而降低吞吐量,于是诞生了Redis6.0多线程模型。
但是 Redis 多线程网络模型实际上并不是一个标准的 Multi-Reactors/Master-Workers模型。Redis 的多线程方案中,I/O 线程任务仅仅是通过 socket 读取客户端请求命令并解析,却没有真正去执行命令。所有客户端命令最后还需要回到主线程去执行,因此对多核的利用率并不算高,而且每次主线程都必须在分配完任务之后忙轮询等待所有 I/O 线程完成任务之后才能继续执行其他逻辑。
在我看来,Redis 目前的多线程方案更像是一个折中的选择:既保持了原系统的兼容性,又能利用多核提升 I/O 性能。
参考链接:
-
-
39 Redis 6.0的新特性:多线程、客户端缓存与安全
2021-12-15 22:44:2539 Redis 6.0的新特性:多线程、客户端缓存与安全前言一、从单线程处理网络请求到多线程处理二、实现服务端协助的客户端缓存三、从简单的基于密码访问到细粒度的权限控制四、启用 RESP 3 协议总结 前言 Redis 官方...39 Redis 6.0的新特性:多线程、客户端缓存与安全
前言
Redis 官方在 2020 年 5 月份正式推出了 6.0 版本,这个版本中有很多的新特性,分别是面向网络处理的多 IO 线程、客户端缓存、细粒度的权限控制,以及 RESP 3 协议的使用。
- 面向网络处理的多 IO 线程可以提高网络请求处理的速度;
- 客户端缓存可以让应用直接在客户端本地读取数据,这两个特性可以提升 Redis 的性能。
- 细粒度权限控制让 Redis 可以按照命令粒度控制不同用户的访问权限,加强了 Redis 的安全保护;
- RESP 3 协议则增强客户端的功能,让应用更加方便地使用 Redis 的不同数据类型。
一、从单线程处理网络请求到多线程处理
在 Redis 6.0 中,非常受关注的第一个新特性就是多线程。因为 Redis 一直被大家熟知的就是它的单线程架构,虽然有些命令操作可以用后台线程或子进程执行(比如数据删除、快照生成、AOF 重写),但是从网络 IO 处理到实际的读写命令处理,都是由单线程完成的。
随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 IO 的处理上,单个主线程处理网络请求的速度跟不上底层网络硬件的速度。
应对这个问题有两种方法:
- 用用户态网络协议栈(例如 DPDK)取代内核网络协议栈,让网络请求的处理不用在内核里执行,直接在用户态完成处理就行。避免频繁让内核进行网络请求处理,很好地提升请求处理效率。但是这个方法要求在 Redis 的整体架构中,添加对用户态网络协议栈的支持,需 要修改 Redis 源码中和网络相关的部分(例如修改所有的网络收发请求函数),这会带来很多开发工作量。而且新增代码还可能引入新 Bug,导致系统不稳定。所以 Redis 6.0 中 并没有采用这个方法。
- 采用多个 IO 线程来处理网络请求,提高网络请求处理的并行度。Redis 6.0 就是采用的这种方法。但是 Redis 的多 IO 线程只是用来处理网络请求的,对于读写命令 Redis 仍然使用单线程来处理。因为 Redis 处理请求时,网络处理经常是瓶颈,通过多个 IO 线程并行处理网络操作,可以提升实例的整体处理性能。而继续使用单线程执行命令操作,就不用为了保证 Lua 脚本、事务的原子性,额外开发多线程互斥机制了。这样 Redis 线程模型实现就简单了。
在 Redis 6.0 中,主线程和 IO 线程协作完成请求处理的原理,把主线程和多 IO 线程的协作分成四个阶段:
-
服务端和客户端建立 Socket 连接,并分配处理线程:
首先,主线程负责接收建立连接请求。当有客户端请求和实例建立 Socket 连接时,主线程会创建和客户端的连接,并把 Socket 放入全局等待队列中。紧接着主线程通过轮询方法 把 Socket 连接分配给 IO 线程。 -
IO 线程读取并解析请求:
主线程一旦把 Socket 分配给 IO 线程,就会进入阻塞状态,等待 IO 线程完成客户端请求读取和解析。因为有多个 IO 线程在并行处理,所以这个过程很快就可以完成。 -
主线程执行请求操作:
等到 IO 线程解析完请求,主线程还是会以单线程的方式执行这些命令操作。
-
IO 线程回写 Socket 和主线程清空全局队列:
当主线程执行完请求操作后,会把需要返回的结果写入缓冲区,然后主线程会阻塞等待 IO 线程把这些结果回写到 Socket 中,并返回给客户端。和 IO 线程读取和解析请求一样,IO 线程回写 Socket 时,也是有多个线程在并发执行, 所以回写 Socket 的速度也很快。等到 IO 线程回写 Socket 完毕,主线程会清空全局队列,等待客户端的后续请求。
下图展示了这个阶段主线程和 IO 线程的操作,你可以看下。
在 Redis 6.0 中, 多线程机制默认是关闭的,如果需要使用多线程功能,需要在 redis.conf 中完成两个设置:
- 设置 io-thread-do-reads 配置项为 yes,表示启用多线程:
io-threads-do-reads yes
- 设置线程个数。一般线程个数要小于 Redis 实例所在机器的 CPU 核个数,例如,一个 8 核的机器 Redis 官方建议配置 6 个 IO 线程:
io-threads 6
如果在实际应用中,发现 Redis 实例的 CPU 开销不大,吞吐量却没有提升,可以考虑使用 Redis 6.0 的多线程机制,加速网络处理,进而提升实例的吞吐量。
二、实现服务端协助的客户端缓存
Redis 6.0 新增了一个重要的特性,实现了服务端协助的客户端缓存功能,也称为跟踪(Tracking)功能。有了这个功能,业务应用中的 Redis 客户端可以把读取的数据缓存在业务应用本地了,应用就可以直接在本地快速读取数据了。
不过当把数据缓存在客户端本地会面临一个问题:如果数据被修改了或是失效了,如何通知客户端对缓存的数据做失效处理?
6.0 实现的 Tracking 功能实现了两种模式来解决这个问题:
- 普通模式。实例会在服务端记录客户端读取过的 key,并监测 key 是否有修改。一旦 key 的值发生变化,服务端会给客户端发送 invalidate 消息,通知客户端缓存失效了。需要注意,服务端对于记录的 key 只会报告一次 invalidate 消息,服务端在给客户端发送过一次 invalidate 消息后,如果 key 再被修改,服务端就不会再次给客户端发送 invalidate 消息。只有当客户端再次执行读命令时,服务端才会再次监测被读取的 key,并在 key 修改时发送 invalidate 消息。这样设计的考虑是节省有限的内存空间。毕竟如果客户端不再访问这个 key 了,而服务端仍然记录 key 的修改情况,就会浪费内存资源。
通过执行下面的命令,打开或关闭普通模式下的 Tracking 功能:
CLIENT TRACKING ON|OFF
- 广播模式。服务端会给客户端广播所有 key 的失效情况,这样做了之后,如果 key 被频繁修改,服务端会发送大量的失效广播消息,这就会消耗大量的网络带宽资源。所以在实际应用时,让客户端注册希望跟踪的 key 的前缀,当带有注册前缀的 key 被修改时,服务端会把失效消息广播给所有注册的客户端。和普通模式不同,在广播模式下,即使客户端还没有读取过 key,但只要它注册了要跟踪的 key,服务端都会把 key 失效消息通知给这个客户端。这种监测带有前缀的 key 的广播模式,需要对 key 的命名规范。在实际应用时,会给同一业务下的 key 设置相同的业务名前缀,就可以非常方便地使用 广播模式。
例如,客户端使用广播模式接收 key 失效消息。在客户端执行下面的命令后,如果服务端更新了 user : id : 1003 这个 key,客户端就会收到 invalidate 消息。
CLIENT TRACKING ON BCAST PREFIX user
不过普通模式和广播模式,需要客户端使用 RESP 3 协议,RESP 3 协议是 6.0 新启用的通信协议。
使用 RESP 2 协议的客户端需要使用另一种模式,也就是重定向模式 (redirect)。在重定向模式下,想要获得失效消息通知的客户端,就需要执行订阅命令 SUBSCRIBE,专门订阅用于发送失效消息的频道 redis:invalidate。同时再使用另外一个客户端,执行 CLIENT TRACKING 命令,设置服务端将失效消息转发给使用 RESP 2 协议的客户端。
例如,使用 RESP 2 协议的客户端接受失效消息。假设客户端 B 想要获取失效消息,但是客户端 B 只支持 RESP 2 协议,客户端 A 支持 RESP 3 协议。可以分别在客户端 B 和 A 上执行 SUBSCRIBE 和 CLIENT TRACKING,如下所示:
//客户端B执行,客户端B的ID号是303 SUBSCRIBE _redis_:invalidate //客户端A执行 CLIENT TRACKING ON BCAST REDIRECT 303
这样设置以后,如果有键值对被修改了,客户端 B 就可以通过 redis:invalidate 频道, 获得失效消息了。
三、从简单的基于密码访问到细粒度的权限控制
Redis 6.0 提供了实例的访问权限控制列表功能(Access Control List,ACL),提升 Redis 的使用安全性
在 Redis 6.0 版本之前,要想实现实例的安全访问,只能通过设置密码来控制,例如,客户端连接实例前需要输入密码。对于一些高风险的命令(例如 KEYS、FLUSHDB、FLUSHALL 等),在 Redis 6.0 之前,也只能通过 rename-command 来重新命名这些命令,避免客户端直接调用。
Redis 6.0 提供了更加细粒度的访问权限控制,有两方面的体现:
- 6.0 版本支持创建不同用户来使用 Redis。在 6.0 版本前,所有客户端可以使用同一个密码进行登录使用,但是没有用户的概念,而在 6.0 中,可以使用 ACL SETUSER 命令创建用户。执行下面的命令,创建并启用一个用户 normaluser,把它的密码设置为“abc”:
ACL SETUSER normaluser on > abc
- 6.0 版本还支持以用户为粒度设置命令操作的访问权限。具体操作在下表中,加号(+)和减号(-)就分别表示给用户赋予或撤销命令的调用权限。
假设要设置用户 normaluser 只能调用 Hash 类型的命令操作,而不能调用 String 类型的命令操作,执行如下命令:
ACL SETUSER normaluser +@hash -@string
6.0 版本还支持以 key 为粒度设置访问权限,使用波浪号“~”和 key 的前缀来表示控制访问的 key。执行下面命令,设置用户 normaluser 只能对以“user:”为前缀的 key 进行命令操作:
ACL SETUSER normaluser ~user:* +@all
Redis 6.0 可以设置不同用户来访问实例,而且可以基于用户和 key 的粒度,设置某个用户对某些 key 允许或禁止执行的命令操作。在有多用户的 Redis 应用场景下,非常方便和灵活地为不同用户设置不同级别的命令操作权限,这对于提供安全的 Redis 访问非常有帮助。
四、启用 RESP 3 协议
Redis 6.0 实现了 RESP 3 通信协议,之前都是使用的 RESP 2。在 RESP 2 中,客户端和服务器端的通信内容都是以字节数组形式进行编码的,客户端需要根据操作的命令或是数据类型自行对传输的数据进行解码,增加了客户端开发复杂度。
RESP 3 直接支持多种数据类型的区分编码,包括空值、浮点数、布尔值、有序的字典集合、无序的集合等。
区分编码指直接通过不同的开头字符,区分不同的数据类型,这样客户端就可以直接通过判断传递消息的开头字符,来实现数据转换操作了,提升了客户端的效率。除此之外,RESP 3 协议还可以支持客户端以普通模式和广播模式实现客户端缓存。
总结
Redis 6.0 的新特性:
Redis 6.0 新的功能特性需要在实际应用中进行部署和验证,所以如果想试用 Redis 6.0,可以尝试先在非核心业务上使用 Redis 6.0,一方面可以验证新特性带来的性能或功能优势,另一方面,也可以避免因为新特性不稳定而导致核心业务受到影响。 -
redis 6.0之多线程,深入解读
2022-05-15 22:57:44一般来说,一个 redis 请求有两大模块,网络模块 + 命令处理模块。我们常说的 redis 单线程模型,其实主要就是值的一个正常请求涉及的网络模块和命令处理模块。当然,两者都有可能出现问题,我们分别来看看: 当一个...
前言
本文参考源码版本为
redis6.2
一般来说,一个 redis
请求
有两大模块,网络模块 + 命令处理模块。我们常说的 redis 单线程模型,其实主要就指的是一个正常请求涉及的网络模块和命令处理模块。当执行一个特别慢的命令时,比如删除一个百万级的字典,可能会造成暂时的卡顿,导致 QPS 骤降;基于此,在 redis 4.0 出现专门处理这种 Lazy Free 模型的
后台线程
。另外,正常情况下,redis 单线程模型中,网络模块往往成为瓶颈高发地;因此,redis 6.0 引入
多线程
模型,解决网络模块的问题。前面系列文章已经介绍了 redis 的单线程模型及其背后的后台线程,因此,本文主要焦点集中在 redis 6.0 出现的多线程。
在开始阅读之前,你也可以思考一个问题:命令处理为什么不采用多线程模型?
一、架构演进?
复杂架构都是逐渐演进而来,从单线程到多线程,从单体功能到复杂功能等等,redis 也是如此。
redis 单线程情况下,也能达到极高的吞吐量,但某些情况下,会出现相当耗时的操作,导致吞处理骤降,因此逐渐引入后台线程来完成这些操作。
慢慢地,当我们要求更高的吞吐量时,网络模块却经常掉链子,在 redis 6.0 中又引入多线程来解决这个问题 ----- 这也是本文主要探讨的问题。
话不多说,开始吧~
1. 单线程
redis 是单线程模式
------- 这是我们经常听到了说法,仅靠一个线程就能达到几万QPS,简直令人称奇!我画了张单线程模型图,大概是这样:
你可以直观的看到,redis 确实仅靠一个线程处理了所有客户端的请求
,一条龙服务!!!从接收新连接、IO就绪监听、IO读,到命令执行,最后到命令执行后的数据回复(IO写)等都是一个线程处理,这些操作的封装,redis 中称之为
文件事件
;当然,还有。redis 中的另一大事件 -------
时间事件
,负责相关的周期性处理任务,比如 key 过期清理、字典 rehash、触发 AOF 重写/RDB 的 bgsave等等。值得注意的是,文件事件 和 时间事件 都是由主线程来驱动完成的。入口是 aeMain() 方法,redis 服务启动后,将会一直在此方法中轮训监听事件。
到这,你可能会说,一个线程做这么多事还不得累死?
是的,一个线程串行做这么多事情确实存在很大风险,对于一些耗时长的操作,可能严重拖垮 redis 吞吐量,所以,redis 又搞了一些后台线程来专门处理这些耗时操作。
2. 单线程+后台线程
特别需要注意的是!!!我们经常听说的 redis 单线程模型(上图),其实仅仅指的是对客户端的请求处理,但其实,还有一些工作由对应特殊的线程来完成。
在 redis 6.0 以前,
完整的 redis 线程模型是 主线程(1个)+ 后台线程(三个)
,我画了一张图,你可以看下:
三个后台线程分别处理:- close_file:关闭 AOF、RDB 等过程中产生的大临时文件
- aof_fsync:将追加至 AOF 文件的数据刷盘(一般情况下 write 调用之后,数据被写入内核缓冲区,通过 fsync 调用才将内核缓冲区的数据写入磁盘)
- lazy_free:惰性释放大对象
这三个线程有一个共同特点,都是用来处理耗时长的操作,也印证了我们常说的,
专业的人做专业的事
。3. 多线程+后台线程
咱们继续将时钟往后拨到 redis6.0 版本,此版本出现了一种新的 IO 线程 ---- 多线程。我同样也画了张图,你可以看下:
我们先思考下,引进 IO 线程解决了哪些问题?
在之前系列文章中,我们提到过,通常情况下,
redis 性能在于网络和内存,而不是 CPU
。针对 网络,一般是处理速度较慢的问题;针对内存,一般是指物理空间的限制。所以到这,你应该很清楚了,究竟哪个模块需要引入多线程来处理?
没错,就是网络模块,因此,引入的这些线程也叫 IO线程;由于主线程也会处理网络模块的工作,因此,主线程习惯上也叫做主IO线程。
网络模块有接收连接、IO读(包括数据解析)、IO写等操作;其中,主线程负责接收新连接,然后分发到 IO线程进行处理(主线程也参与)。我画了张图,你可以看下:
默认情况下,只针对写操作启用IO线程,如果读操作也需要的话,需要在配置文件中进行配置:// server.c#redisServer结构体 int io_threads_do_reads;
二、原理
前面我们已经讲到,redis 6.0 出现的多线程主要致力于解决网络模块的瓶颈,通过使用多线程处理读/写客户端数据,进而分担主IO线程的压力。
值得注意的是,命令处理仍然是单线程执行。
为了更好的帮助你理解,我们再来回顾下,请求处理流程:
接下来,我们将结合源码,看看多线程如何大展身手~
1. 初始化
在 server.c#main 启动的最后阶段,调用方法 InitServerLast,我们来看看其实现:
// server.c#InitServerLast void InitServerLast() { bioInit(); initThreadedIO(); set_jemalloc_bg_thread(server.jemalloc_bg_thread); server.initial_memory_usage = zmalloc_used_memory(); }
其中,initThreadedIO 调用正是初始化 IO 线程:
// networking.c#initThreadedIO void initThreadedIO(void) { server.io_threads_active = 0; /* We start with threads not active. */ /* Don't spawn any thread if the user selected a single thread: * we'll handle I/O directly from the main thread. */ if (server.io_threads_num == 1) return; if (server.io_threads_num > IO_THREADS_MAX_NUM) { serverLog(LL_WARNING,"Fatal: too many I/O threads configured. " "The maximum number is %d.", IO_THREADS_MAX_NUM); exit(1); } /* Spawn and initialize the I/O threads. */ for (int i = 0; i < server.io_threads_num; i++) { /* Things we do for all the threads including the main thread. */ io_threads_list[i] = listCreate(); if (i == 0) continue; /* Thread 0 is the main thread. */ /* Things we do only for the additional threads. */ pthread_t tid; pthread_mutex_init(&io_threads_mutex[i],NULL); setIOPendingCount(i, 0); pthread_mutex_lock(&io_threads_mutex[i]); /* Thread will be stopped. */ // 真正的创建线程,并指定处理方法 IOThreadMain if (pthread_create(&tid,NULL,IOThreadMain,(void*)(long)i) != 0) { serverLog(LL_WARNING,"Fatal: Can't initialize IO thread."); exit(1); } io_threads[i] = tid; } }
值得注意的是,i == 0 表示主IO线程!!!
我们定位到 for 循环中的
pthread_create
方法 ---- 真正的创建线程的方法,并指定线程的执行方法体 IOThreadMain — 主角。void *IOThreadMain(void *myid) { /* The ID is the thread number (from 0 to server.iothreads_num-1) */ long id = (unsigned long)myid; char thdname[16]; snprintf(thdname, sizeof(thdname), "io_thd_%ld", id); redis_set_thread_title(thdname); redisSetCpuAffinity(server.server_cpulist); makeThreadKillable(); while(1) { /* Wait for start */ for (int j = 0; j < 1000000; j++) { if (getIOPendingCount(id) != 0) break; } /* Give the main thread a chance to stop this thread. */ if (getIOPendingCount(id) == 0) { pthread_mutex_lock(&io_threads_mutex[id]); pthread_mutex_unlock(&io_threads_mutex[id]); continue; } serverAssert(getIOPendingCount(id) != 0); if (tio_debug) printf("[%ld] %d to handle\n", id, (int)listLength(io_threads_list[id])); /* Process: note that the main thread will never touch our list * before we drop the pending count to 0. */ listIter li; listNode *ln; listRewind(io_threads_list[id],&li); while((ln = listNext(&li))) { client *c = listNodeValue(ln); if (io_threads_op == IO_THREADS_OP_WRITE) { writeToClient(c,0); } else if (io_threads_op == IO_THREADS_OP_READ) { readQueryFromClient(c->conn); } else { serverPanic("io_threads_op value is unknown"); } } listEmpty(io_threads_list[id]); setIOPendingCount(id, 0); if (tio_debug) printf("[%ld] Done\n", id); } }
如果你熟悉 java 的话,应该知道 IOThreadMain 就相当于 runable 的具体实现。核心逻辑在于 while(1) 无限循环中。
从源码中看到,IO 线程是从 io_threads_list 队列(或者说列表)获取待处理的客户端,并根据操作类型选择具体的执行逻辑。
看到这,你应该就豁然开朗了,这就是典型的
生产者-消费者模型
,主IO线程负责投递事件,IO 线程负责消费事件(主线程也参与)。从 IO 线程执行主体中,我们看到,通过 writeToClient 处理写请求, readQueryFromClient 处理读请求,我们接下来将具体分析这两种情况~
2. 多线程读
一般情况下,当我们通过多路复用监听到客户端数据准备就绪时,将会在主事件循环中,轮询处理这批就绪的客户端。
从读取数据 => 数据解析 => 命令执行 => 写会客户端缓冲区 => 待下一轮主事件循环到来时,将客户端缓冲数据写会客户端。
在多线程模式下(假设配置了多线程读),上述流程有了些许变化:读取数据 => 数据解析 模块处理操作,将均分给多个 IO 线程处理(包括主IO线程)。
所有就绪客户端暂存至队列:
struct redisServer { ... list *clients_pending_read; ... }
1)入队:
具体代码上的体现是,postponeClientRead 返回 1 之后,将直接退出。
// networking.c#readQueryFromClient void readQueryFromClient(connection *conn) { client *c = connGetPrivateData(conn); int nread, readlen; size_t qblen; // 如果 IO 线程读开启,退出操作,待下一次 eventloop 循环到来时处理 if (postponeClientRead(c)) return; ... }
多线程读开启时,函数 postponeClientRead 是关键:
// networking.c#postponeClientRead int postponeClientRead(client *c) { if (server.io_threads_active && server.io_threads_do_reads && !ProcessingEventsWhileBlocked && !(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_PENDING_READ))) { c->flags |= CLIENT_PENDING_READ; listAddNodeHead(server.clients_pending_read,c); return 1; } else { return 0; } }
可以看到,当我们开启 读 IO 多线程配置,将直接将该客户端添加至队列中,等待进行分配(下一轮 eventloop 循环)。
2)分配:
在新一轮 eventloop 循环,通过 IO 多路复用查询之前(这一步通常是阻塞等待,因此,也常称为阻塞操作),会调用
beforeSleep
处理一些客户端的操作,其中就包括多线程读取客户端数据
,刷新客户端缓存数据至客户端
。来看看 handleClientsWithPendingReadsUsingThreads 方法:
// networking.c#handleClientsWithPendingReadsUsingThreads int handleClientsWithPendingReadsUsingThreads(void) { // 如果没有开启多线程IO读,将直接退出 if (!server.io_threads_active || !server.io_threads_do_reads) return 0; ... // 将客户端均分,每个IO线程有对应的队列 listRewind(server.clients_pending_read,&li); int item_id = 0; while((ln = listNext(&li))) { client *c = listNodeValue(ln); int target_id = item_id % server.io_threads_num; listAddNodeTail(io_threads_list[target_id],c); item_id++; } // 通知等待的IO线程 io_threads_op = IO_THREADS_OP_READ; for (int j = 1; j < server.io_threads_num; j++) { int count = listLength(io_threads_list[j]); setIOPendingCount(j, count); } // 主线程也要参与处理部分客户端 listRewind(io_threads_list[0],&li); while((ln = listNext(&li))) { client *c = listNodeValue(ln); readQueryFromClient(c->conn); } listEmpty(io_threads_list[0]); // 等待所有线程完成操作 while(1) { unsigned long pending = 0; for (int j = 1; j < server.io_threads_num; j++) pending += getIOPendingCount(j); if (pending == 0) break; } if (tio_debug) printf("I/O READ All threads finshed\n"); ... return processed; }
主要处理:
- 将待处理客户端(clients_pending_read)均分至各 IO 线程对应队列中(主IO线程参与均分)
- 通知等待中的 IO 线程
- 主IO线程处理部分客户端
- 等待所有 IO 线程处理结束
简单总结以上两点:
- 当多线程读开启,并且多线程处于激活状态,客户端暂存于队列;反之直接通过 readQueryFromClient 进行处理。
- 暂存于队列中的客户端,会在下一次 eventloop 中,before sleep 之前,分发至IO线程各自的队列中处理
再次强调,主IO线程也参与处理。
3. 多线程写:
同样的,客户端响应数据也是先写到队列:
struct redisServer { ... list *clients_pending_write; ... }
从处理时机上看,多线程 写与读 都是在
beforeSleep
中被触发的,写操作是通过 handleClientsWithPendingWritesUsingThreads 完成:// networking.c#handleClientsWithPendingWritesUsingThreads int handleClientsWithPendingWritesUsingThreads(void) { int processed = listLength(server.clients_pending_write); if (processed == 0) return 0; /* Return ASAP if there are no clients. */ // 如果没有启用多线程写,或者仅有少量客户端,写操作就直接由主IO线程来完成。 if (server.io_threads_num == 1 || stopThreadedIOIfNeeded()) { return handleClientsWithPendingWrites(); } // 激活IO线程 if (!server.io_threads_active) startThreadedIO(); ... // 分发客户端至IO线程的队列中 listRewind(server.clients_pending_write,&li); int item_id = 0; while((ln = listNext(&li))) { client *c = listNodeValue(ln); c->flags &= ~CLIENT_PENDING_WRITE; // 如果客户端已关闭,直接移除即可 if (c->flags & CLIENT_CLOSE_ASAP) { listDelNode(server.clients_pending_write, ln); continue; } int target_id = item_id % server.io_threads_num; listAddNodeTail(io_threads_list[target_id],c); item_id++; } // 通知等待中的线程 io_threads_op = IO_THREADS_OP_WRITE; for (int j = 1; j < server.io_threads_num; j++) { int count = listLength(io_threads_list[j]); setIOPendingCount(j, count); } // 主线程也要处理部分客户端 listRewind(io_threads_list[0],&li); while((ln = listNext(&li))) { client *c = listNodeValue(ln); writeToClient(c,0); } listEmpty(io_threads_list[0]); // 等待所有线程完成工作 while(1) { unsigned long pending = 0; for (int j = 1; j < server.io_threads_num; j++) pending += getIOPendingCount(j); if (pending == 0) break; } ... return processed; }
该方法主要做了几件事情:
- 如果没有启用多线程写,或者仅有少量客户端,写操作就直接由主IO线程来完成。
- 激活IO线程(当待客户端较少时,会挂起IO线程 ---- 锁等待)
- 通知等待中的线程(共享变量值 > 0 时,表示有待处理任务)
- 主线程也要处理部分客户端。
- 等待所有线程完成工作。
值得注意的是,当
待处理客户端
过少时,redis 认为没必要采用多线程来共同处理,因此,完全交给主IO线程来完成:int stopThreadedIOIfNeeded(void) { int pending = listLength(server.clients_pending_write); if (server.io_threads_num == 1) return 1; if (pending < (server.io_threads_num*2)) { if (server.io_threads_active) stopThreadedIO(); return 1; } else { return 0; } }
可见,当
待处理客户端 < 2倍IO线程数
时,将由主 IO 线程完成所有客户端数据刷回。三、配置
redis 默认情况下不会开启多线程处理,官方也建议,除非性能达到瓶颈,否则没必要开启多线程。
配置多少合适?
官方文档 redis.conf 中介绍有:
By default threading is disabled, we suggest enabling it only in machines that have at least 4 or more cores, leaving at least one spare core. Using more than 8 threads is unlikely to help much. We also recommend using threaded I/O only if you actually have performance problems, with Redis instances being able to use a quite big percentage of CPU time, otherwise there is no point in using this feature. So for instance 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. In order to enable I/O threads use the following configuration directive:
CPU 4 核以上,才考虑开启多线程,其中:
- 4 核开启 2 - 3 个 IO 线程
- 8 核 开启 6 个 IO 线程
- 超过 8 个 IO 线程,性能提升已经不大
值得注意的是,以上的 IO 线程其实包含了主 IO 线程。
配置:
开启多线程:配置 io-thread 即可。io-thread = 1 表示只使用主 IO 线程
io-threads 4
开启之后,默认写操作会通过多线程来处理,而读操作则不会。
如果读操作也想要开启多线程,则需要配置:
io-threads-do-reads yes
总结
本文从 redis 架构演进开始讲起,从单线程模型 => 单线程 + 后台线程 => 多线程 + 后台线程 演进。
每一次演进,都是为了解决某一类特殊问题;后台线程的出现,解决了一些耗时长的重操作。同样,多线程的出现,解决了网络模块的性能瓶颈。
回到开篇问题:为什么命令执行为什么不采用多线程?
- 使用多线程会提升复杂度,对于 redis 这种内存数据库,代价太高
- 一般情况下,redis 的瓶颈在于网络模块和内存,而非 CPU
- 可以在一台机器上部署多个实例(集群模式)
- 复杂(慢)命令可以通过 redis module 解决
相关文档:
-
Redis6.0的多IO线程
2022-01-08 12:18:44Redis6.0的多IO线程 通过上篇文章的学习,我们知道 Redis server 启动后的进程会以单线程的方式,执行客户端请求解析和处理工作。但是,Redis server 也会通过 bioInit 函数启动三个后台线程,来处理后台任务。也... -
【Redis源码】Redis 6.0 字符串 SDS 源码分析
2021-07-21 14:54:12Redis 6.0 字符串 SDS 源码分析 -
redis 6.0 源码代码整体结构
2021-12-23 16:55:20redis 6.0 源码代码整体结构整体结构deps目录src目录tests目录 整体结构 在redis源码目录下,一共包含了deps、src、tests、utils四个子目录。 deps目录 主要包含了Redis依赖的第三方代码库,这些代码可以独立于... -
Redis 6.0 新特性
2021-08-31 09:17:56redis 6.0 提供了多线程的支持,redis 6 以前的版本,严格来说也是多线程,只不过执行用户命令的请求时单线程模型,还有一些线程用来执行后台任务, 比如 unlink 删除 大key,rdb持久化等。 redis 6.0 提供了多线程... -
Redis 6.0引入的多线程机制简介
2021-09-24 10:58:27简单介绍了Redis 6.0的多线程模型。 -
Redis6.0 简单明确搭建集群 Redis-cluster 教程
2022-03-28 11:47:33此处以三台服务器部署为例,IP地址分别为192.168.124.23,192....下载Redis6.0 Stable版安装包到/usr/local/redis-6.0.x.tar.gz(文件位置可自定义) 将安装包解压tar -zxvf redis-6.0.x.tar.gz 进入redis文件夹(cd -
Windows编译安装Redis 6.0
2022-04-11 13:02:22Windows编译安装Redis 6.0 附上成品:https://wwa.lanzous.com/iDAyJezy7rc 2021年1月25日更新: 经评论区提醒,发现上面分享的以及教程演示的redis版本号显示为999,看了看是因为我下的源码是 redis-unstable 因此... -
Redis 6.0新特性
2020-05-08 19:46:14文章目录总览Redis 6.0新特性多线程IO(Threaded I/O)那么为什么不能采用多线程操作命令?为什么引入多线程IO?多线程IO启用多线程IO香吗?RESP3协议ACLsSSL支持客户端缓存(Client side caching)Redis Cluster ... -
基于Docker构建Redis6.0-Cluster及Haproxy
2021-12-01 15:36:30搭建Redis Cluster 本文使用多机搭建,一台虚机两个节点 ...redis:6.0 192.168.119.157 CentOS Linux release 7.6.1810 version 19.03.9 redis:6.0 192.168.119.158 CentOS Linux release 7.6.1810 v -
Centos安装redis6.0.
2022-06-03 20:28:16redis安装 -
Centos7下安装Redis 6.0
2022-04-01 16:47:09Centos7下安装Redis 6.0Redis安装1、下载安装包2、安装 依赖环境3、编译4、安装5、配置启动6、注册开机启动7、常用命令 Redis安装 1、下载安装包 官网下载地址 tar -xzvf redis-6.2.6.tar.gz 2、安装 依赖环境 yum ... -
redis 6.0多线程机制
2021-03-27 11:01:21redis6.0主要重磅特性是多线程,本篇主要向大家讲解redis多线程的机制,在讲解多线程机制之前,先要看看redis的单线程机制。所以,本篇先来带大家回顾一下redis 6.0以前版本的单线程机制,再重点讲解redis6.0的多... -
redis6.0主从配置
2021-12-31 21:47:19redis安装:...修改从节点的redis.config文件 #主节点的redis ip和端口 replicaof 127.0.0.1 6379 #开启只读 replica-read-only yes #主节点的redis密码,如果没有密码注释掉即可 masterauth 123456 ...