Redis架构演进之路
2023-09-17 18:13:02 阿炯

如今 Redis 变得越来越流行,几乎在很多项目中都要被用到,如何才能让它稳定且高性能地提供服务的?
使用 Redis 的场景很简单,只使用单机版会有什么问题吗?
Redis 服务故障宕机了,数据丢失了怎么办,如何能保证我的业务应用不受影响?
为什么需要主从集群,它有什么优势?
什么是分片集群,我真的需要分片集群吗?

如果你对 Redis 已经有些了解,肯定也听说过「数据持久化、主从复制、哨兵、分片集群」这些概念,它们之间又有什么区别和联系呢?

如果存在这样的疑惑,文章会从 0 到 1,再从 1 到 N,带你一步步构建出一个稳定、高性能的 Redis 集群。在这个过程中可以了解到 Redis 为了做到稳定、高性能,都采取了哪些优化方案,以及为什么要这么做?掌握了这些原理,这样平时在使用 Redis 时,就能够做到「游刃有余」。

一、从最简单的开始:单机版 Redis

首先从最简单的场景开始。假设现在你有一个业务应用,需要引入 Redis 来提高应用的性能,此时你可以选择部署一个单机版的 Redis 来使用,就像这样:


这个架构非常简单,业务应用可以把 Redis 当做缓存来使用,从 MySQL 中查询数据,然后写入到 Redis 中,之后业务应用再从 Redis 中读取这些数据,由于 Redis 的数据都存储在内存中,所以这个速度飞快。如果业务体量并不大,那这样的架构模型基本可以满足你的需求。是不是很简单?

随着时间的推移,业务体量逐渐发展起来了,Redis 中存储的数据也越来越多,此时业务应用对 Redis 的依赖也越来越重。突然有一天,Redis 因为某些原因宕机了,这时所有业务流量,都会打到后端 MySQL 上,其压力剧增,严重的话甚至会压垮 MySQL。


这时应该怎么办?

方案肯定是赶紧重启 Redis,让它可以继续提供服务。但因为之前 Redis 中的数据都在内存中,尽管现在把 Redis 重启了,之前的数据也都丢失了(假设没开持久化)。重启后的 Redis 虽然可以正常工作,但是由于 Redis 中没有任何数据,业务流量还是都会打到后端 MySQL 上,它的压力还是很大。

有没有什么好的办法解决这个问题?

既然 Redis 只把数据存储在内存中,那是否可以把这些数据也写一份到磁盘上呢?如果采用这种方式,当 Redis 重启时,把磁盘中的数据快速「恢复」到内存中,这样它就可以继续正常提供服务了。这是一个很好的解决方案,这个把内存数据写到磁盘上的过程,就是「数据持久化」。

二、数据持久化:有备无患

现在设想的 Redis 数据持久化是这样的:


但数据持久化具体应该怎么做呢?最容易想到的一个方案是,Redis 每一次执行写操作,除了写内存之外,同时也写一份到磁盘上,就像这样:


没错,这是最简单直接的方案。但仔细想一下这个方案有个问题:客户端的每次写操作,既需要写内存,又需要写磁盘,而写磁盘的耗时相比于写内存来说,肯定要慢很多!这势必会影响到 Redis 的性能。如何规避这个问题?

这时需要分析写磁盘的细节问题了。都知道要把内存数据写到磁盘,其实是分 2 步的:
程序写文件的 PageCache(write)
把 PageCache 刷到磁盘(fsync)

具体就是下图这样:


数据持久化最粗暴的思路就是上面提到的那样,写完 Redis 内存后,同步写 PageCache + fsync 磁盘,当然这样肯定因为磁盘拖慢整个写入速度。如何优化?也很简单,可以这样做:Redis 写内存由主线程来做,写内存完成后就给客户端返回结果,然后 Redis 用「另一个线程」去写磁盘,这样就可以避免主线程写磁盘对性能的影响。这种持久化方案,其实就是经常听到的 Redis AOF(Append Only File)。


Redis AOF 持久化提供了 3 种刷盘机制:
appendfsync always:主线程同步 fsync
appendfsync no:由 OS fsync
appendfsync everysec:后台线程每间隔1秒 fsync

解决了数据实时持久化后还会面临另一个问题,数据实时写入 AOF,随着时间的推移,AOF 文件会越来越大,那使用 AOF 恢复时变得非常慢,这该怎么办?Redis 很贴心地提供了 AOF rewrite 方案,俗称 AOF 「瘦身」,顾名思义,就是压缩 AOF 的体积。因为 AOF 里记录的都是每一次写命令,例如执行 set k1 v1,set k1 v2,其实我们只关心数据的最终版本 v2 就可以了。AOF rewrite 正是利用了这个特点,在 AOF 体积越来越大时(超过设定阈值),Redis 就会定期重写一份新的 AOF,这个新的 AOF 只记录数据的最终版本就可以了。


这样就可以压缩 AOF 体积。除此之外,思考一下还有什么方式可以持久化数据?

这时就要结合 Redis 的使用场景来考虑了。在使用 Redis 时,通常把它用作什么场景?
是的,缓存。

把 Redis 当做缓存来用,意味着尽管其中没有保存全量数据,对于不在缓存中的数据,业务应用依旧可以通过查询后端数据库得到结果,只不过查询后端数据的速度会慢一点而已,但对业务结果其实是没有影响的。基于这个特点,Redis 数据持久化还可以用「数据快照」的方式来做。

那什么是数据快照呢?

简单来讲可以这么理解:
把 Redis 想象成一个水杯,向 Redis 写入数据,就相当于往这个杯子里倒水。
此时拿一个相机给这个水杯拍一张照片,拍照的这一瞬间,照片中记录到这个水杯中水的容量,就是水杯的数据快照。


也就是说,Redis 的数据快照,是记录某一时刻下 Redis 中的数据,然后只需要把这个数据快照写到磁盘上就可以了。其优势在于,只在需要持久化时,把数据「一次性」写入磁盘,其它时间都不需要操作磁盘。基于这个方案,我们可以「定时」给 Redis 做数据快照,把数据持久化到磁盘上。这种方案就是我们经常听到的 Redis RDB,RDB 采用「定时快照」的方式进行数据持久化,它的优点是:


持久化文件体积小(二进制 + 压缩)
写盘频率低(定时写入)

缺点也很明显,因为是定时持久化,数据肯定没有 AOF 实时持久化完整,如果 Redis 只当做缓存,对于丢失数据不敏感(可从后端的数据库查询),那这种持久化方式是非常合适的。如果来选择持久化方案可以这样选择:
业务对于数据丢失不敏感,选 RDB
业务对数据完整性要求比较高,选 AOF

理解了 RDB 和 AOF,再进一步思考一下有没有什么办法,既可以保证数据完整性,还能让持久化文件体积更小,恢复更快呢?回顾一下前面讲到的 RDB 和 AOF 各自的特点:
RDB 以二进制 + 数据压缩方式存储,文件体积小
AOF 记录每一次写命令,数据最全

可否利用它们各自的优势呢?
当然可以,这就是 Redis 的「混合持久化」。要想数据完整性更高,肯定就不能只用 RDB 了,重点还是要放在 AOF 优化上。

具体来说,当 AOF 在做 rewrite 时,Redis 先以 RDB 格式在 AOF 文件中写入一个数据快照,再把在这期间产生的每一个写命令,追加到 AOF 文件中。因为 RDB 是二进制压缩写入的,这样 AOF 文件体积就变得更小了。


因为 AOF 体积进一步压缩,你在使用 AOF 恢复数据时,这个恢复时间就会更短了!Redis 4.0 以上版本才支持混合持久化。

注意:混合持久化是对 AOF rewrite 的优化,这意味着使用它必须基于 AOF + AOF rewrite。

这么一番优化,你的 Redis 再也不用担心实例宕机了,当发生宕机时,你就可以用持久化文件快速恢复 Redis 中的数据。但这样就没问题了吗?

虽然已经把持久化的文件优化到最小了,但在恢复数据时依旧是需要时间的,在这期间你的业务应用无法提供服务,这怎么办?

一个实例宕机,只能用恢复数据来解决,那我们是否可以部署多个 Redis 实例,然后让这些实例数据保持实时同步,这样当一个实例宕机时,我们在剩下的实例中选择一个继续提供服务就好了。没错,这个方案就是接下来要讲的「主从复制:多副本」。

三、主从复制:多副本

可以部署多个 Redis 实例,架构模型就变成了这样:


这里把实时读写的节点叫做 master,另一个实时同步数据的节点叫做 slave。采用多副本的方案的优势是:
缩短不可用时间:master 发生宕机,我们可以手动把 slave 提升为 master 继续提供服务
提升读性能:让 slave 分担一部分读请求,提升应用的整体性能

