Redis

引言: redis:一个非关系型高性能键值对数据库

Redis核心内容总结

  1. 支持的数据结构有:字符串、列表、字典(哈希)、集合、有序集合
  2. 字符串的底层数据结构:SDS简单动态字符串(三个字段:指针free剩余的空间大小、len长度)
  3. 使用SDS替代C原本字符串的优点:
    • 获取字符串长度为O(1)级别(C中需要遍历一遍O(n))
    • 实现了空间预分配(扩大时会扩的更大一点)、惰性空间释放(缩小时不会立即缩小)
    • 二进制安全(使用len来判断字符串结尾,而不是C中的使用/0来判断)
  4. 列表的底层数据结构:数据少用压缩列表;数据多用双向链表
  5. 什么时候用压缩列表?满足两个条件:
    • 每个数据的长度不超过64
    • 数据总个数不超过512
  6. 哈希的底层数据结构:数据少用压缩列表;数据多用散列表
  7. 散列表的使用细节:
    • 解决哈希冲突:拉链法
    • 维护一个装载因子:装载因子大于1.0,就翻2倍左右;小于0.1,就缩小2倍左右
  8. 集合的底层数据结构:数据少用有序数组;数据多用散列表
  9. 什么时候用有序数组?满足两个条件:
    • 存储的数据全为数字
    • 总个数不超过512
  10. 有序集合的底层数据结构:数据少用压缩列表;数据多用跳表
  11. 跳表的实现细节:
    • 给链表隔一个元素建一个索引,在给索引隔一个建一个索引,直到最顶层建了两个索引为止
    • 插入、删除、查询都很快
    • **时间复杂度:O(log n)**(再细一点,如果是隔一个建立一个索引,那么时间复杂度是O(3 log n))
    • 通过一个随机函数来保持平衡(随机函数的选择很重要,但是不需要深究)
  12. 为什么使用跳表而不是红黑树?两个原因:
    • 跳表实现相对来说简单
    • 红黑树的范围查询性能没有跳表好
  13. 数据的持久化:
    • RDB:默认的方式,隔一段时间,就将当前redis库内的所有元素写入磁盘
    • AOF:日志记录的方式
  14. 各类数据结构的应用场景:
    • 字符串:可以统计网站人数、可以实现分布式锁(用setnx
    • 列表:可以实现栈、实现队列、实现消息队列
    • 哈希:可以实现一个购物车
    • 集合:可以实现社交领域发现共同好友、发现用户共同兴趣等
    • 有序集合:可以实现一个直播的动态排行榜

NOSQL

随着互联网web2.0网站的兴起,传统的关系数据库在处理web2.0网站,特别是超大规模和高并发的SNS类型的web2.0纯动态网站已经显得力不从心,出现了很多难以克服的问题

redis作为非关系型数据库的一员,在web2.0的交互中展露枝头

关系型数据库

例如:

  • Oracle
  • MySQL

是指采用了关系模型来组织数据的数据库,其以行和列的形式存储数据,以便于用户理解,关系型数据库这一系列的行和列被称为表,一组表组成了数据库。

特点:

  1. 数据之间有关联关系
  2. 数据存储在硬盘的文件上
  3. 表现为一个个的表格

非关系型数据库(NOSQL)

例如:

  • redis
  • hbase
  • mongdb

区别于关系数据库,它们不保证关系数据的ACID特性。(ACID指关系型数据库的原子性,一致性,隔离性,持久性)相对于铺天盖地的关系型数据库运用,这一概念无疑是一种全新的思维的注入。

但是一个共同的特点都是去掉关系数据库的关系型特性。
NoSQL有如下优点:易扩展,NoSQL数据库种类繁多

特点:

  1. 数据之间没有关系
  2. 数据存储在内存中
  3. 表现为key:value的形式

NOSQL与关系型数据库的比较

优点:

  1. 成本:NOSQL数据库简单易部署,基本都是开源软件,相比关系型数据库成本低廉
  2. 查询速度:NOSQL存储在缓存当中,查询速度非常快
  3. 存储格式为:key:value形式,文档形式,图片形式等,所以可以基础类型以及对象或是集合等格式,而关系型数据库只能存储基础类型

缺点:

  1. 维护的工具和资料有限:因为NOSQL属于新的技术,而关系型数据库已经有十几年的技术
  2. 不提供对SQL的支持:不支持SQL这样的工业标准,将产生一定用户的学习和使用成本,例如redis与hbase差别很大,而Oracle与mysql差别就很小
  3. 不提供关系型数据库对事务的处理

我们一般将数据存储在关系型数据库中,在NOSQL数据库中备份存储关系型数据库的数据

Redis

初识Redis

Redis:Remote Dictionary Server 远程字典服务

redis是一款高性能的NOSQL系列的非关系型数据库

redis是用c语言开发的高性能键值对数据库

常见的应用场景:

  1. 缓存(数据查询,短连接,新闻内容,商品内容等等)
  2. 聊天室的在线好友列表
  3. 任务队列(秒杀、抢购、12306等)
  4. 应用排行榜
  5. 网站访问统计
  6. 数据过期处理
  7. 分布式集群架构中的session分离

两大维度、三大主线

三大主线:高性能、高可靠、高可扩展

两大维度:应用维度、系统维度

Redis与三高

redis不同数据的操作

本节是关于Redis的命令操作

redis数据库操作

1
2
3
4
5
6
7
8
9
10
ping //测试一个连接是否还可用,如果不加任何内容返回pong,加了内容返回它本身
select [index] // 切换数据库[1-15]
dbsize //查看当前数据库大小
keys * // 查看所有的key
flushdb // 清除当前数据库
flushall // 清除全部数据库的内容
exist key // 是否存在这个key
expire key [n] // 设置过期时间(默认为秒)
ttl key // 查看key的过期时间
type key //查看当前key的类型

redis的数据结构

支持的五大数据结构:

  1. 字符串类型 string
  2. 哈希类型 hash
  3. 列表类型 list
  4. 集合类型 set
  5. 有序集合类型 sortedset (Zset)

另外还能存储几种特殊数据结构:

  1. HyperLogLogs:省内存的去统计各种计数,比如注册 IP 数、每日访问 IP 数的页面实时UV、在线用户数,共同好友数等
  2. Bitmap:两个状态的,都可以使用 Bitmap,比如登录未登录、性别
  3. geospatial :推算地理位置的信息: 两地之间的距离, 方圆几里的人;底层是Zset,有序列表
  4. stream:用于消息队列和发布/订阅模式

字符串类型

Redis中的字符串

  • string类型是二进制安全的,意思是 Redisstring 可以包含任何数据(图片、对象等等)
  • key最大可以是512Mb

基本的操作

1
2
3
set key value // 存储
get key //查找
del key //删除

追加与长度获取

1
2
append key xxx // 给一个key追加字符串
strlen key // 查看这个key的长度

自增与自减(可以使用这个来进行计数,比如统计系统在线人数)

1
2
3
4
incr key // 给一个key的值+1(必须是数值)
decr key // 给一个key的值-1
incrby key n // 步长为n +n
decrby key n // 步长为n -n

获取子串

1
2
getrange key [start] [end] //返回子串[start,end],如果end是-1,代表末尾
setrange key [index] [newStr] // 将从index开始用newStr替换原来的字符串

ex与nx(setnx可以实现分布式锁)

1
2
setex key [second] [value] // 设置值附带过期时间
setnx key value// 如果不存在此key,则设置;存在则不设置(分布式设置乐观锁)

批量操作(原子操作,但注意,Redis单线程,如果一次传输数据太多,会导致Redis阻塞或者网络拥塞)

1
2
3
mset key1 value1 k2 v2 k3 v3 // 批量创建
mget k1 k2 k3 k4 //批量获取
msetnx // 不存在则设置也支持批量

存放对象

1
mset user:1:name zhangsan user:1:age 3 // mset 对象:{id}:field value

getset

1
getset //设置新值,返回旧值

总结:表格来自于

命令 描述 用法
SET (1)将字符串值Value关联到Key(2)Key已关联则覆盖,无视类型(3)原本Key带有生存时间TTL,那么TTL被清除 SET key value [EX seconds] [PX milliseconds] [NX|XX]
GET (1)返回key关联的字符串值(2)Key不存在返回nil(3)Key存储的不是字符串,返回错误,因为GET只用于处理字符串 GET key
MSET (1)同时设置一个或多个Key-Value键值对(2)某个给定Key已经存在,那么MSET新值会覆盖旧值(3)如果上面的覆盖不是希望的,那么使用MSETNX命令,所有Key都不存在才会进行覆盖(4)MSET是一个原子性操作,所有Key都会在同一时间被设置,不会存在有些更新有些没更新的情况 MSET key value [key value …]
MGET (1)返回一个或多个给定Key对应的Value(2)某个Key不存在那么这个Key返回nil MGET key [key …]
SETEX (1)将Value关联到Key(2)设置Key生存时间为seconds,单位为秒(3)如果Key对应的Value已经存在,则覆盖旧值(4)SET也可以设置失效时间,但是不同在于SETNX是一个原子操作,即关联值与设置生存时间同一时间完成 SETEX key seconds value
SETNX (1)将Key的值设置为Value,当且仅当Key不存在(2)若给定的Key已经存在,SEXNX不做任何动作 SETNX key value

Redis字符串的应用

应用场景:

Redis单线程,不需要考虑并发

  • setnx来设置分布式锁
  • incrby进行计数、统计网站人数

哈希类型

以Java来理解,Redis的哈希类型就是Map<String, Map<String, Object>>

1
2
3
4
hset key field value // 存储
hget key field //获取对应的value
hgetall key //获取所有的键和值
hdel key field //删除

redis-cli示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//设置
127.0.0.1:6379> hset person name zhangsan
(integer) 1
127.0.0.1:6379> hset person user lisi
(integer) 1
//获取
127.0.0.1:6379> hget person name
"zhangsan"
127.0.0.1:6379> hget person user
"lisi"
//获取所有
127.0.0.1:6379> hgetall person
1) "name"
2) "zhangsan"
3) "user"
4) "lisi"
//删除
127.0.0.1:6379> del person name
(integer) 1
127.0.0.1:6379> del person user
(integer) 0
127.0.0.1:6379> hgetall person
(empty list or set)

