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. 缓存雪崩、击穿、穿透解决方法:

  • 缓存雪崩
    大量缓存到达了过期时间,导致请求都打到了数据库中,短时间内大量访问使数据库宕机。
    解决:

    1. redis cluster 多增加几台服务器
    2. 限流降级 缓存失效后,通过加锁或者 队列 来限制数据库执行缓存写操作的线程数量、限制客户端访问缓存的线程数量。实现 1 key 同时只被 1 个线程写、或者查询
    3. 数据预热 预先访问一些可能的数据,错开刷新缓存时间,(或者)给缓存设置不同的过期时间(比如在一个范围内随机),让缓存失效在时间上均匀分布
  • 缓存击穿
    类似雪崩,但是发生在单个 key 上
    解决:

    1. 设置热点数据永不过期
    2. 限流降级 数据库层面给这个 key 的访问加锁
  • 缓存穿透
    大量用户查询的数据在数据库中没有,导致缓存不命中,又导致大量对数据库的访问。
    解决:

    1. 布隆过滤器:预先存储可能查询的 key 对应的多次 hash 值。拦截不符合的查询请求。
      布隆过滤器将 key 值用 k 个哈希函数依次运算,将每次运算后对应的数字所在的下标位置 1 。
      缺点:不好删除;会有误判,但只要误判率足够低,也不会很影响数据库性能。
    2. 缓存空对象:一次不命中后,缓存该 key,但 value 为 null,设置过期时间,后续对此 key 的查询会拦截在缓存层。
      缺点:缓存里会有很多空对象,占用内存;缓存层和数据层会有一段时间不一致。

4. 持久化策略原理、实现细节及应用场景:

  • RDB 持久化
    通过 save、bgsave 指令,间接调用 rdbSave 函数,将当前内存中的数据快照进一个临时 RDB 文件,待持久化完毕后,覆盖原 RDB 文件。
    RDB 在服务器启动时自动载入,但优先级落后于 AOF 文件,在 AOF 持久化关闭时,才会执行 RDB 文件的载入。
    触发机制:
    1. 自动间隔性保存:满足了 save 中设定的任意一个条件,即可触发自动 bgsave
    2. 执行了 flushall 命令
    3. 退出 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 无区别,但断线后重复制,则采用 部分重同步:

    1. 主、从服务器分别维护自己的 复制偏移量 offset,主服务器接收新数据就更新,并将数据通过命令传播到从服务器,帮助其更新。
    2. 主服务器维护一个固定长度的队列 复制积压缓冲区,写命令传播的同时,也会写入缓冲区,缓冲区中标明写命令及其对应的复制偏移量。
    3. 每个服务器启动时会生成一个 服务器运行 ID run ID,由随机的 40 个十六进制字符组成。从服务器在首次对主服务器进行复制时,会保存由主服务器发出的 run ID,以备后续校验。
    4. 这样,当从服务器重连后,发送自己保存的 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 交互查询等问题。
  • 使用重试机制,确保淘汰缓存和数据库更新操作能够完成。
  • 采用「先淘汰缓存,后更新数据库」的策略,采用订阅 binlog 的方式异步更新缓存,也能较好保证数据一致性。
  • 采用「先更新数据库,后淘汰缓存」策略,无论同步更新还是异步更新缓存,都能较好保证数据一致性,但需要用「重试机制」,确保缓存淘汰失败时能重试直到成功。

n. 题目: