当我在学习 redis
的时候,我到底在学习个啥?
众所周知,对于 Javaer
而言,redis
是做 web
后端开发无法绕过的一个必学的知识点。它是一个用来做应用缓存数据管理的软件。其本质上就是一个管理内存工具,也就是说我们只是在学习一个工具的使用方法。因此与其研究一个工具的使用,那么换个思路:如果让我们自己设计一款内存管理应用、我们该如何设计?
在什么时候需要引入缓存?
在后端开发过程中,基本上所有的数据都会保存在数据库中,数据库软件会把对应的数据刷到磁盘上,当需要使用到相关数据时,需要请求后端路由,根据对应的请求参数从数据库中捞取对应的数据。数据库常常作为一个持久化的工具,大量的数据都是在磁盘上,在进行IO
操作时,往往效率不高。当有大量的请求来请求数据时,显而易见的是从数据库中捞取数据满足不了这种情况,因此我们需要一个中间层,用于存储一些符合app
应用的常用数据,当有相同的请求时,直接从内存返回数据,不用从数据库中捞取数据。
如何设计一个缓存系统?
在考虑如何设计一个缓存系统之前,先考虑如下的问题:
- 什么样的数据需要存入缓存系统?这些数据需要什么样的数据结构?
- 如何高效的操作这些数据结构,如果指令操作的时间大于从数据库捞取的时间,岂不多此一举?
- 缓存系统宕机了怎么办?如何恢复数据?
- 在高并发的情况下、当缓存系统崩溃了如何保证应用的可用性?
- 当持久层数据发生变化时,如何更新?是先更新持久层数据还是缓存数据?
什么样的数据需要存储在内存中?
不管是在实际的工作中、还是学习的过程中、我们会接触到很多的业务场景,比如查看用户信息、商品信息、设备信息等场景,通常会根据对应的标识返回的详细信息,如要查看雷达设备的详细信息会根据mac
码返回如下字段的信息:
js
{
"deviceId":"设备id",
"version":"设备版本",
"protocol":"设备协议",
"produceDate":"生产日期",
"mac":"mac码"
}
上述信息只要设备入库之后,基本不会发生变动,因此可以将这些信息放在缓存里面,提高查询效率。亦或查询个人用户信息时,需要将个人的详细信息返回给前端展示。除此之外:一些高并发的场景,如双十一、618等商品秒杀类项目、统计系统在线人数、游戏里的交易系统、微信等共同好友可见等。这些场景中尤其是涵盖秒杀相关的场景,对请求处理的速度要求极高,因此就不可能把对应的数据放在数据库中,通过修改数据库中的统计数据的方式来实现。因此就需要针对不同的场景设计出不同的数据结构来存放不同的缓存数据。
想明白了上面的内容,回过头来看看redis
的常见的数据结构:
- String类型:kv键值对形式,通过key找到对应的value,value可以为字符串、整数、二进制等类型,可以用与统计在线人数、商品库存等。
- Hash类型:存储多个键值对,键值对的集合,适合存储对象(如用户信息)。
- List链表:通过key获取其对应的双向链表、支持在头部或尾部插入或弹出元素,如实现消息队列、或者根据通过试卷的标识、返回对应的全部习题。
- Set:存储无序不可重复的元素、可对集合中的元素执行交集、并集、补集计算,如计算两个人的共同好友。
- Zset:有序集合,元素按分数(score)排序,且唯一。如何游戏分数排行榜。
如何操作这些数据结构?
不同的数据结构必然有不同的操作指令、对String类型而言无外乎是是对键值的修改或者查看、由于其value可以为数字类型,肯定会存在把值取出来计算之后在存回去的场景,对于简单的增加删除可以直接设置相关指令简化操作。

对于List而言,是一个双向链表、要么从头遍历、要么从尾部开始遍历、亦或者取出指定范围的元素【stratIndex,endIndex】,

