Redis 基础知识
Redis 基础知识校招面经整理、热身
1. Redis的数据结构:
Redis 的数据库的基本单元是一个个「键值对」,键值对由「对象」组成, 具体是后述 5 种对象之一。
键值对中的键一定是字符串对象,可以认为是这个键值对的「名字」
键值对中的值可以是任意类型的某个具体的对象,可以认为是这个键值对的「本体」。
Redis 还有许多基本的数据结构如 简单动态字符串(SDS)、双端链表(linkedlist)、字典(dict)、跳跃表(skiplist)、压缩列表(ziplist)、整数集合(intset) 等,由这些数据结构的一种或多种(作为编码方式等),Redis 可以实现我们数据操作的基本单元「对象」。
2. Redis的各对象底层结构:
所有redis的对象都由一个 redisObject 结构表示,有如下几个与数据有关的字段:
unsigned type // 类型(如 String、List、Hash等)
unsigned encoding // 编码方式(底层数据结构的实现方式)
void *ptr; // 指向底层数据结构的指针
// … 其他字段
String 字符串对象
编码方式
int: 存储可以用 long 表示的整数
embstr: 浮点数、超过 long 范围的整数、或者一个字符串,但要求字符串 len 小于等于 39 字节。 embstr 结构与
raw(即用 SDS 存储): 浮点数、超过 long 范围的整数、或者一个字符串,无长度限制List 列表对象
每个结点都是一个 String 字符串对象
当所有元素的 len 都小于 64 字节,并且总的元素个数小于 512 个,则优先采用 ziplist 编码,否则转化为 linkedlist 编码Hash 哈希对象
保存一个个键值对,键值对的键和值均为 String 字符串对象
当所有元素的 len 都小于 64 字节,并且总的元素个数小于 512 个,则优先采用 ziplist 编码,否则转化为 hashtable 编码Set 集合对象
保存一个个元素,元素可以是整数值(当使用 intset 编码时),也可以是一个 String 字符串对象
当所有元素的都是整数值,并且总的元素个数小于 512 个,则优先采用 intset 编码,否则转化为 hashtable 编码ZSet 有序集合对象
保存一个个元素,每个元素的「名字」member 是一个 String 字符串对象,而其对应的「分值」 score 是一个 String 字符串对象表示的浮点数。
每个元素的「名字」和「分值」的映射关系存储在一个字典 dict 中
而每个元素的顺序关系则以 ziplist 或 skiplist 实现,当所有元素的「名字」的 len 都小于 64 字节,并且总的元素个数小于 128 个,则优先采用 ziplist 编码,否则转化为 skiplist 编码
3. 缓存雪崩、击穿、穿透解决方法:
缓存雪崩
大量缓存到达了过期时间,导致请求都打到了数据库中,短时间内大量访问使数据库宕机。
解决:- redis cluster 多增加几台服务器
- 限流降级 缓存失效后,通过加锁或者 队列 来限制数据库执行缓存写操作的线程数量、限制客户端访问缓存的线程数量。实现 1 key 同时只被 1 个线程写、或者查询
- 数据预热 预先访问一些可能的数据,错开刷新缓存时间,(或者)给缓存设置不同的过期时间(比如在一个范围内随机),让缓存失效在时间上均匀分布
缓存击穿
类似雪崩,但是发生在单个 key 上
解决:- 设置热点数据永不过期
- 限流降级 数据库层面给这个 key 的访问加锁
缓存穿透
大量用户查询的数据在数据库中没有,导致缓存不命中,又导致大量对数据库的访问。
解决:- 布隆过滤器:预先存储可能查询的 key 对应的多次 hash 值。拦截不符合的查询请求。
布隆过滤器将 key 值用 k 个哈希函数依次运算,将每次运算后对应的数字所在的下标位置 1 。
缺点:不好删除;会有误判,但只要误判率足够低,也不会很影响数据库性能。 - 缓存空对象:一次不命中后,缓存该 key,但 value 为 null,设置过期时间,后续对此 key 的查询会拦截在缓存层。
缺点:缓存里会有很多空对象,占用内存;缓存层和数据层会有一段时间不一致。
- 布隆过滤器:预先存储可能查询的 key 对应的多次 hash 值。拦截不符合的查询请求。
4. 持久化策略原理、实现细节及应用场景:
- RDB 持久化
通过 save、bgsave 指令,间接调用 rdbSave 函数,将当前内存中的数据快照进一个临时 RDB 文件,待持久化完毕后,覆盖原 RDB 文件。
RDB 在服务器启动时自动载入,但优先级落后于 AOF 文件,在 AOF 持久化关闭时,才会执行 RDB 文件的载入。
触发机制:- 自动间隔性保存:满足了 save 中设定的任意一个条件,即可触发自动 bgsave
- 执行了 flushall 命令
- 退出 Redis
优点:适合大规模数据恢复,效率高;对数据完整性要求不高。
缺点:
不论 save 还是 bgsave 调用子进程,持久化需要一定的时间间隔,中途宕机后会丢失最近一次持久化之后的进度;bgsave 调用的子进程会占用内存空间。
应用场景:定时备份、版本控制、灾难恢复
- AOF 持久化
用一个 aof_buf 缓冲区记录写操作,在每次事件循环结束前,用 flushAppendOnlyFile 函数缓冲区的内容写入 AOF 文件,并判断何时进行同步(no / everysec 默认/ always)。
AOF 载入:使用一个不带连接的伪客户端,重播 AOF 中的命令,直到文件载入完毕。
AOF 重写:将多条指令合并为一条效果相同的指令,节约空间。
优点:复制频率较高,不易丢失数据
缺点:AOF 文件大小往往远大于 RDB 文件,修复速度也较慢;AOF 运行效率也慢于 RBD。
应用场景:对数据完整性要求比较高的情形(希望在服务器故障后,能完全恢复到故障前的状态)
5. 主从复制原理及故障恢复:
完整重同步:客户端向从服务器发送 slaveof 命令 - 从服务器执行同步,向主服务器发送 sync 命令 - 主服务器执行 bgsave 生成 RDB 文件,同时用一个缓冲区记录当前开始的写命令(类似 AOF) - 待 bgsave 执行完毕,主服务器将 RDB 文件发送给从服务器,从服务器载入这个 RDB 文件 - 主服务器接着将缓冲区的命令发送给从服务器,从服务器重播这些命令,完成同步
完成同步后,还要搭配 命令传播,保证一段时间内的主从一致
缺点:断线后复制,需要完整复制整个 RDB 文件,而从服务器断线前的大部分数据全部作废,非常浪费性能。新版本:用 psync 代替 sync,完整重同步与 sync 无区别,但断线后重复制,则采用 部分重同步:
- 主、从服务器分别维护自己的 复制偏移量 offset,主服务器接收新数据就更新,并将数据通过命令传播到从服务器,帮助其更新。
- 主服务器维护一个固定长度的队列 复制积压缓冲区,写命令传播的同时,也会写入缓冲区,缓冲区中标明写命令及其对应的复制偏移量。
- 每个服务器启动时会生成一个 服务器运行 ID run ID,由随机的 40 个十六进制字符组成。从服务器在首次对主服务器进行复制时,会保存由主服务器发出的 run ID,以备后续校验。
- 这样,当从服务器重连后,发送自己保存的 run ID 以及其复制偏移量 offset 给主服务器,主服务器核对两者满足条件(run ID 为自己的 run ID,且 offset 落在缓冲区内),只需实现部分重同步,主服务器将缓冲区内数据发送给从服务器即可;否则,执行完整重同步。
6. 分布式锁:
- 分布式锁用于解决并发竞争 key 的问题。Redis 中利用 setnx 实现。
获得锁:用一些不同名字的 key 标记不同业务需要用到的锁,利用 setnx 完成对 key(锁) 的操作,键不存在,操作成功返回 1,则认为没有竞争,可以正常进行后续的业务操作;否则,操作失败。
解锁:完成业务逻辑后需要执行 del(key) 操作,释放锁。
锁超时:获得锁时使用 expire 命令给锁设置超时时间,即使因为某些故障没有释放锁,锁也会自动释放。
缺点1:setnx 与 expire 不是原子操作,中途故障会导致锁永远无法释放。
改进:用 lua 脚本将其原子化,或者使用最新 redis 版本,支持 SET key value EX seconds PX milliseconds NX
缺点2:锁误解除,线程获取锁后执行用时超过 expire 时间,锁被其他线程获取,但原线程完成工作后删除此锁。
改进:将 key 对应的 value 设置为一个标识符标识所属线程,线程只能删除对应自己标识符的锁。
缺点3:超时解锁导致并发,线程获取锁后工作超时,其他线程获取锁后与原线程产生竞争。
改进:过期时间设置得足够长,或者为获取到锁的线程增加守护线程,为将过期的锁自动续期。
缺点4:不可重入,线程已持有锁的情况下再次请求加锁,如果不可重入的话会导致操作失败。
改进1:在 Redis Map 结构中设置重入次数,加锁时增 1,解锁时减 1,归 0 时释放。
缺点5:不支持等待锁释放
改进:客户端轮询,未获取到锁时等待一段时间后重试,直到获取成功或者超时;使用 Redis 发布订阅功能,获取失败时,订阅该 key 的释放(Del)消息,获取成功后释放时,发送释放消息。
- 也可使用 redlock 红锁实现分布式锁。
7. 缓存(Redis)与数据库的一致性问题:
- 缓存设置过期时间
- 先更新 DB 还是先更新缓存?
- 应该先更新 DB,因为多数时候我们以实际 DB 为准,先更新 DB 保证了基准数据一致。缓存不一致只是暂时的;如果反过来先更新缓存,那么当更新 DB 失败时(更新 DB 所花费的时间更长),这个缓存不一致的时间就会更长。
- 旧缓存要淘汰还是更新?
- 应该采用淘汰机制,当发生针对数据库的 DML 写操作时,直接淘汰缓存中对应的 key。
这样虽然会触发一次 miss,但实现逻辑简单。 - 并且应对缓存与数据库多次交互的情形,淘汰缓存性能更佳,更适合串行化。如果采用更新机制,需要处理复杂的并发写问题、DB 交互查询等问题。
- 应该采用淘汰机制,当发生针对数据库的 DML 写操作时,直接淘汰缓存中对应的 key。
- 使用重试机制,确保淘汰缓存和数据库更新操作能够完成。
- 采用「先淘汰缓存,后更新数据库」的策略,采用订阅 binlog 的方式异步更新缓存,也能较好保证数据一致性。
- 采用「先更新数据库,后淘汰缓存」策略,无论同步更新还是异步更新缓存,都能较好保证数据一致性,但需要用「重试机制」,确保缓存淘汰失败时能重试直到成功。