Redis知识点总结(数据类型、过期策略、持久化、主从、集群、分布式锁、缓存)
Redis 数据结构 持久化 高可用(Sentinel和Cluster) 缓存 分布式锁
目录
- Redis和Java中的Map的区别?
- Redis为什么快?
- 1. 基于内存实现
- 2.高效的数据结构
- 3.单线程模型
- 4.IO多路复用模型
- 5. 全局哈希表
- 数据类型及应用场景
- 字符串(string)
- Hash
- (**)List
- Set
- ZSet(Sorted Set)
- Stream
- 数据类型的内部编码
- 数据类型(type)和内部编码(encoding)对应关系
- 简单动态字符串(Simple Dynamic String,SDS)
- dict(字典)
- linkedlist(链表)
- ziplist
- quicklist(快表)
- intset
- skiplist
- Redis的事务
- Redis的过期策略
- 过期策略
- 读到过期数据
- Redis的内存淘汰策略
- 写入Redis的数据不见了
- 如何保证高可用、高并发?
- (**)Redis的持久化
- RDB(Redis Database) —— 内存快照
- RDB的命令
- RDB的配置
- AOF
- AOF的命令
- AOF的配置
- RDB和AOF
- RDB和AOF的优缺点
- RDB和AOF混用
- 主从复制
- 全量复制
- 无磁盘复制
- 部分复制
- Redis Sentinel —— 高可用方案
- 哨兵的核心知识
- 故障转移过程
- Redis Sentinel主备切换的数据丢失问题
- 数据丢失的解决方案
- (**)Redis Cluster —— 高性能、高可用方案
- Redis集群的分区方式 —— 虚拟槽
- 虚拟槽分区的好处
- 节点间通信 —— Gossip协议
- 请求路由
- 请求重定向 —— Moved
- ASK重定向
- 故障转移 —— 保证高可用
- Redis的应用场景
- (**)缓存
- 为什么使用缓存?
- 提高缓存利用率
- (**)缓存一致性问题
- 并发问题的解决方案 —— 先更新数据库,再删除缓存
- 执行异常问题的解决方案
- 读写分离 + 主从复制延迟 —— 缓存延迟删除
- 二级缓存
- 缓存雪崩
- 缓存穿透
- 热点Key失效/缓存击穿(Hotspot Invalid)
- Redis实现分布式锁
- 1. 基于setnx实现
- 2. 基于set扩展实现
- 3. Redisson
- 加锁过程
- 解锁过程
- 参考
Redis和Java中的Map的区别?
Redis本质上是一个Map,不过在此基础上提供了更多特性,包括单线程、IO多路复用、多种数据类型、持久化、高可用方案(Sentinel和Cluster)等。
Redis为什么快?
1. 基于内存实现
内存的的读写速度约是固态硬盘的3~50倍,是机械硬盘的1000倍以上。
- DDR4内存读写:24G/S
- .M.2固态硬盘:1.5~7GB/S
- SATA-III接口的固态硬盘:500MB
- 台式机机械硬盘 7200转:130MB~190MB
- 笔记本 5400转:60~90MB
2.高效的数据结构
3.单线程模型
- Redis键值对的读写指令的执行是单线程的
- Redis 6.x后网络IO使用多线程
单线程的好处:
- 避免多线程竞争的各种锁,线程上下文切换的问题
4.IO多路复用模型
Redis线程不会阻塞在特定的Socket上,可以并发的处理请求。
5. 全局哈希表
Redis本身是一个哈希表,能够实现O(1)
的复杂度完成读写操作。
数据类型及应用场景
Redis常用的数据类型:
- string
- list
- set
- hash
- zset(sorted set)
Redis 除了这 5 种数据类型之外,还有 Bitmaps、HyperLogLogs、Streams 等。
字符串(string)
最简单的数据类型。
应用场景:
- 缓存
Hash
类似于Java中的Map,用于保存结构化的数据,如POJO。
(**)List
有序列表(有序指访问顺序和插入顺序相同)。
应用场景:
- 粉丝列表、文章列表评论列表
- 提供高效的分页查询功能
- 简单的P2P方式的消息队列
Set
无序集合,自动去重。提供交集、并集、差集等操作。
应用场景:
- 共同好友:对两个人的好友列表做交集
ZSet(Sorted Set)
带score的集合,去重,提供score的排序。
应用场景:
- 排行榜
数据类型的内部编码
数据类型(type)和内部编码(encoding)对应关系
类型 | 编码方式 | 数据结构 | 触发条件 |
string | int | 整数编码 | 小于8字节表示的整数 |
raw | 动态字符串编码 | 小于39字节的字符串 | |
emstr | 优化内存分配的字符串编码 | 大于39字节的字符串 | |
hash | ziplist | 压缩列表编码 | 同时满足: 1. value最大空间(字节)<= |
hashtable | 散列表编码 | ziplist的条件只要不满足,就转换成hashtable | |
list | ziplist | 压缩列表编码 | 同时满足: 1. value最大空间(字节)<= |
linkedlist | 双向链表编码 | ziplist的条件只要不满足,就转换成linkedlist | |
quicklist(3.2引入代替ziplist和linkedlist) | 3.2版本引入的列表编码,代替linkedlist和ziplist |
| |
set | intset | 整数集合编码 | 同时满足: 1. 元素必须为整数 2.集合长度 <= |
hashtable | 散列表编码 | intset的条件只要不满足,就转换成hashtable | |
zset | ziplist | 压缩列表编码 | 同时满足: 1. value最大空间(字节) <= |
skiplist | 跳跃表编码 | 只要ziplist的条件不满足,就转换成skiplist |
简单动态字符串(Simple Dynamic String,SDS)
Redis使用简单动态字符串的数据结构表示string:
- len:字符串已用的长度
- free:字符串的剩余空间
- buf:字节数组
dict(字典)
- 一个字典(dictht)对应一组字典节点(dictEntry)
- 每个元素经过哈希函数分配到对应的字典节点
- 为了解决哈希冲突,每个字典节点都有next指针(
dictEntry *next
),以链表的形式串连相同索引值(哈希值)的键值
扩容和缩容
linkedlist(链表)
Redis的linkedlist是双向链表。
Redis的链表特点:
- 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置点的的复杂度都是O(1)
- 无环:表头节点的prev指针和表尾节点的next指针都为null,对链表的访问以null为终点
- 带表头节点和表尾节点:表头节点和表尾节点访问的复杂度为O(1)
- 带链表长度计数字段:获取链表长度的复杂度为O(1)
- 多态:链表节点使用void*指针来保存节点值,链表结构的dup、free、match三个属性控制节点的数据类型,所以链表可以保存各种不同类型的值。
相较ziplist
- linkedlist的节点并不是连续的内存空间,linkedlist的节点的prev和next指针占用额外的空间(64位系统中,每个占8字节,共16字节)
ziplist
ziplist主要是为了节约内存,采用了线性连续的内存结构。ziplist用于list(Redis 3.2之前)set和hash的内部编码,主要用于小数据量的场景。
ziplist的数据结构:
- zlbytes:记录整个压缩列表所占字节长度。int32,长度为4字节
- zltail:记录距离尾节点的偏移量,方便弹出操作。int32,长度为4字节
- zllen:记录压缩链表节点数量,当长度超过
2^16 - 2
时需要遍历整个列表获取长度。int16,长度为2字节 - entry:具体的节点,长度根据存储的数据而定
- prev_entry_bytes_length:记录前一个节点所占空间,用于快速定位前一个节点,实现列表的反向迭代;占用1字节或5字节
- encoding:表示当前节点编码和长度,前两位表示编码类型:字符串/整数,其余位标识数据长度;占用1字节,2字节或5字节
- contents:保存节点的值,
- zlend:列表的尾节点,占用一个字节
ziplist的特点
- 一块连续的数组
- 可以模拟双向链表
- 新增、删除操作涉及内存重新分配或释放
ziplist为什么能够省内存
- ziplist中每个entry保存的内容(content)不是等长的,通过
encoding
字段记录数据实际保存的长度 - 遍历元素问题:在普通数组中每个元素等长,所以无需考虑这个问题;ziplist的entry中增加prev_entry_bytes_length字段解决遍历问题。
quicklist(快表)
quicklist是以ziplist为节点的双向链表。使用ziplist存储数据,压缩节点数据(相较于linkedlist节点),是一种时间和空间折中的方案。
intset
intset编码是集合(set)类型的一种编码,存储有序、不重复的整数集合。
- encoding:表示整数类型,分为:int-16,int-32,int-64
- length:表示集合元素个数
- contents:整数数组,按从小到大顺序保存
intset保存的整数类型根据长度划分,当保存的整数长度超出当前类型时,会触发升级。升级操作会重新申请内存,把原有数据按转换类型后拷贝到新数组。
复杂度
- 插入数据:
O(n)
- 查询数据和删除数据:
O(log(n))
skiplist
跳跃表在双向链表之上的改进,每个节点可以有多个指向其它节点的指针,从而提高遍历的效率。
Redis的事务
Redis 事务并不能保证原子性,在日常开发不怎么使用。
Redis 事务在发送每个指令到事务缓存队列时都要经过一次网络读写,当一个事务内部的指令较多时,需要的网络 IO 时间也会线性增长。所以通常 Redis 的客户端在执行事务时都会结合 pipeline 一起使用,这样可以将多次 IO 操作压缩为单次 IO 操作。
Redis的过期策略
过期策略
Redis的过期策略:定期删除+惰性删除
- 定期删除:Redis默认每隔100ms就抽取一部分一定数量的键,如果键过期了,执行del命令,之后同步给从节点。
- 惰性删除:在获取key的时候,Redis会检查key是否过期,如果已经过期,直接删除
读到过期数据
主从同步存在延迟,主节点存在大量过期数据,定期删除策略采样的速度根本上键的过期速度,那么从节点无法收到del命令,这时从节点上就可以读取到过期数据。在Redis 3.2,从节点读取数据之前会检查键的过期时间,如果已经过期不会返回数据。
Redis的内存淘汰策略
Redis的内存使用量达到最大内存限制(由maxmemory
参数控制),Redis会执行内存淘汰策略。
涉及的配置有:
-
maxmemory <bytes>
:设置最大内存,Redis的应用超出最大内存限制,会使用内存淘汰策略清除key maxmemory-policy <policy>
:Redis的内存淘汰策略。
- noeviction:默认值,当内存不足以容纳新写入的数据时,新写入操作会报错。
- allkeys-lru(least recently used):当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)
- volatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
- volatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的key淘汰
- allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key
- volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
- volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
- allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
写入Redis的数据不见了
当Redis的内存使用量达到最大内存限制时,会执行内存淘汰策略,清除键,所以可能导致不常使用的键被移除
如何保证高可用、高并发?
- Redis单机:可以达到几万的QPS
- 一主多从 + Sentinel:支持读高并发、高性能方案,多数项目都足够使用了。一主写入数据,几万的QPS;多从读取数据,10万以上的QPS。Sentinel实现故障的自动转移。
- Redis集群:读多写多的高性能、高可用方案。可以实现每秒几十万的读写QPS
(**)Redis的持久化
Redis支持两种持久化技术:
- RDB(snapshotting,RDB)
- AOF(append-only file, AOF)
RDB(Redis Database) —— 内存快照
RDB是一种快照技术,保存某个时间点Redis的内存快照。RDB快照还会用于主从复制的功能。
RDB的命令
-
save
:同步保存RDB文件,保存RDB文件期间,主线程阻塞,无法响应请求 -
bgsave
:异步保存RDB文件,主线程通过fork创建子线程保存RDB文件,子线程保存RDB文件之后,通知主线程 - 查看RDB快照生成的命令:
info persistence
info stats
-
lastsave
:最近一次保存RDB的时间
RDB的配置
save 3600 1 #每一小时,至少有1个key发生变化,触发bgsave
save 300 100 #每5分钟,至少有100个key发生变化,触发bgsave
save 60 10000 #每1分钟,至少有10000个key发生变化,触发bgsave
dir /data #指定数据文件保存的目录
dbfilename dump.rdb #指定rdb文件名称
rdbcompression {yes|no} #是否开启rdb文件压缩
AOF
以日志的形式(append-only)记录每一次的写命令。可以使用AOF重写压缩AOF文件。
AOF的命令
AOF在开启
appenonly yes
配置后,就会自动生成AOF文件
-
bgrewriteaof
:手动触发重写AOF文件,压缩AOF文件的大小
AOF的配置
appendonly yes #开启AOF,默认为no
appendfilename appendonly.aof #aof文件名称,默认appendonly.aof
dir /data #指定数据文件保存的目录
appendfsync {always|everysec|no} #aof缓冲区向磁盘同步的策略配置
#自动触发AOF重写配置
auto-aof-rewrite-min-size 67108864 #触发AOF文件重写的最小文件大小,默认64MB
auto-aof-rewrite-percentage 100 #触发AOF文件的比值。(AOF文件当前的大小-AOF文件上一次重写后的大小)/AOF文件上一次重写后的大小 >= (auto-aof-rewrite-percentage)%
- appendfsync:
alway
:
- 命令写入aof_buf后,调用fsync操作同步到AOF文件,fsync完成后线程返回。
- 每次写入命令都要同步AOF文件,严重影响Redis的性能
- (**)
everysec
:
- 命令写入aof_buf后,调用write操作,write完成后线程返回。fsync同步文件操作由专门的线程每秒调用一次,不会阻塞主线程
- 默认配置,兼顾性能和数据安全性
- 个人理解:在该策略下,aof_buf记录最近一秒内产生的命令;而系统缓冲区,记录了前一秒的命令数据,用于同步到硬盘。会不会存在系统缓冲区同步过慢,存储了较长时间的命令数据?
-
no
:命令写入aof_buf后,调用write操作,不对AOF文件做fsync同步,同步硬盘操作由操作系统负责,通常同步周期最长30秒。
RDB和AOF
RDB和AOF的优缺点
- RDB:
- 优点:
- RDB文件是紧凑压缩的二进制文件,为某个时间点的数据快照。适用于备份和全量复制。
- 比AOF的数据恢复速度更快。
- 缺点:
- 没法做到实时持久化/秒级持久化。
bgsave
命令会创建fork
操作,成本高 - 存在版本兼容问题
- AOF:
- 优点:
- 可以保证内存的数据实时同步到硬盘
- 缺点:
- 数据恢复速度慢
- AOF文件过大(可以通过AOF文件重写,压缩AOF文件)
- AOF回放可能出现错误,导致结果不一致
RDB和AOF混用
重启Redis后,如果使用RDB会丢失大量的数据,使用AOF会影响数据加载的性能。Redis 4.0提供的RDB和AOF混用机制,先加载RDB文件,再执行增量的AOF文件,提高加载效率。
- 优点:
- 快速加载,避免丢失过多数据
- 缺点:
- AOF文件中,RDB文件部分的可读性差
aof-use-rdb-preamble yes #AOF重写的时候,直接把RDB的内容写到AOF文件的开头 默认值yes
主从复制
主从复制的作用:
- 负载均衡:主库负责写;从库负责读,横向扩展读并发能力
- 故障恢复:主库故障宕机,从库升级为主库继续提供服务
全量复制
当主从库第一次建立连接,会进行全量复制。
全量复制分为3个阶段:
- 建立连接:
1.1 从库执行slaveof
后,发送psync ? -1
命令,
1.2 主库确认后,连接建立 - 同步主库数据:
2.1 主库执行bgsave
命令生成RDB文件,发送给从库
2.2 主库同步从库期间,主库为每个从库生成一个客户端缓冲区用于缓存主从同步期间新增的写命令;
2.3 从库接收完RDB文件,先写入磁盘。然后,清空自身数据,加载RDB文件 - 保证主从一致性:主库发送客户端缓冲区给从库,slave同步这些数据,保证数据一致性
全量复制配置
repl-timeout 60 #全量复制超时时间,如果超时全量复制失败,默认60s
client-output-buffer-limit slave 256MB 64MB 60 #如果60秒内缓冲区消耗持续大于64M或直接超过256MB时,主节点直接关闭复制客户端连接,造成全量复制失败
无磁盘复制
全量复制过程中,主库会将RDB文件写入磁盘,然后发送给从库。无磁盘复制主库直接将RDB文件写入Socket发送给从库。
主库的配置:
repl-diskless-sync yes #无磁盘配置开关,默认false
repl-diskless-sync-delay 5 # 等待 5s 后再开始复制,因为要等更多 slave 重新连接过来
部分复制
部门复制用于从库因网络中断、命令丢失的情况下,从节点要求主节点补发丢失的命令数据,如果主节点的客户端缓冲区内存在这部分数据则直接发送给从节点。
主库内部维护着复制积压缓冲区(repl_backlog_buffer),是一个循环数组,如果数据满了,就会从头开始覆盖内容。
主库使用master_repl_offset
记录自己写到的偏移量,从库使用slave_repl_offset
。
中断恢复后,主库只需要将master_repl_offset
和slave_repl_offset
之间的数据发送给从库即可。
配置
repl-backlog-size 1mb #复制积压缓冲区的容量,默认1MB,复制积压缓冲区越大,断线重连后通过部分复制恢复同步的可能越大
区分:
- 复制缓冲区(replication buffer):用于主从库在连接正确情况下的主从同步。
- 复制积压缓冲区(replication back buffer):用于主从库断连后,主库存储写命令,故障恢复后,主库同步写命令给从库
Redis Sentinel —— 高可用方案
Redis 2.8提供了Redis Sentinel。Redis Sentinel通过提供Sentinel节点监控主从数据节点的状态,实现自动的故障转移过程,从而保证Redis架构的高可用。
Sentinel的作用:
- 监控:Sentinel节点监控Redis数据节点是否可用
- 故障转移:主节点发生故障,自动晋升从节点为主节点
- 通知:故障转移后,将结果通知应用方
- 提供配置:Redis Sentinel架构中,客户端通过连接Sentinel节点集合,获取数据节点信息。
哨兵的核心知识
- 哨兵至少需要3个实例,才能保证健壮性。
哨兵数设置示例
故障转移时,领导者选举过程需要majority=max(quorum,num(sentinels)/2 + 1)
个Sentinel节点在线,才可以开始选举。
quorum | majority(在线节点数) |
2 | 2 |
3 | 2 |
4 | 3 |
5 | 3 |
可以看到当quorum=2,那么需要Sentinel节点都存在,才可以执行故障转移,无法保证高可用。当quorum=3时,如果一个Sentinel节点宕机了,其余两个Sentinel节点就可以实现故障转移过程。
3节点哨兵集群部署示例
+----+
| M1 |
| S1 |
+----+
|
+----+ | +----+
| R2 |----+----| R3 |
| S2 | | S3 |
+----+ +----+
- M:主节点
- R2和R3:从节点
- S1、S2和S3:Sentinel节点
数据节点部署在不同的物理服务器上,保证一个数据节点宕机不会影响另外一个节点,且保证读性能的横向扩展;Sentinel节点同样分布在不同的物理服务器,也是为了保证一台物理服务器的宕机不会影响其它Sentinel节点
故障转移过程
- 主节点出现故障,多个Sentinel节点通过定时监控发现主节点出现故障
- 多个Sentinel节点对主节点的故障达成一致(主观下线和客观下线),选举出sentinel-3节点作为领导者,负责故障转移
- Sentinel-3执行故障转移:
- 对slave1执行
slaveof no one
- 对slave2执行
slaveof new master
- 如果原master恢复连接,同样执行
slaveof new master
- 通知客户端更新连接
Redis Sentinel主备切换的数据丢失问题
主备切换过程存在两种数据丢失的情况:
- 异步复制导致的数据丢失:在主从同步过程中,主节点有一部分数据未复制到从节点,就宕机了,导致部分数据丢失
- 脑裂导致的数据丢失:主节点突然脱离了正常的网络,但仍然运行着,客户端仍然像故障的master写入数据。此时哨兵认为master已经宕机,故障转移选举出新的master。等到master网络恢复,作为slave挂到新的master,自身数据被清空,复制新master的数据。这样就导致客户端之前写入的数据丢失了。
数据丢失的解决方案
增加如下配置,解决数据丢失问题:
min-slaves-to-write 1 #至少有一个节点和主节点保持同步,主节点才能正常提供服务
min-slaves-max-lag 10 #从节点和主节点的同步延迟不能超过10秒,主节点才能正常提供服务
这两个配置表示至少有1个从节点,和主节点数据同步的延迟不能超过10秒。一旦所有的从节点,数据复制的延迟都超过了10秒,那么主节点将不再接收任何请求。
通过以上配置,可以保证最多丢失10秒的数据。
(**)Redis Cluster —— 高性能、高可用方案
Redis集群是Redis 3.0推出的Redis分布式方案,实现高性能和高可用
Redis集群的分区方式 —— 虚拟槽
Redis集群使用虚拟槽的分区方式,共有16384(2^14)
个的虚拟槽(槽的范围[0,16383]
),每个节点分配一定数量的虚拟槽。键数据经过哈希计算被分配到特定的虚拟槽。
虚拟槽分区的好处
- 虚拟槽的数量很多,可以更加均匀的分布数据
节点间通信 —— Gossip协议
Redis集群中,节点之间通过Gossip协议通信,来保证每个节点获取集群当前的状态。
Gossip消息分为:ping消息、pong消息、meet消息和fail消息。
- meet消息:用于通知新加入集群的节点。消息发送者通知接收者加入到当前集群。
- ping消息:用于集群内节点交换节点信息。集群内每个节点每秒向多个其它节点发送ping消息。注意:Redis集群中每个节点都会维护一份集群的信息
- pong消息:接收ping、meet消息后,用于响应消息。pong消息会封装节点的当前状态信息。另外,节点可以通过向集群广播pong消息,通知整个集群更新自身的状态。
- fail消息:当一个节点判断另一个节点下线,会广播一个fail消息,通知其它节点更新对应节点为下线状态。
Gossip的节点通信是使用当前节点的服务端口号 + 10000
执行通信的。如:当前节点的端口号是6379,那么它用于Gossip通信的端口号是16379。
请求路由
请求重定向 —— Moved
MOVED重定向:
- Redis Cluster节点接收任何键,计算键对应的槽,
crc16(key)%16383
- 根据槽找出对应的节点
- 如果节点是自身,处理键命令;否则,返回MOVED重定向错误
ASK重定向
Redis集群支持在线槽迁移。当槽对应的数据从源节点迁往目标节点的过程中,客户端发起请求:
- 如果当前节点存在目标数据,直接返回
- 否则,返回ASK重定向。
故障转移 —— 保证高可用
当集群中主节点不可用时,会选出他其中的一个从节点作为主节点,继续提供服务。
如果某个主节点没有从节点,那么当它发生故障时,集群将完全处于不可用状态。
故障转移过程分为两个阶段:
- 故障发现:
- 主观下线:当一个节点发现自身超过一定时间无法与另一个节点通信,会标记该节点为下线节点。通过ping/pong消息传播下线节点状态。
- 客观下线:当集群内超过一半的持有槽的节点标记一个节点为主观下线,就会触发客观下线,在集群中广播fail消息,通知集群内所有节点故障节点为客观下线
- 故障恢复:当故障节点客观下线后,故障节点的从节点就会触发故障恢复流程。
- 资格检查
- 准备选举时间
- 发起选举
- 选举投票
- 替换主节点
故障恢复是发生在故障节点的从节点上,而不是集群中持有槽的节点。
Redis的应用场景
- session共享:分布式场景中,用于存储access_token和refresh_token,维持会话
- 短信登录:通过Redis保存验证码信息
- 缓存:对于读多写少的数据,通过缓存,加速数据的访问,减少数据库的压力
- 分布式锁 : 通过Redis来做分布式锁是一种比较常见的方式。通常情况下,我们都是基于 Redisson 来实现分布式锁。
- 限流 :一般是通过 Redis + Lua 脚本的方式来实现限流。
- 消息队列 :Redis 自带的 list 数据结构可以作为一个简单的队列使用。Redis 5.0 中增加的 Stream 类型的数据结构更加适合用来做消息队列。它比较类似于 Kafka,有主题和消费组的概念,支持消息持久化以及 ACK 机制。
- 复杂业务场景 :通过 Redis 以及 Redis 扩展(比如 Redisson)提供的数据结构,我们可以很方便地完成很多复杂的业务场景比如通过 bitmap 统计活跃用户、通过 sorted set 维护排行榜。
(**)缓存
为什么使用缓存?
1. 高并发、提高吞吐量
MySQL单机能支撑2000QPS,而Redis单机可以达到几万到几十万的QPS,是MySQL的几十倍
2. 提升响应效率
一些复杂耗时的业务,且查询结果不会频繁变化,读请求多的,即读多写少的复杂查询,通过缓存大幅降低响应时间,降低数据库的访问次数。如:一个业务通过MySQL执行查询,需要耗时600ms,通过Redis缓存,仅耗时2ms,那么查询的性能提升了300倍。
提高缓存利用率
系统中一些不频繁访问的数据,占用内存,会造成资源的浪费。需要为一些热数据(频繁访问的数据)设置缓存。
提高缓存利用率的策略:读请求先读缓存,如果缓存不存在,则从数据库中读取,并重建缓存,为缓存设置超时时间。这样不频繁访问的数据随着时间的推移,由于超时而被淘汰。
(**)缓存一致性问题
需要保证缓存中的数据和数据库中的一致。
为了保证数据的持久性,还是应该选择先更新数据库,再操作缓存。
处理一致性的方案:
- 同时更新数据库和更新缓存:先更新缓存,再更新数据库;或先更新数据库,再更新缓存
- 更新数据库和删除缓存:先删除缓存,再更新数据库;或先更新数据库,再删除缓存
以上的执行方式,仍会导致缓存不一致的问题:
- 执行异常引发的不一致:无论哪种方案,在第一步完成后,第二部如果执行失败,都会导致数据库和缓存的不一致问题。
- 并发引发的不一致:多个线程同时执行的情况下,也可能存在缓存不一致的问题
并发问题的解决方案 —— 先更新数据库,再删除缓存
推荐使用的方案:先更新数据库,再删除缓存。有两个好处:
- 发生并发问题的可能性很低:可能出现缓存与数据库不一致的情况如下:
- 缓存中 X 不存在(数据库 X = 1)
- 线程 A 读取数据库,得到旧值(X = 1)
- 线程 B 更新数据库(X = 2)
- 线程 B 删除缓存
- 线程 A 将旧值写入缓存(X = 1)
要出现并发问题必须满足3个条件:
- 缓存刚好已失效
- 读请求 + 写请求并发
- 更新数据库 + 删除缓存的时间(步骤 3-4),要比读数据库 + 写缓存时间短(步骤 2 和 5)
- 用删除缓存代替更新缓存,提高资源利用率:更新缓存的方式缓存中的内容不一定会被例可访问,造成资源浪费。使用删除缓存的方式,在数据被访问时,生成缓存能更有效的利用资源
执行异常问题的解决方案
为了保证第二步的成功执行,可以将更新缓存的过程写入消息队列,从而保证消息最终能够被消费。
- 消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失(重启项目也不担心)
读写分离 + 主从复制延迟 —— 缓存延迟删除
读写分离 + 主从复制延迟导致的缓存不一致问题:
- 线程 A 更新主库 X = 2(原值 X = 1)
- 线程 A 删除缓存
- 线程 B 查询缓存,没有命中,查询「从库」得到旧值(从库 X = 1)
- 从库「同步」完成(主从库 X = 2)
- 线程 B 将「旧值」写入缓存(X = 1)
缓存延迟删除
消费者延时从消息队列拉取消息,删除缓存,保证主从同步完毕。
二级缓存
缓存雪崩
在同一时间缓存服务全部不可用,导致所有的请求压力来到数据库端,导致数据库扛不住,宕机。
完整的解决方案:
- 事前:Redis高可用方案,Sentinel或Cluster,避免Redis服务全部不可用
- 事中:本地ehcache缓存+hystrix限流&降级,避免MySQL因为请求过多挂掉
- 事后:Redis持久化,一旦重启,自动从磁盘恢复数据。
- ehcache缓存(即二级缓存中的本地缓存):服务端接收请求,先查ehcache本地缓存;如果本地缓存不存在,再查找Redis缓存;Redis缓存不存在,再从数据库中读取,写入ehcache和Redis。
- hystrix限流和降级:通过限流组件数据库在可承受的请求压力的范围内工作;多余的请求通过降级处理,返回默认值。
缓存穿透
黑客发出大量的恶意请求,数据库中不存在,也无法命中缓存,直接访问数据库,导致数据库挂掉。
有两种解决方案:
1. 存储null值
每次系统在数据库中没有查询到数据,就写一个空值到缓存,并设置过期时间,这样在缓存失效之前,请求都可以命中缓存。该方案存在一个问题,如果黑客每次请求的key都不相同,请求仍然需要访问数据库,需要使用布隆过滤器解决。
2. 布隆过滤器
布隆过滤器:映射了所有可能的数据哈希值。
每个请求执行过程:
- 请求的key不存在于布隆过滤器,数据一定不存在于数据库,立即返回。
- 请求的key存在于布隆过滤器,继续查询缓存获取数据
热点Key失效/缓存击穿(Hotspot Invalid)
热点key访问非常频繁,当这个key失效的瞬间,大量的请求击穿了缓存,访问数据库,重建缓存。
解决方案:
- 热点key设置为永不过期:对于不频繁更新的数据,可以使用该方案
- 定时任务重建缓存:定时任务在缓存过期前,重建缓存。
- 分布式锁:通过分布式锁保证仅少量请求访问数据库重新构建缓存。
Redis实现分布式锁
代码实现:Redisson模块下,com.ldh.redisson.DistributedLockTest
1. 基于setnx实现
涉及的命令:
- setnx
- get
- getset
-
setnx(lock,expireTime)
获取锁,如果setnx返回1,成功获取成功 - 否则,
get(lock)
获取currentValue
,如果currentValue<expireTime
不成立,获取锁失败 - 否则,
oldValue = getset(lock,expireTime)
,如果oldValue == currentValue
,成功获取锁;否则,获取失败
说明:
- 以上方式是在
setnx+expire
命令的优化,避免因为执行了setnx
命令之后,服务宕机,导致expire
命令无法执行,分布式锁无法释放的问题。 - 设置超时时间是为了服务如果挂了,能够及时释放锁
问题:
- 锁存在超时时间:如果当前服务的业务执行超时,其它服务可以进入临界区;
- 时间同步:锁的超时时间是由服务自身确定的,不同的服务需要时间同步
- 持有者并没有唯一标识,当前服务获取的锁,可能被其它服务删除
2. 基于set扩展实现
基于set命令的EX和NX参数实现功能:
SET key value [EX seconds] [PX milliseconds] [NX|XX]
- EX second :设置键的过期时间为
second
秒。 - PX millisecond :设置键的过期时间为
millisecond
毫秒。 - NX :只在键不存在时,才对键进行设置操作。
- XX :只在键已经存在时,才对键进行设置操作。
解决的问题:
- 超时时间由Redis自身维护,无需多服务间的时间同步
- 锁的value指定了对应的所有者,不会存在误删的可能
问题:
- 锁过期问题:锁设定了过期时间,如果业务执行时间超过了锁的时间,其它服务会进入临界区,无法保证互斥同步的效果。
3. Redisson
Redisson是通过Lua脚本执行多条命令的,从而保证命令执行的原子性。
加锁过程
Redisson中锁的格式为哈希,格式如下:
myLock:{
"8743c9c0-0795-4907-87fd-6c719a6b4586:1":1
}
//RedissonLock
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
long threadId = Thread.currentThread().getId();
//尝试获取锁
Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
// ttl为空,表示成功获取锁,返回
if (ttl == null) {
return;
}
//获取锁失败,订阅`解锁频道`,如:`redisson_lock__channel:{test-lock}`,线程会阻塞,直到收到锁的释放事件。这样避免了通过循环获取锁,占用CPU资源
RFuture<RedissonLockEntry> future = subscribe(threadId);
if (interruptibly) {
commandExecutor.syncSubscriptionInterrupted(future);
} else {
commandExecutor.syncSubscription(future);
}
//继续尝试获取锁
try {
//一直循环,直到成功
while (true) {
//尝试获取锁
ttl = tryAcquire(-1, leaseTime, unit, threadId);
// ttl为空,表示成功获取锁,返回
if (ttl == null) {
break;
}
// 未成功获取锁
if (ttl >= 0) {
try {
future.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
if (interruptibly) {
throw e;
}
future.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
}
} else {
if (interruptibly) {
future.getNow().getLatch().acquire();
} else {
future.getNow().getLatch().acquireUninterruptibly();
}
}
}
} finally {
//取消订阅`解锁频道`
unsubscribe(future, threadId);
}
}
/**
*加锁的核心逻辑:
* 1. 锁不存在,创建锁,设置clientId和设置超时时间,返回空值
* 2. 锁重入,增加重入次数,刷新超时时间,返回空值
* 3. 锁已经被占用,返回锁的剩余时间TTL
*/
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}
- KEYS[1]:指键的名称,通过
getRawName()
指定的值。如:test-lock
- ARGV[1]:指占有锁的客户端,通过
getLockName(threadId)
指定的值。如:dcc7861b-8446-4fcb-9649-6b2cb95026c3:1 - ARGV[2]:指键的超时时间,通过
unit.toMillis(leaseTime)
指定值。如:默认值是30000
,即30秒
看门狗(watch dog)自动延迟机制 —— 解决锁失效问题
Redisson对锁设置一个超时时间,默认30秒。启用一个后台线程,每隔10秒检查锁的持有者如果没有发生变化,就重新将锁的超时时间重置。
可重入锁
Redisson加锁时,如果锁已经存在,且客户端Id为自身,就对锁的重入次数加1。
命令执行的原子性
Redisson使用Lua脚本封装多条Redis命令,从而保证命令执行的原子性。
解锁过程
//RedissonLock
/**
* 解锁的核心逻辑
* 1. 如果锁没有被当前客户端占用,直接返回;
* 2. 否则,将锁的重入次数减1
* 3. 如果锁的可重入次数大于0,增加锁的超时时间,返回0;
* 4. 否则,即锁的重入次数等于0,删除锁,并发布通知到`解锁频道`,返回1
*/
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end; " +
"return nil;",
Arrays.asList(getRawName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
- KEYS[1]:指键的名称,通过
getRawName()
指定的值。如:test-lock
- KEYS[2]:值解约频道的名称,通过
getChannelName()
指定的值。如:redisson_lock__channel:{test-lock}
- ARGV[1]:解越消息的值,通过
LockPubSub.UNLOCK_MESSAGE
指定的值。如:这里是0表示解约 - ARGV[2]:键的超时时间,通过
internalLockLeaseTime
。如:默认值30000,即30秒 - ARGV[3]:指占有锁的客户端,通过
getLockName(threadId)
指定的值。如:dcc7861b-8446-4fcb-9649-6b2cb95026c3:1