文章目录
- 一 Redis 概述
- Redis 为什么是单线程,单线程为什么这么快?
- 数据存储结构
- 二 常用数据类型
- 1 String
- 2 Hash
- Hash 的扩容机制:渐进式 rehash*
- 3 List
- 4 Set
- 5 Zset
- 三 Redis 事务
- 1 乐观锁与 watch 命令
- 2 事务的三个特性
- 四 Redis 持久化
- 1 RDB(Redis Database)与写时复制
- 2 AOF(Append Only File)
- 五 Redis 高可用:主从复制
- 1 分布式与集群
- 2 Redis 复制原理
- 2 哨兵模式 Redis Sentinel
- 3 Redis Cluster
- 六 典型问题及解决
- 1 缓存穿透
- 2 缓存击穿
- 3 缓存雪崩
- 4 解决缓存穿透:布隆过滤器
- 5 如何保证 Redis 缓存与数据库的一致性
- 七 数据过期与内存淘汰
- 1 数据过期策略:惰性删除 + 定期删除
- 2 内存淘汰策略
一 Redis 概述
- 属于一种 NoSQL(非关系型数据库),另一种常用的非关系型数据库是 MongoDB
- Redis 的数据都在内存中,并且支持持久化,主要用作备份恢复
- 除了支持简单的 key-value 模式,还支持多种数据结构的存储: string、list、set、hash、zset 等,这些数据类型都支持 push / pop、add / remove 及取交集并集和差集等操作,而且这些操作都是原子性的,但 Redis 事务不具有原子性
- 一般是作为缓存数据库辅助持久化的数据库
- Redis 不支持自定义数据库的名字,每个数据库都以编号命名,开发者必须自己记录哪些数据库存储了哪些数据;Redis 也不支持为每个数据库设置不同的访问密码,所以一个客户端要么可以访问全部数据库,要么连一个数据库也没有权限访问;多个数据库之间并不是完全隔离的,这些数据库更像是一种命名空间,而不适宜存储不同应用程序的数据(比如可以使用0号数据库存储某个应用生产环境中的数据,使用1号数据库存储测试环境中的数据,但不适宜使用0号数据库存储A应用的数据而使用1号数据库B应用的数据,不同的应用应该使用不同的Redis实例存储数据)
Redis 为什么是单线程,单线程为什么这么快?
- 因为 Redis 是基于内存的操作,CPU 不是 Redis 的瓶颈,Redis 的瓶颈最有可能是内存大小或者网络带宽。既然单线程容易实现,而且 CPU 不会成为瓶颈,那就顺理成章地采用单线程的方案了
- 在单线程的情况下,处理逻辑更简单,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗,不存在多进程或者多线程导致的切换而消耗 CPU
- Redis 采用网络 I/O 多路复用技术,来保证在多连接的时候系统的高吞吐量
数据存储结构
- Redis 的 Db 默认情况下有16个,每个 redisDb 内部包含一个 dict 的数据结构
- dict 内部包含 ht 的数组,数组个数为2,元素类型为
dictht
,主要用于 hash 扩容使用(参考下面的 Hash 扩容) - dictht 内部包含 dictEntry 的数组,可以理解就是 hash 桶,然后使用拉链法解决冲突
- dictEntry 当中的 key 和 v 的指针指向的是
redisObject
,redisObject
是 Redis server 存储最原子数据的数据结构,其中的void *ptr
会指向真正的存储数据结构
typedef struct redisDb {//数据字典,保存着数据库中的所有键值对dict *dict; //过期字典,字典的值为键的过期时间,是一个UNIX时间戳dict *expires; //正处于阻塞状态的键dict *blocking_keys;//可以解除阻塞的键dict *ready_keys;//正在被 WATCH 命令监视的键dict *watched_keys;//失效池,根据对象lru时间戳保存要被淘汰的对象struct evictionPoolEntry *eviction_pool;int id;//数据库的键的平均 TTL ,统计信息long long avg_ttl;
} redisDb;typedef struct dict {//类型特定函数dictType *type;//私有数据void *privdata;//哈希表dictht ht[2];//rehash 索引,当rehash不在进行时,值为 -1int rehashidx;//目前正在运行的安全迭代器的数量int iterators;
} dict;typedef struct dictht {//哈希表数组dictEntry **table;//哈希表大小unsigned long size;//哈希表大小掩码,用于计算索引值,总是等于 size - 1unsigned long sizemask;// 该哈希表已有节点的数量unsigned long used;
} dictht;typedef struct dictEntry{//键void *key;//值union {void *val;uint64_t u64;int64_t s64;} v;//指向下个哈希表节点的指针(拉链法解决哈希冲突)struct dictEntry *next;
} dictEntry;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;
二 常用数据类型
1 String
参考链接
- 具有二进制安全(binary safe)特性,这意味着它的长度是已知的,不由任何其他终止字符决定的,一个字符串类型的值最多能够存储 512 MB 的内容。所有 SDS API 都会以处理二进制的方式来处理 SDS 存放在 buf 数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设(保证数据在写入时是什么样的, 它被读取时就是什么样),这也是 SDS 的 buf 属性称为字节数组的原因,Redis 不是用这个数组来保存字符, 而是用它来保存一系列二进制数据
- Redis 实现了SDS(Simple Dynamic String,简单动态字符串)的抽象类型,它通过存储额外数据能简单地得到自身信息:总长度、可用长度等
- SDS 遵循 C 字符串以空字符结尾的惯例, 保存空字符的 1 字节空间不计算在 SDS 的 len 属性里面, 并且为空字符分配额外的 1 字节空间(遵循空字符结尾这一惯例的好处是, SDS 可以直接重用一部分 C 字符串函数库里面的函数)
- SDS 还被用作缓冲区,比如 AOF 模块中的 AOF 缓冲区
struct sdshdr {// 记录 buf 数组中已使用字节的数量// 等于 SDS 所保存字符串的长度int len;// 记录 buf 数组中未使用字节的数量int free;// 字节数组,用于保存字符串char buf[];};
2 Hash
- 底层存储使用 ziplist 或 hashtable(再底层是字典)
当一个哈希对象可以满足以下两个条件时,哈希对象会选择使用ziplist编码来进行存储:
1 哈希对象中的所有键值对总长度(包括键和值)小于64字节(这个阈值可以通过参数hash-max-ziplist-value 来进行控制)
2 哈希对象中的键值对数量小于512个(这个阈值可以通过参数hash-max-ziplist-entries 来进行控制)一旦不满足这两个条件中的任意一个,哈希对象就会选择使用hashtable来存储
- 使用拉链法解决哈希冲突
- 可以理解成一个包含了多个键值对的集合,一般用于存储对象
Hash 的扩容机制:渐进式 rehash*
将 rehash 的操作分摊在每一个的访问中,避免集中式 rehash 可能会导致服务器在一段时间内停止服务
参考链接
-
Hash 底层有两个数组
ht[0]
和ht[1]
,还有一个rehashidx
用来控制 rehash 过程
-
初始默认长度为4,当元素个数与 Hash 表长度一致时(负载因子为1)发生扩容,长度变为原来的二倍;同时
rehashindex
的值设置为0,表示 rehash 工作正式开始
-
在 rehash 期间,每次对字典执行增删改查时,还会顺带将
ht[0]
哈希表在rehashindex
索引上的所有键值对rehash
到ht[1]
,当 rehash 工作完成以后,rehashindex
的值 +1
-
随着字典操作的不断执行,最终会在某一时间段上
ht[0]
的所有键值对都会被 rehash 到ht[1]
,这时将rehashindex
的值设置为 -1,表示 rehash 操作结束 -
在渐进式 rehash 的过程中,如果有增删改查操作,
index
大于rehashindex
,访问ht[0]
,否则访问ht[1]
3 List
- 单键多值,内容按照插入的顺序排序(
lpush
和rpush
的顺序不同),可以向头部或尾部添加数据 - 底层是快速链表 QuickList,每个部分是压缩链表 ZipList(内部是一段连续的内存),所以只需要额外存储 ZipList 之间的指针
4 Set
- 单键多值,集合中的元素无序不重复
- 底层实现为 intset 或 hashtable
- intset 实现为数组,这个数组以有序、无重复的方式保存集合元素,在有需要时,程序会根据新添加元素的类型,改变这个数组的类型
5 Zset
- ZSet 基于跳表实现,是一种有序的数据结构,支持平均
O(logn)
的查询效率 - 每个元素都关联了一个 score,被用来按照从最低分到最高分的方式排序集合中的成员;集合里的成员是唯一的,但是 score 可以重复
- 底层实现为 ziplist 或 跳表:
跳表的作用是根据 score 给元素排序,方便获取指定 score 范围的元素列表
每次创建一个新跳表节点的时候,程序都根据幂次定律(power law,越大的数出现的概率越小)随机生成一个1和32之间的值作为新节点所在的层的高度
跳表的插入/删除首先需要执行查找,平均时间复杂度O(logn)
- 跳表详解
三 Redis 事务
- Redis 事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。主要作用就是串联多个命令防止别的命令插队。
- 事务操作通过命令
multi
-discard
/exec
执行
multi
过程中某个命令出现错误,执行时整个的所有命令都会被取消
exec
阶段某个命令出现错误,则只有报错的命令不会被执行
1 乐观锁与 watch 命令
- 在读数据时不认为该数据会被更新,不会上锁
- 在更新的时候会判断:在此期间该数据是否被更新,如果被更新则不能执行,需要获取最新的版本。可以使用版本号等机制
- 乐观锁适用于多读的应用类型,可以提高吞吐量
- 如果在事务执行之前被 watch 的 key 被其他命令改动,那么事务将被打断
2 事务的三个特性
- 单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断
- 没有隔离级别的概念:队列中的命令没有提交之前都不会实际被执行
- 不保证原子性:事务中除了执行失败的命令,其它的命令仍然会被执行
四 Redis 持久化
1 RDB(Redis Database)与写时复制
- 在指定的时间间隔内将内存中的数据集快照写入磁盘,恢复时将快照文件直接读到内存里
- RDB 持久化的流程:主进程不进行任何IO操作,而是创建子进程,子进程将快照先写入临时文件,再用这个临时文件替换上次持久化好的文件
- 优点:
适合大规模的数据恢复
节省磁盘空间,恢复速度快
对数据完整性和一致性要求不高时,更适合使用 - 缺点:
如果 Redis 意外停止,会丢失最后一次快照后的数据
Redis持久化时可以进行写操作吗?
BGSAVE
命令的保存工作是由子进程执行的,所以在子进程下创建RDB文件的过程中,Redis 服务器仍然可以继续处理客户端的命令请求- 子进程是通过
fork
系统调用创建的,刚创建时由于CopyOnWrite
机制会与父进程共享同一块地址空间,此时如果父进程收到写请求,CopyOnWrite
机制就会创建新页面存放修改的数据,不影响持久化的 RDB
- 写时复制,通俗来说是多个调用者同时去请求一个资源数据的时候,有一个调用者需要对当前的数据源进行修改,这个时候系统将会复制一个当前数据源的副本给调用者修改
- 写时复制的优势是,在并发的场景下进行读操作不需要加锁
2 AOF(Append Only File)
- AOF 的策略是增量保存,以日志的形式来记录每个写操作(不记录读操作)
- 对于 AOF 文件,只允许追加但不可以改写
- AOF 的持久化流程:
客户端的请求写命令会被追加到 AOF 缓冲区内
AOF 缓冲区根据 AOF 同步频率的设置always / everysec / no
将操作同步到磁盘的 AOF 文件中
AOF 文件大小超过重写策略或手动重写时,对 AOF 文件Rewrite
,压缩 AOF 文件容量
Redis 服务重启时,会重新加载 AOF 文件中的写操作,达到数据恢复的目的 - AOF 同步频率:
always | 始终同步,每次 Redis 的写入都会立刻记入日志 |
---|---|
everysec | 每秒同步,每秒记入日志一次 |
no | 不主动进行同步,把同步时机交给操作系统 |
Rewrite
:AOF 文件的大小超过所设定的阈值时,Redis 就会启动 AOF 文件的内容压缩, 只保留可以恢复数据的 最小指令集。重写也是fork
子进程完成的,类似 RDB- AOF 优点:
丢失数据概率更低 - AOF 缺点:
比起 RDB 占用更多的磁盘空间,恢复备份速度更慢
五 Redis 高可用:主从复制
参考链接
1 分布式与集群
-
分布式:解决高并发问题,将一个业务分拆多个子业务,部署在不同的服务器上。通过将业务拆细,为不同的子业务配置不同性能的服务器,提高整个系统的性能
-
集群:解决高可用问题,同一个业务部署在多个服务器上。分散每台服务器的压力,任意一台或者几台服务器宕机也不会影响整个系统
-
Redis 主从复制是一种 集群 的具体应用,可以实现:读写分离(Master 以写为主,Slave 以读为主)、容灾恢复(高可用)
2 Redis 复制原理
- Slave 启动成功,连接到 Master 后会发送一个
sync
命令 - Master 接收到命令后执行持久化,并将持久化的文件发送给 Slave(全量复制)
- Slave 接收到持久化文件,存盘并加载到内存
- 后续 Master 继续将新的所有收集到的修改命令依次传给 Slave(增量复制)
2 哨兵模式 Redis Sentinel
- Redis 官方推荐的高可用解决方案
- 哨兵实现的功能
- 监控:Sentinel 会不断地检查主服务器和从服务器是否运作正常
- 提醒:当被监控的某个 Redis 服务器出现问题时,Sentinel 可以通过 API 发送通知
- 自动故障迁移:当一个主服务器不能正常工作时,Sentinel 会开始自动故障迁移操作,根据一定的策略将失效主服务器的其中一个从服务器升级为新的主服务器,并让失效主服务器的其他从服务器改为复制新的主服务器; 当客户端试图连接失效的主服务器时, 集群也会向客户端返回新主服务器的地址,使得集群可以使用新主服务器代替失效服务器
3 Redis Cluster
- Redis 3.0 版本提出的方案,同样可以实现高可用
- Redis Cluster 是一个 去中心化 的分布式实现方案,客户端和集群中 任一节点 连接,然后通过节点交互,得到全局的数据分片映射关系
- 在 Redis Sentinel 模式中,每个节点需要保存全量数据,冗余比较多,而在Redis Cluster 模式中,每个分片只需要保存一部分的数据
- 引入哈希方法,根据哈希值确定数据所在的服务节点
- 只有 master 对外提供写服务,读服务可由 master/slave 提供;所有节点之间通过 Redis Bus 连接并交换信息(包括数据分片和节点对应关系、节点可用状态等)
六 典型问题及解决
1 缓存穿透
- 问题:
key 对应的数据在数据源并不存在,每次针对此 key 的请求从 Redis 缓存获取不到,请求都会传递到数据源,从而可能压垮数据源 - 解决:
1、对空值缓存,如果一个查询返回的数据为空(不管是数据是否不存在),仍然把空结果进行缓存,设置过期时间相对短
2、白名单、布隆过滤器
3、实时监控,当发现 Redis 的命中率开始急速降低,排查访问对象和访问的数据,设置黑名单限制服务
2 缓存击穿
- 问题:
Redis 中的 某个 key 过期了,但这个 key 又被超高并发地访问,从后端 DB 加载数据并返回到缓存,这个时候大并发的请求可能会瞬间把后端 DB 压垮 - 解决:
1、实时监控
2、预先设置热门数据,在 Redis 高峰访问之前,把一些热门数据提前存入到 Redis 里面,加大这些热门数据 key 的时长
3、使用锁,保证 DB 不会承受过高的访问压力,但是了降低效率
3 缓存雪崩
- 问题:
和缓存击穿类似,区别在于,缓存雪崩针对很多 key 缓存,缓存击穿则是某一个 key - 解决:
1、构建多级缓存架构:nginx 缓存 + redis 缓存 +其他缓存(ehcache 等)
2、使用锁或队列降低 DB 压力,但降低效率,不适于高并发的情况
3、记录缓存数据是否过期(可以在此设置提前量),如果过期(或快过期)会触发通知另外的线程,后台更新 key 的缓存
4、将缓存失效时间分散开
4 解决缓存穿透:布隆过滤器
- 由 一个 二进制向量(或者说位数组)和 一系列 随机映射函数(哈希函数)两部分组成的数据结构
- 对于一个字符串,用所有的哈希函数对其进行计算,将得到的一系列值作为下标,并将位数组对应位置的值设置为1;如果该字符串再次插入,用相同的方法验证出位数组的对应位置都是1,则说明重复加入
- 理论情况下添加到集合中的元素越多,误报的可能性就越大
- 不同的字符串可能哈希出来的位置相同,这种情况可以适当增加位数组大小,或者调整哈希函数
- 布隆过滤器认为某个元素存在,小概率会误判。布隆过滤器认为某个元素不在,那么这个元素一定不在
5 如何保证 Redis 缓存与数据库的一致性
参考链接
- 四种同步策略:
- 先更新缓存,再更新数据库
- 先更新数据库,再更新缓存
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
策略 | 优点 | 缺点 |
---|---|---|
更新缓存 | 查询时不易出现未命中的情况 | 频繁的更新缓存影响服务器的性能 |
删除缓存(更优) | 操作简单,无论更新操作是否复杂,都是将缓存中的数据直接删除 | 删除缓存后,下一次查询缓存会出现未命中,需要重新读取一次数据库 |
-
先删除缓存再更新数据库
情况1:线程A删除缓存成功,更新数据库失败时,缓存和数据库的数据是一致的,但仍然是旧的数据
情况2:线程A删除缓存成功,更新数据库也成功,但B的读操作发生在线程A两次操作之间,导致 缓存中存储了旧的数据,而数据库中存储了新的数据,二者数据不一致
-
先更新数据库再删除缓存(推荐)
情况1:线程A更新数据库成功,线程A删除缓存失败(后续会尝试重新删除缓存),缓存和数据库的数据是一致的,但是会有一些线程读到旧的数据
情况2:线程A更新数据库成功,线程A删除缓存也成功,缓存和数据库的数据是一致的,但是会有一些线程读到旧的数据,但A的两步操作执行速度比较快,影响并不大
七 数据过期与内存淘汰
1 数据过期策略:惰性删除 + 定期删除
对过期的数据进行删除
- 定时删除:每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即清除。这种策略对内存友好,但对 CPU 不友好
- 惰性删除:在获取某个 key 的时候,Redis 对该数据进行检查,如果该 key 设置了过期时间则判断该过期时间是否已经过期,如果过期则删除。这种策略对 CPU 是友好的,但对内存不友好
- 定期删除:Redis 每隔固定时间,随机抽取一些设置了过期时间的 key,检查其是否过期,如果过期就删除。是上述两种方法的折中
2 内存淘汰策略
当 Redis 内存数据达到一定的大小时,根据配置的策略来进行数据淘汰(被淘汰的数据不是过期的数据)
策略 | 行为 |
---|---|
no-enviction | 默认策略,禁止驱逐数据,仅对写操作返回错误 |
volatile-lru | 从已设置过期时间的数据中,选择最近最少使用的数据淘汰 |
volatile-ttl | 从已设置过期时间的数据中,选择将要过期的数据淘汰 |
volatile-random | 从已设置过期时间的数据中,任意选择数据淘汰 |
allkeys-lru | 从所有数据中,选择最近最少使用的数据淘汰 |
allkeys-random | 从所有数据中,任意选择数据淘汰 |