目录
Redis简介
数据结构和对象
数据库
RDB
AOF
事件
客户端
复制
哨兵机制
集群
发布与订阅
事务
缓存问题
内存淘汰机制
Redis并发竞争key
缓存与数据库一致性问题
Redis实现消息队列
参考资料
- 《Redis设计与实现》
- JavaG
Redis 简介
简单来说redis就是一个数据库,不过与传统数据库不同的是redis的数据是存在内存中的,所以读写速度非常快,因此redis被广泛应用于缓存方向。另外,redis也经常用来做分布式锁。redis提供了多种数据类型来支持不同的业务场景。除此之外,redis支持事务、持久化、LUA脚本、LRU驱动事件、多种集群方案。
数据结构和对象
redis数据库里面的每个键值对都是由对象组成的,其中,
· 数据库键总是一个字符串对象
· 而数据库键的值则可以是字符串对象、列表对象、哈希对象、集合对象、有序集合对象。
SDS简单动态字符串
SDS定义
保留空字符“\0”作为字符串的结尾,兼容C语言,但是空字符的1个字节不计入SDS的len属性中。
与C串的区别
(1)通过常数复杂度回去字符串长度,len属性
(2)字符串拼接、修改等操作时,杜绝缓存区溢出,自动修改大小。
(3)减少修改时内存重新分配次数
SDS通过未使用空间(free属性记录)解除了字符串长度和底层数组长度之间的关联。SDS实现了空间预分配和惰性空间释放两种优化策略。
· 空间预分配:优化SDS字符串增长操作。对象与修改len后,若len小于1MB,则free = len;若len大于等于1MB,则free = 1MB。故将连续增长N次字符串所需的内存重分配次数从必定N次降低为最多N次。
· 惰性空间释放:优化SDS字符串缩短操作。利用free属性将不适用的数据大小记录下来,等将来使用。
(4)二进制安全
所有的SDS API都会以处理二进制的方式来处理SDS存放的buf数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设,数据在写入时是什么样的,它被读取时就是什么样。
链表
定义
特性
(1)双端
(2)无环
(3)带有表头和表尾指针
(4)带链表长度计数器
(5)多态,通过dup、free、match设置类型特定的函数
字典
定义
Redis的字典使用哈希表作为底层实现。
(1)哈希表节点
next指针将多个哈希值相同的键值对连接在一起,以此来解决键冲突问题(collision)。
(2)哈希表
(3)字典
ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。
哈希算法
(1)计算hash值,采用MurmurHash2算法计算。
(2)计算在哈希表中的位置index = hash & sizemask
解决键冲突
(1)使用链地址法解决冲突
(2)因为dictEntry节点组成的链表没有表尾指针,故将新加节点加到链表的表头位置。
rehash重新散列
哈希表保存的键值对随着操作会逐渐增多或者减少,为了让哈希表的负载因子(load factor)维持在一个合理的范围之内,需要重新散列。
步骤
负载因子
(1)load_factor = ht[0].used / ht[0].size
(2)还要结合服务器是否在执行BGSAVE或者BGREWRITEAOF指令,在load_factor >= 1(否)或者load_factor >= 5(是)时进行rehash。
渐进式rehash
(1)rehash动作分多次、渐进式地完成.
(2)渐进式rehash期间,字典的增删查改在两个哈希表上进行。
skiplist跳跃表
是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
定义
(1)zskiplistNode
· 层用来加速访问其他节点,一般层的数量越多,访问其他节点的速度越快
· 层的前进指针:用于从表头向表尾访问节点
· 跨度:用来计算排位的,查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在条鱼表中的排位。
· 后退指针:用于从表尾向表头方向访问节点,每次只能后退至前一个节点。
· 分值:跳跃表中的所以节点按照分值从小到大排序
· 成员对象:指向一个字符串对象SDS,且对象必须唯一
(2)zskiplist
typedef struct zskiplist {
// 表头节点和表尾节点
structz skiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
应用
(1)有序集合键
(2)集群节点中用作内部数据结构
intset整数集合
定义
· encoding属性决定contents数组真正的类型可以为INTSET_ENC_INT16(int16_t),INTSET_ENC_INT32(int32_t),INTSET_ENC_INT64(int64_t)
· length属性是contents数组的长度
· contents数组是整数集合的底层实现,各个按值大小从小到大有序地排列,不包含重复项。
升级
当新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级。
(1)步骤
· 根据新元素的类型,扩展整数集合底层数组的空间大小
· 底层数组现有的所有元素都转换成与新元素相同的类型,并保持数组的有序性。
· 将新元素添加到底层数组里面
(2)新元素插入的位置
要么是最大值要么是最小值,故只会在表头和表尾插入。
(3)优点
· 提升整数集合的灵活性
· 尽可能地节约内存
ziplist压缩列表
定义
(1)ziplist
(2)节点entry
· previous_entry_length:记录了压缩列表中前一个节点的长度。压缩列表从表尾向表头遍历操作就是依赖这个属性。
· encoding:记录节点的content属性所保存数据的类型和长度。
· content 保存节点的值,可以是一个字节数组或者一个整数。
连锁更新
当添加或者删除节点的时候,导致previous_entry_length所占字节空间发生变化(1字节或者5字节),新节点的后续节点都要重新分配空间。
对象
包含5中类型的对象。基于引用计数计数进行内存回收和对象共享机制。
redisObject
(1)结构定义
(2)type
(3)encoding
字符串对象
(1)可以使用的encoding类型
REDIS_ENCODING_INT、REDIS_ENCODING_EMBSTR、REDIS_ENCODING_RAW
(2)编码转换
int编码和embstr编码的字符串对象在条件满足的情况下,被转换为raw对象。
列表对象
(1)可以使用的encoding类型
REDIS_ENCODING_ZIPLIST、REDIS_ENCODING_LINKEDLIST
(2)编码转换
哈希对象
(1)可以使用的encoding类型
REDIS_ENCODING_ZIPLIST、REDIS_ENCODING_HT
(2)编码转换
集合对象
(1)可以使用的encoding类型
REDIS_ENCODING_INTSET、REDIS_ENCODING_HT
(2)编码转换
有序集合对象
(1)可以使用的encoding类型
REDIS_ENCODING_ZIPLIST、REDIS_ENCODING_SKIPLIST
(2)底层结构zset
typedef struct zset{
zskiplist *zsl;
dict *dict;
}
· zsl按分值从小到大保存所有集合元素
· dict保存成员到分值的映射,键为成员,值为分值。
这两种数据结构通过指针来共享相同元素的成员和分值,不会浪费额外的内存。
(3)编码转换
多态命令
redis除了会根据值对象的类型来判断键是否能够执行指定命令外,还会根据值对象的编码方式,选择正确的命令实现代码来执行命令。
内存回收
引用计数法:利用redisObject中的refcount属性,当计数值为0时,回收内存。
对象共享
· 将数据库键的值指针指向一个向右的值对象
· 将被共享的值对象的引用计数增1
· 注意:redis只对包含整数值的字符串对象进行共享,其他字符串可能比较值相同较耗CPU。
空转时长
· redis利用redisObject中的lru属性,记录对象最后一次被命令程序访问的时间。
· 空转时长,就是通过将当前时间减去键的值对象的lru时间计算得出
· 如服务器打开maxmemory选项,且回收内存算法为volatile-lru或者allkeys-lru,则当内存超过maxmemory时,空转时长较高的部分将优先被服务器释放,回收内存。
数据库
定义
(1)redisServer
struct redisServer{
// 保存服务器中所有数据库的数组
redisDb *db;
// 服务器数据库数量
int dbnum;
// ....
}
dbnum的数量默认为16.
(2)redisClient
typedef struct redisClient{
// 记录客户端当前正在使用的数据库
redisDb *db;
// ....
}
默认使用0号数据库。可以通过SELECT命令切换数据库。
(3)redisDb
typedef struct redisDb{
// 数据库键空间
dict *dict
// 过期时间
dict *expires;
// ...
}
· dict字典中保存一个数据库中的所有键值对。
· 脏键,客户端使用WATCH命令监视的键,服务器每次修改一个键之后,都会对脏键计数器的值加1,计数器会触发服务器的持久化和复制操作。
过期时间
(1)EXPIRE/PEXPIRE命令
设置TTL生存时间,EXPIRE单位为s,PEXPIRE单位为ms。
(2)EXPIREAT/PEXPIREAT命令
设置过期时间,后面跟UNIX时间戳。
(3)使用 redisDb的expires字典保存过期时间,键为对象,值为过期时间。
(4)redis的过期删除策略
惰性删除:在查询key时,若过期才删除
定期删除:当服务器周期操作serverCron函数执行时删除。每次删除随机抽取部分,并维护一个进度记录,知道过期键全部清除。
(5)RDB功能和过期键
· 生成RDB文件时,检测键,过期的键不会被保存到文件中。
· 载入RDB文件时,主服务器忽略过期键,从服务器直接载入(主从同步时会删除)。
(6)AOF功能和过期键
过期键对AOF无影响,当过期键被删除式,AOF文件追加DEL语句。
RDB
概念
(1)RDB持久化是将某个时间点上的数据库状态保存到一个RDB文件中。
(2)RDB文件是一个经过压缩的二进制文件,通过该文件可以还原生成RDB文件时的数据库状态。
创建和载入
创建
(1)SAVE命令
阻塞Redis服务器,直到RDB文件创建完毕为止。
(2)BGSAVE命令
派生出一个子进程,子进程负责创建RDB,服务器进程继续处理命令。
· 注意:如果服务器开启了AOF持久化功能,那么服务器会优先使用AOF文件来还原数据。当AOF关闭时,才会使用RDB方式。
载入
在服务器启动时自动执行。
自动间隔性保存
(1)配置
在redis.windows.conf中,通过save选项设定服务器自动执行BGSAVE命令的间隔时间。
· 900秒内,对数据库修改1次,创建RDB
· 300秒内,对数据库修改10次,创建RDB
· 60秒内,对数据库修改10000次,创建RDB
(2)原理
· save选项会设置到redisServer的saveparam属性中。
· redisServer的dirty计数器记录一次SAVE/BGSAVE后,数据库修改次数
· redisServer的lastsave属性记录上一次SAVE/BGSAVE执行时间
· 服务器周期性操作函数serverCron默认每个100ms执行一次,其中一项工作为检测save选项条件是否满足
RDB文件结构
(1)REDIS用来快速检测所载入的文件是否是RDB文件
(2)db_version长度为4字节,记录RDB文件版本号
(3)databases包含0个或多个数据库,以及各数据库中的键值对数据
(4)EOF长度为1字节,标志RDB文件正文内容的结束
(5)check_sum长度为8字节无符号整数,保存一个校验和,用来检测RDB文件是否有出错或者损坏的情况
AOF
概念
AOF持久化是通过保存Redis服务器锁执行的写命令来记录数据库状态的
创建步骤
命令追加(append)
服务器在执行完一个写命令后,会以协议格式将被执行的写命令追加到redisServer的aof_buf缓冲区(SDS类型)的末尾。
文件写入与同步
服务器在每个事件处理结束时,调用flushAppendOnlyFile函数将aof_buf缓冲区中的内容写入和保存到AOF文件里面。flushAppendOnlyFile函数的appendfsync参数决定同步(何时将操作系统内存缓存区的内容写入磁盘)方式:
everysec为默认设置。
载入
步骤:
(1)创建一个不带网络连接的伪客户端
(2)从AOF文件中分析并读取一条写命令(包括新增,修改,删除)
(3)使用伪客户端执行写命令
(4)一直执行(2)和(3)直到所有命令被处理完。
AOF重写
(1)原因:为了解决长时间后AOF文件体积膨胀的问题(主要由于Redis的内存淘汰机制,一定时间后,大量数据被淘汰,使得原本的AOF存在大量之前的写记录,变得冗长)
(2)实现:创建一个新的AOF文件代替现有AOF文件。新旧两个AOF文件保存的数据库状态相同,但新AOF文件不包含任何浪费空间的冗命令。
(3)原理:从数据库读取键现在的值,用一条命令去记录键值对,代替之前记录这个键值对的多条命令。
(4)后台重写:创建子进程执行AOF重写程序(因为Reids采用单线程模式工作)。
事件
Redis服务器是一个事件驱动程序
文件事件
构成
(1)套接字 socket
Redis服务器通过套接字与客户端连接。
(2)I/O多路复用
将所有产生的套接字都放在一个队列里面,然后通过这个队列,以有序、同步、每次一个套接字的方式向文件事件分派器传送套接字。
Redis以单线程模式运行。
多路复用的具体实现,详见:深度解析单线程的 Redis 如何做到每秒数万 QPS 的超高处理能力,主要是通过epoll模型进行多路复用。
(3)文件事件分派器
根据IO复用器传过来的套接字产生的事件类型,调用相应的事件处理器
(4)事件处理器
· 命令请求处理器
· 命令回复处理器
· 连接应答处理器
· 。。。
事件类型
(1)AE_READABLE
套接字可读。即客户端对套接字执行write操作,或者close操作,或者有新的可应答套接字出现。
(2)AE_WRITABLE
套接字可写,即客户端对套接字执行read操作。
当两种事件同时发生时,文件事件分派器优先处理AR_READABLE事件。
时间事件
分为定时事件和周期性事件两类。
分类
(1)定时事件:让一段程序在指定的时间后执行一次。事件处理器返回AE_NOMORE
(2)周期性事件:让程序每隔指定时间就执行一次。事件处理器返回非AE_NOMORE整数值。
目前版本Redis只使用周期性事件。
时间事件的属性
· id 服务器为时间事件创建的全局唯一ID
· when 毫秒精度的UNIX时间戳,记录时间事件的到达时间
· timeProc 时间时间处理器,当时间事件到达时,服务器会调用相应的处理器来处理事件
实现
服务器将所有时间事件放在一个无序链表中,每当时间事件执行器运行时,它就遍历整个链表,查找所有已到达的时间事件,并调用相应的事件处理器。
· 新的时间事件总是插入到链表的表头。
· 正常模式下的Redis服务器只使用serverCron一个时间事件,故无序链表并不影响事件执行性能。
serverCron函数
(1)Redis需要定期对自身的资源和状态进行检查和调整
(2)主要工作:
· 更新服务器的各类统计信息,如时间、内存占用等
· 清理数据库中的过期键值对
· 关闭和清理连接失效的客户端
· 尝试进行AOF或RDB持久化操作
· 如果服务器是主服务器,对从服务器进行定期同步
· 如果处于集群模式,对集群进行定期同步和连接测试
(3)默认serverCron平均每间隔100ms运行一次(Redis2.6),Redis2.8开始,用户通过修改redis.windos.conf的hz选项来调整每秒执行次数。
事件的执行原则
(1)一次文件事件之后,仍然没有时间事件到达,那么服务器将再次等待并处理文件事件。
(2)事件的处理都是同步、有序、原子地执行的。
(3)时间事件在文件事件之后执行,通常执行时间会比时间事件设定的到达时间稍晚一些。
客户端
redisClient
struct redisServer {
// ...
// 一个链表,保存了所有客户端状态
list *client
// 套接字描述符
int fd;
//...
}
客户端分类
(1)普通客户端
· fd > -1的整数,来源于网络
· 创建时,添加到链表表尾
(2)伪客户端
· fd = -1,不是来源于网络
· AOF在载入AOF文件时创建。在载入完成后,伪客户端关闭。
· Lua脚本执行时创建。在服务器关闭时,伪客户端关闭。
复制
概念
(1)”SLAVEOF ip port”命令或者配置文件中的slaveof选项
(2)主从服务器的数据库将保存相同的数据
旧版(Redis2.8之前)
分为两个步骤进行,同步和命令传播。
同步
(1)从服务器向主服务器发送SYNC命令
(2)收到SYNC命令的主服务器执行BGSAVE命令。在后台生成一个RDB文件,并使用一个缓冲区记录从现在开始执行的所有写命令
(3)主服务器将RDB文件发送给从服务器,从服务器收入并载入RDB文件。
(4)主服务器将记录在缓冲区里面的所有写命令发送给从服务器。
命令传播
同步之后,每次将主服务器的写命令发送给从服务器。
新版
为了解决旧版复制功能在处理断线重复制情况时的低效问题。
实现
使用PSYNC命令,具有完整重同步和部分重同步两种模式
(1)完整重同步,用于初次复制情况
和SYNC命令类似,传送RDB文件和写命令缓冲区。
(2)部分重同步,用于处理断线后重复制情况
主服务器将主从服务器连接断开期间执行的写命令发送给从服务器。
心跳检测
(1)在命令传播阶段,从服务器默认会以每秒一次的频率,向主服务器发送命令:
REPLCONF ACK
(2)作用:
· 检测主从服务器的网络连接状态
· 辅助实现min-slaves选项
· 检测命令丢失
哨兵机制
意义
sentinel是Redis的高可用性解决方案。监视的主服务器进入下线状态时,自动将下线主服务器属下的某个从服务器升级为新的主服务器。
若之前的主服务器重新上线,则自动成为现存主服务器的从服务器。
启动sentinel
(1)初始化服务器
· Sentinel本质上是一个运行在特殊模式下的Redis服务器
· Sentinel不适用数据库,不会载入RDB或者AOF文件
(2)将普通Redis服务器使用的代码替换为sentinel专用代码
(3)初始化sentinel状态
(4)根据配置文件,初始化Sentinel的监视主服务器列表
(5)创建连向主服务器的网络连接
· 命令连接:向服务器发送命令和接收命令回复
· 订阅连接:订阅服务器的_sentinel_:hello频道
获取服务器信息
(1)Sentinel默认以十秒一次的频率,通过命令连接向被监视的主服务器发送INFO命令。
可以获取以下消息:
· 主服务器本身的信息
· 所有从服务器的信息
(2)Sentinel默认以十秒一次的频率,通过命令连接向被监视的从服务器发送INFO命令。
发送和接收
(1)sentinel每两秒一次发送命令给监视的主从服务器的_sentinel_:hello频道
(2)订阅连接建立之后,通过_sentinel_hello频道获取信息。
检测是否下线
(1)主观下线
Sentinel默认每次一秒的频率向建立了命令连接的Redis实例发送PING命令。若在down-after-milliseconds选项配置的时间内没有有效回复,认为为主观下线状态。
(2)客观下线
当Sentinel将一个主服务器判断为主观下线后,为了确认是否真的下线了,会向同样监视这一主服务器的其他Sentinel进行询问,若其他Sentinel也认为为下线状态,在接收到足够数量的下线判断后,Sentinel认为主服务器为客观下线,并进行故障转移操作。
选举领头Sentinel
当一个主服务器为客观下线时,监视这个主服务器的所有Sentinel选举一个领头Sentinel对主服务器执行故障转移操作。
主要步骤如下:
(1)在一个配置纪元(计数器)里面,所有Sentinel有一次将某个Sentinel设置为局部领头Sentinel的机会,并且局部领头一旦设置,在这个配置纪元里面就不能再更改。
(2)Sentinel向另一个Sentinel发送带有自己运行ID的命令,让其设置自己为局部领头Sentinel(相当于抢票)。
(3)局部领头Sentinel规则:先到先得。已经设置为别人为Sentinel的Sentinel,拒绝后续收到的设置
(4)如果某个Sentinel被半数以上的Sentinel设置成了局部领头Sentinel,那么这个Sentinel成为领头Sentinel。
故障转移
(1)在已下线主服务器属下的所有从服务器中,挑选出一个从服务器,将其转换为主服务器。
领头Sentinel按照从服务器的优先级,对列表中剩余的从服务器进行排序,并选出其中优先级最高的从服务器
(2)让已下线主服务器属下的所以从服务器改为复制新的主服务器。
(3)将已下线主服务器设置为新的主服务器的从服务器,当这个旧的主服务器重新上线时,它就会成为新的主服务器的从服务器。
集群
Redis集群是Redis提供的分布式数据库方案,集群通过分片进行数据共享,并提供复制和故障转移功能。
节点
(1)CLUSTER MEET命令
· 格式 CLUSTER MEET
· 向另一个节点发送命令,进行握手,握手成功后加入所在集群。
(2)启动节点
(3)集群数据结构
· clusterNode 每个节点使用其记录自己的状态,并为集群中其他节点创建一个相应的clusterNode结构
· clusterLink 保存了连接节点所需的有关信息
· clusterState 记录在当前节点的视角下,集群目前所处状态
(4)节点握手
槽指派
(1)概念
Redis集群通过分片的方式来保存数据库中的键值对:集群的整个数据库被分为16384割槽(slot),数据库中的每个键都属于整个16384个槽的其中一个,集群中的每个节点可以处理0~16384个槽。
(2)数据结构
struct clusterNode{
//....
unsigned char slots[16384/8];
int numslots;
// ....
}
· 二进制数组中索引上的二进制位为1,则表示节点负责处理该槽
· numslots表示该节点负责的槽数量
(3)相关命令
CLUSTER ADDSLOTS 指派槽
(4)注意点
节点数据库只能使用0号数据库,这和单机服务器的数据库不同。
复制和故障转移
复制
主节点(master)用于处理槽,从节点用于复制某个主节点,在其主节点下线时可以代替主节点继续处理命令请求。
故障转移
(1)在从节点中选一个成为新的主节点
新主节点的选取,类似于sentinel领头的选取,算法都是基于Raft算法实现的。
(2)新的主节点撤销对已下线主节点的槽指派,并将这些槽指派给自己
(3)新的主节点广播PONG消息,让其他节点知道自己成为新的主节点
(4)接收和处理自己的槽相关的请求,故障转移完成。
发布与订阅
概述
(1)由PUBLISH(发送消息)、SUBSCRIBE(订阅频道)、PSUBSCRIBE(订阅模式)等命令组成
(2)通过执行SUBSCRIBE命令,客户端可以订阅一个或多个频道,每当有其他客户端向被订阅的频道发送消息时,频道的所有订阅者都会受到这条消息。
(3)通过执行PSUBSCRIBE命令订阅一个或多个模式,每当有其他客户端向某个频道发送消息时,消息会被发送给与这个频道相匹配的模式的订阅者。
频道
数据结构
struct redisServer{
// ...
// 保存所有频道的订阅关系
dict *pubsub_channels;
// ...
}
· 字典的键是某个被订阅的频道
· 字典的值是一个链表,记录所有订阅该频道的客户端
订阅和退订
(1)订阅:使用SUBSCRIBE命令,在链表的尾部添加
(2)退订:使用UNSUBSCRIBE命令,从链表中删除客户端。当出现键对应空链表,要从字典中删除键
模式
数据结构
struct redisServer{
// ...
// 保存所有频道的订阅关系
dict *pubsub_patterns;
// ...
}
订阅和退订
类似频道。
发送消息
PUBLISH命令:在pubsub_channels字典里找到频道channel的订阅者名单(一个链表),然后将消息发送给名单上的所有客户端。
事务
概述
使用MULTI(事务开始)、EXEC(提交事务)、WATCH等命令来实现事务。
事务提供了一种多个命令请求打包,然后一次性、按顺序地执行多个命令的机制,并且在事务执行期间,服务器不会中断事务而改去执行其他客户端的命令请求。
事务的实现
(1)事务开始
· MULTI命令将客户端切换至事务状态。
· 原理:在客户端状态的flags属性中打开REDIS_MULTI标识。
(2)命令入队
· 如果是EXEC、DISCARD、WATCH、MULTI四个命令,立即执行
· 其他命令放入事务队列,然后客户端返回QUEUED回复
(3)事务执行
遍历客户端的事务队列,执行队列中保存的所有命令。
WATCH命令
(1)作用
是一个乐观锁,在执行EXEC命令前,监视任意数量的数据库键。在EXEC命令执行时,若监视的键是否至少有一个已经被修改过了,如果是的话,服务器拒绝执行事务,向客户端返回空回复。
(2)数据结构
typedef struct redisDb{
// ...
// 正在被WATCH命令监视的键
dict *watched_keys;
// ...
}
每个数据库都保存一个字典,键为WATCH命令监视的数据库键。值为一个链表,记录所有监视相应数据库键的客户端。
(3)当被监视的键被修改,则客户端的REDIS_DITRY_CAS标识打开。
缓存问题
缓存雪崩
(1)原因
缓存同一时间大面积的失效,大量的请求直接落到数据库上,造成数据库短时间内承受大量请求而崩掉。
(2)解决办法
· 事前:尽量保证整个Redis集群的高可用性,发现机器宕机几块补上,选择合适的内存淘汰策略。
· 事中: 本地ehcache缓存+hystrix限流&降级,避免MySQL崩掉
· 事后:利用redis持久化机制保存的数据尽快恢复缓存
缓存穿透
原因
大量请求的key根本不在缓存中,导致请求直接到了数据库上。
一般MySQL的最大连接数在150左右,最大连接数还只是一个指标,cpu,内存,自盘,网络等
解决办法
无效key的时间减短
黑客每次构建不同的请求Key,会导致redis中缓存大量无效的key,故可以将无效key的过期时间设置短一点。
布隆过滤器
(1)布隆过滤器的概念
布隆过滤器(Bloom Filter)可以看作由二进制向量(或者说位数组)和一系列随机映射函数(哈希函数)两部分组成的数据结构。相比于我们平时常用的的 List、Map 、Set 等数据结构,它占用空间更少并且效率更高,但是缺点是其返回的结果是概率性的,而不是非常准确的。理论情况下添加到集合中的元素越多,误报的可能性就越大。并且,存放在布隆过滤器的数据不容易删除。
位数组中的每个元素都只占用 1 bit ,并且每个元素只能是 0 或者 1。这样申请一个 100w 个元素的位数组只占用 1000000Bit / 8 = 125000 Byte = 125000/1024 kb ≈ 122kb 的空间。
总结:一个名叫 Bloom 的人提出了一种来检索元素是否在给定大集合中的数据结构,这种数据结构是高效且性能很好的,但缺点是具有一定的错误识别率和删除难度。并且,理论情况下,添加到集合中的元素越多,误报的可能性就越大。
(2)布隆过滤器的原理介绍
· 当一个元素加入布隆过滤器中的时候,会进行如下操作:
使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)
根据得到的哈希值,在位数组中把对应下标的值置为 1
· 当我们需要判断一个元素是否存在于布隆过滤器的时候,会进行如下操作:
对给定元素再次进行相同的哈希计算
得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中
举个简单的例子:
不同的字符串可能哈希出来的位置相同,这种情况我们可以适当增加位数组大小或者调整我们的哈希函数。
综上,我们可以得出:布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。
在Redis中具体工作机制如下:
(3)布隆过滤器的实现
可以参考:Springboot + Redis的布隆过滤器实现
内存淘汰机制
保证Redis中的数据为热点数据。
(1)volatile-lru:从已设置过期时间的数据集中挑选最近最少使用的数据淘汰
(2)volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰
(3)volatile-random:从已设置过期时间的数据集中挑选任意数据淘汰
(4)allkeys-lru:内存不足时,在键空间中移除最近最少使用的key(最常用)
(5)allkeys-random:从数据集中任意选择数据淘汰
(6)no-eviction:禁止驱逐数据
redis4.0后新增
(7)volatile-lfu:从已设置过期时间的数据集中挑选最不经常使用的数据淘汰
(8)allkeys-lfu:内存不足时,在键空间中移除最不经常使用的key
Redis分布式锁
分布式锁一般有三种方式实现:数据库乐观锁,基于Redis的分布式锁,基于zookeeper的分布式锁。
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>2.9.0</version>
</dependency>
public class RedisTool {
private static final String LOCK_SUCCESS = "OK";
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";
/**
\* 尝试获取分布式锁
\* @param jedis Redis客户端
\* @param lockKey 锁
\* @param requestId 请求标识
\* @param expireTime 超期时间
\* @return 是否获取成功
*/
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}
}
这个set()方法一共有五个形参:
· 第一个为key,我们使用key来当锁,因为key是唯一的。
· 第二个为value,我们传的是requestId,很多童鞋可能不明白,有key作为锁不就够了吗,为什么还要用到value?原因就是我们在上面讲到可靠性时,分布式锁要满足第四个条件解铃还须系铃人,通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。
· 第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
· 第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
· 第五个为time,与第四个参数相呼应,代表key的过期时间。
方法底层主要使用Redis的Setnx 命令实现。
总的来说,执行上面的set()方法就只会导致两种结果:1. 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。2. 已有锁存在,不做任何操作。
缓存与数据库一致性问题
首先写入缓存中的数据,都设置失效时间,这样一来,缓存中不经常访问的数据,随着时间的推移,都会逐渐「过期」淘汰掉,最终缓存中保留的,都是经常被访问的「热数据」,缓存利用率得以最大化。
在满足实时性的条件下,不存在两者完全保存一致的方案,只有最终一致性方案。
我们对比以下 6 种方案:
先写 Redis,再写 MySQL
这种方案,我肯定不会用,万一 DB 挂了,你把数据写到缓存,DB 无数据,这个是灾难性的;
我之前也见同学这么用过,如果写 DB 失败,对 Redis 进行逆操作,那如果逆操作失败呢,是不是还要搞个重试?
先写 MySQL,再写 Redis
对于并发量、一致性要求不高的项目,很多就是这么用的,我之前也经常这么搞,但是不建议这么做;
当 Redis 瞬间不可用的情况,需要报警出来,然后线下处理。
先删除 Redis,再写 MySQL
这种方式,我还真没用过,直接忽略吧。
先删除 Redis,再写 MySQL,再删除 Redis
这种方式虽然可行,但是感觉好复杂,还要搞个消息队列去异步删除 Redis。
先写 MySQL,再删除 Redis
比较推荐这种方式,删除 Redis 如果失败,可以再多重试几次,否则报警出来;
这个方案,是实时性中最好的方案,在一些高并发场景中,推荐这种。
先写 MySQL,通过 Binlog,异步更新 Redis
对于异地容灾、数据汇总等,建议会用这种方式,比如 binlog + kafka,数据的一致性也可以达到秒级;
纯粹的高并发场景,不建议用这种方案,比如抢购、秒杀等。
个人结论:
- 实时一致性方案:采用“先写 MySQL,再删除 Redis”的策略,这种情况虽然也会存在两者不一致,但是需要满足的条件有点苛刻,所以是满足实时性条件下,能尽量满足一致性的最优解。
- 最终一致性方案:采用“先写 MySQL,通过 Binlog,异步更新 Redis”,可以通过 Binlog,结合消息队列异步更新 Redis,是最终一致性的最优解。
详情看:https://mp.weixin.qq.com/s/RL4Bt_UkNcnsBGL_9w37Zg
另外,Redis如果写数据库成功了,写缓存失败了怎么办?
高峰期的时候让缓存的过期时间小一点,但是明显不能完全解决这个问题~
可以单独写一个定时任务,定时对比数据库和Redis中的数据,数据中可以加入版本号,查阅版本号是否一致等等。