当我们谈论 Redis 时,应该谈论什么?
Redis 基本数据类型有哪些?以及他们各自的使用场景是什么?
常见的有五种:字符串、哈希、列表、集合、有序集合。5.0 版本中新添加了 Stream 类型。
- 字符串
String: 就是常规的GET/SET操作。是 Redis 最基本的数据类型,一个键最大能存储512MB(底层数据结构:SDS); - 哈希
Hash:可以理解成一个键值对的集合,十分适合存储结构化数据。比如MySQL中有一条记录:id=1, name=demo, age=18,那么可以使用 hash 将其存到 Redis 中:HSET user:1 name demo age 18(数据结构:ZipList或HashTable); - 列表
List:就是简单的字符串列表,按照插入顺序排序。比较常见的场景是当做队列或者栈使用(数据结构:QuickList,是ZipList和双向链表的组合)。 - 集合
Set:存放的是一堆不重复值的集合,通常用来做去重,同时还提供了不同Set之间求交集、并集、合集等功能,业务上也能使用的到。它底层也是通过哈希表去实现的,可以做到增删改查都是O(1)的复杂度(数据结构:HashTable)。 - 有序集合
Sorted Set:跟Set一样,也是一堆不重复值的集合,不同的是每一个元素都会关联一个float64类型的分数,而 Redis 正是基于这个分数为集合中的成员进行排序的。比较常见的使用场景是存排行榜数据,去Top N会非常方便(数据结构:跳表SkipList)。 - 流式数据
Stream:这是V5.0版本引入的新的数据类型,用来弥补Pub/Sub的不足,工作模式类似于kafka,可以使用XADD往一个 stream 中发送消息,而消费者可以是单个,也可以是消费者集群,并且任意一个消费者消费之后,必须手动调用XACK才会完全标志这条消息被处理,特别适合做消息队列。
Redis 使用场景
- 热数据存储:当成缓存中间件来使用,以缓解 DB 的压力。
- 做消息队列:我们可以使用它的
List或Stream或Pub/Sub来实现一个消息队列,完成业务逻辑上的数据解耦; - 排行榜:利用
Redis Sorted Set实现; - 限流器:利用单线程、原子递增等特性,可以记录某个用户在某段时间内的访问量,结合业务逻辑做到限流效果;
- 分布式锁:
setnx命令,设置成功表示拿到锁,不成功表示没拿到锁。
Redis 是单线程还是多线程?为什么这么快?
- 4.0 以前,不管是主业务逻辑还是持久化,都是单线程;
- 4.0 版本,引入了多线程处理
AOF等不太核心的操作,但主Reactor模型依旧使用单线程。主要是体现在大数据的异步删除功能上,例如unlink key、flushdb async、flushall async等; - 6.0 版本,主
Reactor真正引入多线程处理用户逻辑。
既然是单线程,为什么还这么快?
官方的 QA 里说过,Redis 是基于内存的操作,CPU 并不是 Redis 的瓶颈,最大的瓶颈可能来自于机器内存大小以及网络带宽。快的原因:
- 基于内存操作,并且有许多非常优秀的的数据结构为数据存储和处理做支撑;
- 单线程避免了多线程的竞争,省去了多线程切换带来的时间和性能损失;
- 基于
I/O 多路复用实现了自己类似于Reactor模型的事件库,大大提高网络处理能力。
Redis 是如何实现分布式锁的?
主要利用 Redis 的 SETNX 命令实现:SETNX k v,当 k 不存在时,k v 设置成功并返回成功,表示拿到锁;k 已经存在则返回失败,加锁失败。操作结束后,可以使用 del k 删除,表示释放锁;也可以在加锁的同时,给这个锁一个过期时间,避免锁没有被显式释放而造成永久锁住。
但上述方式也存在一些问题:
SETNX和EXPIRE并不是原子性操作,如果我SET之后因为网络原因没有EXPIRE,锁因为没有设置超时时间而永远无法释放。很多开源的解决方案是 通过 lua 脚本同时设置过期时间,也可以 使用原生的SET命令,加上nx选项以及对应的过期时间,都可以解决没有 没有expire造成的锁不释放 问题。- 使用了
expire,但有可能出现新的问题:就是加锁的一方的执行时间超过了expire,此时锁自动过期释放,另一个线程获得锁,此时两个线程并发运行,就会出问题,而且如果当前线程处理完后调用expire也会将另一个线程的锁解除;而且这个锁也不是可重入锁。
针对这个问题,Redis 作者提出了在基于分布式环境下提出了更高级的分布式锁的实现:RedLock。(不过也并不是完美的,而且实际使用时也不会给你 5 个独立的 redis master)
结论:Redis 以其高性能著称,但使用其实现分布式锁来解决并发仍存在一些困难。Redis 分布式锁只能作为一种缓解并发的手段,如果要完全解决并发问题,仍需要数据库的防并发手段。
缓存雪崩、缓存穿透、缓存击穿等问题
- 缓存雪崩
现象:大量的热 key 设置了相同的过期时间,在该时刻这些热 key 全部失效,所有的请求铺天盖地都打到了 DB。
解决方案:不要设置相同的过期时间,可以在一个
baseDuration上加减一个随机数。
- 缓存穿透
现象:一般的逻辑都是在 redis 中找不到,就会去 DB 查,然后将结果缓存到 Redis。但是如果某些 Key 在 DB 中也不存在(如小于 0 的用户 ID),这类 Key 每次都会进行两次无用的查询。
解决方案:
- 加强非法参数的逻辑校验,提前返回失败;
- 将不存在的 Key 也缓存下来;
- 使用布隆过滤器,可以帮助识别:哪些数据一定不存在和可能存在,提前过滤一定不存在的数据。
- 缓存击穿
现象:某一个热点 key 扛着非常大的并发,某一时刻这个热点 key 失效,所有请求全部打到 DB 上,像是在墙上穿了一个洞。
解决方案:1. 设置这个热点 key 永不过期;2. 如果非要更新,那么在这个热点 key 为空的时候,设置一个锁(比如 SETNX),只让一个请求去数据库拉取数据,取完之后释放锁,恢复正常缓存逻辑。
Redis 持久化方式以及实现细节
Redis 是在内存中处理数据的,但断电后内存数据会消失,因此需要将内存数据通过某种方式存储到磁盘上,以便服务器重启后能够恢复原有数据,这就是 Redis 的持久化。有三种方式:
AOF日志(Append Only File):文件追加方式,并且以文本的形式追加到文件中;RDB快照(Redis DataBase):将某一时刻的内存数据,以二进制的形式全部存到磁盘中;混合持久方式:v4.0 增加了混合持久化方式,集成了RDB和AOF的优点。
AOF
AOF 采用的是写后日志的方式,现将数据写入内存,再记录到日志文件中。AOF 记录的是实际的操作命令和数据,即我们在终端输入的命令。等到重启恢复时,只需要将 AOF 文件中的命令重复执行一遍(涉及到 AOF 重写)。
命令同步到 AOF 需要经历三个阶段:
- 命令追加:Redis 将执行完的命令、命令的参数等信息“传播” AOF 程序中:
- 缓存追加:AOF 程序根据接收到的命令数据,将命令编码为自己的网络通信协议,然后将内容追加到服务器的 AOF 缓存中(
redisServer中有一个字段叫sds aof_buf); - 文件写入和保存:缓存数据到一定条件,在事件处理器之后,会调
flushAppendOnlyFile函数,这个函数会执行两个操作:- WRITE:将
aof_buf中的数据缓存写入AOF文件中; - SAVE:调用
fsync或者fdataasync函数,将AOF 文件保存到磁盘中;
- WRITE:将
而 AOF 的文件保存模式有三种:
- 不保存:
WRITE会被执行,SAVE只会在服务关闭等常见会被执行一次,平常会被略过。这个时候,这两个操作都是由主线程来完成的,会阻塞主线程; - 每秒保存一次:
WRITE每次都被执行,SAVE启动子线程每秒执行一次。WRITE操作由主进程执行,阻塞主进程;SAVE操作由子线程执行,不直接阻塞主进程,但SAVE完成的快慢会影响WRITE的阻塞时长。 - 每执行一个命令保存一次:每次执行完一个命令之后,
WRITE和SAVE都会被执行。这两个动作都由主线程执行,会阻塞主线程。
文件重写(bgrewriteaof):
当开启的AOF时,随着时间推移,AOF文件会越来越大,当然redis也对AOF文件进行了优化,即触发AOF文件重写条件(后续会说明)时候,redis将使用bgrewriteaof对AOF文件进行重写。这样的好处在于减少AOF文件大小,同时有利于数据的恢复。常见的重写策略:
- 重复或无效的命令不写入文件;
- 过期的数据不再写入文件;
- 多条命令合并写入。
RDB
按照指定时间间隔对你的数据集生成的时间点快照。它是 Redis 数据库中数据的内存快照,它是一个二进制文件(默认名称为:dump.rdb,可修改),存储了文件生成时 Redis 数据库中所有的数据内容。在 Redis Server 重启时可以通过加载 RDB 文件来还原数据库状态。 可用于 Redis 的数据备份、转移与恢复。
rdbSave 负责将内存中的数据以 RDB 的格式保存到磁盘中,如果 RDB 文件已经存在,那么旧的文件会被新的文件替换。
而 SAVE 和 BGSAVE 都会调用 rdbSave 函数,但他们的执行方式不同:
SAVE直接调用rdbSave,阻塞 Redis 主进程,直到保存完为止。在主进程阻塞期间,服务器不能处理任何客户端请求;BGSAVE则会folk出一个子进程,子进程调用rdbSave,并在结束后向主进程发送信号通知。因为rdbSave是在子进程运行的,所以并不会阻塞主进程,在此期间服务器仍旧可以继续处理客户端的请求。
其他需要注意的:
- 为了避免产生竞争条件,
BGSAVE执行时,SAVE命令不能执行。 - 调用
rdbLoad函数载入RDB文件时,不能进行任何和数据库相关的操作,不过订阅与发布方面的命令可以正常执行,因为它们和数据库不相关联。 AOF文件的保存频率通常要高于RDB文件保存的频率, 所以一般来说,AOF文件中的数据会比RDB文件中的数据要新。因此, 如果服务器在启动时, 打开了AOF功能, 那么程序优先使用AOF文件来还原数据。 只有在AOF功能未打开的情况下,Redis才会使用RDB文件来还原数据。
混合持久化
混合持久化就是 同时结合 RDB 持久化以及 AOF 持久化混合写入 AOF文件。这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据,缺点是 AOF 里面的 RDB 部分就是压缩格式不再是 AOF 格式,可读性差,并且 4.0 之前的版本并不识别;
混合持久化同样也是通过 bgrewriteaof 完成的,不同的是当开启混合持久化时,fork 出的子进程先将共享的内存副本全量的以 RDB 方式写入 AOF文件,然后在将重写缓冲区的增量命令以 AOF 方式写入到文件,写入完成后通知主进程更新统计信息,并将新的含有 RDB 格式和 AOF 格式的 AOF文件 替换旧的的 AOF文件。
总结
RDB 优点:
- 是一个非常紧凑的问题,特别适合文件备份以及灾难恢复;
- 节省性能。开启子进程不影响主进程功能。
RDB 缺点:
- RDB 是某一时刻的快照,无法保存全部数据,在请求较大时,丢失的数据会更多。
AOF 优点:
- 数据更完整,秒级数据丢失(取决于设置fsync策略);
- 文件内容可读性高,方便 debug。
AOF 缺点:
- 文件体积更大,且恢复速度慢于 RDB。
Redis 如何实现高可用
Redis 实现高可用主要有三种方式:主从复制、哨兵模式,以及 Redis 集群。
主从复制
在 主从复制 中,Redis server 分为两类:主库 master 和 从库 slave。主库可以进行读写操作,当写操作导致数据变化时会自动同步到从库。而从库一般是只读的,并接受来自主库的数据,一个主库可拥有多个从库,而一个从库只能有一个主库。
哨兵模式
哨兵(sentinel) 是官方推荐的的 高可用(HA) 解决方案。Redis 的主从高可用解决方案,这种方案的缺点在于当 master 故障时候,需要手动进行故障恢复,而 sentinel 是一个独立运行的进程,它能监控一个或多个主从集群,并能在 master 故障时候自动进行故障转移,更为理想的是 sentinel 本身是一个分布式系统,其分布式设计思想有点类似于 zookeeper,当某个时候 Master 故障后,sentinel集群 采用一致性算法来选取Leader,故障转移由 Leader 完成。而对于客户端来说,操作 Redis 的主节点,我们只需要询问 sentinel,sentinel 返回当前可用的 master,这样一来客户端不需要关注的切换而引发的客户端配置变更。
Redis 集群
从最开始的 一主N从,到 读写分离,再到 Sentinel 哨兵机制,单实例的Redis缓存足以应对大多数的使用场景,也能实现主从故障迁移。为什么还需要 Redis 集群?这是因为某些场景下,单实例会存在一下几个问题:
- 写并发:读操作可以通过负载均衡由诸多从节点分担,但所有的写操作只能由主节点完成,在海量数据高并发场景下,主节点压力也会飙升;
- 海量数据的存储压力:单实例本质上是只有一台主节点作为存储,其他从结点都是复制主节点的数据,也就是说,Redis 服务的存储能力取决于主节点所能承载的上线。
为了扩展写能力和存储能力,Redis引入集群模式。
Redis3.0 加入了 集群模式,实现了数据的分布式存储,对数据进行分片,将不同的数据存储在不同的 master 节点上面,从而解决了海量数据的存储问题。
同时 Redis集群 采用去中心化的思想,没有中心节点的说法,对于客户端来说,整个集群可以看成一个整体,可以连接任意一个节点进行操作,就像操作单一Redis实例一样,不需要任何代理中间件,当客户端操作的key没有分配到该node上时,Redis会返回转向指令,指向正确的node。
Redis 也内置了高可用机制,支持 N 个 master节点 ,每个 master节点 都可以挂载多个slave节点,当 master节点 挂掉时,集群会提升它的某个slave节点 作为新的master节点。
Redis集群可以看成多个主从架构组合起来的,每一个主从架构可以看成一个节点(其中,只有master节点具有处理请求的能力,slave节点主要是用于节点的高可用)。
问:集群中那么多 master节点,集群在存储的时候如何确定选择哪个节点呢?
采用 类一致性哈希算法 实现节点选择。
首先,集群将自己分成 16384 个 slot(槽位),然后让每个节点分别负责一部分槽位(范围固定)。当某个 key 到来时,某个集群的master会先计算这个 key 应该被分配到哪个槽位(CRC16后的哈希值与 16384 取模的结果就是应该放入的槽位号),如果这个槽位刚好是自己负责,那么开始处理并返回;如果不属于当前节点负责的范围,那么会返回一个moved error,并告诉你应该去哪个节点指定这个写入命令。
问:那集群如何实现扩容?
通过
reshard(重新分片)来实现。它可以将已经分配给某个节点的任意数量的slot迁移给另一个节点,同时将对应slot的数据也全部迁移值新的节点。
Redis 的过期策略以及内存淘汰机制
过期策略
定期随机检测删除:
Redis默认每隔xxx ms就随机抽取设置了过期时间的key,检测这些key是否过期,如果过期就删除。惰性删除:不再是
Redis主动去删除,而是在客户端获取某个key时,先检查是否过期,没过期则正常返回,如果过期则删除并且返回nil。
内存淘汰机制
惰性删除可以解决一些过期了,但没被定期删除随机抽取到的 key。但有些过期的 key 既没有被随机抽取,也没有被客户端访问,就会一直保留在数据库,占用内存,长期下去可能会导致内存耗尽。所以 Redis 提供了内存淘汰机制来解决这个问题。
Redis 在使用内存达到某个阈值(通过 maxmemory 配置)的时候,就会触发内存淘汰机制,选取一些 key 来删除。当内存不足以容纳新写入的数据时,内存淘汰有以下几种策略:
noeviction:报错。默认策略。allkeys-lru:在所有的key中,删除最近最少使用的key;allkeys-random:在所有的key中,随机移除某个key;volatile-lru:在所有设置了过期时间的key中,删除最近最少使用的key;volatile-random:在所有设置了过期时间的key中,随机移除某个key;volatile-ttl:在所有设置了过期时间的key中,有更早过期时间的key优先移除。
Redis 中 大key 和 热key 问题
大Key 问题
现象:
什么是大 Key:
- 单个简单的
key存储的value很大:会导致网络拥塞,内存使用不均(集群模式下); hash、set、zset以及list结构中存储过多的元素:单个命令耗时太长容易阻塞其他命令,严重会引起集群发生故障切换,循环故障从而整个集群宕机。
如何发现:
- Redis 监控对超多 xxx 的
kv报警; - 定时脚本不断去
scan拿到结果进而报警然后处理优化; - 利用
redis-cli --bigkeys命令行工具分析; - 使用
redis-rdb-tools工具对RDB文件进行分析
如何解决:
- 删除:
4.0以后有lazy delete,不会阻塞主线程。但这只是临时方案;
hash: 使用 hscan + hdel
set : 使用 sscan + srem
zset : 使用 zremrangebyrank
list : 使用 scan + ltrim
- 拆分,然后使用
multiGet获取;
热Key 问题
现象:
突然有非常大的请求去访问 Redis 上的某个特定的key,流量过于集中,甚至达到物理网卡的上限,导致这台 Redis 服务器宕机。此时,这台Redis上的其他读写请求都变得不可用;热 key 会落到同一个 Redis 实例上,无法通过扩容解决;所有的请求都打在 DB 上,Redis 都扛不住,DB 大概率会挂掉。
如何发现:
- 业务经验预估
- 对用户行为数据分析,如点击、加购行为都会有打点数据
- 如果是集群,可以利用集群
proxy统计分析 Redis v4.3的redis-cli有一个--hotkeys选项,可以在命令行直接获取当前namespace中的热点key(实现上是通过scan + object freq完成的)。- 利用
redis-cli monitor抓取数据,利用现有开源工具如redis-faina进行分析,统计出热key。
怎么解决:
- 增加
Redis副本数量,将读请求的压力分配到不同的副本节点上; - 业务上缓存(本地缓存):比如使用一个大小限定的
map,每次去Redis查询前先检查内存中是否存在,如果存在就直接返回了。 - 集群条件下热
key备份:在集群条件下,一个key会被放入指定的实例的slot,增加集群的节点数是没有用的。为了将针对某一个key的请求打散到不同的实例上,可以给对应的key增加前缀或者后缀,这样就可以实现将热key的流量让整个集群来分担,而不是某个节点。不过整个方案需要进行一定的业务开发,比如key前后缀的生成方式。
Redis 通信协议简单介绍
简称 RESP(Redis Serilization Protocol),是 Redis 自定义的用于服务端和客户端之间的通信协议。特点是:实现简单、可读性强、快速解析。
间隔符号,在 类Unix 下是 \r\n,在 Windows 是 \n。
+:简单字符串:"+OK\r\n"-:错误信息:"-Error unknow command 'foobar'\r\n"::整数:":1000\r\n"$:批量字符串:"$6\r\nfoobar\r\n",前面的数组表示字符串长度*:数组:"\*2\\r\\n$2\\r\\nfoo\\r\\n$3\\r\\nbar\\r\\n",数组包含2个元素,分别是字符串foo和bar。
