一、Redis5.0新特性
- 新的流数据类型(Stream data type)
- 新的 Redis 模块 API:定时器、集群和字典 API(Timers, Cluster and Dictionary APIs)
- RDB 增加 LFU 和 LRU 信息
- 集群管理器从 Ruby (redis-trib.rb) 移植到了redis-cli 中的 C 语言代码
- 新的有序集合(sorted set)命令:ZPOPMIN/MAX 和阻塞变体(blocking variants)
- 升级 Active defragmentation 至 v2 版本
- 增强 HyperLogLog 的实现
- 更好的内存统计报告
- 许多包含子命令的命令现在都有一个 HELP 子命令
- 客户端频繁连接和断开连接时,性能表现更好
- 升级 Jemalloc 至 5.1 版本
- 引入 CLIENT UNBLOCK 和 CLIENT ID
- 新增 LOLWUT 命令
- 在不存在需要保持向后兼容性的地方,弃用 "slave" 术语
- 网络层中的差异优化
- 对 Redis 核心代码进行了重构并在许多方面进行了改进
- 引入动态的 HZ(Dynamic HZ) 以平衡空闲 CPU 使用率和响应性
二、Redis 6.0 新特性
深度嵌套的从复制:在Redis 6.0中,引入了深度嵌套的从复制,使得从节点可以成为其他主节点的从节点,从而构建多级的从节点拓扑结构。这一特性提高了系统的可扩展性和灵活性,使得数据在分布式环境中更加可靠。
线程模型改进:Redis 6.0对线程模型进行了改进,引入了新的I/O线程,使得Redis能够更好地利用多核处理器的性能。这一改进提高了Redis在高负载环境下的性能表现,并降低了对单个CPU核心的依赖。
新的RDB版本:Redis 6.0引入了新的RDB版本(RDB版本 9),在处理大型数据库时具有更好的性能和可靠性。这一改进使得Redis在备份和恢复大规模数据时更加高效,降低了与持久化相关的性能开销。
慢查询日志改进:Redis 6.0对慢查询日志进行了改进,引入了新的命令SLOWLOG GET,使得开发者能够更加灵活地检索和分析慢查询日志。这一改进有助于开发者更好地识别和优化性能瓶颈,提高系统的响应速度。
TLS支持: Redis 6.0新增了对TLS(Transport Layer Security)的支持,通过加密保护数据在传输过程中的安全性。这一改进使得Redis在安全性方面更加强大,并能够满足更严格的安全要求。
多线程模型
1. redis 6.0 提供了多线程的支持,redis 6 以前的版本,严格来说也是多线程,只不过执行用户命令的请求时单线程模型,还有一些线程用来执行后台任务, 比如 unlink 删除 大key,rdb持久化等。
redis 6.0 提供了多线程的读写IO, 但是最终执行用户命令的线程依然是单线程的,这样,就没有多线程数据的竞争关系,依然很高效。
1、服务端和客户端建立 Socket 连接,并分配处理线程。首先,主线程负责接收建立连接请求。当有客户端请求和实例建立 Socket 连接时,主线程会创建和客户端的连接,并把 Socket 放入全局等待队列中。紧接着,主线程通过轮询方法把 Socket 连接分配给 IO 线程。
2、IO 线程读取并解析请求。主线程一旦把 Socket 分配给 IO 线程,就会进入阻塞状态,等待 IO 线程完成客户端请求读取和解析。因为有多个 IO 线程在并行处理,所以,这个过程很快就可以完成。
3、主线程执行请求操作。等到 IO 线程解析完请求,主线程还是会以单线程的方式执行这些命令操作。
4、IO 线程回写 Socket 和主线程清空全局队列。当主线程执行完请求操作后,会把需要返回的结果写入缓冲区,然后,主线程会阻塞等待 IO 线程把这些结果回写到 Socket 中,并返回给客户端。
和 IO 线程读取和解析请求一样,IO 线程回写 Socket 时,也是有多个线程在并发执行,所以回写 Socket 的速度也很快。等到 IO 线程回写 Socket 完毕,主线程会清空全局队列,等待客户端的后续请求。
可以通过如下参数配置多线程模型
io-threads 4 // 这里说 有三个IO 线程,还有一个线程是main线程,main线程负责IO读写和命令执行操作
默认情况下,如上配置,有三个IO线程, 这三个IO线程只会执行 IO中的write 操作,也就是说,read 和 命令执行 都由main线程执行。最后多线程将数据写回到客户端。
io-threads-do-reads yes // 将支持IO线程执行 读写任务
客户端缓存
客户端缓存:redis 6 提供了服务端追踪key的变化,客户端缓存数据的特性,这需要客户端实现
执行流程为, 当客户端访问某个key时,服务端将记录key 和 client ,客户端拿到数据后,进行客户端缓存,这时,当key再次被访问时,key将被直接返回,避免了与redis 服务器的再次交互,节省服务端资源,当数据被其他请求修改时,服务端将主动通知客户端失效的key,客户端进行本地失效,下次请求时,重新获取最新数据
实现代码举例
public static void main(String[] args) throws InterruptedException {
RedisClient redisClient = RedisClient.create("redis://192.168.109.200");
Map<String, String> clientCache = new ConcurrentHashMap<>();
StatefulRedisConnection<String, String> myself = redisClient.connect();
CacheFrontend<String, String> frontend =
ClientSideCaching.enable(CacheAccessor.forMap(clientCache),
myself,
TrackingArgs.Builder.enabled().noloop());
String key="csk";
int count = 0;
while (true){
System.out.println(frontend.get(key));
TimeUnit.SECONDS.sleep(3);
if (count++ == Integer.MAX_VALUE){
myself.close();
redisClient.shutdown();
}
}
}
ACL权限系统
在Redis 5版本之前,Redis 安全规则只有密码控制 还有通过rename 来调整高危命令比如 flushdb , KEYS* , shutdown 等。
Redis 6 则提供ACL的功能对用户进行更细粒度的权限控制 ,支持对客户端的权限控制,实现对不同的key授予不同的操作权限:
(1)接入权限: 用户名和密码 (2)可以执行的命令 (3)可以操作的 KEY
启用 RESP 3 协议
Redis 6.0 实现了 RESP 3 通信协议,而之前都是使用的 RESP 2。在 RESP 2 中,客户端和服务器端的通信内容都是以字节数组形式进行编码的,客户端需要根据操作的命令或是数据类型自行对传输的数据进行解码,增加了客户端开发复杂度。
而 RESP 3 直接支持多种数据类型的区分编码,包括空值、浮点数、布尔值、有序的字典集合、无序的集合等。
提升RDB加载速度
根据文件的实际组成(较大或较小的值),可以预期20/30%的改进。当有很多客户机连接时,信息也更快了,这是一个老问题,现在终于解决了。
Redis Cluster proxy(集群代理)
antirez开发了 Proxy 功能,让 Cluster 拥有像单实例一样的接入方式,降低大家使用cluster的门槛。不过需要注意的是代理不改变 Cluster 的功能限制,不支持的命令还是不会支持,比如跨 slot 的多Key操作。
三、Redis7.0 新特性
Redis Functions
Redis 2.6 引入了 Lua 脚本。使用 Lua 脚本有非常多好处,比如可以在 Redis 中实现多条命令的原子性操作,能够轻松安全地实现分布式锁功能。但 Lua 脚本持久化在过去的版本中始终属于模糊不清的概念,它不会被 RDD 或 AOF 持久化到磁盘,也不会随着主从由主库同步至从库,一旦发生进程重启或主从 HA, Lua 脚本会丢失。官方也建议用户应当在本地保存一份 Lua 脚本,以保证其安全性。因此,Lua 脚本一直是 Redis 运维中令人头疼的问题之一。
Redis 7.0 中 Functions 的出现很好地对 Lua 脚本进行了补充。它允许用户向 Redis 加载自定义的函数库:一方面用户自定义的函数名可以有更为清晰的语义,另一方面,Functions 加载的函数库会被主从复制和持久化存储。
Functions 彻底解决了过去 Lua 脚本在持久化上含糊不清的问题,安全性也得到了大幅度提高,通过使用 Functions 即可完全避免 Lua 脚本丢失的问题了,极大减轻了运维压力。社区计划在后续版本中让 Functions 支持更多语言,比如 javascript 、Python等,相信 Redis Functions 在不久的将来将会彻底替代 Lua 脚本。
Client-eviction
运维 Redis 的时候一定会经历内存未被数据用尽,但 client 却发生了无法写入或数据被强制逐出的问题。
Redis 内存占用主要分为三个部分: data 是用户数据的内存占用, metadata 是元数据的内存占用,client buffer 是连接内存占用。在之前的版本中,可以通过 maxmemory 对 Redis 的内存上限进行控制,但数据占用内存和连接占用内存并不会被 maxmemory 区分。虽然 Redis 提供了参数,比如 clientoutput、buffer limit ,允许对每个连接的内存占用进行限制,但它并不能解决总连接内存的占用。
所以一旦 Redis 连接较多,再加上每个连接的内存占用都比较大的时候, Redis 总连接内存占用可能会达到 maxmemory 的上限,出现内存未被数据用尽却无法写入数据的情况,进而导致丢数据。为了解决上述问题,通常需要给 Redis 分配更多内存来避免连接内存太大而影响业务或数据。
Redis 7.0 新增了 client-invocation 参数,能够从全局的角度对 Redis 连接总内存占用进行控制。举个例子,如果连接总内存占用超过配置上限, Redis 会优先清理内存占用较大的连接,在一定程度上实现了对内存占用和数据内存占用的隔离控制,能够更好地管理 Redis 内存,节约内存使用成本,无须再预留过多内存。
Multi-part AOF
AOF 触发 Rewrite 的时候,Redis 会先将全量数据做内存快照,然后落盘。落盘的漫长过程中会产生增量数据,此时 Redis 会开辟一块新的内存空间来记录这些增量数据,这就带来了额外的内存开销。在极端情况下,AOF Rewrite 过程中的额外内存占用会与 Redis 的数据内存几乎相等,极易发生 OOM ,因此也被迫需要在操作系统中预留更多内存来避免 Redis OOM 的发生,这也是 Redis 内存资源浪费的重要原因。
此外,在 AOF Rewrite 的最后,Redis 会将全量数据与增量数据做一次合并操作,导致一份数据带来两次磁盘 IO。 同时在 AOF 的合并过程中,主进程和子进程之间的数据交互也会占用 CPU 资源。
所以在 AOF Rewrite 过程中,内存、IO、CPU 都会被占用,而这些都是额外的负担,非常影响业务。因此,通常需要将 AOF 自动 Rewrite 改在业务低峰期,通过脚本触发,甚至关闭 AOF。经历了多次迭代和开发,我们终于通过 Multi-part AOF 彻底解决了上述问题。
Multi-part AOF 是对 AOF 的彻底改造。它将 AOF 分为三个部分,分别是 base AOF、incr AOF 和 History AOF 。其中 base AOF 用来记录 AOF Rewrite 时刻的全量内存数据,incr AOF 用来记录 rewrite 过程中所有增量数据。incr AOF 的出现,使 Redis 不需要再开辟新的内存空间来记录增量数据。而多文件设计的理念也使得新版 AOF 无需做数据合并,因为它的全量和增量被放在不同文件中,天然隔离。在 AOF React 的最后,此前的历史 AOF 文件都会成为 history AOF 被直接删除,因而也不存在合并。在 Redis 7.0 中, AOF Rewrite 不再是运维中的洪水猛兽,对业务影响降至非常低。
另一个重要改进是 AOF 的增量数据带上了时间戳。在此之前,如果误操作造成数据损坏需要对数据进行恢复,通常需要先用最近一次的 RDB 全量备份做基础数据,然后用 AOF 文件做增量数据恢复。但由于 AOF 中的数据并没有时间戳,因此需要进行繁杂的人工分析。在没有时间信息的情况下,人工找到异常位置难度极大且容易出错。
而新版 AOF 中时间戳的加入可以大幅度减轻人工分析的复杂度。但是 AOF 的恢复仍然存在一些难题:
第一,如果没有及时发现问题,AOF 可能发生 Rewrite ,增量数会全部丢失,无法恢复增量数据。
第二, AOF 不支持按 KEY 恢复数据。如果想在极大的 AOF 文件中恢复个别 KEY,扫描大量无关数据会严重拉长恢复时间。
第三,虽然可以通过关闭 AOF Rewrite 来解决问题一,但也很可能因为 Redis 的大量写入造成 AOF 文件占用巨量磁盘空间。另外,本地备份还存在磁盘损坏,造成数据丢失的风险。
基于以上痛点,阿里云 Tair (即阿里云 Redis 企业版)实现了数据闪回功能。首先,它去掉了 AOF Rewrite ,确保增量数据能够永久保留。同时,它支持将增量数据流式备份至异地存储,解决了本地磁盘不够用以及本地磁盘损坏导致数据丢失的问题。此外,它能够支持 KEY 级恢复,通过过滤能力使数据恢复速度有了大幅提高,真正意义上实现了数据的闪回。
除此之外,阿里云 Tair 还有大量其他企业级能力,无论在性能、稳定性还是功能方面均强于 Redis 社区版。阿里云 Tair 内核团队会逐步将这些企业级能力下放至 Redis 社区版,实现对社区的回馈。
ACL v2
Redis 6.0 大版本中引入了 ACL v1,虽然能够实现一定程度的权限控制,但实用性并不强,比如无法支持粒度至 KEY 的权限访问控制,所有 KEY 的权限必须一致。而在 Redis 7.0 中, ACL v2 正式支持粒度至 KEY 的权限访问控制,可以轻松实现账户对不同 KEY 有不同的权限访问控制。基于 ACL v2,过去 Redis 常被诟病的业务权限难以维护管理的问题也将得到彻底解决。
四、Redis数据结构演进
Redis哈希桶保存键值数据
- redisDb 结构,表示 Redis 数据库的结构,结构体里存放了指向了 dict 结构的指针;
- dict 结构,结构体里存放了 2 个哈希表,正常情况下都是用「哈希表1」,「哈希表2」只有在 rehash 的时候才用,具体什么是 rehash,我在本文的哈希表数据结构会讲;
- ditctht 结构,表示哈希表的结构,结构里存放了哈希表数组,数组中的每个元素都是指向一个哈希表节点结构(dictEntry)的指针;
- dictEntry 结构,表示哈希表节点的结构,结构里存放了 **void * key 和 void * value 指针, key 指向的是 String 对象,而 value 则可以指向 String 对象,也可以指向集合类型的对象,比如 List 对象、Hash 对象、Set 对象和 Zset 对象。
void * key 和 void * value 指针指向的是 Redis 对象,Redis 中的每个对象都由 redisObject 结构表示,如下图:
对象结构里包含的成员变量:
- type,标识该对象是什么类型的对象(String 对象、 List 对象、Hash 对象、Set 对象和 Zset 对象);
- encoding,标识该对象使用了哪种底层的数据结构;
- ptr,指向底层数据结构的指针。
SDS
字符串在 Redis 中是很常用的,键值对中的键是字符串类型,值有时也是字符串类型。
Redis 是用 C 语言实现的,但是它没有直接使用 C 语言的 char* 字符数组来实现字符串,而是自己封装了一个名为简单动态字符串(simple dynamic string,SDS) 的数据结构来表示字符串,也就是 Redis 的 String 数据类型的底层数据结构是 SDS。
既然 Redis 设计了 SDS 结构来表示字符串,肯定是 C 语言的 char* 字符数组存在一些缺陷。
C 语言字符串用 “\0” 字符作为结尾标记有个缺陷。假设有个字符串中有个 “\0” 字符,这时在操作这个字符串时就会提早结束,比如 “xiao\0lin” 字符串,计算字符串长度的时候则会是 4
C 语言的字符串是不会记录自身的缓冲区大小的,所以 strcat 函数假定程序员在执行这个函数时,已经为 dest 分配了足够多的内存,可以容纳 src 字符串中的所有内容,而一旦这个假定不成立,就会发生缓冲区溢出将可能会造成程序运行终止,(这是一个可以改进的地方)。
而且,strcat 函数和 strlen 函数类似,时间复杂度也很高,也都需要先通过遍历字符串才能得到目标字符串的末尾。然后对于 strcat 函数来说,还要再遍历源字符串才能完成追加,对字符串的操作效率不高。
Redis5.0的SDS结构
- len,记录了字符串长度。这样获取字符串长度的时候,只需要返回这个成员变量值就行,时间复杂度只需要 O(1)。
- alloc,分配给字符数组的空间长度。这样在修改字符串的时候,可以通过 alloc - len 计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出的问题。
- flags,用来表示不同类型的 SDS。一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,后面在说明区别之处。
- buf[],字符数组,用来保存实际数据。不仅可以保存字符串,也可以保存二进制数据。
Redis 的 SDS 结构在原本字符数组之上,增加了三个元数据:len、alloc、flags,用来解决 C 语言字符串的缺陷。
二进制安全
因为 SDS 不需要用 “\0” 字符来标识字符串结尾了,而是有个专门的 len 成员变量来记录长度,所以可存储包含 “\0” 的数据。但是 SDS 为了兼容部分 C 语言标准库的函数, SDS 字符串结尾还是会加上 “\0” 字符。
不会发生缓冲区溢出
当判断出缓冲区大小不够用时,Redis 会自动将扩大 SDS 的空间大小(小于 1MB 翻倍扩容,大于 1MB 按 1MB 扩容),以满足修改所需的大小。
在扩展 SDS 空间之前,SDS API 会优先检查未使用空间是否足够,如果不够的话,API 不仅会为 SDS 分配修改所必须要的空间,还会给 SDS 分配额外的「未使用空间」。
这样的好处是,下次在操作 SDS 时,如果 SDS 空间够的话,API 就会直接使用「未使用空间」,而无须执行内存分配,有效的减少内存分配次数。
所以,使用 SDS 即不需要手动修改 SDS 的空间大小,也不会出现缓冲区溢出的问题。
节省内存空间
Redos 一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。
这 5 种类型的主要区别就在于,它们数据结构中的 len 和 alloc 成员变量的数据类型不同。
之所以 SDS 设计不同类型的结构体,是为了能灵活保存不同大小的字符串,从而有效节省内存空间。比如,在保存小字符串时,结构头占用空间也比较少。
除了设计不同类型的结构体,Redis 在编程上还使用了专门的编译优化来节省内存空间,即在 struct 声明了 __attribute__ ((packed)) ,它的作用是:告诉编译器取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐。
链表结构
typedef struct listNode {
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
void *value;
} listNode;
typedef struct list {
//链表头节点
listNode *head;
//链表尾节点
listNode *tail;
//节点值复制函数
void *(*dup)(void *ptr);
//节点值释放函数
void (*free)(void *ptr);
//节点值比较函数
int (*match)(void *ptr, void *key);
//链表节点数量
unsigned long len;
} list;
压缩列表
压缩列表的最大特点,就是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销。
但是,压缩列表的缺陷也是有的:
- 不能保存过多的元素,否则查询效率就会降低;
- 新增或修改某个元素时,压缩列表占用的内存空间需要重新分配,甚至可能引发连锁更新的问题。
因此,Redis 对象(List 对象、Hash 对象、Zset 对象)包含的元素数量较少,或者元素值不大的情况才会使用压缩列表作为底层数据结构。
Rehash
这个过程看起来简单,但是其实第二步很有问题,如果「哈希表 1 」的数据量非常大,那么在迁移至「哈希表 2 」的时候,因为会涉及大量的数据拷贝,此时可能会对 Redis 造成阻塞,无法服务其他请求。
跳表
这个查找过程就是在多个层级上跳来跳去,最后定位到元素。当数据量很大时,跳表的查找复杂度就是 O(logN)。
quicklist
在 Redis 3.0 之前,List 对象的底层数据结构是双向链表或者压缩列表。然后在 Redis 3.2 的时候,List 对象的底层改由 quicklist 数据结构实现。
其实 quicklist 就是「双向链表 + 压缩列表」组合,因为一个 quicklist 就是一个链表,而链表中的每个元素又是一个压缩列表。
在前面讲压缩列表的时候,我也提到了压缩列表的不足,虽然压缩列表是通过紧凑型的内存布局节省了内存开销,但是因为它的结构设计,如果保存的元素数量增加,或者元素
变大了,压缩列表会有「连锁更新」的风险,一旦发生,会造成性能下降。
quicklist 解决办法,通过控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。因为压缩列表元素越少或越小,连锁更新带来的影响就越小,从而提供了更好的访问性能。
quicklistNode 结构体里包含了前一个节点和下一个节点指针,这样每个 quicklistNode 形成了一个双向链表。但是链表节点的元素不再是单纯保存元素值,而是保存了一个压缩列表,所以 quicklistNode 结构体里有个指向压缩列表的指针 *zl。
在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 quicklistNode 结构。
quicklist 会控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来规避潜在的连锁更新的风险,但是这并没有完全解决连锁更新的问题。
listpack
quicklist 虽然通过控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来减少连锁更新带来的性能影响,但是并没有完全解决连锁更新的问题。
因为 quicklistNode 还是用了压缩列表来保存元素,压缩列表连锁更新的问题,来源于它的结构设计,所以要想彻底解决这个问题,需要设计一个新的数据结构。
于是,Redis 在 5.0 新设计一个数据结构叫 listpack,目的是替代压缩列表,它最大特点是 listpack 中每个节点不再包含前一个节点的长度了,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患。
主要包含三个方面内容:
- encoding,定义该元素的编码类型,会对不同长度的整数和字符串进行编码;
- data,实际存放的数据;
- len,encoding+data的总长度;
可以看到,listpack 没有压缩列表中记录前一个节点长度的字段了,listpack 只记录当前节点的长度,当我们向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题。