redis 是什么 ?

redis 一个非关系型数据库,单机可抗 10W+ qps,速度快

redis可以做什么 ?

  • 缓存
  • 分布式锁
  1. 记录帖子的点赞数、评论数和点击数 (hash)。
  2. 记录用户的帖子 ID 列表 (排序),便于快速显示用户的帖子列表 (zset)。
  3. 记录帖子的标题、摘要、作者和封面信息,用于列表页展示 (hash)。
  4. 记录帖子的点赞用户 ID 列表,评论 ID 列表,用于显示和去重计数 (zset)。
  5. 缓存近期热帖内容 (帖子内容空间占用比较大),减少数据库压力 (hash)。
  6. 记录帖子的相关文章 ID,根据内容推荐相关帖子 (list)。
  7. 如果帖子 ID 是整数自增的,可以使用 Redis 来分配帖子 ID (计数器)。
  8. 收藏集和帖子之间的关系 (zset)。
  9. 记录热榜帖子 ID 列表,总热榜和分类热榜 (zset)。
  10. 缓存用户行为历史,进行恶意行为过滤 (zset,hash)。
  11. 数据推送去重 Bloom filter
  12. pv,uv 统计

为什么使用 redis ?

因为传统的关系型数据库如 Mysql 已经不能适用所有的场景了,比如秒杀的库存扣减,APP 首页的访问流量高峰等等,都很容易把数据库打崩,所以引入了缓存中间件,目前市面上比较常用的缓存中间件有 RedisMemcached 不过中和考虑了他们的优缺点,最后选择了 Redis。

Redis 和 Memcached 有啥区别,为啥选择用 Redis 作为你们的缓存中间件?

  • 都是内存型缓存中间件
  • redis 的存储类型更多,mc只支持kv存储,但是kv存储速度更快
  • reids 支持持久化,所以 Redis 不仅仅可以用作缓存,也可以用作 NoSQL 数据库。
  • Redis 提供主从同步机制,以及 Cluster 集群部署能力,能够提供高可用服务。

    缓存有哪些类型?

是高并发场景下提高热点数据访问性能的一个有效手段,在开发项目时会经常使用到。

  • 本地缓存
    • 就是在进程的内存中进行缓存,比如我们的 JVM 堆中,可以用 LRUMap 来实现,也可以使用 Ehcache 这样的工具来实现。

本地缓存是内存访问,没有远程交互开销,性能最好,但是受限于单机容量,一般缓存较小且无法扩展。

  • 分布式缓存
    • 分布式缓存一般都具有良好的水平扩展能力,对较大数据量的场景也能应付自如。缺点就是需要进行远程请求,性能不如本地缓存
  • 多级缓存
    • 平衡这种情况,实际业务中一般采用多级缓存,本地缓存只保存访问频率最高的部分热点数据,其他的热点数据放在分布式缓存中。

在目前的一线大厂中,这也是最常用的缓存方案,单考单一的缓存方案往往难以撑住很多高并发的场景。

淘汰策略

不管是本地缓存还是分布式缓存,为了保证较高性能,都是使用内存来保存数据,由于成本和内存限制,当存储的数据超过缓存容量时,需要对缓存的数据进行剔除。

  • FIFO 淘汰最早数据、LRU 剔除最近最少使用、和 LFU 剔除最近使用频率最低的数据几种策略。

Redis 有哪些数据结构 ?

  • String
    • 内部的实现是通过 SDS(Simple Dynamic String )来存储的。SDS 类似于 Java 中的 ArrayList,可以通过预分配冗余空间的方式来减少内存的频繁分配。

这是最简单的类型,就是普通的 set 和 get,做简单的 KV 缓存。

  • List
  • Set
  • Hash
  • Sort Set
  • Hyperloglog: 不精确的去重计数功能,比较适合用来做大规模数据的去重统计,例如统计 UV;
  • bitmap (布隆过滤器)
  • pub/sub:功能是订阅发布功能,可以用作简单的消息队列。
  • Pipeline:可以批量执行一组指令,一次性返回全部结果,可以减少频繁的请求应答。
  • Lua:Redis 支持提交 Lua 脚本来执行一系列的功能。我在前电商老东家的时候,秒杀场景经常使用这个东西,讲道理有点香,利用他的原子性。

