Redis进阶
这篇文章主要介绍了Redis的一些内存管理、优化以及Redis事务、Redis集群与高可用性,通过这些高级用法和策略,可以高效地管理 Redis 的数据存储,保证其性能和可靠性。
上一篇介绍Redis的内容中着重讲述了Redis的数据结构以及功能,了解了Redis的持久化机制以及Redis为什么这么快,这一篇博客将着重介绍Redis中的一些更加底层的构造以及更高级的用法。
Redis内存
如果Redis的内存达到设置的上限,Redis的写命令会返回错误信息,读命令还能够正常返回。这个时候我们可以配置内存淘汰机制,当Redis达到内存上限的时候冲刷掉旧的内容。
Redis过期键的删除策略
一般而言我们会使用expire
和persist
命令来对键的过期时间进行处理。
在单机版本的Redis中,主要存在两种删除的策略:
- 惰性删除:服务器不会主动删除数据,只有当客户端查询某个数据的时候,服务器判断该数据是否过期,如果过期就会删除。
- 定期删除:服务器执行定时任务删除过期的数据,但是考虑到内存和CPU的折中(删除会释放内存,但是删除意味着需要调用CPU),删除的频率和执行时间都受到了限制。
在主从复制的场景下,为了主从节点的数据一致性,从节点从来不会主动删除数据,而是由主节点控制节点中过期数据的删除。由于主节点的惰性删除和定期删除策略,都无法保证主节点及时对过期数据执行删除操作。因此当客户端通过Redis从节点读取数据的时候,很容易读取到已经过期的数据。而在Redis 3.2版本以后,从节点在读取数据的时候增加了对数据是否过期的判断。如果数据已经过期就不会返回给客户端。
Redis内存淘汰策略
Redis一共提供了八种淘汰的策略:
主要可以分为三种类型:
其一是不淘汰:
- noeviction:不进行淘汰,当内存达到限制的时候,尝试写入新的数据会导致错误,这种策略适用于对数据完整性极高,绝不允许丢失数据的场景。
其二是针对所有键淘汰:
- allkeys-lru:LRU算法全称是Least Recently Used,按照最近最少使用的原则来对数据进行筛选。这种模式下会使用LRU算法筛选设置了过期时间的键值对。Redis会记录每个数据的最近一次被访问的时间戳。在Redis决定淘汰数据的时候,第一次会随机选出N个数据,将其作为候选集合,而后会比较集合中的lLRU字段,将LRU字段值最小的数据从缓存中淘汰出去。通过随机读取待删除集合,可以让Redis不用维护一个巨大的链表,也不必操作链表,进而提升性能。
- allkeys-lfu:LFU在LRU的基础上为每个数据增加了一个计数器,来统计这个数据被访问的次数。当LFU策略筛选数据的时候,Redis会在候选集合中根据数据LRU字段的后8bit来选择访问次数最少的数据进行淘汰,当访问次数相同的时候,在根据LRU的字段的前16bit值大小来选择访问时间最久远的数据进行淘汰。LRU的后8bit(0~255)很容易在访问快速的情况下达到255,这样计数器似乎就失效了。Redis提供了Morris计数器,计数值不直接表示实际的计数,而是通过一个随机过程模拟的近似值,每次计数的时候会根据一定的概率来决定是否递增计数器,计数器的值越大,其递增的概率就会越小,这样的计数器的值就会以对数增长的方式近似表示实际的计数。
- allkeys-random:从所有键中随机淘汰数据。
其三是针对设置了过期时间的键的淘汰策略:
- volatile-lru:从设置了过期时间的键中淘汰最近最少使用的数据。
- volatile-lfu:从设置了过期时间的键中淘汰最不经常使用的数据。
- volatile-random:从设置了过期时间的键中随机淘汰数据。
- volatile-ttl:从设置了过期时间的键中淘汰剩余存活时间最短的数据。这种策略可以尽可能保留哪些即将过期的键。
Redis内存优化
- 缩减键值对象:缩短key和value的长度。key的长度在完整描述业务的情况下越短越好;value的长度缩减较为复杂,一般而言都是将业务对象序列化成为二进制数组之后再存入Redis。应该再业务层面上精简业务对象,去掉不必要的属性避免存储无效的数据,并且再序列化工具的选择上,选择更加高效的序列化工具来降低字节数组大小。
- 共享对象池:Redis内部维护了一个[0~9999]的整数对象池。创建大量的整数类型redisObject会导致内存开销,每个redisObject内部结构至少占16个字节,甚至超过了整数内部空间的消耗。所以Redis内存维护一个[0~9999]的整数对象池,用于节约内存。在开发中可以在满足需求的前提下尽量使用整数对象来节省内存。
- 字符串优化:使用整数存储以及避免长字符串,如果字符串可以转换为二进制格式,可以直接使用二进制进行存储,减少内存的占用。
- 编码优化:参考Redis数据结构的底层实现,大多数数据结构都有多种底层实现,就是为了根据数据的规模和内容动态选择编码。
- 控制Key的数量:合理设计Key的命名,使用合并存储以及定期清理Key,减少BigKeys。
Redis事务
Redis事务的本质就是一组命令的集合。事务支持一次执行多个命令,一个事务中的所有命令都会被序列化。在执行事务的过程中会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。
事务相关命令
MULTI
:开启事务,Redis会将后续的命令逐个放入到队列中,然后使用EXEC命令来原子化执行该命令序列。EXEC
:执行事务中的所有操作命令。
DISCARD
:取消事务,放弃执行事务块中的所有命令。
WATCH
:监视一个或多个KEY,如果事务在执行前,KEY被其他命令修改,事务就会被终端,不再指定事务中的任何命令。多个客户端使用WATCH可以保证事务的安全性。如有两个客户端执行如下命令,如果一个客户端发出的指令先执行了,另一个客户端就不会再执行。
UNWATCH
:取消WATCH中对KEY的监视。
事务实现
- 使用Redis自带的
MULTI
、EXEC
等命令实现。当一个客户端切换到事务状态之后,服务器就会根据这个客户端发送的不同命令来执行不同的操作:如果客户端发送的是EXEC
、DISCARD
、WATCH
、MULTI
这四个命令之中的一个,那么服务器就会立即执行该命令,否则就会将命令放入事务队列中,并且向客户端发送QUEUED
回复。 - 基于Lua脚本,Redis可以保证脚本内命令的一次性、按顺序地执行,但是不提供事务运行错误的回滚,执行的过程中如果部分命令运行错误,剩下的命令还是会继续执行完。
- 基于中间标记变量,通过另外的标记变量来标识事务是否执行完成,读取数据时先读取标记变量来判断事务是否执行完成。
Redis回滚支持
Redis其实并没有回滚支持。Redis命令通常只会因为错误的语法而失败,或是命令用在了错误类型的键上面,也就是说从实用性的角度来说,失败的命令是由于变成编程错误造成的,而这些错误都应该在开发的过程中被发现,而不是出现在生产环境中。并且因为并不需要对回滚提供支持,所以Redis的内部可以保持简单和快速。
Redis中的ACID
- 原子性:Redis官方文档认为Redis的事务是原子性的:所有的命令要么完全执行,要么完全不执行,而不是完全成功。
- 一致性:Redis可以保证命令失败的情况下得到回滚,数据恢复到命令没有执行之前的样子。
- 隔离性:Redis事务是严格遵守隔离性的,原因是Redis是单进程单线程模式,可以保证命令执行的过程中不会被其他客户端命令打断。
- 持久性:Redis事务并不保证持久性,因为不论是RDB还是AOF都是异步执行的,不保证持久性是出于对性能的考虑。
Redis集群
Redis集群是为了解决Redis单机容量有限、高并发场景下的性能瓶颈以及单点故障问题而提出的解决方案。它将数据分散在存储多个Redis节点上,实现了数据的分布式存储和高可用性。简单来说就是将多个Redis实例组织起来,共同对外提供服务。主要有三种模式:主从复制模式(Master-Slave)、哨兵模式(Sentinel)、Cluster模式。
主从复制模式
主从复制,就是将一台Redis服务器的数据复制到其他的Redis服务器,前者称为主节点(master),后者称为从节点(slave);数据的复制是单向的,只能从主节点到从节点。
主从复制的作用主要包括:
- 数据冗余:主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。
- 故障恢复:当主节点出现问题的时候,可以由从节点提供服务,实现快速的故障恢复,实际上是一种服务的冗余。
- 负载均衡:在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由从节点提供读服务(即写Redis数据时应用连接主节点,读Redis数据时应用连接从节点),分担服务器的负载,特别是在写少读多的场景下,通过多个从节点分担读负担,可以大大提高Redis服务器的并发量。
- 高可用基石:主从复制还是哨兵和集群能够实现的基础,是Redis高可用的基础。
主从库之间采取的是读写分离的模式:
读操作主库和从库都能够接收,而写操作则首先到主库执行,而后主库将写操作同步给从库。同步的方式主要有全量复制(第一次同步时)和增量复制(只会把主从库网络断连期间主库收到的命令同步给从库)。
全量复制
全量复制的过程主要分为三个阶段:
- 主从库间建立连接,协商同步的过程。主要是为全量复制做准备,在这一步从库和主库会建立连接,并且告诉主库即将进行同步,主库确认恢复之后,主从库之间就可以开始同步了。从库会给主库发送
psync
命令,表示要进行数据同步,主库根据这个命令的参数来启动复制。psync
命令包含了主库的runID
(每个Redis实例启动时自动生成,用于唯一标记该实例)和复制进度offset
两个参数。当从库和主库第一次复制的时候,由于不知道主库的runID
,所以暂时将其设定为“?”,而offset
则设定为-1,表示第一次复制。主库收到psync
命令之后,会使用fullresync
响应命令并且带上主库的runID
和主库当前的复制进度offse
t,返回给从库。从库在收到命令之后会记录下这两个参数。fullresync
响应表示第一次采用的是全量复制。 - 主库将所有数据同步给从库,这个过程很依赖内存快照生成的RDB文件。主库会执行bgsave指令,然后将文件发送给从库。从库接收到RDB文件以后会清空当前的数据库,然后加载RDB文件。在此过程中主库仍然可以正常接受请求,但是这些请求并没有记录到刚刚所生成的RDB文件中,为了保持主从的一致性,主库会在内存中使用专门的replication buffer来记录RDB文件生成之后所收到的所有写操作。
- 主库会将收到的所有写操作,通过命令流的形式再发送给从库。从库再重新执行这些操作,主从库就能够实现同步了。
增量复制
在Redis2.8之前,如果主从库在命令传播的时候出现了网络闪断,那么从库就会和主库重新进行一次全量复制,这会导致极大的开销。在Redis 2.8之后,网络断开并且重新连接之后,主从库会采用增量复制的方式继续同步。
增量复制主要涉及到两个概念:replication_buffer以及repl_backlog_buffer。
- replication_backlog_buffer:为了从库断开之后如何找到主从差异数据而设计的环形缓冲区,从而避开全量复制带来的性能开销。主库会在该缓冲区中存储最近写入的命令,如果从库断开的时间太久,repl_backlog_buffer环形缓冲区被主库的写命令覆盖,那么从库连上主库之后只能进行一次全量复制,所以replication_backlog_buffer的配置可以尽量写大一些,降低主从断开后全量复制的概率,而在replication_backlog_buffer中找到主从差异的数据以后,可以通过replication_buffer发送到从库。
- replication_buffer:Redis和客户端与从库通信都需要分配一个内存buffer进行交互。对于Redis而言,从库和客户端都是client,Redis连接之后都会为其分配一个client buffer,所有的数据交互都通过buffer进行:Redis先将数据写入到buffer中,再将其发送到client buffer再通过网络发送。为了再主从交互中保持数据一致而专门使用的buffer就是replicaiton_buffer。
每个从库都有其独特的slave_replication_offset。当replication_backlog_size的环形缓冲区被填满,主库上对应slave_replication_offset位置的数据被覆盖后,从库将执行全量复制。如果数据未被覆盖,则执行增量复制。
当从库的数量比较多且都要和主库进行全量复制的话,就会导致主库忙于fork子进程生成RDB文件,进行数据全量复制,这个过程会对主线程造成阻塞,主线程无法处理正常请求。同时传输RDB文件也会占用主库的网络带宽,给主库的资源带来使用压力。这个时候可以通过“主-从-从”模式来分担主库的压力,将主库生成RDB和传输RDB的压力以级联的方式分散到从库上,即在部署主从集群的时候手动选择一个从库,用于级联其他的从库,然后再选择一些从库,使其和所选择的级联从库建立起主从同步,这样在执行同步的时候,从库就不再直接和主库进行交互了,而是和级联的从库进行写操作同步即可,减轻了主库的压力。
此外,Redis 还支持无磁盘复制模式,在此模式下,子进程会直接通过网络将 RDB 文件发送到服务器,而不是使用磁盘作为中转。这适用于磁盘速度慢而网络速度快的环境。
Redis哨兵
哨兵的核心功能是实现主节点的自动故障转移。
哨兵主要实现了以下功能:
- 监控:哨兵会不断检查主节点和从节点是否运作正常。
- 自动故障转移:当主节点不能够正常工作时,哨兵会开始自动故障转移工作。
- 配置提供者:客户端在初始化时,通过连接哨兵来获得当前Redis服务的主节点地址。
- 通知:哨兵可以将故障转移的结果发送给客户端。
哨兵集群主要是通过Redis的pub/sub机制实现的,即发布/订阅机制。在主从集群中,主库上会有一个名为__sentinel__:hello
的频道,不同的哨兵就是通过其来相互发现从而实现互相通信的。哨兵通过把自己的ip和端口发布到该频道上,其他哨兵可以通过该频道获取,而后建立网络连接。
哨兵通过向主库发送INFO
命令来监控Redis集群,主库接收到INFO
命令之后,就会把从库列表返回给哨兵。接着哨兵就可以根据从库列表中的连接信息,和每一个从库建立连接,并且在这个连接上持续地对从库进行监控。
Redis哨兵节点下线判定
下线的方式主要有两种:主观下线和客观下线。
- 主观下线:任何一个哨兵都可以监控探测并且做出Redis节点下线的判断。当某个哨兵判断主库”主观下线“以后,就会给其他哨兵发送
is-master-down-by-addr
命令,而后其他哨兵就会根据自己和主库的连接情况,做出Y或者N的响应。 - 客观下线:由哨兵集群来共同决定Redis节点是否下线。当哨兵共同做出的票数大于哨兵配置文件中的
quorum
配置项,就认为主库客观下线了。
当主库判断客观下线之后,会在过滤掉不健康的、没有回复过哨兵ping响应的从节点之后,选择salve-priority
从节点优先级最高的以及复制偏移量最大的、复制最完整的从节点。
Redis哨兵选举机制
为了避免哨兵的单点故障,需要一个哨兵的分布式集群。分布式集群就必然涉及到共识问题,同时故障的转移和通知都只需要一个主哨兵节点即可。
哨兵的选举机制主要采用的是Raft选举算法:选举的票数大于等于nums(sentinel)/2 + 1
的时候就能够成为领导者,如果没有超过的话就会继续选举。即任何一个想要成为Leader的哨兵,要满足两个条件:拿到半数以上的赞成票;拿到的票数要大于等于哨兵配置文件中的quorum
值。
Redis哨兵故障转移
以Redis哨兵的图为例,假设判断主库客观下线了,同时选出了sentinel3为哨兵leader。那么此时哨兵集群就会选择一个最完整的节点,假设此时选出了slave-1为主节点,那么salve-1此时会执行slave of no one
解除从节点的身份,变为新的master,slave-2会变成新的主节点的从节点,如果原主节点恢复也会变为新的主节点的从节点,最后通知应用程序新的主节点的地址。
Redis Cluster
Redis Cluster 通过分布式的方式将数据分布到多个节点上,能够自动地进行分片(Sharding)、容错处理和数据复制。它的设计目标是使 Redis 在集群模式下支持更多的数据量、负载均衡,并能保证高可用性和自动故障恢复。
Redis Cluster由多个Redis节点组成,和其他的方式一样,也分为主节点和从节点。但是其他的不一样的是,Redis Cluster的数据分布机制基于哈希槽,整个集群包含16384个哈希槽,这个数字的选择有其深层的原因:Redis Cluster的心跳包机制会定期发送集群状态信息,帮助集群中的节点保持同步。在心跳包中,Redis需要传递当前集群中每个节点所负责的哈希槽的状态。由于有16384个哈希槽,这些槽信息可以通过bitmap 压缩为一个 2KB 的二进制位图进行传输,从而显著减少网络传输的开销。具体而言,使用1位来表示每个哈希槽的状态(如节点是否持有该槽),共需 16384 位(即 2KB)。相较于如果选择更多的哈希槽(比如 65535 个槽,对应 65535 个哈希槽,需要使用 8KB 的心跳包),16384 个哈希槽使得心跳包的大小保持在一个合适的范围内,减少了网络负载。。每一个键都会根据哈希函数计算出一个哈希值,并且将该哈希值映射到16384哈希槽中的某一个槽,每个节点负责一部分哈希槽。客户端通过计算键的哈希值并且找到对应的哈希槽,将请求发送到对应的节点。
Redis缓存
在使用Redis作为缓存的时候,我们需要考虑以下问题:
缓存穿透
缓存穿透是指缓存和数据库中都不存在的数据,而用户不断发起请求,由于缓存是没有命中的时候被动写的,并且出于容错考虑,如果从存储层没有查询到数据就不写入缓存,这就导致了这个不存在的数据每次都要到存储层去查询,失去了缓存的意义,增大了存储层的负担。
解决方案:
- 参数校验:在请求到达缓存或者数据库之前,现在前端或者接口层对请求的参数进行校验,避免非法参数或无效请求。
- 缓存空值:从缓存取不到的数据,在数据库中也没有取到,就将key-value写为key-null或者其他字符串如empty等,缓存的有效时间可以设置得短一些,如30s或者1min,这样可以有效防止用户反复使用一个id进行暴力攻击。
- 布隆过滤器:布隆过滤器是一种高效的空间压缩数据结构,用于判断某个元素是否在集合中。它可以快速判断某个请求的数据是否可能存在于数据库当中,从而避免不必要的数据库查询。
- 限定请求的频率:缓存穿透不仅仅是空请求或者无效请求的频繁访问,也可能是某些恶意攻击或者高频率导致的无效请求导致的压力。
缓存击穿
缓存击穿是指当请求的数据在缓存中不存在时,缓存无法命中,而数据直接查询数据库,且在查询数据库时,数据是合法且存在的。在这种情况下,虽然缓存可以有效存储数据,但是由于缓存的过期、淘汰等原因,或者缓存未命中,导致每次查询都直接从数据库获取数据,从而给数据库带来较大的负担。
解决方案:
- 设置热点数据永不过期。
- 缓存预热:提前将热点数据加载到缓存中,避免缓存穿击的发生。尤其是对于一些数据库查询频繁的数据,预先将这些数据缓存起来,减少对数据库的访问。
- 接口限流与熔断,降级:重要的接口做好限流策略,防止用户恶意刷接口,同时要降级准备,当接口中的某些服务不可用的时候进行熔断,失败快速返回机制。
- 互斥锁机制:当缓存中的数据失效时,只有一个请求允许进入数据库查询,而其他请求则会等待,等到第一个请求查询并且更新缓存之后,其他请求就可以从缓存中读取数据。
缓存雪崩
缓存雪崩是指缓存系统中,多个缓存项同时失效,导致大量请求直接打到数据库上,造成数据库压力激增,甚至可能导致数据库宕机或性能严重下降,通常这种情况发生在缓存的失效时间是相同或者非常接近的,或者缓存的内容批量失效时,所有缓存都会同时失效并且所有请求一并访问数据库,导致系统的负载急剧增加。
解决方案:
- 避免缓存同时失效:可以通过为不同的缓存项设置不同的过期时间,避免它们在同一时间点失效。即使多个缓存项失效,他们也会错开过期时间,减少大量请求同时打到数据库的情况。或者对缓存的过期时间进行随机化,即使多个缓存项失效,他们的失效时间也不完全相同。可以通过在设置过期时间的时候加上一些随机的偏差来避免缓存在统一时间过期。
- 如果缓存数据库是分布式部署,将热点数据均匀分布在不同的缓存数据库中。
参考资料:
Java 全栈知识点问题汇总(上) | Java 全栈知识体系
Redis cluster specification | Docs
掌握 Redis 缓存 - 您需要了解的一切 [2024] --- Master Redis Cache - Everything You Need To Know [2024]