REDIS 专题。版本:3.2
数据结构
基础数据结构
一. 字符串 string
底层实现为简单动态字符串(SDS),是可修改的字符串,采用预分配冗余空间
的方式来减少内存的频繁分配。
SDS数据结构主要由len、free、buf[]
这三个属性组成。
-
len:buf数组已用的长度。
-
free:buf数组未用的长度。
-
buf[]:存储字符串
空间预分配:
对SDS字符串修改后,如果需要扩容,那么不仅为SDS分配此次扩展的空间,还会分配额外的空间,待下次使用。
惰性空间释放:
对SDS字符串缩短后,不会立即回收多余的空间,而是更新free的值,方便下次使用。
可以通过object encoding
查看对象编码。 SDS类型内部编码有3中:
-
int:整型。
-
embstr:小于等于44字节。
-
raw:大于44字节。
embstr和raw都使用redisObject(对象包装类)和sds保存数据,embstr使用只分配一次内存空间,raw需分配两次内存空间(redisObject和sds内存地址不连续)。
redisObject定义:
typedef struct redisObject {
unsigned type:4; /* 类型 */
unsigned encoding:4; /* 编码 */
unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) 内部时钟,记录对象最后一次被访问的时间 */
int refcount; /* 记录对象引用次数 */
void *ptr; /* 指向底层数据结构的指针 */
} robj;
二. 列表 list
可用来做异步消息队列。rpush/lpush 存入列表和散key, 取列表值后,成功处理则删除散key, 保证数据不丢失。如取队列值后处理失败,定时任务重push散key进入队列。
较早版本为 ziplist压缩列表O(n), linkedlist 双向链表O(n);
3.2后底层实现为 quicklist
。
ziplist是内存空间连续的有序数据结构。存储效率高,内存开销少。但插入删除操作时,需要频繁申请和释放内存。
quicklist:linkedlist 和 ziplist 的结合,quicklist 将linkedlist 按段切分,每一段使用ziplist 来紧凑存储,多个ziplist 之间使用双向指针串接起来。
list-max-ziplist-size -2 : 每个quicklist节点上的ziplist大小不能超过8Kb。超出这个字节会增加一个ziplist。
三. 集合 set
无序、自动去重的集合数据类型。底层实现:哈希表O(1), inset 整数数组O(n)。
四. 哈希 hash
适合用来缓存对象信息。底层实现为:hashtable 哈希表O(1), ziplist 压缩列表O(n)。
五. 有序集合 zset
有序、自动去重的集合数据类型。底层实现为:压缩列表O(n), skiplist
跳表O(logN)。
redis.conf配置文件中有定义两个参数:
-
zset-max-ziplist-entries 128 有序集合元素个数,默认128。
-
zset-max-ziplist-value 64 元素字节数,默认64字节。
zset集合满足上面两个条件,使用ziplist(压缩列表)编码,如果某个元素字节数大于64,会转换成skiplist(跳表)编码。
跳跃表
跳表:将有序链表改造为支持“折半查找”算法,通过建立索引层,可以快速的插入、删除、查询操作。
跳表的数据结构主要有两个,zskiplist
(定义跳表头尾指针、长度、层级信息),zskiplistNode
(跳表的结点信息,包含前后指针)。
假设目前6个元素,插入如下图所示。第一层L0是双向链表
,判断结点是否加入到上一层是根据一个随机层函数来判断。有1/4的概率决定是否将当前结点加入到上一层。
最高层级level = 32。
举例插入一个score=3.5 的结点。当前最高为3层,level=3。层级结构level[]是一个数组,也就是从L2层开始查找下一个元素forward,5 > 3.5,不满足继续找。最终在L0层插入3.5结点,改变指针地址。
伪代码
update[]数组记录每一层插入位置的前一个节点。
x->level[i].forward = update[i]->level[i].forward;
update[i]->level[i].forward = x;
跳表删除操作和插入逻辑类似,比如删除x 结点,从最高层往下找,更新update[]数组,记录每层x 的前一个结点指针backward,把x从跳表上逐层删除并更新指针地址。
zset底层结构用平衡二叉树也能实现,但skiplist实现起来简单,平衡二叉树需左旋右旋来保持平衡,实现起来复杂。
布隆过滤器
数据结构:一个大型的位数组
和几个不一样的无偏(元素算的比较均匀)hash函数
。
原理: 计算key时,使用多个hash函数对key进行hash算得整数索引值然后对位数组长度取模得到一个位置,每个hash函数都会算得一个不同位置,再把位数组的这几个位置都置为1完成操作。
查询:先把hash几个位置都算出来,看看位数组几个位置是否都是1,只要有一个位为0,那说明布隆过滤器中这个key不存在,如果都是1,也不一定存在,因为有可能被其它key占用的位置(下图)。所以数组空间比较大,判断准确率高;空间比较小,准确率低。
redis官方在4.0提供了布隆过滤器插件。
docker部署包含布隆过滤器的redis镜像
命令:
docker search redis
docker pull redislabs/rebloom
docker run --name redislabs -p 6379:6379 -d redislabs/rebloom
docker exec -it redislabs bash
redis指令:
-
bf.reserve:创建自定义布隆过滤器。其中有个错误率参数:error_rate。error_rate越小,需要存储空间就越大。
-
bf.add:添加元素到布隆过滤器。
-
bf.exists:查看value值是否存在。
-
bf.mexists:判断多个值是否存在。
应用场景:
-
对爬虫url过滤,大幅降低去重存储消耗,会错过少量页面;
-
黑名单校验,垃圾邮件过滤;
-
解决缓存穿透,过滤掉一定不存在的查询请求。
持久化策略
一. RDB
redis配置文件默认rdb备份,将数据保存到磁盘,文件名为dump.rdb。
自动触发条件:900秒/1次,或300秒/10次,或60秒/10000次 key被改变则快照保存。
redis.conf:
save 900 1
save 300 10
save 60 10000
dbfilename dump.rdb
原理:
-
自动触发:redis父进程会调用bgsave 来fork 出一个子进程,子进程创建RDB文件,父进程继续处理请求。
-
手动触发:调用save命令,但会阻塞redis。
fork产生的子进程使用了linux的copy-on-write 机制。
copy-on-write(写时复制):
子进程共享主进程的物理内存空间
,子进程和主进程虚拟地址指向同一份物理内存空间。
主进程fork子进程之后,内核会把主进程中所有内存页权限设置为只读。
Redis子进程拷贝的时候,主进程有新的写命令,cpu硬件检测页是只读,会触发页异常中断。内核会把触发异常的页(也就是要修改的数据页,linux内存管理以页为单位,默认4kb,修改的数据对应内存空间中的某一页)复制一份。主进程对应的指针指向新的数据页,子进程指向旧的数据页。
缺点:数据会丢失,fork子进程时会阻塞。
二. AOF
AOF持久化默认关闭。如果需要保证数据完整性和实时性的话,可以开启AOF。如rdb和AOF同时开启,默认加载AOF。
AOF主要采用文件追加方式,文件过大会fork一个子进程重写;重写过程不是读取旧AOF文件,而是将内存中的数据用命令的方式重写生成新的AOF文件。
appendonly no
# AOF文件名
appendfilename "appendonly.aof"
# 同步频率 每秒
appendfsync everysec
# 日志重写时,no指进行命令追加操作,修改数据会出现IO阻塞
no-appendfsync-on-rewrite no
# 自动重写条件一:AOF配置默认达到百分比重写 100%,达到上次重写文件大小1倍
auto-aof-rewrite-percentage 100
# 自动重写条件二:重写aof最小文件大小
auto-aof-rewrite-min-size 64mb
自动触发需满足上面重写条件;手动触发执行bgrewriteaof命令。
原理:
AOF持久化功能的实现可以分为3个步骤:命令追加、文件同步、文件重写。
1. 追加
所有写入命令追加到AOF缓冲区。
2. 同步
同步文件策略由参数appendfsync控制:always:
主线程命令写入aof_buf后调用系统write写入内核空间,接着调用系统fsync把内核中修改数据写入文件,完成后主线程返回,这段期间主线程是阻塞的。可靠性较高。no:
主线程命令写入aof_buf后调用系统write操作后返回。由操作系统负责同步。everysec:
默认配置。 主线程命令写入aof_buf后调用系统write操作后返回。另起同步线程调用fsync函数写入磁盘。
3. 重写 bgrewirteaof
redis主进程fork子进程后,子进程基于当前内存中的数据构建日志,往临时aof文件写入日志。fork子进程写文件和rdb快照保存操作类似。fork完成后,主进程继续响应客户端命令
。
所有修改命令写入aof文件缓冲区
并根据appendfsync策略同步到磁盘(旧的aof文件),也会写入到aof重写缓冲区
。
子进程写完后通知主进程。主进程把fork子进程之后产生的修改命令,也就是aof重写缓存区的数据写入新的aof文件。
使用新的aof文件替换老的aof文件,完成aof重写。
为什么重写期间需要写两个缓冲区,防止重写期间宕机不至于丢失fork操作之后的所有命令。
缺点:AOF文件过大;恢复时间较慢。
淘汰算法
LRU:
最近最少使用,根据数据的历史访问记录来淘汰数据。核心思想是:如果数据最近被访问,那么将来被访问的几率也更高。
参考leetcode 146题解法思路:数据结构为双向链表 + 哈希表。双向链表用于存储元素,哈希表查询元素,时间复杂度O(1)。
LFU:
最不经常使用,按访问频次来淘汰最少被访问的数据。
参考leetcode 460题解法思路:双向链表实现,维护两个哈希表。hash1 key为频次count, value为双向链表。hash2 key为键值,value为单个结点。
get时在hash2 中找到结点的访问次数,从hash1 中通过次数找到双向链表,移除此结点,加入到key为 count+1 的双向链表中。
put时容量不够,删除最小频率缓存。然后构建访问频次为1 的key加入hash1 中。
Redis LRU:
相关淘汰策略配置:
# 设置内存使用上限
maxmemory <bytes>
# 清理缓存策略
maxmemory-policy noeviction
# 随机采样的数量,选择一个最近最少使用的key删除
maxmemory-samples 5
maxmemory-policy 策略机制:
-
noeviction 不淘汰,超过内存后返回错误。
-
allkeys-lru 所有key采用lru算法淘汰。
-
volatile-lru 设置过期时间的key采用lru算法淘汰。
-
allkeys-random 随机删除key。
-
volatile-random 随机删除设置了过期时间的key。
-
volatile-ttl 按过期时间删除key。
Redis为了节约内存,采用近似LRU算法实现。原理:通过随机获取采样的数据,从中淘汰key。
Redis LFU:
redis4.0 新增 lfu淘汰机制。相关配置:
# 设置过期时间的键按lfu算法淘汰
volatile-lfu
# 所有key按lfu算法淘汰
allkeys-lfu
lfu算法把原来key对象的内部时钟24bit 分为高16位和低8位。
-
高16位记录上一次递减时间。根据衰减周期计算递减时间。
-
低8位记录访问次数。最大为255,超过这个访问次数不再递加。并随递减时间更新访问次数。
集群模式
Redis 集群是一个提供在多个Redis节点间共享数据的程序集。
Redis 集群并不支持处理多个keys的命令,因为这需要在不同的节点间移动数据,从而达不到像Redis那样的性能,在高负载的情况下可能会导致不可预料的错误。
Redis 集群通过分区来提供一定程度的可用性,在实际环境中当某个节点宕机或者不可达的情况下继续处理命令。
Redis 集群的优势:
-
自动分割数据到不同的节点上。
-
整个集群的部分节点失败或者不可达的情况下能够继续处理命令。
Redis 集群拓扑图:
所有集群节点通过tcp连接和一个二进制协议建立通信。 每一个节点都通过cluster bus 与集群上的其余每个节点连接起来。 节点们使用一个gossip协议来传播集群的信息。
由于集群节点不能代理请求,所以客户端在接收到重定向错误 -moved 和 -ask的时候,将命令重定向到其它节点。
主从节点间采用异步复制。
currentEpoch
当前纪元,记录集群状态变更的递增版本号。作用:适用于故障转移流程。
集群选举机制:
-
从节点会将自己记录的currentEpoch +1, 并广播一个FAILOVER_AUTH_REQUEST 数据包给集群里的每个主节点请求选票;
-
主节点如果给从节点投票,则回复FAILOVER_AUTH_ACK 消息,在一个给定的时间段内,一个主节点只能投一次票;
-
从节点忽略epoch比当前epoch小的回应acks;
-
从节点收到了大多数回应,赢得了选举;
-
用ping或pong数据包向其它节点宣布自己是主节点,并提供哈希槽信息,其它节点升级自己的配置信息。
从节点不是在主节点fail状态后立马发起,而是在一个延迟时间后。
分布式锁
实现分布式锁需满足条件:
-
互斥性。在任意时刻,只有一个客户端能持有锁。
-
不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
-
具有容错性。只要大多数Redis节点正常运行,客户端就能够获取和释放锁。
redis 分布式锁
原子命令: set key value [EX seconds] [PX milliseconds] [NX|XX]。
设置一个不存在的key, 并加入过期时间。
单实例redis 分布式锁命令:set xiaoli 123 ex 3 nx。 如果key xiaoli不存在则写入key,并设置过期时间为3秒。
总结分布式锁问题对应解决方案:
-
死锁问题:设置过期时间。
-
锁过期:守护线程,锁自动续期。
-
释放别人的锁:锁写入唯一业务id。
-
可冲入锁:计数器。
由于单实例redis 加锁只作用在一个节点上。即使通过sentinel保证高可用,但master节点由于某些原因发生主从切换,则会出现锁丢失。
所以redis 作者基于分布式环境提供的实现方式:redlock
。
redlock基本原理: 有n个master节点,相互独立,写锁满足n/2+1 以上成功就代表加锁完成。
开源项目 redisson
是在redis 基础上实现的一套开源解决方案。 在分布式锁基础上提供了:
-
RedissonLock 分布式可重入锁。 加锁只作用在一个redis节点上。支持可重入,失败重试,可设置锁最大等待时间。
-
RedissonRedLock 分布式红锁。 实现了Redlock介绍的加锁算法,将多个RLock对象关联为一个红锁,在大部分节点上加锁成功就算成功。
[1] [2]
参考资料
[1]
redis中文网站: http://www.redis.cn/
[2]
知乎问答:怎么实现分布式锁: https://www.zhihu.com/question/300767410