这个方案不错,不仅节省了数据恢复的时间,还能提升性能。但它的问题在于:当 master 宕机时需要「手动」把 slave 提升为 master,这个过程也是需要花费时间的。虽然比恢复数据要快得多,但还是需要人工介入处理。一旦需要人工介入,就必须要算上人的反应时间、操作时间,所以在这期间你的业务应用依旧会受到影响。是否可以把这个切换的过程,变成自动化?

四、哨兵:故障自动切换

要想自动切换,肯定不能依赖人了。现在可以引入一个「观察者」,让这个观察者去实时监测 master 的健康状态,这个观察者就是「哨兵」。具体如何做?
哨兵每间隔一段时间,询问 master 是否正常
master 正常回复,表示状态正常,回复超时表示异常
哨兵发现异常,发起主从切换

此方案就不需要人去介入处理了,一切就变得自动化了。


但这里还有一个问题,如果 master 状态正常,但这个哨兵在询问 master 时,它们之间的网络发生了问题,那这个哨兵可能会「误判」。


这个问题怎么解决?

既然一个哨兵会误判,那我们可以部署多个哨兵,让它们分布在不同的机器上,让它们一起监测 master 的状态,流程就变成了这样:


多个哨兵每间隔一段时间,询问 master 是否正常
master 正常回复,表示状态正常,回复超时表示异常
一旦有一个哨兵判定 master 异常(不管是否是网络问题),就询问其它哨兵,如果多个哨兵(设置一个阈值)都认为 master 异常了,这才判定 master 确实发生了故障
多个哨兵经过协商后,判定 master 故障,则发起主从切换

所以用多个哨兵互相协商来判定 master 的状态,这样就可以大大降低误判的概率。哨兵协商判定 master 异常后,这里还有一个问题:由哪个哨兵来发起主从切换呢?

答案是,选出一个哨兵「领导者」,由这个领导者进行主从切换。问题又来了,这个领导者怎么选?在现实生活中,选举是怎么做的?
是的,投票。

在选举哨兵领导者时,我们可以制定这样一个选举规则:
每个哨兵都询问其它哨兵,请求对方为自己投票
每个哨兵只投票给第一个请求投票的哨兵,且只能投票一次
首先拿到超过半数投票的哨兵,当选为领导者,发起主从切换

这个选举的过程就是我们经常听到的:分布式系统领域中的「共识算法」。

什么是共识算法?
在多个机器部署哨兵,它们需要共同协作完成一项任务,所以它们就组成了一个「分布式系统」。在分布式系统领域,多个节点如何就一个问题达成共识的算法,就叫共识算法。在这个场景下,多个哨兵共同协商,选举出一个都认可的领导者,就是使用共识算法完成的。这个算法还规定节点的数量必须是奇数个,这样可以保证系统中即使有节点发生了故障,剩余超过「半数」的节点状态正常,依旧可以提供正确的结果,也就是说,这个算法还兼容了存在故障节点的情况。

共识算法在分布式系统领域有很多,例如 Paxos、Raft,哨兵选举领导者这个场景,使用的是 Raft 共识算法,因为它足够简单,且易于实现。

好,到此先小结一下。

Redis 从最简单的单机版,经过数据持久化、主从多副本、哨兵集群,这一路优化下来,你的 Redis 不管是性能还是稳定性,都越来越高,就算节点发生故障,也不用担心了。以这样的架构模式部署,基本上就可以稳定运行很长时间了。随着时间的发展,业务体量开始迎来了爆炸性增长,此时你的架构模型,还能够承担这么大的流量吗?一起来分析一下:
数据怕丢失:持久化(RDB/AOF)
恢复时间久:主从副本(副本随时可切)
手动切换时间长:哨兵集群(自动切换)
读存在压力:扩容副本(读写分离)
写存在压力:一个 mater 扛不住怎么办?

可见,现在剩下的问题是,当写请求量越来越大时,一个 master 实例可能就无法承担这么大的写流量了。要想完美解决这个问题,此时你就需要考虑使用「分片集群」了。

五、分片集群:横向扩展

什么是「分片集群」?
简单来讲,一个实例扛不住写压力,那我们是否可以部署多个实例,然后把这些实例按照一定规则组织起来,把它们当成一个整体,对外提供服务,这样不就可以解决集中写一个实例的瓶颈问题吗?所以现在的架构模型就变成了这样:


现在问题又来了,这么多实例如何组织呢?制定规则如下:
每个节点各自存储一部分数据,所有节点数据之和才是全量数据
制定一个路由规则,对于不同的 key,把它路由到固定一个实例上进行读写

数据分多个实例存储,那寻找 key 的路由规则需要放在客户端来做,具体就是下面这样:


这种方案也叫做「客户端分片」,这个方案的缺点是,客户端需要维护这个路由规则,也就是需要把路由规则写到你的业务代码中。如何做到不把路由规则耦合在客户端业务代码中呢?

继续优化,可以在客户端和服务端之间增加一个「中间代理层」,这个代理就是我们经常听到的 Proxy,路由转发规则,放在这个 Proxy 层来维护。这样客户端就无需关心服务端有多少个 Redis 节点了,只需要和这个 Proxy 交互即可。Proxy 会把你的请求根据路由规则,转发到对应的 Redis 节点上,而且,当集群实例不足以支撑更大的流量请求时,还可以横向扩容,添加新的 Redis 实例提升性能,这一切对于客户端来说都是透明无感知的。


业界开源的 Redis 分片集群方案,例如 Twemproxy、Codis 就是采用的这种方案。这种方案的优点在于,客户端无需关心数据转发规则,只需要和 Proxy 打交道,客户端像操作单机 Redis 那样去操作后面的集群,简单易用。

架构演进到目前为止,路由规则无论是客户端来做,还是 Proxy 来做,都是「社区」演进出来的分片解决方案,它们的特点是集群中的 Redis 节点,都不知道对方的存在,只有客户端或 Proxy 才会统筹数据写到哪里,从哪里读取,而且它们都依赖哨兵集群负责故障自动切换。也就是说其实就是把多个孤立的 Redis 节点,自己组合起来使用。

Redis 在 3.0 其实就推出了「官方」的 Redis Cluster 分片方案,但由于推出初期不稳定,所以用的人很少,也因此业界涌现出了各种开源方案,上面讲到的 Twemproxy、Codis 分片方案就是在这种背景下诞生的。但随着 Redis Cluster 方案的逐渐成熟,业界越来越多的公司开始采用官方方案(毕竟官方保证持续维护,Twemproxy、Codis 目前都逐渐放弃维护了),Redis Cluster 方案比上面讲到的分片方案更简单,它的架构如下。


Redis Cluster 无需部署哨兵集群,集群内 Redis 节点通过 Gossip 协议互相探测健康状态,在故障时可发起自动切换。另外关于路由转发规则,也不需要客户端自己编写了,Redis Cluster 提供了「配套」的 SDK,只要客户端升级 SDK,就可以和 Redis Cluster 集成,SDK 会帮你找到 key 对应的 Redis 节点进行读写,还能自动适配 Redis 节点的增加和删除,业务侧无感知。

虽然省去了哨兵集群的部署,维护成本降低了不少,但对于客户端升级 SDK,对于新业务应用来说,可能成本不高,但对于老业务来讲,「升级成本」还是比较高的,这对于切换官方 Redis Cluster 方案有不少阻力。于是各个公司有开始自研针对 Redis Cluster 的 Proxy,降低客户端的升级成本,架构就变成了这样:


这样客户端无需做任何变更,只需把连接地址切到 Proxy 上即可,由 Proxy 负责转发数据,以及应对后面集群增删节点带来的路由变更。至此,业界主流的 Redis 分片架构已经成型,当你使用分片集群后,对于未来更大的流量压力,也都可以从容面对了。

小结

上面小结了如何从 0 到 1,再从 1 到 N 构建一个稳定、高性能的 Redis 集群的,从中可以清晰地看到 Redis 架构演进的整个过程:
数据怕丢失:持久化(RDB/AOF)
恢复时间久:主从副本(副本随时可切)
故障手动切换慢:哨兵集群(自动切换)
读存在压力:扩容副本(读写分离)
写存在压力/容量瓶颈:分片集群
分片集群社区方案:Twemproxy、Codis(Redis 节点之间无通信,需要部署哨兵,可横向扩容)
分片集群官方方案:Redis Cluster(Redis 节点之间 Gossip 协议,无需部署哨兵,可横向扩容)
业务侧升级困难:Proxy + Redis Cluster(不侵入业务侧)

至此 Redis 集群才得以长期稳定、高性能的为业务提供服务。希望本文可以帮助更好的理解 Redis 架构的演进之路。
作者:Kaito,来源于公众号:水滴与银弹(ID:waterdrop_bullet)

六、线程模型

1.v6.0之前的版本真的是单线程吗