说一下他们的特性,还有分别的使用场景么?

  • String
    • 缓存功能:String 字符串是最常用的数据类型,不仅仅是 Redis,各个语言都是最基本类型,因此,利用 Redis 作为缓存,配合其它数据库作为存储层,利用 Redis 支持高并发的特点,可以大大加快系统的读写速度、以及降低后端数据库的压力。
    • 计数器:许多系统都会使用 Redis 作为系统的实时计数器,可以快速实现计数和查询的功能。而且最终的数据结果可以按照特定的时间落地到数据库或者其它存储介质当中进行永久保存。
    • 共享用户 Session:用户重新刷新一次界面,可能需要访问一下数据进行重新登录,或者访问页面缓存 Cookie,但是可以利用 Redis 将用户的 Session 集中管理,在这种模式只需要保证 Redis 的高可用,每次用户 Session 的更新和获取都可以快速完成。大大提高效率。
  • List
    • 比如可以通过 List 存储一些列表型的数据结构,类似粉丝列表、文章的评论列表之类的东西。
    • 比如可以通过 lrange 命令,读取某个闭区间内的元素,可以基于 List 实现分页查询,这个是很棒的一个功能,基于 Redis 实现简单的高性能分页,可以做类似微博那种下拉不断分页的东西,性能高,就一页一页走。文章列表或者数据分页展示的应用。比如,我们常用的博客网站的文章列表,当用户量越来越多时,而且每一个用户都有自己的文章列表,而且当文章多时,都需要分页展示,这时可以考虑使用 Redis 的列表,列表不但有序同时还支持按照范围内获取元素,可以完美解决分页查询功能。大大提高查询效率。
    • 比如可以搞个简单的消息队列,从 List 头怼进去,从 List 屁股那里弄出来。消息队列:Redis 的链表结构,可以轻松实现阻塞队列,可以使用左进右出的命令组成来完成队列的设计。比如:数据的生产者可以通过 Lpush 命令从左边插入数据,多个数据消费者,可以使用 BRpop 命令阻塞的 “抢” 列表尾部的数据。
  • Set 是无序集合,会自动去重的那种。
    • 直接基于 Set 将系统里需要去重的数据扔进去,自动就给去重了,如果你需要对一些数据进行快速的全局去重,你当然也可以基于 JVM 内存里的 HashSet 进行去重,但是如果你的某个系统部署在多台机器上呢?得基于 Redis 进行全局的 Set 去重。
    • 可以基于 Set 玩儿交集、并集、差集的操作,比如交集吧,我们可以把两个人的好友列表整一个交集,看看俩人的共同好友是谁?对吧。
    • 反正这些场景比较多,因为对比很快,操作也简单,两个查询一个 Set 搞定。
  • Sorted Set:
    • Sorted set 是排序的 Set,去重但可以排序,写进去的时候给一个分数,自动根据分数排序。
    • 有序集合的使用场景与集合类似,但是 set 集合不是自动有序的,而 Sorted set 可以利用分数进行成员间的排序,而且是插入时就排序好。所以当你需要一个有序且不重复的集合列表时,就可以选择 Sorted set 数据结构作为选择方案。
    • 排行榜:有序集合经典使用场景。例如视频网站需要对用户上传的视频做排行榜,榜单维护可能是多方面:按照时间、按照播放量、按照获得的赞数等。
    • 用 Sorted Sets 来做带权重的队列,比如普通消息的 score 为 1,重要消息的 score 为 2,然后工作线程可以选择按 score 的倒序来获取工作任务。让重要的任务优先执行。微博热搜榜,就是有个后面的热度值,前面就是名称

Redis 分布式锁,它是什么回事 ?

先拿 setnx 来争抢锁,抢到之后,再用 expire 给锁加一个过期时间防止锁忘记了释放。

setnx 之后执行 expire 之前进程意外 crash 或者要重启维护了,那会怎么样?

我记得 set 指令有非常复杂的参数,这个应该是可以同时把 setnxexpire 合成一条指令来用的!

假如 Redis 里面有 1 亿个 key,其中有 10w 个 key 是以某个固定的已知的前缀开头的,如何将它们全部找出来?

