之前写的博客太杂,最近想把Redis的知识点再系统的过一遍,带着自己的理解使用简短的话把一些问题总结一下,尤其是开发中和面试中的高频问题,基础知识点参考–>Redis入门、Spring Cache,这篇不再赘述。
基础
简介;与Memcached的区别;为什么作为mysql缓存?
什么是redis?
Redis 是一种基于内存的数据库,读写速度极快,常用于缓存、消息队列、分布式锁等场景。Redis 提供多种数据类型支持不同业务需求。其操作具有原子性,由单线程执行,无并发竞争问题。
此外,Redis 还支持事务、持久化、Lua 脚本、多种集群模式(主从复制、哨兵、切片集群)、发布/订阅模式,以及内存淘汰和过期删除机制。
redis与与Memcached的区别
很多人选择 Redis 作为缓存,而不是 Memcached,即使 Memcached 也是基于内存的数据库。要解答这个问题,需要了解两者的区别和共同点。
共同点:1、都是基于内存的数据库,主要用作缓存;2、都有过期策略;3、性能非常高。
区别:
数据类型:Redis 支持丰富的数据类型(String、Hash、List、Set、ZSet),而 Memcached 只支持简单的 key-value 数据类型。
持久化:Redis 支持将内存数据持久化到磁盘,重启可恢复;Memcached 不支持持久化,数据存于内存中,重启或故障后数据丢失。
集群:Redis 原生支持集群模式;Memcached 没有原生集群模式,依赖于客户端实现分片写入。
功能:Redis 支持发布订阅模型、Lua脚本、事务等高级功能;Memcached 不支持这些功能。
为什么作为mysq缓存
Redis 作为 MySQL 缓存的优势主要在于「高性能」和「高并发」。
高性能:用户首次访问 MySQL 数据时会比较慢,因为需要从硬盘读取。将数据缓存在 Redis 中,这样后续访问可以直接从内存获取,速度极快。当 MySQL 数据发生变化时,需同步更新 Redis 缓存,这涉及双写一致性问题。
高并发:Redis 的 QPS 是 MySQL 的 10 倍以上。Redis 单机 QPS 能轻松突破 10万,而 MySQL 单机 QPS 很难达到 1万。因此,使用 Redis 能大幅提升请求承受能力,把部分数据缓存到 Redis 可以减少对 MySQL 的直接访问。
如何保证都是热点数据 ;如何监控缓存命中率
如何保证Redis中的数据都是热点数据 ?
如何提高缓存命中率?
1.一般redis会给key设置过期时间,可以设置在过期之前如果被访问了,就给key加上过期时间,类似看门狗(redission)机制的思想,具体加多少可以根据实际业务场景来决定,这样的话访问的次数越多,过期时间就会越长,留在redis里的自然就是热点数据(过期key是怎么删除的下面有介绍)
如果定期删除漏掉了很多过期key没有及时查询也没有走惰性删除,就会走内存淘汰机制了。
2.可以使用 allkeys-lru (挑选最近最少使用的数据淘汰)淘汰策略(下面内存淘汰有介绍),核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”,那留下来的都是经常访问的热点数据。不使用 LRU 算法的原因是,其无法解决缓存污染问题,大量一次性读取数据会长时间占用内存。
3.对于一些可以预测的热点数据,比如秒杀库存、热门产品等,这些访问量比较大的就可以预先加载到redis里。
4.定期监控redis的使用情况,比如说命中率或者内存使用情况,然后根据实际情况去调整和配置淘汰策略,确保数据有效管理和内存高效利用
如何监控缓存命中率?
在memncached中,运行state命令可以查看memcached服务的状态信息,其中cmd_get表示总的get次数,get_hits表示get的总命中次数,命中率 = get_hits/cmd_get
也可以通过一些开源的第三方工具对整个memCached集群进行监控,典型的包括:Zabbix**、**MemAdmin等。
同理,在redis中可以运行info命令查看redis服务的状态信息,其中keyspace_hits为总的命中次效,keyspace_misses为总的miss次数,命中率=keyspace_hits / (keyspace_hits+keyspace_misses)
开源工具Redis-star能以图表方式直观reids服务相关信息,同时,zabbix也提供了相关的插件对reids服务进行监控。
线程模型?为什么单线程还那么快?I/O多路复用(epoll)?
Redis的网络(线程)模型是怎样的?
Redis 6.0之前,使用的是单Reactor单线程模式。核心在于所有操作都在单个进程中完成,避免了进程间通信和多进程竞争的复杂性,因此实现相对简单。
单Reactor单线程模式存在两个主要缺点:
1.无法充分利用多核CPU性能:因为只有一个线程在执行,CPU的多核优势无法得到充分发挥。
2.处理延迟问题:当一个Handler对象正在处理业务时,其他连接的事件无法得到及时处理。如果某个任务耗时较长,会导致整体响应时间延迟。
因此,单Reactor单线程模式不适用于CPU密集型的场景,只适合业务处理非常快速的场景。Redis主要通过C语言实现,在6.0之前因为操作主要在内存中完成,处理速度很快,所以性能瓶颈不在CPU上。随着网络硬件性能的提升,网络I/O处理有时会成为瓶颈。
Redis 6.0之后,Redis引入了多线程模式。通过多线程处理网络I/O,可以提高并行度和网络处理性能。然而,Redis在命令执行上仍然采用单线程模式,也就不会存在线程安全问题。这种设计在增强网络I/O处理性能的同时,保持了命令执行的一致性和简洁性,使得Redis依然能够高效地运行。
总结:Redis的网络模型在6.0版本之前是单Reactor单线程模式,在6.0版本后改为多线程处理网络I/O,但命令执行仍用单线程。
Redis 6.0之前实际上并不是单线程的。只是主要工作(网络I/O和命令执行)一直使用单线程模型,其实启动时,Redis 会启动后台线程(BIO)以处理耗时任务:
在Redis 2.6版本,启动了2个后台线程,分别处理文件关闭和AOF刷盘任务,例如,命令 unlink key、flushdb async、flushall async 会交给后台线程处理,避免主线程卡顿。因此,删除大key时应使用 unlink 而非 del 命令,以免阻塞主线程。
在Redis 4.0版本后,新增一个用于异步释放内存的线程,即lazyfree线程。
Redis为「关闭文件、AOF刷盘、释放内存」等任务创建单独线程,因为这些操作耗时较长,如果在主线程处理会导致阻塞,影响后续请求的处理。后台线程相当于一个消费者,不停轮询任务队列,任务完成就执行相应操作。
Redis是单线程的,但是为什么还那么快?
Redis 实际上并不是单线程的。只是主要工作(网络I/O和命令执行)一直使用单线程模型,但在Redis 6.0版本后,引入了多I/O线程来处理网络请求,是因为随着硬件性能提升,Redis的瓶颈有时出现在网络I/O处理上。
其实启动时,Redis 会启动后台线程(BIO)以处理耗时任务:
在Redis 2.6版本,启动了2个后台线程,分别处理文件关闭和AOF刷盘任务,例如,命令 unlink key、flushdb async、flushall async 会交给后台线程处理,避免主线程卡顿。因此,删除大key时应使用 unlink 而非 del 命令,以免阻塞主线程。
在Redis 4.0版本后,新增一个用于异步释放内存的线程,即lazyfree线程。
Redis为「关闭文件、AOF刷盘、释放内存」等任务创建单独线程,因为这些操作耗时较长,如果在主线程处理会导致阻塞,影响后续请求的处理。后台线程相当于一个消费者,不停轮询任务队列,任务完成就执行相应操作。
Redis 之所以采用单线程模型,并且能够保持极高的性能,主要有以下几个原因:
- 内存操作和高效数据结构:Redis 的大部分操作都在内存中完成,速度极快,;采用了高效的数据结构,进一步提升了操作效率;瓶颈通常在于机器的内存或网络带宽,而非 CPU。因此,使用单线程并不会遇到性能瓶颈。
- 避免多线程开销:单线程模型避免了多线程之间的资源竞争,省去了线程切换带来的时间开销。任务执行也是单线程的,也不存在线程安全问题。这样也消除了死锁等多线程编程中的潜在问题,提高了系统的稳定性和性能。
- I/O 多路复用机制:Redis 采用 I/O 多路复用机制来处理大量的客户端 Socket 请求。I/O 多路复用是一种允许一个线程处理多个 IO 流的技术,常见的实现方式包括select和epoll。例如:bgsave 和 bgrewriteaof 都是在后台执行操作,不影响主线程的正常使用,不会产生阻塞
解释一下I/O多路复用模型?
I/O多路复用是指利用单个线程来同时监听多个Socket ,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。目前的I/O多路复用都是采用的epoll模式实现,它会在通知用户进程Socket就绪的同时,把已就绪的Socket写入用户空间,不需要挨个遍历Socket来判断是否就绪,提升了性能。
其中Redis的网络模型就是使用I/O多路复用结合事件的处理器来应对多个Socket请求,比如,提供了连接应答处理器、命令回复处理器,命令请求处理器;
在Redis6.0之后,为了提升更好的性能,在命令回复处理器使用了多线程来处理回复事件,在命令请求处理器中,将命令的转换使用了多线程,增加命令转换速度,在命令执行的时候,依然是单线程。
最基础的 TCP Socket 编程使用阻塞 I/O 模型,只能一对一通信。为支持多客户端,传统方法是使用多进程/线程模型,每个客户端连接分配一个进程/线程。当请求过多时,调度和内存开销巨大,成为瓶颈。
I/O 多路复用解决上述问题,有三种 API:select、poll、epoll。
select 和 poll:
1.本质没有区别,都是使用线性结构存储进程关注的 Socket 集合。只不过poll 不再使用 BitsMap 存储关注的文件描述符,而是改用动态数组并以链表形式组织。这打破了 select 的文件描述符个数限制,但仍受系统文件描述符限制。
2.需要将关注的 Socket 集合通过系统调用从用户态拷贝到内核态,由内核检测事件。
3.当事件生成,内核要遍历集合集才能找到对应 Socket 并设置状态为可读/可写,再拷贝回用户态,用户态继续遍历处理。
3.缺陷在于客户端多时,遍历和拷贝开销大,难以应对 C10K 问题(C 是 Client 单词首字母缩写,C10K 就是单机同时处理 1 万个请求的问题。)
epoll:
使用红黑树管理待检测 Socket,增删改 O(logn),这能减少内核和用户空间的大量数据拷贝和内存分配。
事件驱动机制,内核通过链表记录就绪事件,仅传递有事件发生的 Socket 集合,提高效率。
支持边缘触发和水平触发,边缘触发效率更高,而 select/poll 仅支持水平触发。
什么是边缘触发、水平触发?
Epoll 支持两种事件触发模式:边缘触发(ET,Edge-triggered)和水平触发(LT,Level-triggered)。虽然这两个词听上去有些抽象,但其实很容易理解。
边缘触发(ET):当监控的 Socket 有可读事件时,服务器只会从 epoll_wait 中苏醒一次,无论你是否调用 read 函数读取数据。因此,你需要保证每次都尽可能读完所有数据。
水平触发(LT):当监控的 Socket 有可读事件时,服务器会不断从 epoll_wait 中苏醒,直到内核缓冲区的数据被读完,以确保你知道还有数据需要处理。
类比:
边缘触发:想象你的快递到了一个快递箱,快递箱只能发一次短信通知,即使你没有去取,它也不会再提醒你。即只要数据未读完,就会持续通知。
水平触发:快递箱会不停地发短信,直到你把快递取走为止。即只在事件第一次发生时通知一次,之后不会再重复通知。
用水平触发时,当内核通知文件描述符可读写后,你可以检测它的状态,并决定是否继续操作,没有必要一次性读写完数据。而用边缘触发时,事件只会通知一次,所以你必须尽可能多地读写数据,否则可能错过处理机会。
边缘触发一般和非阻塞 I/O搭配使用。因为如果你使用边缘触发模式,当 I/O 事件发生时系统只会通知你一次,我们不知道具体能读写多少数据。因此,在收到通知后要尽可能多地读写数据,以免错失机会。这通常需要对文件描述符进行循环读写操作。如果文件描述符是阻塞的,当没有数据可读写时,进程会卡在读写操作上,程序就无法继续运行。因此,边缘触发模式通常与非阻塞 I/O 结合使用,这样程序可以一直尝试读写操作,直到系统调用(例如 read 和 write)因为没有数据而返回 EAGAIN 或 EWOULDBLOCK 错误。
一般来说,边缘触发效率更高,因为它减少了 epoll_wait 的调用次数,从而减少了系统调用的开销。传统的 select/poll 只有水平触发模式,而 epoll 默认是水平触发,但可以设置为边缘触发。
除了做缓存还能拿来做什么?
Redis 是非关系型的基于内存存储的键值对数据库,它的主要作用就是用来缓存数据,来提高系统的性能;同时 Redis中,还提供了多种数据类型(比如:String / Hash / List / Set / ZSet / Geo / BitMap等),正是基于这些丰富的数据类型和它的单线程等特性,也赋予了 Redis 除缓存数据外的多种能力。
1.分布式锁。我们可以使用 Redis 自带的 SETNX 命令来实现分布式锁,当然,生产场景中我们还是更推荐使用 Redisson 框架来帮我们实现分布式锁的功能。
2.分布式ID。利用 Redis 原子性的自增命令,可以考虑作为应用程序的分布式ID来使用。如果获取分布式ID 比较频繁,我们可以每次请求设置一个合适的步长,比如 2000(一次取2000个连续的ID),然后缓存在本地。
3. 分布式Session。我们可以使用Redis 中提供的 String 数据类型或者 Hash 数据类型来保存 Session 数据,从而实现分布式环境下 Session 会话的同步。
4. 分布式限流。比如我们可以基于 Redis 的 SETNX命令可以实现计数器算法限流;基于 Redis 的 ZSet 数据结构可以实现滑动窗口算法限流;基于 Redis 的 List 数据结构可以实现令牌桶算法限流;当然,我们还可以基于Redis +Lua 脚本的方式实现分布式限流。限流算法简介。
5. 消息队列。如果是中小型项目,业务量不是很大,对于数据丢失不敏感,且项目中已经使用了中间件 Redis,同时又需要消息中间件功能的情况下,可以考虑使用 Redis 的Stream 来实现消息队列的功能。但是如果并发量很高,资源又足够支持的情况下,还是强烈建议使用更专业的消息中间件,比如 RocketMQ、Kafka 等。
6. 抽奖。Redis 中提供的 Set 数据类型,可以很轻松的实现模拟抽奖的功能。存储某活动中中奖的用户名 ,Set 类型因为有去重功能,可以保证同一个用户不会中奖两次。
7. 地理位置应用。Redis 中提供的GEO 数据类型,可以很轻松的实现附近的人等查询功能。
8. 海量数据统计。Redis 中提供的 BitMap 的数据类型,可以用来很方便的做海量数据的统计。还记得腾讯三面中被问到的那道经典的面试题吗:限制1个G的内存,40亿的QQ号如何实现快速去重!这道面试题就可以使用 BitMap 来解決!
9.排行榜。Redis 中提供的 ZSet 数据类型,可以很轻松的实现排行榜等查询功能,比如经典的 TOP 10问题。
10. 关注模型。Redis 中提供的 Set 数据类型,可以很轻松的实现共同关注、我关注的人也关注他、我可能认识的人等关注模型的查询功能。(Set 类型支持交集运算,set的查找也是O(1),所以效率高,总的时间复杂度会更接近于O(min(M, N)), 而不是O(MN))。
Redis是AP还是CP的?
CAP 理论是针对分布式环境而言的,其中C代表一致性(Consistency),A 代表可用性(Availability),P 代表分区容错性(Partition tolerance);CAP 理论简单来说就是C、A、P 三者不能同时满足,最多只能满足其中2个。一般来说,分布式环境下,分区容错性P是一定要满足的,所以,我们通常所使用的第三方组件都是在CP 和AP之间进行选择。
这个问题网上流行的说法是,单机的Redis是CP的,集群Redis是AP的,这种说法其实不对,因为CAP本身就是针对分布式环境的,单机环境压根也没有AP、CP一说
所以,我们所讨论的「Redis 是AP的还是CP的」这个问题的前提是针对分布式环境而言的!在分布式环境下,Redis 是AP 的,它的一致性模型采用的是最终一致性。其实,这也不难理解,说 Redis 是AP 的原因是 Redis 提供的哨兵和 Cluster 的集群模式能够很好的保证系统的可用性和容错性;而 Redis 无法保证强一致性的原因主要有以下两点:
1.Redis 集群的主从节点之间采用的异步复制的策略。既然是异步复制,在数据传输过程中,就有可能存在网络延迟等原因而导致主从节点之间的数据不一致。
2.数据一致性可能会受到主节点故障的影响。假如主节点突然宕机,瞬时产生的数据是无法同步到其他从节点的,即使集群故障转移后,主节点网络恢复,它也只是会以从节点的身份重新加入该集群,在与新的主节点做全量数据同步时,会首先清空全部数据,从而丟失掉了那瞬时产生的数据。
如果是单机的reids不会面临CAP理论中的权衡问题,因为它本质上是一个单一的、直接可控的数据存储实例。在这种情境下,Redis可以同时提供较强的一致性和高可用性,接近于满足CAP的所有条件,但这主要是因为单点系统不涉及分布式系统中必须考虑的网络分区容错(P)问题。但是也不能完全保证数据不丢失,除非将AOF策略设置为always,而不是everysec(每秒),但是这个时候也相当于丧失了一部分可用性,性能会变差。
Key冲突
注意区分这个问题面试官想问什么,这个问题可能在问三种问题:
一、key冲突 所谓 Redis 的并发竞争 Key 的问题也就是多个系统同时对一个 key进行操作,但是最后执行的顺序和我们期望的顺序不同,这样也就导致了結果的不同!(多个客户端操作同一个Key)
比如,有ABC三个系统A系统要把变量a赋值为1;B系统要把变量a赋值为2;C系统要把变量a赋值为3;
本来我们期望顺序执行A>B>C后,a的值为3,但是如果并发太大,导致A晚了一步,让BC先执行了,最后a的值就成1了;
解决方案:
- 在设计规范上,将key的粒度调整的更为细致。可以把不同业务key区分开来,比如业务模块+系统名称+关键字(比如core-pay-prderid),从业务上减少key冲突
- 分布式锁+时间戳。(zookeeper和redis都可以实现分布式锁)。
在操作a变量时候,为了以期望顺序执行,额外维护一个时间戳,打个比方
A在执行的时候时间是19:13:30 B在执行的时候时间是19:13:33 C在执行的时候时间是19:13:35
假如B先执行,B执行完后a变量对应的时间戳值应为19:13:33
这时候A再来,发现当前时间是19:13:30,而a对应的时间戳为19:13:33,早于当前时间,说明在自己执行之前已经有其他请求操作过了,这时候就根据实际业务来决定怎么继续,废弃A操作或者轮询等; - 基于消息队列。这种实现方式比较简单,也是目前主流的解决方案,把所有操作写入同一个队列,利用消总队列把所有操作申行化。(详细思路请移步MQ相关博客 如何保证消息顺序消费和避免消息重复消费)
二、Redis 数据库级别的 key 冲突
插入两个 key,哈希值相同但 key 不同的情况,Redis 会使用链地址法解决哈希冲突。即查找数据时,Redis 根据查询的键(key)计算其哈希值,并定位到对应的槽位(bucket)。如果该槽位存储了多个键值对(因为发生了哈希冲突),Redis 会顺序遍历这个链表,对每个key 进行逐一比较,直到找到匹配的 key,并返回对应的值。
而如果你插入两个相同的 key,即完全一样 , Redis 不会报错。Redis 使用的是 哈希表(Hash Table) 来存储键值对,当新 key 与已有 key 相同时而是采取 覆盖更新 的策略。也就是说,后插入的 key 会覆盖前一个相同 key 的值,而不会产生冲突:
127.0.0.1:6379> SET mykey "hello"
OK
127.0.0.1:6379> GET mykey
"hello"
127.0.0.1:6379> SET mykey "world"
OK
127.0.0.1:6379> GET mykey
"world"
解决方案:
EXISTS
:你可以使用 EXISTS mykey 来判断 key 是否已经存在,避免覆盖的情况。NX
和XX
选项:
使用 SET key value NX:只在 key 不存在时插入。
使用 SET key value XX:只在 key 存在时更新,否则不做任何操作。
三、Redis的Hash数据结构怎么解决Key冲突
Redis的 Hash 是由压缩列表或哈希表实现的:如果哈希类型元素个数小于 512 个(可配置),所有值小于 64 字节(可配置)的话,会使用压缩列表作为底层数据结构;否则使用哈希表。在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。
- 当Hash由压缩列表/ listpack实现时,因为压缩列表 / listpack 是一种连续的内存结构,将键值对 (key, value) 成对地按顺序存储。在这种结构中,没有 哈希映射 的概念。因此,它无法通过哈希值直接定位键,而是采用顺序遍历的方式查找某个 key。所以当插入相同的key时会直接覆盖。
- 当Hash由哈希表实现时,如果遇到了哈希冲突,即key不相同,但是哈希值相同,哈希表会采用链式哈希,通过next指针将冲突的节点链接起来。如果key相同,则也是会直接覆盖。
压缩列表、listpack 、哈希表数据结构详解见——Redis 8种底层数据结构简介
Redis的最佳实践
本笔记中主要收集了 Redis 在使用过程中的一些约定成俗的规范。
0.关于数据库和缓存的双写一致性问题。博客后面有介绍。(本博客有详细介绍)
1.Redis 中key的定义要具有可读性和可管理性,建议以业务名为前缀,使用冒号隔开。此外,在保证语义的前提下,要尽可能的控制 key 的长度,以节省系统的内存。
2.应尽可能的避免 Big key。 Big key 除了会占用大量的内存,还会导致客户端的执行变慢。建议:
String 数据类型的大小控制在 10KB 以下;List/Hash/Set/ZSet 数据类型的元素数量控制在1万以下;(本博客有详细介绍)
3.对于热 key 问题,可以选择引入二级缓存或者热key 拆分的方案来解决;当然,也可以通过引入像京东零售的 hotkey 缓存组件的方式来解决。(本博客有详细介绍)
4.存储在 Redis 中的数据,应尽可能的设置合适的过期时间。设置过期时间,不仅可以提高 Redis 的缓存利用率,还可以作为兜底方案来解决一些线上的问题,比如分布式锁的问题。
5.应尽可能的设置 Redis 实例的 maxmemory 参数,以避免 Redis 内存使用过多而导致系统崩溃。一般来说,Redis 单个节点的内存大小不宜超过10G。
6.根据业务类型,设置合适的内存淘汰策略。Redis 中默认的内存淘汰策略是 noeviction(不淘汰,内存不足时写入直接报错(还可以查、删));一般场景下,建议采用 allkeys-Iru 的淘汰策略,但是如果当前缓存数据中存在热点