Redis 是一种基于内存的数据库,对数据的读写操作都是在内存中完成,因此读写速度非常快,常用于缓存,消息队列、分布式锁等场景。
Redis 提供了多种数据类型来支持不同的业务场景,比如 String(字符串)、Hash(哈希)、 List (列表)、Set(集合)、Zset(有序集合)、Bitmaps(位图)、HyperLogLog(基数统计)、GEO(地理信息)、Stream(流),并且对数据类型的操作都是原子性的,因为执行命令由单线程负责的,不存在并发竞争的问题。
除此之外,Redis 还支持事务 、持久化、Lua 脚本、多种集群方案(主从复制模式、哨兵模式、切片机群模式)、发布/订阅模式,内存淘汰机制、过期删除机制等等。
(1)Redis 具备高性能
Redis 缓存直接操作内存,速度相当快。
(2)Redis 具备高并发
单台设备的 Redis 的 QPS(Query Per Second,每秒钟处理完请求的次数) 是 MySQL 的 10 倍,Redis 单机的 QPS 能轻松破 10w,而 MySQL 单机的 QPS 很难破 1w。
所以,直接访问 Redis 能够承受的请求是远远大于直接访问 MySQL 的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。
应用场景:
Redis 后续版本又支持四种数据类型,它们的应用场景如下:
(1)String
使用SDS实现:
(2)List
List 类型的底层数据结构是由双向链表或压缩列表实现的:
但是在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表。
(3)Hash 类型内部实现
Hash 类型的底层数据结构是由压缩列表或哈希表实现的:
在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。
(4)Set 类型内部实现
Set 类型的底层数据结构是由哈希表或整数集合实现的:
(5)ZSet 类型内部实现
Zset 类型的底层数据结构是由压缩列表或跳表实现的:
在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。
参考: 图解Redis介绍 | 小林coding (xiaolincoding.com)
简单动态字符串。
① 组成结构
alloc - len
计算出剩余的空间大小。空间不够,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现缓冲区溢出的问题。② sdshdr
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
首先,attribute ((packed))表示一种紧凑的内存分配方式。通常情况下,编译器会对称的存放数据,比如一个char类型占1字节,int占4字节,会为了对齐,给char也开4字节。紧凑的内存分配减少了内存消耗。
通过flags优化内存,因为buf所需字节越少,len、alloc的最大长度自然也会减少,通过提前预判字符串长度,减少内存消耗。
其中sdshdr5不同,只有5个字节,甚至减少了len和alloc的内存消耗。不过一般来说,value就算字节小于2的5次,也会使用sdshdr8存储,可能因为value会更新、查看长度,所以使用了sdshdr8,但key会使用sdshdr5。
③ 作用
实现String。
① 实现
typedef struct listNode {
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
void *value;
} listNode;
c语言没有链表,redis自己实现的一个链表。
② 作用
实现了List,在数据量大于512个,或单个元素字节大于64时使用。
后来被quicklist取代。
① 实现
由连续内存块组成的顺序型数据结构
只适用于保存节点数量不多、每个节点数据较小的场景,避免连锁更新造成较大损失。所以数据量大会用链表存储,之后甚至被淘汰。
连锁更新:
prevlen 存储前一个节点的长度,如果某个节点更新,导致下一个结点的prevlen更新,可能又导致下一个节点的prevlen更新。这种在特殊情况下产生的连续多次空间扩展操作就叫做「连锁更新」。
② 作用
实现了List,在数据量小于于512个,且单个元素字节小于64时使用。
在hash、zset中也有使用。
后来被listpack取代。
① 数据结构
typedef struct quicklist {
//quicklist的链表头
quicklistNode *head;
//quicklist的链表尾
quicklistNode *tail;
//所有压缩列表中的总元素个数
unsigned long count;
//quicklistNodes的个数
unsigned long len;
...
} quicklist;
typedef struct quicklistNode {
//前一个quicklistNode
struct quicklistNode *prev;
//下一个quicklistNode
struct quicklistNode *next;
//quicklistNode指向的压缩列表
unsigned char *zl;
//压缩列表的的字节大小
unsigned int sz;
//压缩列表的元素个数
unsigned int count : 16;
....
} quicklistNode;
在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 quicklistNode 结构。
② 作用
代替了链表和压缩列表,成为了List的底层数据结构。
key-value 的数据结构。
① 数据结构
同java的hashmap,使用dictEntry指针组存放每个dictEntry链的第一个dictEntry。
每个dictEntry都有key、value、next。同时哈希表还存放一个size,存放dictEntry数量。
② hash冲突
同样,redis的哈希表也会存在哈希冲突。
使用链式存储的方式存储相同hashcode的kv。
③ 重哈希
【(1)键值对数据库是怎么实现的?】中说过,Redis的存储结构,在一开始创建了两个dictht。这个哈希表2是空的,需要重哈希时,给哈希表2开辟两倍哈希表1的空间,如下图进行重哈希操作:
但是,如果数据量较大,会导致redis阻塞时间较长,于是采用渐进式rehash,分为多次进行重哈希,也就是说不主动进行数据的移动,而是跟随用户操作顺便进行移动:
④ 重哈希条件
负载因子 = 哈希表已保存结点数/哈希表大小
触发 rehash 操作的条件,主要有两个:
⑤ 作用
用于hash、set
① 数据结构
一块连续的内存空间。
typedef struct intset {
//编码方式,INTSET_ENC_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64,决定了contents中数据的类型
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
} intset;
② 整数集合升级
如果新元素的类型(int32_t)比整数集合现有所有元素的类型(int16_t) 都要长,就要进行升级。
整数集合升级可以节省内存资源。
整数集合不支持降级。
① zset
Redis只有zset用到了跳表,当元素个数大于512使用跳表(反之使用压缩列表/quicklist)。zset数据结构:
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
可以看到,此处除了 跳表/压缩列表 外,还使用了哈希表,在更新数据时,也会同时更新到哈希表中。所以zset才能支持范围查询的同时,也能作到O(1)的查询元素权重(zscore)。
② 数据结构
typedef struct zskiplist {
//头节点、尾节点
struct zskiplistNode *header, *tail;
//节点数量
unsigned long length;
//最大层数
int level;
} zskiplist;
typedef struct zskiplistNode {
//Zset 对象的元素值
sds ele;
//元素权重值
double score;
//后向指针
struct zskiplistNode *backward;
//节点的level数组,保存每层上的前向指针和跨度
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Z49xU4a7-1662688778334)(https://cdn.xiaolincoding.com/gh/xiaolincoder/redis/%E6%95%B0%E6%8D%AE%E7%B1%BB%E5%9E%8B/3%E5%B1%82%E8%B7%B3%E8%A1%A8-%E8%B7%A8%E5%BA%A6.png)]
可以看出图中 1、2、3 就是头节点,zskiplist保存了头节点、尾节点、长度等用于遍历,而zskiplistNode中后向指针backward可以用于找到下一个头节点。
③ 查询步骤:(查询元素abcd,权重4)
从头节点开始,找到权重3,所以继续向后找,为null,向下层找。来到元素abcde,权重4,权重相同,但abcde大于abcd,所以返回,在向下层找。通过leve[0]找到目标。
④ 插入/更新步骤:
更新和插入使用的都是zadd,先找是否存在该元素,如果不存在,插入:
找到最后一个比 插入元素 小的节点,随机生成层数,插入元素,同时更新最大层数。
如果存在,更新:
5.0之前,先删除后更新;5.0之后,直接更新,之后调整score值。
⑤ 删除:
同理,找到元素,删除元素,更新层数。
⑥ 跳表层数设置:
跳表的相邻两层的节点数量最理想的比例是 2:1,查找复杂度可以降低到 O(logN)。
Redis 的方法是,跳表在创建节点的时候,随机生成每个节点的层数,并没有严格维持相邻两层的节点数量比例为 2 : 1 的情况。
具体的做法是,跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 p,那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 p 结束,最终确定该节点的层数。
p一般是0.25或0.5, 每个元素所包含的平均指针数量是 2 或者 1.33。
这样的做法,相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 32。
⑦ 为什么用跳表,不用平衡树、红黑树
用来代替 压缩列表 ,压缩列表每个节点需要保存前一个节点的长度字段,会有连锁更新的隐患。listpack 中每个节点不再包含前一个节点的长度了。
可以看到,listpack 没有压缩列表中记录前一个节点长度的字段了,listpack 只记录当前节点的长度,当我们向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题。
1、基于本机内存的缓存
当调用api访问数据库时,假如此过程需要2秒,如果每次请求都要访问数据库,那将对服务器造成巨大的压力,如果将此sql的查询结果存到Redis中,再次请求时,直接从Redis中取得,而不是访问数据库,效率将得到巨大的提升,Redis可以定时去更新数据(比如1分钟)。
2、如果电脑重启,写入内存的数据是不是就失效了呢,这时Redis还提供了持久化的功能。
3、哨兵(Sentinel)和复制
Sentinel可以管理多个Redis服务器,它提供了监控、提醒以及自动的故障转移功能;
复制则是让Redis服务器可以配备备份的服务器;
Redis也是通过这两个功能保证Redis的高可用;
4、集群(Cluster)
单台服务器资源总是有上限的,CPU和IO资源可以通过主从复制,进行读写分离,把一部分CPU和IO的压力转移到从服务器上,但是内存资源怎么办,主从模式只是数据的备份,并不能扩充内存;
现在我们可以横向扩展,让每台服务器只负责一部分任务,然后将这些服务器构成一个整体,对外界来说,这一组服务器就像是集群一样。
1、先把Redis的连接池拿出来
JedisPool pool = new JedisPool(new JedisPoolConfig(),"127.0.0.1");
Jedis jedis = pool.getResource();
2、存取值
jedis.set("key","value");
jedis.get("key");
jedis.del("key");
//给一个key叠加value
jedis.append("key","value2");//此时key的值就是value + value2;
//同时给多个key进行赋值:
jedis.mset("key1","value1","key2","value2");
3、对map进行操作
Map<String,String> user = new HashMap();
user.put("key1","value1");
user.put("key2","value2");
user.put("key3","value3");
//存入
jedis.hmset("user",user);
//取出user中key1
List<String> nameMap = jedis.hmget("user","key1");
//删除其中一个键值
jedis.hdel("user","key2");
//是否存在一个键
jedis.exists("user");
//取出所有的Map中的值:
Iterator<String> iter = jedis.hkeys("user").iterator();
while(iter.next()){
jedis.hmget("user",iter.next());
}
Redis 单线程指的是「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的,这也是我们常说 Redis 是单线程的原因。
但是,Redis 程序并不是单线程的,Redis 在启动的时候,是会启动后台线程(BIO)的:
之所以 Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理,是因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求了。
后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的方法即可。
关闭文件、AOF 刷盘、释放内存这三个任务都有各自的任务队列:
Redis 6.0 版本之前的单线模式如下图:
图中的蓝色部分是一个事件循环,是由主线程负责的,可以看到网络 I/O 和命令处理都是单线程。 Redis 初始化的时候,会做下面这几件事情:
初始化完后,主线程就进入到一个事件循环函数,主要会做以下事情:
官方使用基准测试的结果是,单线程的 Redis 吞吐量可以达到 10W/每秒。
之所以 Redis 采用单线程(网络 I/O 和执行命令)那么快,有如下几个原因:
CPU 并不是制约 Redis 性能表现的瓶颈所在,更多情况下是受到内存大小和网络I/O的限制,所以 Redis 核心网络模型使用单线程并没有什么问题,如果你想要使用服务的多核CPU,可以在一台服务器上启动多个节点或者采用分片集群的方式。
而且,使用了单线程后,可维护性高,多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。
虽然 Redis 的主要工作(网络 I/O 和执行命令)一直是单线程模型,但是在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求,这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上。
但是对于命令的执行,Redis 仍然使用单线程来处理。
Redis 6.0 版本支持的 I/O 多线程特性,默认情况下 I/O 多线程只针对发送响应数据(write client socket),并不会以多线程的方式处理读请求(read client socket)。要想开启多线程处理客户端读请求,就需要把 Redis.conf 配置文件中的 io-threads-do-reads 配置项设为 yes。
//读请求也使用io多线程
io-threads-do-reads yes
同时, Redis.conf 配置文件中提供了 IO 多线程个数的配置项。
// io-threads N,表示启用 N-1 个 I/O 多线程(主线程也算一个 I/O 线程)
io-threads 4
关于线程数的设置,官方的建议是如果为 4 核的 CPU,建议线程数设置为 2 或 3,如果为 8 核 CPU 建议线程数设置为 6,线程数一定要小于机器核数,线程数并不是越大越好。
因此, Redis 6.0 版本之后,Redis 在启动的时候,默认情况下会创建 6 个线程:
Redis源码解析:坏了,Redis服崩了,内存中的数据该如何恢复?_小识的博客-CSDN博客_redis崩了
RDB持久化
RDB持久化即通过创建快照(压缩的二进制文件)的方式进行持久化,保存某个时间点的全量数据。RDB持久化是Redis默认的持久化方式。RDB持久化的触发包括手动触发与自动触发两种方式。
AOF持久化
AOF(Append-Only-File)持久化即记录所有变更数据库状态的指令,以append的形式追加保存到AOF文件中。在服务器下次启动时,就可以通过载入和执行AOF文件中保存的命令,来还原服务器关闭前的数据库状态。
RDB、AOF混合持久化
Redis从4.0版开始支持RDB与AOF的混合持久化方案。首先由RDB定期完成内存快照的备份,然后再由AOF完成两次RDB之间的数据备份,由这两部分共同构成持久化文件。该方案的优点是充分利用了RDB加载快、备份文件小及AOF尽可能不丢数据的特性。缺点是兼容性差,一旦开启了混合持久化,在4.0之前的版本都不识别该持久化文件,同时由于前部分是RDB格式,阅读性较低。
Redis 持久化方案的建议
如果Redis只是用来做缓存服务器,比如数据库查询数据后缓存,那可以不用考虑持久化,因为缓存服务失效还能再从数据库获取恢复。
如果你要想提供很高的数据保障性,那么建议你同时使用两种持久化方式。如果你可以接受灾难带来的几分钟的数据丢失,那么可以仅使用RDB。
通常的设计思路是利用主从复制机制来弥补持久化时性能上的影响。即Master上RDB、AOF都不做,保证Master的读写性能,而Slave上则同时开启RDB和AOF(或4.0以上版本的混合持久化方式)来进行持久化,保证数据的安全性。
Redis 持久化方案的优缺点
RDB持久化
优点:RDB文件紧凑,体积小,网络传输快,适合全量复制;恢复速度比AOF快很多。当然,与AOF相比,RDB最重要的优点之一是对性能的影响相对较小。
缺点:RDB文件的致命缺点在于其数据快照的持久化方式决定了必然做不到实时持久化,而在数据越来越重要的今天,数据的大量丢失很多时候是无法接受的,因此AOF持久化成为主流。此外,RDB文件需要满足特定格式,兼容性差(如老版本的Redis不兼容新版本的RDB文件)。
AOF持久化
与RDB持久化相对应,AOF的优点在于支持秒级持久化、兼容性好,缺点是文件大、恢复速度慢、对性能影响大。
缓存穿透
指查询一个一定不存在的数据,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到 DB 去查询,可能导致 DB 挂掉。
解决方案:
i. 查询返回的数据为空,仍把这个空结果进行缓存,但过期时间会比较短
ii. 布隆过滤器:将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对 DB 的查询。
布隆过滤器:
所有可能存在的数据哈希到一个足够大的 bitmap(由01组成) 中,通过哈希函数得出多个索引值,如果均为1,则该数据可能存在(因为该1可能是其他数据的索引),如果存在0,则该数据一定不存在。
布隆过滤器详解 参考博客: 布隆过滤器究竟是什么,这一篇给讲的明明白白的_vincent-CSDN博客_布隆过滤器
缓存击穿
对于设置了过期时间的 key,缓存在某个时间点过期的时候,恰好这时间点对这个 Key 有大量的并发请求过来,这些请求发现缓存过期一般都会从后端 DB 加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把 DB 压垮。
解决方案:
i. 设置热点数据永远不过期。
ii. 加互斥锁,互斥锁:缓存中没有数据,第1个进入的线程,获取锁并从数据库去取数据,没释放锁之前,其他并行进入的线程会等待100ms,再重新去缓存取数据。这样就防止都去数据库重复取数据,重复往缓存中更新数据情况出现。
缓存雪崩
设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到 DB, DB 瞬时压力过重雪崩。与缓存击穿的区别:雪崩是很多 key,击穿是某一个key 缓存。
解决方案:
缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
如果缓存数据库是分布式部署,将热点数据均匀分布在不同搞得缓存数据库中。
设置热点数据永远不过期。
由于数据存储受限,系统并不是将所有数据都需要存放到缓存中的,而只是将其中一部分热点数据缓存起来,所以我们要设计一个热点数据动态缓存的策略。
热点数据动态缓存的策略总体思路:通过数据最新访问时间来做排名,并过滤掉不常访问的数据,只留下经常访问的数据。
以电商平台场景中的例子,现在要求只缓存用户经常访问的 Top 1000 的商品。具体细节如下:
在 Redis 中可以用 zadd 方法和 zrange 方法来完成排序队列和获取 200 个商品的操作。
常见的缓存更新策略共有3种:
实际开发中,Redis 和 MySQL 的更新策略用的是 Cache Aside,另外两种策略应用不了。
Cache Aside(旁路缓存)策略
Cache Aside(旁路缓存)策略是最常用的,应用程序直接与「数据库、缓存」交互,并负责对缓存的维护,该策略又可以细分为「读策略」和「写策略」。
写策略的步骤:
读策略的步骤:
注意,写策略的步骤的顺序顺序不能倒过来,即不能先删除缓存再更新数据库,原因是在「读+写」并发的时候,会出现缓存和数据库的数据不一致性的问题。
举个例子,假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21。
最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库的数据不一致。
为什么「先更新数据库再删除缓存」不会有数据不一致的问题?
继续用「读 + 写」请求的并发的场景来分析。
假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中。
最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库数据不一致。 从上面的理论上分析,先更新数据库,再删除缓存也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高。
因为缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。而一旦请求 A 早于请求 B 删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中而从数据库中重新读取数据,所以不会出现这种不一致的情况。
Cache Aside 策略适合读多写少的场景,不适合写多的场景,因为当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。如果业务对缓存命中率有严格的要求,那么可以考虑两种解决方案:
Read/Write Through(读穿 / 写穿)策略
Read/Write Through(读穿 / 写穿)策略原则是应用程序只和缓存交互,不再和数据库交互,而是由缓存和数据库交互,相当于更新数据库的操作由缓存自己代理了。
1、Read Through 策略
先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库查询数据,并将结果写入到缓存组件,最后缓存组件将数据返回给应用。
2、Write Through 策略
当有数据更新的时候,先查询要写入的数据在缓存中是否已经存在:
下面是 Read Through/Write Through 策略的示意图:
Read Through/Write Through 策略的特点是由缓存节点而非应用程序来和数据库打交道,在我们开发过程中相比 Cache Aside 策略要少见一些,原因是我们经常使用的分布式缓存组件,无论是 Memcached 还是 Redis 都不提供写入数据库和自动加载数据库中的数据的功能。而我们在使用本地缓存的时候可以考虑使用这种策略。
Write Back(写回)策略
Write Back(写回)策略在更新数据的时候,只更新缓存,同时将缓存数据设置为脏的,然后立马返回,并不会更新数据库。对于数据库的更新,会通过批量异步更新的方式进行。
实际上,Write Back(写回)策略也不能应用到我们常用的数据库和缓存的场景中,因为 Redis 并没有异步更新数据库的功能。
Write Back 是计算机体系结构中的设计,比如 CPU 的缓存、操作系统中文件系统的缓存都采用了 Write Back(写回)策略。
Write Back 策略特别适合写多的场景,因为发生写操作的时候, 只需要更新缓存,就立马返回了。比如,写文件的时候,实际上是写入到文件系统的缓存就返回了,并不会写磁盘。
但是带来的问题是,数据不是强一致性的,而且会有数据丢失的风险,因为缓存一般使用内存,而内存是非持久化的,所以一旦缓存机器掉电,就会造成原本缓存中的脏数据丢失。所以你会发现系统在掉电之后,之前写入的文件会有部分丢失,就是因为 Page Cache 还没有来得及刷盘造成的。
要修改数据库数据,需要将缓存中该数据删除,并更新数据库。下次读取该数据时,在缓存中找不到数据,则去数据库找,返回数据,并将该数据存到缓存中。但可能出现下面情况:
public void use(String key, Object data){
redis.delKey(key);
db.updateData(data);
Thread.sleep(800);
redis.delKey(key);
}
① 先更新再删除缓存
因为缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。
② 采用延时双删策略
(1)先淘汰缓存
(2)再写数据库
(3)休眠800ms,再次淘汰缓存
这么做,可以将800ms内所造成的缓存脏数据,再次删除。
在删除缓存(第二个操作)的时候失败了,导致缓存还是旧值,而数据库是最新值,造成数据库和缓存数据不一致的问题,会对敏感业务造成影响。
① 重试机制
我们可以引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。
② 订阅 MySQL binlog,再操作缓存
「先更新数据库,再删缓存」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。
于是我们就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。
Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。
下图是 Canal 的工作原理:
所以,如果要想保证「先更新数据库,再删缓存」策略第二个操作能执行成功,我们可以使用「消息队列来重试缓存的删除」,或者「订阅 MySQL binlog 再操作缓存」,这两种方法有一个共同的特点,都是采用异步操作缓存。
设计一个高可用的 Redis 服务,要从 Redis 的多服务节点来考虑,比如 Redis 的主从复制、哨兵模式、切片集群。
主从复制是 Redis 高可用服务的最基础的保证,实现方案就是将从前的一台 Redis 服务器,同步数据到多台从 Redis 服务器上,即一主多从的模式,且主从服务器之间采用的是「读写分离」的方式。
主服务器可以进行读写操作,当发生写操作时自动将写操作同步给从服务器,而从服务器一般是只读,并接受主服务器同步过来写操作命令,然后执行这条命令。
也就是说,所有的数据修改只在主服务器上进行,然后将最新的数据同步给从服务器,这样就使得主从服务器的数据是一致的。
注意,主从服务器之间的命令复制是异步进行的。
具体来说,在主从服务器命令传播阶段,主服务器收到新的写命令后,会发送给从服务器。但是,主服务器并不会等到从服务器实际执行完命令后,再把结果返回给客户端,而是主服务器自己在本地执行完命令后,就会向客户端返回结果了。如果从服务器还没有执行主服务器同步过来的命令,主从服务器间的数据就不一致了。
所以,无法实现强一致性保证(主从数据时时刻刻保持一致),数据不一致是难以避免的。
在使用 Redis 主从服务的时候,会有一个问题,就是当 Redis 的主从服务器出现故障宕机时,需要手动进行恢复。
为了解决这个问题,Redis 增加了哨兵模式(Redis Sentinel),因为哨兵模式做到了可以监控主从服务器,并且提供主从节点故障转移的功能。
一致性Hash原理与实现 - 简书 (jianshu.com)
当 Redis 缓存数据量大到一台服务器无法缓存时,就需要使用 Redis 切片集群方案,它将数据分布在不同的服务器上,以此来降低系统对单主节点的依赖,从而提高 Redis 服务的读写性能。
从上图中,我们需要查询的是图product.png
,由于我们有6台主服务器,所以计算的公式为:hash(product.png) % 6 = 5
, 我们就可以定位到是5号主从,这们就省去了遍历所有服务器的时间,从而大大提升了性能。
在上述hash取模的过程中,我们虽然不需要对所有Redis服务器进行遍历而提升了性能。但是,使用Hash算法缓存时会出现一些问题,Redis服务器变动时,所有缓存的位置都会发生改变
。
比如,现在我们的Redis缓存服务器增加到了8台,我们计算的公式从hash(product.png) % 6 = 5
变成了hash(product.png) % 8 = ?
结果肯定不是原来的5了。
再者,6台的服务器集群中,当某个主从群出现故障时,无法进行缓存,那我们需要把故障机器移除,所以取模数又会从6变成了5。我们计算的公式也会变化。
由于上面hash算法是使用取模来进行缓存的,为了规避上述情况,Hash一致性算法就诞生了。
一致性hash算法
一致性Hash算法使用取模的方法,不过,与对服务器的数量进行取模不同,一致性Hash算法是对2的32方
取模。即,一致性Hash算法将整个Hash空间组织成一个虚拟的圆环,Hash函数的值空间为0 ~ 2^32 - 1(一个32位无符号整型)
。
将数据Key使用相同的函数Hash计算出哈希值,并确定此数据在环上的位置,从此位置沿环顺时针查找,遇到的服务器就是其应该定位到的服务器。
一致性Hash算法的容错性和可扩展性
假设某节点宕机了,多数其他节点不会受到影响,只有该节点中的对象被重新定位到顺时针下一个节点。
假如系统新增了一台服务器,也只需要重定位这个节点之前的数据即可。
总结:一致性Hash算法对于节点的增减都只需重定位环空间中的一小部分数据,有很好的容错性和可扩展性。
数据倾斜问题-虚拟节点
在一致性Hash算法服务节点太少的情况下,容易因为节点分布不均匀面造成数据倾斜(被缓存的对象大部分缓存在某一台服务器上)问题
。
为了解决数据倾斜问题,一致性Hash算法引入了虚拟节点机制
,即对每一个服务器节点计算多个哈希,每个计算结果位置都放置一个此服务节点,称为虚拟节点。
数据定位算法不变,只需要增加一步:虚拟节点到实际点的映射。
所以加入虚拟节点之后,即使在服务节点很少的情况下,也能做到数据的均匀分布。
什么是脑裂
由于网络问题,集群节点之间失去联系,此时旧主节点还在接收数据,但已经选举出了新主节点。等网络恢复,旧主节点会降级为从节点,再与新主节点进行同步复制的时候,由于会从节点会清空自己的缓冲区,所以导致之前客户端写入的数据丢失了。
解决方案
当主节点发现从节点下线或者通信超时的总数量小于阈值时,那么禁止主节点进行写数据,直接把错误返回给客户端。
在 Redis 的配置文件中有两个参数我们可以设置:
我们可以把 min-slaves-to-write 和 min-slaves-max-lag 这两个配置项搭配起来使用,分别给它们设置一定的阈值,假设为 N 和 T。
这两个配置项组合后的要求是,主库连接的从库中至少有 N 个从库,和主库进行数据复制时的消息延迟不能超过 T 秒,否则,主库就不会再接收客户端的写请求了。
即使原主库是假故障,它在假故障期间也无法响应哨兵心跳,也不能和从库进行同步,自然也就无法和从库进行 ACK 确认了。这样一来,min-slaves-to-write 和 min-slaves-max-lag 的组合要求就无法得到满足,原主库就会被限制接收客户端写请求,客户端也就不能在原主库中写入新数据了。
等到新主库上线时,就只有新主库能接收和处理客户端请求,此时,新写的数据会被直接写到新主库中。而原主库会被哨兵降为从库,即使它的数据被清空了,也不会有新数据丢失。
Redis分布式锁(图解 - 秒懂 - 史上最全)_架构师-尼恩的博客-CSDN博客_redissession分布式锁
① 跨JVM的线程安全问题
在同一个JVM内部,大家往往采用synchronized或者Lock的方式来解决多线程间的安全问题。但在分布式集群工作的开发场景中,在JVM之间,就需要使用分布式锁,处理跨JVM进程之间的线程安全问题。
比如说在一个分布式系统中,多台机器上部署了多个服务,当客户端一个用户发起一个数据插入请求时,如果没有分布式锁机制保证,那么那多台机器上的多个服务可能进行并发插入操作,导致数据重复插入,对于某些不允许有多余数据的业务来说,这就会造成问题。而分布式锁机制就是为了解决类似这类问题,保证多个服务之间互斥的访问共享资源,如果一个服务抢占了分布式锁,其他服务没获取到锁,就不进行后续操作。
② 分布式锁
分布式锁的条件:
分布式锁的实现:
① 特点
② 实现
Redis 的 SET 命令有个 NX 参数可以实现「key不存在才插入」,所以可以用它来实现分布式锁:
基于 Redis 节点实现分布式锁时,对于加锁操作,我们需要满足三个条件。
SET lock_key unique_value NX PX 10000
解锁的过程就是将 lock_key 键删除(del lock_key),但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。
可以看到,解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。
基于 Redis 实现分布式锁的缺点:
超时时间不好设置
超时时间过长,会影响性能,超时时间过短保护不到共享资源。
Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。
为了保证集群环境下分布式锁的可靠性,Redis 官方已经设计了一个分布式锁算法 Redlock(红锁)。
Redlock 算法加锁三个过程:
加锁成功要同时满足两个条件:有超过半数的 Redis 节点成功的获取到了锁,并且总耗时没有超过锁的有效时间。
加锁成功后,客户端需要重新计算这把锁的有效时间,计算的结果是「锁的最初有效时间」减去「客户端为获取锁的总耗时」。
加锁失败后,客户端向所有 Redis 节点发起释放锁的操作,释放锁的操作和在单节点上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。
每当我们对一个 key 设置了过期时间时,Redis 会把该 key 带上过期时间存储到一个过期字典中,「过期字典」保存了数据库中所有 key 的过期时间。
当我们查询一个 key 时,Redis 首先检查该 key 是否存在于过期字典中:
Redis 使用的过期删除策略是「惰性删除+定期删除」这两种策略配和使用。
① 惰性删除
key过期了不会删除,等到用户使用该数据,发现过期了,再进行删除。
优点:对CPU消耗最小,只有在访问key的时候才检查key是否过期,基本不消耗CPU性能。
缺点:由于只有在访问的时候才检查key是否过期,如果不访问则key过期了也会一直存在,所以会占用大量内存。
② 定期删除
优点:减少了CPU的消耗,同时也能释放内存中占用的过期key
缺点:不好决定定期删除的频率,如果频繁太快则定期任务将会消耗过多的CPU资源,同样可能影响Redis服务的稳定性。如果频率太慢,在超时的客户端连接数较多或过期key较多等情况下,资源无法被及时整理回收,可能影响Redis服务的性能甚至导致服务崩溃。
③ 定时删除
给key添加一个定时器,时间到了删除。
优点:能及时清理过期的key
缺点:消耗过多的CPU资源
Redis 采用的是惰性删除和定期删除两种方式来删除过期的key。
Redis 持久化文件有两种格式:RDB和 AOF。
RDB 文件分为两个阶段,RDB 文件生成阶段和加载阶段。
RDB 文件生成阶段:从内存状态持久化成 RDB(文件)的时候,会对 key 进行过期检查,过期的键「不会」被保存到新的 RDB 文件中,因此 Redis 中的过期键不会对生成新 RDB 文件产生任何影响。
RDB 加载阶段
:RDB 加载阶段时,要看服务器是主服务器还是从服务器,分别对应以下两种情况:
AOF 文件分为两个阶段,AOF 文件写入阶段和 AOF 重写阶段。
当 Redis 运行在主从模式下时,从库不会进行过期扫描,从库对过期的处理是被动的。也就是即使从库中的 key 过期了,如果有客户端访问从库时,依然可以得到 key 对应的值,像未过期的键值对一样返回。
从库的过期键处理依靠主服务器控制,主库在 key 到期时,会在 AOF 文件里增加一条 del 指令,同步到所有的从库,从库通过执行这条 del 指令来删除过期的 key。
在 Redis 的运行内存达到了某个阀值,就会触发内存淘汰机制,这个阀值就是我们设置的最大运行内存,此值在 Redis 的配置文件中可以找到,配置项为 maxmemory。
Redis 内存淘汰策略共有八种,这八种策略大体分为「不进行数据淘汰」和「进行数据淘汰」两类策略。
1、不进行数据淘汰的策略
noeviction(Redis3.0之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,而是不再提供服务,直接返回错误。
2、进行数据淘汰的策略
针对「进行数据淘汰」这一类策略,又可以细分为「在设置了过期时间的数据中进行淘汰」和「在所有数据范围内进行淘汰」这两类策略。
在设置了过期时间的数据中进行淘汰:
在所有数据范围内进行淘汰:
使用策略规则:
1、如果数据呈现幂律分布,也就是一部分数据访问频率高,一部分数据访问频率低,则使用lru或lfu
2、如果数据呈现平等分布,也就是所有的数据访问频率都相同,则使用allkeys-random
① LRU
最久未使用。
传统 LRU 算法的实现是基于「链表」结构,最新操作的键会被移动到表头,当需要内存淘汰时,删除链表尾部的元素。
缺点:
- 需要用链表管理所有的缓存数据,额外的空间开销;
- 如果有大量数据被访问,会有很多链表移动操作,会很耗时,进而降低 Redis 缓存性能。
Redis 实现的是一种近似 LRU 算法,它的实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间。
当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 5 个值(此值可配置),然后淘汰最久没有使用的那个。
Redis 实现的 LRU 算法的优点:
但是, LRU 算法无法解决缓存污染问题,比如应用一次读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在 Redis 缓存中很长一段时间,造成缓存污染。因此,在 Redis 4.0 之后引入了 LFU 算法来解决这个问题。
② LFU
最近最少使用。
LFU 算法相比于 LRU 算法的实现,多记录了「数据的访问频次」的信息。淘汰时先按频次排序,再按时间排序。
在 LRU 算法中,Redis 对象头的 24 bits 的 lru 字段是用来记录 key 的访问时间戳,因此在 LRU 模式下,Redis可以根据对象头中的 lru 字段记录的值,来比较最后一次 key 的访问时间长,从而淘汰最久未被使用的 key。
在 LFU 算法中,Redis对象头的 24 bits 的 lru 字段被分成两段来存储,高 16bit 存储 ldt(Last Decrement Time),用来记录 key 的访问时间戳;低 8bit 存储 logc(Logistic Counter),用来记录 key 的访问频次。
使用keys指令可以扫出指定模式的key列表。
如果这个redis正在给线上的业务提供服务,那使用keys指令会有什么问题?
redis的单线程的。keys指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。这个时候可以使用scan指令,scan指令可以无阻塞的提取出指定模式的key列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用keys指令长。
当salve发现自己的master变为FAIL状态时,尝试进行Failover(故障切换)
当存在多个slave的时候,就需要竞争成为master,过程如下
(1) slave发现自己的master变为FAIL
(2) 将自己记录的集群currentEpock加一,并广播FAILOVER_AUTH_REQUEST信息
(3) 其他小集群的master会相应slave的广播消息,首先判断请求者的合法性,并发送FAILOVER_AUTH_ACK,对每一个epoch只发送一次ack
(4) 尝试failover的slave收集master返回的FAILOVER_AUTH_ACK
(5) Slave受到超过半数的master的ack就成为新的master
(6) Slave广播ping消息通知其他集群节点
Slave节点不是主节点已进入FAIL状态就马上尝试发起选举,而是有一定的延时,一定的延时确保等待FAIL状态在集群中传播。Slave如果立即尝试选举,其它的masters或许尚未意识到FAIL状态,可能会拒绝投票。
集群为什么至少三个节点而且推荐节点数为奇数
因为选举需要收到大于master节点总数的一半,若只有两个master节点,其中一个挂了,是选举不出来新的master的
奇数个master节点是为了满足上述要求并且节省资源,三个master节点与四个master节点,当大家都挂了一个master的时候,都能进行选举,当挂了两个的是否都选举不了。
sentinel(哨兵) 会以每秒一次的频率向主从和其他sentienl发送ping命令,当超过设置的down-after-milliseconds后未收到的有效的响应会认为该master主观下线,接着向其他sentiel进行确认,接收到足够数量的后,sentinel会判定该服务器客观下线。
当一个master服务器被sentinel认为下线状态后,sentinel会与其余的sentinel协商选出的leader进行故障转移,每个发现master服务器进入下线的sentinel都可以要求其他sentinel选自己为sentinel的leader,选举是先到先得,同是每个sentinel每次选举都会自增配置纪元(选举周期),每个纪元中只有选择一个sentinel的leader.如果所有超过一半的sentinel选举其中一个sentinel作为leader.之后该sentinel作为leader进行故障转移操作。(选举出的slave会执行slaveof no one成为主节点,向其他slave发送命令其成为新的master的slave,监视旧的master恢复后成为新的master的slave并进行同步)
Jedis 和 Redisson 都是Java中对Redis操作的封装。Jedis 只是简单的封装了 Redis 的API库,可以看作是Redis客户端,它的方法和Redis 的命令很类似。Redisson 不仅封装了 redis ,还封装了对更多数据结构的支持,以及锁等功能,相比于Jedis 更加大。但Jedis相比于Redisson 更原生一些,更灵活。
1、缩短键值的长度
缩短值的长度才是关键,如果值是一个大的业务对象,可以将对象序列化成二进制数组;
首先应该在业务上进行精简,去掉不必要的属性,避免存储一些没用的数据;
其次是序列化的工具选择上,应该选择更高效的序列化工具来降低字节数组大小;
以JAVA为例,内置的序列化方式无论从速度还是压缩比都不尽如人意,这时可以选择更高效的序列化工具,如: protostuff,kryo等
2、共享对象池
对象共享池指Redis内部维护[0-9999]的整数对象池。创建大量的整数类型redisObject存在内存开销,每个redisObject内部结构至少占16字节,甚至超过了整数自身空间消耗。所以Redis内存维护一个[0-9999]的整数对象池,用于节约内存。 除了整数值对象,其他类型如list,hash,set,zset内部元素也可以使用整数对象池。因此开发中在满足需求的前提下,尽量使用整数对象以节省内存。
3、字符串优化
4、编码优化
5、控制key的数量