2.v6.0之前为什么一直不使用多线程

3.v6.0为什么要引入多线程呢

4.v6.0为什么不用协程

5.Redis为什么这么快

redis-v6.0之前主线程模型


而下图是redis6.0的主线程+IO线程模型


准备基本知识


面对高性能计算、大数据分析等IO高并发、低时延应用,现有TCP/IP软硬件架构不能满足应用的需求,这主要体现在传统的TCP/IP网络通信是通过内核发送消息,这种通信方式存在很高的数据移动和数据复制的开销。RDMA(RemoteDirect Memory Access)技术全称远程直接内存访问





五种IO模型、阻塞IO和非阻塞IO、同步IO和异步IO(减少与内核交换 同步2次 异步是1次)    







1.epoll 在文件描述符可进行 I/O 操作时进行通知,而 kqueue 和 IOCP 都在请求的操作完成时进行通知。前者是同步io 后者异步io

2.kqueue 是一种可扩展的事件通知接口。2000 年 7 月发布的 FreeBSD 4.1 中首次引入了 kqueue,随后也被 NetBSD、OpenBSD、macOS 等操作系统支持。

queue 可以监控文件描述符事件,以及其他如文件修改、信号、异步 I/O 事件、子进程状态更改、定时器等多种类型的事件。

Redis是一个纯内存的Key-Value型数据库,其并发模型的讨论非常有趣,涵盖了多个方面,包括系统设计、性能优化以及如何有效利用硬件资源。具体分析如下:

1. Redis v6.0 之前版本是单线程
Redis 是一个高性能的键值数据库,其设计目标是充分利用 CPU 和 I/O 资源。v6.0 之前它主要采用单线程模型,主要原因是:

·无锁化:单线程模型避免了并发访问共享资源时的锁机制,这样可以减少线程切换和锁竞争带来的开销。

·避免时序问题:单线程模型简化了数据安全和一致性的维护,因为不存在并发修改数据的情况,使得设计更为简单和高效。    

Redis 使用单线程处理命令请求,这样可以确保操作的原子性和一致性,而不需要担心多线程编程中复杂的同步和锁问题。

2. Redis 不是典型的并发数据库
即使在 Redis 引入了某些形式的多线程处理(如在 6.0 版本之后引入的一些多线程特性,主要用于处理 I/O 操作),它在核心上依然保持了单线程处理请求的模型。原因包括:

·设计哲学:Redis 的设计哲学侧重于简单、快速,使用单线程模型能够保证高性能和低延迟。

·内存模型:Redis 是基于内存的存储,这意味着数据访问非常快,多线程带来的并发性能提升较为有限。

3. 引入多线程加速网络 I/O
v6.0 版本引入多线程主要用于优化网络 I/O 操作,而非处理客户端的命令请求。这种设计允许 Redis 利用多核 CPU 加速网络请求的接收和响应过程,同时保持命令处理的单线程模型。这样既能提高 I/O 效率,又能维持数据处理的简洁性和效率。

4. 协程无法解决 CPU 性能瓶颈
协程是一种用户态的轻量级线程,它能在单线程内实现多任务的并发执行,优势在于低成本的任务切换和高效的同步处理。但确实,仅仅使用协程并不能增加整体的 CPU 处理能力,它更多地是解决 I/O 等待问题,而非计算瓶颈。如果要利用多核 CPU 的并发能力,无论是线程还是支持多核调度的协程,本质上都是要依赖于操作系统内核的调度,并发运行在不同核上。

当协程设计得能够支持多核并发时,其背后通常依赖的是线程池或其他机制来实现实际的并发执行。这种情况下,协程的作用更像是一个调度和优化的工具,用于提高程序在多核上的执行效率和可管理性。

Redis 的设计和开发反映了对效率和简洁性的追求。引入多线程处理网络 I/O 操作,是利用现代多核 CPU 加速网络处理的智慧选择。而对于协程而言,它们在 I/O 密集型任务中非常有用,但它们并不直接增加计算资源,而是优化了资源使用和任务调度。

1.Redis6.0之前版本是单线程,核心因素就是无锁化和避免时序问题。

2.Redis不是典型的并发数据库,多线程并不能带来实际意义上性能提升。

3.引入多线程,可以利用多核机器的并发能力加速网络IO,报文处理和内核协议栈也会消耗大量CPU。

4.协程无法解决CPU性能瓶颈,如果协程支持多核并发调度,与线程也无异。


Redis Stream 数据结构实现原理

使用 List 实现消息队列有很多局限性。
没有 ACK 机制与消息堆积。
没有类似 Kafka 的 ConsumerGroup 消费组概念。
List 是线性结构,查询指定数据需要遍历整个列表。

1. 是什么

Stream 是 Redis 5.0 版本专门为消息队列设计的数据类型,借鉴了 Kafka 的 Consume Group 设计思路,提供了消费组概念。同时提供了消息的持久化和主从复制机制,客户端可以访问任何时刻的数据,并且能记住每一个客户端的访问位置,从而保证消息不丢失。以下几个是 Stream 类型的主要特性。
使用Radix Tree 和 listpack结构来存储消息。
消息 ID 序列化生成。
借鉴 Kafka Consume Group 的概念,多个消费者划分到不同的 Consume Group 中,消费同一个 Streams,同一个 Consume Group 的多个消费者可以一起并行但不重复消费,提升消费能力。
支持多播(多对多),阻塞和非阻塞读取。
ACK 确认机制,保证了消息至少被消费一次。
可设置消息保存上限阈值,我会把历史消息丢弃,防止内存占用过大。

需要注意的是,Redis Stream 是一种超轻量级的 MQ,并没有完全实现消息队列的所有设计要点,所以它的使用场景需要考虑业务的数据量和对性能、可靠性的需求。适合系统消息量不大,容忍数据丢失,使用 Redis Stream 作为消息队列就能享受高性能快速读写消息的优势。

2. 修炼心法

每个 Stream 都有一个唯一的名称,作为 Stream 在 Redis 的 key,在首次使用xadd指令添加消息的时候会自动创建。可以看到 Stream 在一个 Redix Tree 树上,树上存储的是消息 ID,每个消息 ID 对应的消息通过一个指针指向 listpack。Stream 流就像是一个仅追加内容的消息链表,把消息一个个串起来,每个消息都有一个唯一的 ID 和消息内容,消息内容则由多个 field/value 键值对组成。底层使用 Radix Tree 和 listpack 数据结构存储数据。

为了便于理解,参考下图,并对 Radix Tree 的存储数据做了下变形,使用列表来体现 Stream 中消息的逻辑有序性。


这张图涉及很多概念,会一步步拆开说,最后再回头看就懂了,先清理一下思路:
Consumer Group:消费组,每个消费组可以有一个或者多个消费者,消费者之间是竞争关系。不同消费组的消费者之间无任何关系。
*pel,全称是 Pending Entries List,记录了当前被客户端读取但是还没有 ack(Acknowledge character 确认字符)的消息。如果客户端没有 ack,这个变量的消息 ID 会越来越多。这是一个核心数据结构,用来确保客户端至少消费消息一次。

Stream 结构

Streams结构的源码定义在stream.h源码中的stream结构体中。
typedef struct stream {
rax *rax;
uint64_t length;
streamID last_id;
streamID first_id;
streamID max_deleted_entry_id;
uint64_t entries_added;
rax *cgroups;
} stream;

typedef struct streamID {
uint64_t ms;
uint64_t seq;
} streamID;

*rax,是一个rax的指针,指向一个 Radix Tree,key 存储消息 ID,value实际上指向一个 listpack 数据结构,存储了多条消息,每条消息的 ID 都大于等于 这个 key 的消息 ID。
length,该 Stream 的消息条数。
streamID结构体,消息 ID 抽象,一共占 128 位,内部维护了毫秒时间戳(字段 ms);一个毫秒内的自增序号(字段 seq),用于区分同一毫秒内插入多条消息。
last_id,当前 Stream 最后一条消息的 ID。
first_id,当前 Stream 第一条消息的 ID。
max_deleted_entry_id,当前 Stream 被删除的最大的消息 ID。
entries_added,总共有多少条消息添加到 Stream 中,entries_added = 已删除消息条数 + 未删除消息条数。
*cgroups,rax 指针,也指向一个 Radix Tree ,记录当前 Stream 的所有 Consume Group,每个 Consume Group 的名称都是唯一标识,作为 Radix Tree 的 key,Consumer Group 实例作为 value。

Consumer Group

Consumer Group 由streamCG结构体定义,每个 Stream 可以有多个 Consumer Group,一个消费组可以有多个消费者同时对组内消息进行消费。
/* Consumer group. */
typedef struct streamCG {
streamID last_id;
long long entries_read;
rax *pel;
rax *consumers;
} streamCG;

