Redis (Remote Dictionary Server) 是一个由 SalvatoreSanfilippo 写的 key-value 存储系统。
Redis 是一个开源的使用 ANSI C语言编写、遵守 BSD 协议、支持网络、可基于内存亦可持久化的日志型、Key-Value 数据库,并提供多种语言的API。
它通常被称为数据结构服务器,因为值(value)可以是字符串(String), 哈希(Map), 列表(list), 集合(sets) 和有序集合(sorted sets)等类型。
redis 特点
Redis 本质上是一个 Key-Value 类型的内存数据库,很像 memcached,整个数据库统统加载在内存当中进行操作,定期通过异步操作把数据库数据 flush 到硬盘上进行保存。因为是纯内存操作,Redis 的性能非常出色,每秒可以处理超过 10 万次读写操作,是已知性能最快的Key-Value DB。
Redis 的出色之处不仅仅是性能,Redis 最大的魅力是支持保存多种数据结构,此外单个value 的最大限制是 1GB,不像 memcached 只能保存 1MB 的数据,因此 Redis 可以用来实现很多有用的功能,比方说用他的 List 来做 FIFO 双向链表,实现一个轻量级的高性 能消息队列服务,用他的 Set 可以做高性能的tag系统等等。另外 Redis 也可以对存入的 Key-Value 设置 expire 时间,因此也可以被当作一 个功能加强版的 memcached 来用。
Redis 的主要缺点是数据库容量受到物理内存的限制,不能用作海量数据的高性能读写,因此Redis 适合的场景主要局限在较小数据量的高性能操作和运算上。
Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用。
Redis 不仅仅支持简单的 key-value 类型的数据,同时还提供 list,set,zset,hash 等数据结构的存储。
Redis 支持数据的备份,即 master-slave 模式的数据备份。
redis 的 5 种存储类型
string类型
- 能表达3中类型:字符串、整数和浮点数。根据场景相互间自动转型,并且根据需要选取底层的承载方式
- value内部以 int、sds 作为结构存储。int 存放整型数据,sds 存放字节/字符串和浮点型数据
- sds内部结构:
- 用 buf 数组存储字符串的内容,但数组的长度会大于所存储内容的长度。会有一格专门存放”\0”(C标准库)作为结尾,还有预留多几个空的(即free区域),当 append 字符串的长度小于 free 区域,则 sds 不会重新申请内存,直接使用 free 区域
- 扩容:当对字符串的操作完成后预期的串长度小于 1M 时,扩容后的 buf 数组大小 = 预期长度*2 + 1;若大于 1M,则 buf 总是会预留出1M的 free 空间
- value 对象通常具有两个内存部分:redisObject 部分和 redisObject 的 ptr 指向的 sds 部分。创建 value 对象时,通常需要为 redisObject 和 sds 申请两次内存。单对于短小的字符串,可以把两者连续存放,所以可以一次性把两者的内存一起申请了
list类型
- list 类型的 value 对象内部以 linkedList 或 zipList 承载。当 list 的元素个数和单个元素的长度较小时,redis 会采用 zipList 实现以减少内存占用,否则采用linkedList 结构
- linkedList 内部实现是双向链表。在 list 中定义了头尾元素指针和列表的长度,是的pop/push 操作、llen 操作的复杂度为O(1)。由于是链表,lindex 类的操作复杂度仍然是O(N)
- zipList 的内部结构
- 所有内容被放置在连续的内存中。其中 zlbytes 表示 zipList 的总长度,zltail 指向最末元素,zllen 表示元素个数,entry 表示元素自身内容,zlend 作为zipList 定界符
- rpush、rpop、llen,复杂度为O(1);lpush/pop 操作由于涉及全列表元素的移动,复杂度为O(N)
map类型
- map 又叫 hash。map 内部的 key 和 value 不能再嵌套 map 了,只能是 string 类型:整形、浮点型和字符串
- map 主要由 hashTable 和 zipList 两种承载方式实现,对于数据量较小的 map,采用zipList 实现
- hashTable 内部结构
- 主要分为三层,自底向上分别是 dictEntry、dictht、dict
- dictEntry:管理一个 key-value 对,同时保留同一个桶中相邻元素的指针,一次维护哈希桶的内部连
- dictht:维护哈希表的所有桶链
- dict:当 dictht 需要扩容/缩容时,用于管理 dictht 的迁移
- 哈希表的核心结构是dictht,它的table字段维护着hash桶,它是一个数组,每个元素指向桶的第一个元素(dictEntry)
- set 值的流程:先通过 MurmurHash 算法求出 key 的 hash 值,再对桶的个数取模,得到 key 对应的桶,再进入桶中,遍历全部 entry,判定是否已有相同的 key,如果没有,则将新 key 对应的键值对插入到桶头,并且更新 dictht 的 used 数量,used 表示 hash 表中已经存了多少元素。由于每次插入都要遍历 hash 桶中的全部entry,所以当桶中 entry 很多时,性能会线性下降
- 扩容:通过负载因子判定是否需要增加桶数。负载因子=哈希表中已有元素/哈希桶数的比值。有两个阈值,小于1一定不扩容;大于5一定扩容。扩容时新的桶数目是现有桶的2n倍
- 缩容:负载因子的阈值是0.1
- 扩/缩容通过新建哈希表的方式实现。即扩容时,会并存两个哈希表,一个是源表,一个是目标表。通过将源表的桶逐步迁移到目标表,以数据迁移的方式实现扩容,迁移完成后目标表覆盖源表。迁移过程中,首先访问源表,如果发现 key 对应的源表桶已完成迁移,则重新访问目标表,否则在源表中操作
- redis 是单线程处理请求,迁移和访问的请求在相同线程内进行,所以不会存在并发性问题
- zipList 内部结构
- 和 list 的 zipList 实现类似。不同的是,map 对应的 zipList 的 entry 个数总是2的整数倍,奇数存放 key,偶数存放 value
- zipList 实现下,由哈希遍历变成了链表的顺序遍历,复杂度变成O(N)
set类型
- set 以 intset 或 hashtable 来存储。hashtable 中的 value 永远为 null,当 set 中只包含整数型的元素时,则采用 intset
- intset 的内部结构
- 核心元素是一个字节数组,从小到大有序存放着set的元素
- 由于元素有序排列,所以 set 的获取操作采用二分查找方式实现,复杂度O(log(N))。进行插入时,首先通过二分查找得到本次插入的位置,再对元素进行扩容,再将预计插入位置之后的所有元素向右移动一个位置,最后插入元素,插入复杂度为O(N)。删除类似
sorted-set类型
- 类似 map 是一个 key-value 对,但是有序的。value 是一个浮点数,称为 score,内部是按照 score 从小到大排序
- 内部结构以 zipList 或 skipList+hashTable 来实现
redis 客户端与服务器的交互模式
串行的请求/响应模式
- 每一次请求的发送都依赖于上一次请求的相应结果完全接收,同一个连接的每秒吞吐量低
- redis 对单个请求的处理时间通常比局域网的延迟小一个数量级,所以串行模式下,单链接的大部分时间都处于网络等待
双工的请求/响应模式(pipeline)
- 适用于批量的独立写入操作。即可将请求数据批量发送到服务器,再批量地从服务器连接的字节流中一次读取每个响应数据,减少了网络延迟,所以单连接吞吐量较串行会提高一个数量级
原子化的批量请求/响应模式(事务)
- 客户端通过和 redis 服务器两阶段的交互做到批量命令原子执行的事务效果:入队操作(即服务器端先将客户端发送过来的连接对象暂存在请求队列中)和执行阶段(依次执行请求队列中的所有请求)
- 一个连接的请求在执行批量请求的过程中,不会执行其他客户端的请求
- redis 的事务不是一致的,没有回滚机制。如果中途失败,则返回错误信息,但已经成功执行的命令不会回滚
- 事务里面有可能会带有读操作作为条件,由于批量请求只会先入队列,再批量一起执行,所以一般读操作不会跟批量写请求一起执行,这时候就有可能会导致批量写之前和之后读到的数据不一致,这种可以通过乐观锁的可串行化来解决,redis 通过 watch 机制实现乐观锁。具体实现过程看下一题
发布/订阅模式
- 发布端和订阅者通过 channel 关联
- channel 的订阅关系,维护在 reids 实例级别,独立于 redisDB 的 key-value 体系。所有的 channel 都由一个 map 维护,键是 channel 的名字,value 是它所有订阅者 client 的指针链表
脚本化的批量执行(脚本模式)
乐观锁流程
redis通过watch机制实现乐观锁流程
- 将本次事务涉及的所有 key 注册为观察模式
- 执行只读操作
- 根据只读操作的结果组装写操作命令并发送到服务器端入队
- 发送原子化的批量执行命令 EXEC 试图执行连接的请求队列中的命令
- 如果前面注册为观察模式的 key 中有一个货多个,在 EXEC 之前被修改过,则 EXEC 将直接失败,拒绝执行;否则顺序执行请求队列中的所有请求
- redis 没有原生的悲观锁或者快照实现,但可通过乐观锁绕过。一旦两次读到的操作不一样,watch 机制触发,拒绝了后续的 EXEC 执行
redis 的网络协议
redis 协议位于 TCP 层之上,即客户端和 redis 实例保持双工的连接,交互的都是序列化后的协议数据.
redis 处理命令的主要逻辑
- redis 服务器对命令的处理都是单线程的,但是 I/O 层面却面向多个客户端并发地提供服务,并发到内部单线程的转化通过多路复用框架来实现
- 首先从多路服用框架(epoll、evport、kqueue)中 select 出已经 ready 的文件描述符(fileDescriptor)
- ready 的标准是已有数据到达内核(kernel)、已准备好写入数据
- 对于上一步已经 ready 的 fd,redis 会分别对每个 fd 上已 ready 的事件进行处理,处理完相同 fd 上的所有事件后,再处理下一个 ready 的 fd。有 3 种事件类型
- acceptTcpHandler:连接请求事件
- readQueryFromClient:客户端的请求命令事件
- sendReplyToClient:将暂存的执行结果写回客户端
- 对来自客户端的命令执行结束后,接下来处理定时任务(TimeEvent)
- aeApiPoll 的等待时间取决于定时任务处理(TimeEvent)逻辑
- 本次主循环完毕,进入下一次主循环的 beforeSleep 逻辑,后者负责处理数据过期、增量持久化的文件写入等任务
redis 的持久化机制
redis 主要提供了两种持久化机制:RDB 和 AOF;
RDB
默认开启,会按照配置的指定时间将内存中的数据快照到磁盘中,
创建一个dump.rdb文件,redis启动时再恢复到内存中。
redis会单独创建fork()一个子进程,将当前父进程的数据库数据复制到子进程的内存中,
然后由子进程写入到临时文件中,持久化的过程结束了,再用这个临时文件替换上次的快照文件,
然后子进程退出,内存释放。
需要注意的是,每次快照持久化都会将主进程的数据库数据复制一遍,导致内存开销加倍,
若此时内存不足,则会阻塞服务器运行,直到复制结束释放内存;
都会将内存数据完整写入磁盘一次,所以如果数据量大的话,而且写操作频繁,必然会引起大量的磁盘I/O操作,
严重影响性能,并且最后一次持久化后的数据可能会丢失;
AOF
以日志的形式记录每个写操作(读操作不记录),只需追加文件但不可以改写文件,
redis启动时会根据日志从头到尾全部执行一遍以完成数据的恢复工作。包括flushDB也会执行。
主要有两种方式触发:有写操作就写、每秒定时写(也会丢数据)。
因为 AOF 采用追加的方式,所以文件会越来越大,针对这个问题,新增了重写机制,就是当日志文件大到一定程度的时候,
会 fork 出一条新进程来遍历进程内存中的数据,每条记录对应一条 set 语句,写到临时文件中,
然后再替换到旧的日志文件(类似 rdb 的操作方式)。
默认触发是当 aof 文件大小是上次重写后大小的一倍且文件大于 64M 时触发;
当两种方式同时开启时,数据恢复 redis 会优先选择 AOF 恢复。一般情况下,只要使用默认开启的 RDB 即可,因为相对于 AOF,RDB 便于进行数据库备份,并且恢复数据集的速度也要快很多。
开启持久化缓存机制,对性能会有一定的影响,特别是当设置的内存满了的时候,更是下降到几百reqs/s。所以如果只是用来做缓存的话,可以关掉持久化。
redis 内存分析的设计思路
主要有3种方式可以实现
- keys 命令:获取到所有的 key,再根据 key 获取所有的内容。缺点是如果 key 数量特别多,则会导致 redis 卡住影响业务
- aof:通过 aof 文件获取到所有数据。缺点是有一些 redis 实例写入频繁,不适合开启 aof,并且文件可能特别大,传输、解析效率差
- rdb:使用 bgsave 获取 rdb 文件,然后解析。缺点是 bgsave 在 fork 子进程时有可能会卡住主进程。当对于其他两种,在低峰期在从节点做 bgsave 获取 rdb 文件,相对安全可靠。
设计思路:
- 在访问低峰期时根据 redis 获取 rdb 文件
- 解析 rdb 文件
- 根据相对应的数据结构及内容,估算内容消耗等
- 统计并生成报表
开源框架:https://github.com/xueqiu/rdr
redis 集群(redis cluster)
redis3 以后,节点之间提供了完整的 sharding(分片)、replication(主备感知能力)、failover(故障转移)的特性
配置一致性:每个节点(Node)内部都保存了集群的配置信息,存储在 clusterState 中,通过引入自增的 epoch 变量来使得集群配置在各个节点间保持一致
sharding 数据分片
- 将所有数据划分为 16384 个分片(slot),每个节点会对应一部分 slot,每个 key 都会根据分布算法映射到 16384 个 slot 中的一个,分布算法为 slotId=crc16(key)%16384
- 当一个 client 访问的 key 不在对应节点的 slots 中,redis 会返回给 client 一个 moved 命令,告知其正确的路由信息从而重新发起请求。client 会根据每次请求来缓存本地的路由缓存信息,以便下次请求直接能够路由到正确的节点
- 分片迁移:分片迁移的触发和过程控制由外部系统完成,redis 只提供迁移过程中需要的原语支持。主要包含两种:一种是节点迁移状态设置,即迁移钱标记源、目标节点;另一种是key 迁移的原子化命令
failover 故障转移
- 故障发现:节点间两两通过 TCP 保持连接,周期性进行 PING、PONG 交互,若对方的 PONG 相应超时未收到,则将其置为 PFAIL 状态,并传播给其他节点
- 故障确认:当集群中有一半以上的节点对某一个 PFAIL 状态进行了确认,则将起改为 FAIL 状态,确认其故障
- slave 选举:当有一个 master 挂掉了,则其 slave 重新竞选出一个新的 master。主要根据各个 slave 最后一次同步 master 信息的时间,越新表示 slave 的数据越新,竞选的优先级越高,就更有可能选中。竞选成功之后将消息传播给其他节点。
集群不可用的情况:
- 集群中任意 master 挂掉,且当前 master 没有 slave。
- 集群中超过半数以上 master 挂掉。
其他
普通哈希算法和一致性哈希算法对比
普通哈希:也称硬哈希,采用简单取模的方式,将机器进行散列,这在 cache 环境不变的情况下能取得让人满意的结果,但是当cache环境动态变化时,这种静态取模的方式显然就不满足单调性的要求(当增加或减少一台机子时,几乎所有的存储内容都要被重新散列到别的缓冲区中)。
一致性哈希:将机器节点和 key 值都按照一样的 hash 算法映射到一个 0~2^32 的圆环上。当有一个写入缓存的请求到来时,计算 Key 值 k 对应的哈希值 Hash(k),如果该值正好对应之前某个机器节点的 Hash 值,则直接写入该机器节点,如果没有对应的机器节点,则顺时针查找下一个节点,进行写入,如果超过 2^32 还没找到对应节点,则从 0 开始查找(因为是环状结构)。为了更可能的满足平衡性,可以引入虚拟节点,即一个实体节点映射到多个虚拟节点。
参考:http://blog.huanghao.me/?p=14
缓存雪崩,缓存穿透,缓存并发,缓存预热,缓存算法
- 缓存雪崩:可能是因为数据未加载到缓存中,或者缓存同一时间大面积的失效,从而导致所有请求都去查数据库,导致数据库 CPU 和内存负载过高,甚至宕机。解决思路:
加锁计数(即限制并发的数量,可以用 semphore)或者起一定数量的队列来避免缓存失效时大量请求并发到数据库。但这种方式会降低吞吐量。
分析用户行为,然后失效时间均匀分布。或者在失效时间的基础上再加 1~5 分钟的随机数。
如果是某台缓存服务器宕机,则考虑做主备。
- 缓存穿透:指用户查询数据,在数据库没有,自然在缓存中也不会有。这样就导致用户查询的时候,在缓存中找不到,每次都要去数据库中查询。解决思路:
如果查询数据库也为空,直接设置一个默认值存放到缓存,这样第二次到缓冲中获取就有值了,而不会继续访问数据库。设置一个过期时间或者当有值的时候将缓存中的值替换掉即可。
可以给 key 设置一些格式规则,然后查询之前先过滤掉不符合规则的 Key。
- 缓存并发:如果网站并发访问高,一个缓存如果失效,可能出现多个进程同时查询DB,同时设置缓存的情况,如果并发确实很大,这也可能造成DB压力过大,还有缓存频繁更新的问题。解决思路:
对缓存查询加锁,如果KEY不存在,就加锁,然后查DB入缓存,然后解锁;其他进程如果发现有锁就等待,然后等解锁后返回数据或者进入DB查询。
- 缓存预热:目的就是在系统上线前,将数据加载到缓存中。解决思路:
数据量不大的话,在系统启动的时候直接加载。
自己写个简单的缓存预热程序。
- 缓存算法:
FIFO 算法:First in First out,先进先出。原则:一个数据最先进入缓存中,则应该最早淘汰掉。也就是说,当缓存满的时候,应当把最先进入缓存的数据给淘汰掉。
LFU 算法:Least Frequently Used,最不经常使用算法。
LRU 算法:Least Recently Used,近期最少使用算法。
LRU 和 LFU 的区别。LFU算法是根据在一段时间里数据项被使用的次数选择出最少使用的数据项,即根据使用次数的差异来决定。而LRU是根据使用时间的差异来决定的。
用redis实现分布式锁
主要使用的命令:
1 | setnx key val。当且仅当key不存在时,set一个key为val的字符串,返回1;若key存在,则什么都不做,返回0。 |
实现思想:
使用 setnx 加锁,如果返回1,则说明加锁成功,并设置超时时间,避免系统挂了,锁没法释放。在 finally 中 delete 删除锁释放。
如果需要设置超时等待时间,则可以加个 while 循环,在获取不到锁的情况下,进行循环获取锁,超时了则退出。
参考
https://segmentfault.com/a/1190000012919740
https://www.jianshu.com/p/6023c8fa5937
https://blog.csdn.net/wuyangyang555/article/details/82152005
https://blog.csdn.net/fly43108622/article/details/52964491
https://blog.csdn.net/waeceo/article/details/78701397
Redis 相关的问题