Redis有哪些应用场景
- 热点数据的缓存
- 限时业务的运用:expire
- 计数器相关:incrby原子自增
- 分布式锁:setnx
Redis 5种基本数据类型
数据结构都是针对value来说的,key都是string,value可以放不同的类型。
-
String 字符串:key都是String,但value可以是String,int,float。
-
List 列表:列表内部元素都是String。有双向列表功能。
-
Hash 散列:value有field和value。适合用来存对象,可以将对象中的每个字段独立存储,并能对单个字段做crud
-
Set 集合:无序。不可重复。查找快。支持交并差集操作。存的元素也是字符串类型。
-
Sorted Set/zset 有序集合。每个元素都带有一个score字段(很像一个hash),基于score进行排序,底层用跳表+hash表实现。
Redis 3种特殊数据类型
- HyperLogLog(基数估计):可做UV统计
- Bitmap (位存储):可做签到统计、大数排序
- Geospatial (地理位置)
Redis 数据类型的6种底层数据结构
6种底层数据结构。
- 简单动态字符串 - sds
- 整数集 - IntSet
- 字典/哈希表 - Dict
- 压缩列表 - ZipList
- 快表 - QuickList
- 跳表 - ZSkipList
Redis一个String类型的值能存储多大的数据?
512M
SDS 简单动态字符串
底层为C语言的结构体
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[];
};
- len :字符串长度的,不包含‘\0’
- alloc:buf申请的大小,不包含‘\0’
- flag:标识不同的sds头类型
- buf:字符数组,存储字符串
为什么要有sds?为什么不使用C语言字符串实现,而是使用SDS呢?这样实现有什么好处?
-
常数时间获取字符串长度
-
二进制安全(不像C语言的那样靠‘\0’判断结束,所以可以存储特殊的字符)
-
支持动态扩容,杜绝缓冲区溢出
-
减少修改字符串的内存分配次数
- 空间预分配,追加字符串时
- 如果新字符串小于1M,则新空间为扩展后字符串长度的两倍+1;
- 如果新字符串大于1M,则新空间为扩展后字符串长度+1M+1。
- 懒惰空间释放,删除部分字符时不立刻使用内存重新分配来回收缩短后的多余字节,而是改变alloc,预留给后续使用
- 空间预分配,追加字符串时
-
能重用部分<string.h>函数
IntSet 整数集
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
- encoding:编码方式,取值有三个:INTSET_ENC_INT16, INTSET_ENC_INT32, INTSET_ENC_INT64
- length:存储的整数的个数
- contents:存储整数元素的数组,各个元素在数组中按值的大小从小到大有序排序,且数组中不能有重复项。(虽然 intset 结构将 contents 属性声明为 int8_t 类型的数组,但实际上 contents 数组并不保存任何 int8_t 类型的值,contents 数组的真正类型取决于 encoding 属性的值)
整数集合的编码升级
当在一个int16类型的整数集合中插入一个int32类型的值,整个集合的所有元素都会转换成32类型。
- 升级编码为INTSET_ENC_INT32,每个整数占4字节,并按照新的编码方式及元素个数扩容数组
- 倒序将数组中旧的元素拷贝到对应位置,(倒序可以防止覆盖元素)。插入的待插入的元素,这个位置是二分查找来找的,会保持有序性。
- 改变encoding的值,length+1。
没有编码降级,减少开销。
Dict 字典/哈希表
本质上就是哈希表,跟hashmap非常像。大的区别就是还多了个Dictht,有两条hashtable。
dict 的扩容机制
Dict在每次新增键值对时都会检查负载因子(LoadFactor = used/size) ,满足以下两种情况的任意一种时会触发哈希表扩容:
- LoadFactor >= 1,并且服务器没有执行 BGSAVE 或者 BGREWRITEAOF 等后台进程;
- LoadFactor > 5 ,不管那么多,必须扩容;
dict 的收缩机制
每次删除元素时,会检测LoadFactor ,若LoadFactor < 0.1,就收缩。
dict 渐进式 rehash
/* return DICT_ERR if expand was not performed */
int dictExpand(dict *d, unsigned long size) {
return _dictExpand(d, size, NULL);
}
/* Expand or create the hash table,
* when malloc_failed is non-NULL, it'll avoid panic if malloc fails (in which case it'll be set to 1).
* Returns DICT_OK if expand was performed, and DICT_ERR if skipped. */
int _dictExpand(dict *d, unsigned long size, int* malloc_failed)
{
if (malloc_failed) *malloc_failed = 0;
/* the size is invalid if it is smaller than the number of
* elements already inside the hash table */
if (dictIsRehashing(d) || d->ht[0].used > size)
return DICT_ERR;
dictht n; /* the new hash table */ //新建hash表
unsigned long realsize = _dictNextPower(size);
/* Detect overflows */
if (realsize < size || realsize * sizeof(dictEntry*) < realsize)
return DICT_ERR;
/* Rehashing to the same table size is not useful. */
if (realsize == d->ht[0].size) return DICT_ERR;
/* Allocate the new hash table and initialize all pointers to NULL */
n.size = realsize;
n.sizemask = realsize-1;
if (malloc_failed) {
n.table = ztrycalloc(realsize*sizeof(dictEntry*));
*malloc_failed = n.table == NULL;
if (*malloc_failed)
return DICT_ERR;
} else
n.table = zcalloc(realsize*sizeof(dictEntry*));
n.used = 0;
/* Is this the first initialization? If so it's not really a rehashing
* we just set the first hash table so that it can accept keys. */
if (d->ht[0].table == NULL) {
d->ht[0] = n;
return DICT_OK;
}
/* Prepare a second hash table for incremental rehashing */
d->ht[1] = n;
d->rehashidx = 0;
return DICT_OK;
}
不管是扩容还是收缩,都会调用dictExpand,都必定会创建新的哈希表,导致哈希表的size和sizemask变化,而key的查询与sizemask有关。因此必须对哈希表中的每一个key重新计算索引,插入新的哈希表,这个过程称为rehash。
- 计算新hash表的realsize,值取决于当前要做的是扩容还是收缩:
- 如果是扩容,则新size为第一个大于等于dict.ht[0].used + 1的2^n
- 如果是收缩,则新size为第一个大于等于dict.ht[0].used的2^n (不得小于4)
- 按照新的realeSize申请内存空间,创建dictht,并赋值给dict.ht[1]
- 设置dict.rehashidx = 0,表示开始rehash
- 将dict.ht[0]中的每一个dictEntry都rehash到dict.ht[1]
- 将dict.ht[1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存
- 将rehashidx赋值为-1,表示rehash结束。
dict的渐进式rehash:
将dict.ht[0]中的每一个dictEntry都rehash到dict.ht[1]
并不是一次性完成,而是在每次增删改查时,都检查一下dict.rehashidx是否大于-1,如果是则将dict.ht[0].table[rehashidx]的entry链表rehash到dict.ht[1],并且将rehashidx++。每次有增删改查操作只会rehash一个角标的元素/链,直至dict.ht[0]的所有数据都rehash到dict.ht[1],才会把dict.ht[1]赋给dict.ht[0],dict.ht[1]赋为空,释放原来的dict.ht[0]的内存。
在rehash过程中,新增操作,则直接写入ht[1],查询、修改和删除则会在dict.ht[0]和dict.ht[1]依次查找并执行(同一个entry只能在其中一个表中,第一个表没找到,就去第二个表上找)。这样可以确保ht[0]的数据只减不增,随着rehash最终为空。
ZipList 压缩列表
-
一种特殊的“双端链表”(本质不是链表),经过特殊编码,是连续的内存块。
-
支持在两端进行pop/push操作,时复O(1)。
-
节点之间不通过指针连接,而是记录上一个节点长度和当前节点的长度,实现寻址,内存占用较低,而且不会产生内存碎片。
-
如果列表太长,会影响性能。
-
可能会出现连锁更新问题,影响性能,不过发生概率很低。
属性 | 类型 | 长度 | 用途 |
---|---|---|---|
zlbytes | uint32_t | 4 字节 | 记录整个压缩列表占用的内存字节数 |
zltail | uint32_t | 4 字节 | 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过这个偏移量,可以确定表尾节点的地址。 |
zllen | uint16_t | 2 字节 | 记录了压缩列表包含的节点数量。 最大值为UINT16_MAX - 1 =65534,如果超过65534,此处会记录为UINT16_MAX=65535,但节点的真实数量需要遍历整个压缩列表才能计算得出。 |
entry | 列表节点 | 不定 | 压缩列表包含的各个节点,节点的长度由节点保存的内容决定。 |
zlend | uint8_t | 1 字节 | 特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。 |
ZipListEntry
-
prevlen:前一节点的长度,占1个或5个字节。
- 如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
- 如果前一节点的长度大于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据
-
encoding:编码属性,记录content的数据类型(字符串还是整数)以及长度,占用1个、2个或5个字节
-
entry-data:负责保存节点的数据,可以是字符串或整数
ZipList中所有存储长度的数值均采用小端字节序,即低位字节在前,高位字节在后。例如:数值0x1234,采用小端字节序后实际存储值为:0x3412
encoding
分为字符串和整数两种:
- 字符串:“00”、“01”或者“10”开头
|00pppppp| | 1 bytes | <= 63 bytes |
---|---|---|
|01pppppp|qqqqqqqq| | 2 bytes | <= 16383 bytes |
|10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt| | 5 bytes | <= 4294967295 bytes |
例如,我们要保存字符串:“ab”和 “bc”
- 整数:“11”开头
编码 | 编码长度 | 整数类型 |
---|---|---|
11000000 | 1 | int16_t(2 bytes) |
11010000 | 1 | int32_t(4 bytes) |
11100000 | 1 | int64_t(8 bytes) |
11110000 | 1 | 24位有符整数(3 bytes) |
11111110 | 1 | 8位有符整数(1 bytes) |
1111xxxx | 1 | 直接在xxxx位置保存数值,范围从0001~1101(1-13),但实际代表存的数字对应为(0-12) |
为什么ZipList省内存
- 连续内存空间,不产生内存碎片。
- 普通list或数组的每个元素是定长的(预留空间),而ZipList增加了encoding字段细化存储大小,使得列表更紧凑。
- 但插入和删除的时复平均为O(n),时间换空间。
ZipList缺点
- 没有空间预留,移除节点后立刻缩容,每次增删操作都会进行数据的移动和内存分配。
- 可能会出现连锁更新问题,但概率不大:
- ZipList里要恰好有多个连续的、长度介于
250
字节至253
字节之间的节点, 连锁更新才有可能被引发; - 即使出现连锁更新, 但只要被更新的节点不多, 性能影响非常非常小。
- ZipList里要恰好有多个连续的、长度介于
QuickList 快表
ZipList需要连续的内存空间,比较大的ZipList申请这么大块的连续内存的效率低(内存中大块的连续内存不多,不容易申请,没有的话得等操作系统回收资源)。**QuickList是一个双端链表,每个节点都是ZipList(LinkedList+ZipList),**可以限制单个ZipList的长度或内存大小,创建多个ZipList来分片存储数据。
QuickList的特点:
- 是一个节点为ZipList的双端链表
- 节点采用ZipList,解决了传统链表的内存占用问题
- 控制了ZipList大小,解决连续内存空间申请效率问题
- 中间节点可以压缩(节点之间连续存储),进一步节省了内存
SkipList 跳表
本质是个链表,建立了多级指针(跨度不同的指针)的机制,提高遍历查找的效率。同时元素是根据score升序排列的,像Innodb的B+树索引一样,以score构建索引,多级指针的机制相当于建立了索引。
SkipList的特点:
- 跳表是一个双向链表,每个节点都包含score和ele值
- 节点按照score值排序,score值一样则按照ele字典排序
- 每个节点都可以包含多层指针,层数是1到32之间的随机数,最多可以构建32级指针。
- 不同层指针到下一个节点的跨度不同,层级越高,跨度越大
- 空间换时间,增删改查效率与红黑树基本一致,都能达到O(logN)时间复杂度,实现却更简单。
RedisObject
Redis的每种对象其实都由对象结构(RedisObject) 与 对应编码的数据结构组合而成,而每种对象类型对应若干编码方式,不同的编码方式所对应的底层数据结构是不同的。
string 对象
3种编码方式:
- raw:保存长度大于44字节的字符串,ptr指向sds。(44字节是字符串长度,不是sds大小)存储字符串的大小上限为512M。
- embstr:
- 保存长度小于或等于44字节的字符串,对象头和sds为连续的内存空间,只需一次内存分配,提高分配效率。
- 且大小刚好为64字节,刚好满足Redis分配空间片为2^n的大小,不产生碎片。
- 如果字符串的长度增加需要重新分配内存时,整个RedisObject和sds都需要重新分配空间,因此Redis中的embstr实现为只读。
- 在对embstr对象进行修改时,无论是否达到了44个字节,都会先转化为raw再进行修改,修改后的对象一定是raw的。
- int:存储Long类型的数字,数值直接存储在ptr指针位置(刚好8字节),不需要sds。当 int 编码保存的值不再是整数,或大小超过了long的范围时,自动转化为raw。
List 对象
QuickList实现
Set 对象
编码为HT或INTSET,底层结构为Dict或IntSet。
- Dict中的key用来存储元素,value统一为null。
- 若存储的数据全部都为整数,并且元素数量不超过set-max-intset-entries(默认512)时,Set会采用IntSet编码,以节省内存。若中途一旦出现非整数类型元素,或数量超过512,就会立刻转换编码为HT。只会“升级”,不会从HT转为IntSet“降级”。
ZSet对象
- Dict+SkipList:小孩子才做选择,大人全都要。
- 优点:很完善,既能通过member快速查找到score、满足元素不重复(dict的作用),又能根据score排序(SkipList的作用)
- 缺点:比较占内存。
- 虽然底层是两个结构,但编码只有一个,以SkipList来表示。
-
ZipList:当元素数量不多时,HT和SkipList的速度优势不明显,而且更耗内存,因此zset还会采用ZipList结构来节省内存。不过需要同时满足两个条件:
- 元素数量小于zset_max_ziplist_entries,默认值128
- 每个元素都小于zset_max_ziplist_value字节,默认值64字节
默认是ZipList,若中途不满足条件,就会升级为Dict+SkipList。
ZipList本身没有排序功能,也没有键值对的概念,只能通过业务代码来模拟实现:
- ZipList是连续内存,因此score和element是紧挨在一起的两个entry, element在前,score在后
- score越小越接近队首,score越大越接近队尾,按照score值升序排列
Hash 对象
Hash底层采用的编码与Zset也基本一致,只需要把排序有关的SkipList去掉即可。
-
Dict
-
ZipList:Hash结构默认采用ZipList编码,用以节省内存。 ZipList中相邻的两个entry 分别保存field和value。
当数据量较大时,Hash结构会转为HT编码,也就是Dict,触发条件有两个:
- ZipList中的元素数量超过了hash-max-ziplist-entries(默认512)
- ZipList中的任意entry大小超过了hash-max-ziplist-value(默认64字节)
hyperloglog 底层数据结构
HyperLogLog是一种用于统计唯一元素数量的数据结构。它使用了一种称为基数估计的算法,可以通过有限的内存使用最小的误差来统计唯一元素的数量。
HyperLogLog的底层数据结构是一个位数组,其中每个元素对应一个哈希函数的输出。每个哈希函数输出的值表示该元素在哈希函数的输出中的最高位数。
HyperLogLog算法通过使用标准的平方误差估计公式来统计唯一元素的数量。这种方法在统计唯一元素数量方面具有很高的准确性,同时使用的内存非常小。因此,HyperLogLog是用于大数据集合中统计唯一元素数量的理想选择。
Redis 过期键删除策略
Redis如何知道一个key是否过期?
利用两个dict来分别记录key-value和key-ttl(设置了过期时间才会在expires里有)。
Redis 过期键的删除策略有哪些?
-
单节点模式下:不能立即删除,不然开销很大。
- 惰性删除:等访问一个key的时候,判断是否过期,若过期,则执行删除。存在问题就是若永不访问,则永不删除。
- 定期删除:通过定时任务,定期删除一部分过期key。
- Redis服务初始化函数initServer()中设置定时任务,按照server.hz的频率来执行过期key清理,为SLOW模式,执行频率默认为10,清理耗时每次不超过25ms。
- Redis的每个事件循环前会调用beforeSleep()函数,执行过期key清理,为FAST模式,执行频率不固定,但两次间隔不低于2ms,清理耗时每次不超过1ms。
-
集群模式下:
在主从同步场景下,为了主从节点的数据一致性,从节点不会主动删除数据,而是由主节点控制从节点中过期数据的删除。由于主节点的惰性删除和定期删除策略,都不能保证主节点及时对过期数据执行删除操作,因此,当客户端通过从节点读取数据时,增加了对数据是否过期的判断,如果该数据已过期,则不返回给客户端。
Redis 内存淘汰策略
内存淘汰:当内存使用达到设置的上限时,主动挑选部分key删除以释放更多内存。Redis每次执行任何操作命令都会检查是否要做内存淘汰。
8种策略:
- noeviction: 不淘汰任何key,但是内存满时不允许写入新数据。(默认)
- volatile-ttl: 对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰
- allkeys-random:对全体key ,随机进行淘汰。也就是直接从db->dict中随机挑选
- volatile-random:对设置了TTL的key ,随机进行淘汰。也就是从db->expires中随机挑选。
- allkeys-lru: 对全体key,基于LRU算法进行淘汰
- volatile-lru: 对设置了TTL的key,基于LRU算法进行淘汰
- allkeys-lfu: 对全体key,基于LFU算法进行淘汰
- volatile-lfu: 对设置了TTL的key,基于LFU算法进行淘汰
比较容易混淆的有两个:
- LRU(Least Recently Used),最少最近使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。
- LFU(Least Frequently Used),最少频率使用(最近最少使用)。会统计每个key的访问频率,值越小淘汰优先级越高。
每个Redisobject对象都会有lru标记标量。
LFU的访问次数之所以叫做逻辑访问次数(不是真实的访问次数),是因为并不是每次key被访问都计数,而是通过运算:
- 生成0~1之间的随机数R
- 计算 (旧次数 * lfu_log_factor + 1),记录为P
- 如果 R < P ,则计数器 + 1,且最大不超过255
- 访问次数会随时间衰减,距离上一次访问时间每隔 lfu_decay_time 分钟,计数器 -1
这个算法能够保证随着访问次数的增加,逻辑访问次数也会增加。
Redis是单线程还是多线程?
- 从核心业务(操作命令的执行)部分看,是单线程。内存操作执行速度很快,多线程执行不能带来性能的巨大提升,线程间的上下文切换也很耗时间,而且还会出现线程安全问题,增大系统的设计复杂度,线程安全也会造成性能损失。
- 从整个Redis来看,是多线程。多线程主要集中于:
- Redis v4.0:引入多线程异步处理一些耗时较久的任务(清理脏数据、无用连接的释放、大 key 的删除,bgsave,bgrewriteaof等),例如异步删除命令unlink
- Redis v6.0:在核心网络模型中引入多线程,进一步提高对于多核CPU的利用率。因为Redis基于内存操作,执行速度非常快,性能瓶颈在于网络延迟。v6.0以前的网络模型是单线程的IO多路复用,实现单线程监听多个套接字。
Redis持久化机制有哪些?
- RDB :把数据库的数据生成快照保存到硬盘上。
- AOF:每一个写操作执行后,都会将命令记录到磁盘中的AOF文件中,属于写后日志,先写内存,再记录日志。
RDB的触发方式有哪些?
-
手动触发:
- save命令:主进程执行RDB,会阻塞其他所有进程。数据较大时会长时间阻塞服务器,不建议使用。
- bgsave命令(后台save):主进程执行fork操作开启子进程执行RDB,不影响主进程。只在fork阶段阻塞,一般时间非常短。
-
自动触发:
- 停机shutdown或重启reload时:若没开启AOF,就会触发bgsave。
- 主从同步时,主节点进行全量同步,会触发bgsave,生成快照发送给从节点。
- Redis.conf中的触发条件:
save m n
m秒内有n个key被修改就触发bgsave。
# 900秒内,如果至少有1个key被修改,则执行bgsave , 如果是save "" 则表示禁用RDB save 900 1 save 300 10 save 60 10000
RDB的fork原理和copy-on-write技术
-
fork原理:子进程拷贝主进程的页表(虚拟内存与物理内存的映射表),同时还会把共享的物理内存标记为只读(read-only),就可写新的RDB文件,替换旧的文件。
-
copy-on-write技术:
**数据比较大时,RDB过程(子进程)可能会持续比较长时间,而实际情况是这段时间Redis服务(主进程)一般都会收到数据写操作请求。那么如何保证数据一致性呢?**靠copy-on-write技术
- 当主进程是读操作,访问共享的物理内存即可。
- 当主进程是写操作,将要修改的数据拷贝一份,更改主进程的页表,在副本上进行写操作。
在进行RDB快照操作的这段时间,如果发生服务崩溃怎么办?
RDB快照操作会写新的RDB文件,此文件是临时文件,等全部写完后才能替换掉旧的RDB文件。如果中途宕机,仍能用旧的文件快速恢复,但新的数据就会丢失。
可以每秒做一次RDB快照吗?
不可以。
- RDB属于全量快照,频繁地将全量数据写入磁盘,会竞争有限的磁盘带宽,磁盘压力很大。
- fork会阻塞主进程,且主进程内存越大,阻塞时间越长,频繁地fork会频繁地阻塞主线程。
AOF 如何实现
开启AOF后,每执行一条更改数据库的命令后,会把该命令写入到内存中的server.aof_buf
缓冲区中,然后根据appendfsync
配置来决定用什么样的策略写回硬盘中的append.aof
。整个流程都是主进程完成。
AOF 3种写回刷盘策略
默认everysec
AOF 文件重写
一段时间内对同一个key的多次写操作,但只有最后一次写操作才有效,中间的命令就浪费了存储空间。通过执行bgrewriteaof
命令,可以对AOF文件执行重写功能,用最少的命令达到相同效果。
bgrewriteaof和bgsave一样fork主进程的aof文件和页表,fork时阻塞主进程,同时子进程还会有一个aof重写缓冲区,重写完aof文件后替换旧的文件。
Redis会在触发阈值时自动去重写AOF文件。阈值可以在Redis.conf中配置:
# AOF文件比上次文件 增长超过多少百分比则触发重写
auto-aof-rewrite-percentage 100
# AOF文件体积最小多大以上才触发重写
auto-aof-rewrite-min-size 64mb
AOF重写日志时,有新数据写入怎么办?
重写时有主和子两个进程,有新数据写入时会把新命令写入主进程的aof_buf缓冲区和子进程的aof重写缓冲区中(两个区都写入),等子进程重写完后会将子进程的aof重写缓冲区中的命令追加到新的aof文件后(此时也会阻塞主进程),然后替换旧的aof文件。
若写回策略是always,则新命令直接写回旧的aof文件,然后又写入子进程的aof重写缓冲区中,重写完后又追加,相当于新命令写入磁盘写了两次,有点浪费。
为什么AOF重写不复用原AOF日志?
- 两个进程对同一个文件进行读写,会产生竞争,影响主进程性能
- 若重写失败,则会污染原本的aof文件。
RDB 和 AOF 比较
Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble
开启),实际开发用这个最多:
RDB以一定频率执行,两次RDB之间用aof记录这期间的操作。
Redis 主从同步
- 全量同步:主从第一次建立连接,第一次同步。
- 增量同步:除了第一次全量同步外,大多数情况下都用增量同步。
全量同步的三个阶段是啥?
- 第一阶段:主从建立连接,协商该怎么同步,同步哪些数据。
- 主从建立连接,从库发送psync 命令请求数据同步,psync中包含了从库的replid和从库的同步进度offset。
- 主库根据replid和offset判断该怎么同步。若replid与主库的不一致,则要进行全量同步,主库会用 FULLRESYNC 响应命令带上主库的replid和主库的offset返回给从库。从库收到后保存这两个信息。
- 第二阶段:主库同步给从库。
3. 主库执行bgsave,生成RDB,同时记录生成RDB期间新执行的写操作命令到repl_buf中。
4. 发送RDB文件给从库。
5. 从库收到RDB后清空本地数据,然后加载RDB文件。 - 第三阶段:把生成RDB期间(不是整个阶段二)新执行的写操作发送给从库继续同步。
6. 发送完RDB后(不用等从库加载完RDB),主库把repl_buf中的命令发送给从库。
7. 从库收到后执行新增的命令。往后的同步就是增量同步。
两个概念: replication buffer
和 repl_backlog_buffer
replication buffer 是在全量同步阶段会出现,主库会给每个新连接的从库,分配一个replication buffer;
repl backlog buffer 是在增量同步阶段出现,一个主库只有一个repl_backlog_buffer,所有slave共用。
-
repl_backlog_buffer
:它是为了从库断开重连或者继续同步时,如何找到主从差异数据而设计的环形缓冲区,从而避免全量同步带来的性能开销。如果从库断开时间太久,repl_backlog_buffer环形缓冲区被主库的写命令覆盖了,那么从库连上主库后只能乖乖地进行一次全量同步,所以repl_backlog_buffer配置尽量大一些,可以降低主从断开后全量同步的概率。而在repl_backlog_buffer中找主从差异的数据后,如何发给从库呢?这就用到了replication buffer。(repl_backlog_buf仅仅用来找差异,同步需要的命令还得靠repl_buf来发送提供)
-
replication buffer
:Redis和客户端通信也好,和从库通信也好,Redis都需要给分配一个内存buffer进行数据交互,客户端是一个client,从库也是一个client,我们每个client连上Redis后,Redis都会分配一个client buffer,所有数据交互都是通过这个buffer进行的:Redis先把数据写到这个buffer中,然后再把buffer中的数据发到client socket中再通过网络发送出去,这样就完成了数据交互。所以主从在同步时,从库作为一个client,也会分配一个buffer,只不过这个buffer专门用来传播用户的写命令到从库,保证主从数据一致,我们通常把它叫做replication buffer。
增量同步的流程是啥?
增量同步主要是在全量同步之后的主从同步和主从网络断开后重新连接进行同步进行。大致流程如下:
- 请求同步,主库发现replid和自己的一样,进行增量同步。
- 根据主从的offset来选择要同步哪些操作,将offset之后的操作命令通过repl_buf发送给从库。若断开太久,repl_baklog 被“套圈”覆盖了,就要进行全量同步了。
每个从库会记录自己的offset,每个从库的同步进度也不一定相同。在和主库重连进行恢复时,从库会通过psync命令把自己记录的offset发给主库,主库会根据从库各自的复制进度,来决定这个从库可以进行增量复制,还是全量复制。
为什么全量同步使用RDB而不使用AOF?
- rdb体积小,网络传输快,从库还原rdb快。
- 若用aof就要打开aof,并选择写回策略,选择不当就会影响性能。
无磁盘复制
磁盘复制:在硬盘上创建rdb文件,然后又从磁盘中读取这个文件发送到slave的socket上。
无磁盘复制:master创建一个新进程直接dump RDB到slave的socket,不经过主进程,不经过硬盘。适用于disk较慢,并且网络较快的时候。配置repl-diskless-sync
来启用无磁盘复制。
主从同步优化
可以从以下几个方面来优化Redis主从集群:
- 在master中启用无磁盘复制,避免全量同步时的磁盘IO。
- 单节点上的内存占用不要太大,减少RDB导致的过多磁盘IO
- 适当提高repl_baklog的大小,发现slave宕机时尽快实现故障恢复,尽可能避免全量同步
- 限制一个master上的slave节点数量,如果实在是太多slave,则可以采用主-从-从链式结构,将主库生成 RDB 和传输 RDB 的压力,以级联的方式分散到从库上。
Redis哨兵机制
哨兵机制的作用是啥?
核心功能就是自动故障转移。
- 监控:不断监控每个节点是否正常。
- 自动故障转移:主节点故障后,会选举一个从节点为主节点。
- 通知:当故障发生时,会通知客户端。
- 配置提供者:客户端在初始化时,通过连接哨兵来获得主节点地址。
哨兵集群是通过什么方式组建的?(怎么和主库连接?)
基于发布订阅模式。
主节点(是reids主库,不是哨兵节点)有一个__sentinel__:hello
的频道,不同哨兵就是通过它来相互发现,实现互相通信的。每个哨兵都把自己的ip和端口都发布到该频道上,其他哨兵订阅了该频道,就能拿到ip和端口,就能互相通信。
哨兵是怎么监控集群的?(怎么跟从库连接?)
每个哨兵都向主节点发送INFO命令获取从节点列表,从而同每个节点建立连接。然后基于心跳机制监测节点状态,每隔1秒向集群的每个实例发送ping命令:
- 主观下线:某个哨兵发现某个节点未能在规定时间内响应,则该哨兵认为该节点主观下线。第一台认为主观下线的哨兵会向其他哨兵广播is-master-down-by-addr命令让他们判断节点状态。
- 客观下线:大于等于指定数量quorum(一般为哨兵总数的一半)的哨兵认为某个节点主观下线,则该节点客观下线。
(哨兵也需要和客户端连接,不然主从切换后客户端不知道主库是谁)
由哪个哨兵执行主从切换?
由第一个发现主观下线并且提议获得多数派投票的哨兵执行。
主库客观下线后怎么进行故障转移?
- 选举一个slave作为新的master,对其执行
replicaof no one
- 其他所有健康的slave都执行
replicaof 新master
- 修复故障节点后,执行
replicaof 新master
怎样选举哪个节点为master?
- 过滤掉不健康的slave。
- 根据slave的slave-priority值,越小优先级越高,选择优先级高的,如果是0则永不参与选举。
- 若优先级相同,选择offset大的,代表数据越新。
哨兵leader的选举机制是啥?
哨兵集群也是集群,也有leader(主哨兵),哨兵也会出现故障,当leader出现故障时,也会选举新的leader。
Raft算法:某个节点选举的票数大于等于num(sentinels)/2+1时,且拿到的票数要达到quorum,将成为master,如果都没有超过,则继续选举。
Redis 分片集群(Redis-cluster)
如果单机的数据很多,而且还开启了持久化的话就会导致主进程阻塞时间较长,所以可以将数据分片存储。
- 集群中有多个master,每个master保存不同数据
- 每个master都可以有多个slave节点
- master之间通过ping监测彼此健康状态
- 客户端请求可以访问集群任意节点,最终都会被转发到正确节点
- 能实现自动故障转移,也可手动转移
哈希槽(hash slot)
没有使用一致性哈希,而是使用哈希槽。
一共有16384个插槽(所以最多只能分片16384个),对应编号为0-16383,将每个master节点都等量映射到16384个插槽上。数据key不与节点绑定,而是与插槽绑定,根据key的有效部分利用CRC16算法得到hash值,再对16384取余得到对应的插槽值。(有效部分是key中“{}”中的内容,若没有”{}“,则整个key就是有效部分)。
若添加了新节点或删除了某个节点,插槽需要手动转移,用Redis-cli --cluster reshard host:port
命令。
failover 故障转移
当某个master出现故障就需要进行failover故障转移。
这种failover命令可以指定三种模式:
- 缺省:默认的流程,如图1~6步
- force:省略了对offset的一致性校验
- takeover:直接执行第5步,忽略数据一致性、忽略master状态和其它master的意见
Redis 客户端有哪些?
jedis、lettuce、Redisson(官方推荐)
Redis如何做大量数据插入?
pipline。
缓存穿透
不断请求缓存和数据库都不存在的数据,缓存永不命中,数据库查询压力大。
需要注意的是缓存穿透不是单个存在的问题,穿透、击穿、雪崩是可以一起存在的,所以真正写代码的话一个查询中是可以同时解决这些问题的。
解决方案:
-
接口层添加校验:
- 用户鉴权
- 增加id复杂度,避免被猜出规律
- 基础格式校验,id<=0直接拦截;对查询的key做正则规范匹配(因为我们的key肯定不是完全随机生成的,会有一定的格式规范),不符合规范的直接拦截,防恶意攻击。
- 热点参数限流
-
缓存空对象:数据库也查询不到的数据,就把key-null写入缓存中,并设置ttl(防止同一个id暴力攻击,但ttl不要太久)注意这个空对象不是null,而是空内容,写代码的时候要注意。
- 优点:实现简单,维护方便
- 缺点:
- 若请求大量不存在的数据,则会造成大量内存消耗
- 有ttl,若该id数据突然写入了数据库,可能造成短期不一致。
-
布隆过滤器:布隆过滤器类似hash set,能快速判断一个对象是否存在于集合中,底层是bitmap实现,使用k个哈希函数生成k个bitmap的下标,如果这k个bit都为1则认为存在,有一个不为1则不存在。在客户端与缓存之间加一层布隆过滤器,不存在的请求就直接拒接,存在的才放行。
- 优点:内存消耗少,无多余key
- 缺点:
- 实现复杂一些,但Redis中可用bitmap来实现布隆过滤器。
- 可能存在误判(布隆过滤器说不存在就是不存在,但说存在就不一定存在),且存入的元素越多,误判率越高。
- 删除困难。
缓存击穿
是比较少数的经常被访问的数据(缓存雪崩则是大量数据),就是一个被高并发访问并且缓存重建业务耗时较久的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。!!(不是热Key问题,但属于热key导致的雪崩问题)
解决方案:
-
接口限流与熔断,降级:重要的接口一定要做好限流策略,防止用户恶意刷接口,同时要降级准备,当接口中的某些服务不可用时候,进行熔断,失败快速返回机制。
-
互斥锁:第一个缓存未命中的线程获取互斥锁来查询数据库,实现缓存重构,其他线程则等待重试,第一个线程重构完后就释放锁。
-
逻辑过期(热点key的ttl永不过期):在value中加一个过期时间的字段,第一个查到已逻辑过期的线程获取互斥锁来开启新线程去查数据库,重构缓存,然后就可以返回旧数据,其他线程来的时候也是返回旧数据,新线程重构完后就释放锁,之后的线程就能拿到新数据。
对比:各有优缺点,根据业务场景需求来选择。
-
多级缓存:如果热key把redis打宕机了,流量打到db上,宕机上面的操作就实现不了,除了降级熔断之外,更优雅的解法就是多级缓存,这是一个缓存类问题的常用解法,不仅是热key问题可以用,一个缓存崩了就多加一层缓存。
缓存雪崩
大量缓存key同时失效或缓存宕机,大量请求打到数据库。
解决方案:
- 给不同key的ttl添加随机值,防止大量数据在同一时间过期。(没宕机的情况)
- 多级缓存。(很常用的思想,如果某一级缓存宕机了,这个方案就更重要了)
- 集群部署,提高可用性。
- 降级限流策略。
热点(热key)问题怎么解决?
热点问题是某些key太多人访问了,单台机器都抗不住流量,需要多台机器。
-
客户端或应用层加本地缓存,比如caffeine:仅需数据读取端做改造,数据写入端完全不需要改造。缺点也很明显:
- 需要各端自行实现,会增加应用层开发和维护成本。
- 会额外浪费各端的存储空间。
-
增加数据副本,分片保存。原始的热点Key叫做XXX_KEY,在数据写入的时候,用不同的Key重写10份,比如 XXX_KEY_01, XXX_KEY_02……XXX_KEY_10, 访问时在原始Key上随机拼接一个1-10的后缀,将请求打散。优点是数据读取端实现成本较低(也不是完全没有),但对数据写入端的要求比较高,不仅要写入多份,还需要考虑写入后一致性的问题。但一般来说读取端会很多且很分散,改造的成本会非常高,频繁变动更是不太可能,所以有些工作不得不放置到比较集中的端上。
-
再加一层缓存,这一层可以做成统一的数据访问层,对特定的Key加本地Cache,至于对哪些Key加本地Cache,中间层可以实时去分析近期请求热点数据,自行决定。其实最简单的方式就是开个LRU或者LFU的Cache。另外,像第二种增加数据副本的方案,也完全可以由中间层去实现。当我们发现有数据热点时,让中间层主动将热点数据复制,拦截并改写所有对热点数据的请求,将其分散开来。如果中间层更智能(热点探测机制),这些完全都可以实现自动化,从热点的发现到解决,完全不需要人参与。
Redis为什么使用网络IO多路复用?
- Redis是内存操作,命令执行非常快,主要性能瓶颈不在CPU,而是网络IO。
- 使用IO多路复用就可以做到单线程/进程监听所有请求,可以减少线程或进程的创建和切换开销,提高CPU的利用率,从而提高系统的整体性能。
Redis与Memcache的区别?(为什么用redis不用memcache?)
- redis更快更高效。redis是单线程的IO多路复用模型;Memcache是多线程的非阻塞IO模型。
- redis支持更丰富的数据类型,如string、set等,能支持更复杂的业务场景;Memcache只支持string数据类型。
- redis支持数据持久化;Memcache不支持持久化,只存在内存中。
- redis支持原生的cluster集群;Memcache不支持原生集群,需要依靠客户端来实现往集群中分片写入数据。
Redis怎么实现分布式锁?
核心原理/核心命令
SETNX key value #不存在key才能创建key
-
如果指定的 key 不存在,则创建并设置value,返回状态码 1,代表获得了锁;如果此时其他进程再次尝试创建时,由于 key 已经存在,则都会返回状态码 0 ,代表锁已经被占用,获取不到锁。
-
当获得锁的进程处理完成业务后,再通过
del
命令将该 key 删除,其他进程就可以再次竞争性地进行创建,获得该锁。为了避免死锁,我们会通过expire
命令来为锁设置TTL:
EXPIRE key seconds
我们将两者结合起来,并使用 Jedis 客户端来进行实现,其代码如下:
Long result = jedis.setnx("lockKey", "lockValue");
if (result == 1) {
// 如果此处程序被异常终止(如直接kill -9进程),则设置超时的操作就无法进行,该锁就会出现死锁
jedis.expire("lockKey", 3);
}
-
上面的代码存在原子性问题,即 setnx + expire 操作是非原子性的,如果在setnx与设置TTL中间程序被异常终止了,就会出现死锁。当然我们可以用lua脚本保障两条语句的原子性,但不够优雅。官方推荐直接在set命令中设置nx和TTL:
SET key value [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL] # EX :设置超时时间,单位是秒; # PX :设置超时时间,单位是毫秒; # NX :当且仅当对应的 Key 不存在时才进行设置; # XX:当且仅当对应的 Key 存在时才进行设置。
对应的jedis代码如下:
jedis.set("lockKey", "lockValue", SetParams.setParams().nx().ex(3));
一条指令也就保证了原子性。但仅靠这条指令还不足以实现一个高可用的分布式锁,还要考虑锁误删、可重入、重试、延长锁时效、集群模式下的主从一致性等问题。
锁误删
某个线程A的业务处理耗时过长,超过TTL,锁自动释放,此时另外一个线程B获得锁,线程A执行del就会删掉线程B的锁,出现锁误删问题。解决办法:
- 给锁添加唯一标识,如UUID + 线程ID。在删除锁前看看是不是自己线程的锁,是才删,不是就不删。
String identifier = UUID.randomUUID() + ":" + Thread.currentThread().getId();
jedis.set("LockKey", identifier, SetParams.setParams().nx().ex(3));
- 但这里是多条指令,又涉及到原子性问题,所以要靠lua脚本保障。
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
可重入
用一个变量来计数。所以就不能只是简单用string数据类型来存锁,需要用hash数据类型,key为锁名,feild为线程标识,value为重入次数。
Redisson
分布式锁的实现靠自己来写是很麻烦的,所以有了Redisson这个客户端组件帮我们实现。
Redisson的锁重试
如果trylock获取不到锁,不会立即返回false,而是在等待时间内不断重试,直到获取到锁或超时返回。
具体源码流程比较复杂,总的来说就是会有一个订阅(subscribeFuture)锁释放的异步等待和while(true)循环重试。
Redisson的watchdog机制(延长锁时效)
boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException;
// waitTime :等待获取锁的超时时间
// leaseTime :锁释放时间
Redisson的看门狗机制可以实现自动”续费“,实现方案是:在超时时间内,每隔1/3看门狗时间(lockWatchdogTimeout ,默认为30秒,可通过Config.setLockWatchdogTimeout设置,1/3也就是每隔10秒)就会去判断当前线程是否完成业务,如果没完成,就会递归调用自己实现自动”续费“到看门狗时间(30s)。这10s内如果任务完成了,会通知当前线程,然后立刻释放锁。
!!注意!! 看门狗机制只对没有设置了leaseTime的锁生效,如果设置了leaseTime,超过leaseTime后锁便自动解开了,不会延长锁的有效期。
高可用 redisson
在redis哨兵模式下,主从间的数据同步是会有延迟的,因此无法避免锁失效的问题。
想要实现高可用的分布式锁,需要采用 Redis 集群模式。
RedLock 红锁
为了实现集群模式下的分布式锁 ,Redis 提供了 RedLock 方案,假设我们有 N 个 Redis 实例,此时客户端的执行过程如下:
- 以毫秒为单位记录当前的时间,作为开始时间;
- 接着采用和单机版相同的方式,依次尝试在每个实例上创建锁。为了避免客户端长时间与某个故障的 Redis 节点通讯而导致阻塞,这里采用快速轮询的方式:假设创建锁时设置的超时时间为 10 秒,访问每个 Redis 实例的超时时间可能在 5 到 50 毫秒之间,如果在这个时间内还没有建立通信,则尝试连接下一个实例;
- 如果在至少 N/2+1 个实例上都成功创建了锁。并且
当前时间 - 开始时间 < 锁的超时时间
,则认为已经获取了锁,锁的有效时间等于超时时间 - 花费时间
(如果考虑不同 Redis 实例所在服务器的时钟漂移,则还需要减去时钟漂移); - 如果少于 N/2+1 个实例,则认为创建分布式锁失败,此时需要删除这些实例上已创建的锁,以便其他客户端进行创建。
- 该客户端在失败后,可以等待一个随机时间后重试。
可以看到redlock主要是由客户端来实现,并不真正涉及到 Redis 集群相关的功能。因此这里的 N 个 Redis 实例并不要求是一个真正的 Redis 集群,它们彼此之间可以是完全独立的,但由于只需要半数节点获得锁就能真正获得锁,因此其仍然具备容错性和高可用性。
redisson也支持了红锁方案:
// 创建 RedissonRedLock
RedissonRedLock redLock = new RedissonRedLock(lock01, lock02, lock03);
try {
boolean isLock = redLock.tryLock(10, 300, TimeUnit.SECONDS);
if (isLock) {
// 模拟业务处理
System.out.println("处理业务逻辑");
Thread.sleep(200 * 1000);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
// 释放锁
redLock.unlock();
}
低延迟通信
实现 RedLock 方案的客户端与所有 Redis 实例进行通讯时,必须要保证低延迟,而且最好能使用多路复用技术来保证一次性将 SET 命令发送到所有 Redis 节点上,并获取到对应的执行结果。如果网络延迟较高,假设客户端 A 和 B 都同时尝试创建锁:
SETNX key 随机数A EX 3 #A客户端
SETNX key 随机数B EX 3 #B客户端
此时可能客户端 A 在一半节点上创建了锁,而客户端 B 在另外一半节点上创建了锁,那么两个客户端都将无法获取到锁。如果并发很高,则可能存在多个客户端分别在部分节点上创建了锁,而没有一个客户端的数量超过 N/2+1。这也就是上面过程的最后一步中,强调一旦客户端失败后,需要等待一个随机时间后再进行重试的原因,否则所有失败的客户端又同时发起重试,还是会失败。
因此最佳的实现就是客户端的 SET 命令能几乎同时到达所有节点,并几乎同时接受到所有执行结果。 想要保证这一点,低延迟的网络通信极为关键,Redisson 就采用 Netty 框架来保证这一功能的实现。
持久化
为了高可用,必须开启持久化。
Redis事务
有两种方法可以实现Redis事务
-
Redis自带的事务模式:
- 使用MULTI开启事务,然后输入多个命令让命令入队列,执行EXEC执行事务;DISCARD命令丢弃事务。因为在MULTI之后、 EXEC 之前 ,Redis key 依然可以被其他线程修改,所以可以使用watch命令实现类似乐观锁的效果(事务中操作的某个key如果被其他线程修改了,事务会执行失败)
- Redis 的事务模式具备如下特点:
- 保证隔离性;
- 无法保证持久性,取决于持久化方式;
- 不保证原子性,在特定条件下,才具备原子性,且不支持回滚:
- 命令入队时报错, 会放弃事务执行,保证原子性;
- 命令入队时正常,但执行 EXEC 命令后报错,不保证原子性,且不回滚;
- 一致性的概念有分歧,假设在一致性的核心是约束的语意下,Redis 的事务可以保证一致性。
-
Lua脚本:从 Redis 2.6.0 版本开始, Redis内置的 Lua 解释器,可以实现在 Redis 中运行 Lua 脚本。
-
Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入,保证了原子执行,但不保证原子性,如果脚本执行报错,事务不会回滚。
-
保证隔离性,而且可以完美的支持后面的步骤依赖前面步骤的结果。
-
lua脚本使用非常广泛,不过要注意:
- 为了避免 Redis 阻塞,Lua 脚本业务逻辑不能过于复杂和耗时;
- 因为不保证原子性,所以脚本要多测试,不报错。
-
cache aside pattern 缓存旁路策略
https://www.dtstack.com/bbs/article/9477
先更新数据库后删缓存
- 读策略:
- 命中:从cache读数据,命中后返回。
- 没命中:先从cache读数据,没命中,则从数据库中取数据,成功后,放到缓存中。
- 写策略:先更新/删除db的数据,成功后,再删除缓存。
优点
正常情况下,在写库和删缓存之间会有线程读到缓存中没被删除的旧数据,但该时间非常非常短,是一致性最高的方案了。需要完全杜绝这个问题保证强一致的话就加锁:
- 对于写操作,需要将更新DB和删除Cache锁住。
- 对于读操作,需要将查询Cache不存在之后的操作锁住。
- 并且读和写使用同一把锁。
缺点
- 首次请求数据一定不在 cache,必须得走一次db,存在一定延时 。需要预热热点缓存。
- 写/改操作频繁的话导致cache中的数据会被频繁删除,这样会影响缓存命中率,失去缓存的意义 。所以cache aside适合读多写少,不适合写多读少。
- 极端并发情况下,会出现较严重的数据不一致情况:一个读线程,但是没有命中缓存,到数据库中取数据,此时来一个写线程,写完数据库并删缓存后,之前的那个读线程再把旧数据写入缓存,造成脏数据。(这个case实际上出现的概率非常低,因为这个条件需要发生在读缓存时缓存失效,而且并发着有一个写操作。而实际上数据库的写操作会比读操作慢得多,而且还要锁表,而读操作必需在写操作前进入数据库操作,而又要晚于写操作更新缓存,所有的这些条件都具备的概率非常小)。解决方法是给缓存加上过期时间,兜底最终一致。
删除缓存(第二个操作)失败怎么办/如何保证两个操作都能执行成功?
如果删缓存失败就会造成旧数据一直留在缓存中,以后一直会读到旧数据。
-
首先缓存最好是有过期时间兜底最终一致。
-
(放在一个事务里,强一致,但性能会明显下降,而且要是redis宕机了,数据库即使能用,整个业务也不可用了。所以缓存和数据库的双写在绝大部份业务场景都不会采用这种同步双写,都会异步双写,保证性能,牺牲一定的一致性、实时性。当然要求强实时性的场景还是得同步双写)
-
消息队列重试,删除缓存失败后放入消息队列,由消费者来操作数据,进行重试,超过一定次数还没有成功就报错信息了。
-
使用canal订阅 MySQL binlog,再操作缓存。
先删缓存后更新数据库+延迟双删
先删缓存后更新数据库的数据不一致会非常严重,如果一定要使用,可以在此基础上使用延迟双删。伪代码:
#删除缓存
redis.delKey(X)
#更新数据库
db.update(X)
#睡眠
Thread.sleep(N) 几百毫秒到几秒
#再删除缓存
redis.delKey(X)
再次删除的目的是为了防止写线程在删缓存和更新数据库之间的时间内有写线程因未命中缓存而读数据库中旧数据并写入缓存。一般睡眠时间N要大于一次「从数据库读取数据 + 写入缓存」的时间。
缺点
数据不一致时间取决于延迟时间,延迟时间的确定比较困难,但在这段时间内的高并发读能降低数据库的读请求压力。