列表类型

Redis中的list

redis内的列表是一个横向的列表,可以添加到左边或者右边

redis列表是一个有序的列表,而且可以重复

1
2
3
4
5
6
7
8
9
10
//添加
lpush key value //将元素加入列表左边
rpush key value //将元素加入列表右边

//获取
lrange key start end //范围获取,从0开始

//删除
lpop key //删除列表最左边的元素,并将元素返回
rpop key //删除列表最右边的元素,并将元素返回

redis-cli示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//添加到list
127.0.0.1:6379> lpush list a
(integer) 1
127.0.0.1:6379> lpush list b
(integer) 2
127.0.0.1:6379> rpush list c
(integer) 3
127.0.0.1:6379> lrange list 0 -1 //end为-1代表查找所有
1) "b"
2) "a"
3) "c"
127.0.0.1:6379> lpop list
"b"
127.0.0.1:6379> rpop list
"c"

Redis列表的应用

Redis的列表特点:有左右、有序、可重复

  • 实现栈(lpushlpop
  • 实现队列(lpushrpop
  • 实现消息队列(lpushbrpop

brpop命令:移出并获取列表的最后一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。

集合类型

Redis集合类型

特点:

  • 不允许重复

  • 无序

1
2
3
4
5
6
//存储
sadd key value
//获取
smembers key
//删除
srem key

redis示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 127.0.0.1:6379> sadd myset a b c d e f g
(integer) 7
127.0.0.1:6379> smembers myset
1) "d"
2) "b"
3) "g"
4) "c"
5) "a"
6) "e"
7) "f"
127.0.0.1:6379> srem myset g
(integer) 1
127.0.0.1:6379> sadd myset a a b b
(integer) 0
127.0.0.1:6379> smembers myset
1) "c"
2) "a"
3) "d"
4) "e"
5) "b"
6) "f"
127.0.0.1:6379>

Redis集合的应用

可以利用集合的并、交特性,在社交领域,可以求共同好友、发现用户共同兴趣等

有序集合类型

Redis有序集合Zset

特点:

  • 不允许重复
  • 有序
1
2
3
4
5
6
//存储,存储数据以及数据对应的值
zadd key score value
//获取
zrange key start end
//删除
zrem key value

redis示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
127.0.0.1:6379>  zadd mysort 60 zhangsan
(integer) 1
127.0.0.1:6379> zadd mysort 50 lisi
(integer) 1
127.0.0.1:6379> zadd mysort 80 wangwu
(integer) 1
127.0.0.1:6379> zrange mysort
(error) ERR wrong number of arguments for 'zrange' command
127.0.0.1:6379> zrange mysort 0 -1
1) "lisi"
2) "zhangsan"
3) "wangwu"
127.0.0.1:6379> zrange mysort 0 -1 withscores//带上withscore会显示分数
1) "lisi"
2) "50"
3) "zhangsan"
4) "60"
5) "wangwu"
6) "80"

Redis有序集合的应用

可以做排行榜、也可以用于社交领域