使用 keys 指令可以扫出指定模式的 key 列表。

如果这个 redis 正在给线上的业务提供服务,那使用 keys 指令会有什么问题?

Redis 的单线程的。keys 指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。这个时候可以使用 scan 指令,scan 指令可以无阻塞的提取出指定模式的 key 列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用 keys 指令长。
不过,增量式迭代命令也不是没有缺点的:举个例子, 使用 SMEMBERS 命令可以返回集合键当前包含的所有元素, 但是对于 SCAN 这类增量式迭代命令来说, 因为在对键进行增量式迭代的过程中, 键可能会被修改, 所以增量式迭代命令只能对被返回的元素提供有限的保证 。

使用过 Redis 做异步队列么,你是怎么用的?

一般使用 list 结构作为队列,rpush 生产消息,lpop 消费消息。当 lpop 没有消息的时候,要适当 sleep 一会再重试。

如果对方追问可不可以不用 sleep 呢?

list 还有个指令叫 blpop,在没有消息的时候,它会阻塞住直到消息到来。

如果对方接着追问能不能生产一次消费多次呢?

使用 pub/sub 主题订阅者模式,可以实现 1:N 的消息队列。

如果对方继续追问 pub/su b 有什么缺点?

在消费者下线的情况下,生产的消息会丢失,得使用专业的消息队列如 RocketMQ 等。

如果对方究极 TM 追问 Redis 如何实现延时队列?

使用 sortedset,拿时间戳作为 score,消息内容作为 key 调用 zadd 来生产消息,消费者用 zrangebyscore 指令获取 N 秒之前的数据轮询进行处理。

Pipeline 有什么好处,为什么要用 pipeline?

可以将多次 IO 往返的时间缩减为一次,前提是 pipeline 执行的指令之间没有因果相关性。使用 redis-benchmark 进行压测的时候可以发现影响 redis 的 QPS 峰值的一个重要因素是 pipeline 批次指令的数目。

是否使用过 Redis 集群,集群的高可用怎么保证,集群的原理是什么?

  • Redis Sentinal 着眼于高可用,在 master 宕机时会自动将 slave 提升为 master,继续提供服务。
  • Redis Cluster 着眼于扩展性,在单个 redis 内存不足时,使用 Cluster 进行分片存储。

那么我们直接开门见山,直接怼常见的几个大问题,Redis 雪崩了解么?

  • 雪崩是指大量的key在同一时间全部失效,导致所有请求直接访问db,造成db压力过大

面试官摸了摸自己的头发,嗯还不错,那这种情况咋整?你都是怎么去应对的?

  • 在失效时间上面添加随机数,避免key同时失效

那你了解缓存穿透和击穿么,可以说说他们跟雪崩的区别么?

  • 穿透是指,请求全部绕过了缓存,并发访问数据库,例如同时并发访问 id = -1 的数据
  • 击穿和雪崩比较类似,是指热点key失效,造成数据库 db 压力过大

面试官露出欣慰的眼光,那他们分别怎么解决

  • 穿透可以添加前端校验,参数校验,鉴权等
  • 击穿可以对热点key设置永不过期

那你还有别的办法么?

  • 可以使用布隆过滤器,将主键等放入redis,确认数据不存在时,数据就是不存在,避免恶意请求参数

之前问过了你基础知识以及一些缓存的常见几个大问题了,那你能跟我聊聊为啥 Redis 那么快么?

  • redis基于内存的,减少了硬盘 I/O 开销
  • redis是单线程的,避免了多线程上下文切换等开销
    • Redis 采用单线程模式处理请求。这样做的原因有 2 个:一个是因为采用了非阻塞的异步事件处理机制;另一个是缓存数据都是内存操作 IO 时间不会太长,单线程可以避免线程上下文切换产生的代价。
    • 不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗;
  • redis是 I/O 多路复用,非阻塞 IO
  • 高效的数据结构,加上底层做了大量优化

    我可以问一下啥是上下文切换么?

  • 切换线程时,需要获取到线程的情况

那他是单线程的,我们现在服务器都是多核的,那不是很浪费?

  • 他是单线程的,但是,我们可以通过在单机开多个 Redis 实例嘛。

