导图社区 Redis
内部机制,数据类型,集群模式,持久化方式。常见问题,主从复制流程,删除淘汰策略,jave客户端内容点梳理。
编辑于2022-07-08 12:26:52redis
内部机制
内部结构
Redis中所有的数据都会被加载至内存中(类ZK) 一个Redis实例默认支持16个DB,默认使用0号DB
每个DB都对应一个redisDB结构: 主dict用来存储当前DB中的所有数据 expires字典存储key和过期时间的映射
dict字典中,有一个长度为2的哈希表数组,日常访问0号哈希表,若0号哈希表数据过多,则会触发重哈希,分配一个2倍0号哈希表大小的空间给1号哈希表,然后逐步迁移数据
dictht(dict hashtable)中的table是一个hash表数组,每个元素指向一个dictEntry结构。dictht是以dictEntry来存key-value映射的,其中key是sds字符串,value存储各种数据类型的redisObject结构
可见,dictht结构类似Java Hashtable
table属性是一个数组,数组中的每一个元素都是一个指向dictEntry结构的指针,每个dictEntry结构保存着一个键值对。size属性记录了哈希表的大小,也即table数组的大小。而used属性则记录了哈希表实际节点(键值对)的数量(故used完全可大于size的大小)。sizemask属性的值总是等于size-1,该属性和哈希值一起决定一个键应该被放到table数组的哪个索引上
dictEntry的next属性是指向另一个哈希表节点的指针,该指针将多个哈希值相同的键值对连接在一起,以此来解决键冲突的问题
dict中type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数(Redis会为用途不同的字典设置不同类型的特定函数)。而privdata属性则保存了需要传给那些特定类型函数的可选参数。ht属性是一个包含两个项的数组,数组中的每一项都是一个dictht哈希表,一般情况下字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。除了ht[1]外,另一个和rehash有关的属性就是rehashidx,它记录了rehash目前的进度,若目前没有在进行rehash则其值为-1
Redis是一个Key-Value结构的数据库系统,key一般为字符串,可用sds表示;而value有多种类型,这里统一用redisObject结构表示,redisObject结构中会存储当前对象的数据类型及内部存储结构等信息
type:表示当前对象的数据类型,如string、list、set、zset、hash encoding:表示当前对象的编码,即内部、底层数据结构,如ziplist、skiplist ptr:指向对象真实的内部数据结构
数据编码 (内部结构)
每种数据类型的底层由一种或多种数据结构实现
dict
dict用途: 1.用于维护key和value映射关系的数据结构,与Java中的Map类似。Redis中所有的key到value的映射就是使用一个dict来维护的 2.作为一些数据类型的底层存储结构,如Hash类型数据当它的field较多时,会使用dict来存储
dict本质上是为了解决算法中的查找问题,一般查找问题的解决方法分为两类:基于各种平衡树或基于哈希表。平衡树实现较复杂,哈希表则更简单些,故很多查找方法都使用的哈希算法。哈希表的查找性能能做到非常高效,接近O(1)(若没有哈希碰撞)
dict也是一个基于哈希表的算法,它对key使用哈希函数计算出其在哈希表中的位置,若key出现hash冲突则采用拉链法(单向链表),并在装载因子超过预定值时自动扩展内存,引发重哈希。Redis的重哈希会将操作分散到对于dict的各个增删改查操作中去,这样可做到每次只对一小部分key进行重哈希,从而避免对所有key进行重哈希导致期间请求的响应时间剧烈增加
rehash
负载因子 = ht[0].used / ht[0].size
为了避免一次性、集中式的rehash操作的庞大计算量可能会导致服务器在一段时间内停止服务,redis采用分多次、渐进式地完成,将rehash键值对所需的计算工作均摊到每一个增删改操作上
sds
字符串是Redis中最常见的数据类型,其底层实现是sds,其本质上是一个char*,内部通过sdshdr进行管理
len:字符串实际长度 alloc:当前字节数组分配的内存大小 buf:存储字符串真正的值
sds相对C语言中字符串处理的优势
获取字符串长度时间复杂度O(1)
sdshdr用一个len字段记录了当前字符串的长度,C语言获取字符串长度的时间复杂度为O(n),空间换时间
空间预分配
C语言中修改字符串时会重新分配内存,修改地越频繁,内存分配也就越频繁,而内存分配会消耗性能。sds 在修改及空间扩充时,除了分配所必需的空间外,还会额外分配一段空间备用;sds 缩短时,并不会回收多余的内存空间,而是使用 free字段记录下来,后续有变更操作时直接使用free中记录的空间,减少了内存的分配
二进制安全
C中的字符串遇到'\0'时会作为结束标志,之后的数据不再读取,而sds根据len来读取字符串内容
Redis中可存储各种数据类型,甚至包括图片、视频、音频、压缩包等二进制数据,而C中的字符串无法存储二进制数据
ziplist
为了节约内存(Redis所有数据都放内存,故数据结构必须精简),并减少内存碎片,Redis设计了ziplist(压缩列表)内部数据结构。压缩列表是一块连续的内存空间,可以连续存储多个元素,没有冗余空间,是一种连续内存数据块组成的顺序性内存结构
由于ziplist是连续紧凑存储,没有冗余空间,所以插入新的元素需要realloc扩展内存,所以如果ziplist占用空间太大,realloc重新分配内存和拷贝的开销就会很大,所以ziplist不适合存储过多的元素和过大的字符串。因此只有元素个数和value都不大的时候,ziplist才作为hash和zset的内部数据结构
previous_entry_length:记录了压缩表中前一个节点的长度 encoding:记录了节点的content属性所保存数据的类型及长度 content:节点的content属性负责保存节点的值
压缩列表的从表尾向表头遍历操作就是使用这一原理实现的,只要我们拥有了一个指向某个节点起始地址的指针,那么通过这个指针以及这个节点的previous_entry_length属性,程序就可以一直向前一个节点回溯,最终到达压缩列表的表头节点
quicklist
quicklist是一个基于ziplist的双向链表,将数据分段存储到ziplist中,然后将这些ziplist用双向指针连接
head/tail:指向第一个/最后一个 ziplist 节点的指针 count:quicklist 中所有的元素个数 len:ziplist 节点的个数 compress:LZF 算法的压缩深度
快速列表中,管理ziplist的是quicklistNode结构。quicklistNode主要包含一个 prev/next 双向指针,以及一个 ziplist 节点。单个 ziplist 节点可存放多个元素
双端链表查找的时间复杂度较低
zskiplist
目前常用的Key-Value数据结构有3种: 【Hash表】 时间复杂度:O(1);哈希冲突后可使用链表,无锁 【红黑树】 时间复杂度:O(logN)(N较小);一般需加锁;数据天然有序 【SkipList】 时间复杂度:O(logN)(N较大);底层为链表,无锁;数据天然有序
大部分情况下,跳跃表的效率可以和平衡树媲美,但跳跃表的实现比平衡树更简单,故不少程序使用跳跃表代替平衡树
跳跃表是一种分层有序数据结构,查找效率很高
若zset类型数据的元素个数比较多或值比较大,Redis将会选择跳跃表作为zset的内部数据结构
跳跃表主要由 zskipList 和 zskiplistNode 构成。 【zskipList】 header:指向跳跃表的表头节点 tail:指向跳跃表的表尾节点 length:跳跃表的长度,它是跳跃表中不包含表头节点的节点数量 level:目前跳跃表内,除表头节点外的所有节点中层数最大节点的层数 【zskiplistNode】 ele:节点对应的 sds 值,在 zset 有序集合中就是集合中的 field 元素 score:节点的分数,通过 score,跳跃表中的节点自小到大依次排列 backward:指向当前节点的前一个节点的指针 level:节点中的层
图中跳跃表有3个节点,对应的元素值分别是S1、S2和S3,分数值依次为 1.0、3.0 和 5.0。其中 S3 节点的 level 最大是 5,故跳跃表的level是 5。header 指向表头节点,tail 指向表尾节点。跳跃表中,元素必须是唯一的,但 score 可以相同。相同 score 的不同元素,按照字典序进行排序
单Reactor模型 (I/O多路复用模型)
数据类型
String
概念
最基本的类型,一个key对应一个value
value可以是String、数字,也可以是图片或序列化的二进制对象
常用命令
set/get
setnx
分布式锁
setex
相当于set+expire,原子操作
mset/mget
mset原子操作(所有key均set)
incr/decr
计数、秒杀、限流
使用场景
计数:粉丝数、关注数
分布式锁
秒杀
限流
List
概念
双向链表
按插入顺序排序
常用命令
lpush/rpush/lpop/rpop
linsert/lrem/lset/lindex/llen
使用场景
最新消息排行(如最新评论)
简单轻量级消息队列
Hash
概念
一个或多个键值对集合
常用命令
hset/hget/hmset/hmget
hset user name "Tom" hget user name hmset user name "Tom" sex "male" hmget user name sex
hdel/hexists/hlen
使用场景
存取/修改对象部分属性
精简数据;不用序列化&反序列化;不用控制并发 不要整存整取,可能会比较耗时而影响其它操作,此时可序列化存储对象
Set
概念
String类型的唯一、无序集合
内部实现是一个value永远为null的HashMap
常用命令
sadd/srem/sismember/smembers
sinter/sunion/sdiff
交集/并集/差集
使用场景
共同好友/关注(交集)
统计网站访问ip(唯一性)
tag交集大于阈值即推送好友/视频
ZSet
概念
String类型的唯一、有序、有权重集合
使用HashMap和SkipList实现的 HashMap里存放成员到Score的映射,SkipList里存放所有的成员 依据Score排序,使用SkipList获得较高的查询效率
常用命令
zadd/zrem/zcount/zrange
zscore/zrank
zunionstore/zinterstore
使用场景
关注列表/粉丝列表
排行榜
优先级队列
其它
HyperLogLog
概念
基数统计
在我们为网站统计访问量、日活量时,由于我们统计的是用户数量而非访问次数,因此即使一个用户多次访问也只会统计一次,这种不重复的数据通常被称为基数。 在传统的做法中,我们通常会采用set来保存用户的ID来进行计数,因为其本身存在着去重的功能,但是由于我们所需要的是对用户进行计数,如果通过将所有用户的ID保存的方法来完成,当用户量大的时候就会对内存产生巨大的压力,并且效率也大大降低。 为了解决这个问题,Redis在2.8.9版本添加了HyperLogLog结构。
使用场景
统计网站UV
Geospatial
概念
存取地理空间
在使用一些小程序的时候,里面通常都会通过定位使用者的位置,来显示附近的人、外卖距离、剩余路径等功能,在Redis3.2中也引入了推算地理信息的数据结构,即Geospatial
使用场景
获取附近的人
获取外卖距离
获取剩余距离
Bitmap
概念
位图
位图其实就是哈希的变形,它通过哈希映射来处理数据,位图本身并不存储数据,而是存储标记。通过一个比特位,即0/1来标记一个数据的两种状态 位图通常情况下用在数据量庞大,且数据不重复的情景下标记某个数据的两种状态。 我们可以使用位图来记录当前用户的登录情况,或者实现打卡、签到等功能。
使用场景
记录用户登录状态
实现打卡/签到
集群模式
主从模式
优点
主从高可用
读写分离
主可读写,从只读
扩QPS很方便,增加Slave即可
缺点
主节点单点
主节点有写压力
需手动主从切换
存储受单机所限
哨兵模式
在主从复制的基础上,哨兵模式实现了自动化故障恢复
Sentinel 每隔1秒通过心跳来检测主服务器和从服务器运作是否正常,当主服务器不能正常工作时, Sentinel 会进行故障恢复操作
由两部分节点组成,哨兵节点和数据节点: 哨兵节点:特殊的Redis节点,不存储数据 数据节点:主节点和从节点都是数据节点
概念
主观下线(SDOWN)
单个 Sentinel 实例对服务器做出的下线判断,即单个 Sentinel 认为某个服务已下线
有可能是接收不到订阅,之间的网络不通等原因
若服务器在给定时间内未正确回复 Sentinel 的 PING 命令, 那么Sentinel会将这个服务器标记为主观下线
客观下线(ODOWN)
多个 Sentinel 实例对同一个服务器做出 SDOWN 判断并交流之后得出的服务器下线判断,然后开启failover
只有足够数量的 Sentinel都将一个服务器标记为主观下线之后, 服务器才会被标记为客观下线。只有当 Master 被认定为客观下线时,才会发生故障迁移
仲裁
某个 Sentinel 先将 Master 节点标记为主观下线,然后询问其他Sentinel节点是否也认为 Master 节点要主观下线,最后当达成这一共识的 Sentinel 个数达到 quorum 设置的值时,该 Master 节点会被认定为客观下线并进行故障转移
quorum 的值一般设置为 Sentinel 个数的二分之一加 1,例如 3 个 Sentinel 就设置为 2
工作过程
1.每个 Sentinel 每隔一秒向 Master、Slave 及其它 Sentinel 节点发送 PING 命令 2.如果一个实例距离最后一次有效回复 PING 命令的时间超过了阈值,则这个实例会被 Sentinel 标记为主观下线; 3.如果一个 Master 被标记为主观下线,那么所有 Sentinel 会以每秒一次的频率确认 Master 是否真的进入主观下线状态; 4.当有足够数量的 Sentinel 在指定的时间内确认 Master 的确进入了主观下线状态,则 Master 会被标记为客观下线; 5.如果 Master 处于 ODOWN 状态,则投票自动选出新的主节点。将剩余的从节点指向新的主节点继续进行数据复制; 6.正常情况下,每个 Sentinel 会每隔 10 秒向所有 Master、Slave 发送 INFO 命令;当 Master 被 Sentinel 标记为客观下线时,Sentinel 发送 INFO 命令的频率会从 10 秒一次改为 1 秒一次; 7.若没有足够数量的 Sentinel 同意 Master 已主观下线,Master 的客观下线状态就会被移除;若 Master 重新返回有效回复,Master 的主观下线状态也会被移除
优缺点
优点
基于主从模式,具有主从模式的所有优点
主从可自动切换,可用性高
缺点
基于主从模式,有主从模式需手动主从切换外的所有缺点
主从切换需要时间,会丢失数据
在线动态扩容复杂
Cluster模式
哨兵模式中,单个节点的写能力、存储能力受到单机的限制,动态扩容困难复杂。于是,Redis 3.0 推出 Redis Cluster 集群模式,有效解决了 Redis 分布式方面的需求。Redis Cluster 集群模式具有高可用、可扩展、分布式、集群容错等特性
分布式系统中,一般通过副本实现高可用,通过分片实现分布式
Redis Cluster 采用无中心化结构,每个节点都可保存数据和整个集群的状态,每个节点都和其它所有节点连接。Cluster一般由多个节点组成,节点数量至少为 6 个才能组成完整高可用的集群,其中3个为主节点,3个为从节点。3个主节点会分配槽并处理客户端的请求,而从节点可用于主节点故障后顶替主节点
除了主从Redis节点之间进行数据复制外,所有 Redis 节点之间采用 Gossip 协议进行通信,交换维护节点元数据信息
概念
分片/哈希槽
Redis Cluster 采用虚拟哈希槽分区,所有的键根据哈希函数映射到 0 ~ 16383 整数槽内,计算公式: HASH_SLOT = CRC16(key) % 16384 每个节点负责维护部分槽以及槽所映射的键值数据
优缺点
优点
无中心架构
数据分片,突破单机限制
单机、主从、哨兵模式,数据都存储在一个节点上,其它节点进行数据复制。而单个节点存储是存在上限的,集群模式就是把数据进行分片存储,当一个分片数据达到上限时,可分成多个分片
可在线动态扩缩容
扩缩容后,槽需要重新分配,数据也需要重新迁移,但服务不需要下线
高可用,故障自动failover
节点之间通过gossip协议交换状态信息,使用投票机制完成 Slave 到 Master的角色提升
在创建集群时,一定要为每个主节点添加对应的从节点,否则主节点挂了之后该部分槽数据无从恢复
通过分片实现水平扩容,通过主从备份实现高可用
缺点
数据通过异步复制,无法保证数据强一致性
GossIp协议通过类似病毒传播机制实现数据的最终一致性
集群环境部署复杂
基于Docker部署会相对简单
内部机制
GossIp
Redis 集群节点间使用 gossip协议进行通信,在 redis cluster 架构下,每个 redis要开放两个端口,若一个是 6379,则另一个就是 加10000 的端口号 16379,16379 端口用来进行节点间通信。为了让集群中的每个实例都知道其它所有实例的状态信息,Redis 集群规定各实例之间按照 Gossip 协议进行通信传递信息
通过左图这些消息,集群中的每一个实例都能获得其它所有实例的状态信息。这样当有新节点加入、节点故障、Slot 变更等事件发生,实例间也可通过 PING、PONG 消息的传递,完成集群状态在每个实例上的同步
定时ping/pong消息
Redis Cluster 中的节点都会定时向其它节点发送 PING 消息来交换各个节点的状态信息,如在线状态、疑似下线状态 PFAIL 和已下线状态 FAIL
1. 每个实例之间会按照一定的频率,从集群中随机挑选一些实例,向其发送PING消息,用来检测这些实例是否在线,并交换彼此的状态信息。PING 消息中封装了自身的状态信息、部分其它实例的状态信息,以及 Slot 映射表 2. Redis实例在接收到 PING 消息后,会给对方响应一个 PONG 消息。PONG 消息包含的内容和 PING 消息一样
新节点上线
Redis Cluster 加入新节点时,客户端需要执行 CLUSTER MEET 命令
1. 节点一会根据据 CLUSTER MEET 命令中的 IP 地址和端口号,向新节点发送一条 MEET 消息 2. 新节点向节点一返回一条PONG消息。节点一接收到新节点返回的PONG消息后,得知新节点已经成功地接收了自己发送的MEET消息 3. 节点一向新节点发送一条 PING 消息。新节点接收到该条 PING 消息后,可知节点一已经成功地接收到了自己返回的PONG消息,从而完成了新节点接入的握手操作 4. MEET 操作成功之后,节点一会通过定时 PING 机制将新节点的信息发送给集群中的其他节点,让其它节点也与新节点进行握手,最终经过一段时间后,新节点会被集群中的所有节点认识
客观下线&主观下线
Redis Cluster 中的节点会定期检查已经发送 PING 消息的接收方节点是否在规定时间内返回了 PONG消息,若没有则会将其标记为疑似下线状态,也就是 PFAIL 状态
节点一通过 PING 消息将节点二处于疑似下线状态的信息传播给其它节点,如节点三。节点三接收到节点一的 PING 消息后,会在自己维护的集群状态中将节点一对节点二的状态判断标记为已下线
随着时间的推移,若比如节点十也因为 PONG 超时认为节点二疑似下线了,并发现自己维护的集群状态中有半数以上的节点将节点二标记为了 PFAIL 状态,那么节点十会将节点二标记为已下线FAIL状态,并立刻向集群其它节点广播节点二已下线的 FAIL 消息,所有收到 FAIL 消息的节点都会立即将节点二标记为已下线状态
疑似下线报告是有时效性的,若超过 cluster-node-timeout*2 的时间,这个报告就会被忽略掉,让节点二又恢复成正常状态
持久化方式
RDB
定义
定期为整个内存数据生成快照
物理日志(dump.rdb),二进制文件
save 60 1000 如上设置后,会让Redis在满足"60秒内至少有1000个key被改动"条件时自动保存一次数据集
Redis默认使用该模式
通过备份快照来恢复数据
优点
速度比AOF更快
不需要每秒fsync,只需定期fork出子进程进行备份
文件体积比AOF更小
备份和恢复更快
RDB是一个非常紧凑的文件,它保存了Redis在某个时间点的数据集。RDB是物理日志,AOF是逻辑日志,故 RDB 文件更小,备份和恢复速度都更快
备份一般不影响主进程
保存RDB文件时会fork出一个子进程,该子进程会完成所有的保存工作,父进程无需执行任何磁盘IO操作
缺点
会丢失更多数据
一旦发生故障停机,会丢失上次RDB后的所有数据变更
备份可能影响主进程
若数据量巨大,fork子进程可能会耗费大量CPU,导致服务器在短时间内无法及时响应客户端请求
AOF
定义
记录所有写操作命令
逻辑日志,文本文件
appendonly yes 可通过修改配置文件来开启AOF
通过重新执行命令来恢复数据
新命令顺序追加到日志文件末尾
优点
丢失数据少
AOF默认每秒fsync一次,故即使发生故障停机,最多只丢失1秒的数据
fsync策略可配置: 不fsync:最快,但最不安全 每次fsync:最慢,但最安全 每秒fsync:兼顾性能和安全
fsync是在后台执行的,故即使每秒一次fsync,Redis仍可保持良好的性能
日志读写性能高
AOF是顺序追加的,故读写性能都很高
日志文件会自动精简
当AOF变得很大时,会在后台自动触发对AOF进行重写。 重写后的AOF文件包含了恢复当前数据所需的最小命令集合(对文件进行精简)。 重写操作是安全的,因为Redis在创建新AOF的过程中,会继续将命令追加到现有的AOF中 一旦新的AOF文件创建完毕,Redis就会从旧AOF切换到新AOF,并开始追加写新AOF文件
最小命令集: 举个例子, 对某个key调用了100次 INCR , 那么仅仅为了保存该计数器的值, AOF 文件就需使用 100 条记录。而实际上只需使用一条 SET key value命令就可保存该key的值了, 其余 99条记录实际上是多余的。为了处理这种情况, Redis 可以在不打断服务客户端的情况下, 对 AOF 文件进行重建
AOF过程:(Copy-On-Write) 1.fork出子进程 2.子进程开始AOF重写,主进程一边将写入命令缓存到内存中,一边继续追写原有AOF文件 3.子进程重写完毕后,主进程将内存中缓存的所有写入命令追加到新的AOF文件末尾后开始追写新的AOF文件
缺点
速度更慢
虽然每秒 fsync 的性能依然非常高,但要稍慢于RDB
体积更大
对于相同的数据集来说,AOF 文件的体积通常要大于 RDB 文件的体积
RDB or AOF
可同时RDB&AOF,则重启时优先使用AOF
AOF丢失数据更少
若注重性能&安全,则RDB&AOF 若更注重性能,则RDB(默认) 不建议单独使用AOF
Redis 4.0之后新增了混合持久化方式,结合了 RDB和AOF的优点:先RDB全量备份,之后的写命令追加到AOF。这样既可保证文件大小、重启速度,又能减少数据丢失。这也是Redis5.0后的默认方式
可在线切换
java客户端
Jedis
优点
老牌,API全面
缺点
同步阻塞I/O,性能差
线程不安全,需使用连接池
一个连接只能被一个线程使用,但可结合连接池来提升其性能
lettuce
优点
支持同步&异步&响应式
线程安全
多个线程可共享同一个连接。但也提供了线程池,供事务(MULTI/EXEC)及阻塞(BLPOP)操作命令使用
支持哨兵、集群、管道、自动重连等高级特性
缺点
不支持分布式锁等分布式功能
Redisson
优点
提供了一些常用的分布式操作
JUC下并发操作基本都有对应的API
支持同步&异步&响应式
线程安全
多个线程可共享同一个连接。但也提供了线程池,供事务(MULTI/EXEC)及阻塞(BLPOP)操作命令使用
提供了丰富的数据结构
缺点
功能简单(string支持差,不支持事务、管道、分区等Redis特性)
选型
尽量不用jedis
若不需要使用分布式的高级特性,推荐lettuce
Spring Boot2默认
若需要使用分布式的高级特性,推荐Redisson+lettuce
常见问题
分布式锁
redisson分布式锁
简单易用,支持: 锁重入 阻塞等待 Lua脚本原子操作 WatchDog自动续期
用法示例
// 1.构造redisson实现分布式锁必要的Config Config config = new Config(); config.useSingleServer().setAddress("redis://127.0.0.1:5379").setPassword("123456").setDatabase(0); // 2.构造RedissonClient RedissonClient redissonClient = Redisson.create(config); // 3.获取锁对象实例(无法保证是按线程的顺序获取到) RLock rLock = redissonClient.getLock(lockKey); try { /** * 4.尝试获取锁 * waitTimeout 尝试获取锁的最大等待时间,超过这个值,则认为获取锁失败 * leaseTime 锁的持有时间,超过这个时间锁会自动失效(值应设置为大于业务处理的时间,确保在锁有效期内业务能处理完) */ boolean res = rLock.tryLock((long)waitTimeout, (long)leaseTime, TimeUnit.SECONDS); if (res) { //成功获得锁,在这里处理业务 } } catch (Exception e) { throw new RuntimeException("aquire lock fail"); }finally{ //无论如何, 最后都要解锁 rLock.unlock(); }
redisson底层依赖Lua脚本和Netty(各种Future/FutureListener的异步/同步操作转换),加解锁过程利用了Redis的发布订阅功能
Lua脚本
加锁Lua脚本
 -- 若锁不存在:则新增锁,并设置锁重入计数为1、设置锁过期时间 if (redis.call('exists', KEYS[1]) == 0) then redis.call('hset', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; -- 若锁存在,且唯一标识也匹配:则表明当前加锁请求为锁重入请求,故锁重入计数+1,并再次设置锁过期时间 if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; -- 若锁存在,但唯一标识不匹配:表明锁是被其他线程占用,当前线程无权解他人的锁,直接返回锁剩余过期时间 return redis.call('pttl', KEYS[1]);
解锁Lua脚本
 -- 若锁不存在:则直接广播解锁消息,并返回1 if (redis.call('exists', KEYS[1]) == 0) then redis.call('publish', KEYS[2], ARGV[1]); return 1; end; -- 若锁存在,但唯一标识不匹配:则表明锁被其他线程占用,当前线程不允许解锁其他线程持有的锁 if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then return nil; end; -- 若锁存在,且唯一标识匹配:则先将锁重入计数减1 local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); if (counter > 0) then -- 锁重入计数减1后还大于0:表明当前线程持有的锁还有重入,不能进行锁删除操作,但可以友好地帮忙设置下过期时期 redis.call('pexpire', KEYS[1], ARGV[2]); return 0; else -- 锁重入计数已为0:间接表明锁已释放了。直接删除掉锁,并广播解锁消息,去唤醒那些争抢过锁但还处于阻塞中的线程 redis.call('del', KEYS[1]); redis.call('publish', KEYS[2], ARGV[1]); return 1; end; return nil;