Redis底层数据结构

Redis的底层数据结构,支持了上一节五大基本结构的使用

redis底层数据结构

  • String:简单动态字符串(注意:String也有第二个数据结构:当你保存的是一个64位有符号整数时,String会保存为一个8字节的Long类型整数,这也叫int编码方式)
  • List:压缩列表;双向链表
  • Hash:压缩列表;散列表
  • Set:整数数组;散列表
  • Sorted Set:压缩列表;跳表
1
OBJECT ENCODING [key] # 此命令可以查看底层的数据结构 

SDS简单动态字符串

使用自己构建的名为SDS(Simple dynamic string)简单动态字符串

1
2
3
4
5
struct sdshdr{
int len; //记录字符串长度
int free; // 记录未使用的字节数
char buf[];//字节数组,用于保存字符串
}

使用如此的结构,极大的提高了性能:

SDS结构示意

  1. O(1)级别获取字符串长度;在C中,需要进行遍历,为O(n)
  2. 杜绝缓冲区溢出;C中,进行strcat等字符串操作,如果没有分配足够长度空间,容易导致缓冲区溢出;而对于SDS数据类型,字符串修改会首先根据len属性检查内存是否符合要求
  3. 减少修改字符串的内存重新分配次数;SDS可以进行空间预分配惰性空间释放
    • 空间预分配:当字符串扩展时,会扩展更大的空间
    • 惰性释放:当字符串缩小,会增大free的值,但是空间不一定会跟着变化
  4. 二进制安全:C中使用/0空字符作为字符串结束位,但是对于图片等二进制文件,内容可能有空字符,因此SDS用len来判断字符串是否结束
  5. 兼容部分C字符串函数:使用C语言实现,可以兼容部分C的API

压缩列表

压缩列表:

​ 通过一片连续的内存空间来存储数据(类似于C的数组)

​ 但是和数组的区别是,存放的数据大小允许不同

压缩列表的特点:

  • 压缩列表在表头有三个字段 zlbyteszltail zllen,分别表示列表长度、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。
  • 如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段 的长度直接定位,**复杂度是 O(1)**。
  • 而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了。

优点:

  1. 允许存储不同类型的数据
  2. 节省空间

Redis什么时候使用压缩列表的实现?

数据量小的时候才会使用,需要同时满足:

  1. 保存的单个数据小于64字节
  2. 列表中的数据总个数小于512个

双向链表

双向循环链表:

​ Redis的list结构体额外定义一个list的结构体

链表节点:(这与基本的双向列表一样)

1
2
3
4
5
6
7
8
typedef  struct listNode{
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
void *value;
}listNode

双向链表:(额外定义list的结构体)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct list{
//表头节点
listNode *head;
//表尾节点
listNode *tail;
//链表所包含的节点数量
unsigned long len;
//节点值复制函数
void (*free) (void *ptr);
//节点值释放函数
void (*free) (void *ptr);
//节点值对比函数
int (*match) (void *ptr,void *key);
}list;

特点:

  • 双端:双向链表,获取头尾元素均为O(1)
  • 无环:头尾均指向NULL
  • 带链表长度计数器:获取链表长度为O(1)
  • 多态:链表节点使用void*指针,可以保存不同元素

散列表

散列表

哈希算法:Redis 使用 MurmurHash2这种运行速度快、随机性好的哈希算法作为哈希函数

哈希冲突解决:拉链法

数据动态增加之后,散列表的装载因子会不停地变大。为了避免散列表性能的下降:

装载因子 = 已存储元素数量 / 哈希表大小

  • 装载因子大于 1 的时候,Redis 会触发扩容,将散列表扩大为原来大小的 2 倍左右

  • 为了节省内存,当装载因子小于 0.1 的时候,Redis 就会触发缩容, 缩小为字典中数据个数的大约 2 倍大小

(2倍左右,是估计值!)

有序数组

什么时候使用有序数组

  • 存储的数据都是整数
  • 存储的数据元素个数不超过512个

在集合中如果不满足这些,就用散列表实现集合

整数数组和压缩列表在查找时间复杂度方面并没有很大的优势,那为什么 Redis 还会把它 们作为底层数据结构呢?

虽然没有什么时间复杂度的优势,但是胜在其使用数组,有两个特点:

  • 节省空间
  • 没有内存碎片

跳表

跳表:一个链表,为了提高它的查询速度,隔一个设置一个索引,用来加快查找,为了加快索引的搜索,再对索引隔一个设置一个索引

每一层索引都是一个链表,这样构成的数据结构就是跳表

跳表

特点:

  1. 由很多层结构组成;
  2. 每一层都是一个有序的链表,排列顺序为由高层到底层,都至少包含两个链表节点
  3. 最底层的链表包含了所有的元素;
  4. 如果一个元素出现在某一层的链表中,那么在该层之下的链表也全都会出现(上一层的元素是当前层的元素的子集);
  5. 链表中的每个节点都包含两个指针
    • 一个指向同一层的下一个链表节点
    • 另一个指向下一层的同一个链表节点;
  6. 插入、删除、查询都很快
  7. 通过随机函数来维护自平衡

有多快?

如果总共有 n个 节点

那么会建立log2N的高度的节点

查询一个数据的时间复杂度为O(m*log n)

m 是多少? m=3,因为每一层最多遍历三个节点(因为索引是隔一个抽一个建立起来的)

如何维护自平衡?通过一个随机函数

比如随机函数生成了值 K,那我们就将这个结点添加到第一级到第 K 级这 K 级索引中。

随机函数的选择比较重要,这也比较复杂,我们无需研究。

为什么选跳表不用红黑树?

  1. 按照区间来查找数据这个操作,红黑树的效率没有跳表高(虽然增删查的时间复杂度一样)
  2. 跳表更容易实现(相比较而言简单)

为什么SortedSet用跳表?

跳表的数据结构可以看做:

1
2
3
4
5
6
public class Node {
private int data = -1;
// 存放跳转的索引
private Node[] forwards = new Node[MAX_LEVEL];
private int maxLevel = 0;
}

跳表式依赖于概率算法的:比如现在要新加入一个节点,需要决定该节点的层级

理论上来讲,一级索引应该占50%,二级索引占25%,三级索引占12.5%等等以此类推

决定当前节点层级是通过随机来决定的,晋升的概率为1/2。

1
2
3
4
5
6
7
8
9
private static final float PROB = 0.5 f;
private int randomLevel() {
int level = 1;
// 如果随机数大于0.5,就允许晋升
while (Math.random() > PROB && level < MAX_LEVEL) {
++level;
}
return level;
}

对于有序Set来说,一般需要支持的操作有:

  1. 查一个数据
  2. 查区间数据