last_id,表示该消费组的消费者已经读取但还未 ACK 的最后一条消息 ID。
*pel,是pending entries list简写,指向一个 Radix Tree 的指针,保存着 Consumer group 中所有消费者读取但还未 ACK 确认的消息,就是这玩意实现了 ACK 机制。该树的 key 是消息 ID,value 关联一个streamNACK实例。
*consumers, Radix Tree 指针,表示消费组中的所有消费者,key 是消费者名称,value 指向一个streamConsumer实例。

streamNACK

streamCG -> *pel对应的 value 是一个 streamNACK 实例,用于抽象消费者已经读取,但是未 ACK 的消息 ID 相关信息。

/* Pending (yet not acknowledged) message in a consumer group. */
typedef struct streamNACK {
mstime_t delivery_time;
uint64_t delivery_count;
streamConsumer *consumer;
} streamNACK;

delivery_time,该消息最后一次推送给 Consumer 的时间戳。
delivery_count,消息被推送次数。
*consumer,消息推送的 Consumer 客户端。

streamConsumer

Consumer Group 中对 Consumer 的抽象。

/* A specific consumer in a consumer group. */
typedef struct streamConsumer {
mstime_t seen_time;
sds name;
rax *pel;
} streamConsumer;

seen_time,消费者最近一次被激活的时间戳。
name,消费者名称。
*pel, Radix Tree 指针,对于同一个消息而言,`streamCG -> pel与streamConsumer -> pel的streamNACK` 实例是同一个。

再来一张图便于理解。


Redis Stream 如何结合 Radix Tree 和 listpack 结构来存储消息?为什么不使用散列表来存储,消息 ID 作为散列表的 key,散列表的 value 存储消息键值对内容。

在回答之前,先插入几条消息到 Stream,对 Stream 消息的存储格式有个大体认知。该命令的语法如下。
XADD key id field value [field value ...]

Stream 中的每个消息可以包含不同数量的多个键值对,写入消息成功后,我会把消息的 ID 返回给客户端。

执行如下指令把用户购买书籍的下单消息存放到hotlist:books队列,消息内容主要由 payerID、amount 和 orderID。
> XADD hotlist:books * payerID 1 amount 69.00 orderID 9
1679218539571-0
> XADD hotlist:books * payerID 1 amount 36.00 orderID 15
1679218572182-0
> XADD hotlist:books * payerID 2 amount 99.00 orderID 88
1679218588426-0
> XADD hotlist:books * payerID 3 amount 68.00 orderID 80
1679218604492-0

hotlist:books是 Stream 的名称,后面的 “*” 表示让 Redis 为插入的消息自动生成一个唯一 ID,你也可以自定义。

消息 ID 由两部分组成:
当前毫秒内的时间戳;
顺序编号。从 0 为起始值,用于区分同一时间内产生的多个命令。

如何理解 Stream 是一种只执行追加操作(append only)的数据结构?

通过将元素 ID 与时间进行关联,并强制要求新元素的 ID 必须大于旧元素的 ID,Redis 从逻辑上将 Stream 变成了一种只执行追加操作(append only)的数据结构。用户可以确信,新的消息和事件只会出现在已有消息和事件之后,就像现实世界里新事件总是发生在已有事件之后一样,一切都是有序进行的。

插入的消息 ID 大部分相同,比如这四条消息的 ID 都是 1679218 前缀。另外每条消息键值对的键通常都是一样的,比如这四条消息的键都是 payerID、amount 和 orderID。使用散列表存储的话会很多冗余数据,你这么抠门,所以不使用散列表对不对?

为了节省内存,使用了 Radix Tree 和 listpack。Radix Tree 的 key 存储消息 ID,value 使用 listpack 数据结构存储多个消息,listapck 中的消息 ID 都大于等于 key 存储的消息 ID。在前面已经讲过 listpack,这是一个紧凑型列表,非常节省内存。而 Radix Tree 数据结构的最大特点是适合保存具有相同前缀的数据,从而达到节省内存。到底 Radix Tree 是怎样的数据结构,继续往下看。

Radix Tree

Radix Tree也被称为 Radix Trie,或者 Compact Prefix Tree),用于高效地存储和查找字符串集合。它将字符串按照前缀拆分成一个个字符,并将每个字符作为一个节点存储在树中。当插入一个键值对时,Redis 会将键按照字符拆分成一个个字符,并根据字符在 Radix tree 中的位置找到合适的节点,如果该节点不存在,则创建新节点并添加到 Radix tree 中。

