[总结]为什么分布式一定要有Redis?
考虑到绝大部分写业务的程序员,在实际开发中使用 Redis 的时候,只会 Set Value 和 Get Value 两个操作,对 Redis 整体缺乏一个认知。
所以我斗胆以 Redis 为题材,对 Redis 常见问题做一个总结,希望能够弥补大家的知识盲点。
本文围绕以下几点进行阐述:
- 为什么使用 Redis
- 使用 Redis 有什么缺点
- 单线程的 Redis 为什么这么快
- Redis 的数据类型,以及每种数据类型的使用场景
- Redis 的过期策略以及内存淘汰机制
- Redis 持久化机制
- Redis 事务
- Redis 和数据库双写一致性问题
- 如何应对缓存穿透和缓存雪崩问题
- 如何解决 Redis 的并发竞争 Key 问题
1. 为什么使用 Redis
我觉得在项目中使用 Redis,主要是从两个角度去考虑:性能和并发。
当然,Redis 还具备可以做分布式锁等其他功能,但是如果只是为了分布式锁这些其他功能,完全还有其他中间件,如 ZooKpeer 等代替,并不是非要使用 Redis。因此,这个问题主要从性能和并发两个角度去答。
1-1. 性能
如下图所示,我们在碰到需要执行耗时特别久,且结果不频繁变动的 SQL,就特别适合将运行结果放入缓存。这样,后面的请求就去缓存中读取,使得请求能够迅速响应。
题外话:忽然想聊一下这个迅速响应的标准。根据交互效果的不同,这个响应时间没有固定标准。
不过曾经有人这么告诉我:“在理想状态下,我们的页面跳转需要在瞬间解决,对于页内操作则需要在刹那间解决。
另外,超过一弹指的耗时操作要有进度提示,并且可以随时中止或取消,这样才能给用户最好的体验。”
那么瞬间、刹那、一弹指具体是多少时间呢?
根据《摩诃僧祗律》记载:
一刹那者为一念,二十念为一瞬,二十瞬为一弹指,二十弹指为一罗预,二十罗预为一须臾,一日一夜有三十须臾。
那么,经过周密的计算,一瞬间为 0.36 秒、一刹那有 0.018 秒、一弹指长达 7.2 秒。
1-2. 并发
如下图所示,在大并发的情况下,所有的请求直接访问数据库,数据库会出现连接异常。
这个时候,就需要使用 Redis 做一个缓冲操作,让请求先访问到 Redis,而不是直接访问数据库。
1-3. 为什么要用 Redis 而不用 map/guava 做缓存
缓存分为本地缓存和分布式缓存。以 Java 为例,使用自带的 map 或者 guava 实现的是本地缓存,最主要的特点是轻量以及快速,生命周期随着 JVM 的销毁而结束。
并且在多实例的情况下,每个实例都需要各自保存一份缓存,缓存不具有一致性。
使用 Redis 或 Memcached 之类的称为分布式缓存,在多实例的情况下,各实例共用一份缓存数据,缓存具有一致性。
缺点是需要保持 Redis 或 Memcached 服务的高可用,整个程序架构上较为复杂。
1-4. Redis 和 Memcached 的区别
对于 Redis 和 Memcached 我总结了下面四点:
- Redis 支持更丰富的数据类型(支持更复杂的应用场景):Redis 不仅仅支持简单的 K/V 类型的数据,同时还提供 list、set、zset、hash 等数据结构的存储。Memcache 支持简单的数据类型 String。
- Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memecache 把数据全部存在内存之中。
- 集群模式:Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 Redis 目前是原生支持 Cluster 模式的。
- Memcached 是多线程,非阻塞 IO 复用的网络模型;Redis 使用单线程的多路 IO 复用模型。
来自网络上的一张对比图,这里分享给大家:
2. 使用 Redis 有什么缺点
大家用 Redis 这么久,这个问题是必须要了解的,基本上使用 Redis 都会碰到一些问题,常见的也就几个。
回答主要是四个问题:
- 缓存和数据库双写一致性问题
- 缓存雪崩问题
- 缓存击穿问题
- 缓存的并发竞争问题
这四个问题,我个人觉得在项目中是常遇见的,具体解决方案,后文给出。
2-1.
3. 单线程的 Redis 为什么这么快
这个问题是对 Redis 内部机制的一个考察。根据我的面试经验,很多人都不知道Redis 是单线程工作模型。所以,这个问题还是应该要复习一下的。
回答主要是以下三点:
- 纯内存操作
- 单线程操作,避免了频繁的上下文切换
- 采用了非阻塞 I/O 多路复用机制
- Redis数据结构简单、对数据的操作也比较简单。
- 使用底层模型不同,它们之间底层实现方式以及与客户端之间通信的应用协议不一样,Redis直接自己构建了VM机制 ,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求
题外话:我们现在要仔细的说一说 I/O 多路复用机制,因为这个说法实在是太通俗了,通俗到一般人都不懂是什么意思。I/O多路复用技术,是为了解决进程或线程阻塞到某个I/O系统调用而出现的技术,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪,就是这个文件描述符进行读写操作之前),能够通知程序进行相应的读写操作。
打一个比方:小曲在 S 城开了一家快递店,负责同城快送服务。小曲因为资金限制,雇佣了一批快递员,然后小曲发现资金不够了,只够买一辆车送快递。
客户每送来一份快递,小曲就让一个快递员盯着,然后快递员开车去送快递。
慢慢的小曲就发现了这种经营方式存在下述问题:
- 几十个快递员基本上时间都花在了抢车上了,大部分快递员都处在闲置状态,谁抢到了车,谁就能去送快递。
- 随着快递的增多,快递员也越来越多,小曲发现快递店里越来越挤,没办法雇佣新的快递员了。
- 快递员之间的协调很花时间。
综合上述缺点,小曲痛定思痛,提出了下面的经营方式。
小曲只雇佣一个快递员。然后呢,客户送来的快递,小曲按送达地点标注好,然后依次放在一个地方。
最后,那个快递员依次的去取快递,一次拿一个,然后开着车去送快递,送好了就回来拿下一个快递。
上述两种经营方式对比,是不是明显觉得第二种,效率更高,更好呢?
在上述比喻中:
- 每个快递员→每个线程
- 每个快递→每个 Socket(I/O 流)
- 快递的送达地点→Socket 的不同状态
- 客户送快递请求→来自客户端的请求
- 小曲的经营方式→服务端运行的代码
- 一辆车→CPU 的核数
于是我们有如下结论:
- 经营方式一就是传统的并发模型,每个 I/O 流(快递)都有一个新的线程(快递员)管理。
- 经营方式二就是 I/O 多路复用。只有单个线程(一个快递员),通过跟踪每个 I/O 流的状态(每个快递的送达地点),来管理多个 I/O 流。
下面类比到真实的 Redis 线程模型,如图所示:
简单来说,就是我们的 redis-client 在操作的时候,会产生具有不同事件类型的 Socket。
在服务端,有一段 I/O 多路复用程序,将其置入队列之中。然后,文件事件分派器,依次去队列中取,转发到不同的事件处理器中。
需要说明的是,这个 I/O 多路复用机制,Redis 还提供了 select、epoll、evport、kqueue 等多路复用函数库,大家可以自行去了解。
3-1.
4. Redis 的数据类型,以及每种数据类型的使用场景
是不是觉得这个问题很基础?我也这么觉得。然而根据面试经验发现,至少百分之八十的人答不上这个问题。
建议,在项目中用到后,再类比记忆,体会更深,不要硬记。基本上,一个合格的程序员,五种类型都会用到。
这个没啥好说的,最常规的 set/get 操作,Value 可以是 String 也可以是数字。一般做一些复杂的计数功能的缓存, 常规计数:微博数,粉丝数等。在Redis中一个字符串最大的容量为512MB,可以说是无所不能了。
这里 Value 特别适合用于存放结构化的对象,比较方便的就是操作其中的某个字段。
后续操作的时候,你可以直接仅仅修改这个对象中的某个字段的值。比如我们可以 Hash 数据结构来存储用户信息,商品信息等等。
我在做单点登录的时候,就是用这种数据结构存储用户信息,以 CookieId 作为 Key,设置 30 分钟为缓存过期时间,能很好的模拟出类似 Session 的效果。
List 就是链表。使用 List 的数据结构,比如微博的关注列表,粉丝列表,消息列表等功能都可以用 Redis 的 List 结构来实现。
Redis List 的实现为一个双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。
另外可以利用 lrange 命令,就是从某个元素开始读取多少个元素, 做基于 Redis 的分页功能,性能极佳,用户体验好。
这是很棒的一个功能,基于 Redis 实现简单的高性能分页,可以做类似微博那种下拉不断分页的东西(一页一页的往下走),性能高。
因为 Set 堆放的是一堆不重复值的集合。所以可以做全局去重的功能。为什么不用 JVM 自带的 Set 进行去重?
因为我们的系统一般都是集群部署,使用 JVM 自带的 Set,比较麻烦,难道为了一个做一个全局去重,再起一个公共服务,太麻烦了。
并且 Set 提供了判断某个成员是否在一个 Set 集合内的重要接口,这个也是 List 所不能提供的。你可以基于 Set 轻易实现交集、并集、差集的操作。
另外,就是利用交集、并集、差集等操作,可以计算共同喜好,全部的喜好,自己独有的喜好等功能。
比如:在微博应用中,可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。Redis 可以非常方便的实现如共同关注、共同粉丝、共同喜好等功能。
Sorted Set多了一个权重参数 Score,集合中的元素能够按 Score 进行排列。
可以做排行榜应用,取 TOP N 操作。Sorted Set 可以用来做延时任务。最后一个应用就是可以做范围查找。
举例:在直播系统中,实时排行信息包含直播间在线用户列表,各种礼物排行榜,弹幕消息(可以理解为按消息维度的消息排行榜)等信息,适合使用 Redis 中的 Sorted Set 结构进行存储。
5. Redis 的过期策略以及内存淘汰机制
这个问题相当重要,到底 Redis 有没用到家,这个问题就可以看出来。
比如你 Redis 只能存 5G 数据,可是你写了 10G,那会删 5G 的数据。怎么删的,这个问题思考过么?
还有,你的数据已经设置了过期时间,但是时间到了,内存占用率还是比较高,有思考过原因么?
回答:Redis 采用的是定期删除+惰性删除策略。
定时删除,用一个定时器来负责监视 Key,过期则自动删除。虽然内存及时释放,但是十分消耗 CPU 资源。
在大并发请求下,CPU 要将时间应用在处理请求,而不是删除 Key,因此没有采用这一策略。
定期删除,Redis 默认每个 100ms 检查,是否有过期的 Key,有过期 Key 则删除。
需要说明的是,Redis 不是每个 100ms 将所有的 Key 检查一次,而是随机抽取进行检查(如果每隔 100ms,全部 Key 进行检查,Redis 岂不是卡死)。
因此,如果只采用定期删除策略,会导致很多 Key 到时间没有删除。于是,惰性删除派上用场。
也就是说在你获取某个 Key 的时候,Redis 会检查一下,这个 Key 如果设置了过期时间,那么是否过期了?如果过期了此时就会删除。
采用定期删除+惰性删除就没其他问题了么?
不是的,如果定期删除没删除 Key。然后你也没即时去请求 Key,也就是说惰性删除也没生效。这样,Redis的内存会越来越高。那么就应该采用内存淘汰机制。
5-1. 内存淘汰机制
在 redis.conf 中有一行配置:
1 |
# maxmemory-policy volatile-lru |
该配置就是配内存淘汰策略的(什么,你没配过?好好反省一下自己):
- noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。应该没人用吧。
- allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 Key。推荐使用,目前项目在用这种。
- allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个 Key。应该也没人用吧,你不删最少使用 Key,去随机删。
- volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 Key。这种情况一般是把 Redis 既当缓存,又做持久化存储的时候才用。不推荐。
- volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 Key。依然不推荐。
- volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 Key 优先移除。不推荐。
PS:如果没有设置 expire 的 Key,不满足先决条件(prerequisites);那么 volatile-lru,volatile-random 和 volatile-ttl 策略的行为,和 noeviction(不删除) 基本上一致。
6. Redis 持久化机制
怎么保证 Redis 挂掉之后再重启数据可以进行恢复?很多时候我们需要持久化数据也就是将内存中的数据写入到硬盘里面。
大部分原因是为了之后重用数据(比如重启机器、机器故障之后恢复数据),或者是为了防止系统故障而将数据备份到一个远程位置。
Redis 不同于 Memcached 的很重要一点就是,Redis 支持持久化,而且支持两种不同的持久化操作。
Redis 的一种持久化方式叫快照(snapshotting,RDB),另一种方式是只追加文件(append-only file,AOF)。
这两种方法各有千秋,下面我会详细讲这两种持久化方法是什么,怎么用,如何选择适合自己的持久化方法。
6-1. 快照(snapshotting)持久化(RDB)
Redis 可以通过创建快照来获得存储在内存里面的数据在某个时间点上的副本。
Redis 创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis 主从结构,主要用来提高 Redis 性能),还可以将快照留在原地以便重启服务器的时候使用。
快照持久化是 Redis 默认采用的持久化方式,在 redis.conf 配置文件中默认有此下配置:
1 2 3 4 5 |
save 900 1 #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发BGSAVE命令创建快照。 save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发BGSAVE命令创建快照。 save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发BGSAVE命令创建快照。 |
6-2. AOF(append-only file)持久化
与快照持久化相比,AOF 持久化的实时性更好,因此已成为主流的持久化方案。
默认情况下 Redis 没有开启 AOF(append only file)方式的持久化,可以通过 appendonly 参数开启:
1 |
appendonly yes |
开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入硬盘中的 AOF 文件。
AOF 文件的保存位置和 RDB 文件的位置相同,都是通过 dir 参数设置的,默认的文件名是 appendonly.aof。
在 Redis 的配置文件中存在三种不同的 AOF 持久化方式,它们分别是:
1 2 3 4 |
appendfsync always <span class="">#每次有数据修改发生时都会写入AOF文件,这样会严重降低Redis的速度</span> appendfsync everysec <span class="">#每秒钟同步一次,显示地将多个写命令同步到硬盘</span> appendfsync <span class="">no</span> <span class="">#让操作系统决定何时进行同步</span> |
为了兼顾数据和写入性能,用户可以考虑 appendfsync everysec 选项 ,让 Redis 每秒同步一次 AOF 文件,Redis 性能几乎没受到任何影响。
而且这样即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。当硬盘忙于执行写入操作的时候,Redis 还会优雅的放慢自己的速度以便适应硬盘的最大写入速度。
6-3. Redis 4.0 对于持久化机制的优化
Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。
如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。
这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。
当然缺点也是有的,AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。
6-4. 补充内容:AOF 重写
AOF 重写可以产生一个新的 AOF 文件,这个新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。
AOF 重写是一个有歧义的名字,该功能是通过读取数据库中的键值对来实现的,程序无须对现有 AOF 文件进行任伺读入、分析或者写入操作。
在执行 BGREWRITEAOF 命令时,Redis 服务器会维护一个 AOF 重写缓冲区,该缓冲区会在子进程创建新 AOF 文件期间,记录服务器执行的所有写命令。
当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新旧两个 AOF 文件所保存的数据库状态一致。
最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。
7. Redis 事务
Redis 通过 MULTI、EXEC、WATCH 等命令来实现事务(transaction)功能。
事务提供了一种将多个命令请求打包,然后一次性、按顺序地执行多个命令的机制。
并且在事务执行期间,服务器不会中断事务而改去执行其他客户端的命令请求,它会将事务中的所有命令都执行完毕,然后才去处理其他客户端的命令请求。
在传统的关系式数据库中,常常用 ACID 性质来检验事务功能的可靠性和安全性。
在 Redis 中,事务总是具有原子性(Atomicity)、一致性(Consistency)和隔离性(Isolation),并且当 Redis 运行在某种特定的持久化模式下时,事务也具有持久性(Durability)。
8. Redis 和数据库双写一致性问题
一致性问题是分布式常见问题,还可以再分为最终一致性和强一致性。数据库和缓存双写,就必然会存在不一致的问题。
答这个问题,先明白一个前提。就是如果对数据有强一致性要求,不能放缓存。我们所做的一切,只能保证最终一致性。
另外,我们所做的方案从根本上来说,只能说降低不一致发生的概率,无法完全避免。因此,有强一致性要求的数据,不能放缓存。一般来说,就是如果你的系统不是严格要求缓存+数据库必须一致性的话,缓存可以稍微的跟数据库偶尔有不一致的情况。
最好不要做这个方案:读请求和写请求串行化,串到一个内存队列里去,这样就可以保证一定不会出现不一致的情况。串行化之后,就会导致系统的吞吐量会大幅度的降低,用比正常情况下多几倍的机器去支撑线上的一个请求。
回答:首先,采取正确更新策略,先更新数据库,再删缓存。其次,因为可能存在删除缓存失败的问题,提供一个补偿措施即可,例如利用消息队列。
方案:
9. 如何应对缓存穿透和缓存雪崩问题
这两个问题,说句实在话,一般中小型传统软件企业,很难碰到这个问题。如果有大并发的项目,流量有几百万左右。这两个问题一定要深刻考虑。
9-1. a 缓存穿透 Cache Peneration
即黑客故意去请求缓存中不存在的数据,导致所有的请求都怼到数据库上,从而数据库连接异常。
缓存穿透解决方案:
- 后台设置定时任务,主动地去更新缓存数据。这种方案容易理解,但是当Key比较分散的时候,操作起来还是比较复杂的。
- 利用互斥锁,缓存失效的时候,先去获得锁,得到锁了,再去请求数据库。没得到锁,则休眠一段时间重试。
- 采用异步更新策略,无论 Key 是否取到值,都直接返回。Value 值中维护一个缓存失效时间,缓存如果过期,异步起一个线程去读数据库,更新缓存。需要做缓存预热(项目启动前,先加载缓存)操作。可以对查询返回的空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。AutoLoadCache采用这种策略。
- 分级缓存。比如设置两层缓存保护层,1级缓存失效时间短,2级缓存失效时间长。有请求过来优先从1级缓存中去查找,如果在1级缓存中没有找到相应数据,则对该线程进行加锁,这个线程再从数据库中取到数据,更新至1级和2级缓存。其他线程则直接从2级线程中获取。
- 提供一个能迅速判断请求是否有效的拦截机制,比如,利用布隆过滤器,内部维护一系列合法有效的 Key, 将所有可能存在的数据哈希到一个足够大的 bitmap 中。迅速判断出,请求所携带的 Key 是否合法有效。如果不合法,则直接返回。
- 再谈缓存的穿透、数据一致性和最终一致性问题
9-2. b 缓存雪崩 Cache Avalanche
即缓存同一时间大面积的失效,这个时候又来了一波请求,结果请求都怼到数据库上,从而导致数据库连接异常。
缓存雪崩解决方案:
- 给缓存的失效时间,加上一个随机值,避免集体失效。
- 使用互斥锁:但是该方案吞吐量明显下降了。
- 分级缓存:和缓存击穿解决方案类似,做二级缓存。我们有两个缓存,缓存 A 和缓存 B。缓存 A 的失效时间为 20 分钟,缓存 B 不设失效时间。自己做缓存预热操作。然后细分以下几个小点:从缓存 A 读数据库,有则直接返回;A 没有数据,直接从 B 读数据,直接返回,并且异步启动一个更新线程,更新线程同时更新缓存 A 和缓存 B。
- 事前:尽量保证整个 Redis 集群的高可用性,发现机器宕机尽快补上。选择合适的内存淘汰策略。
事中:本地 Ehcache 缓存 + Hystrix 限流&降级,避免 MySQL 崩掉。
事后:利用 Redis 持久化机制保存的数据尽快恢复缓存。
9-3. c 热点失效 Hotspot data set is invalid
查看文中:5.2 热点失效 Hotspot data set is invalid
10. 如何解决 Redis 的并发竞争 Key 问题
这个问题大致就是,同时有多个子系统去 Set 一个 Key, 但是最后执行的顺序和我们期望的顺序不同,这样也就导致了结果的不同。这个时候大家思考过要注意什么呢?
需要说明一下,我提前百度了一下,发现答案基本都是推荐用 Redis 事务机制。
我并不推荐使用 Redis 的事务机制。因为我们的生产环境,基本都是 Redis 集群环境,做了数据分片操作。
你一个事务中有涉及到多个 Key 操作的时候,这多个 Key 不一定都存储在同一个 redis-server 上。因此,Redis 的事务机制,十分鸡肋。
10-1. 方案:
这种情况下,准备一个分布式锁,大家去抢锁,抢到锁就做 set 操作即可,比较简单。
假设有一个 key1,系统 A 需要将 key1 设置为 valueA,系统 B 需要将 key1 设置为 valueB,系统 C 需要将 key1 设置为 valueC。
期望按照 key1 的 value 值按照 valueA > valueB > valueC 的顺序变化。这种时候我们在数据写入数据库的时候,需要保存一个时间戳。
假设时间戳如下:
系统B key 1 {valueB 3:05}
系统C key 1 {valueC 3:10}
那么,假设这会系统 B 先抢到锁,将 key1 设置为{valueB 3:05}。接下来系统 A 抢到锁,发现自己的 valueA 的时间戳早于缓存中的时间戳,那就不做 set 操作了,以此类推。
其他方法,比如利用队列,将 set 方法变成串行访问也可以。总之,灵活变通。
10-2. 分布式锁
ZooKeeper 和 Redis 都可以实现分布式锁。(如果不存在 Redis 的并发竞争 Key 问题,不要使用分布式锁,这样会影响性能)
基于 ZooKeeper 临时有序节点可以实现的分布式锁。大致思想为:每个客户端对某个方法加锁时,在 ZooKeeper 上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。
判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。
同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。完成业务流程后,删除对应的子节点释放锁。
在实践中,当然是以可靠性为主。所以首推 ZooKeeper。
11. 总结
本文对 Redis 的常见问题做了一个总结。大部分是自己在工作中遇到,以及之前面试别人的时候,爱问的一些问题。
另外,不推荐大家临时抱佛脚,真正碰到一些有经验的工程师,其实几下就能把你问懵。最后,希望大家有所收获吧。
[resouce]