红黑树与跳表比较来说,对于前三个操作基本一致,但是查区间数据会慢一些,且实现要比跳表难一些。

为什么跳表范围查询比红黑树快?

红黑树是老老实实一级一级查的,但是跳表是顺序和跳跃的结合,这使得跳表可以快速跳跃到目标区间的起点,而一旦找到起点后,可以直接顺序遍历底层链表。

深入Redis数据结构

占内存的String

前面说了String的底层,由SDS构成,但其实,String类有很深的门道,这节我们来说一下


String有三种编码方式:

  • int编码:在保存64位有符号整数时
  • embstr编码:在保存的字符串小于44字节时
  • raw编码:大于44字节时

下图是RedisObejct的示意图

三种编码在内存中的示意图

可见,三种编码方式,在内存中的实现是不一样的,int编码的数据就直接在指针里放着,节省内存;embstr的SDS在内存中与RedisObejct紧挨着(这样可以避免内存碎片)


这里额外补充一下:

  • RedisObejct是记录数据是什么类型的结构体,由两部分组成,元数据与类型指针

  • 对于全局哈希表,它的每一项都是一个DictEntry结构体,这个结构体存放三个数据(下一节):key、value、next

内存结构是这么存的:

存储结构

假如我们用String保存2个10位的数,会消耗多少内存呢?

由于存10的数字,所以会直接占用指针的8个字节,元数据用8个字节,共有两个会占用32字节;

DictEntry占24字节,但是Redis 使用的内存分配库 jemalloc只会分配2的幂次的大小,所以分配32字节

所以占用了64字节,但是有效的信息,只有16字节,所以说String的开销很大!利用率不高

使用压缩列表

String消耗内存很多,当存的数据只是简单的数字时,越多String消耗的空间越大,但实际上根本没有利用多少

压缩列表就是一个很节省内存的例子


压缩列表在表头有三个字段 zlbyteszltail zllen,分别表示列表长度、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束

压缩列表的每一个entry,由四个部分构成:prev_lenencodinglencontent

  • prev_len:表示前一个entry的长度,只有两种取值情况,要么占1B、要么占5B
    • 如果上一个entry小于254字节,那么就取1B;否则取5B
  • encoding:编码方式,1B
  • len:表示自身长度,占4B
  • content:保存实际数据

压缩列表结构

用一个RedisObejct,就可以存一堆数,这可比存一个数就要一个RedisObejct的String要省内存多了

Redis什么时候使用压缩列表的实现?

数据量小的时候才会使用,需要同时满足:

  1. 保存的单个数据小于64字节
  2. 列表中的数据总个数小于512个

这两个参数都可以调节

全局哈希表

Redis使用了两个全局哈希表

全局哈希表:如图,value为一个一个节点,节点才会去存真正的数据类型

全局哈希表

这个全局的哈希表中,每一项是一个dictEntry节点,这个dictEntry结构体包含三个部分(图中只画了两个):key、value、next(指向下一个dictEntry)

那么这里有几个问题,带着问题来思考

全局哈希表如何解决哈希碰撞?

是使用拉链法来解决哈希冲突的

如果拉链变的十分长,导致查询速度下降,那么Redis是怎么保证高性能呢?

​ 当Redis发现存放的数据达到一定程度,就会进行rehash,使用两个哈希表来进行交替,这样即使在rehash过程中,也不会无法进行服务

rehash的过程

  1. 给哈希表2分配更大的空间(比如是哈希表1的两倍)
  2. 将哈希表1的数据重新映射并拷贝到哈希表2中
  3. 释放哈希表1的空间

如果哈希表2再满了,那么再次重复这个过程

渐进式rehash

如果真的采用上面的rehash过程,那么在2步过程中,由于copy浪费的时间,将导致redis不能进行服务

所以Redis是渐进式rehash的(即每次的请求,都捎带着copy一个桶上的索引)

渐进式rehash

可以保证在copy过程中,也能继续保持服务

单线程数据库Redis

Redis是单线程的吗?

严格意义上来说,Redis不是单线程的;

常说的Redis是单线程,是指其提供的网络IO 和 键值对读写是由一个线程来完成的

为什么使用单线程?

多线程其实并不会快很多,对于一些共享数据,多线程需要加锁进行操作,这种情况下,多线程的性能不会比单线程高,只会让设计上更加复杂

为什么使用单线程,Redis还这么快?

几方面原因:

  • 处理方面:redis是单线程的,单线程无需考虑并发操作,无需加锁
  • 内存方面:redis的底层数据结构比较快,比如跳表
  • 持久化方面:有AOF这种快速的日志机制。
  • 网络方面:使用epoll网络编程模型

多路复用机制

以一个Redis的GET请求为例,基本的IO模型如图

基本IO模型

整个过程如下

  1. Redis要先监听客户端请求(bind/listen)
  2. 建立连接(accept)
  3. 读取请求(recv)
  4. 解析客户端发送的请求(parse)
  5. 从数据库获取数据(get)
  6. 返回发送给客户端(send)

其中2与3是阻塞点

(2:redis监听到连接请求,但是一直未能成功连接,导致其他客户端无法与redis连接;3:读取的数据如果一直没有到达,也会造成阻塞)

如何解决基本的网络IO带来的阻塞?

Socket网络模型支持非阻塞模式

非阻塞:即在遇到阻塞后,不在原地等待,而是先去执行其他操作,隔段时间再来观察是否还阻塞

Socket 网络模型的非阻塞模式设置,主要体现在三个关键的函数调用上:

调用方法 返回套接字类型 非阻塞模式 效果
socket() 主动套接字
listen() 监听套接字 可设置 accept()非阻塞
accept() 已连接套接字 可设置 send()/recv()非阻塞
  1. socket()方法会返回主动套接字

  2. 然后调用 listen() 方法,将主动套接字转化为监听套接字,此时,可以监听来自客户端的连接请求

  3. 调用 accept() 方法接收到达的客户端连接,并返回已连接套接字

因为可以设置非阻塞,所以Redis在accept()recv()迟迟没有得到反映,就可以先进行其他操作


虽然Redis可以不继续等待,但是总得有机制在监听套接字上等待,这就用到了Linux的IO多路复用机制

多路复用机制:

​ IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制

select/epoll 机制:可以快速看一下这篇blog,图画的非常nice,一眼就懂了

这里贴一下图,图源在上面已经说了

  • select
    • 处理所有的fd,进行挨个询问
    • 水平触发:如果不处理,下次会继续通知
  • epoll
    • 所只处理那些就绪的fd
    • 支持水平触发边缘触发:只通知一次,如果我没有处理,只有到下次状态发生实际变化才会通知

