数据结构及应用
基础数据结构
String 类型
简单的 key/value 类型,可以保存文本数据和二进制数据
操作:set、mset、get、mget、strlen、exists、decr、incr、setex
应用场景:最多的是用于需要计数的场景,比如用户访问的次数,热点文章的点赞转发之类的数量。
- 缓存,用于支持高并发
- 计数器,视频播放数
- 限速,处于安全考虑,每次进行登录时让用户输入手机验证码,为了短信接口不被频繁访问,会限制用户每分钟获取验证码的频率。就是设置过期时间
set key value // 设置 key-value 类型的值
get key // 根据 key 获取 value
exists key // 判断是否存在 key
strlen key // 返回 key 对应 value 的长度
del key // 删除 key 和对应的 vlaue
mset key1 value1 key2 value2 // 批量设置 key-value 类型的值
mget key1 key2 // 批量获取 key1 key2 对应的 value
-------计数器---------
set number 1 // 设置 key 对应的值为整数
incr number // key 对应的值自增 1
decr number // key 对应的值自减 1
expire key 60 // 设置 key 的过期时间为 60s
setex key 60 value // 相当于 set + expire
ttl key // 返回 key 的剩余时间 time to live
list链表
操作:rpush、lpush、rpop、rpop、lrange、llen
应用场景:发布与订阅、消息队列
- 文章列表,每个用户都有属于自己的文章列表,现在需要分页展示文章列表,此时可以考虑使用列表,列表不但有序,同时支持按照索引范围获取元素。(lrange命令)
- 消息队列:lpush+brpop
rpush myList value1 // 向 myList 右端压入 value1
rpush myList value2 value3 // 向 myList 右端批量压入 value2 value3
lpop myList // 弹出并返回最左端的值
lrange myList 0 1 // 查看 0 - 1 范围的值
lrange myList 0 -1 // 查看 0 - 最右端 范围的值 (负数表示最右端第几个)
llen myList // 返回 myList 长度
hash
操作:hset、hmset、hexists、hget、hgetall、hkeys、hvals
应用场景:系统中对象数据的存储。
- 记录帖子的点赞数、评论数和点击数。帖子的标题、摘要、作者和封面信息,用于列表页展示
- 用户信息管理,key是用户标识,value是用户信息。hash 特别适合用于存储对象
hset userInfoKey name "guide" description "dev" age "24" // 插入 userInfoKey
hexists userInfoKey name // 查看 userInfoKey 是否存在 name 字段
hget userInfoKey name // 获取 userInfoKey 的 name 字段的值
hgetall userInfoKey // 获取 userInfoKey 的所有字段和值
hkeys userInfoKey // 获取 userInfoKey 的所有字段
hvals userInfoKey // 获取 userInfoKey 的所有值
hset userInfoKey name "summer" // 修改 userInfoKey 的 name 字段的值
set
操作:sadd、spop、smembers、sismember、scard、sinterstore、sunion
应用场景:需要存放不重复以及需要取交集并集的数据
sadd mySet value1 value2 // 添加 value1 value2
smembers mySet // 查看 mySet 所有元素
scard mySet // 查看 mySet 的长度
sismember mySet value1 // 检查 value1 是否在 mySet 中
sadd mySet2 value2 value3 // 设置 mySet2
sinter mySet mySet2 // 返回 mySet 和 mySet1 的交集
sinterstore mySet3 mySet mySet2 // 取 mySet 和 mySet1 的交集并存到 mySet3 中
sdiff mySet mySet2 // 返回 mySet 和 mySet2 的差集
zset 有序集合
操作:zadd、zcard、zscore、zrange、zrevrange、zrem
应用场景:需要对数据根据某个权重进行排序,比如用户按照发言次数进行排序
- 排行榜,记录热榜帖子 ID 列表,总热榜和分类热榜
zadd myZset 3 value1 //添加权重为 3.0 的值 value1 到 myZset
zadd myZset 2 value2 1 value3 // 添加多个元素
zcard myZset //查询 myZset 的元素数量
zscore myZset value1 // 查看 myZset 中 value1 的权重
zrange myZset 0 2 // 顺序输出 0 - 2 范围内的元素
zrevrange myZset 0 1 // 逆序输出 0 - 1 范围内的元素
bitmap 位图
bitmap 存储的是连续二进制数字(位图不是特殊的数据结构,本质就是普通字符串,也就是 byte 数组)
操作:setbit、getbit、bitcount、bitop
应用场景:需要保持状态信息,并进一步分析;比如用户签到情况、活跃用户情况、用户行为统计。
setbit myKey 7 1 // 设置 myKey 第 7 位为 1
getbit myKey 7 // (integer) 1 返回第 7 位状态
bitcount myKey // (integer) 1 返回 1 的个数
bitop and deskkey key1 [key2..] // 对 key1 .. 进行 and 运算后保存到 deskkey 中
位数组顺序和字符的位顺序相反
# ‘h’ =>01101000b
setbit h 1 1
setbit h 2 1
setbit h 4 1
# 等价于
set h h
位图可以零存零取、零存整取、整存零取、整存整取。
bitcount h # 获取 h 中 1 的个数
bitpos h 1 # 获取 h 中 第一个 1 的位置
bitop and(or/xor/not) destkey key[key2..] # 把一个/多个 key 的位运算结果保存到 destkey 中
bitfield 可以对多个位进行操作。
set w hello
getfiled w get u4 0 # 取 w 中第一个开始的 4 个位,返回无符号整数
getfiled w get i4 0 # 取 w 中第一个开始的 4 个位,返回有符号整数
getfiled w set u8 8 97 # 将 w 中第九个开始的 8 个位设置为 97
getfiled w incrby u4 2 1 # 将 w 中第二个开始的 8 个位的无符号整数自增
bitfield 指令提供了溢出指令 overflow,可以选择处理方式,默认位 wrap,还有 sat(超过范围就停在最大/最小值) 和 fail(报错不执行)。
getfield w set u4 2 15 # 将第三个开始的 4 个位设置为 15,此时再自增会溢出
getfield w overflow sat incrby u4 2 1 # 结果还是 15,因为 sat 默认保持在最大或者最小位置
getfield w overflow fail incrby u4 2 1 # 直接失败不执行,返回null
getfield w incrby u4 2 1 # 结果为 0,溢出后折返变成 0
HyperLogLog
在业务中有统计 PV(page view) 和 UV(unique visitor) 的需求。PV 可以通过 incrby 就可以完成了,但是 UV 如果使用 set 去重再统计会耗费很多空间,这里可以使用 HyperLogLog 来解决,虽然不是完全准确,但是标准误差在 0.81%,相对很低了。
pfadd codehole user1 # 增加用户
...
pfadd codehole user100
pfcount codehole # 返回计数值
pfadd codehole1 user101
pfmerge codehole2 codehole codehole1 # 返回 codehole 和 codehole1 合并后的计数值
容器通用规则
- create if not exist:如果操作时不存在对应的容器,那么会先创建后再去操作。
- delete if no elements:如果容器中没有元素就会删除容器。
过期时间
如果一个容器设置了过期时间,然后对这个容器进行修改,那么这个容器的过期时间会消失。
底层实现
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DZOOa67H-1635407242049)(/Users/chenzhijie/Library/Application Support/typora-user-images/image-20211016160939314.png)]
SDS 简单动态字符串
Redis 中的 sds 是可以修改的,C 中的字符串是不能修改的。
struct sdshdr {
// 记录 buf 数组中已使用字节的数量
// 等于 SDS 所保存字符串的长度
int len;
// 记录 buf 数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
};
SDS 相对 C 中的 字符串 做了一些优化:
-
SDS 中有 len 属性,所以获取字符串长度时间复杂度为 O(1)
-
SDS 中进行拼接不会溢出,在进行拼接前会先查看 SDS 分配的空间是否足够放下,如果不够会先为其分配足够空间再进行拼接
-
减少修改字符串带来的内存重分配次数。C 中字符串长度就字符个数加一,而 SDS 中有不同的分配策略来配合
空间预分配:在对 SDS 进行增长操作后,如果 len 小于 1MB,为它分配同样大小未使用的空间;如果大于 1MB,为它分配 1MB 未使用空间。
惰性空间回收:在对 SDS 进行缩短操作后,不会立即使用内存重分配回收多出来的字节,而是先用 free 保存起来。
-
二进制安全:因为在 SDS 中有 len 属性,所以在字符数组中间可以有 一些特殊数据格式;而 C 中有些不支持
-
因为 SDS 中的 char buf[],如果有 SDS 的指针 *p 可以直接将 p->buf 当作 C 中的字符串使用部分 C 中的字符串函数
链表
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} listNode;
typedef struct list {
// 表头节点
listNode *head;
// 表尾节点
listNode *tail;
// 链表所包含的节点数量
unsigned long len;
// 节点值复制函数
void *(*dup)(void *ptr);
// 节点值释放函数
void (*free)(void *ptr);
// 节点值对比函数
int (*match)(void *ptr, void *key);
} list;
字典
Redis 的所有键值对都存在一个数据字典中
哈希表定义:
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
table 是一个 dictentry 数组,索引位置 = sizemask & hash 值。
哈希表节点定义:
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
字典定义:
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;
其中 ht[0] 保存正在使用的哈希表,ht[1] 可能保存正在 rehash 过程中的哈希表。因为 Redis 中 哈希表的 rehash 是渐进式的,rehashidx 保存了渐进 rehash 的索引位置。
插入过程和 HashMap 过程类似,先取得键值对的键的哈希值,然后和掩码与运算得到索引位置,插入时如果有冲突就用链地址法解决放在链表头部。
Redis 为了保证性能将 rehash 过程延长;
- 为 ht[1] 分配空间,如果是扩展 ht[1] 的大小等于 第一个大于等于 used*2 的2次方幂;如果是收缩,那么大小等于 第一个大于等于 used 的2次方幂;
- 将 rehashidx 设置为 0,表示开始 reahsh
- 在 rehash 期间,每次对字典进行操作时都会将 ht[0] 中 rehashidx 位置的所有键值对 rehash 到 ht[1] 上
- 某个时间点如果 rehashidx 位置到达 ht[0] 数组尾部那么结束 rehash,用ht[1] 代替 ht[0],并释放 ht[0]。
如果服务器正在执行 bgsave 或者 bgrewriteaof,需要等待负载因子大于等于 5 才会强制进行 rehash;
在 rehash 过程中,查找删除过程会在两个数组中进行,而更新操作则只会在 ht[1] 数组进行。
跳表
跳表定义:
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
跳表节点定义:
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backward;
// 分值
double score;
// 成员对象
robj *obj;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
zskiplistNode *zslInsert(zskiplist *zsl, double score, robj *obj) {
// updata[]数组记录每一层位于插入节点的前一个节点
zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
// rank[]记录每一层位于插入节点的前一个节点的排名
unsigned int rank[ZSKIPLIST_MAXLEVEL];
int i, level;
serverAssert(!isnan(score));
x = zsl->header; // 表头节点
// 从最高层开始查找
for (i = zsl->level-1; i >= 0; i--) {
// 存储rank值是为了交叉快速地到达插入位置
rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
// 前向指针不为空,前置指针的分值小于score或当前向指针的分值相等但成员对象不等的情况下,继续向前查找
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
(x->level[i].forward->score == score &&
compareStringObjects(x->level[i].forward->obj,obj) < 0))) {
rank[i] += x->level[i].span;
x = x->level[i].forward;
}
// 存储当前层上位于插入节点的前一个节点
update[i] = x;
}
// 此处假设插入节点的成员对象不存在于当前跳跃表内,即不存在重复的节点
// 随机生成一个level值
level = zslRandomLevel();
if (level > zsl->level) {
// 如果level大于当前存储的最大level值
// 设定rank数组中大于原level层以上的值为0
// 同时设定update数组大于原level层以上的数据
for (i = zsl->level; i < level; i++) {
rank[i] = 0;
update[i] = zsl->header;
update[i]->level[i].span = zsl->length;
}
// 更新level值
zsl->level = level;
}
// 创建插入节点
x = zslCreateNode(level,score,obj);
for (i = 0; i < level; i++) {
// 针对跳跃表的每一层,改变其forward指针的指向
x->level[i].forward = update[i]->level[i].forward;
update[i]->level[i].forward = x;
// 更新插入节点的span值
x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
// 更新插入点的前一个节点的span值
update[i]->level[i].span = (rank[0] - rank[i]) + 1;
}
// 更新高层的span值
for (i = level; i < zsl->level; i++) {
update[i]->level[i].span++;
}
// 设定插入节点的backward指针
x->backward = (update[0] == zsl->header) ? NULL : update[0];
if (x->level[0].forward)
x->level[0].forward->backward = x;
else
zsl->tail = x;
// 跳跃表长度+1
zsl->length++;
return x;
}
方法中主要的步骤如下:
- 找到节点应该插入的位置,这里用 x 指针的移动来找到到具体位置;并且在移动过程中会更新 update 和 rank 数组。其中,update 数组主要是记录插入节点在每层前一个 zskiplistNode 节点,以便后续节点的插入和 backward 指针填充;rank 数组主要是记录插入节点在每层前一个节点的 rank 值,以便发生改变的 span 值。
- 随机新节点的 level,并生成新节点
- 填充节点的 level 数组中 forward 和 span,同时更新节点各层前驱的 span
- 更新各个节点的前向指针 backward
SortedSet 中的元素是唯一的,但是在 zslInsert 函数中并没有看到判断重复之类的操作。后面在 zadd 方法中接近 1350行 找到了下面的流程,所以说有序集合保持唯一的方法就是先尝试删除对应的 元素,然后在进行插入。
/* Remove and re-insert when score changed. */
if (score != curscore) {
zobj->ptr = zzlDelete(zobj->ptr,eptr);
zobj->ptr = zzlInsert(zobj->ptr,ele,score);
*flags |= ZADD_UPDATED;
}
删除方法也类似,先找到对应的节点然后删除后需要更新收到影响的节点的 forward 和 span 值,然后更新 backward。
压缩列表
压缩列表定义:
struct ziplist<T>{
int32 zlbytes; //整个压缩列表占用内存空间大小
int32 zltail_offset; //最后一个 entry 的偏移量
int16 zllength; //元素个数
T[] entries; //元素列表
int8 zlend; //压缩列表结束标志位,恒为 0xFF
}
压缩列表属性:
属性 | 类型 | 长度 | 用途 |
---|---|---|---|
zlbytes | uint32_t | 4 字节 | 记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配, 或者计算 zlend 的位置时使用。 |
zltail | uint32_t | 4 字节 | 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节: 通过这个偏移量,程序无须遍历整个压缩列表就可以确定表尾节点的地址。 |
zllen | uint16_t | 2 字节 | 记录了压缩列表包含的节点数量: 当这个属性的值小于 UINT16_MAX (65535 )时, 这个属性的值就是压缩列表包含节点的数量; 当这个值等于 UINT16_MAX 时, 节点的真实数量需要遍历整个压缩列表才能计算得出。 |
entryX | 列表节点 | 不定 | 压缩列表包含的各个节点,节点的长度由节点保存的内容决定。 |
zlend | uint8_t | 1 字节 | 特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。 |
压缩列表节点定义:
struct entry{
int<var> prevlen; //前一个 entry 长度;方便逆序遍历
int<var> encoding; //元素类型编码
optional byte[] content; //元素内容
}
对象
Redis 在执行命令之前可以根据数据类型来判读是否可以执行该操作;
Redis 基于引用计数实现了内存回收机制,并且实现了内存共享。
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 指向底层实现数据结构的指针
void *ptr;
// 对象的热度
unsigned lru:24;
// 引用计数
int refcount;
} robj;
对象类型主要有:
- 字符串对象
- 列表对象
- 哈希对象
- 集合对象
- 有序集合对象
字符串对象
字符串的编码有 int、embstr 和 raw
如果 字符串对象 保存的是一个整数值,并且这个整数可以用 long 类型表示,那么这个对象的编码会被设置为 int;
如果保存的是一个字符串,并且长度小于45字节,那么会用 embstr 编码保存;
如果大于那么就会用 raw 编码方式。embstr 中对象头和字符串相邻只需调用一次内存分配,而 raw 格式不连续需要调用两次内存分配
列表对象
列表对象编码方式有 ziplist 和 linkedlist
列表对象的所有字符串元素长度都小于 64 字节,并且列表元素数量小于 512 个时使用 ziplist
不满足时使用 linkedlist
哈希对象
同列表对象
集合对象
集合对象编码方式有 intset 和 hashtable
当集合对象中所有元素都是整数值,并且元素数量小于 512 个使用 intset;否则转换为 hashtable
有序集合对象
编码方式有 ziplist 和 skiplist
在 zset 结构中同时包含了一个 skiplist 和一个字典;字典用于存储 obj 和 score 的映射。
命令多态:因为基本每种数据结构底层都有不同的集中实现,比如 列表 可以用 ziplist 和 linkedlist 来实现,那么调用 llen 方法时,会根据底层实现来调用 ziplistLen/listLen 。DEL 基于类型的多态命令,LLEN 基于编码的多态命令。
内存共享:Redis 在初始化服务器时会创建 1-9999 的字符串对象,之后如果有新建范围内的数字会直接引用已有对象,从而实现内存共享。但是 Redis 中也仅对 保存整数值的字符串 进行共享。虽然其他复杂对象也可以进行内存共享,但是需要进行的判断是否相同操作同样也会消耗 CPU,而数字判断的时间复杂度仅 O(1)。
分布式锁
Redis 可以实现一个简单的分布式锁。
主要逻辑是:
- 操作 1 在执行前需要拿到 Redis 中对应的分布式锁,如果失败就先等待重试;
- 操作 1 执行完后释放对应锁;
- 操作 2 在操作 1 释放锁后获取到锁开始执行。
主要是依靠 setnx 指令,如果存在那么会返回 0。如果不存在才会 set 成功,并返回 1。
但是如果操作 1 在执行期间出现异常,导致锁没有正常释放,这样会导致其他需要这把锁的操作一直阻塞。所以需要给这个锁设置一个过期时间,但是如果 setnx 和 expires 语句分开还是可能出现异常,这里使用 set 的扩展指令 set lock true ex 10 nx
来实现。
但是这里设置的时间是固定的,如果因为 Redis 抖动导致没有在过期时间内完成整个操作就会出现问题。书中是使用 Lua 脚本进行处理,但是真的操作后还是会有问题。这里我想可以在操作过程中,增加一些 “延长过期时间” 的执行节点,这样可能相对增加的过期时间,这个时间也需要去多次测试后才能得到一个较好的结果。
#delifequals
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
在 Java 中,一般都是可重入锁,在 Redis 中可以使用 ThreadLocal 类对 set 方法进行包装一下,但是还需要考虑过期时间。以下代码还没有考虑过期时间,如果考虑可以增加 ttl 的判断。
public class RedisWithReentrantLock{
private ThreadLocal<Map<String,Integer>> lockers = new ThreadLocal<>();
private Jedis jedis;
public RedisWithReentrantLock(Jedis jedis){
this.jedis = jedis;
}
public boolean _lock(String key){
return jedis.set(key, "", "nx", "ex", 10L) != null;
}
public boolean _unlock(String key){
return jedis.del(key);
}
private Map<String,Integer> currentLockers(){
Map<String,Integer> refs = lockers.get();
if(refs != null){
return refs;
}
lockers.set(new HashMap<>());
return lockers.get();
}
public boolean lock(String key){
Map<String,Integer> refs = currentLockers();
Integer refCnt = refs.get(key);
if(refCnt != null){
refs.put(key, refCnt+1);
return true;
}
boolean ok = this._lock(key);
if(!ok){
reutrn false;
}
refs.put(key,1);
return true;
}
public boolean unlock(String key){
Map<String,Integer> refs = currentLockers();
Integer refCnt = refs.get(key);
if(refCnt == null){
return false;
}
refCnt--;
if(refCnt > 0){
refs.put(key, refCnt);
}else{
refs.remove(key);
this._unlock(key);
}
return true;
}
}
消息队列
Redis 可以实现一个简单的消息队列,简单但是没有 rabbitMQ 之类的专业消息队列可靠。
实现主要依靠 list 的 lpush、rpop,但是如果队列空了 rpop 方法会陷入死循环,这里使用 brpop 来弹出消息。但是 brpop 可能会产生空闲连接,所以可以在超过时限后主动断开连接然后抛出异常,然后在后端捕获后进行重试。
流量控制
可以利用 zset 数据结构实现简单的流量控制。
限制用户时间内访问次数:
在 zset 中保存用户的时间戳,每个用户请求来的时候会维护一次窗口,把时间窗口外的记录删除,然后判断时间窗口内的请求个数是否小于请求阈值。这种实现只适合限制较为严格的记录,如果限制宽松会导致存储的记录过多,耗费大量空间。
public class SimpleRateLimiter{
private Jedis jedis;
public SimpleRateLimiter(){
this.jedis = jedis;
}
public boolean isActionAllowed(String userId, String actionKey, int period, int maxCount){
String key = String.format("hist:%s:%s", userId, actionKey);
long nowTs = System.currentTimeMillis();
Pipeline pipe = jedis.pipelined();
pipe.multi();
pipe.zadd(key, nowTs, " "+nowTs);
pipe.zremrangeByScore(key, 0, nowTs-period*1000);
Response<Long> count = pipe.zcard(key);
pipe.expire(key, period+1);
pipe.exec();
pipe.close();
return count.get() <= maxCount;
}
}
布隆过滤器
Redis 4.0 提供布隆过滤器插件。
主要有 bf.add、bf.exists、bf.madd、bf.mexists 方法。
附近的人
Redis 提供的 Geo 指令可以实现地图,使用了 GeoHash 算法,将二维的经纬度映射到一位的整数,底层是用 zset 数据结构存储的。
GeoHash 算法:二分区间,区间编码
一次完整二分过程:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qUwpNt7l-1635407242056)(/Users/chenzhijie/Library/Application Support/typora-user-images/image-20211021205852700.png)]
经纬度分别二分编码后合并:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mwLNdcOK-1635407242059)(/Users/chenzhijie/Library/Application Support/typora-user-images/image-20211021210031954.png)]
geoadd company 116.48105 39.9967 juejin
geoadd company 116.514203 39.905409 ireader
geodist company juejin meituan km # m/km/ml/ft 米/千米/英里/尺
geopos company juejin
geohash company juejin
# georadiusbymember 提供查询指定位置附近的其他元素
# ireader 可以改为经纬度
georadius company ireader 20 km count 3 asc
需要注意,如果很多条位置数据放在同一个 zset 集合中会导致集合过大,这样在集群中迁移时会影响迁移速度,可以对 Geo 数据进行拆分,比如国家/省市/区域进行拆分后存储。
单机数据库
数据库
struct redisServer{
redisDb *db; //保存服务器所有数据库
int dbnum; //服务器数据库个数,默认16
dict *dict; //数据库键空间
dict *expires; //过期字典
}
默认进入服务器后使用 0 号数据库,使用 select 可以修改数据库
删除策略
- 定时删除:设置过期时间时创建一个定时器,等到过期时立即删除键
- 惰性删除:只有在取出 key 的时候才会对数据进行过期检查,这样减小了 CPU 压力,但是可能会造成大量过期的 key 没有删除。
- 定期删除:每隔一段时间抽取一批 key 进行过期删除。
Redis 采用的 定期删除+惰性删除
**淘汰机制:**保证 Redis 内存中都是热点数据
但是在 Redis 内存满了之后还需要进行数据淘汰:
- volatile-lru:从 expires 数据集挑选最近最少使用的淘汰
- volatile-ttl:从 expires 数据集挑选快要过期的淘汰
- volatile-random:从 expires 数据集随机淘汰
- allkeys-lru:从 dict 数据集挑选最近最少使用的淘汰
- allkeys-random:从 dict 数据集随机淘汰
- no-eviciton:内存满后禁止新的写入操作
4.0 版本增加:
- volatile-lfu:从 expires 数据集挑选最不经常使用的淘汰
- allkeys-lfu:从 dict 数据集挑选最不经常使用的淘汰
在 RDB 过程中,碰到过期的键不会保存到快照文件中。并且在载入过程中,主服务器会忽略过期键。
如果取一个键发现其过期,会删除该键,并且会在 AOF 文件追加一条 DEL 指令。并且在 AOF 重写过程中也不会对过期键进行重写。
在主从复制过程中,主服务器删除一个过期键会显示的向从服务器发送一个 DEL 指令;而从服务器即使碰到过期键也不会进行删除,只有在接收到主服务器的 DEL 指令才会进行删除。
持久化
RDB 快照持久化
save 和 bgsave
save 是主线程进行快照;bgsave 是子进程进行快照。
快照会保存 bgsave 子进程创建时的数据,之后如果主进程修改了数据,会先进行写时复制,然后对复制的副本进行修改,而 bgsave 依然会保存之前的数据。
最坏情况是所有共享内存都被修改了,这时就会导致所有内存都有副本。
RDB 是 Redis 的默认持久化方式,它通过快照来获得内存中某个时间点的所有数据副本。
save [时间限制] [次数限制] : save 300 10 在300s内如果有10个key发生变化就快照
struct redisServer{
struct saveparam *saveparam; // 记录保存条件的数组
long long dirty; // 修改计数器
time_t lastsave; // 上一次保存的时间
}
struct saveparam{
time_t seconds;
int changes;
}
服务器周期性操作函数 serverCron 默认间隔 100ms 执行一次,其中就会检查 save 设置的条件是否满足,如果满足就会执行 bgsave 命令。
AOF 持久化
默认不开启,可以在 redis.config 文件修改 appendonly 为 yes。
aof 文件:「*3
」表示当前命令有三个部分,每部分都是以「$+数字
」开头,后面紧跟着具体的命令、键或值。然后,这里的「数字
」表示这部分中的命令、键或值一共有多少字节。例如,「$3 set
」表示这部分有 3 个字节,也就是「set
」命令这个字符串的长度。
先进行写操作,再写日志。
好处:
- 避免额外检查开销。能完成写操作肯定语法没有问题,可以直接写入日志中
- 不会阻塞当前写操作。
坏处:
- 可能丢失写操作。如果 redis 在写操作后宕机,那这个写操作可能没有写入日志中
- 阻塞下一个写操作。执行写操作的线程和写入日志的线程都是主进程,如果写入日志时硬盘IO压力较大会导致写入速度较慢将后续操作阻塞住。
三种写回策略:
Redis 写入 AOF 日志 过程:
执行写操作–》写命令追加到缓冲区–》系统调用write–》内核缓冲区–》内核发起写操作到磁盘
- Always 每次写操作后都将日志写入磁盘中
- EverySec 先将写命令写入内核缓冲区,然后每隔一秒将缓冲区内容写入磁盘
- No 每次将写命令写入内核缓冲区,然后让操作系统决定什么时候写入磁盘
三种策略通过控制调用 fsync 方法来实现。Always 在每次写操作后调用;EverySec 创建一个异步任务来调用 fsync;No 用不执行 fsync。
AOF 重写
aof 文件过大后会带来性能问题,比如 reids 重启后恢复数据。所以在 aof 文件大于阈值后会启动 aof 重写来压缩 aof 文件。
过程:读取当前数据库中的所有键值对,然后每个键值对用一条命令记录到新的aof 文件,等到全部记录完就用新的文件替换旧的文件。
重写过程中主线程:执行客户端命令;将写命令追加到 AOF缓冲区;将写命令追加到 AOF重写缓冲区。
AOF 完成后向主线程发送通知,主线程接收后将 AOF重写缓冲区内容追加到新 AOF 文件中;将新的 AOF文件改名覆盖旧 AOF文件。
AOF 重写过程时由后台子进程 bgrewriteaof 完成的。这样在 aof 重写期间可以继续处理命令请求。
可能阻塞的阶段:
- 父进程创建子进程回复制页表数据,如果页表数据过大会造成阻塞
- 父/子进程修改共享数据后会进行写时复制,对应物理内存越大,阻塞时间越长
事件
Redis 是事件驱动程序,服务器需要处理 文件事件(服务器套接字的抽象,服务器监听并处理这些事件) 和 时间事件(定时操作的抽象,比如 serverCron 函数)。
文件事件
Redis 基于 Reactor 模式开发的一种网络事件处理器,使用 IO 多路复用来同时监听多个套接字,并根据套接字来关联不同的事件处理器。当被监听的套接字准备好执行 连接应答accept、读取read、写入write、关闭close 等操作,与操作对应的文件事件就会产生,文件事件处理器就会调用之前关联的事件处理器进行处理。虽然文件事件处理器单线程运行,但是通过 IO多路复用程序 监听了多个套接字,文件事件处理器既实现了高性能的网络通信,又保持了单线程设计的简单性。
文件处理器主要包括:
- 支持多个客户端的 socket 连接
- IO多路复用程序:支持多个客户端连接的关键
- 文件事件分派器:将 socket 关联到想应的事件处理器
- 事件处理器:连接应答处理器、命令请求处理器、命令回复处理器
Redis 通过 IO多路复用 来监听多个客户端的 socket 连接,它会将感兴趣的事件及类型注册到内核并监听事件是否发生。IO多路复用 技术让 Redis 不需要创建多余的线程来监听客户端的 socket 连接,降低了资源的消耗。
事件的类型:
- 当套接字变得可读时(客户端对套接字写,或者执行close),或者有新的 acceptable 套接字出现时会产生 AE_READABLE 事件
- 当套接字变得可写时(客户端对套接字读),套接字产生 AE_WRITABLE 事件
对应的有 aeCreateFileEvent 函数,接受一个套接字描述符、一个事件类型和一个事件处理器;将给定套接字的指定事件加入 I/O 多路复用程序的监听范围,并对事件和事件处理器进行关联。
aeDeleteFileEvent 函数接受一个套接字描述符和一个监听事件类型,取消多路复用程序的监听,并取消事件和事件处理器之间的关联。
aeGetFileEvents 函数接受一个套接字描述符,返回套接字正在被监听的事件类型。
文件事件的处理器:
-
连接应答处理器
Redis 服务器初始化时,程序会将连接应答处理器和服务器监听套接字的 AE_READABLE 事件关联起来,当客户端连接服务器监听套接字时就会产生 AE_READABLE 事件,引发连接应答处理器执行相应操作
-
命令请求处理器
客户端通过连接应答处理器成功连接服务器后会将客户端套接字的 AE_READABLE 事件和命令请求处理器关联,之后客户端发起命令请求就会产生 AE_READABLE 事件,引发命令请求处理器执行请求
-
命令回复处理器
当服务器有命令回复需要传送给客户端的时候,服务器会将客户端套接字的 AE_WRITABLE 事件和命令回复处理器关联起来,当客户端准备好接受服务器传回的命令时就会产生 AE_WRITABLE 事件,引发命令回复处理器执行相应操作。并且命令发送完毕后服务器就会解除命令回复处理器和客户端套接字的 AE_WRITABLE 事件关联。
时间事件:
主要分为周期事件和定时事件。
一个时间事件主要包含 服务器创建的全局唯一ID、时间事件到达时间 when 和 时间处理器 timeProcess。
定时事件运行结束后会返回一个 AE_NOMORE ,周期性事件运行结束后会返回一个非 AE_NOMORE 的整数值。
命令请求完整执行过程:
redis> SET KEY VALUE
OK
-
发送命令请求
客户端会先将命令转换成协议 “*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$5\r\nVALUE\r\n” 然后发送给服务器
-
服务器调用命令请求处理器将 1)读取命令请求并保存到客户端状态的输入缓冲区;2)对命令请求进行分析 首先提取命令参数和个数,并保存到客户端的 argv 和 argc 中。3)调用命令执行起,执行客户端指定的命令
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uxKaP2H0-1635407242061)(/Users/chenzhijie/Library/Application Support/typora-user-images/image-20211023200205610.png)]
-
命令执行器查找命令实现
根据客户端 argv[0] 参数,在命令表查找参数指定命令并保存在客户端状态的 cmd 属性里
-
命令执行器执行预备操作
检查 cmd 指针是否指向 null;检查命令参数个数是否正确;检查客户端是否通过身份验证…
-
调用命令的实现函数
-
执行后续工作
如果开启慢查询日志,会检测刚才请求是否需要添加日志;如果开启了 AOF 持久化,需要把请求写入 AOF 缓存;如果开启了主从复制,需要将请求传播给从服务器
-
把命令回复发送给客户端
多机数据库
主从同步
第一次同步
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Enlo6lOd-1635407242062)(/Users/chenzhijie/Library/Application Support/typora-user-images/image-20211016190528481.png)]
-
建立链接、协商同步
- 从服务器执行
replicaof <服务器IP地址> <服务器Redis端口号>
; - 建立套接字连接
- 发送 ping 命令:确认套接字连接的读写是否正常、检查主服务器是否能正常处理命令请求
- 身份验证:如果设置了 masterauth 需要先进行身份验证
- 发送 slave 端口信息:
REPLCONF listening-port <port-number>
- 从服务器执行
-
数据同步
- 向主服务器发送 psync 命令,runID(主服务器id) 和 offset(复制进度) 参数。
- 主服务器响应 FULLRESYNC,runID 和 offset
- 主服务器执行 bgsave 生成 RDB 文件发送给从服务器;从服务器清空数据后载入 RDB 文件,但是在生成文件和发送过程中会有新的数据产生
-
命令传播
将 replication buffer 中的写操作命令发送给从服务器,从服务器执行
无盘同步:因为 2.1 步骤中先生成 RDB 文件然后再发送需要先将文件写入磁盘中,会对系统产生较大的负载。Redis 2.8.18 支持无盘同步,可以直接遍历内存,将生成的快照内容序列化后发送到从节点,这样就减少了两次磁盘IO。
命令传播
主从服务器在完成一次同步后会维护一个 TCP 长连接
如果从服务器数量过多,并且都进行全量复制就会导致主服务器忙于 使用 fork 创建子进程,并且传输 RDB 文件会占用网络带宽,影响主服务器响应客户端请求。
从服务器执行 replicaof <服务器IP地址> <服务器Redis端口号> 后,如果对应服务器也是从服务器,那么这个服务器会成为经理的角色,不仅可以接受主服务器同步的数据,同时也会把数据同步到自己下属的从服务器
心跳检测
命令传播阶段从服务器会以每秒一次的频率向主服务器发送命令 REPLCONF ACK <replication_offset>
,replication_offset 为从服务器复制偏移量。
- 检测主从服务器的连接状态
- 辅助实现 min-slaves
- 检测命令丢失:如果主服务器发现从服务器的偏移量小于自己的偏移量,会在backlog_buffer 中找到缺少的数据,并重新发送给从服务器
增量复制
从服务器断开连接后会触发增量复制,将网络断开期间主服务器收到的写命令同步给从服务器
- repl_backlog_buffer,是一个「环形」缓冲区,用于主从服务器断连后,从中找到差异的数据;
- replication offset,标记上面那个缓冲区的同步进度,主从服务器都有各自的偏移量,主服务器使用 master_repl_offset 来记录自己「写」到的位置,从服务器使用 slave_repl_offset 来记录自己「读」到的位置。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2jmLExCe-1635407242064)(/Users/chenzhijie/Library/Application Support/typora-user-images/image-20211016193855414.png)]
-
如果从服务器要读取的数据还在缓冲区中,那么直接采用增量同步方式;
-
如果已经不在缓冲区中,那么会采用全量同步方式;在快照同步过程中,复制buffer 也同时会写,如果 buffer 设置过小,可能会导致全量复制死循环。
wait 指令
Redis 的复制是异步进行的,也就是说可能存在节点数据不一致的情况。如果对数据一致性要求较高可以使用 wait 指令。
wait 提供两个参数:
- 第一个参数 N 是从节点数量
- 第二个参数是时间 t,单位为毫秒(0表示无限等待直到满足N个节点同步完成)
wait 3 1 表示等待 wait 指令前的所有写操作同步到 3 个从节点,最多等待 1 ms。
但是如果时间设置为 0,并且出现了网络分区,会导致 Redis 集群不可用。
哨兵机制
哨兵进程在主从实例运行的同时也在运行,主要负责:监控、选主、通知。
监控:周期性的给所有主从库发送 PING 命令,检测它是否仍在运行。如果从库没有在规定时间响应哨兵的 PING 命令,那么就会把它标记为下线状态;如果主库没有在规定时间响应哨兵的 PING 命令,就会判定主库下线,然后开始自动切换主库的流程。
从库可以直接标记为“主观下线”;主库只有在大多数哨兵判断主库已经“主观下线”,才会标记为“客观下线”。
选主:哨兵在很多从库中按规则选择一个从库为新的主库。
筛选:先淘汰发生断连次数超过10次的实例
打分:从库优先级、从库复制进度、从库ID号(如果有相同得分进入下一轮比较)
从库优先级:可以根据 CPU 数量、内存大小等条件设置优先级
通知:哨兵会把新主库的连接信息发送给其他从库,让它们执行 replicaof 命令与主库建立连接,同时会把连接信息发送给客户端。
哨兵模式原理
底层基于 Redis 订阅/发布模式实现
主从集群上主库有一个名为 “_sentinel_:hello” 的频道,如果有新的哨兵连接到会将自己的信息推送,然后其他哨兵
独立功能原理
通信协议
Redis 作者认为数据库的瓶颈一般不在网络流量,而在于数据库内部的逻辑处理上,因此 Redis 使用了浪费流量的文本协议。
RESP (Redis Serialization Protocol)的实现过程非常简单,解析性能极好。
Redis 传输协议有 5 种最小单位类型,单元结束后统一加上 /r/n 回车换行符:
- 单行字符串以 “+” 开头
+hello world\r\n
- 多行字符串以 “$” 开头,后面跟字符串长度
$11\r\nhello world\r\n
- 整数值以“:”开头,后面跟整数的字符串形式
:1024\r\n
- 错误信息以“-”开头
-warning...
- 数组以“*”开头,后跟数组长度
*3\r\n:1\r\n:2\r\n:3\r\n
数组[1,2,3]
NULL表示为 $-1\r\n
空串表示为 $0\r\n\r\n
比如 set author codehole 会被序列化为:
*3\r\n$3\r\nset\r\n$6\r\n\author\r\n$8\r\ncodehole\r\n
*3
$3
set
$6
author
$8
codehole
管道
管道并不是 Redis 服务器直接提供的技术,相反本质上是客户端提供的。
当客户端对 Redis 进行一次操作时,客户端会把请求发送给服务器,然后服务器处理完毕后将响应返回给客户端。
request (客)-》response(服)
如果连续执行多条指令就是:
request (客)-》response(服)-》request (客)-》response(服)
如果在客户端层面那么就是:
write -》read -》write -》read
如果调整一下顺序变为:
write -》write -》read -》read
这样就等于只会花费一次网络来回,好像连续的读/写操作被合并了,这就是管道的本质,管道中的指令越多,节省的时间就越长。
请求交互流程:
- 客户端调用 write 将消息写到操作系统内核为套接字分配的发送缓冲区 send buffer
- 客户端操作系统内核将缓冲区内容发送到网卡,并通过网络发送到服务器的网卡
- 服务器操作系统内核将网卡的数据放到内核为套接字分配的 recv buffer 中
- 服务器进程调用 read 从接收缓冲区取出消息进行处理
- 服务器进程调用 write 将响应信息写到发送缓冲区 send buffer
- 服务器操作系统将缓冲区内容发送到网卡,并通过网络发送到客户端网卡
- 客户端操作系统内核将网卡数据放到为套接字分配的 recv buffer 中
- 客户端进程调用 read 从接受缓冲中取出消息返回给上层业务逻辑进行处理
其中,write 操作只负责将数据写到发送缓冲区 send buffer,只有在缓冲区满了的时候才需要等待缓冲区空出位置;read 操作只负责从缓冲区将数据取出来,但是缓冲区为空的时候就需要等待数据的到来。
所以管道只是改变了客户端的读写顺序为 Redis 提供的性能提升。
事务
Redis 的事务和传统关系型数据库的事务不同。
数据库的事务有原子性、一致性、隔离性、持久性;Redis 不支持回滚,主要是因为大部分事务失败是语法、类型的错误,是可以预见和避免的,并且回滚会影响 Redis 的性能。并且 Redis 默认是 RDB 方式持久化,不能保证传统意义的持久性。
原子性:Redis 不支持事务回滚,所以事务不能保证原子性,如果 exec 后执行过程中有操作执行失败,后续操作还是会继续执行。
一致性:1.入队错误,如果命令不存在或者格式不正确就会拒绝这个事务;2.执行错误,事务执行过程中错误的命令会被识别出来,不会执行;3.服务器停机
Redis 的事务就是将多个命令请求打包,然后再按顺序执行所有命令。
typedef struct redisClient{
multiState mstate; // 事务状态
}
typedef struct multiState{
multiCmd *commands; // 事务队列 FIFO
int count; // 入队命令计数
struct redisCommand *cmd; // 命令指针
}
typedef struct multiCmd{
robj **argv; // 参数
int argc;
struct redisCommand *cmd; //命令指针
}
Redis 可以通过 MULTI、EXEC、DISCARD、WATCH 来实现事务。
使用 MULTI 命令后可以输入多个命令,Redis 会将这些命令放到队列,直到调用了 EXEC 命令才会执行这些命令;DISCARD 会清除之前的 MULTI 命令;WATCH 命令会监控对应的键,如果键修改了那么事务无法成功 EXEC ;UNWATCH 清除监控的键
通过 watch 可以实现乐观锁。
for( ; ; ){ do_watch(); // 需要在 multi 指令前 watch commands(); multi(); send_commands(); try{ exec(); break; }catch(WatchError error){ continue; } }
发布/订阅模式
在前面用 list 实现的消息队列不能进行多播,使用发布订阅模式支持多播。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-abM18pLh-1635407242065)(/Users/chenzhijie/Library/Application Support/typora-user-images/image-20211021193248149.png)]
消息多播允许生产者生产一次消息,然后由中间件复制到各个消息队列
发布订阅模式有 subscribe、unsubscribe、publish、psubscribe 方法
subscribe 后面可以接多个 topic
psubscribe 后面可以跟 正则表达式,会订阅所有满足的 topic
PubSub 生产者传递过来一个消息后,Redis 会找到对应的消费者发送过去,如果该消费者掉线那么会抛弃消息。如果消费者全都掉线那么会直接抛弃消息,并且因为 PubSub 没有持久化,消费者掉线重连后也不会收到断线期间的消息。并且如果 Redis 宕机后所有消息都会被抛弃,不过 Stream 数据结构为 Redis 实现可持久化的消息队列。
详细可以看Redis订阅发布模式底层实现
排序
typedef struct _redisSortObject{
robj *obj; // 被排序的值
union{
double score; //排序数字值
robj *cmpobj; //排序带 By 选项的字符串
}u; //权重
}redisSortObject;
sort <key>
sort <key> ALPHA
sort <key> ASC|DESC
sort <key> BY *-price
第一行是普通的排序,对包含数字的键排序
第二行是按字母排序,对包含字符串的键排序
第三行按键的升序/降序排序
第四行按满足要求的键进行排序
sadd fruits "apple" "banana" "cherry"
mset apple-price 8 banana-price 5.5 cherry-price 7
sort fruits by *-price //按照水果价格排序
sort by
也可以增加 ALPHA
来对字符串进行比较
sadd fruits "apple" "banana" "cherry"
mset apple-id "fruit-25" banana-id "fruit-79" cherry-id "fruit-13"
或者增加 LIMIT start num
来限制选项个数,返回从 start 开始的 num 个元素
sort fruits alpha limit 0 2
// 1)“apple”
// 2)“banana”
增加 get *-price
可以返回水果的价格
sort fruits alpha get *-id
增加 store 进行保存
sort fruits alpha store sorted_fruits
SORT <key> [alpha] [DESC|ASC] [BY <by-pattern>] [LIMIT <offset> <count>] [GET <get-pattern>] [STORE <store_key>]
选项的执行顺序:
- 排序,使用 alpha、asc、desc、by 返回一个排序结果集
- limit 限制结果集长度
- get 获取外部键
- store 保存排列结果集
- 想客户端返回结果集
慢查询日志
慢查询日志 slowlog-log-slower-than
可以指定执行时间超过时间限制(微秒)会记录到慢查询日志上。
slowlog-max-len
可以记录服务器最多保存慢查询日志条数(FIFO淘汰)
struct redisServer{
// 下一条慢查询日志ID
// 初始为 0,每次都会使用这个 id,然后 id+1
long long slowlog_entry_id;
list *slowlog; // 慢查询日志链表
long long slowlog_log_slower_than; // 配置时间限制
unsigned long slowlog_max_len; // 配置条数限制
}
typedef struct slowlogEntry{
long long id; // 慢查询日志 id
time_t time; // 执行时间
long long duration; // 执行命令时长
robj **argv; //命令与命令参数
int argc; //命令与命令参数数量
}slowlogEntry;
监视器
客户端调用 monitor 命令可以变成服务器的一个监视器。
服务器端包含一个 *monitor
链表,保存了服务器的所有监视器,在服务器每次处理命令请求前都会先调用 replicationFeedMonitors 函数,这个函数会把被处理命令的相关信息发送给各个监视器
def replicationFeedMonitors(client, monitor, dbid, argv, argc){
msg = create_message(client, dbid, argv, argc);
for monitor in monitors:
send_message(monitor, msg);
}
应用
Redis 为什么快
- redis 基于 c 语言开发,语言接近底层运行速度相对 Java 之类的更快
- 是内存数据库,相比 MySQL 数据主要存储在磁盘,数据存取更快
- Redis 自定义了许多数据结构,并且所有键值对的键都在一个 全局字典 上,查询速度很快;并且对这个数据结构进行了优化,比如渐进式哈希来保证性能
- 采用单线程逻辑,避免了不必要的上下文切换和锁资源的消耗。基于多路复用 IO 模型的文件处理器可以处理大量的客户端连接请求
- rdb 持久化 和 aof 重写都有后台运行进程保证主进程不被阻塞
- 使用了自定义的文本协议,解析性能极好
- 可以使用管道进行加速,加快请求执行速度
和 mencached 比较
都是内存数据库,都有过期策略
redis 数据结构更加丰富;有持久化策略;内存满了之后会进行淘汰(mencached 直接报错);redis 过期策略有惰性删除和定时删除(m 无);redis 支持事务、Lua 脚本;Redis 支持原生 cluster 模式。
缓存数据的处理流程
首先用户发送请求之后,服务器会先判断缓存中是否有对应的数据,如果有就会直接返回缓存中的数据;如果没有,就会到数据库进行查询,如果有就会更新缓存数据并返回,如果没有就返回空数据。其中的一些判断流程已经进行了简化。
为什么要用Redis当作缓存
主要是为了提高整体的性能和并发能力。
从 Redis 中读取数据的速度非常快,如果把经常访问并且不经常改变的数据放到 Redis 中可以提高整个系统的查询速度,不过为了保持数据的一致性,在数据改变后及时同步到缓存中。
并且直接操作缓存可以承受的数据库请求数量远大于访问数据库,增加 Redis 当作缓存后可以提高系统并发能力。
缓存穿透
原因:数据误删;黑客攻击
用户请求后首先判断是否在缓存中,如果不在判断数据库是否有对应数据,最后没有才会返回空数据。
大量请求的 key 不在缓存中,导致请求都到了数据库上,根本没有经过缓存。
解决方法:
- 在判断前增加参数校验,不合法的参数直接返回客户端。
- 缓存无效 key:如果大量的不同无效 key 请求也会增加缓存的压力,需要将无效 key 设置过期时间
- 布隆过滤器:把所有可能存在的请求都放到布隆过滤器中,用户请求后先判断是否在布隆过滤器中,如果不在直接返回空数据。布隆过滤器可能会对某个元素的存在误判,因为布隆过滤器是根据元素的哈希值来判断数据是否存在的,而两个不同的元素可能会产生相同的哈希值;但是布隆过滤器判断某个元素不在,那么一定不存在。
布隆过滤器:布隆过滤器是由位数组和哈希函数构成;布隆过滤器的插入和查找时间复杂度都是O(1),但是布隆过滤器可能会误判,并且无法删除元素,因为本身判断元素是否存在就不是一定的。
可删除的过滤器:布隆过滤器中的 bitmap 改为 short数组;布谷鸟过滤器。
缓存击穿
原因:
热点数据过期
解决:
- 互斥锁
- 热点数据不设置过期时间
缓存雪崩
原因:大量数据同时过期;Redis 故障宕机。
缓存在同一时间大面积的失效,导致后面大量的请求直接落在数据库上。
针对 Redis 服务不可用的情况:采用 Redis 集群,提高可用性;限流,避免同时处理大量请求。
对于数据同时过期:
-
设置均匀过期时间(过期时间加一个随机数)
-
互斥锁(避免过多相同请求同时打到数据库)
如果访问的数据不在缓存中,那么设置一个互斥锁保证同一时间只有一个请求来构建缓存,等到缓存构建完后释放锁。但是需要设置阻塞时间,否则其他请求可能一直阻塞得不到结果
-
二级缓存(避免加锁阻塞其他请求,场景允许出现旧值)
一个数据设置两份缓存,一份是原始缓存设置短期过期时间,另一份为拷贝缓存设置长期过期时间。空间利用率较低
-
双 key 策略(相比二级缓存空间利用率更高)
双 key 分别为 过期时间 和 缓存数据。过期时间为短有效期,缓存数据为长有效期。请求来了之后先判断 key-time 是否过期,如果过期会先更新 key-time,然后更新 key-data;在这期间有其他请求访问会先返回旧值。
-
后台更新缓存
业务线程发现缓存数据失效后通过消息队列通知后台线程更新缓存
对于 redis 故障宕机:
-
服务熔断或请求限流
在 redis 故障时启动服务熔断机制,减少系统对数据库访问的压力,直到 redis 恢复后再解除熔断和限流
-
构建 redis 集群
如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了由于 Redis 故障宕机而导致的缓存雪崩问题。
如何保持缓存和数据库一致
缓存方式:
旁路缓存模式:服务器需要同时维系 数据库 和 缓存,并且以 数据库 的数据为准
- 读:从 cache 读取数据,读取到直接返回;没读到取 DB 读取数据,并把数据放到 cache 上
- 写:先更新 DB,再删除 cache
读写穿透模式:把 cache 当作主要数据存储,cache 服务负责将此数据读取和写入 DB
- 读:从 cache 读取数据,读到直接返回;没读到先从 DB 加载,写到 cache 后返回
- 写:先查 cache,cache 不存在直接更新 DB;存在则先更新 cache,然后 cache 服务更新 DB
异步缓存写入模式:和读写穿透模式类似,都是用 cache 服务来负责 cache 和 DB 的读写
异步缓存只更新缓存,不直接 DB,而是以异步的方式更新 DB,比如消息队列。
缓存数据量太大怎么办
可以使用 Redis Cluster ,Redis 集群可以解决大数据缓存问题,也方便进行横向拓展。
Redis 集群通过分片进行数据管理,并且提供复制和故障转移功能。
Redis 集群使用 hash slot 来进行管理,每个节点会分配一部分 hash slot;并且 Redis 集群是去中心化的,任何一台机器宕机,只需要将 hash slot 移到剩下的节点即可,增加节点只需要分配一些 hash slot 到新节点即可。
Redis 集群是分散式元数据维护模式,遵循 gossip 协议,各个节点交换的信息有当前节点负责的 slots 信息,当前节点的状态
在一个处于有界网络的集群里,如果每个节点都随机与其他节点交换特定信息,经过足够长的时间后,集群各个节点对该份信息的认知终将收敛到一致。
Gossip 协议:利用一种随机、带有传染性的方式,将信息传播到整个网络中,并在一定时间内,使得系统内的所有节点数据一致。即使集群的数量增加,每个节点的负载也不会增加很多,几乎是恒定的,因此 Redis 集群可以拓展数千个节点。
如何提高 Redis 可用性
如果数据量较小只有几个G的话,不需要引入 Redis 集群,只需要通过主从模式就可以提高可用性和读吞吐量。
主从模式一旦 master 宕机,slave 需要晋升成 master,并且需要修改应用方的主节点地址,其他从节点复制新的主节点。可以使用 哨兵模式
参考:
《Redis 设计与实现》
《Redis 深度历险 核心原理与应用实现》
极客时间 Redis 核心技术与时间
小林coding Redis 知识点总结