当所有字符都添加完毕后,将值对象指针保存到最后一个节点中。当查询一个键时,Redis 按照字符顺序遍历 Radix tree,如果发现某个字符不存在于树中,则键不存在;否则,如果最后一个节点表示一个完整的键,则返回对应的值对象。

Radix Tree 改进

每个节点只保存一个字符,一是会浪费内存空间,二是在进行查询时,还需要逐一匹配每个节点表示的字符,对查询性能也会造成影响。所以 Redis 并没有直接使用标准前缀树,而是做了一次变种——Compact Prefix Tree(压缩前缀树)。通俗来说,当多个 key 具有相同的前缀时,那就将相同前缀的字符串合并在一个共享节点中,从而减少存储空间。如下几个 key(test、toaster、toasting、slow、slowly)在 Radix Tree 上的布局。


由于 Compact Prefix Tree 可以共享相同前缀的节点,所以在存储一组具有相同前缀的键时,Redis 的 Radix tree 比其他数据结构(如哈希表)具有更低的空间消耗和更快的查询速度。Radix Tree 节点的数据结构由rax.h文件中的raxNode定义:
typedef struct raxNode {
uint32_t iskey:1;
uint32_t isnull:1;
uint32_t iscompr:1;
uint32_t size:29;
unsigned char data[];
} raxNode;

iskey:从 Radix Tree 根节点到当前节点组成的字符串是否是一个完整的 key。是的话 iskey 的值为 1。
isnull:当前节点是否为空节点,如果当前节点是空节点的话,就不需要为该节点分配指向 value 的指针内存。
iscompr,是否为压缩节点。
size,当前节点的大小,具体指会根据节点类型而改变。如果是压缩节点,该值表示压缩数据的长度;如果是非压缩节点,该值表示节点的子节点个数。
data[],实际存储的数据,根据节点类型不同而有所不同。
    压缩节点,data 数据包括子节点对应的字符、指向子节点的指针,节点为最终 key 对应的 value 指针。
    压缩节点,data 数据包含子节点对应的合并字符串、指向子节点的指针,以及节点为最终 key 的 value 指针。
    value 指针指向一个 listpack 实例,里面保存了消息实际内容

Radix Tree 最大的特点就是适合保存具有相同前缀的数据,实现节省内存的目标,以及支持范围查找。而这就是 Stream 采用 Radix Tree 作为底层数据结构的原因。


Redis 缓存设计注意事项

(1)如何避免缓存雪崩?

通常为了保证缓存中的数据与数据库中的数据一致性,会给 Redis 里的数据设置过期时间,当缓存数据过期后,用户访问的数据如果不在缓存里,业务系统需要重新生成缓存,因此就会访问数据库,并将数据更新到 Redis 里,这样后续请求都可以直接命中缓存。


那么,当大量缓存数据在同一时间过期(失效)时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩的问题。

对于缓存雪崩问题可以采用两种方案解决:
1.将缓存失效时间随机打散:可以在原有的失效时间基础上增加一个随机值(比如 1 到 10 分钟)这样每个缓存的过期时间都不重复了,也就降低了缓存集体失效的概率。
2.设置缓存不过期:可以通过后台服务来更新缓存数据,从而避免因为缓存失效造成的缓存雪崩,也可以在一定程度上避免缓存并发问题。

(2)如何避免缓存击穿?

业务通常会有几个数据会被频繁地访问,比如秒杀活动,这类被频地访问的数据被称为热点数据。

如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿的问题。


可以发现缓存击穿跟缓存雪崩很相似,你可以认为缓存击穿是缓存雪崩的一个子集。 应对缓存击穿可以采取前面说到两种方案:

互斥锁方案(Redis 中使用 setNX 方法设置一个状态位,表示这是一种锁定状态),保证同一时间只有一个业务线程请求缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间;

(3)如何避免缓存穿透?

当发生缓存雪崩或击穿时,数据库中还是保存了应用要访问的数据,一旦缓存恢复相对应的数据,就可以减轻数据库的压力,而缓存穿透就不一样了。

当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增,这就是缓存穿透的问题。


缓存穿透的发生一般有这两种情况:
1.业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据;

2.恶意攻击,故意大量访问某些读取不存在数据的业务。

应对缓存穿透的方案,常见的方案有三种:
1.非法请求的限制:当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在 API 入口处要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。

2.设置空值或者默认值:当线上业务发现缓存穿透的现象时,可以针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。

3.使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在:可以在写入数据库数据时使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在,即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。