既然提到了单机会有瓶颈,那你们是怎么解决这个瓶颈的?


我们用到了集群的部署方式也就是 Redis cluster,并且是主从同步读写分离,类似 Mysql 的主从同步,Redis cluster 支撑 N 个 Redis master node,每个 master node 都可以挂载多个 slave node。
这样整个 Redis 就可以横向扩容了。如果你要支撑更大数据量的缓存,那就横向扩容更多的 master 节点,每个 master 节点就能存放更多的数据了。

哦?那问题就来了,他们之间是怎么进行数据交互的?以及 Redis 是怎么进行持久化的?Redis 数据都在内存中,一断电或者重启不就木有了嘛?

是的,持久化的话是 Redis 高可用中比较重要的一个环节,因为 Redis 数据在内存的特性,持久化必须得有,我了解到的持久化是有两种方式的。

  • RDB:RDB 持久化机制,是对 Redis 中的数据执行周期性的持久化。
  • AOF:AOF 机制对每条写入命令作为日志,以 append-only 的模式写入一个日志文件中,因为这个模式是只追加的方式,所以没有任何磁盘寻址的开销,所以很快,有点像 Mysql 中的 binlog

两种方式都可以把 Redis 内存中的数据持久化到磁盘上,然后再将这些数据备份到别的地方去,RDB 更适合做 冷备 AOF 更适合做 热备,比如我杭州的某电商公司有这两个数据,我备份一份到我杭州的节点,再备份一个到上海的,就算发生无法避免的自然灾害,也不会两个地方都一起挂吧,这灾备也就是异地容灾,地球毁灭他没办法。
tip:两种机制全部开启的时候,Redis 在重启的时候会默认使用 AOF 去重新构建数据,因为 AOF 的数据是比 RDB 更完整的。

那这两种机制各自优缺点是啥?

我先说 RDB 吧

优点:

他会生成多个数据文件,每个数据文件分别都代表了某一时刻 Redis 里面的数据,这种方式,有没有觉得很适合做 冷备,完整的数据运维设置定时任务,定时同步到远端的服务器,比如阿里的云服务,这样一旦线上挂了,你想恢复多少分钟之前的数据,就去远端拷贝一份之前的数据就好了。
RDBRedis 的性能影响非常小,是因为在同步数据的时候他只是 fork 了一个子进程去做持久化的,而且他在数据恢复的时候速度比 AOF 来的快。

缺点:

RDB 都是快照文件,都是默认五分钟甚至更久的时间才会生成一次,这意味着你这次同步到下次同步这中间五分钟的数据都很可能全部丢失掉。AOF 则最多丢一秒的数据, 数据完整性上高下立判。
还有就是 RDB 在生成数据快照的时候,如果文件很大,客户端可能会暂停几毫秒甚至几秒,你公司在做秒杀的时候他刚好在这个时候 fork 了一个子进程去生成一个大快照,哦豁,出大问题。

优点:

上面提到了,RDB 五分钟一次生成快照,但是 AOF 是一秒一次去通过一个后台的线程 fsync 操作,那最多丢这一秒的数据。
AOF 在对日志文件进行操作的时候是以 append-only 的方式去写的,他只是追加的方式写数据,自然就少了很多磁盘寻址的开销了,写入性能惊人,文件也不容易破损。
AOF 的日志是通过一个叫 非常可读的方式记录的,这样的特性就适合做灾难性数据误删除的紧急恢复了,比如公司的实习生通过 flushall 清空了所有的数据,只要这个时候后台重写还没发生,你马上拷贝一份 AOF 日志文件,把最后一条 flushall 命令删了就完事了。
tip:我说的命令你们别真去线上系统操作啊,想试去自己买的服务器上装个 Redis 试,别到时候来说,敖丙真是个渣男,害我把服务器搞崩了,Redis 官网上的命令都去看看,不要乱试!!!

缺点:

一样的数据,AOF 文件比 RDB 还要大。
AOF 开启后,Redis 支持写的 QPS 会比 RDB 支持写的要低,他不是每秒都要去异步刷新一次日志嘛 fsync,当然即使这样性能还是很高,我记得 ElasticSearch 也是这样的,异步刷新缓存区的数据去持久化,为啥这么做呢,不直接来一条怼一条呢,那我会告诉你这样性能可能低到没办法用的,大家可以思考下为啥哟。