select

epoll

Redis中的高性能IO模型

图源自极客时间

回调机制:

​ select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数

​ 这些事件会被放进一个事件队列,Redis 单线程对该事件队列不断进行处理。

​ 这样一来, Redis 无需一直轮询是否有请求实际发生,这就可以避免造成 CPU 资源浪费

AOF详解

redis是一个内存数据库,储存在内存当中,关闭后数据会丢失,但是Redis提供了两种持久化方式:RDB、AOF


AOF (Append Only File): 日志记录的方式,可以记录每一条命令的操作,可以每一次命令操作后,持久化数据

AOF日志的工作比较特殊,不同于传统的WAL,他是先执行操作,再写数据

为什么AOF日志要先执行操作,后写入日志呢?

好处有两个:

  1. 避免检查语法错误:因为只有执行成功,才会被写入日志
  2. 命令执行后再写,不会阻塞当前的操作

不过有两个潜在的风险:

  1. 如果操作执行完,宕机,会导致日志上并没有记录(数据会丢失,不再可以使用日志进行恢复)
  2. 虽然避免了阻塞当前操作,但可能会阻塞下一个操作(因为写日志是需要操作磁盘的!)

如果我们可以找好时机写入磁盘,那么这两个风险会好一点:Redis提供了三种写回策略

三种写回策略

AOF 机制给我们提供了三个选择,也就是 AOF 配置项 appendfsync 的三个可选值:

  • Always:每一个操作都写回日志
  • Everysec:每秒写回,先写在缓冲区,每隔一秒写回一次
  • No:先写在缓冲区,操作系统控制何时写回
配置项 写回时机 优点 缺点
Always 同步写回 可靠性好,数据基本不丢失 影响性能
Everysec 每秒写回 性能适中 宕机丢失1s数据
No 操作系统控制写回 性能好 宕机丢失数据较多

不管哪一种写回方式,随着日志越写越多,终究会导致性能问题,所以AOF还提供了重写机制

AOF重写机制

重写:就是在重写时,Redis 根据数据库的现状创建一个新的 AOF 文件

这个文件会比原来的小很多,因为它只记录结果!

重写机制

并且 和AOF 日志由主线程写回不同,重写过程是由后台线程 bgrewriteaof 来完成的

重写的过程:一个拷贝、两个日志

​ 一个拷贝:主线程fork出后台线程bgrewriteaof注意:fork这一操作的过程是阻塞了主线程的!

