Redis常见问题总结
Redis常见问题总结
参考
目录
认识Redis
Redis是一种开源的内存数据结构存储工具,用作分布式内存中的键值数据库、缓存和消息代理、并具有可选的持久化功能。
由于其将所有的数据存储中并设计独特,Redis提供了低延迟的读写操作,特别适合需要缓存的情况。
在阅读官方文档后可以总结大概几个意思
- Redis是一种基于内存的数据库,对数据的读写操作都是在内存中完成的,因此读写速度非常快常用于缓存,消息队列,分布式锁等场景。
- Redis提供了多种数据结构来支持不同的场景,比如String,Hash,List等等,并且对数据的操作都是原子的,因此执行命令由单线程负责,不存在竞争问题。
- 除此之外,Redis还支持事物,持久化,Lua脚本等等。
Redis具备「高性能」和「高并发」两种特性
1、高性能
用户在第一次访问MySQL中某些数据的时候,这个过程是比较慢的,访问数据需要从磁盘获取,而引入Redis后获取数据可以直接从内存(缓存)获取,速度会快的多(前提是Redis和MySQL数据一致),当MySQL中的数据发生变化的时候,会出现Redis和MySQL数据一致性的问题,这里我们后面会提到。另外,由于Redis是单线程的,避免了上下文切换带来的CPU开销。
2、高并发
单设备Redis的QPS是MySQL的十倍,单机Redis的QPS可以轻松破10w而MySQL单机的QPS很难破1w。
所以,直接访问Redis能够承受的请求是远远大于直接访问MySQL的,所以我们考虑把数据库中的一部分数据转移到缓存中来提高这部分数据的访问速度。
Q:既然Redis优点这么多,为什么不直接用Redis代替MySQL呢?
这个话题曾被讨论了很久,因为在某些CRD情况少的场景确实比较实用,但是稍微复杂一点的情况Redis的缺点便暴漏了出来。正如他们的类别一样,作为关系型数据库,MySQL能够处理的情况也更多,mysql中like/in/and/or/join等数据查询检索redis是无法支持的,相比于Redis具有更完善的事务处理机制,安全性等优点,在许多复杂的数据库场景Redis虽然可能也可以通过一些方式来实现,但是付出的成本也会更高。对于MySQL和Redis,他们不应该是竞争关系,而是一对好“基友”,在实际工作中,针对不同的场景各有所长,合理运用才能达到最好的效果。当然,读到后面你就会发现这个问题真的很蠢。。。
Redis虽然功能强大、性能高效,但是也不是万能的,项目在引入Redis的时候,需要考虑的问题也比较多,并且会带来额外的开发和运维的工作量。
要判断数据是否适合缓存到Redis中,可以从几个方面考虑:数据会被经常查询么?命中率如何?写操作多么?数据大小?数据一致性如何保证等等,我们在下文中详细介绍。
Redis应用场景
我们先看一下Redis的常用数据结
结构名 | Name |
---|---|
字符串 | String |
哈希 | Hash |
列表 | List |
集合 | Set |
有序集合 | Zset |
1、 String
- 最基础的类型,使用场景广泛。
2、 List
- 使用List可以轻松实现最新消息队列功能,List的另一个应用就是消息队列,可以利用List的Push操作,将任务存放在List中,然后工作先线程再用POP操作将任务取出进行执行。
应用场景如微信朋友圈点赞,要求按照点赞顺序显示点赞好有信息,按照时间顺序的微博等等。
3、 Set
- 一个key存储大量数据,在查询方面提供更高的效率。应用场景比较多样,如 每位用户首次使用微博时会设置3项爱好的内容,但是后期为了增加用户的活跃度、兴趣点,必须让用户对其他信息类别逐渐产生兴趣,增加客户留存度,如何实现?等等这类场景。
4、 Hash
- 相比string更节省空间,能直观的维护缓存信息,视频信息等等。
5、Zset
- Redis 有序集合和集合一样也是 string 类型元素的集合,且不允许重复的成员。不同的是每个元素都会关联一个 double 类型的分数。redis 正是通过分数来为集合中的成员进行从小到大的排序。有序集合的成员是唯一的, 但分数(score)却可以重复。有序集合是通过两种数据结构实现:
(1)压缩列表(ziplist)
(2)跳表(zSkiplist)
Redis持久化
我们现在知道了Redis的读写都在内存中,但是当Redis服务器重启的时候Redis数据就会丢失,为了保证内存中的数据不会丢失,Redis提供了一套持久化机制来将内存中的数据写入磁盘。
Redis共有三种持久化机制
AOF日志
每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里
⌜我们可以查看一下自己Redis的写入策略,我这里使用redis-cli CONFIG GET dir
后找到CONFIG的路径,发现并没有预期的appendonly.aof
文件,可以通过redis-cli CONFIG GET appendonly
查看是否开启了AOF,可以使用sudo nano /opt/homebrew/etc/redis.conf
找到 appendonly
这一行,将其设置为 yes。⌟
AOF写回策略
Redis提供了三种写回策略
- Always:意思就是每次执行完写入操作立刻将AOF数据写入磁盘
- Everysec:每次操作执行后先将操作命令写入到AOF的内核缓冲区,每隔一秒将缓冲区中的内容写入到磁盘。
- No:这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘
Q:当然,这里还有一个问题,当AOF日志过大该如何解决?
A:我们都知道AOF日志本质上就是一个文件,在文件内容不断增长,文件体量不断变大的过程中可能会带来性能问题,例如当Redis重启时,如果AOF日志体量过大可能会导致启动时间过长的问题,这里Redis为了避免AOF文件过大的情况,提供了重写机制。
其实很好理解,比如这样一个例子
set name haomiao000
del name
set name haomiao
set name miaohao
在没有重写的情况下AOF日志中存储的是完整的这一套命令,但是当文件体量过大时,这一段该如何优化呢?没错,直接存set name miaohao
就可以了,因为上下文都是覆盖关系,前三句并没有实际的作用。以这样的规则重写后的AOF日志会覆盖旧日志来起到压缩的目的。
Q:与此同时又会有一个新的问题,在重写过程中如果有新的数据写入,修改了某个已经存在的key值,那么执行重写的进程中该key值与主进程key值就出现了不一致的情况,那么我们应该怎么解决呢?
A:其实方法也很好理解,在重写子进程开启的时候,会同时启用一个AOF重写缓冲区,在重写期间主进程作出的操作会同时追加到AOF缓冲区以及AOF重写缓冲区,在子进程重写结束后会像主进程发送一条信号,主进程收到该信号的时候会将AOF缓冲区中的内容追加到重写后的AOF日志,并覆盖成为新的AOF日志。
RDB快照
将某一时刻的内存数据,以二进制的方式写入磁盘
我们可以通过 redis-cli CONFIG GET save
来很方便的查看自己的Redis是否开启
>redis-cli CONFIG GET save
1) "save"
2) "3600 1 300 100 60 10000"
比如我的返回结果,代表着我的RDB是开启的,并且具有以下规则
• 每 3600 秒(1 小时)如果有至少 1 个键发生了变化,Redis 就会保存数据到 RDB 文件。
• 每 300 秒(5 分钟)如果有至少 100 个键发生了变化,Redis 就会保存数据到 RDB 文件。
• 每 60 秒(1 分钟)如果有至少 10000 个键发生了变化,Redis 就会保存数据到 RDB 文件。
RDB到底是怎么实现的呢?
我们在看完AOF后就会发现AOF其实缓存的是具体的命令,在恢复过程中如果AOF文件非常庞大,那么通过命令来恢复的效率就会变得非常低。RDB便可以很好的解决这个问题,在程序执行过程中,RDB只是写入了具体的数据,在恢复过程中也只是恢复数据,而不是通过AOF那样执行命令获得数据。
混合持久化方式
Redis 4.0 新增的方式,集成了 AOF 和 RBD 的优点
我们可以发现AOF虽然可以记录完整的信息,但是其恢复速度受限,而RDB快照频率设置不当很有可能影响性能,所以Redis4.0结合了他们的优点,诞生出目前更实用的混合持久化的方式。
核心思想很简单:内存快照以一定频率执行,在两次快照中间使用AOF记录这期间的操作,RDB更新快照使用AOF。
举个例子:
Redis集群
在大型项目中,Redis作为关键的一环,如果是单节点部署的,那么出现问题会导致一系列连锁反应,即使将数据持久化到磁盘,重启Redis的时间也会造成不可估量的损失,因此为了保持Redis能够稳定的工作,通常对于Redis的部署都是集群部署来保证可用性。
主从架构
架构模型其实很简单,一个主对应多个从节点,当主节点挂掉的时候立刻切换至从节点。同时,由于从服务器数据和主服务器是同步的,因此在读多写少的情况下我们也可以将读操作分担给从服务器来减少主服务器的压力例如:
主从节点数据同步
在主从模式下,数据同步非常重要,是从节点能否成为选举节点的基础。主从数据同步分为快照同步,增量同步
快照同步
这种同步方式是当Master发生变更的时候,会将整个RDB文件发送至从节点,从节点收到后会先将自己现有的RDB文件删除,然后用收到的RDB当作新的RDB文件。
但是这里同时又有一个问题,如果RDB文件过大亦或网络延迟过高导致同步过程阻塞,整个同步过程占用过多的资源显然是我们不期望的,所以这里引用了一个新的概念:增量同步。
增量同步
增量同步的核心是异步同步,只同步变化的内容,其实很好理解,在主从服务器中间维护一个同步缓冲区buffer,将master的变化加入buffer中,slave从buffer中取出变化内容进行同步。
主从架构带来的问题
-
很明显的问题是在分布式系统中虽然主从架构解决了数据冗余的问题,但是在更换节点上仍然需要人工选举更换,不仅增加了人工成本,也增加了不确定性因素。这个问题我们在后续的结构中会涉及解决。
-
主从风暴,通常我们在设计Redis集群时应该尽可能控制从服务器数量,过多的从服务器在同时向主服务器发起同步时会增大服务器压力,影响主服务器性能,导致主从风暴。当然,在大型项目中有些情况避免不了这种情况,所以我们可以考虑将从服务器设计成树形结构,例如:
哨兵集群
主从架构无法解决主节点宕机自动恢复的问题,因此引入了哨兵集群,哨兵集群其实和服务发现有些类似,我们搭建哨兵集群来监控主节点,当主节点宕机后,哨兵集群立刻从从节点中选举出新的主节点,在客户端访问时不直接访问主从架构,而是访问哨兵集群,哨兵集群将当前主节点的IP和端口发送至客户端,再有客户端通过该IP和端口访问对应的节点。
结构如图:
当然,哨兵集群也伴随着一些问题
-
在选举过程中无法对外提供服务。
-
由于集群容量受限,一个哨兵集群只能服务于一个主从架构,所以在大型系统中对每个主从集群配备一个哨兵集群的做法并不现实。
Redis 切片集群(Redis Cluster )
当出现如上问题的时候,又或者当数据量大到一个节点无法单独存储的时候,Redis切片集群是一个更好的解决方案。
架构如图
在切片集群中并不存在哨兵节点,如果某个节点宕机了,会向其他主从节点发出选举,其他主从节点会根据是否同意升级该从节点回应响应,当响应过半后该从节点会主动将自己升级为主节点。
另外,切片集群解决了数据过大的问题,本质上也是一个分片的方法,其内置的哈希槽会将每次处理的数据映射到对应的节点中。
具体模式如图
Redis的过期删除和内存淘汰策略
Redis内存淘汰策略分为惰性删除和定期删除
惰性删除
惰性删除指的是只有当客户端向Redis访问时,如果查找到的key是过期的才将其删除。
这样做可以减少cpu开销,但是缺点也很明显,没被访问的过期数据会一直保留在服务器中,这样会造成内存的浪费。
定期删除
Redis 的定期删除的流程:
1. 从过期字典中随机抽取 20 个 key;
2. 检查这 20 个 key 是否过期,并删除已过期的 key;
3. 如果本轮检查的已过期 key 的数量,超过 5 个 (20/4),也就是「已过期 key 的数量」大于 25%,则继续重复步骤 1;如果已过期的 key 比例小于 25%,则停止继续删除过期 key,然后等待下一轮再检查。
可以看到,定期删除是一个循环的流程。那 Redis 为了保证定期删除不会出现循环过度,导致线程卡死现象,为此增加了定期删除循环的时间上限,默认不会超过 25ms。
定期删除显然可以解决惰性删除的缺点问题,但是过多的删除次数同样会导致性能下降,所以在实际应用中,往往采用惰性删除和定期删除配合使用使得收益最大。
Redis设计
在学习Redis设计之前,我们先了解一下Redis常见的设计缺陷导致的问题。
缓存雪崩
我们都知道用户在发起请求的时候如果缓存中没有会向数据库中检索,当Redis宕机或大量受访数据在同一时间失效时,会有大量用户请求直接访问数据库,数据库压力骤增甚至宕机,从而导致的一系列连锁反应,就是缓存雪崩。
对于引起雪崩的原因主要有两种
1、Redis宕机
2、大量受访数据同时过期
根据这两个原因,我们可以总结出对应的解决方案。
-
针对Redis宕机问题,我们可以通过熔断机制来暂停对缓存服务的继续访问,直接返回错误。同样我们可以搭建Redis集群,在主节点宕机时及时发现并更换新节点。
-
对于大量受访数据同时过期的问题,我们可以均匀设计过期时间,增加互斥锁使得同一时间只有一个请求构建缓存。
缓存击穿
缓存击穿和缓存雪崩其实是包含关系,缓存击穿的意思是指当某个热点数据过期,大量请求会同时向数据库发起申请,数据库不堪重负宕机的情况。
对于缓存击穿我们可以很容易想到两个解决办法
-
增加互斥锁
-
对于热点数据不设计过期时间
缓存穿透
缓存穿透和其他两种情况略有不同,当大量未在缓存且不在数据库中的数据同时发起请求时,数据库压力会骤增。这种情况多发于恶意攻击,为了避免缓存穿透,我们可以在设计时通过
-
对于非法请求我们可以在网关处就对其进行检查,做合理性判断。(较为实用)
-
对于不存在的key可以将其缓存设置为空值,下次访问返回空,不会再去查数据库。(不是很实用)
-
使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在。(较为实用)
布隆过滤器
布隆过滤器由初始值全是0的位图数组和N个哈希函数组成,工作原理参考下图:
举个例子,我现在正在爬取所有与xx相关的网站URL;我怎么快速判断某个URL是否已经爬取过呢?你可能第一反应是哈希表,但是对于巨量的数据哈希表占用的内存过大,并且当不断的插入过程中哈希表的扩容也会带来额外的开销,所以我们考虑更加简单高效的布隆过滤器。
假设我这里有3个哈希函数,通过3个哈希函数分别处理URL得到3个哈希值x1,x2,x3,布隆过滤器实质上是一个桶状结构,判断x1 , x2 , x3槽位是否都为1,如果有一个位是0,说明这个URL并没有被获取过,执行获取操作后将x1,x2,x3位置1。
当然,当x1,x2,x3都是1的时候,其实我们并不能完全确定这个URL被获取过了(存在哈希冲突亦或其他几个URL的值组合后使得x1,x2,x3都为1),所以布隆过滤器过滤出的内容可以保证没有存在的一定未曾存在过,但是存在过的不一定存在过,主要应用于对空间要求严格并且精确性要求没有那么高的需求。
常见缓存策略
常见的缓存车了共有3种
-
旁路缓存
-
读穿/写穿策略
-
写回策略
实际开发中,旁路缓存是最常见的,所以我们这里只介绍旁路缓存(Cache Aside)策略。
旁路缓存
分布式系统中,数据库和缓存作为不同的组件,保证他们的一致性非常重要。那么当执行读写操作时,应该以什么顺序或什么方式进行呢?
先更新数据库再更新缓存(不可行)
想象这样一个场景
A更新了数据库 -> B更新了数据库 -> B更新了缓存 -> A更新了数据库
结果就导致数据库是B更新的结果,而缓存是A更新的结果
先更新缓存再更新数据库(不可行)
想象这样一个场景
A更新了缓存 -> B更新了缓存 -> B更新了数据库 -> A更新了数据库
结果就导致数据库是A更新的结果,而缓存是B更新的结果
所以传统的先后顺序是解决不了他们的一致性问题的。这里引入了一个新的概念叫**旁路缓存**
旁路缓存的策略是不直接更新缓存,而是在更新数据的时候删除缓存中的数据,在读取的时候再写入缓存。这样的策略可以解决上述问题,但是这里又出现了一个新的问题,先更新数据库还是先更新缓存?
以下情况均考虑最劣情况其他情况可自己讨论(结果一定是可行的)
先删除缓存,再更新数据库(不可行)
想象这样一个读写场景
A删除缓存 -> B读取缓存缓存未命中,读取数据库的值 -> B更新了缓存 -> A更新了数据库
结果就导致数据库是A更新的结果,而缓存是仍是原来的值,当然,我们可以通过延迟双删,手动删除来解决,但是这么做怎么都感觉很蠢不是吗。
先更新数据库,再删除缓存(可行)
想象这样一个读写场景
A读取缓存未命中 -> A读取数据库的值 -> B更新数据库的值为新值 ->B删除缓存 ->A将读取的旧值写入缓存
这样虽然会导致B更新的新值与缓存最后的值不一致,但是实际上,由于缓存写入相较于数据库的写入快很多,所以这个问题出现的概率并不高。所以先更新数据库再更新缓存是可行的。为了避免极端情况的发生,这里可以给缓存添加过期时间,即使出现了数据不一致,也有更正的可能。当然也可以通过延迟删除的方式,手动增加数据库操作时间,二次删除来保证一致性。
这个方案,是实时性中最好的方案,在一些高并发场景中,推荐这种。
补充(可用性讨论)
我们可以发现其实数据库操作和缓存操作其实是两个操作,他们并不具备原子性。我们上述讨论都是以他们都可以正常进行为前提的,但是当这种情况并不成立的时候,上述讨论就会出现一些漏洞。对于先更新数据库再更新缓存的操作,如果Redis在删除的过程中出现问题,导致只有数据库更新成功,redis并没有删除,就会导致数据库和缓存不一致的情况,这里该怎么处理呢?
这里有两种重试机制
-
消息队列重试机制
-
订阅MySQL binlog再操作缓存。
消息队列重试机制
对于上述两种重试机制原理其实类似,都是采用异步的方式来处理,采用消息队列重试机制,我们需要将需要在缓存中删除的数据加入消息队列中,由消费者来操作删除,删除失败则重试(当然失败一定次数就要返回错误了),删除成功就将数据从消息队列移除。
这么做看似简单,但是对原本业务的侵入性强,需要改变原本的代码逻辑,所以我这里不做过多解释。
订阅MySQL binlog再操作缓存
这种方案,主要是监听 MySQL 的 Binlog,然后通过异步的方式,将数据更新到 Redis,这种方案有个前提,查询的请求,不会回写 Redis。这个方案,会保证 MySQL 和 Redis 的最终一致性,但是如果中途请求 B 需要查询数据,如果缓存无数据,就直接查 DB;如果缓存有数据,查询的数据也会存在不一致的情况。
所以这个方案,是实现最终一致性的终极解决方案,但是不能保证实时性。
对于异地容灾、数据汇总等,建议会用这种方式,比如 binlog + kafka,数据的一致性也可以达到秒级;
纯粹的高并发场景,不建议用这种方案,比如抢购、秒杀等。