那两者怎么选择?

  • 全部都要

小孩子才做选择,我全都要,你单独用 RDB 你会丢失很多数据,你单独用 AOF,你数据恢复没 RDB 来的快,真出什么时候第一时间用 RDB 恢复,然后 AOF 做数据补全,真香!冷备热备一起上,才是互联网时代一个高健壮性系统的王道。

看不出来年纪轻轻有点东西的呀,对了我听你提到了高可用,Redis 还有其他保证集群高可用的方式么?

  • 主从同步+哨兵

我记得你还提到了主从同步,能说一下主从之间的数据怎么同步的么?

  • 你启动一台 slave 的时候,他会发送一个 psync 命令给 master ,如果是这个 slave 第一次连接到 master,他会触发一个全量复制。master 就会启动一个线程,生成 RDB 快照,还会把新的写请求都缓存在内存中,RDB 文件生成后,master 会将这个 RDB 发送给 slave 的,slave 拿到之后做的第一件事情就是写进本地的磁盘,然后加载进内存,然后 master 会把内存里面缓存的那些新命名都发给 slave。

数据传输的时候断网了或者服务器挂了怎么办啊?

  • 上线后会补全剩余数据

那说了这么多你能说一下他的内存淘汰机制么,来手写一下 LRU 代码?

Redis 的过期策略,是有 定期删除 + 惰性删除两种。
定期好理解,默认 100ms 就随机抽一些设置了过期时间的 key,去检查是否过期,过期了就删了。

官网上给到的内存淘汰机制是以下几个:

  • noeviction: 返回错误当内存限制达到并且客户端尝试执行会让更多内存被使用的命令(大部分的写入指令,但 DEL 和几个例外)
  • allkeys-lru: 尝试回收最少使用的键(LRU),使得新添加的数据有空间存放。
  • volatile-lru: 尝试回收最少使用的键(LRU),但仅限于在过期集合的键,使得新添加的数据有空间存放。
  • allkeys-random: 回收随机的键使得新添加的数据有空间存放。
  • volatile-random: 回收随机的键使得新添加的数据有空间存放,但仅限于在过期集合的键。
  • volatile-ttl: 回收在过期集合的键,并且优先回收存活时间(TTL)较短的键,使得新添加的数据有空间存放。如果没有键满足回收的前提条件的话,策略 volatile-lru, volatile-random 以及 volatile-ttl 就和 noeviction 差不多了。

其实在大家熟悉的 LinkedHashMap 中也实现了 Lru 算法的,实现如下:
Redis 学习 - 图1
当容量超过 100 时,开始执行 LRU 策略:将最近最少未使用的 TimeoutInfoHolder 对象 evict 掉。
真实面试中会让你写 LUR 算法,你可别搞原始的那个,那真 TM 多,写不完的,你要么怼上面这个,要么怼下面这个,找一个数据结构实现下 Java 版本的 LRU 还是比较容易的,知道啥原理就好了。
Redis 学习 - 图2

为啥不扫描全部设置了过期时间的 key 呢?

假如 Redis 里面所有的 key 都有过期时间,都扫描一遍?那太恐怖了,而且我们线上基本上也都是会设置一定的过期时间的。全扫描跟你去查数据库不带 where 条件不走索引全表扫描一样,100ms 一次,Redis 累都累死了。

如果一直没随机到很多 key,里面不就存在大量的无效 key 了?

好问题,惰性删除,见名知意,惰性嘛,我不主动删,我懒,我等你来查询了我看看你过期没,过期就删了还不给你返回,没过期该怎么样就怎么样。

最后就是如果的如果,定期没删,我也没查询,那可咋整?

内存淘汰机制

有没有考虑过,如果你多个系统同时操作(并发)Redis 带来的数据问题?

这个问题我以前开发的时候遇到过,其实并发过程中确实会有这样的问题,比如下面这样的情况