​ fork操作会把主线程的内存拷贝一份bgrewriteaof 子进程,这里面就包含了数据库的最新数据(也就是说,如果主线程的内存越大,这个拷贝的时间也会越长

两个日志:

  • 正常使用的AOF日志
  • AOF重写日志

重写过程

RDB详解

另一种Redis的持久化方式

RDB (Redis DataBase) : 默认方式,不需要进行配置,默认就使用这种机制,一定时间间隔,检测key的变化情况,然后持久化存储

类似于快照,会将当前内存所处的状态,生成一个RDB文件

和AOF比,RDB 记录的是某一时刻的数据并不是操作

所以,在做数据恢复时,我们可以直接把 RDB 文件读入内存,很快地完成恢复

RDB快照

Redis 提供了两个命令来生成 RDB 文件,分别是 savebgsave

  • save:在主线程中执行,会导致阻塞;
  • bgsave:创建一个子进程,专门用于写入 RDB 文件,避免了主线程的阻塞,这也是 Redis RDB 文件生成的默认配置

在进行快照时,数据可以改变吗?

可以改变,Redis采用COW(写时复制)的思想来实现备份时也可以服务

  • 如果是读操作:(例如图中的键值对 A),那么,主线程和 bgsave 子进程相互不影响
  • 如果是写操作:(例如图中的键值对 C), 那么,这块数据就会被复制一份,生成该数据的副本。然后,bgsave 子进程会把这个副本数据写入 RDB 文件,而在这个过程中,主线程仍然可以直接修改原来的数据

COW

RDB的缺点

虽然RDB可以快速的进行恢复,而且也可以在备份时继续服务

但是隔多久进行一次快照并不好把控

  • 隔得时间短,影响性能
  • 隔得时间长,如果宕机会丢失很多数据

Redis提出了增量快照

增量快照:即在一次全量快照后,之后的更改只记录修改的部分

Redis4.0混合AOF与RDB

Redis 4.0 中提出了一个混合使用 AOF 日志和内存快照的方法。

简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作

这个方法既能享受到 RDB 文件快速恢复的好处,又能享受到 AOF 只记录操作命令的简单优势

Redis主从复制

为了保证数据故障恢复时也可以进行服务(Redis为了可以提供服务操碎了心)Redis也有主从机制

主从库模式

Redis主从库承担了不同的任务,采用读写分离的方式

  • 主库:负责读操作、写操作
  • 从库:只负责读操作

写操作只可以对主库进行

主从库

为什么要采用读写分离的方式?

可以避免主从库之间进行数据协调,只需要让主库同步从库即可(如果都可以进行写操作,那么会很混乱)

第一次同步

从库如何成为主库的从库?

一行代码即可:

1
2
3
4
5
# 现在有实例 1(ip:172.16.19.3)和实例 2(ip:172.16.19.5)
# 实例2键入
replicaof 172.16.19.3 6379
# 实例2就变为了实例1的从库
# Redis 5.0之前用 slaveof命令

第一次同步有三个阶段:

第一次同步过程

  1. 从库发起连接请求(psync a b包含两个参数,a为主库的runId,由于不知道主库runID,所以为?;b为复制进度,第一次复制为-1);主库收到后,会使用FULLRESYNC命令,表示这是一次全量复制

  2. 主库将所有数据同步(即一个RDB块)给从库。从库收到数据后,清除原本的数据,并在本地完成数据加载

    (主库发送RDB后会在内存中用专门的 replication buffer,记录 收到新的写操作)

  3. 主库会把第二阶段执行过程中新收到的写命令,再发送给从库

主从级联模式

如果主库频繁的从库创建RDB、发送RDB

这会影响主库的性能(因为fork会阻塞主线程,而且越大,fork所需要的时间会越长)

所以为了解决这个问题,有了主 - 从 - 从模式

即部署主从集群的时候,可以手动选择一个从库(比如选择内存资源配置 较高的从库),用于级联其他的从库

这个从库就可以帮主库分担压力

主从从模式

网络闪断与repl_backlog_buffer缓存

网络闪断:即网络不好,短短续续

如果在Redis2.8版本之前,出现了网络闪断,在重新连接后,会重新进行一次全量复制,开销极大

Redis2.8版本之后,网络断了之后,主从库会采用增量复制的方式继续同步

​ 当主从库断连后,主库会把断连期间收到的写操作命令,写入replication buffer同时也会把这些操作命令也写入 repl_backlog_buffer这个缓冲区

repl_backlog_buffer是一个环形缓冲区,随着主库不断接收新的写操作,它在缓冲区中的写位置会逐步偏离起始位置

这个缓存有两个指针:如图,分别为主库读的指针、从库读的指针

增量复制的实现

连接恢复之后,从库首先会给主库发送 psync 命令,并把自己当前的 slave_repl_offset 发给主库

主库会判断自己的 master_repl_offset slave_repl_offset 之间的差距,只用把master_repl_offsetslave_repl_offset 之间的命令操作同步给从库就行

哨兵机制

主库提供了所有的写服务,如果主库挂掉,那么整个系统将陷入瘫痪

Redis使用哨兵机制,来解决这个问题

Redis哨兵机制:在主库挂掉后,将从从库中选出一个作为新的主库

哨兵

哨兵:即运行在特殊模式下的Redis进程

哨兵有三个任务:

  1. 监控:周期性的给主库发送PING,检测主库是否在运行
  2. 选择主库:从众多的从库中筛选一个作为主库
  3. 通知:把新主库的连接信息发送给从库

其实这三个功能都是围绕着一个流程来进行的

监控——主观下线与客观下线

哨兵如何判断一个主库已经挂掉了呢?

哨兵会周期性给主库、从库发送PING请求,如果主从库没有在规定时间内回应:

  • 从库没有回应:标记为主观下线
  • 主库没有回应:有可能主库真的下线了(此时会进行主从切换),也有可能是哨兵误判了

所以需要有很多哨兵来进行对主库是否下线的判断,这就称为哨兵集群

所以,当多数的哨兵都认为主库已经下线,那么此时主库就会被认定为客观下线

客观下线的标准就是:当有 N 个哨兵实例时,最好要有 N/2 + 1 个实例判断主库为“主观下线”,才能最终判定主库为“客观下线”


监控的具体流程:

  1. 当一个哨兵判断主库主观下线后,就会给其他哨兵发送is-master-down-by-addr
  2. 其他的哨兵会根据自己的判断,投出Y/N(即赞成票和不赞成票)
  3. 当赞成票大于quorum参数时,就会认为主库客观下线

哨兵投票仲裁

主库挂掉,哪一个哨兵来执行主从切换的任务呢?

是由Leader来执行,leader也是由投票选举产生的:

在投票过程中,任何一个想成为 Leader 的哨兵,要满足两个条件

  1. 拿到半数以上的赞成票
  2. 拿到的票数同时还需要大于等于哨兵配置文件中的 quorum

哨兵是如何确定Leader投票是投是还是否呢?

它会给第一个向它发送投票请求的哨兵回复 Y,给后续再发送投票请求的哨兵回复 N

选主——选择新主库

哨兵如何选择主库?

整个过程分为两步:筛选 + 打分

​ 简单来说,我们在多个从库中,先按照一定的筛选条件,把不符合条件的从库去掉。然后,我们再按照一定的规则, 给剩下的从库逐个打分,将得分最高的从库选为新主库

(从库都在卷~)

筛选条件:除了要检查从库的当前在线状态,还要判断它之前的网络连接状态(通过配置一个最大连接时长,默认如果发生断连的次数超过10次,就认为这个从库的网络状况不好,就被miss掉了)

打分规则:别按照三个规则依次进行三轮打分,这三个规则分别是从库优先级从库复制进度以及从库 ID 号

三轮打分过程:

  1. 优先级最高的从库得分高(不同的从库可以设置不同的优先级,一般给内存大的从库设定一个高的优先级)
  2. 和旧主库同步程度最近的从库得分高(通过repl_backlog_buff缓存中的指针,详情见上一节)
  3. ID号小的从库得分高:默认的规定,在优先级和复制进度都相同的情况下,ID 号最小的从库得分最高,会被选为新主库

通知——发布/订阅机制

哨兵之间是如何进行交流的?

要弄清楚这个问题,首先得知道如何设置一个哨兵:

1
2
sentinel monitor <master-name> <ip> <redis-port> <quorum>
# 需要主库的ip、端口号

此时哨兵之间是不知道彼此的

哨兵实例之间可以相互发现,要归功于 Redis 提供的 pub/sub 机制,也就是发布 / 订阅 机制

发布订阅机制:订阅了同一个频道的应用,可以通过发布的消息进行信息交换

频道有很多:

Redis频道

哨兵只要和主库建立起了连接,就可以在主库上发布消息了,比如说发布它自己的连接信息(IP 和端口)

同时,它也可以从主库上订阅消息,获得其他哨兵发布的连接信息。当多个哨兵实例都在主库上做了发布和订阅操作后,它们之间就能知道彼此的 IP 地址和端口。

举个例子,具体说明一下。

在下图中,哨兵 1 把自己的 IP(172.16.19.3)和端口 (26579)发布到“__sentinel__:hello”频道上,哨兵 2 和 3 订阅了该频道。那么此时,哨兵 2 和 3 就可以从这个频道直接获取哨兵 1 的 IP 地址和端口号。

发布/订阅

哨兵是如何知道从库的IP的?

哨兵向主库发送 INFO 命令,主库接受到这个命令后,就会把从库列表返回给哨兵

Redis缓存

缓存处理请求的两种情况

缓存接收请求后,无非两种情况:

  • 缓存命中:缓存中有数据,可以直接读
  • 缓存缺失:缓存中没有数据,必须要去数据库中读取;而且读取完成后,还必须把这个数据再加载到缓存中

注意:缓存缺失后,除了要访问数据库,还要把新的数据写到缓存内(缓存更新)

Redis的缓存类型

Redis中缓存由两种类型,我们可以根据业务的不同来选择不同的缓存:

  • 只读缓存:只读缓存只接受读请求,对于写请求直接交给数据库处理
  • 读写缓存:读写请求接收读、写请求

只读缓存对于不同的请求:

  1. 写请求:只读缓存会删除当前缓存
  2. 读请求:如果读不到,会发生缓存缺失,在读完数据库后进行缓存更新

只读缓存

读写缓存对于不同的请求的处理:

  1. 读请求:与只读缓存一样
  2. 写请求:直接在缓存中进行更改

在读写缓存中,由于写请求在缓存中就进行了处理,所以必须要写回磁盘,这时就有两种不同的写回磁盘的策略

  • 同步直写:服务器向缓存和数据库同时发起请求,缓存处理完成后,需要等待数据库也处理完成后,才会返回响应
  • 异步写回:缓存处理完数据后直接返回,而且数据只有在被淘汰出缓存时才会写回磁盘

同步直写&异步写回

对比 同步直写 异步写回
优点 即使缓存发生故障,数据也不会丢失 访问快速
缺点 等待数据库,降低了缓存的访问性能 缓存发生故障会导致数据直接丢失

Redis的缓存淘汰机制

Redis有两种淘汰策略:

  1. 惰性删除:redis不主动删除过期键,而是处理每一个请求时,判断是否过期,如果过期就删除。
  2. 定期删除:每隔一段时间(默认100ms)随机抽查一部分键,根据不同的策略淘汰键。

Redis4.0定期删除有 8 种淘汰策略:

淘汰策略

  • noeviction:不会淘汰数据,如果缓存满了,将不再提供服务(默认策略)
  • volatile开头的淘汰策略:代表淘汰的选择源是所有有时间限制的数据,不会淘汰那些无时间限制的数据(即只淘汰那些设置EXPIRE的数据)
  • allkeys开头的淘汰策略:淘汰的选择源是所有数据
  • LRU:基于LRU算法进行淘汰
  • LFU:基于LFU算法进行淘汰
  • random:随机进行淘汰
  • volatile-ttl:淘汰最早的请求(通过TTL判断)

如何选择?

  • 优先使用allkeys-lru策略:可以充分利用 LRU 这一经典缓存算法的优势,把最 近最常访问的数据留在缓存中

  • 如果你的业务中有类似置顶的需求(比如置顶新闻、置顶视频):可以使用 volatile-lru 策略,同时不给这些置顶数据设置过期时间。这样一来,这些需要置顶的数据一直不会被删除,而其他数据会在过期时根据 LRU 规则进行筛选

淘汰的数据如何处理?

一般来讲,对于干净页直接删除,但是对于脏页要写回数据库后再删除。

但是在Redis中,不管干不干净,只要认定淘汰,就会直接删除

Redis中的LRU算法

由于LRU需要借助链表,而且涉及到了链表的很多断开、连接的操作

所以如果缓存中的数据很多,那么直接使用LRU算法,显然会造成很大负担

LRU算法就不介绍了,在Redis中的特殊处理,要说一下


首先要知道RedisRedisObejct中,保存了lru字段记录最近一次的访问时间戳

  • 第一次LRU淘汰:随机选出N个数据,把他们作为一个候选集合,接下来,Redis会比较这N个数据的lru字段,将数值最小的(也就是好久没有访问的)淘汰

  • 之后的每一次淘汰:挑选一些数据进入候选集合,挑选的标准是该数据的lru字段的值比候选集合里最小的lru还要小,然后淘汰lru字段最小的数据

经过这样的特殊处理,Redis就既实现了LRU,也提高了缓存的性能

缓存一致性问题

由于Redis有两种缓存,所以缓存不一致问题,我们分开来讨论:

对于读写缓存

只能采用同步直写的方式避免,涉及到Redis的事务

对于只读缓存

  • 新增数据时:数据在写入数据库后,加载到内存,不存在不一致问题
  • 删改数据时:只读缓存只接受读请求,所以如果你改了数据库中的数据,假设此时还没来得及删掉缓存中的数据,那么此时有请求来读,读到的将是旧的数据(存在不一致问题

解决措施:删除缓存中的数据,并且将数据库中的数据修改


但是!这个解决措施并不一定能完全成功:(我们还需要保障措施保证这两步能完成!)

下面我们看删改数据时都会有哪些情况,可以导致数据不一致的发生

情况一:删改数据后,由于删缓存数据的操作失败而导致的缓存不一致

情况二:删改数据后,由于多线程,还没来得及删缓存数据,而导致的缓存不一致

如何解决?

对于情况一:采用重试机制

对于情况二:采用延迟双删

重试机制

可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用 Kafka 消息队列)

当删除缓存数据操作失败后,可以从消息队列中重新读取这些值,然后再次进行删除或更新

重试机制

延迟双删

在线程 A 更新完数据库值以后,我们可以让它先 sleep 一小段时间再进行一次缓存删除操作

(线程 A sleep 的时间,就需要大于线程 B 读取数据再写入缓存的时间)

之所以要加上 sleep 的这段时间,就是为了让线程 B 能够先从数据库读取数据,再把缺失的数据写入缓存,然后,线程 A 再进行删除

1
2
3
4
5
# 伪代码
redis.delKey(X)
db.update(X)
Thread.sleep(N)
redis.delKey(X)# 第二次删除

使用延迟双删+消息队列方式解决缓存一致性会有什么弊端吗?

  1. 对业务代码的侵入比较大
  2. 延迟时间不好控制,时间短可能导致双删没有作用;时间太长可能会降低系统并发度

因此可以使用异步缓存更新机制:使用canal订阅binlog日志文件,在有数据更新后触发缓存更新重试机制。

异步更新缓存

缓存雪崩

缓存雪崩:指大量的应用请求无法在 Redis 缓存中进行处理,应用将大量请求发送到数据库层,导致数据库层的压力激增

导致缓存雪崩的原因:

  1. 缓存中有大量的数据同时过期
  2. Redis缓存发生故障宕机

解决措施:

对于情况1,有两种解决办法:

  1. 避免给大量的数据设置相同的过期时间(可以给EXPIRE设置时间时加一个1-3分钟的随机值)
  2. 服务降级

服务降级:

对于非核心数据,暂停缓存服务,让其直接返回预定义的信息或者空值

对于核心数据,继续提供服务

对于情况2,也有两种解决办法:

  1. 服务熔断或请求限流机制
  2. 使用主从Redis集群,切换主库(比较好的办法)

服务熔断:

为了防止引发连锁的数据库雪崩,甚至是整个系统的崩溃,我们暂停业务应用对缓存系统的接口访问

服务熔断会终止所有的缓存服务,这当然是不好的

请求限流机制:

在业务系统的请求入口前端控制每秒进入系统的请求数,避免过多的请 求被发送到数据库

比如请求入口前端允许每秒进入系统的请求是 1 万个,其中,9000 个请求都能在缓存系统中进行处理,只有 1000 个请求会被应用发送到数据库进行处理

缓存击穿

缓存击穿:针对某个访问非常频繁的热点数据的请求,无法在缓存中进行处理,导致访问该数据的大量请求,一下子都发送到了后端数据库

解决办法:

热点数据不要设置过期时间,让其能对热点数据持续提供服务

缓存穿透

缓存穿透:要访问的数据既不在 Redis 缓存中,也不在数据库中,导致请求在访问缓存时,发生缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据

发生缓存穿透的原因:

  1. 业务层误操作:业务层不慎删除了缓存的数据和数据库的数据
  2. 恶意攻击:专门访问数据库没有的数据

解决措施:

  1. 缓存空值或缺省值
  2. 使用布隆过滤器快速判断是否存在,减轻数据库压力
  3. 前端的请求入口进行检测,把恶意的请求直接过滤掉

布隆过滤器

布隆过滤器:

是由初值都为0的bit数组与N个哈希函数组合,可以快速判断数据是否存在

如果一个数据被写入数据库,那么需要在布隆过滤器上标记它的存在:

  1. 使用N个哈希函数分别计算这个数据的哈希值,得到N个哈希值

  2. 将哈希值对bit数组的长度取模,得到每个哈希值在数组中的对应位置

  3. 将对应的bit位置为1

如果想要查一个数据是否存在:

  1. 同样使用N个哈希函数计算其哈希值
  2. 去对应位置查看bit数组对应位是否为1
  3. 只要有一个不为1,那么这个数据就不存在

缓存异常总结

三种缓存异常对比

缓存污染

缓存污染:指缓存中存放了很多访问次数很少,甚至只会被访问一次的数据,白白占用缓存的空间

如何解决缓存污染?

LRU可以解决缓存污染问题吗?

LRU的确可以有效的留存最近访问的数据,但是在出现扫描式单次查询操作时,LRU策略显得无能为力

扫描式单词查询:应用对大量的数据进行一次全体读取,每个数据都会被读取,而且只会被读取一次。此时,因为这些被查询的数据刚刚被访 问过,所以 lru 字段值都很大

Redis中的LFU算法

LFU(least frequently used)可以看做LRU算法的一种优化,它会从两个值来判断将什么数据淘汰:

  1. 数据访问的时效性(即访问时间到当前时间的长度)
  2. 数据被访问的次数

LFU判断依据:

​ LFU算法会先根据数据被访问的次数进行筛选,如果访问次数相同,那么才会去比较访问的时效性,将访问更久的数据淘汰。

这两个数据也存放在RedisObejct中,RedisObejct中的lru字段占用了3B(24b),将这个字段分开:

  • ldt值:lru字段的前16bit,表示数据的访问时间戳
  • counter值:lru字段的后8bit,表示数据的访问次数

注意:

counter的值并不是每访问一次就增加1

它的加1过程很复杂:

counter计数规则是:

​ 每当数据被访问一次时,首先,用计数器当前的值乘以配置项 lfu_log_factor 再加 1,再取其倒数,得到一个 p 值;

​ 把这个 p 值和一个取值范围在(0,1)间的随机数r值比大小,只有p值大于 r 值时,计数器才加 1

我们不需要记住这个计数规则,官方给我们一个lfu_log_factor值取不同数据时,counter值的变化情况:

counter值的变化情况

可见,一般的项目,我们可以设置为10,就可以保证在1M点击的情况下也能保证区分度


除此外,Redis还规定了counter的衰减机制:

LFU 策略使用衰减因子配置项lfu_decay_time 来控制访问次数的衰减。

LFU 策略会计算当前时间和数据最近一次访问时间的差值,并把这个差值换算成以分钟为单位。

然后,LFU 策略再把这个差值除以 lfu_decay_time 值,所得的结果就是数据 counter 要衰减的值。

热key问题

热key指在缓存系统中,某些缓存key访问过于频繁,导致对于这些key需要额外进行性能处理的情况。

一般处理热key问题的解决手段有三种:

  1. 缓存预热:在系统启动或者业务低峰,主动缓存热门数据
  2. 一致性hash:使用哈希环,将键散列在整个环上,可以使请求更均匀的分布在各个redis节点。
  3. 数据分片:按照业务某个键分片,拆分缓存在不同的缓存节点上,减轻单个节点的压力。

但是在实际应用中,热key可能是可预知的,也可能不可预知:

  • 可预知的热key:比如秒杀活动。在发布商品时就需要进行分布式缓存、本地缓存预热
  • 不可预知的热key:比如某个商品突然称为爆款。接入热点探测系统(定期上报key的查询次数,热点系统检测是否为热key),如果是热key立即建立本地缓存与缓存

除此外,对于热key问题,即使有缓存手段也要做好兜底工作,做好限流熔断兜底

热key如何发现?

1、在LFU算法下,可以使用--hotkeys

1
redis-cli -p 6379 --hotkeys

该参数能够返回所有 key 的被访问次数

2、MONITOR命令

实时查看 Redis 的所有操作的方式,可以用于临时监控 Redis 实例的操作情况,包括读写、删除等操作。

对 Redis 性能的影响比较大,因此禁止长时间开启 MONITOR(生产环境中建议谨慎使用该命令)。

3、业务代码是否有日志?

大key问题

怎么算是一个大key?

  • 如果是String类型:value超过1MB
  • 复合类型(List、Hash、Sort、Sorted Sort):value元素超过5000个

大key带来的副作用:

  • 占用内存大
  • 处理大key的网络IO消耗大:比如一个key是1mb,qps是1000,那么每秒就有1GB的流量处理,一般的千兆宽带是扛不住这个压力的
  • del困难:删除大key时,阻塞的时间会比较长

如何发现大key?

1、加--bigkeys参数:

1
2
redis-cli -p 6379 --bigkeys -i 3
# 每3秒执行一次大key扫描

这种方式会扫描所有的key,且只能找出每种数据结构 top 1 bigkey(占用内存最大的 String 数据类型,包含元素最多的复合数据类型)

实际上这种方式会调用scan命令进行执行

2、使用scan命令

SCAN 命令在遍历大量数据时不会阻塞服务器,但是返回的数据可能不是实时的。

1
2
SCAN 0 MATCH user:* COUNT 100
# 从游标 0 开始扫描,匹配所有user:开头的键,并且最多返回100个

3、借助开源工具,分析RDB文件(前提使用了RDB的方式)

4、借助平台的分析服务(阿里云Redis有bigkey实时分析)

如何处理大key?

  • 手动清理

    • Redis 4.0+可以使用 UNLINK 命令来异步删除一个或多个指定的 key
    • Redis 4.0 以下可以考虑使用 SCAN 命令结合 DEL 命令来分批次删除。
  • 分割:将一个bigkey分为多个小key,比如一个hash通过二次hash,拆分为多个hash

  • 采用合适的数据结构:例如,文件二进制数据不使用 String 保存、使用 HyperLogLog 统计页面 UV、Bitmap 保存状态信息(0/1)

  • 开启lazy-free: Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理

Redis分布式锁

查看此篇:redis分布式锁

相关链接

  1. 博客园
  2. b站狂神说
  3. Redis设计与实现
  4. csdn图解epoll
  5. 极客时间蒋德钧老师的redis课