Redis 知识点
缓存知识点
缓存有哪些类型?
缓存是高并发场景下提高热点数据访问性能的一个有效手段,在开发项目时会经常使用到。
缓存的类型分为:本地缓存、分布式缓存和多级缓存。
本地缓存:
本地缓存就是在进程的内存中进行缓存,比如我们的 JVM
堆中,可以用 LRUMap
来实现,也可以使用 Ehcache
这样的工具来实现。
本地缓存是内存访问,没有远程交互开销,性能最好,但是受限于单机容量,一般缓存较小且无法扩展。
分布式缓存:
分布式缓存可以很好地解决这个问题。
分布式缓存一般都具有良好的水平扩展能力,对较大数据量的场景也能应付自如。缺点就是需要进行远程请求,性能不如本地缓存。
多级缓存:
为了平衡这种情况,实际业务中一般采用多级缓存,本地缓存只保存访问频率最高的部分热点数据,其他的热点数据放在分布式缓存中。
在目前的一线大厂中,这也是最常用的缓存方案,单靠单一的缓存方案往往难以撑住很多高并发的场景。
淘汰策略
不管是本地缓存还是分布式缓存,为了保证较高性能,都是使用内存来保存数据,由于成本和内存限制,当存储的数据超过缓存容量时,需要对缓存的数据进行剔除。
一般的剔除策略有 FIFO 淘汰最早数据、LRU 剔除最近最少使用、和 LFU 剔除最近使用频率最低的数据几种策略。
- noeviction: 返回错误当内存限制达到并且客户端尝试执行会让更多内存被使用的命令(大部分的写入指令,但 DEL 和几个例外)
- allkeys-lru: 尝试回收最少使用的键(LRU),使得新添加的数据有空间存放。
- volatile-lru: 尝试回收最少使用的键(LRU),但仅限于在过期集合的键,使得新添加的数据有空间存放。
- allkeys-random: 回收随机的键使得新添加的数据有空间存放。
- volatile-random: 回收随机的键使得新添加的数据有空间存放,但仅限于在过期集合的键。
- volatile-ttl: 回收在过期集合的键,并且优先回收存活时间(TTL)较短的键,使得新添加的数据有空间存放。
如果没有键满足回收的前提条件的话,策略 volatile-lru, volatile-random 以及 volatile-ttl 就和 noeviction 差不多了。
Memcache
注意后面会把 Memcache 简称为 MC。
先来看看 MC 的特点:
- MC 处理请求时使用多线程异步 I/O 的方式,可以合理利用 CPU 多核的优势,性能非常优秀;
- MC 功能简单,使用内存存储数据;
- MC 的内存结构以及钙化问题我就不细说了,大家可以查看官网了解下;
- MC 对缓存的数据可以设置失效期,过期后的数据会被清除;
- 失效的策略采用延迟失效,就是当再次使用数据时检查是否失效;
- 当容量存满时,会对缓存中的数据进行剔除,剔除时除了会对过期 key 进行清理,还会按 LRU 策略对数据进行剔除。
另外,使用 MC 有一些限制,这些限制在现在的互联网场景下很致命,成为大家选择 Redis、MongoDB 的重要原因:
- key 不能超过 250 个字节;
- value 不能超过 1M 字节;
- key 的最大失效时间是 30 天;
- 只支持 K-V 结构,不提供持久化和主从同步功能。
Redis
先简单说一下 Redis 的特点,方便和 MC 比较。
- 与 MC 不同的是,Redis 采用单线程模式处理请求。这样做的原因有 2 个:一个是因为采用了非阻塞的异步事件处理机制;另一个是缓存数据都是内存操作 I/O 时间不会太长,单线程可以避免线程上下文切换产生的代价。
- Redis 支持持久化,所以 Redis 不仅仅可以用作缓存,也可以用作 NoSQL 数据库。
- 相比 MC,Redis 还有一个非常大的优势,就是除了 K-V 之外,还支持多种数据格式,例如 list、set、sorted set、hash 等。
- Redis 提供主从同步机制,以及 Cluster 集群部署能力,能够提供高可用服务。
详解 Redis
Redis 的知识点结构如下图所示。
功能
来看 Redis 提供的功能有哪些吧!
我们先看基础类型:
String:
String 类型是 Redis 中最常使用的类型,内部的实现是通过 SDS(Simple Dynamic String)来存储的。SDS 类似于 Java
中的 ArrayList
,可以通过预分配冗余空间的方式来减少内存的频繁分配。
这是最简单的类型,就是普通的 set 和 get,做简单的 KV 缓存。
String 的实际应用场景比较广泛的有:
- 缓存功能:String 字符串是最常用的数据类型,不仅仅是 Redis,各个语言都是最基本类型,因此,利用 Redis 作为缓存,配合其它数据库作为存储层,利用 Redis 支持高并发的特点,可以大大加快系统的读写速度、以及降低后端数据库的压力。
- 计数器:许多系统都会使用 Redis 作为系统的实时计数器,可以快速实现计数和查询的功能。而且最终的数据结果可以按照特定的时间落地到数据库或者其它存储介质当中进行永久保存。
- 共享用户 Session:用户重新刷新一次界面,可能需要访问一下数据进行重新登录,或者访问页面缓存 Cookie,但是可以利用 Redis 将用户的 Session 集中管理,在这种模式只需要保证 Redis 的高可用,每次用户 Session 的更新和获取都可以快速完成。大大提高效率。
Hash:
这个是类似 Map 的一种结构,这个一般就是可以将结构化的数据,比如一个对象(前提是这个对象没嵌套其他的对象)给缓存在 Redis 里,然后每次读写缓存的时候,可以就操作 Hash 里的某个字段。
但是这个的场景其实还是多少单一了一些,因为现在很多对象都是比较复杂的,比如你的商品对象可能里面就包含了很多属性,其中也有对象。我自己使用的场景用得不是那么多。
List:
List 是有序列表,这个还是可以玩儿出很多花样的。
比如可以通过 List 存储一些列表型的数据结构,类似粉丝列表、文章的评论列表之类的东西。
比如可以通过 lrange
命令,读取某个闭区间内的元素,可以基于 List 实现分页查询,这个是很棒的一个功能,基于 Redis 实现简单的高性能分页,可以做类似微博那种下拉不断分页的东西,性能高,就一页一页走。
比如可以搞个简单的消息队列,从 List 头怼进去,从 List 屁股那里弄出来。
List 本身就是我们在开发过程中比较常用的数据结构了,热点数据更不用说了。
- 消息队列:Redis的链表结构,可以轻松实现阻塞队列,可以使用左进右出的命令组成来完成队列的设计。比如:数据的生产者可以通过
Lpush
命令从左边插入数据,多个数据消费者,可以使用BRpop
命令阻塞的“抢”列表尾部的数据。 - 文章列表或者数据分页展示的应用。
比如,我们常用的博客网站的文章列表,当用户量越来越多时,而且每一个用户都有自己的文章列表,而且当文章多时,都需要分页展示,这时可以考虑使用 Redis 的列表,列表不但有序同时还支持按照范围内获取元素,可以完美解决分页查询功能。大大提高查询效率。
Set:
Set 是无序集合,会自动去重的那种。
直接基于 Set 将系统里需要去重的数据扔进去,自动就给去重了,如果你需要对一些数据进行快速的全局去重,你当然也可以基于 JVM
内存里的 HashSet
进行去重,但是如果你的某个系统部署在多台机器上呢?得基于 Redis 进行全局的 Set 去重。
可以基于 Set 玩儿交集、并集、差集的操作,比如交集吧,我们可以把两个人的好友列表整一个交集,看看俩人的共同好友是谁?对吧。
反正这些场景比较多,因为对比很快,操作也简单,两个查询一个 Set 搞定。
Sorted Set:
Sorted Set 是排序的 Set,去重但可以排序,写进去的时候给一个分数,自动根据分数排序。
有序集合的使用场景与集合类似,但是set集合不是自动有序的,而 Sorted Set 可以利用分数进行成员间的排序,而且是插入时就排序好。所以当你需要一个有序且不重复的集合列表时,就可以选择 Sorted Set 数据结构作为选择方案。
- 排行榜:有序集合经典使用场景。例如视频网站需要对用户上传的视频做排行榜,榜单维护可能是多方面:按照时间、按照播放量、按照获得的赞数等。
- 用 Sorted Set 来做带权重的队列,比如普通消息的 score 为 1,重要消息的 score 为 2,然后工作线程可以选择按 score 的倒序来获取工作任务。让重要的任务优先执行。
微博热搜榜,就是有个后面的热度值,前面就是名称。
高级用法:
Bitmap:
位图是支持按 bit 位来存储信息,可以用来实现 布隆过滤器(BloomFilter);
HyperLogLog:
供不精确的去重计数功能,比较适合用来做大规模数据的去重统计,例如统计 UV;
Geospatial:
可以用来保存地理位置,并作位置距离计算或者根据半径计算位置等。有没有想过用 Redis 来实现附近的人?或者计算最优地图路径?
pub/sub:
功能是订阅发布功能,可以用作简单的消息队列。
Pipeline:
可以批量执行一组指令,一次性返回全部结果,可以减少频繁的请求应答。
Lua:
Redis 支持提交 Lua 脚本来执行一系列的功能。
事务:
最后一个功能是事务,但 Redis 提供的不是严格的事务,Redis 只保证串行执行命令,并且能保证全部执行,但是执行命令失败时并不会回滚,而是会继续执行下去。
持久化
Redis 提供了 RDB 和 AOF 两种持久化方式,RDB 是把内存中的数据集以快照形式写入磁盘,实际操作是通过 fork 子进程执行,采用二进制压缩存储;AOF 是以文本日志的形式记录 Redis 处理的每一个写入或删除操作。
RDB 把整个 Redis 的数据保存在单一文件中,比较适合用来做灾备,但缺点是快照保存完成之前如果宕机,这段时间的数据将会丢失,另外保存快照时可能导致服务短时间不可用。
AOF 对日志文件的写入操作使用的追加模式,有灵活的同步策略,支持每秒同步、每次修改同步和不同步,缺点就是相同规模的数据集,AOF 要大于 RDB,AOF 在运行效率上往往会慢于 RDB。
高可用
来看 Redis 的高可用。Redis 支持主从同步,提供 Cluster 集群部署模式,通过 Sentinel 哨兵来监控 Redis 主服务器的状态。当主挂掉时,在从节点中根据一定策略选出新主,并调整其他从 slaveof 到新主。
选主的策略简单来说有三个:
- slave 的 priority 设置的越低,优先级越高;
- 同等情况下,slave 复制的数据越多优先级越高;
- 相同的条件下 runid 越小越容易被选中。
在 Redis 集群中,sentinel 也会进行多实例部署,sentinel 之间通过 Raft 协议来保证自身的高可用。
Redis Cluster 使用分片机制,在内部分为 16384 个 slot 插槽,分布在所有 master 节点上,每个 master 节点负责一部分 slot。数据操作时按 key 做 CRC16 来计算在哪个 slot,由哪个 master 进行处理。数据的冗余是通过 slave 节点来保障。
哨兵
哨兵必须用三个实例去保证自己的健壮性的,哨兵 + 主从并不能保证数据不丢失,但是可以保证集群的高可用。
为啥必须要三个实例呢?我们先看看两个哨兵会咋样。
master 宕机了 s1 和 s2 两个哨兵只要有一个认为你宕机了就切换了,并且会选举出一个哨兵去执行故障,但是这个时候也需要大多数哨兵都是运行的。
那这样有啥问题呢?M1 宕机了,S1 没挂那其实是 OK 的,但是整个机器都挂了呢?哨兵就只剩下 S2 个裸屌了,没有哨兵去允许故障转移了,虽然另外一个机器上还有 R1,但是故障转移就是不执行。
经典的哨兵集群是这样的:
M1 所在的机器挂了,哨兵还有两个,两个人一看他不是挂了嘛,那我们就选举一个出来执行故障转移不就好了。
暖男我,小的总结下哨兵组件的主要功能:
- 集群监控:负责监控 Redis master 和 slave 进程是否正常工作。
- 消息通知:如果某个 Redis 实例有故障,那么哨兵负责发送消息作为报警通知给管理员。
- 故障转移:如果 master node 挂掉了,会自动转移到 slave node 上。
- 配置中心:如果故障转移发生了,通知 client 客户端新的 master 地址。
主从
提到这个,就跟我前面提到的数据持久化的 RDB 和 AOF 有着比密切的关系了。
我先说下为啥要用主从这样的架构模式,前面提到了单机 QPS 是有上限的,而且 Redis 的特性就是必须支撑读高并发的,那你一台机器又读又写,这谁顶得住啊,不当人啊!但是你让这个 master 机器去写,数据同步给别的 slave 机器,他们都拿去读,分发掉大量的请求那是不是好很多,而且扩容的时候还可以轻松实现水平扩容。
你启动一台 slave 的时候,他会发送一个 psync
命令给 master ,如果是这个 slave 第一次连接到 master,他会触发一个全量复制。master 就会启动一个线程,生成 RDB 快照,还会把新的写请求都缓存在内存中,RDB 文件生成后,master 会将这个 RDB 发送给 slave 的,slave 拿到之后做的第一件事情就是写进本地的磁盘,然后加载进内存,然后 master 会把内存里面缓存的那些新命名都发给 slave。
我发出来之后来自 CSDN 的网友:Jian_Shen_Zer 问了个问题:
主从同步的时候,新的 slave 进来的时候用 RDB,那之后的数据呢?有新的数据进入 master 怎么同步到 slave 啊
敖丙答:笨,AOF 嘛,增量的就像 MySQL 的 Binlog 一样,把日志增量同步给从服务就好了。
key 失效机制
Redis 的 key 可以设置过期时间,过期后 Redis 采用主动和被动结合的失效机制,一个是和 MC 一样在访问时触发被动删除,另一种是定期的主动删除。
定期 + 惰性 + 内存淘汰
缓存常见问题
缓存更新方式
这是决定在使用缓存时就该考虑的问题。
缓存的数据在数据源发生变更时需要对缓存进行更新,数据源可能是 DB,也可能是远程服务。更新的方式可以是主动更新。数据源是 DB 时,可以在更新完 DB 后就直接更新缓存。
当数据源不是 DB 而是其他远程服务,可能无法及时主动感知数据变更,这种情况下一般会选择对缓存数据设置失效期,也就是数据不一致的最大容忍时间。
这种场景下,可以选择失效更新,key 不存在或失效时先请求数据源获取最新数据,然后再次缓存,并更新失效期。
但这样做有个问题,如果依赖的远程服务在更新时出现异常,则会导致数据不可用。改进的办法是异步更新,就是当失效时先不清除数据,继续使用旧的数据,然后由异步线程去执行更新任务。这样就避免了失效瞬间的空窗期。另外还有一种纯异步更新方式,定时对数据进行分批更新。实际使用时可以根据业务场景选择更新方式。
数据不一致
第二个问题是数据不一致的问题,可以说只要使用缓存,就要考虑如何面对这个问题。缓存不一致产生的原因一般是主动更新失败,例如更新 DB 后,更新 Redis 因为网络原因请求超时;或者是异步更新失败导致。
解决的办法是,如果服务对耗时不是特别敏感可以增加重试;如果服务对耗时敏感可以通过异步补偿任务来处理失败的更新,或者短期的数据不一致不会影响业务,那么只要下次更新时可以成功,能保证最终一致性就可以。
缓存穿透
缓存穿透,产生这个问题的原因可能是外部的恶意攻击,例如,对用户信息进行了缓存,但恶意攻击者使用不存在的用户 id 频繁请求接口,导致查询缓存不命中,然后穿透 DB 查询依然不命中。这时会有大量请求穿透缓存访问到 DB。
解决的办法如下:
- 对不存在的用户,在缓存中保存一个空对象进行标记,防止相同 ID 再次访问 DB。不过有时这个方法并不能很好解决问题,可能导致缓存中存储大量无用数据。
- 使用 BloomFilter 过滤器,BloomFilter 的特点是存在性检测,如果 BloomFilter 中不存在,那么数据一定不存在;如果 BloomFilter 中存在,实际数据也有可能会不存在。非常适合解决这类的问题。
缓存击穿
缓存击穿,就是某个热点数据失效时,大量针对这个数据的请求会穿透到数据源。
解决这个问题有如下办法:
- 可以使用互斥锁更新,保证同一个进程中针对同一个数据不会并发请求到 DB,减小 DB 压力。
- 使用随机退避方式,失效时随机 sleep 一个很短的时间,再次查询,如果失败再执行更新。
- 针对多个热点 key 同时失效的问题,可以在缓存时使用固定时间加上一个小的随机数,避免大量热点 key 同一时刻失效。
缓存雪崩
缓存雪崩,产生的原因是缓存挂掉,这时所有的请求都会穿透到 DB。
解决方法:
- 使用快速失败的熔断策略,减少 DB 瞬间压力;
- 使用主从模式和集群模式来尽量保证缓存服务的高可用。
实际场景中,这两种方法会结合使用。