系统 A、B、C 三个系统,分别去操作 Redis 的同一个 Key,本来顺序是 1,2,3 是正常的,但是因为系统 A 网络突然抖动了一下,B,C 在他前面操作了 Redis,这样数据不就错了么。
就好比下单,支付,退款三个顺序你变了,你先退款,再下单,再支付,那流程就会失败,那数据不就乱了?你订单还没生成你却支付,退款了?明显走不通了,这在线上是很恐怖的事情。

那这种情况怎么解决呢?

可以找个管家帮我们管理好数据的嘛!

某个时刻,多个系统实例都去更新某个 key。可以基于 Zookeeper 实现分布式锁。每个系统通过 Zookeeper 获取分布式锁,确保同一时间,只能有一个系统实例在操作某个 Key,别人都不允许读和写。
你要写入缓存的数据,都是从 MySQL 里查出来的,都得写入 MySQL 中,写入 MySQL 中的时候必须保存一个时间戳,从 MySQL 查出来的时候,时间戳也查出来。
每次要写之前,先判断一下当前这个 Value 的时间戳是否比缓存里的 Value 的时间戳要新。如果是的话,那么可以写,否则,就不能用旧的数据覆盖新的数据。

你只要用缓存,就可能会涉及到缓存与数据库双存储双写,你只要是双写,就一定会有数据一致性的问题,那么你如何解决一致性问题?

如果允许缓存可以稍微的跟数据库偶尔有不一致的情况,也就是说如果你的系统不是严格要求 “缓存 + 数据库” 必须保持一致性的话,最好不要做这个方案,即:读请求和写请求串行化,串到一个内存队列里去。
串行化可以保证一定不会出现不一致的情况,但是它也会导致系统的吞吐量大幅度降低,用比正常情况下多几倍的机器去支撑线上的一个请求。
把一些列的操作都放到队列里面,顺序肯定不会乱,但是并发高了,这队列很容易阻塞,反而会成为整个系统的弱点,瓶颈

你了解最经典的 KV、DB 读写模式么?

最经典的缓存 + 数据库读写的模式,就是 Cache Aside Pattern

  • 读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
  • 更新的时候,先更新数据库,然后再删除缓存。

为什么是删除缓存,而不是更新缓存?


原因很简单,很多时候,在复杂点的缓存场景,缓存不单单是数据库中直接取出来的值。
比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据并进行运算,才能计算出缓存最新的值的。
另外更新缓存的代价有时候是很高的。是不是说,每次修改数据库的时候,都一定要将其对应的缓存更新一份?也许有的场景是这样,但是对于比较复杂的缓存数据计算的场景,就不是这样了。如果你频繁修改一个缓存涉及的多个表,缓存也频繁更新。但是问题在于,这个缓存到底会不会被频繁访问到?
举个栗子:一个缓存涉及的表的字段,在 1 分钟内就修改了 20 次,或者是 100 次,那么缓存更新 20 次、100 次;但是这个缓存在 1 分钟内只被读取了 1 次,有大量的冷数据。
实际上,如果你只是删除缓存的话,那么在 1 分钟内,这个缓存不过就重新计算一次而已,开销大幅度降低。用到缓存才去算缓存。
其实删除缓存,而不是更新缓存,就是一个 Lazy 计算的思想,不要每次都重新做复杂的计算,不管它会不会用到,而是让它到需要被使用的时候再重新计算。
像 Mybatis,Hibernate,都有懒加载思想。查询一个部门,部门带了一个员工的 List,没有必要说每次查询部门,都里面的 1000 个员工的数据也同时查出来啊。80% 的情况,查这个部门,就只是要访问这个部门的信息就可以了。先查部门,同时要访问里面的员工,那么这个时候只有在你要访问里面的员工的时候,才会去数据库里面查询 1000 个员工。

Redis 的线程模型了解么?

Redis 内部使用文件事件处理器 file event handler,这个文件事件处理器是单线程的,所以 Redis 才叫做单线程的模型。它采用 IO 多路复用机制同时监听多个 Socket,根据 Socket 上的事件来选择对应的事件处理器进行处理。
文件事件处理器的结构包含 4 个部分:

  • 多个 Socket
  • IO 多路复用程序
  • 文件事件分派器
  • 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)