对于hash
而言、单个键值对的修改或者多个键值对的修改。对于 set
而言存储的是无序不可重复的元素、那么必然有向集合里面增加、删除操作、多个集合之间的交、并、补操作。
与其去研究单一指令的操作、倒不如研究为什么 redis
能够快速响应多个指令、以及如何批处理多个指令。
redis为什么这么快?
这是因为所有的操作都是基于内存完成的、且redis
的线程模型是单线程、没有线程上下文切换带来的资源浪费,除此之外,redis
采用了IO
多路复用和Reactor
模式来处理指令,单线程接受客户端发来的指令,将其分发给事件处理器处理。
redis是如何批处理的?
批处理最开始接触到这个概念的时候还是大学操作系统这门课,在讲计算机的操作系统的发展历史中,从单任务处理发展到多任务处理时提出来的。批处理就字面意义而言就是将多个单一指令一起发送到处理端(在现有的知识体系里面、是否想到了数据库事务?)、在这里既是redis
服务端。为了应对这个情况redis
设计了redis
事务这一概念。
redis
事务:redis
事务需要如下命令来执行:
- Multi: 开启事务,后续的指令入队,不执行
- exec:执行多个指令
- watch:在multi之前,监听key的值,如果值发生变化,则exec执行失败,返回nil
- unwatch:取消监听
- discard:放弃执行
当需要执行多条指令是,通过multi
指令告知redis
需要开启事务,接下来就是需要将执行的一条条指令输入,最后发送exec
命令,依次执行输入的命令,但是redis事务不像mysql
事务一样,当执行过程中发生错误时,exec
会返回nil
,已执行的命令不会回滚。
redis数据持久化?
系统在运行过程中可能出现各种问题,就如同使用windows
笔记本经常出现蓝屏这一状况。redis
也有可能出现异常,出现异常不可怕、可怕的是如何恢复工作现场,数据库软件MySQL
采用了通过限刷盘日志【redo日志、undo日志】的情况应对突然的崩溃,redis
也是、只不过redis
设计了两种持久化数据的方式
- rdb
- aof
RDB:将某一时刻的内存数据、以二进制的方式写入磁盘,通过接受命令的频率来触发刷盘时机。
- 900 1:900秒内至少进行了一次修改
- 300 10:300s内至少进行了10次修改
- 60 10000:60s内至少修改10000次
AOF:每执行一条写操作、就把该命令以追加的方式写入到一个文件里
- Always:每次写操作命令执行完后、同步将aof日志数据写回到硬盘
- Everysec:每次写操作指令完成之后、先将命令写入到aof内核缓冲区、然后隔一秒将缓冲区里的内容写回到硬盘。
- no:交给操作系统控制
自动重写:Redis 可以根据配置自动触发 AOF 重写。当 AOF 文件的大小超过上次重写后的一定比例时,自动进行重写操作。相关参数有:
auto-aof-rewrite-min-size
:触发重写的最小 AOF 文件大小。auto-aof-rewrite-percentage
:触发重写的增长百分比。
上面两种持久化方式解决了redis
服务崩溃后从哪里可以恢复工作现场的问题,那么如何保证在这台redis
服务器崩溃后、不影响系统的正常运行呢?那就是redis
集群!
Redis集群
Redis
集群:是一种通过将多个Redis
节点连接在一起以实现高可用性、数据分片和负载均衡的技术。它允许Redis
在不同节点上同时提供服务,提高整体性能和可靠性。
redis
集群有不同的搭建方式,分别为:
- 主从复制
- 哨兵模式
- cluster模式
redis
主从复制:指的是一个redis
服务器作为主节点、其余redis
节点作为redis
从服务器、从服务器的数据要和主节点保持一致。redis
主节点负责写入指令、redis
从服务器负责处理读指令。
redis
主从同步的过程:A主 B从
第一次同步
bash
# 服务器 B 执行这条命令
replicaof <服务器 A 的 IP 地址> <服务器 A 的 Redis 端口号>
建立连接、主服务器通过
bgsave
命令生成rdb
文件、传递给从服务器
主服务器在下面这三个时间间隙中将收到的写操作命令,写入到 replication buffer 缓冲区里:
- 主服务器生成 RDB 文件期间;
- 主服务器发送 RDB 文件给从服务器期间;
- 「从服务器」加载 RDB 文件期间;
从服务器收到 RDB 文件后,会先清空当前的数据,然后载入 RDB 文件。
主服务器将 replication buffer 缓冲区里所记录的写操作命令发送给从服务器,从服务器执行来自主服务器 replication buffer 缓冲区里发来的命令,这时主从服务器的数据就一致了【第一次同步结束】
命令传播
主从服务器在完成第一次同步后,双方之间就会维护一个 TCP
连接。后续主服务器可以通过这个连接继续将写操作命令传播给从服务器,然后从服务器执行该命令,使得与主服务器的数据库状态相同。
增量复制:如果在主从库在命令传播时出现了网络闪断,为了不再进行全量复制设计的。
- 从服务器在恢复网络后,会发送 psync 命令给主服务器,此时的 psync 命令里的 offset 参数不是 -1;
- 主服务器收到该命令后,然后用 CONTINUE 响应命令告诉从服务器接下来采用增量复制的方式同步数据;
- 然后主服务将主从服务器断线期间,所执行的写命令发送给从服务器,然后从服务器执行这些命令
找到差异化的数据:
repl_backlog_buffer ,是一个「环形」缓冲区,用于主从服务器断连后,从中找到差异的数据;
- replication offset ,标记上面那个缓冲区的同步进度,主从服务器都有各自的偏移量,主服务器使用 master_repl_offset 来记录自己「写 」到的位置,从服务器使用 slave_repl_offset 来记录自己「读」到的位置。
- 何时写入:
在主服务器进行命令传播时,不仅会将写命令发送给从服务器,还会将写命令写入到 repl_backlog_buffer 缓冲区里,因此 这个缓冲区里会保存着最近传播的写命令。
- 如果判断出从服务器要读取的数据还在 repl_backlog_buffer 缓冲区里,那么主服务器将采用增量同步的方式;
- 相反,如果判断出从服务器要读取的数据已经不存在 repl_backlog_buffer 缓冲区里,那么主服务器将采用全量同步的方式。
哨兵模式 :是对redis
主从模式的优化,前者当主服务器发生故障时,需要手动设置新的主服务器并进行相关配置。哨兵模式可以自动进行故障转移。通过哨兵监测服务器的状态,判断服务器是否正常。
首先判断下线的方式有两种:
- 主观下线:如果主节点或者从节点没有在规定的时间内响应哨兵的 PING 命令,哨兵就会将它们标记为「主观下线」
- 客观下线:「主节点」其实并没有故障,可能只是因为主节点的系统压力比较大或者网络发送了拥塞,导致主节点没有在规定时间内响应哨兵的 PING 命令
如何判断是否下线:
用多个节点部署成哨兵集群 (最少需要三台机器来部署哨兵集群 ),通过多个哨兵节点一起判断,就可以避免单个哨兵因为自身网络状况不好,而误判主节点下线的情况
-
当这个哨兵的赞同票数达到哨兵配置文件中的
quorum
配置项设定的值后,这时主节点就会被该哨兵标记为「客观下线」,quorum
是指触发故障转移所需的最小哨兵节点数 -
quorum
一般配置为 哨兵数 / 2 【下取整】 + 1,哨兵一般为奇数个。3 /2 = 1,1+1 = 2 -
哪个哨兵节点判断主节点为「客观下线」,这个哨兵节点就是候选者,所谓的候选者就是想当
Leader
的哨兵【候选者条件】- 第一,拿到半数以上的赞成票;
- 第二,拿到的票数同时还需要大于等于哨兵配置文件中的
quorum
值。
Redis
脑裂问题:指的是在redis
集群中由于某种原因误判主节点下线发生触发了故障转移导致集群中存在多个主节点。当旧的主节点没有发生故障从新加入集群后、会被当作从节点加入新的主节点,在进行数据同步的时候会丢失原有数据。旧的主节点没有响应哨兵心跳,但是仍在接受新的写入命令。【从服务器收到 RDB 文件后,会先清空当前的数据,然后载入 RDB 文件】。
为了解决这一情况,redis
提供了如下配置项目:在主节点的配置文件中:
conf
min-slaves-to-write 1 # 至少1个从节点在线
min-slaves-max-lag 10 # 从节点复制延迟不超过10秒
当主节点无法满足条件时,会拒绝写入操作,防止数据不一致。
Cluster模式 :多个redis
节点都为主节点、彼此写入一定的数据、全部主节点的数据加起来就是全部的数据,类似于Kafka
中分区的概念、把全部分区的数据加起来就是一个topic
的所有数据。
Redis Cluster
将数据分为16384个槽位,每个节点负责管理一部分槽位。当客户端向 Redis Cluster
发送请求时,Cluster
会根据键的哈希值将请求路由到相应的节点。具体来说,Redis Cluster
使用 CRC16
算法计算键的哈希值,然后对16384取模,得到槽位编号。
到这前四个问题在参照redis
的知识点上已经回答完毕。接下来便是第五个问题:如何在应用里面更新缓存?
在系统里面加入缓存就不得不考虑如下问题:
- 缓存的命中率
- 数据的一致性问题
缓存的命中率不高,那么缓存就没有意义,同样的缓存的数据不正确那么也没有任何意义。
此时整个系统中的数据存在如下地方:
- 缓存中的数据
- 数据库中的数据
正常情况下:这两部分的数据应该保持一致,不管缓存中的数据是经过多少计算生成的,二者始终应当保持一致。
那么就有如下的缓存方案:
定时任务 + 全量缓存【不设置过期时间】:在内存够大的情况下,把数据库中的数据全部加入缓存中、这样效率最高。但是有如下缺点:
- 缓存命中率低、占据大量的空间
- 数据不一致:数据库数据被修改后需要定时任务才能重新加入到缓冲中。影响业务。
如何提高缓存的命中率:
- 设置缓存过期时间。
- 缓存热数据即经常访问的数据。
这样一来,随着程序的运行,留下的缓存数据都是会被使用到的。
如何解决数据一致性问题:先更新缓存数据 or 数据库中的数据?如果操作都成功那么没有问题。但是由于不是原子操作都存在第一步成功和第二步失败的情况。
- 先更新缓存【成功】、在更新数据库【失败】。缓存失效后会导致缓存的是以前的旧数据。【1】
- 先更新数据库【成功】、在更新缓存【失败】。此时只有等到当前缓存的数据失效后、缓存中的数据才会是最新的数据。
除此之外,在并发情况下:
-
假定先更新缓存 在操作数据库成功
- 情况1:线程a更新缓存--线程b更新缓存--线程b更新数据库--线程a更新数据库【不一致】
-
假定先更新数据库再更新缓存成功
- 情况1: 线程a操作数据库--线程b操作数据库--线程b操作缓存--线程a操作缓存【不一致】
无论怎么操作都可以发现在更新缓存+更新数据库这两个操作面前都存在问题。需要增加分布式锁解决并发问题。那么如果不更新缓存,直接删除缓存呢?
删除缓存 +更新数据库
-
先删除缓存,后更新数据库【优化:延迟双删】
- 先删除缓存,后更新数据库,第二步操作失败,数据库没有更新成功,那下次读缓存发现不存在,则从数据库中读取,并重建缓存,此时数据库和缓存依旧保持一致
- 读写并发:线程a删除缓存--线程b读取缓存--线程b读取数据--现场b更新缓存--线程a更新数据库【成功】【缓存和数据库中的数据不一致】
-
先更新数据库 后删除缓存【优先选择】
- 先更新数据库,后删除缓存,第二步操作失败,数据库是最新值,缓存中是旧值,发生不一致【2】
- 并发情况下:缓存不存在--线程a读取缓存--线程a读取数据库--线程b更新数据库--线程b删除缓存--线程a更新缓存【不一致】【这种情况发生的理论性较低、需要首先满足缓存不存在、其次读写并发,最后【线程b更新数据库+删除缓存的时间】比【线程a读取数据库+更新缓存时间短】】
无论是更新缓存【1】还是删除缓存【2】、只要第二步发生了失败那么就会存在数据不一致的情况,而解决删除失败的方式在程序中最常用的就是重试机制。重试机制可通过MQ队列来实现。
方案:
- 先更新数据库 再删除缓存 、为了保证两步都成功执行、需要添加 消息队列【异步重试】
- 先删除缓存,后更新数据库,为了保证数据一致性,可以采用延迟双删[删除缓存--更新数据库--删除缓存]:第二次删除延迟的时间要大于其他线程回种缓存的时间
参考资料:
- redis实战,redis设计与实现
- 缓存和数据库一致性问题,看这篇就够了