⭐哈希冲突
哈希冲突是指,两个key的哈希值和哈希桶计算对应关系的时候,正好落在了同一个哈希桶中。这是哈希表没法避免的问题。
Redis解决哈希冲突的方式是链表,就是同一个哈希桶中的多个元素用一个链表来保存。
但是如果哈希冲突越来越多,就会导致某些哈希桶上的链表特别长,就导致在这些哈希桶上查找数据变得很慢。Redis的解决方案是rehash。
⭐rehash:rehash操作就是为了增加现有哈希桶的数量,让元素能在更多的哈希桶之间分散保存。
为了让rehash更加高效,Redis使用了两个全局哈希表,默认使用的是哈希表1,哈希表2不会被分配空间。Redis的rehash主要分三个步骤:
- 给哈希表2分配更大的空间。
- 把哈希表1中的数据重新映射并拷贝到哈希表2中。
- 最后释放哈希表1的空间。
这个过程会涉及到大量的数据拷贝,如果一次性把哈希表1的数据都拷贝完,会造成Redis线程的阻塞,无法服务其他请求。为了避免这个问题Redis采用了渐进式rehash。
渐进式rehash:就是在拷贝数据时,Redis每收到一个请求,就把这个请求操作的哈希桶中所有元素拷贝到哈希表2中。这样就可以把rehash过程分摊到多次请求中。
如果是查询操作,Redis会现在哈希表1里面查找,如果没找到就在哈希表2里面找。
如果是新增操作,Redis只会把数据新增到哈希表2中,这样就可以保证哈希表1的数据只减不增,最终所有数据都会被拷贝到哈希表2中。
⭐Redis中的数据结构
Redis中数据类型一共有5种:String、List、Hash、Set、Sorted Set。
底层的数据结构一共有6种:简单动态字符串(SDS)、双向链表、压缩列表、哈希表、跳表、数组。
String:String类型的底层实现只用了一种,简单动态字符串。
List:List类型有两种底层实现,双向链表和压缩列表。
Hash:Hash类型有两种底层实现,压缩列表和哈希表。
Sorted Set:Sorted Set类型有两种底层实现,压缩列表和跳表。
Set:Set类型有两种底层实现,哈希表和数组。
⭐简单动态字符串(Simple Dynamic String):
简单动态字符串主要有三部分组成:
- buf:字节数组,用来保存实际的数据。会在数组结尾处加上一个 “\0” ,用来表示数组的末尾。会占用1个额外的字节。
- len:占用4个字节,表示buf的长度。
- alloc:也占用4个字节,用来表示buf实际分配的长度,一般大于len。
在SDS中,只有buf保存实际的数据,len和alloc都是SDS结构的额外开销。
⭐压缩列表:
压缩列表跟数组很像,但是比数组更节省内存,因为数组中每个元素的大小都是相同的,就需要为每个元素都分配最大元素占用的内存空间,如果大部分元素都不需要这么大的内存空间,就会浪费很多内存(因为数组需要提前知道每个元素的大小才能计算出下一个元素的位置)。
压缩列表就是在数组的基础上对每个元素的大小进行压缩。
压缩列表会维护一个表头,这个表头包含三个属性:列表长度(zlbytes)、列表尾部的偏移量(zltail)、列表中元素的个数(zllen)。
压缩列表会为每个元素都维护一些元数据:前一个entry的长度(pre_len)、自己的长度(len)、保存的数据类型(encoding)、content(实际保存的数据)。
⭐跳表:
跳表是在链表的基础上,增加了多级索引,通过索引位置的跳转,可以快速定位到数据。
假设链表中十个元素。如果要在链表中查找8这个元素,就只能从头开始遍历,需要查找8次,效率比较低。复杂度是O(N)。
可以通过增加索引的方式来提升查找速度:从第一个元素开始,每两个元素选出来一个索引。这些索引再通过指针指向原来的链表。
比如在,1和2中选择1作为索引,从3和4中选择3作为索引,5和6中选择5作为索引,这样就只需要遍历1、3、5、7、8就可以查找到8了,只需要5次查询。
如果还想更快点,可以在一级索引的基础上增加二级索引:从一级索引中每两个元素建立一个索引,这些索引再指向一级索引。
跳表的时间复杂度是O(logN)
⭐AOF日志
AOF日志里面记录的是Redis收到的每一条写命令,这些命令是以文本形式保存的。Redis采用的是先写内存后写日志的方式。
因为AOF日志是以文件的形式保存的,写命令越多,AOF文件就越大。AOF日志大小超过限制就会触发AOF重写机制。
AOF日志过大带来的问题:
- 占用更多磁盘空间
- 大文件会导致写效率变低。
- 会导致恢复数据变得很慢。
AOF是默认关闭的,可以通过appendonly
参数开启。RDB和AOF同时开启的情况下,会优先使用AOF恢复数据。
⭐先写内存后写日志:
先写内存后写日志这种方式的好处是:可以避免语法检查的额外开销,而且AOF是在命令执行之后才记录日志的,所以不会对当前操作的性能有影响。但是会对下一条操作有影响,因为往AOF记录日志是主线程进行的,磁盘压力比较大导致写日志速度比较慢,就会导致后续的操作阻塞。而且如果一个命令刚执行完,还没来得及记录日志就宕机了,那这个命令对应的数据就会丢失。
AOF提供了三个选项,可以通过appendfsync
参数控制AOF写日志的时机:
- Always:同步写回,每个写请求都会对应一次写日志的操作,会影响Redis性能,最多会丢失一条命令的数据。
- Everysec:每秒写回,Redis会先把写请求记录到AOF内存缓冲区,每隔一秒就把缓冲区的内容写到磁盘上,可能会丢失1秒的数据。
- No:由操作系统来控制写回,Redis会先把请求记录到AOF内存缓冲区,由操作系统来决定什么时候把缓冲区的内容写到磁盘上(通常是系统比较空闲的时候)。
⭐AOF重写机制触发规则:
- 可以通过
bgrewriteaof
命令手动触发AOF重写。 - 也可以通过调整Redis参数来控制AOF触发时机:需要同时满足
auto-aof-rewrite-min-size
和auto-aof-rewrite-precentage
两个参数auto-aof-rewrite-min-size
:表示触发AOF重写时,文件的最小大小,默认为64MB。auto-aof-rewrite-precentage
:当前AOF文件与上一次AOF文件重写后的大小的比值,默认是100%。也就是说如果当前AOF日志是10MB,上一次AOF文件重写之后是20MB,那么当前AOF文件需要增长到40MB才满足AOF重写机制。
⭐AOF重写机制:
AOF重写就是把Redis中当前所有数据转换成写命令,然后再记录到AOF日志中。
因为当前数据库中的一条数据可能会对应AOF日志中多个命令,比如原本是name=张三,然后被改成name=李四。name=李四这条数据在AOF日志中就有两条命令。重写时只需要保存name=李四这一条命令就可以了。这样就可以减少AOF日志的大小。
重写是通过后台进程(bgrewriteaof)来完成的,所以不会影响主线程处理请求的性能。
重写过程大概是这样的:
- 首先主线程会创建一个后台进程,然后把内存中的数据拷贝一份给这个后台进程。然后后台进程再把数据转换成写命令记录到新的日志中。
- 重写过程中,主线程如果接收到新的写命令,Redis会把命令记录同时记录到旧的AOF日志和AOF缓冲区中。
- 等重写完成后,再把新收到的命令记录到新的AOF日志中。
- 然后就可以用新日志替换旧日志了。
⭐RDB内存快照
内存快照就是把,数据库中某一时刻所有数据的状态保存到磁盘上。
RDB记录的是某一时刻的数据,可以直接把RDB文件读到内存,恢复数据的效率很高。
Redis提供了两个命令生成RDB文件,save
和bgsave
:
- save:在主线程执行,会阻塞主线程。
- bgsave:会创建一个后台进程,专门用来写RDB文件,可以避免主线程被阻塞。Redis默认用的是bgsave。
在生成RDB文件时,如果主线程接收到了写命令,那这条写命令对应的数据就会被复制一份,生成一个副本。后台进程会把这个副本一起写进RDB文件中,这样就可以保证快照的完整性。
⭐内存快照的频率:
如果频率太高:每次生成内存快照都会把全量数据写到磁盘上,会给磁盘带来很大压力。而且后台进程是主线程创建出来的,创建的过程本身是会阻塞主线程的,如果快照生成频率过高,就会频繁创建进程,就会很频繁的阻塞主线程。
如果频率太低:两次快照生成时间间隔较长,如果服务宕机,就会丢失很多数据。
⭐混合模式
⭐Redis4.0版本新增了混合使用AOF和RDB的机制,就是在两次快照之间,用AOF日志记录这期间的所有命令,等下一次快照执行完再清空AOF日志。(可以通过aof-use-rdb-preamble
参数开启)
这样可以降低内存快照的频率,而且AOF日志也只需要记录两次快照之间的操作,不会出现文件过大的情况。
在混合模式下,AOF重写时,后台进程会先把内存中的数据以RDB的格式写入到AOF文件中,再把AOF缓冲区中的命令以AOF的格式写到文件中。
这样新的AOF文件就会包含RDB格式的数据和AOF格式的数据,在恢复数据时,会先加载RDB格式的数据,然后再执行AOF格式的操作命令。
⭐主从集群
Redis提供了主从模式。在从库上执行replicaof 127.0.0.1(主库ip) 6379(主库端口)
。
通常情况下主库负责写操作,然后再把写操作同步给从库。从库只负责读操作。
Redis2.6版本之后,从库默认是只读的,可以通过slave-read-only
参数修改。
⭐主从同步原理:
主从库建立连接后,从库会给主库发送
psync
命令,表示要进行数据同步。psync
命令包含ID和offset两个参数。ID:每个Redis实例启动时都会自动生成一个随机ID。主库和从库第一次同步数据时,从库不知道主库的ID,所以此时ID的值为”?”。
offset:复制进度。第一次同步数据时,offset为-1。
主库收到
psync
命令后,如果是第一次同步,主库会用FULLRESYNC
命令给从库返回响应。FULLRESYNC:表示全量同步,这个命令包含两个参数:主库ID和复制进度offset。
从库收到响应后会把这两个参数记录下来。
主库将数据同步给从库,这个过程需要依赖内存快照生成的RDB文件。主库会先执行
bgsave
命令生成RDB文件,然后再把文件发送给从库。Redis可以先把RDB文件写到磁盘上,再发送给从库。也可以直接通过socket把RDB文件发送给从库(无需落盘)。
可以通过参数
repl-diskless-sync
开启无盘传输。默认是关闭的。从库接收到RDB文件后,会先清空当前数据库,然后加载RDB文件。
主库发送RDB文件的过程中,如果又接收到了新的写命令,就会把这些命令记录到replication buffer中。
最后主库再把replication buffer中的命令发送给从库。
执行全量数据同步,对于主库来说主要有两个操作:生成RDB文件和传输RDB文件。生成RDB文件需要创建后台进程,这个过程会阻塞主线程。而且传输RDB文件也会占用网络带宽。如果从库的数量比较多,就会对主库性能产生影响。可以通过主从级联模式缓解数据同步时主库的压力。
⭐主从级联模式:
可以选择一个配置比较高的从库,让其他从库和这个从库建立主从关系。这样就可以把一部分数据同步的压力分摊到从库上。
⭐主从连接中断:
在Redis2.8版本之前,如果主从库连接中断,再次连接后主从库就会进行一次全量数据同步。
Redis2.8版本之后,主库会采用增量同步的方式。
⭐增量同步:主库只会把连接中断期间收到的命令同步给从库。
主从库断开连接后,主库会把期间收到的写命令写到repl_backlog_buffer中。
repl_backlog_buffer:是一个环形缓冲区,主库会记录自己写到的位置(master_repl_offset),从库会记录自己读到的位置(slave_repl_offset)。
主从库恢复连接之后,从库会发送
psync
命令,并且把当前repl_backlog_buffer的偏移量发送给主库。主库会判断自己的偏移量与从库偏移量之间的差距。如果差距超过了缓冲区的大小,就说明缓冲区中没有被读取的操作被覆盖过,这种情况就需要重新执行全量数据同步了。
可以通过
repl_backlog_size
这个参数,调整缓冲区的大小,来减少全量数据同步的次数。
⭐主从数据不一致?
因为主从库之间的数据同步是异步进行的,主库执行完写命令之后,并不会等从库同步完数据后再给客户端返回结果。而且从库需要先处理完当前收到的请求,才会执行主库发送的写命令。如果从库正在执行keys *
之类的耗时比较长的命令,就有可能导致客户端读到旧数据。
解决办法:
可以开发一个服务来监控主从库之间的数据同步进度,可以通过INFO replication
命令获取主库写数据的进度(master_repl_offset)和从库复制数据的进度(slave_repl_offset),然后比较主从库数据同步的差值,如果差值比较大,就可以把读请求发送到主库上执行。
⭐哨兵集群
哨兵实际上就是一个特殊的Redis实例,它主要负责:监控、选择主库和通知。
哨兵集群之间的相互发现需要依赖于Redis的发布/订阅机制(pub/sub)。主库上有一个__sentinel__:hello
的频道,哨兵和主库建立连接之后,就会在这个频道上发布自己的连接信息(IP和端口),同时也会订阅这个频道,来获取其它哨兵的连接信息。
哨兵如何获取从库的IP和端口?
哨兵会向主库发送INFO
命令来获取从库的信息,主库在接收到INFO命令之后,会把从库列表发送给哨兵,哨兵就会根据从库列表中的连接信息,和每一个从库建立连接,对这些从库进行监控。
⭐监控:
哨兵会周期性的给所有节点发送PING
命令,如果哨兵没有在规定时间收到响应,哨兵就会把这个节点标记为下线状态。
如果被标记为下线状态的节点是主库,哨兵就会开启切换主库的流程。
可以通过参数down-after-milliseconds
调整哨兵监控周期。
但是哨兵是会有误判的(比如网络压力较大导致哨兵没有及时收到响应),如果被误判的是从库对集群的影响不会太大。如果被误判的是主库就会开始切换主库的流程,选择主库的过程开销就比较大了。可以通过部署多个哨兵一起来判断,降低误判概率。
在哨兵集群中,当某个哨兵与主库连接中断后,就会通知其它哨兵节点主库已下线,其它哨兵会根据自己和主库的连接情况投出赞成票(Y)或反对票(N)。当赞成票的数量达到quorum
参数后,主库就会被标记为下线状态。
⭐选择主库:
在选择主库的过程中,读请求可以在从库上正常执行,写请求会失败。
选择主库的过程可以分为筛选和打分两个阶段。
筛选阶段:主要是判断节点的网络连接状态,如果断开连接的次数比较多,哨兵就会认为这个从库的网络状态不是很好,就会把这个节点筛选掉。
打分阶段:打分分为三轮,只要在任何一轮有从库得到了最高分,这个从库就会被选为主库。
根据优先级来打分,可以通过
slave-priority
参数设置优先级。可以给配置比较高的节点分配更高的优先级。根据数据同步进度来打分,在主从库数据同步时,从库会记录当前数据同步的进度,哨兵会比较所有从库的数据同步进度,谁的数据多谁就会成为主库。
根据节点ID来打分,ID号小的会成为主库。
在哨兵集群中,发现主库下线的哨兵会主动向其它哨兵发送命令,希望由自己来执行主从切换,其它哨兵会进行投票。这个过程也叫Leader选举。
想要成为Leader哨兵,需要满足两个条件:
- 拿到半数以上的赞成票。
- 拿到的票数必须大于等于
quorum
参数。
如果选举失败,哨兵集群会等待一段时间,再重新选举。
需要注意的是:如果哨兵集群只有2个节点,必须要拿到2票才能成为Leader,如果其中有一个哨兵挂掉了,就没办法切换主库了。所以通常至少要配置3个哨兵节点。
⭐通知:
新的主库选出来之后,哨兵会把新主库的连接信息发送给所有从库,让它们执行replicaof
命令与新主库建立连接并同步数据。
哨兵还会把新主库的连接信息发送给客户端,客户端可以通过订阅switch-master
频道来获取主从切换的事件。
客户端也可以主动获取新的主从地址,所以在哨兵模式下,客户端不能直接写死主从库的地址了,需要使用sentinel get-master-addr-by-name
命令获取新地址。
⭐切片集群
切片集群就是把数据拆分成多份,每一份都分别用一个实例来保存,Redis3.0版本提供了Redis Cluster切片集群方案。
Redis Cluster通过哈希槽来处理数据与Redis节点的映射关系,一个切片集群一共有16384个哈希槽,这些哈希槽可以理解为数据分区,每一个键值对都会根据它的key,被映射到一个哈希槽中。
客户端与集群建立连接之后,集群就会把哈希槽的分配信息发送给客户端,客户端会把哈希槽信息缓存在本地,当客户端请求键值对时,会先计算数据对应的哈希槽,然后就可以根据哈希槽向对应的节点发送请求了。
如果需要新增节点或者删除节点,就需要重新分配哈希槽。哈希槽对应的数据也需要迁移。在集群内部,节点之间可以相互通信来获得最新的哈希槽的映射关系。
这种情况下,客户端维护的哈希槽信息就是错误的了,Redis Cluster提供了一种重定向机制。
⭐故障处理机制:
通常情况下,集群中每个节点都会配置为主从模式,当某一个主节点没有和其它节点完成PING
通信,就会被标记为主观下线,然后通知其它节点,其他节点会根据自己与该节点的连接情况进行投票,投票通过后该主节点就会被标记为客观下线。然后开始主从切换,等原来的主节点恢复以后,会自动成为新主节点的从节点。
如果主节点没有从节点,主节点挂了的话,整个集群就会处于不可用的状态。
⭐哈希槽映射:
首先根据键值对的key,通过CRC16算法
计算出一个16位的值。然后再用这个值和16384取模,就可以得到一个0~16383范围内的数字。这个数字就代表这个键值对对应的哈希槽。
默认情况下切片集群会自动把16384个哈希槽平均分布在集群所有节点上,集群中的每个节点内部都会保存哈希槽和Redis节点的对应关系。
⭐重定向机制:
就是客户端给一个节点发送读写操作时,如果这个节点上没有对应的数据,这个节点就会给客户端返回MOVED
命令,这个命令会包含键值对所在的节点信息。客户端也会更新本地缓存的哈希槽信息。
1 | GET hello:key |
还有一种情况是,哈希槽重新映射之后,数据还没有迁移完成,客户端就会收到一条ASK
命令,这个命令会包含数据所在的节点信息
1 | GET hello:key |
客户端需要再向新节点发送ASKING
命令,这个命令表示让新节点处理客户端的请求。
⭐Redis内存优化
实际上Redis中的每一个键值对都对应一个RedisObject对象,这个对象主要用来记录一些元数据,比如:最后一次访问事件,被访问的次数等。
也就是说Redis中的键值对越多,对应的RedisObject对象就越多,这些元数据就会占用越多的内存空间。这种情况下,可以通过Hash集合来减少Redis中的键值对。
⭐基于Hash类型的二级编码:
就是把一个String类型的数据中的key拆分成两部分,前一部分作为Redis中的key,后一部分Hash集合的key,Hash集合的value就是要保存的数据。这样就可以减少数据库中键值对的数量,就可以减少元数据占用的内存空间。
而且String类型底层数据结构是简单动态字符串,Hash类型的底层数据结构是压缩列表,也相对更省空间。
这种方式虽然可以节省空间,但是就没有办法针对每某一个key单独设置过期时间了,虽然可以通过业务层单独维护每个元素的过期删除逻辑,但是会比较复杂。
Hash类型底层其实有两种数据结构的实现:
它会根据Redis中的两个参数动态选择底层的数据结构,一个是Hash集合中最大元素个数(hash-max-ziplist-entries),另一个是单个元素的最大长度(hash-max-ziplist-value)。
如果Hash集合中的元素个数,或者单个元素的大小超过这两个参数,Hash类型的底层实现就会变为哈希表,并且不会再变回来了。节省空间的效果也就没那么好了。
所以需要通过调整字符串的拆分长度,让数据均匀地分布在多个Hash集合中。
⭐数据淘汰机制
Redis 4.0版本之前提供了5种淘汰策略,4.0之后又新加了2种,可以通过maxmemory-policy
参数配置。淘汰策略可以分为两类:
在设置了过期时间的数据中进行淘汰:
- volatile-ttl:会针对设置了过期时间的数据,根据过期时间的先后顺序进行删除,越早过期就越先删除。
- volatile-random:在设置了过期时间的数据中,随即删除。
- volatile-lru:会使用LRU算法删除设置了过期时间的数据,优先删除最近最少使用的key。
- volatile-lfu:会使用LFU算法删除设置了过期时间的数据,优先删除最少使用的key。(Redis 4.0版本新增的)
在所有数据中进行淘汰:
- allkeys-random:会从所有数据中,随机删除数据。
- allkeys-lru:使用LRU算法在所有数据中删除数据,优先删除最近最少使用的key。
- allkeys-lfu:使用LFU算法在所有数据中删除数据,优先删除最少使用的key。(Redis 4.0版本新增的)
默认情况下,如果内存满了,并不会淘汰数据,再有写请求进来的时候,Redis会直接抛异常。
从算法角度来说:
其实Redis4.0以后提供的LFU算法比LRU更实用一些。因为如果某一个Key是一天访问一次,正好这个key刚刚在一秒前被访问过,那么LRU就不会淘汰这个Key,反而会去淘汰一个每分钟都会被访问,但是最近1秒没有被访问过的key。LFU算法就不会有这个问题。
TTL算法比较简单粗暴,它会优先删除最早过期的Key,但是这个key有可能正在被大量访问,所以这种算法会有一些风险。
从key的范围来说:
allkeys可以保证就算忘记设置过期时间,也可以保证key被删掉。如果被删掉的key访问频率很高,有可能会造成缓存击穿,volatile会更稳妥一些。
⭐LRU算法原理
LRU算法的核心是淘汰最久没有使用的数据。
具体来说就是,LRU会把所有数据组成一个链表,链表的头部代表最常使用的数据,链表的尾部代表最不经常使用的数据。在删除数据的时候,会从链表的尾部开始删除。
如果要写入一个新数据,但是链表已经没有空余位置了,LRU算法会做两件事:
- 新数据是刚被访问的,所以会放到链表头部。
- 再把链表尾部的数据删除。
不过LRU算法需要用链表来管理数据,会带来额外的开销。而且如果有大量的数据访问,就会有很多链表移动操作,会降低Redis的性能。
所以Redis对LRU算法做了优化,Redis会记录每个数据每个数据最近一次访问的时间戳(RedisObject中的lru属性)。
在第一次淘汰数据的时候,会随机选出一批数据,作为候选集合,然后找出集合中lru最小的数据淘汰出去。
下一次淘汰数据的时候,会再挑选一批数据进入到候选集合中,这里进入候选集合的标准是必须小于候选集合中最小的lru。然后再把集合lru最小的数据淘汰出去。
这样就可以解决链表移动导致的性能问题。
这里的候选集合是一个链表,数据会按照lru排序,表头是lru最大的,表尾是lru最小的,如果链表满了就会把表头的数据移出去。
可以通过参数maxmemory-samples
来控制挑选数据的个数,默认是5。
建议:
- 如果业务场景有冷热数据区分的话,优先使用allkeys-lru策略。这样可以把经常访问的热数据留在缓存中。
- 如果业务中数据访问的频率差不多,没有冷热数据区分,建议用allkeys-random策略。随机淘汰就行。
- 如果业务中有一些不需要被删除的数据,可以使用volatile-lru策略。然后设置这些数据永不过期。这样这些数据就一直不会被删除了。
⭐LFU算法原理
LFU是在LRU算法的基础上,为每个数据增加一个计数器,用来统计这个数据的访问次数。
淘汰数据的时候,会随机选出一批数据,作为候选集合,然后根据根据集合中数据的访问次数进行筛选,会把访问次数最低的数据淘汰出去。如果两个数据的访问次数一样,会把访问时间更久的数据淘汰出去。
这个计数器的大小只有8位,最大值是255。
所以并不是数据每访问一次,就给对应的计数器+1,而是每当一个数据被访问一次,先用计数器当前的值 * 配置参数lfu_log_factor
+ 1 然后再取倒数,然后判断这个倒数是否大于一个取值范围在0~1之间的随机数,如果大于的话,计数器就+1。(n的倒数是n分之1)
这样设计的好处是可以避免计数器很快就达到255。
可以通过增加lfu_log_factor
的值来减少计数器的增加速度。
有些数据可能刚开始被大量访问之后就不会再被访问了,LFU算法还有一个衰退机制。
衰退机制就是,LFU算法会计算当前时间和数据最近一次被访问的时间的差值,然后把这个差值换算成分钟,然后再把这个差值除以lfu_decay_time
参数,得到的结果就是计数器要衰减的值。
⭐过期策略
Redis的过期策略有被动删除和主动删除两种,这两种策略会一起配合使用。
被动删除:
当收到查询请求时,判断请求的key是否过期,过期了就直接删除。
这个策略的好处是可以降低CPU资源的消耗,缺点是会导致内存中有大量已经过期的key还留在内存中。
主动删除:
- 默认是100ms,就随机抽取一些设置了过期时间的key,检查是否过期,过期了就直接删除。
- 可以通过
hz
参数调整检查频率。 - 主动删除是在Redis主线程中执行的,如果有大量的key在同一时间过期,会导致Redis响应时间变长。
⭐事务机制
Redis提供了四个命令来完成事务:MULTI(开启事务)、EXEC(提交事务)、DISCARD(回滚事务)、WATCH(监听数据是否变更)。
客户端需要用MULTI命令开启一个事务,然后把需要执行的操作发送给Redis,Redis收到这些命令之后并不会立即执行,而是会把这些命令暂存到一个命令队列中。最后客户端发送EXEC命令之后,Redis才会执行命令队列中的内容。
DISCARD可以用来回滚事务。
WATCH命令可以用来监控某些数据是否被修改了,如果被修改了就可以选择放弃事务。
⭐缓存不一致
其实不管是先更新数据库还是先更新缓存,或者先更新数据库再删除缓存,只要没办法保证第二个操作成功,就会导致数据不一致。
更好的方式是,新增一个服务监听binlog,然后把发生变化的数据更新到缓存中。
如果不考虑新增服务,最好的策略是先更新数据库,再删除缓存。这种策略在多线程环境下也可能出现数据不一致,但是概率非常低,需要满足3个条件才有可能发生:
- 缓存刚好失效
- 同时出现读请求+写请求
- 更新数据库比查询数据库时间短
A和B两个线程,线程A是查询请求,线程B是更新请求,缓存中的数据也刚好过期了:
- 线程A查询缓存发现数据不存在,
- 线程A读取数据库,得到X=1
- 线程B更新数据库,把X更新为2
- 线程B删除缓存
- 线程A将X=1写入缓存
如果一定要求强一致性,常见的方案是:两阶段提交、三阶段提交、Paxos、Raft这类分布式事务,但它们的性能都很差,而且实现起来也很复杂,这就违背了我们引入缓存的目的。之所以要引入缓存是为了解决性能问题,只要数据库和缓存完成操作之前,有其他请求进来,都可能查到中间状态的数据,如果非要追求强一致性,那就要求更新操作完成之前不能有其它请求进来,这个可以通过分布式锁来实现。但是又会牺牲系统的可用性,并且分布式锁也有很多问题要解决。
所以,既然决定使用缓存,就必须容忍「一致性」问题,否则不建议使用缓存。
⭐缓存雪崩
缓存雪崩是指短时间内大量缓存失效的情况,就会导致原本可以在Redis中处理的请求全都发送到数据库,导致数据库压力很大。
产生缓存雪崩的原因通常是大量的key在同一时间过期。(缓存雪崩是热key大规模的过期失效,缓存击穿是单个热key过期失效)
解决办法:
针对不同的key设置不同的过期时间,可以在Redis中存数据的时候,把每个Key的失效时间加一个随机值。
或者在内存中定义一个二级缓存(Map),提前把数据从数据库加载到内存中,缓存失效就直接走内存查询。
⭐缓存击穿
缓存击穿是指,某个非常频繁被访问的key突然过期,就会导致访问这个key的大量请求,就都发送到了后端数据库,导致数据库压力很大。
解决办法:
设置热点Key永不过期,并且选择volatile系列的数据淘汰算法。
也可以通过加锁来限制回表的并发。
可以使用Redission来获取一个基于Redis的分布式锁,查询数据库之前先获取锁。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private RedissionClient redissionClient;
public String query() {
String data = redisTemplate.opsForValue().get("key");
if (StringUtils.isEmpty(data)) { // 如果数据为空就通过Redission尝试获取分布式锁
RLock locker = redissionClient.getLock("locker");
// 尝试获取分布式锁,获取不到会阻塞
if (locker.tryLock()) {
try {
//获取到锁后,再次判断数据是否为空,因为其它线程可能已经把数据写回缓存了
data = redisTemplate.opsForValue().get("key");
if (StringUtils.isEmpty(data)) {
// 从数据库获取数据
data = queryForMySQL();
redisTemplate.opsForValue().set("key", data);
}
} finally {
// 释放锁
locker.unlock();
}
}
}
return data;
}不过也不一定非要使用分布式锁,因为这样会导致只有一个线程可以获取数据,其它线程都会阻塞。可以考虑使用进程内的锁,这样每一个节点都可以有一个线程回表查询数据,可以提升一些并发度,也不会对数据库有太大影响。
⭐缓存穿透
缓存穿透是指缓存中查不到的数据不一定是数据没有缓存,有可能是原始数据压根就不存在。
缓存穿透有两种解决办法:
- 第一种办法是,对于不存在的数据,也同样设置一个特殊的value。比如当查询数据库时发现数据为空,就设置-1保存到缓存中,这样下次请求就可以命中缓存了。如果每次请求的key都是随机数,就会导致缓存中有大量无效的key。
- 第二种办法:布隆过滤器。
如果业务规则比较明确的话,可以直接判断请求参数是否合法。
其实这两种方法可以一起用,把布隆过滤器作为前置过滤,对于少量的误判再保存特殊值到缓存。
⭐布隆过滤器(BloomFilter)
布隆过滤器是一种概率性的数据结构,由一个数组和一系列随机映射函数组成,可以用来快速判断某个数据是否存在。
它的原理是:
当我们想标记某个数据的时候,布隆过滤器首先会使用多个哈希函数,分别计算这个数据的哈希值,可以得到多个哈希值。
然后把这些哈希值和数组长度取模,可以得到每个哈希值在数组中对应的位置。
然后把数组中的位置设置为1。
判断某个数据是否存在的时候,就检查这个数据在数组中对应的多个位置,只要有一个位置是0,就说明数据肯定不存在。
布隆过滤器不支持删除,因为多个数据通过哈希函数换算出来的下标可能是一样的,删除某一个key的数据可能会导致其他数据一起被删除。
布隆过滤器还有一个问题就是会误判,因为它本身是一个数组,数组的长度是有限制的,可能会有多个值换算出来的点是同一个位置。所以数组的长度越大,误判的概率就会越低。
可以使用Google的Guava工具包提供的BloomFilter类。
1 | private BloomFilter<Integer> bloomFilter; |
⭐热key问题
- 热key的问题就是,突然大量的请求去访问redis上某些特定的key,这样可能会造成流量过于集中,导致这台redis服务宕机引发雪崩。
- 解决方案:
- 提前把热key打散到不同的服务器,降低压力。
- 加入二级缓存,提前把热key数据从数据库加载到内存中,如果redis宕机,就直接内存查询。
⭐排查Redis性能问题
如果发现业务响应时间变长,首先需要定位到究竟是哪个环节慢,比较高效的做法是集成链路追踪,在服务访问外部资源的地方,记录下时间。
假如发现操作Redis耗时变长了,就只需要关注Redis这条链路上。
操作Redis耗时变长的原因主要有2个:
- 业务服务和Redis之间网络存在问题,比如网速太慢,存在延迟、丢包等问题。
- Redis本身出现问题,需要进一步排查是具体原因。
通常第一种情况发生的概率比较小,如果网络出现问题,这台服务器上的所有服务都会发生网络延迟的情况。
可以通过基准测试判断Redis是不是真的变慢了:
redis-cli -h 127.0.0.1 -p 6379 --intrinsic-latency 60
这个命令可以测试出60秒内最大响应延迟。
查看慢日志:
Redis提供了慢日志,用来记录哪些命令执行耗时比较久。
1
2
3
4 # 执行时间超过5毫秒,记录为慢日志
CONFIG SET slowlog-log-slower-than 5000
# 只保留最近500条慢日志
CONFIG SET slowlog-max-len 500
可以通过SLOWLOG get 5
命令查看具体的慢日志,分析哪些命令比较耗时。
如果有SET / DEL这种简单的命令出现在慢日志中,就有可能是bigkey导致的。
可以通过redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.01
命令扫描出bigkey的分布情况。
如果写入的value非常大,Redis在分配内存的时候就会比较耗时,删除这个key也会比较耗时。所以应该避免写入bigkey。
如果使用的是Redis4.0以上的版本,可以用UNLINK命令代替DEL。
UNLINK命令可以把释放内存的操作放到后台线程去执行。
集中过期:
如果是在某个时间点突然出现一波延时,可以排查一下是否存在大量key集中过期的情况。
Redis的过期策略是主动删除+被动删除,主动删除是在主线程中执行的,Redis必须要把所有key都删除掉才能正常处理请求,如果要删除的key有很多、或者要删除的key很大,耗时就会很长。Redis的响应时间也会变长。
解决办法是给过期时间增加一个随机数,把集中过期的时间点打散。
内存达到上限:
当Redis使用的内存达到上限之后,每次写入新数据的时间会变长,这是因为Redis内存用完了之后,会触发淘汰策略,删除掉一部分数据之后,才能把新数据写进来。
淘汰数据消耗的时间主要取决于配置的淘汰策略,一般比较常用的是 allkeys-lru / volatile-lru,如果存储的key比较大,淘汰数据的耗时也会比较久。
解决办法:
- 把淘汰策略改为随机淘汰,随机淘汰比LRU算法快很多,不过随机淘汰可能会把很热的key淘汰掉也会有一些别的问题。
- 避免存储大key。
- 增加服务器内存。
⭐应用场景
⭐分布式锁
加锁时,用Redis的setnx key value
命令,这个命令的逻辑是这样的:当key不存在时,则设置key和value,并返回1,如果key已经存在了,直接返回0.
如果客户端加完锁之后就掉线了,那其它节点就再也获取不到锁了,所以还要通过set expire
命令设置过期时间。
需要注意的是,如果客户端执行完setnx
命令之后,还没来得及设置过期时间就掉线了,同样会产生死锁,所以需要Redis 2.6.12版本之后拓展了SET命令的参数:SET lock 1 EX 10 NX
。
锁释放错误:
如果代码执行时间超过了锁过期时间,就会导致锁提前释放,然后被其它节点持有,代码执行完后释放锁,这个时候释放的是其它节点的锁。
- 节点A加锁成功,开始执行代码
- 节点A代码执行时间超过了锁的过期时间,锁被自动释放
- 节点B加锁成功,开始执行代码
- 节点A代码执行完毕,释放锁(释放的是节点B的锁)
解决办法是:加锁时将锁对应的value设置为当前节点的ID,释放锁时判断这把锁是否是自己加的。
Redission就有自动续期的方案来避免锁过期,逻辑是是启动一个守护线程定时检测锁的失效时间,如果锁快过期了,代码还没执行完,就自动对锁进行续期。
主从切换问题:
因为Redis主从同步数据是异步的,有可能出现这样的情况:
- 节点A在主库上执行SET命令加锁成功
- 主库还没来得及把SET命令同步给从库,就异常下线了
- 从库被选举为新的主库,这个锁在新的主库上就丢了
Redis的作者提供了一个解决方案:Redlock。Redlock方案有2个前提:
- 不需要部署从库和哨兵,只部署主库。
- 主库需要部署多个,官方推荐至少是5个(这些主库之间没有任何关系)。
加锁的流程是这样的:
- 客户端先获取当前时间戳T1
- 客户端依次向这5个Redis实例发起加锁请求,并且每个请求都设置超时时间,如果加锁失败就向下一个Redis实例申请加锁。
- 如果客户端在半数以上的Redis实例上加锁成功,则再次获取时间戳T2,如果T2 - T1 < 锁过期时间,则认为加锁成功,否则加锁失败。
- 如果加锁失败,需要向所有节点发起释放锁的请求。
⭐zset深度分页
- 将数据库中的主键保存到Zset集合中,假设需要按照创建时间排序,就把创建时间转换为时间戳作为zscore用于排序。
- 分页查询时通过
zrange key start stop
命令获取目标记录的主键,然后根据主键回表。 - 新增数据时使用
Zadd
命令将主键加入缓存。 - 删除数据时使用
zrem
命令删除缓存。
⭐Redis 6.0新特性
⭐多线程模型
Redis 6.0版本采用了多个IO线程来处理网络请求,但是对于读写命令,Redis还是采用单线程来处理。
具体的流程是这样的:
- 首先客户端和主线程建立Socket连接后,Redis会把Socket放到一个等待队列中。
- 然后通过轮询的方式把Socket连接分配给IO线程,主线程会进入等待状态。
- 等IO线程解析完请求后,主线程会以单线程的方式执行这些命令。
- 主线程执行完命令后,会把结果写入到缓冲区。
- 最后IO线程会把结果返回给客户端。
多线程机制默认是关闭的,可以在redis.conf配置文件中配置:
io-threads-do-reads
:设置为yes表示开启多线程。io-threads
:设置线程数量,线程数量要小于CPU核心数。官方给的建议是6个线程。
⭐客户端缓存
客户端缓存就是Redis客户端可以把,读取到的数据缓存在本地,这样就相当于每个客户端都多了一个本地缓存,数据没有发生变化时直接读取本地缓存就能拿到数据,可以节省网络带宽,降低Redis的请求压力。
如果数据被修改或者是失效了,Redis提供了两种方式来通知客户端做缓存失效:
普通模式:Redis会记录客户端读取过的key,并监控key是否发生变化。如果key发生变化,Redis就会给客户端发送invalidate
消息,让客户端做缓存失效。
如果一个key被连续修改两次,Redis只会通知客户端一次,只有客户端再次读取这个key之后,Redis才会再发送缓存失效消息。
1 | CLIENT TRACKING ON|OFF |
广播模式:客户端可以注册要缓存的key的前缀,当这些前缀的key被修改时,Redis就会把缓存失效的消息发送给客户端。
1 | CLIENT TRACKING ON BCAST PREFIX keyPrefix |
权限控制
Redis 6.0版本支持创建不同用户来访问Redis。可以使用ACL SETUSER
命令创建用户:
1 | ACL SETUSER zhangsan on > 123456 |
6.0版本还增加了以用户为单位的命令访问权限:
- +:给用户添加可执行的命令。
- -:减少用户可执行的命令。
- +@:把一类命令分配给用户执行。
- -@:把一类命令禁止用户执行。
- +@all:允许用户执行所有命令。
- -@all:禁止用户执行所有命令。
假设要让用户zhangsan只能调用String类型的命令,不能调用Hash类型的命令:
1 | ACL SETUSER zhangsan +@string -@hash |
Redis 6.0版本还支持以key位粒度设置访问权限。可以用波浪号 ”~“和key的前缀对key进行权限控制。
假设用户zhangsan只能对以 ”user“为前缀的key进行操作:
1 | ACL SETUSER zhangsan ~user* +@all |