多个 Socket 可能会并发产生不同的操作,每个操作对应不同的文件事件,但是 IO 多路复用程序会监听多个 Socket,会将 Socket 产生的事件放入队列中排队,事件分派器每次从队列中取出一个事件,把该事件交给对应的事件处理器进行处理。

持久化


Redis 提供了 RDB 和 AOF 两种持久化方式,RDB 是把内存中的数据集以快照形式写入磁盘,实际操作是通过 fork 子进程执行,采用二进制压缩存储;AOF 是以文本日志的形式记录 Redis 处理的每一个写入或删除操作。
RDB 把整个 Redis 的数据保存在单一文件中,比较适合用来做灾备,但缺点是快照保存完成之前如果宕机,这段时间的数据将会丢失,另外保存快照时可能导致服务短时间不可用。
AOF 对日志文件的写入操作使用的追加模式,有灵活的同步策略,支持每秒同步、每次修改同步和不同步,缺点就是相同规模的数据集,AOF 要大于 RDB,AOF 在运行效率上往往会慢于 RDB。

高可用

Redis 支持主从同步,提供 Cluster 集群部署模式,通过 Sentine l 哨兵来监控 Redis 主服务器的状态。当主挂掉时,在从节点中根据一定策略选出新主,并调整其他从 slaveof 到新主。
选主的策略简单来说有三个:

  • slave 的 priority 设置的越低,优先级越高;
  • 同等情况下,slave 复制的数据越多优先级越高;
  • 相同的条件下 runid 越小越容易被选中。

在 Redis 集群中,sentinel 也会进行多实例部署,sentinel 之间通过 Raft 协议来保证自身的高可用。
Redis Cluster 使用分片机制,在内部分为 16384 个 slot 插槽,分布在所有 master 节点上,每个 master 节点负责一部分 slot。数据操作时按 key 做 CRC16 来计算在哪个 slot,由哪个 master 进行处理。数据的冗余是通过 slave 节点来保障。

哨兵

哨兵组件的主要功能:

  • 集群监控:负责监控 Redis master 和 slave 进程是否正常工作。
  • 消息通知:如果某个 Redis 实例有故障,那么哨兵负责发送消息作为报警通知给管理员。
  • 故障转移:如果 master node 挂掉了,会自动转移到 slave node 上。
  • 配置中心:如果故障转移发生了,通知 client 客户端新的 master 地址。

    主从

    提到这个,就跟我前面提到的数据持久化的 RDBAOF 有着比密切的关系了。
    我先说下为啥要用主从这样的架构模式,前面提到了单机 QPS 是有上限的,而且 Redis 的特性就是必须支撑读高并发的,那你一台机器又读又写, 这谁顶得住啊,不当人啊!但是你让这个 master 机器去写,数据同步给别的 slave 机器,他们都拿去读,分发掉大量的请求那是不是好很多,而且扩容的时候还可以轻松实现水平扩容。
    Redis 学习 - 图3
    你启动一台 slave 的时候,他会发送一个 psync 命令给 master ,如果是这个 slave 第一次连接到 master,他会触发一个全量复制。master 就会启动一个线程,生成 RDB 快照,还会把新的写请求都缓存在内存中,RDB 文件生成后,master 会将这个 RDB 发送给 slave 的,slave 拿到之后做的第一件事情就是写进本地的磁盘,然后加载进内存,然后 master 会把内存里面缓存的那些新命名都发给 slave。

    key 失效机制

Redis 的 key 可以设置过期时间,过期后 Redis 采用主动和被动结合的失效机制,一个是和 MC 一样在访问时触发被动删除,另一种是定期的主动删除。

缓存常见问题

缓存更新方式

这是决定在使用缓存时就该考虑的问题。
缓存的数据在数据源发生变更时需要对缓存进行更新,数据源可能是 DB,也可能是远程服务。更新的方式可以是主动更新。数据源是 DB 时,可以在更新完 DB 后就直接更新缓存。
当数据源不是 DB 而是其他远程服务,可能无法及时主动感知数据变更,这种情况下一般会选择对缓存数据设置失效期,也就是数据不一致的最大容忍时间。
这种场景下,可以选择失效更新,key 不存在或失效时先请求数据源获取最新数据,然后再次缓存,并更新失效期。
但这样做有个问题,如果依赖的远程服务在更新时出现异常,则会导致数据不可用。改进的办法是异步更新,就是当失效时先不清除数据,继续使用旧的数据,然后由异步线程去执行更新任务。这样就避免了失效瞬间的空窗期。另外还有一种纯异步更新方式,定时对数据进行分批更新。实际使用时可以根据业务场景选择更新方式。