源码解读
加锁流程
@Override public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException { // 获取锁能容忍的最大等待时长 long time = unit.toMillis(waitTime); long current = System.currentTimeMillis(); final long threadId = Thread.currentThread().getId(); // 【核心点1】尝试获取锁,若返回值为null,则表示已获取到锁 Long ttl = tryAcquire(leaseTime, unit, threadId); // lock acquired if (ttl == null) { return true; } // 还可以容忍的等待时长=获取锁能容忍的最大等待时长 - 执行完上述操作流逝的时间 time -= (System.currentTimeMillis() - current); if (time <= 0) { acquireFailed(threadId); return false; } current = System.currentTimeMillis(); // 【核心点2】订阅解锁消息,见org.redisson.pubsub.LockPubSub#onMessage /** * 4.订阅锁释放事件,并通过await方法阻塞等待锁释放,有效的解决了无效的锁申请浪费资源的问题: * 基于信息量,当锁被其它资源占用时,当前线程通过 Redis 的 channel 订阅锁的释放事件,一旦锁释放会发消息通知待等待的线程进行竞争 * 当 this.await返回false,说明等待时间已经超出获取锁最大等待时间,取消订阅并返回获取锁失败 * 当 this.await返回true,进入循环尝试获取锁 */ final RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId); //await 方法内部是用CountDownLatch来实现阻塞,获取subscribe异步执行的结果(应用了Netty 的 Future) if (!await(subscribeFuture, time, TimeUnit.MILLISECONDS)) { if (!subscribeFuture.cancel(false)) { subscribeFuture.addListener(new FutureListener<RedissonLockEntry>() { @Override public void operationComplete(Future<RedissonLockEntry> future) throws Exception { if (subscribeFuture.isSuccess()) { unsubscribe(subscribeFuture, threadId); } } }); } acquireFailed(threadId); return false; } // 订阅成功 try { // 还可以容忍的等待时长=获取锁能容忍的最大等待时长 - 执行完上述操作流逝的时间 time -= (System.currentTimeMillis() - current); if (time <= 0) { // 超出可容忍的等待时长,直接返回获取锁失败 acquireFailed(threadId); return false; } while (true) { long currentTime = System.currentTimeMillis(); // 尝试获取锁;如果锁被其他线程占用,就返回锁剩余过期时间【同上】 ttl = tryAcquire(leaseTime, unit, threadId); // lock acquired if (ttl == null) { return true; } time -= (System.currentTimeMillis() - currentTime); if (time <= 0) { acquireFailed(threadId); return false; } // waiting for message currentTime = System.currentTimeMillis(); // 【核心点3】根据锁TTL,调整阻塞等待时长; // 注意:这里实现非常巧妙: //1、latch其实是个信号量Semaphore,调用其tryAcquire方法会让当前线程阻塞一段时间,避免了在while循环中频繁请求获取锁; //2、该Semaphore的release方法,会在订阅解锁消息的监听器消息处理方法org.redisson.pubsub.LockPubSub#onMessage调用;当其他线程释放了占用的锁,会广播解锁消息,监听器接收解锁消息,并释放信号量,最终会唤醒阻塞在这里的线程。 if (ttl >= 0 && ttl < time) { getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS); } else { getEntry(threadId).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS); } time -= (System.currentTimeMillis() - currentTime); if (time <= 0) { acquireFailed(threadId); return false; } } } finally { // 取消解锁消息的订阅 unsubscribe(subscribeFuture, threadId); } } private Long tryAcquire(long leaseTime, TimeUnit unit, long threadId) { // tryAcquireAsync异步执行Lua脚本,get方法同步获取返回结果 return get(tryAcquireAsync(leaseTime, unit, threadId)); } // 见org.redisson.RedissonLock#tryAcquireAsync private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, final long threadId) { if (leaseTime != -1) { // 实质是异步执行加锁Lua脚本 return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG); } RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG); ttlRemainingFuture.addListener(new FutureListener<Long>() { @Override public void operationComplete(Future<Long> future) throws Exception { //先判断这个异步操作有没有执行成功,如果没有成功,直接返回,如果执行成功了,就会同步获取结果 if (!future.isSuccess()) { return; } Long ttlRemaining = future.getNow(); // lock acquired //如果ttlRemaining为null,则会执行一个定时调度的方法scheduleExpirationRenewal if (ttlRemaining == null) { scheduleExpirationRenewal(threadId); } } }); return ttlRemainingFuture; } // 见org.redisson.RedissonLock#tryLockInnerAsync <T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) { internalLockLeaseTime = unit.toMillis(leaseTime); return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command, "if (redis.call('exists', KEYS[1]) == 0) then " + "redis.call('hset', KEYS[1], ARGV[2], 1); " + "redis.call('pexpire', KEYS[1], ARGV[1]); " + "return nil; " + "end; " + "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " + "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + "redis.call('pexpire', KEYS[1], ARGV[1]); " + "return nil; " + "end; " + "return redis.call('pttl', KEYS[1]);", Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId)); }
1.尝试获取锁(通过执行Lua脚本) 2.若未获取到锁,则去订阅解锁消息,并使用信号量阻塞等待锁直至被唤醒或等待超时 3.一旦持有锁的线程释放了锁,就会广播解锁消息,于是解锁消息监听器会释放信号量,因获取锁被阻塞的线程就会被唤醒并重新尝试获取锁
为避免业务线程执行时间超过了锁的过期时间,Redission通过WatchDog机制在实例还在运行且没有主动释放锁情况下一直续约,每次默认续约10s
private void scheduleExpirationRenewal(final long threadId) { if (expirationRenewalMap.containsKey(getEntryName())) { return; } Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() { @Override public void run(Timeout timeout) throws Exception { RFuture<Boolean> future = commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " + " redis.call('pexpire', KEYS[1], ARGV[1]); " + "return 1; " + "end; " + "return 0;", Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId)); future.addListener(new FutureListener<Boolean>() { @Override public void operationComplete(Future<Boolean> future) throws Exception { expirationRenewalMap.remove(getEntryName()); if (!future.isSuccess()) { log.error("Can't update lock " + getName() + " expiration", future.cause()); return; } if (future.getNow()) { // reschedule itself scheduleExpirationRenewal(threadId); } } } ); } } , internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); if (expirationRenewalMap.putIfAbsent(getEntryName(), task) != null) { task.cancel(); } }
解锁流程
@Override public void unlock() { // 执行解锁Lua脚本,这里传入线程id,是为了保证加锁和解锁是同一个线程,避免误解锁其他线程占有的锁 Boolean opStatus = get(unlockInnerAsync(Thread.currentThread().getId())); if (opStatus == null) { throw new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: " + id + " thread-id: " + Thread.currentThread().getId()); } if (opStatus) { cancelExpirationRenewal(); } } // 见org.redisson.RedissonLock#unlockInnerAsync protected RFuture<Boolean> unlockInnerAsync(long threadId) { return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN, "if (redis.call('exists', KEYS[1]) == 0) then " + "redis.call('publish', KEYS[2], ARGV[1]); " + "return 1; " + "end;" + "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " + "return nil;" + "end; " + "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " + "if (counter > 0) then " + "redis.call('pexpire', KEYS[1], ARGV[2]); " + "return 0; " + "else " + "redis.call('del', KEYS[1]); " + "redis.call('publish', KEYS[2], ARGV[1]); " + "return 1; "+ "end; " + "return nil;", Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId)); }
解锁流程主要就是执行Lua脚本
多线程/进程争抢锁
若业务场景可容忍Redis实例挂掉的小概率事件,则推荐使用RedissonLock;若无法容忍,则推荐使用RedissonRedLock。它要求部署几套独立的Redis集群以实现集群容错
缓存雪崩/击穿/穿透/热点
缓存雪崩
某一时刻出现大规模的缓存失效,大量请求直接打在数据库上,导致数据库压力巨大甚至被打死
原因分析
造成缓存雪崩的关键在于同一时间大规模的key失效,则原因可能为: Redis宕机,或大量key在同一时间过期
解决方案
确保Redis集群高可用
key失效时间随机
在原有失效时间的基础上再加一个随机失效时间
DB做限流或提高DB抗压能力
提高DB抗压能力: 增加从库、读写分离、分库分表等
缓存击穿
大量请求在并发集中对一个热点key进行访问时,其突然失效,导致大量请求全部打在数据库上,导致数据库压力剧增
原因分析
关键在于在大并发请求时该热点key突然失效了
解决方案
确保业务高峰期key不会过期,甚至可设为永不过期
使用互斥锁
访问key时加锁,第一个线程查库后将值载入缓存,后续请求可走缓存,这会降低并发性
缓存穿透
key未命中,于是不停回查DB
原因分析
关键在于总是查不到key值,然后不断回查DB
解决方案
空对象也进行缓存
正常情况我们只会在查到结果后才写缓存,若未查到结果则不写缓存。但若要频繁访问某个(些)key,则即使没有结果也写缓存,以避免回库查询
使用布隆过滤器
高效,省内存,但不很准
布隆过滤器
定义
BloomFilter是由一个固定大小且很长的二进制向量或位图(bitmap)和一系列随机映射函数组成的,主要用于判断一个元素是否在一个集合中
步骤
初始化一个长度固定且很长的位数组,所有位都置0
当有数据被加入集合时,通过K个映射函数将该数据映射到位图中的K个点,将这些位置1
查看数据是否在该集合中时,我们只要再次对该数据使用K个映射函数,查看对应的位是否都为1,这些位点中只要有一个0则该数据就一定不在该集合中,但若所有位都为1则该数据很可能存在,但并不一定真的存在(这些散列函数可能会有哈希碰撞)
误判率
BloomFilter的误判是指多个输入经过哈希之后在相同的bit位置1了,这样就无法判断究竟是哪个输入产生的,因此误判根源在于相同的bit位被多次映射置1
这也造成了BloomFilter的删除问题,因为BloomFilter的每一个bit并不是独占的,很可能多个元素共享了某一位,若我们直接删除该位,则会影响其它数据
特性
数据被判断为存在并不一定真的存在,被判断为不存在则一定不存在
BloomFilter可添加数据,但不能删除数据,删除数据会导致误判率增加
优缺点
优点
1.BloomFilter的存储空间是固定的m位bit,并不需要存储数据本身,随着数据越来越多,其空间都是恒定的 2.插入和查询的时间复杂度是常数O(K) 所以BloomFilter在空间和时间方面都是恒定的,并不受数据量的影响
缺点
1.随着存入的数据增加,误判率也随之增加; 若数据量太少,又没必要使用BloomFilter 2.不能从BloomFilter中删除数据
典型应用场景
减少查库
Google Bigtable、HBase 和Postgresql使用BloomFilter来减少不存在的行或列数据的磁盘查找,避免代价高昂的磁盘查找大大提升数据库查询操作的性能
缓存击穿
若key在 BloomFilter中,则查询缓存 若key不在BloomFilter中,则查询DB
视频/文章推荐
判断用户是否阅读过某视频或文章,如抖音或头条,当然会导致一定的误判,即用户并没有看过该视频/文章会被判断为已读过,但用户没看过的一定会被判断为没看过,不会重复推荐相同资源
BloomFilter实现
自己实现 BloomFilter
public class MyBloomFilter { /** * 一个长度为10 亿的比特位 */ private static final int DEFAULT_SIZE = 256 << 22; /** * 为了降低错误率,使用加法hash算法,所以定义一个8个元素的质数数组 */ private static final int[] seeds = { 3, 5, 7, 11, 13, 31, 37, 61 }; /** * 相当于构建 8 个不同的hash算法 */ private static HashFunction[] functions = new HashFunction[seeds.length]; /** * 初始化布隆过滤器的 bitmap */ private static BitSet bitset = new BitSet(DEFAULT_SIZE); /** * 添加数据 * * @param value 需要加入的值 */ public static void add(String value) { if (value != null) { for (HashFunction f : functions) { //计算 hash 值并修改 bitmap 中相应位置为 true bitset.set(f.hash(value), true); } } } /** * 判断相应元素是否存在 * @param value 需要判断的元素 * @return 结果 */ public static Boolean contains(String value) { if (value == null) { return false; } Boolean ret = true; for (HashFunction f : functions) { ret = bitset.get(f.hash(value)); //一个 hash 函数返回 false 则跳出循环 if (!ret) { break; } } return ret; } /** * 模拟用户是不是会员,或用户在不在线。。。 */ public static void main(String[] args) { for (int i = 0; i < seeds.length; i++) { functions[i] = new HashFunction(DEFAULT_SIZE, seeds[i]); } // 添加1亿数据 for (int i = 0; i < 100000000; i++) { add(String.valueOf(i)); } String id = "123456789"; add(id); System.out.println(contains(id)); // true System.out.println("" + contains("234567890")); //false } } class HashFunction { private int size; private int seed; public HashFunction(int size, int seed) { this.size = size; this.seed = seed; } public int hash(String value) { int result = 0; int len = value.length(); for (int i = 0; i < len; i++) { result = (seed * result) + value.charAt(i); } return (size - 1) & result; } }
Guava BloomFilter
<dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>23.0</version> </dependency> public class GuavaBloomFilterDemo { public static void main(String[] args) { //后边两个参数:预计包含的数据量,和允许的误差值 BloomFilter<Integer> bloomFilter = BloomFilter.create(Funnels.integerFunnel(), 100000, 0.01); for (int i = 0; i < 100000; i++) { bloomFilter.put(i); } System.out.println(bloomFilter.mightContain(1)); System.out.println(bloomFilter.mightContain(2)); System.out.println(bloomFilter.mightContain(3)); System.out.println(bloomFilter.mightContain(100001)); //bloomFilter.writeTo(); } }
Redis BloomFilter
Redis 4.0通过插件形式提供了强大的Bloom去重功能 Redis客户端 Redisson 和 lettuce可使用BloomFilter
public class RedissonBloomFilterDemo { public static void main(String[] args) { Config config = new Config(); config.useSingleServer().setAddress("redis://127.0.0.1:6379"); RedissonClient redisson = Redisson.create(config); RBloomFilter<String> bloomFilter = redisson.getBloomFilter("user"); // 初始化布隆过滤器,预计统计元素数量为55000000,期望误差率为0.03 bloomFilter.tryInit(55000000L, 0.03); bloomFilter.add("Tom"); bloomFilter.add("Jack"); System.out.println(bloomFilter.count()); //2 System.out.println(bloomFilter.contains("Tom")); //true System.out.println(bloomFilter.contains("Linda")); //false } }
在缓存之前加一层布隆过滤器: 某个key不存在,则一定不存在,则直接返回; 若某个key存在,则很大可能存在,则再去查缓存
缓存热点
特别热点的数据,如果大部分甚至所有的业务请求都命中同一份缓存数据,则这份数据所在的缓存服务器的压力很大,有可能撑不住
如微博经常会因为某些突发、热点事件导致系统崩溃
解决方案
多副本缓存
1. 写入时,缓存 Key 加上编号,写入到多个缓存服务器 2. 读取时,随机生成编号组装 Key,然后读取
总结
缓存雪崩是大量key同一时间失效; 缓存击穿是某个热点key在高并发访问时突然失效; 缓存穿透是总是未命中然后回查DB
DB和缓存不一致
产生原因
读写高并发
DB主从延迟
解决方案
先delete缓存,后更新DB
delete缓存,不要更新缓存
会引入缓存击穿问题
延迟异步双删
1.del缓存; 2.DB操作; 3.异步延迟del缓存
第3步是防止DB操作期间,有线程因缓存未命中于是回查DB并将查到旧内容写入缓存,导致缓存中的还是旧数据,故需要异步延迟再删一次缓存
订阅从库binlog后删除缓存
读写均加分布式锁
同一id读写均加分布式锁以实现读写串行化
为什么很快
纯内存操作
单线程操作,无需切换
不要有耗时操作,如大value/keys *,将会阻塞其它操作
Reactor模型
NIO多路复用(epollo)
高效的内部数据结构
为降低查询的时间复杂度,Redis针对不同类型的数据在底层结构上做了很多精心设计(如空间换时间(sds),数据压缩(ziplist))
redis or mc?
适合redis场景
数据结构复杂
value是哈希、列表、集合、有序集合这类复杂的数据结构时,选择 redis,mc无法满足这些需求
需要持久化
mc无法满足持久化的需求,只能选择redis
需要高可用
redis天然支持高可用/集群,mc需二次开发
需要支持水平扩容
value较大
mc 的 value 最大为1M,如果存储的value很大,只能使用redis(但也不建议redis中value超过1M)
适合memcache场景
简单场景
除以上适合redis之外的场景,如数据结构简单、不需要持久化、不需要高可用、不需要水平扩容、value较小等
性能要求更高
内存分配 memcache使用预分配内存池的方式管理内存,能够省去内存分配时间。 redis则是临时申请空间,可能导致碎片。 从这一点上,mc会更快一些。 虚拟内存使用 memcache把所有的数据存储在物理内存里。 redis有自己的VM机制,理论上能够存储比物理内存更多的数据,当数据超量时,会引发swap,把冷数据刷到磁盘上。 从这一点上,数据量大时,mc会更快一些。 网络模型 memcache使用非阻塞IO复用模型,redis也是使用非阻塞IO复用模型。 但由于redis还提供一些非KV存储之外的排序,聚合功能,在执行这些功能时,复杂的CPU计算,会阻塞整个IO调度。 从这一点上,由于redis提供的功能较多,mc会更快一些。 线程模型 memcache使用多线程,主线程监听,worker子线程接受请求,执行读写,这个过程中,可能存在锁冲突。 redis使用单线程,虽无锁冲突,但难以利用多核的特性提升整体吞吐量。 从这一点上,mc会快一些。
结论
一般场景下均可使用redis
是否真事务
事务命令
multi
开始事务(begin)
exec
执行所有命令(commit)
假如某个(或某些) key 正处于 WATCH 命令的监视之下,且事务块中有和这个(或这些) key 相关的命令,那么 EXEC 命令只在这个(或这些) key 没有被其他命令所改动的情况下执行并生效,否则该事务被打断(abort)。
discard
取消事务(rollback)
如果正在使用 WATCH 命令监视某个(或某些) key,那么取消事务,等同于执行 UNWATCH 命令。
watch/unwatch
WATCH: 监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。 UNWATCH: 如果在执行 WATCH 命令之后, EXEC 命令或 DISCARD 命令先被执行了的话,那么就不需要再执行 UNWATCH 了。 因为 EXEC 命令会执行事务,因此 WATCH 命令的效果已经产生了;而 DISCARD 命令在取消事务的同时也会取消所有对 key 的监视,因此这两个命令执行之后,就没有必要执行 UNWATCH 了。
避免不可重复读&幻读
redis并非真事务
redis事务不支持rollback,后面执行失败,前面的仍会commit
Redis的QPS/TPS能达到多少
官方测试数据: 并发:50,请求数:10W,数据:256B的字符串 结果: 读QPS:11w,写TPS:8.1w
故大约10w
优化
设置键值过期时间
尽量为键值对设置过期时间,以节约内存占用,避免频繁触发内存淘汰策略
禁用耗时命令
禁用 keys 命令,应使用 scan 命令游标式遍历
禁止大value,并严格控制 Hash/Set/ZSet 等结构的数据大小
何为大Value?
Value很大,如超过1MB
hash/set/zset/list中元素很多,如超过1w
大Value危害
阻塞操作
主动删除、被动过期删除、数据迁移等,由于处理这一个KEY时间长,导致服务端发生阻塞,所以不只影响该key的存取,还影响其它操作,因redis是单线程
耗时
list、hash等数据结构,大量的 elements 需要多次遍历,多次系统调用拷贝数据消耗时间
占内存&带宽
频繁触发内存淘汰机制,给Redis带来运行负担
如何解决
拆分
1、对于需要整取value的key,可以尝试将对象拆分成几个key-value, 使用multiGet获取值,这样拆分的意义在于拆分单次操作的压力,将操作压力平摊到多个实例中,降低对单个实例的IO影响 2、对于每次需要取部分value的key,同样可以拆成几个key-value,也可以将这些存储在一个hash中,每个field代表具体属性,使用hget/hmget来获取部分value,使用hset/ hmset来更新部分属性 3、对于value中存储过多元素的key,同样可以将这部分元素拆分,以hash为例,正常的流程是:hget(hashKey, field);hset(hashKey, field, value)。 现在可以固定一个桶数量,比如1w,每次存取的时候,先在本地计算field的hash值,对1w取模,确定field落在哪个key上,newHashKey = hashKey + (hash(field) % 10000); hset(newHashKey, field, value) ; hget(newHashKey, field)。set,zset,list做法类似
可对数据进行序列化和压缩存储
将排序、并集、交集等操作放在客户端执行,以减少 Redis 服务器运行压力
删除大数据时,可用 unlink 命令另起一线程异步删除,不阻塞主线程
使用 slowlog 优化耗时命令
可使用 slowlog 找出最耗时的 Redis 命令并进行优化
避免大量数据同时失效
Redis 过期键值的删除使用的是贪心策略,它默认每秒会进行 10 次过期扫描,每次默认随机抽取 5 个值,删除这 5 个键中过期的键,如果过期 key 的比例超过 25% ,重复执行此流程
若大量缓存在同一时间同时过期,会导致 Redis 循环多次持续扫描删除过期字典,直到过期字典中过期键值被删除得较稀疏为止,整个执行过程会导致 Redis 的读写出现明显卡顿,卡顿的另一个原因是内存管理器需频繁回收内存页,也会消耗一定的 CPU
会导致缓存雪崩
API使用优化
在客户端,我们应尽量使用Pipeline、连接池、异步响应式等API,如lettuce
Pipeline 管道是客户端提供的一种批处理技术,用于一次性批量处理多个 Redis 命令,从而提升交互性能
限制Redis内存大小
64 位操作系统中 Redis的内存大小是没有限制的(即配置项 maxmemory 是被注掉的),这样会导致在物理内存不足时使用 swap交换空间,当操作系统将Redis所用的内存分页移至swap 交换空间时会阻塞 Redis 进程,导致 Redis 出现延迟,从而影响 Redis 整体性能。因此我们需要限制 Redis的内存大小为一个固定值,当 Redis内存占用到达此值时会触发内存淘汰策略
使用物理机而非虚拟机
在虚拟机中运行 Redis服务器,和物理机共享一个物理网口,并且一台物理机可能有多个虚拟机在运行,因此在内存占用上和网络延迟方面会有糟糕的表现,若对性能有较高要求,应尽可能在物理机上直接部署 Redis 服务器
物理内存要足够
Redis会将所有的数据都加载到内存中,故内存要足够大
RDB&AOF混合持久化
RDB 和 AOF 持久化各有利弊,RDB可能会导致一定时间内的数据丢失,AOF由于文件较大会影响 Redis的启动速度,为了同时拥有 RDB 和 AOF的优点,Redis 4.0 之后新增了混合持久化的方式,因此我们在进行持久化操作时,应该选择混合持久化方式,以避免间歇性卡顿
使用分布式架构增加读写性能
若数据量和读写压力很大,我们可使用 Redis Cluster 方案,将存储及读写压力分摊给更多的服务器,并拥有自动容灾能力
禁用 THP 特性
Linux kernel 在 2.6.38 内核增加了 Transparent Huge Pages (THP) 特性 ,支持大内存页 2MB 分配,默认开启。当开启了 THP 时,fork 的速度会变慢,fork 之后每个内存页从原来 4KB 变为 2MB,会大幅增加重写期间父进程内存消耗。同时每次写命令引起的复制内存页单位放大了 512 倍,会拖慢写操作的执行时间,导致大量写操作慢查询,例如简单的 incr 命令也会出现在慢查询中。因此 Redis建议禁用掉此特性
删除/淘汰策略
key过期删除策略
惰性删除
访问时才删除
定期删除
定期随机取样删除
并非一次性检查所有库的所有key,而是随机检查一定数量的key
类似G1GC,每次只回收一部分
默认每秒运行10次,可调整redis.conf中的hz值
Redis中两种删除策略配合使用
内存淘汰
触发时机
内存使用>maxmemory
最大内存(maxmemory)通常设置为物理内存的3/4
策略
volatile-lru
lru:近期最少使用
取样lru移除设置过过期时间的key
若无特殊场景,推荐使用该方式
allkeys-lru
取样lru无差别移除key
volatile-random
取样随机移除设置过过期时间的key
allkeys-random
取样随机无差别移除key
volatile-ttl
取样移除即将过期的key
noeviction
永不驱逐
默认选项,一般不用
策略配置
可通过maxmemory-policy来设置内存淘汰策略
下面的 maxmemory-samples 为每次取样的key个数
主从复制流程