一 Redis 概述
- 属于一种 NoSQL(非关系型数据库)
- Redis 的数据都在内存中,并且支持持久化,主要用作备份恢复
- 支持多种数据结构的存储:
string
、list
、set
、hash
、zset
等,这些数据类型都支持 push / pop、add / remove 及取交集并集和差集等操作,而且这些操作都是原子性的,但 Redis 事务不具有原子性 - 一般作为缓存数据库,辅助持久化的数据库
1 Redis 为什么是单线程,单线程为什么这么快
- 因为 Redis 是基于内存的操作,CPU 不是 Redis 的瓶颈,Redis 的瓶颈最有可能是内存大小或者网络带宽
- 在单线程的情况下,处理逻辑更简单,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗,不存在多进程或者多线程导致的切换而消耗 CPU
- Redis 采用网络 I/O 多路复用技术,来保证在多连接的时候系统的高吞吐量
Redis 单线程指的是读写数据和进行网络 I/O 时是单线程,持久化、集群数据同步等过程需要子进程的协助
2 Redis 字典数据结构
- Redis 的 Db 默认情况下有16个,每个 redisDb 内部包含一个 dict 的数据结构
- Redis 不支持为每个数据库设置不同的访问密码,所以一个客户端要么可以访问全部数据库,要么连一个数据库也没有权限访问
- 多个数据库之间并不是完全隔离的,这些数据库更像是一种命名空间,而不适宜存储不同应用程序的数据
- 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不在进行时,值为 -1
int rehashidx;
//目前正在运行的安全迭代器的数量
int iterators;
} dict;
typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值,总是等于 size - 1
unsigned 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 不是用这个数组来保存字符, 而是用它来保存一系列二进制数据
- SDS不依赖空字符(‘\0’)作为字符串结束标识
- C语言字符串的限制:在C语言中,字符串是以空字符(‘\0’)作为结束标识的,这种设计使得C语言字符串无法直接存储包含空字符的二进制数据,因为空字符会被错误地解释为字符串的结束
- SDS的改进:SDS通过显式地存储字符串的长度(在SDS的头部结构中),而不是依赖空字符来结束字符串,从而避免了这个问题(简而言之,使用长度边界而非符号边界,标记字符串的结束位置)
- 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 编码来进行存储
- 哈希对象中的所有键值对总长度小于64字节(这个阈值可以通过参数hash-max-ziplist-value 来进行控制)
- 哈希对象中的键值对数量小于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
- 每个元素都关联了一个 score,被用来按照从最低分到最高分的方式排序集合中的成员
- 集合里的成员是唯一的,但是 score 可以重复
- 底层实现为 ziplist 或 跳表+字典:
- 跳表的作用是根据 score 给元素排序,方便获取指定 score 范围的元素列表,支持平均
O(logn)
的查询效率 - 每次创建一个新跳表节点的时候,程序都根据幂次定律(power law,越大的数出现的概率越小)随机生成一个1和32之间的值作为新节点所在的层的高度
- 如果按照成员访问,直接从字典取值;如果按照分数范围访问,从跳表中取值
- 跳表详解
- 跳表的作用是根据 score 给元素排序,方便获取指定 score 范围的元素列表,支持平均
三 Redis 事务
- Redis 事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。主要作用就是串联多个命令防止别的命令插队。
- 事务操作通过命令
multi
-discard
/exec
执行
multi
过程中某个命令出现错误,执行时整个的所有命令都会被取消
exec
阶段某个命令出现错误,则只有报错的命令不会被执行
1 watch 命令
watch
命令是一种乐观锁的实现,乐观锁适用于多读的应用类型,可以提高吞吐量- 如果在事务执行之前被
watch
的key
被其他命令改动,那么事务将不被执行 - 在读数据时不认为该数据会被更新,不会上锁
- 在更新的时候会判断:在此期间该数据是否被更新,如果被更新则不能执行,需要获取最新的版本
2 事务的三个特性
- 单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断
- 没有隔离级别的概念:队列中的命令没有提交之前都不会实际被执行
- 不保证原子性:事务中除了执行失败的命令,其它的命令仍然会被执行
四 Redis 持久化
1 RDB(Redis Database Backup)与写时复制
- 在指定的时间间隔内将内存中的数据集快照写入磁盘,恢复时将快照文件直接读到内存里
- RDB 持久化的流程:主进程不进行任何IO操作,而是创建子进程,子进程将快照先写入临时文件,再用这个临时文件替换上次持久化好的文件
- 优点:
- 适合大规模的数据恢复
- 节省磁盘空间,恢复速度快
- 对数据完整性和一致性要求不高时,更适合使用
- 缺点:
- 如果 Redis 意外停止,会丢失最后一次快照后的数据,无法做到实时的持久化
Redis持久化时可以进行写操作吗
BGSAVE
(异步)命令的保存工作是由子进程执行的,所以在子进程下创建RDB文件的过程中,Redis 服务器仍然可以继续处理客户端的命令请求- 子进程是通过
fork
系统调用创建的,刚创建时由于CopyOnWrite
机制会与父进程共享同一块地址空间,此时如果父进程收到写请求,CopyOnWrite
机制就会创建新页面存放修改的数据,不影响持久化的 RDB
写时复制 CopyOnWrite
- 写时复制,通俗来说是多个调用者同时去请求一个资源数据的时候,有一个调用者需要对当前的数据源进行修改,这个时候系统将会复制一个当前数据源的副本给调用者修改
- 写时复制的优势是,在并发的场景下进行读操作不需要加锁
2 AOF(Append Only File)
- AOF 的策略是增量保存,以日志的形式来记录每个写操作(不记录读操作),只允许追加但不可以改写
- AOF 的持久化流程:
- 客户端的请求写命令会被追加到 AOF 缓冲区内
- AOF 缓冲区根据 AOF 同步频率的设置
always / everysec / no
将操作同步到磁盘的 AOF 文件中 - AOF 文件大小超过重写策略或手动重写时,对 AOF 文件
Rewrite
,压缩 AOF 文件容量 - Redis 服务重启时,会重新加载 AOF 文件中的写操作,达到数据恢复的目的
- AOF 同步频率(记入日志指的是刷写到硬盘,基于 Linux
fsync
命令实现,该命令将指定文件从缓冲区强制刷写到硬盘)
always | 始终同步,每次 Redis 的写入都会立刻记入日志 |
---|---|
everysec | 常用设置,每秒同步,每秒记入日志一次 |
no | 不主动进行同步,把同步时机交给操作系统 |
- AOF Rewrite 机制
- AOF 文件的大小超过所设定的阈值时,Redis 就会启动 AOF 文件的内容压缩, 只保留可以恢复数据的最小指令集
- 重写也是子进程完成的,类似 RDB
- AOF 优点
- 丢失数据概率更低
- AOF 缺点
- 比起 RDB 占用更多的磁盘空间,恢复备份速度更慢
五 Redis 分布式和集群
1 分布式与集群
-
分布式:解决高并发问题,将一个业务分拆多个子业务,部署在不同的服务器上。通过将业务拆细,为不同的子业务配置不同性能的服务器,提高整个系统的性能
-
集群:解决高可用问题,同一个业务部署在多个服务器上。分散每台服务器的压力,任意一台或者几台服务器宕机也不会影响整个系统
-
Redis 主从复制是一种 集群 的具体应用,可以实现:读写分离(Master 以写为主,Slave 以读为主)、容灾恢复(高可用)
2 复制原理
- Slave 启动成功,连接到 Master 后会发送一个
sync
命令 - Master 接收到命令后执行持久化,并将持久化的文件发送给 Slave(全量复制)
- Slave 接收到持久化文件,存盘并加载到内存
- 后续 Master 继续将新的所有收集到的修改命令依次传给 Slave(增量复制)
2 哨兵模式 Redis Sentinel
- Redis 官方推荐的高可用解决方案
- 哨兵节点是特殊的 Redis 节点,不存储数据,只支持部分命令
- 哨兵节点集合由多个哨兵节点构成,即使个别节点不可用,集合依然健壮
- 节点故障判断由多个哨兵节点执行,有效防止误判
- 哨兵实现的功能
- 监控:Sentinel 会不断地检查主服务器和从服务器是否运作正常
- 提醒:当被监控的某个 Redis 服务器出现问题时,Sentinel 可以通过 API 发送通知
- 自动故障迁移:当一个主服务器不能正常工作时,Sentinel 会开始自动故障迁移操作,根据一定的策略将失效主服务器的其中一个从服务器升级为新的主服务器,并让失效主服务器的其他从服务器改为复制新的主服务器; 当客户端试图连接失效的主服务器时, 集群也会向客户端返回新主服务器的地址,使得集群可以使用新主服务器代替失效服务器
3 Redis Cluster
- Redis 3.0 版本提出的方案,同样可以实现高可用
- Redis Cluster 是一个 去中心化 的分布式实现方案,客户端和集群中 任一节点 连接,然后通过节点交互,得到全局的数据分片映射关系
- 在 Redis Sentinel 模式中,每个节点需要保存全量数据,冗余比较多,而在Redis Cluster 模式中,每个分片只需要保存一部分的数据
- 根据哈希值确定数据所在的服务节点
- 只有 master 对外提供写服务,读服务可由 master/slave 提供;所有节点之间通过 Redis Bus 连接并交换信息(包括数据分片和节点对应关系、节点可用状态等)
- 解耦数据和节点的关系,便于节点扩容和收缩
4 如何使用 Redis 实现分布式锁
- 基本原理是设置 key-value 代表持有锁,删除对应的 key 代表释放锁
setnx = set if not exist
,在没有该 key 的时候设置 key-valuesetex = set expire
,设置 key-value 同时指定过期时间- 加锁时添加过期时间,防止客户端忘记释放锁或释放失败,导致死锁
- 将设置 key、指定过期时间绑定为原子操作,防止设置过期时间失败,导致死锁
- 加锁时将 value 设置为随机值,用于验证当前进程是否是持有锁的进程,如果是,才可以解锁(防止某进程释放别的进程的锁)
set key random-value nx ex seconds
# 解锁时验证当前进程是否是加锁的进程
...
5 如何使用 Redis 实现分布式 Session
- 服务器之间是隔离的,Session 不共享
- 将 Session 保存在 Redis 而非服务器本地,服务器每次访问 Session 时尝试从 Redis 获取即可
六 典型问题及解决
1 缓存穿透
- 问题:
- key 对应的数据在数据源并不存在,每次针对此 key 的请求从 Redis 缓存获取不到,请求都会传递到数据源,从而可能压垮数据源
- 解决:
- 对空值缓存,如果一个查询返回的数据为空(不管是数据是否不存在),仍然把空结果进行缓存,设置过期时间相对短
- 白名单、布隆过滤器
- 实时监控,当发现 Redis 的命中率开始急速降低,排查访问对象和访问的数据,设置黑名单限制服务
2 缓存击穿
- 问题:
- Redis 中的 某个 key 过期的瞬间被大量请求访问,从后端 DB 加载数据并返回到缓存,大并发的请求可能会瞬间把后端 DB 压垮
- 解决:
- 实时监控
- 预先设置热门数据,加大这些热门数据 key 的时长,或者不设置过期时间
- 使用互斥锁,一个线程访问该数据时,其它线程只能等待
3 缓存雪崩
- 问题:
- 和缓存击穿类似,区别在于缓存雪崩针对很多 key 缓存,缓存击穿则是某一个 key
- 这种情况发生在大批量的 key 同时过期,或者 Redis 服务器崩溃时
- 解决:
- 构建多级缓存架构:Nginx 缓存 + Redis 缓存 + 其他缓存
- 使用锁或队列降低 DB 压力,不适于高并发的情况
- 构建高可用的 Redis 服务:采用哨兵或集群模式,部署多个 Redis 实例,保证个别节点宕机时服务仍然可用
- 避免数据同时过期:设置过期时间时添加一个随机数,将缓存失效时间分散开
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 内存数据达到一定的大小时,根据配置的策略来进行数据淘汰
LFU(Least Frequently Used)
策略淘汰最少使用频率的数据- 在
LRU(Least Recently Used)
的基础上为每个数据维护一个访问次数 - 首先根据访问次数筛选,淘汰访问次数最少的数据
- 如果访问次数相同,淘汰访问时间更早的数据(LRU)
- 在
策略 | 行为 |
---|---|
no-enviction | 默认策略,禁止驱逐数据,仅对写操作返回错误 |
volatile-lru | 从已设置过期时间的数据中,选择最久未使用使用的数据淘汰 |
volatile-lfu | 从已设置过期时间的数据中,选择使用频率最低的数据淘汰 |
volatile-random | 从已设置过期时间的数据中,任意选择数据淘汰 |
volatile-ttl | 从已设置过期时间的数据中,选择将要过期的数据淘汰 |
allkeys-lru | 从所有数据中,选择最久未使用的数据淘汰 |
allkeys-lfu | 从所有数据中,选择使用频率最低的数据淘汰 |
allkeys-random | 从所有数据中,任意选择数据淘汰 |
3 如何决定 key 过期时间
- 热点数据不设置过期时间,避免缓存击穿问题
- 在设置过期时间时,附加一个随机数,避免大量 key 同时过期,造成缓存雪崩