数据不一致

第二个问题是数据不一致的问题,可以说只要使用缓存,就要考虑如何面对这个问题。缓存不一致产生的原因一般是主动更新失败,例如更新 DB 后,更新 Redis 因为网络原因请求超时;或者是异步更新失败导致。
解决的办法是,如果服务对耗时不是特别敏感可以增加重试;如果服务对耗时敏感可以通过异步补偿任务来处理失败的更新,或者短期的数据不一致不会影响业务,那么只要下次更新时可以成功,能保证最终一致性就可以。

SDS数据结构

4.3 SDS 优势

Redis 学习 - 图4

4.3.1 O (1) 时间复杂度获取字符串长度

由于 C 字符串不记录自身的长度,所以为了获取一个字符串的长度程序必须遍历这个字符串,直至遇到 ‘0’ 为止,整个操作的时间复杂度为 O (N)。而我们使用 SDS 封装字符串则直接获取 len 属性值即可,时间复杂度为 O (1)。
Redis 学习 - 图5

4.3.2 二进制安全

什么是二进制安全?
通俗地讲,C 语言中,用 ‘0’ 表示字符串的结束,如果字符串本身就有 ‘0’ 字符,字符串就会被截断,即非二进制安全;若通过某种机制,保证读写字符串时不损害其内容,则是二进制安全。
C 字符串中的字符除了末尾字符为 ‘\0’ 外其他字符不能为空字符,否则会被认为是字符串结尾 (即使实际上不是)。
这限制了 C 字符串只能保存文本数据,而不能保存二进制数据。而 SDS 使用 len 属性的值判断字符串是否结束,所以不会受 ‘\0’ 的影响。

4.3.3 杜绝缓冲区溢出

字符串的拼接操作是使用十分频繁的,在 C 语言开发中使用 char strcat(char dest,const char *src) 方法将 src 字符串中的内容拼接到 dest 字符串的末尾。由于 C 字符串不记录自身的长度,所有 strcat 方法已经认为用户在执行此函数时已经为 dest 分配了足够多的内存,足以容纳 src 字符串中的所有内容,而一旦这个条件不成立就会产生缓冲区溢出,会把其他数据覆盖掉,Dangerous~。

自动扩容机制 ——sdsMakeRoomFor 方法

自动扩容机制总结:
扩容阶段:

  • 若 SDS 中剩余空闲空间 avail 大于新增内容的长度 addlen,则无需扩容;
  • 若 SDS 中剩余空闲空间 avail 小于或等于新增内容的长度 addlen:
    • 若新增后总长度 len+addlen < 1MB,则按新长度的两倍扩容;
    • 若新增后总长度 len+addlen > 1MB,则按新长度加上 1MB 扩容。

内存分配阶段:

  • 根据扩容后的长度选择对应的 SDS 类型:
    • 若类型不变,则只需通过 s_realloc_usable 扩大 buf 数组即可;
    • 若类型变化,则需要为整个 SDS 重新分配内存,并将原来的 SDS 内容拷贝至新位置。

扩容后的 SDS 不会恰好容纳下新增的字符,而是多分配了一些空间 (预分配策略),这减少了修改字符串时带来的内存重分配次数

4.3.4 内存重分配次数优化

(1) 空间预分配策略

因为 SDS 的空间预分配策略, SDS 字符串在增长过程中不会频繁的进行空间分配。
通过这种分配策略,SDS 将连续增长 N 次字符串所需的内存重分配次数从必定 N 次降低为最多 N 次。

(2) 惰性空间释放机制

空间预分配策略用于优化 SDS 增长时频繁进行空间分配,而惰性空间释放机制则用于优化 SDS 字符串缩短时并不立即使用内存重分配来回收缩短后多出来的空间,而仅仅更新 SDS 的 len 属性,多出来的空间供将来使用。
SDS 中调用 sdstrim 方法来缩短字符串:

问题