Redis,认识与了解以及深入

Redis,认识与了解以及深入

一,Redis,由来


08 年 的时候有一个意大利的小伙子 西西里岛的小伙子,笔名. Antirez ,创建了一个访客信息网站 LLOOGG.COM。有的时候我们需要知道网站的访问情况,比如访客的 IP、操作系统、浏览器、使用的搜索关键词、所在地区、访问的网页地址等等。在国内,有很多网站提供了这个功能,比如 CNZZ,百度统计,国外也有谷歌的 GoogleAnalytics。我们不用自己写代码去实现这个功能,只需要在全局的 footer 里面嵌入一段JS 代码就行了,当页面被访问的时候,就会自动把访客的信息发送到这些网站统计的服务器,然后我们登录后台就可以查看数据了。
LLOOGG.COM 提供的就是这种功能,它可以查看最多 10000 条的最新浏览记录。 这样的话,它需要为每一个网站创建一个列表(List),不同网站的访问记录进入到不同的列表。如果列表的长度超过了用户指定的长度,它需要把最早的记录删除(先进先出)。
请看图例:
在这里插入图片描述

二,Redis,诞生


当 LLOOGG.COM 的用户越来越多的时候,它需要维护的列表数量也越来越多,这种记录最新的请求和删除最早的请求的操作也越来越多。LLOOGG.COM 最初使用的数据库是MySQL,可想而知,因为每一次记录和删除都要读写磁盘,因为数据量和并发量太大,在这种情况下无论怎么去优化数据库都不管用了。
考虑到最终限制数据库性能的瓶颈在于磁盘,所以antirez打算放弃磁盘,自己去实现一个具有列表结构的数据库的原型,把数据放在内存而不是磁盘,这样可以大大地提升列表的push和pop的效率。antirez发现这种思路确实能解决这个问题,所以用C语言重写了这个内存数据库,并且加上了持久化的功能,09年,Redis横空出世了。从最开始只支持列表的数据库,到现在支持多种数据类型,并且提供了一系列的高级特性,Redis已经成为一个在全世界被广泛使用的开源项目。

三,Redis,初识


官网介绍中文官网,为什么叫Redis呢?,它的全称是 Remote Dictionary Service(远程字典服务),它的诞生就是为了解决磁盘的性能问题,所以它是一个 以Key-value 的字典结构存储方式,其中key-valuekey 最大值为 512M,这个数据来自于 官网,Value 根据不同的数据类型 来。

redis 的端口 为什么是 6379? 这个是根据手机键盘字母 MERZ 的位置决定的。 MERZ Antirez 的朋友
圈语言中是「愚蠢」的代名词,它源于意大利广告女郎「Alessia Merz」在电视节目上说了一堆愚蠢的话。

在这里插入图片描述

Redis 特性:

  • 速度快
  • 更丰富的数据库类型
  • 功能丰富:持久化机制,过期策略
  • 支持多种编程语言
  • 高可用,集群部署

四,Redis,安装与基本操作


4.1,linux 安装 Redis


1,可以下载后用ftp工具上传,解压安装。

安装包下载地址

2,可以命令行下载在linux 安装

2.1, 下载

wget http://download.redis.io/releases/redis-6.0.8.tar.gz  

在这里插入图片描述

2.2, 解压

tar xzvf redis-6.0.8.tar.gz  

在这里插入图片描述
在这里插入图片描述

2.2, 安装

cd redis-6.0.8

make

cd src

make install PREFIX=/usr/local/redis

make 遇到以下错误 是 c++ 版本问题
在这里插入图片描述
解决方式

#查看 gcc 版本  并升级到 5.3及以上版本
gcc -v 
yum -y install centos-release-scl
yum -y install devtoolset-9-gcc devtoolset-9-gcc-c++ devtoolset-9-binutils 
scl enable devtoolset-9 bash

在这里插入图片描述
在这里插入图片描述

2.3,移动配置文件到安装目录下

# 返回上一级目录 ,redis.conf  【Redis配置文件】     在这个目录下
cd ../       

 # 创建etc目录,保存redis.conf   【在这里配置Redis】
mkdir  /usr/local/redis/etc     

# 将  redis-4.0.11 里的 redis.conf  ,拷贝到etc目录
mv   redis.conf    /usr/local/redis/etc   
2.4, 配置redis

 # 配置, vim 
vim   /usr/local/redis/etc/redis.conf    
  • 配置redis为后台启动,将 daemonize no 改成 daemonize yes
  • 配置redis可以外网访问,将 bind 127.0.0.1 改成 # bind 127.0.0.1 或者 bind 0.0.0.0
    在这里插入图片描述

在这里插入图片描述

- 在连接Redis服务之前,要先修改redis.conf中的Bind属性  
- 参考:https://blog.csdn.net/sinat_34351851/article/details/79078940
- ![在这里插入图片描述](https://img-blog.csdnimg.cn/20201012225205900.png#pic_center)

将 requirepass foobared 改为 requirepass 123456
在这里插入图片描述

2.5,将redis加入到开机启动

//在里面添加内容:/usr/local/redis/bin/redis-server /usr/local/redis/etc/redis.conf (意思就是开机调用这段开启redis的命令)
vi /etc/rc.local 

在这里插入图片描述

2.6,开启redis

/usr/local/redis/bin/redis-server   /usr/local/redis/etc/redis.conf 
2.7,常用命令

redis-server  /usr/local/redis/etc/redis.conf    # 启动redis

pkill redis  # 停止redis
2.8,可视化工具连接

百度云网盘下载地址
提取码:8iom
注意最新版的 Redis Desktop Manager 已收费 , 请不要升级
在这里插入图片描述

4.2,windows 安装Redis


4.2.1,下载

安装包下载地址
说明一下官网是不提供 Windows 的 下载的 所以是 github 地址
在这里插入图片描述

说明:

  1. msi 是 安装版的包
  2. zip 是 免安装直接解压使用
4.2.2, 解压 启动,双击 redis-server.exe

在这里插入图片描述
在这里插入图片描述

4.2.3, 可视化工具连接

百度云网盘下载地址
提取码:8iom
注意最新版的 Redis Desktop Manager 已收费 , 请不要升级
在这里插入图片描述

五,Redis,基础数据结构与特性


Redis 5种 基础数据结构
-` String(字符串):可以用来存储字符串、整数、浮点数。

  • list(列表):存储有序的字符串(从左到右),元素可以重复。可以充当队列和栈的角色。
  • set(集合):String 类型的无序集合,最大存储数量 2^32-1(40 亿左右)。
  • hash(哈希):包含键值对的无序散列表。value 只能是字符串,不能嵌套其他类型。
  • zset(有序集合):String 类型的有序集合。

5.1,String:可以用来存储字符串、整数、浮点数。


基本指令指令网址

#批量设置

mset zhangsan niubi lisi 222;

#批量获取 

mget zhangsan lisi ;

#获取长度

strlen zhangsan ;

#字符串追求内容

append zhangsan good;

#获取指定范围的字符

getrange zhangsan 0 5 ;

#(整数)值递增

incr intkey;

incrby intkey 100;

#(浮点数)值递增

set f 2.6;

incrbyfloat f 7.3;

#如果不存在这个key设置成功,存在设置失败(分布式锁)

set lock1 1 EX 10 NX;

实现原理:因为RedisKVdic(字典)数据结构,它是通过hashtable实现的(我们把这个叫做外层的哈希),所以每个键值对都会有一个 dictEntry(源码位置:dict.h),里面指向了key和value的指针。next指向下一个dictEntry


typedef struct dictEntry {
/* key 关键字定义 */
void *key; 

    union {

    void *val;
     /* value 定义 */
    uint64_t u64;

    int64_t s64;

    double d;

    } v;
/* 指向下一个键值对节点 */
struct dictEntry *next; 

} dictEntry;	

在这里插入图片描述

key 是字符串,但是 Redis 没有直接使用 C 的字符数组,而是存储在自定义的 SDS中。

SDS 全称 Simple Dynamic String 简单动态字符串在3.2以后的版本中, SDS又有多种结构(源码:sds.h) ,sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64,用于存储不同的长度的字符串,分别代表2^5 =32byte ,2^8 = 256byte,2^16 = 65536byte=64KB,2^32byte = 4GB

那到底什么是SDS?1

struct __attribute__ ((__packed__)) sdshdr8 {
/* 当前字符数组的长度 */
uint8_t len;
/*当前字符数组总共分配的内存大小 */
uint8_t alloc; 
 /* 当前字符数组的属性、用来标识到底是 sdshdr8 还是 sdshdr16 等 */
unsigned char flags; 
/* 字符串真正的值 */
char buf[]; 
};

为什么Redis要用SDS实现字符串?2

SDS 的特点:

  1. 不用担心内存溢出问题,如果需要会对 SDS进行扩容。

  2. 获取字符串长度时间复杂度为 O(1),因为定义了len属性。

  3. 通过“空间预分配”( sdsMakeRoomFor)和“惰性空间释放”,防止多次重分配内存。

  4. 判断是否结束的标志是len属性(它同样以’\0’结尾是因为这样就可以使用 C语言中函数库操作字符串的函数了),可以包含’\0’,value既不是直接作为字符串存储,也不是直接存储在SDS中,而是存储在redisObject中。实际上五种常用的数据类型的任何一种,都是通过redisObject来存储的。

redisObjec定义在src/server.h文件中:

typedef struct redisObject {
/* 对象的类型,包括:OBJ_STRING、OBJ_LIST、OBJ_HASH、OBJ_SET、OBJ_ZSET */
unsigned type:4; 
/* 具体的数据结构编码 */
unsigned encoding:4; 
/* 24 位,对象最后一次被命令程序访问的时间,与内存回收有关 */
unsigned lru:LRU_BITS; 
/* 引用计数。当 refcount 为 0 的时候,表示该对象已经不被任何对象引用,则可以进行垃圾回收了*/
int refcount; 
/* 指向对象实际的数据结构 */
void *ptr;
} robj;

字符串类型的内部编码encoding有三种:

  1. int,存储 8 个字节的长整型(long,2^63-1)。

  2. embstr, 代表embstr格式的SDSSimple Dynamic String 简单动态字符串),存储小于 44 个字节的字符串。

  3. rawSDS存储大于 44 个字节的字符串

/* object.c */

#define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44

embstrraw 的区别 ? 3

intembstr什么时候转化为 raw4

127.0.0.1:6379> set k1 1;

OK

127.0.0.1:6379> append k1 a;

(integer) 2

127.0.0.1:6379> object encoding k1;

"raw"

#明明没有超过阈值,为什么变成raw了?

127.0.0.1:6379> set k2 a;

OK

127.0.0.1:6379> object encoding k2;

"embstr"

127.0.0.1:6379> append k2 b;

(integer) 2

127.0.0.1:6379> object encoding k2;

"raw"

当长度小于阈值时,会还原吗?5

为什么要对底层的数据结构进行一层包装呢?6

应用场景


  1. 缓存

  2. 分布式ID

  3. 计数器、限流 ,incrby

  4. 分布式锁 set 加nx参数

  5. 位运算

    a 对应的 ASCII 码是 97,转换为二进制数据是 01100001

    b 对应的 ASCII 码是 98,转换为二进制数据是 01100010

set k1 a;

setbit k1 6 1;

setbit k1 7 0;

get k1;

5.2,hash(哈希)


包含键值对的无序散列表。value 只能是字符串,不能嵌套其他类型。同样是存储字符串,Hash与String的主要区别?

  1. 把所有相关的值聚集到一个key中,节省内存空间

  2. 只使用一个key,减少key冲突

  3. 当需要批量获取值的时候,只需要使用一个命令,减少内存/IO/CPU 的消耗

Hash不适合的场景:

  1. Field不能单独设置过期时间

  2. 没有bit操作

实现原理


Redis的Hash本身也是一个KV的结构,类似于Java中的HashMap。外层的哈希(Redis KV的实现)只用到了hashtable。当存储 hash 数据类型时,

我们把它叫做内层的哈希。内层的哈希底层可以使用两种数据结构实现:

ziplist:OBJ_ENCODING_ZIPLIST(压缩列表)

hashtable:OBJ_ENCODING_HT(哈希表)

什么是ziplist7

typedef struct zlentry {
   
/* 上一个链表节点占用的长度 */
unsigned int prevrawlensize; 
   
/* 存储上一个链表节点的长度数值所需要的字节数 */
unsigned int prevrawlen;
   

/* 存储当前链表节点长度数值所需要的字节数 */
unsigned int lensize;
   

/* 当前链表节点占用的长度 */

unsigned int len;

/* 当前链表节点的头部大小(prevrawlensize + lensize),即非数据域的大小 */

unsigned int headersize;


/* 编码方式 */

unsigned char encoding;
   

/* 压缩链表以字符串的形式保存,该指针指向当前节点起始位置 */

unsigned char *p;


} zlentry;

ziplist 的内部结构?8

 <zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>
     
typedef struct zlentry {
/* 上一个链表节点占用的长度 */
unsigned int prevrawlensize; 
    
/* 存储上一个链表节点的长度数值所需要的字节数 */
unsigned int prevrawlen;
    
/* 存储当前链表节点长度数值所需要的字节数 */
unsigned int lensize;
    
/* 当前链表节点占用的长度 */
unsigned int len;

/* 当前链表节点的头部大小(prevrawlensize + lensize),即非数据域的大小 */
unsigned int headersize;

/* 编码方式 */
unsigned char encoding;
  
/* 压缩链表以字符串的形式保存,该指针指向当前节点起始位置 */
unsigned char *p;

} zlentry;

编码 encoding(ziplist.c 源码第 204 行)

#define ZIP_STR_06B (0 << 6) //长度小于等于 63 字节

#define ZIP_STR_14B (1 << 6) //长度小于等于 16383 字节

#define ZIP_STR_32B (2 << 6) //长度小于等于 4294967295 字节

 

什么时候使用ziplist存储?9

/* src/redis.conf 配置 */

hash-max-ziplist-value 64 // ziplist 中最大能存放的值长度

hash-max-ziplist-entries 512 // ziplist 中最多能存放的 entry 节点数量

什么是hashtable10

typedef struct dictEntry {
/* key 关键字定义 */
void *key; 

    union {

    void *val;
    /* value 定义 */
    uint64_t u64;

    int64_t s64;

    double d;

    } v;
struct dictEntry *next; /* 指向下一个键值对节点 */

} dictEntry;
dictEntry 放到了 dictht(hashtable 里面):

/* This is our hash table structure. Every dictionary has two of this as we

\* implement incremental rehashing, for the old to the new table. */

typedef struct dictht {

dictEntry **table; /* 哈希表数组 */

unsigned long size; /* 哈希表大小 */

unsigned long sizemask; /* 掩码大小,用于计算索引值。总是等于 size-1 */

unsigned long used; /* 已有节点数 */

} dictht;

/*ht放到了dict里面:*/

typedef struct dict {
/* 字典类型 */
dictType *type; 
    
/* 私有数据 */
void *privdata; 
    
/* 一个字典有两个哈希表 */
dictht ht[2]; 
    
/* rehash 索引 */
long rehashidx; 

/* 当前正在使用的迭代器数量 */    
unsigned long iterators; 

} dict;

在这里插入图片描述

注意dictht后面是NULL说明第二个ht还没用到。dictEntry*后面是NULL说明没有hash到这个地址。dictEntry后面是NULL说明没有发生哈希冲突。

问题:为什么要定义两个哈希表呢?11

使用场景

String可以做的事情,Hash都可以做!存储对象类的数据。比如对象或者一张表的数据,比String节省了更多 key 的空间,也更加便于集中管理购物车,还可以通过hincrby hkey f 1 加数量!

5.3,list(列表)


基本指令:存储有序的字符串(从左到右),元素可以重复。可以充当队列和栈的角色。
在这里插入图片描述

基本指令


/*左右添加元素*/
lpush list b c
rpush list d e

/*左右弹出第一条*/
lpop list 
rpop list 

/*左右弹出第一条,并设置超时,直到有数据弹出或者超时*/
blpop list 10
brpop list 10

/*取值*/
lindex list 0
lrange list 0 -1
    
object encoding huihuilist    

实现原理:统一用quicklist 来存储。quicklist 存储了一个双向链表,每个节点都是一个 ziplist


在这里插入图片描述

那什么是quickList?12

typedef struct quicklist {
/* 指向双向列表的表头 */
quicklistNode *head; 
/* 指向双向列表的表尾 */
quicklistNode *tail; 

/* 所有的 ziplist 中一共存了多少个元素 */    
unsigned long count;

/* 双向链表的长度,node 的数量 */
unsigned long len;

/* fill factor for individual nodes */
int fill : 16;

/* 压缩深度,0:不压缩; */
unsigned int compress : 16; 

} quicklist;
typedef struct quicklistNode {
/* 前一个节点 */
struct quicklistNode *prev; 
/* 后一个节点 */
struct quicklistNode *next; 
/* 指向实际的 ziplist */
unsigned char *zl; 
/* 当前 ziplist 占用多少字节 */
unsigned int sz; 
/* 当前 ziplist 中存储了多少个元素,占 16bit(下同),最大 65536 个 */
unsigned int count : 16; 
/* 是否采用了 LZF 压缩算法压缩节点,1:RAW 2:LZF */
unsigned int encoding : 2; 
/* 2:ziplist,未来可能支持其他结构存储 */
unsigned int container : 2; 
/* 当前 ziplist 是不是已经被解压出来作临时使用 */
unsigned int recompress : 1; 
 /* 测试用 */
unsigned int attempted_compress : 1;
/* 预留给未来使用 */
unsigned int extra : 10; 

} quicklistNode;

在这里插入图片描述

应用场景


  1. 用户消息时间线,因为list是有序的。

  2. 消息队列,List提供了两个阻塞的弹出操作:BLPOP/BRPOP,可以设置超时时间。BLPOP:BLPOP key1 timeout移出并获取列表的第一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。BRPOP:BRPOP key1 timeout 移出并获取列表的最后一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。队列:先进先出:rpush blpop,左头右尾,右边进入队列,左边出队列。栈:先进后出:rpush brpop

5.4,Set集合:String 类型的无序集合,最大存储数量 2^32-1(40 亿左右)。


基本指令

/*添加一个或者多个元素*/
sadd huihuiset a b c d e f g

/*获取所有元素*/
smembers huihuiset

/*获取所有元素的个数*/
scard huihuiset

/*随机获取一个元素*/
srandmember huihuiset

/*随机弹出一个元素*/
spop huihuiset

/*弹出指定元素*/
srem huihuiset g a

/*查看元素是否存在*/
sismember huihuiset e

/*获取前一个集合有而后面1个集合没有的
sdiff huihuiset huihuiset1 

/*获取交集*/
sinter huihuiset huihuiset1

/*获取并集*/
sunion huihuiset huihuiset1

实现原理


Redisintset(整数集合)或hashtable存储set。如果元素都是整数类型,就用intset存储。如果不是整数类型,就用hashtable(数组+链表的存来储结构)。

问题:KV怎么存储set的元素?13

/*配置文件 redis.conf*/
set-max-intset-entries 512;

使用场景


  1. 抽奖

  2. 点赞、签到等

  3. 交集并集 关注等场景

5.5,zset(有序集合)

基本指令

/*批量添加*/
zadd huihuizset 10 java 20 php 30 ruby 40 cpp 50 python

/*根据分数从低到高*/
zrange huihuizset 0 -1 withscores

/*根据分数从高到低*/
zrevrange huihuizset 0 -1 withscores

/*根据分数范围获取值*/
zrangebyscore huihuizset 20 30

/*移除元素*/
zrem huihuizset php

/*获取zset个数*/
zcard huihuizset

/*给某个元素加分*/
zincrby huihuizset 50 java

/*获取范围内的个数*/
zcount huihuizset 50 60

/*返回指定元素的索引*/
zrank huihuizset java

/*获取元素的分数*/
zscore huihuizset java

实现原理


同时满足以下条件时使用ziplist编码:元素数量小于128个,所有member的长度都小于64字节。

/*redis.conf 参数*/
zset-max-ziplist-entries 128;
zset-max-ziplist-value 64;

ziplist的内部,按照score排序递增来存储。插入的时候要移动之后的数据。超过阈值之后,使用skiplist+dict存储。

什么是skiplist14

在这里插入图片描述

使用场景


排行榜,zincrby huihuizset 50 java, 可以实现榜单数据添加,还可以根据分数排行,获取前多少名。

六,内存淘汰


6.1,过期策略


如果内存存满了咋办?15

定时过期(主动淘汰)

每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好,但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。

惰性过期(被动淘汰)

该策略就可以最大化地节省CPU资源,但是却对内存非常不友好。因为你不实时过期了,该过期删除的就可能一直堆积在内存里面!极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。它底层实现是调用方法,db.c 文件下:

expireIfNeeded(redisDb *db, robj *key)
定期过期

/*源码:server.h*/

typedef struct redisDb {

/* 所有的键值对 */

dict *dict;

/* 设置了过期时间的键值对 */

dict *expires;

/*阻塞的所有key,我们昨天讲的BLPOP,如果list没值,我们会等待有值,直到超时!就是放在这里的 Keys with clients waiting for data (BLPOP)*/

dict *blocking_keys;

/*准备好数据可以解除阻塞状态的键  Blocked keys that received a PUSH */

dict *ready_keys;

/* WATCHED keys for MULTI/EXEC CAS */

dict *watched_keys;

/* Database ID */

int id;

/* Average TTL, just for stats  所有键值对的平均生存时间*/

long long avg_ttl;

/* List of key names to attempt to defrag one by one, gradually. */

list *defrag_later;

} redisDb;

每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的 key。expire.c文件下;

activeExpireCycle(int type)

Redis 默认会每秒进行 10 次(redis.conf中通过 hz 配置)过期扫描,扫描并不是遍历过期字典中的所有键,而是采用了如下方法:

  1. 从过期字典中随机取出20个键(server.h文件下ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP配置20);

  2. 删除这20个键中过期的键;

  3. 如果过期键的比例超过 25% ,重复步骤 1 和 2;

该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得 CPU 和内存资源达到最优的平衡效果。

Redis中同时使用了惰性过期和定期过期两种过期策略。

6.2,淘汰策略


如果redis可能没有需要过期的数据了,但是数据量还是会把我们的内存都占满了,因为我们的内存是有个大小配置的,redis.conf参数配置:

# maxmemory <bytes>

如果不设置 maxmemory 或者设置为0,64位系统不限制内存,32位系统最多使用3GB内存。 我们还可以通过命令动态修改,官网地址,动态修改:

redis> config set maxmemory 2GB;

/*redis.conf*/
/* maxmemory-policy noeviction  默认是这个,动态修改淘汰策略:*/

redis> config set maxmemory-policy volatile-lru

在这里插入图片描述

LRU(最久未使用)

Lru,Least Recently Used 翻译过来是最久未使用,根据时间轴来走,仍很久没用的数据只要最近有用过,我就默认是有效的。LRU本来的原理如下图,因为又是用于缓存淘汰,所以要保证时间复杂度的o(1),所以我们LRU算法应该是hash+双向链表来实现的,这2个东西就能保证查跟插都能保证时间复杂度的o(1)。那么它有什么不足呢,如果我们用最原始的,我们需要额外的数据结构存储,因为要存储hash+双向链表,消耗内存。所以redis中的LRU不是这样实现的。Redis LRU对传统的LRU算法进行了改良,通过随机采样来调整算法的精度。如果淘汰策略是LRU,则根据配置的采样值maxmemory-samples(默认是5 个), 随机从数据库中选择采样值配置的key个数, 淘汰其中热度最低的key对应的缓存数据。所以采样参数配置的数值越大, 就越能精确的查找到待淘汰的缓存数据,但是也消耗更多的CPU计算,执行效率降低。

在这里插入图片描述

我们所淘汰热度最低热度最低,那什么是热度最低?16

/*源码:server.h*/

typedef struct redisObject {

unsigned type:4;

unsigned encoding:4;

unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or LFU data (least significant 8 bits frequency and most significant 16 bits access time). */

int refcount;

void *ptr;

} robj;
/*源码:server.c*/

void updateCachedTime(void) {

time_t unixtime = time(NULL);

atomicSet(server.unixtime,unixtime);

server.mstime = mstime();

struct tm tm;

localtime_r(&server.unixtime,&tm);

server.daylight_active = tm.tm_isdst;

}
/*源码 evict.c */

/* Given an object returns the min number of milliseconds the object was never

\* requested, using an approximated LRU algorithm. */

unsigned long long estimateObjectIdleTime(robj *o) {

unsigned long long lruclock = LRU_CLOCK();

if (lruclock >= o->lru) {

return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;

} else {

return (lruclock + (LRU_CLOCK_MAX - o->lru)) *

LRU_CLOCK_RESOLUTION;

}

}
 /*server.h*/
#define LRU_CLOCK_MAX ((1<<LRU_BITS)-1) /* Max value of obj->lru */

为什么到了redis4.0后又出现了一个LFU呢,LFU到底是什么呢?它为什么要出现?17

在这里插入图片描述

LFU(最不常用)

LFU,英文 Least Frequently Used,翻译成中文就是最不常用,是按着使用次数、来算的,那么我们传统的LFU的原理18是什么呢,我们来看图

在这里插入图片描述

/*源码:server.h*/

typedef struct redisObject {

unsigned type:4;

unsigned encoding:4;

unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or

\* LFU data (least significant 8 bits frequency

\* and most significant 16 bits access time). */

int refcount;

void *ptr;

} robj;
void updateLFU(robj *val) {

unsigned long counter = LFUDecrAndReturn(val);

counter = LFULogIncr(counter);

val->lru = (LFUGetTimeInMinutes()<<8) | counter;

}
# lfu-log-factor 10
lfu-decay-time 1

七,持久化机制


持久化,是因为redis的数据都存放在内存,我们为了保证redis宕机的时候,避免数据丢失,所以我们要做持久化,把内存数据放到磁盘去!那么redis到底是怎么实现的?我们先看官网,官网地址

7.1,RDB持久化


RDBRedis默认的持久化方案。RDB快照(Redis DataBase),当满足一定条件的时候,会把当前内存中的数据写入磁盘,生成一个快照文件dump.rdbRedis重启会通过dump.rdb文件恢复数据。那那个一定的条件是啥呢?到底什么时候写入rdb 文件?

  1. 自动触发

配置规则触发,redis.confSNAPSHOTTING配置,其中定义了触发把数据保存到磁盘触发频率。 如果不需要 RDB 方案,注释 save 或者配置成空字符串""。

save 900 1 # 900 秒内至少有一个 key 被修改(包括添加)

save 300 10 # 400 秒内至少有 10 个 key 被修改

save 60 10000 # 60 秒内至少有 10000 个 key 被修改

注意上面的配置是不冲突的,只要满足任意一个都会触发。RDB 文件位置和目录(SNAPSHOTTING配置下):

# 文件路径,
dir ./

# 文件名称
dbfilename dump.rdb

# 是否是 LZF 压缩 rdb 文件
rdbcompression yes

# 开启数据校验
rdbchecksum yes

在这里插入图片描述

shutdown触发 保证服务器正常关闭,关闭的时候不会造成数据丢失。flushallRDB文件是空的,没什么意义。

  1. 手动触发

如果我们需要重启服务或者迁移数据,这个时候就需要手动触RDB快照保存。所以redis也提供了2种手动保存RDB快照的指令。

(1) save:在生成快照的时候会阻塞当前Redis服务器,Redis不能处理其他命令。如果内存中的数据比较多,会造成Redis长时间的阻塞。生产环境不建议使用这个命令。为了解决这个问题,Redis 提供了第二种方式。

(2) bgsave:执行bgsave时,Redis会在后台异步进行快照操作,快照同时还可以响应客户端请求。具体操作是Redis进程执行fork(创建进程函数)操作创建子进程(copy-on-write),RDB持久化过程由子进程负责,完成后自动结束。它不会记录 fork 之后后续的命令。阻塞只发生在fork阶段,一般时间很短。用lastsave命令可以查看最近一次成功生成快照的时间。

我们知道了RDB的实现的原理逻辑,那么我们就来分析下RDB到底有什么优缺点。

优点:

  1. RDB是一个非常紧凑(compact类型)的文件,它保存了redis在某个时间点上的数据集。这种文件非常适合用于进行备份和灾难恢复。
  2. 生成RDB文件的时候,redis主进程会fork()一个子进程来处理所有保存工作,主进程不需要进行任何磁盘IO操作。
  3. RDB在恢复大数据集时的速度比AOF的恢复速度要快。

缺点:

  1. RDB方式数据没办法做到实时持久化/秒级持久化。因为bgsave每次运行都要执行 fork 操作创建子进程,频繁执行成本过高。
  2. 在一定间隔时间做一次备份,所以如果redis意外down掉的话,就会丢失最后一次快照之后的所有修改(数据有丢失)。所以,针对与这种情况,redis又提供了一种持久化机制,就是AOF持久化机制!

7.2,AOF持久化


Append Only File的简称,只加载文件,这个方案在redis默认是不开启的。AOF是怎么恢复的?AOF采用日志的形式来记录每个写操作,并追加到文件中。开启后,执行更改Redis数据的命令时,就会把命令写入到AOF文件中。Redis重启时会根据日志文件的内容把写指令从前到后执行一次以完成数据的恢复工作。配置文件redis.conf如下:

# 开关
appendonly no

# 文件名
appendfilename "appendonly.aof"

 

我们刚才说过RDB快照的方案可能会造成数据丢失,那么AOF它是实时的么,是实时写入硬盘的么,它会不会也会造成数据丢失呢?配置文件redis.conf如下:

# appendfsync always
appendfsync everysec

# appendfsync no

#这个参数就是决定了AOF文件怎么完成磁盘同步!fsync的功能是确保文件fd所有已修改的内容已经正确同步到硬盘上!

appendfsync always:#总是写入aof文件,并完成磁盘同步

appendfsync everysec:#每一秒写入aof文件,并完成磁盘同步

appendfsync no:#写入aof文件,不等待磁盘同步

在这里插入图片描述

redis默认用的是everysec,也就是默认可能会丢失1s的数据没有写入磁盘持久化!我们知道AOF是追加文件,那么大家想下一直追加追加,肯定是有问题的啊对吧,就是会导致文件过大,那么redis是怎么解决这个问题的呢?19

redis> lpush list a

redis> lpush list b c d

redis> lpop list

#就会重写成

redis> lpush list a b c
# 重写触发机制
auto-aof-rewrite-percentage 100

auto-aof-rewrite-min-size 64mb

在这里插入图片描述

我们知道了AOF的实现原理,我们来分析下它的优缺点:

  1. 优点:能最大限度的保证数据安全,就算用默认的配置everysec,也最多只会造成1s的数据丢失。

  2. 缺点:数据量比RDB要大很多,所以性能没有RDB好,没有一个性能保证!

总结:那我们平时开发中应该使用哪种持久化呢?20

八,Redis主从部署与同步原理


单机模式:就是安装一个redis,启动起来,业务调用即可。单机在很多场景也是有使用的,例如在一个并非必须保证高可用的情况下。


优点:

  • 部署简单,0成本。
  • 成本低,没有备用节点,不需要其他的开支。
  • 高性能,单机不需要同步数据,数据天然一致性。

缺点:

  • 可靠性保证不是很好,单节点有宕机的风险。
  • 单机高性能受限于CPU的处理能力,redis是单线程的。
  • 单机模式选择需要根据自己的业务场景去选择,如果需要很高的性能、可靠性,单机就不太合适了。

主从模式:主从复制,是指将一台Redis服务器的数据,复制到其他的Redis服务器。


简单来说:前者称为主节点(master),后者称为从节点(slave);数据的复制是单向的,只能由主节点到从节点。主从模式配置很简单,只需要在从节点配置主节点的ip和端口号即可。
在这里插入图片描述

怎么部署一个1主2从的环境?

//例如203是主节点,在每个 slave 节点的redis.conf配置文件增加一行 
slaveof 192.168.8.203 6379 

//在主从切换的时候,这个配置会被重写成: 
# Generated by CONFIG REWRITE 
replicaof 192.168.8.203 6379 

//或者在启动服务时通过参数指定 master 节点: 
./redis-server --slaveof 192.168.8.203 6379 

//或在客户端直接执行 slaveof xx xx,使该 Redis 实例成为从节点。 启动后,查看集群状态: 
redis> info replication 

//从节点不能写入数据(只读),只能从 master 节点同步数据。get 成功,set 失败。 
127.0.0.1:6379> set gupao 666 
(error) READONLY You can't write against a read only replica. 

//主节点写入后,slave会自动从master同步数据(演示)。 断开复制,就是让自己成为自己的老大: 
redis> slaveof no one 

//此时从节点会变成自己的主节点,不再复制数据。

主从同步


主从同步分为几个阶段
一、连接阶段

  • slave node启动时(执行slaveof命令),会在自己本地保存master node的信息,包括master nodehostip
  • slave node内部有个定时任务replicationCron(源码replication.c),每隔 1秒钟检查是否有新的master node要连接和复制,如果发现,就跟master node建立socket网络连接,如果连接成功,从节点为该socket建立一个专门处理复制工作的文件事件处理器,负责后续的复制工作,如接收RDB文件、接收命令传播等。 当从节点变成了主节点的一个客户端之后,会给主节点发送 ping 请求。

二、数据同步阶段

  • master node第一次执行全量复制,通过bgsave命令在本地生成一份RDB快照,将RDB快照文件发给slave node(如果超时会重连,可以调大 repl-timeout的值)。slave node首先清除自己的旧数据,然后用RDB文件加载数据。

问题:生成 RDB 期间,master 接收到的命令怎么处理?
开始生成 RDB 文件时,master会把所有新的写命令缓存在内存中。在slave node保存了 RDB 之后,再将新的写命令复制给 slave node。 进入我们的命令传播阶段

三、命令传播阶段

  • master node持续将写命令,异步复制给slave node,是异步主同步给从,那么延迟是不可避免的,只能通过优化网络。 我们有1个参数可以设置网络交互频率!
repl-disable-tcp-nodelay no

当设置为yes时,TCP会对包进行合并从而减少带宽,但是发送的频率会降低,从节点数据延迟增加,一致性变差;具体发送频率与Linux内核的配置有关,默认配置为40ms。当设置为noTCP 会立马将主节点的数据发送给从节点,带宽增加但延迟变小。一般来说,只有当应用对 Redis 数据不一致的容忍度较高,且主从节点之间网络状况不好时,才会设置为yes;多数情况使用默认值 no

如果从节点有一段时间断开了与主节点的连接是不是要重新全量复制一遍

如果可以增量复制,怎么知道上次复制到哪里

//通过master_repl_offset记录的偏移量 
redis> info replication 

//可以查看上次复制到哪了
master repl offset:7324556

优点:一旦 主节点宕机,从节点 作为 主节点 的 备份 可以随时顶上来。扩展 主节点 的 读能力,分担主节点读压力。高可用基石:除了上述作用以外,主从复制还是哨兵模式和集群模式能够实施的基础,因此说主从复制是Redis高可用的基石。

缺点:一旦 主节点宕机,从节点 晋升成 主节点,同时需要修改 应用方 的 主节点地址,还需要命令所有 从节点 去 复制 新的主节点,整个过程需要 人工干预。主节点 的 写能力 受到 单机的限制。主节点 的 存储能力 受到 单机的限制。

哨兵模式:刚刚提到了,主从模式,当主节点宕机之后,从节点是可以作为主节点顶上来,继续提供服务的。但是有一个问题,主节点的IP已经变动了,此时应用服务还是拿着原主节点的地址去访问,这…于是,在Redis 2.8版本开始引入,就有了哨兵这个概念。


哨兵是个什么呢。其实sentinel你就可以理解为一个监管体系,监管着你这个redis的所有服务,不管你是主也好,从也好!但是,如果只有1个sentinel监管的话,如果这个哨兵挂了怎么办,那是不是也会有问题。所以sentinel它本身也是一个集群部署的,这些sentinel除了监控所有的redis服务外,它们也会互相监控!
在这里插入图片描述
哨兵模式原理


我们会启动一个或者多个 Sentinel的服务(通过 src/redis-sentinel),它本质上只是一个运行在特殊模式之下的RedisSentinel通过info 命令得到被监听 Redis机器的masterslave 等信息。

那它到底是怎么能实现对redis服务的一个主动下线并把slave升级的呢?

  1. 如果Sentinel 默认以每秒钟1次的频率向Redis所有服务节点发送 PING 命令。如果在down-after-milliseconds 内都没有收到有效回复,Sentinel会将该服务器标记为下线(主观下线)。
# sentinel.conf 
sentinel down-after-milliseconds <master-name> <milliseconds>
  1. 这个时候Sentinel节点会继续询问其他的Sentinel节点,确认这个节点是否下线, 如果多数 Sentinel节点都认为master下线,master才真正确认被下线(客观下线),这个时候就需要重新选举master

我们来看,其实它整个操作是有2个主要的行为的。
第一个我们要从Sentinel中选一个leader来通知某个从节点上位
第二个我们就是要去选举哪个slave redis节点升级主节点

Sentinel选举


其实Sentinel不像redis集群一样有主从的概念,sentinel集群它是平等的,那么当出现问题了后,选谁去通知redis集群呢!这里就会有1个算法选出sentinel,这个算法就是raft算法,raft的一个思想:先到先得,少数服从多数。
基于Sentinel这个思想,所以我们的sentinel服务一般是部署的奇数台,不然就会可能形成脑裂,偶数,每个人50%的票,我不知道选谁!

到底选哪个slave来继承老大的位置。Redis是根据4个条件来决定谁来上升为master,会从多个因素去考虑分别是断开连接时长优先级排序复制数量进程 id。 如果与哨兵连接断开的比较久,超过了某个阈值,就直接失去了选举权。如果拥有选举权,那就看谁的优先级高,这个在配置文件里可以设置(replica-priority 100),数值越小优先级越高。
如果优先级相同,就看谁从master中复制的数据最多(复制偏移量最大),选最多的那个,这个偏移量就是我们刚才讲的主从复制的时候复制到哪里的那个东西!如果复制数量也相同,就选择进程id最小的那个。

所以我们可以总结下Sentinel的作用有哪些

  1. 监控:Sentinel会不断检查主服务器和从服务器是否正常运行
  2. 故障处理:如果主服务器发生故障,Sentinel可以启动故障转移过程。把某台服务器升级为主服务器,并发出通知
  3. 配置管理:客户端连接到 Sentinel,获取当前的 Redis主服务器的地址。我们不是直接去获取redis主服务的地址,而是根据sentinel去自动获取谁是主机,不然发生故障后我们还得改代码的连接,很麻烦!

Sentinel实战


为了保证Sentinel的高可用,Sentinel也需要做集群部署,集群中至少需要三个Sentinel实例(推荐奇数个,防止脑裂)。

在这里插入图片描述
Redis安装路径/usr/local/redis-5.0.5/为例。

//在204和205的src/redis.conf配置文件中添加 
slaveof 192.168.8.203 6379 

203、204、205创建sentinel配置文件(安装后根目录下默认有 sentinel.conf):

cd /usr/local/redis-5.0.5 

mkdir logs 

mkdir rdbs 

mkdir sentinel-tmp 

vim sentinel.conf 

三台服务器内容相同:

daemonize yes 

port 26379 

protected-mode no 

dir "/usr/local/redis-5.0.5/sentinel-tmp"

sentinel monitor redis-master 192.168.8.203 6379 2 

sentinel down-after-milliseconds redis-master 30000 

sentinel failover-timeout redis-master 180000 

sentinel parallel-syncs redis-master 1 

上面出现了4个redis-master,这个名称要统一,并且使用客户端(比如Jedis) 连接的时候名称要正确

Sentinel验证 ,启动Redis服务和Sentinel

cd /usr/local/soft/redis-5.0.5/src 

# 启动 Redis 节点 
./redis-server ../redis.conf 

# 启动 Sentinel 节点 
./redis-sentinel ../sentinel.conf 

# 或者 
./redis-server ../sentinel.conf --sentinel 

# 查看集群状态:
redis> info replication

203服务器
在这里插入图片描述
204205
在这里插入图片描述

模拟master宕机,在203执行:

redis> shutdown 

205被选为新的Master,只有一个Slave节点。 注意看sentinel.conf里面的redis-master被修改了! 模拟原master恢复,在203 启动redis-server。它还是slave,但是master又有两个 slave 了。

优点和缺点


优点:哨兵模式是基于主从模式的,所有主从的优点,哨兵模式都具有。
主从可以自动切换,系统更健壮,可用性更高。
Sentinel 会不断的检查 主服务器 和 从服务器 是否正常运行。当被监控的某个 Redis 服务器出现问题,Sentinel 通过API脚本向管理员或者其他的应用程序发送通知。

缺点:Redis较难支持在线扩容,对于集群,容量达到上限时在线扩容会变得很复杂

集群模式:读请求分配给slave节点,写请求分配给master,数据同步从master到slave节点。读写分离提高并发能力,增加高性能。


在这里插入图片描述

九,Lua脚本与Redisson分布式锁


Lua脚本实战全局ID

Lua脚本是一种轻量级脚本语言,它是用C语言编写的,跟数据的存储过程有点类似。是一种胶水语言,所谓胶水,就是必须要粘合其他东西才能用,要有宿主,它自己不能独立运行

使用 Lua脚本来执行 Redis命令的好处:

  1. 一次发送多个命令,减少网络开销。

  2. Redis会将整个脚本作为一个整体执行,不会被其他请求打断,保持原子性。

  3. 对于复杂的组合命令,我们可以放在文件中,可以实现程序之间的命令集复用。

我们直接来看下在redis里面它是怎么操作的!

redis> eval "redis.call('set',KEYS[1],ARGV[1]) return KEYS[1]" 1 lua-key lua-value

其中,eval代表redis执行lua脚本,而redis.call就是lua脚本里面执行redis命令!KEYS[1]代表的是外面传进来的形参,ARGV表示的是实参,就是参数的具体值, 1代表的是keys的个数,后面的是keyargv的具体值!我们这样在redis-cli中直接写Lua脚本不够方便,也不能实现编辑和复用,通常我们会把脚本放在文件里面,然后执行这个文件! 我们在redis-5.0.5根目录下新建一个id.lua脚本

/*touch id.lua*/


redis.replicate_commands()

local nowtime = redis.pcall('time')

local idgenerate = nowtime[1] * 10000 + tonumber(ARGV[1])

if (1 == redis.call('setnx', KEYS[1], idgenerate)) then

redis.call('expire', KEYS[1], 2)

return idgenerate

else

local incr = redis.call('incr', KEYS[1])

return incr

End

/*然后调用src/redis-cli --eval id.lua id , 100执行就可以得到一个全局ID*/

root@localhost redis-5.0.5]# src/redis-cli --eval id.lua id , 100

(integer) 15963692600100

[root@localhost redis-5.0.5]# src/redis-cli --eval id.lua id , 100

(integer) 15963692730100

这个其实也就是我们一个全局唯一ID的生成方案,配合我们的redis的一个分布式的特性,很多分布式ID就是这样生成的!

Lua脚本其他命令

  • EVAL 执行lua脚本

  • EVALSHA 根据缓存shakey执行

  • SCRIPT LOAD 将脚本缓存得到shakey

  • SCRIPT EXISTS 校验shakey是否存在

  • SCRIPT FLUSH 清除Redis服务端所有 Lua 脚本缓存

  • SCRIPT KILL 杀死当前正在运行的 Lua脚本,当且仅当这个脚本没有执行过任何写操作时,这个命令才生效

Redission分布式锁:Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-MemoryData Grid),提供了分布式和可扩展的 Java 数据结构。


https://redisson.org/ ,https://github.com/redisson/redisson/wiki/目录

它有哪些特点呢?

基于Netty实现,采用非阻塞IO,性能高,支持异步请求,支持连接池、pipeline、LUA Scripting、Redis Sentinel、Redis Cluster,主从、哨兵、集群都支持。Spring 也可以配置和注入 RedissonClient。总之就是一个很强大的工具!Redisson实现一个分布式锁,因为在Redisson里面提供了更加简单的分布式锁的实现!加锁:

public static void main(String[] args) throws InterruptedException {

	RLock rLock=redissonClient.getLock("updateAccount");

	// 最多等待 100 秒、上锁 10s 以后自动解锁
	if(rLock.tryLock(100,10, TimeUnit.SECONDS)){
		System.out.println("获取锁成功");
	}

	// do something
	rLock.unlock();

}

我们来看下它是怎么实现的?

在获得 RLock 之后,只需要一个tryLock方法,里面有3个参数:

  1. watiTime:获取锁的最大等待时间,超过这个时间不再尝试获取锁

  2. leaseTime:如果没有调用 unlock,超过了这个时间会自动释放锁

  3. TimeUnit:释放时间的单位

那么Redisson的分布式锁是怎么实现的呢?

在加锁的时候,在Redis写入了一个HASHkey是锁名称,field是线程名称,value1(表示锁的重入次数)。源码:

tryLock()——>tryAcquire()——>tryAcquireAsync()——>tryLockInnerAsync()

最终也是调用了一段Lua脚本。里面有一个参数,两个参数的值。

在这里插入图片描述

  • KEYS[1]锁名称 updateAccount

  • ARGV[1] key 过期时间 10000ms

  • ARGV[2] 线程名称

  • 锁名称不存在

if (redis.call('exists', KEYS[1]) == 0) then

	// 创建一个 hash,key=锁名称,field=线程名,value=1

	redis.call('hset', KEYS[1], ARGV[2], 1);

	// 设置 hash 的过期时间

	redis.call('pexpire', KEYS[1], ARGV[1]);

	return nil;

end;


// 锁名称存在,判断是否当前线程持有的锁

if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then

	// 如果是,value+1,代表重入次数+1

	redis.call('hincrby', KEYS[1], ARGV[2], 1);

	// 重新获得锁,需要重新设置 Key 的过期时间

	redis.call('pexpire', KEYS[1], ARGV[1]);

	return nil;

end;


// 锁存在,但是不是当前线程持有,返回过期时间(毫秒)

return redis.call('pttl', KEYS[1]);

释放锁,源码:

unlock——unlockInnerAsync

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GKzsskfe-1603823260156)(E:\有道云笔记\m18871625332@163.com\45302ee0de304df89fe0ff4d8dd6cea7\wps21.jpeg)]

  • KEYS[1] 锁的名称 updateAccount

  • KEYS[2] 频道名称redisson_lock__channel:{updateAccount}

  • ARGV[1] 释放锁的消息 0

  • ARGV[2]锁释放时间 10000

  • ARGV[3]线程名称

// 锁不存在(过期或者已经释放了)
if (redis.call('exists', KEYS[1]) == 0) then
	// 发布锁已经释放的消息
	redis.call('publish', KEYS[2], ARGV[1]);
	return 1;
end;

// 锁存在,但是不是当前线程加的锁
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
	return nil;
end;

// 锁存在,是当前线程加的锁
// 重入次数-1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);

// -1 后大于 0,说明这个线程持有这把锁还有其他的任务需要执行
if (counter > 0) then
	// 重新设置锁的过期时间
	redis.call('pexpire', KEYS[1], ARGV[2]);
	return 0;
else
	// -1 之后等于 0,现在可以删除锁了
	redis.call('del', KEYS[1]);

	// 删除之后发布释放锁的消息
	redis.call('publish', KEYS[2], ARGV[1]);
	return 1;
end;

// 其他情况返回 nil
return nil;

十,缓存数据一致性分析


我们知道redis一个很很很重要的功能是缓存是吧,它是怎么玩的呢

  1. 如果数据在Redis存在,应用就可以直接从Redis拿到数据,不用访问数据库。

在这里插入图片描述

  1. 如果 Redis里面没有,先到数据库查询,然后写入到Redis,再返回给应用。

在这里插入图片描述

这个就是我们redis用作缓存的一种场景,因为这些数据是很少修改的,所以在绝大部分的情况下可以命中缓存。但是,一旦被缓存的数据发生变化的时候,我们既要操作数据库的数据,也要操作 Redis 的数据,所以一个老大难的问题来了。我们是先操作Redis还是先操作数据库:

  1. 先操作Redis的数据再操作数据库的数据

  2. 先操作数据库的数据再操作 Redis 的数据

到底选哪一种? 首先需要明确的是,不管选择哪一种方案, 我们肯定是希望两个操作要么都成功,要么都一个都不成功。不然就会发生Redis跟数据库的数据不一致的问题。但是,Redis的数据和数据库的数据是不可能通过事务达到统一的,我们只能根据相 应的场景和所需要付出的代价来采取一些措施降低数据不一致的问题出现的概率,在数据一致性和性能之间取得一个权衡。对于数据库的实时性一致性要求不是特别高的场合,比如 T+1 的报表,可以采用定时任务查询数据库数据同步到Redis的方案。由于我们是以数据库的数据为准的,所以给缓存设置一个过期时间,是保证最终一致性的解决方案。好,那么另外一个问题来了,我更新redis是应该修改还是直接删除?我建议大家采用删除的方案, 因为更新缓存之前,是不是还要经过表的查询、接口调用、计算才能得到最新的数据, 这样其实会让程序复杂化!这一点明确之后,现在我们就剩一个问题:

  1. 到底是先更新数据库,再删除缓存

  2. 还是先删除缓存,再更新数据库

先更新数据库,再删除缓存

正常情况:更新数据库,成功。删除缓存,成功。

异常情况:

  1. 更新数据库失败,程序捕获异常,不会走到下一步,所以数据不会出现不一致。

  2. 更新数据库成功,删除缓存失败。数据库是新数据,缓存是旧数据,发生了不一致的情况。

这种问题怎么解决呢?我们可以提供一个重试的机制。 就是不管怎么样,都得不断重试删除,直到删除成功!达到数据的最终一致性!

比如:如果删除缓存失败,我们捕获这个异常,把需要删除的 key 发送到消息队列。 然后自己创建一个消费者消费,尝试再次删除这个 key。

先删除缓存,再更新数据库

正常情况:

删除缓存,成功。更新数据库,成功。

异常情况:

  1. 删除缓存,程序捕获异常,不会走到下一步,所以数据不会出现不一致。

  2. 删除缓存成功,更新数据库失败。 因为以数据库的数据为准,所以不存在数据不一致的情况。

看起来好像没问题,但是如果有程序并发操作的情况下:

  • 线程A需要更新数据,首先删除了Redis缓存

  • 线程B查询数据,发现缓存不存在,到数据库查询旧值,写入Redis,返回

  • 线程A更新了数据库

这个时候,Redis是旧的值,数据库是新的值,发生了数据不一致的情况。所以我们就有一种延时双删的策略,在写入数据之后,再删除一次缓存。

A 线程:

  • 删除缓存

  • 更新数据库

  • 休眠 500ms(这个时间,依据读取数据的耗时而定)

  • 再次删除缓存


  1. Simple Dynamic String 简单动态字符串,在3.2以后的版本中,SDS又有多种结构(sds.h):sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64,用于存储不同的长度的字符串,分别代表 2^5=32byte,2^8=256byte,2^16=65536byte=64KB,2^32byte=4GB ↩︎

  2. 我们知道,C语言本身没有字符串类型(只能用字符数组char[]实现)1、使用字符数组必须先给目标变量分配足够的空间,否则可能会溢出。2、如果要获取字符长度,必须遍历字符数组,时间复杂度是 O(n)。3、C字符串长度的变更会对字符数组做内存重分配。4、通过从字符串开始到结尾碰到的第一个’\0’来标记字符串的结束,因此不能保存图片、音频、视频、压缩文件等二进制(bytes)保存的内容,二进制不安全。 ↩︎

  3. embstr 的使用只分配一次内存空间(因为RedisObjectSDS是连续的),而raw需要分配两次内存空间(分别为RedisObjectSDS 分配空间)。因此与 raw相比embstr的好处在于创建时少分配一次空间,删除时少释放一次空间,以及对象的所有数据连在一起,寻找方便。而 embstr的坏处也很明显,如果字符串的长度增加需要重新分配内存时,整个RedisObjectSDS都需要重新分配空间,因此Redis中的embstr实现为只读。 ↩︎

  4. 当int数据不再是整数,或大小超过了long的范围(2^63-1=9223372036854775807)时,自动转化为embstr,对于embstr,由于其实现是只读的,因此在对embstr对象进行修改时,都会先转化为raw再进行修改。因此,只要是修改embstr对象,修改后的对象一定是raw的,无论是否达到了44个字节。 ↩︎

  5. 关于Redis内部编码的转换,都符合以下规律:编码转换在Redis写入数据时完成,且转换过程不可逆,只能从小内存编码向大内存编码转换(但是不包括重新set)。 ↩︎

  6. 通过封装,可以根据对象的类型动态地选择存储结构和可以使用的命令,实现节省空间和优化查询速度 ↩︎

  7. ziplist.c文件中,ziplist是一个经过特殊编码的双向链表,它不存储指向上一个链表节点和指向下一个链表节点的指针,而是存储上一个节点长度和当前节点长度,通过牺牲部分读写性能,来换取高效的内存空间利用率,是一种时间换空间的思想。只用在字段个数少,字段值小的场景里面。 ↩︎

  8. ziplist.c 源码第 16 行的注释: ↩︎

  9. 当 hash 对象同时满足以下两个条件的时候,使用ziplist编码所有的键值对的健和值的字符串长度都小于等于64byte(一个英文字母一个字节);哈希对象保存的键值对数量小于512个; ↩︎

  10. Redis中,hashtable被称为字典(dictionary),它是一个数组+链表的结构。前面我们知道了,RedisKV结构是通过一个dictEntry来实现的。Redis又对dictEntry进行了多层的封装。源码位置:dict.h ↩︎

  11. redishash默认使用的是ht[0]ht[1]不会初始化和分配空间。哈希表dictht是用链地址法来解决碰撞问题的。在这种情况下,哈希表的性能取决于它的大小(size 属性)和它所保存的节点的数量(used 属性)之间的比率。比率在1:1时(一个哈希表 ht 只存储一个节点 entry),哈希表的性能最好;如果节点数量比哈希表的大小要大很多的话(这个比例用 ratio 表示,5 表示平均一个ht存储5个entry),那么哈希表就会退化成多个链表,哈希表本身的性能优势就不再存在,在这种情况下需要扩容。Redis里面的这种操作叫做 rehash。rehash的步骤:1.为字符ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对的数量。扩展:ht[1]的大小为第一个大于等于 ht[0].used*2。2. 将所有的 ht[0]上的节点 rehash 到 ht[1]上,重新计算 hash 值和索引,然后放入指定的位置。当 ht[0]全部迁移到了 ht[1]之后,释放 ht[0]的空间,将ht[1]设置为ht[0]表,并创建新的 ht[1],为下次 rehash 做准备。 ↩︎

  12. quicklistNode中的*zl指向一个 ziplist,一个 ziplist可以存放多个元素。如图: ↩︎

  13. key就是元素的值,value为null。如果元素个数超过512个,也会用hashtable存储。 ↩︎

  14. (上图中是 7, 19, 26)。在插入一个数据的时候,决定要放到那一层,取决于一个算法 (在 redist_zset.c 有一个zslRandomLevel这个方法)。现在当我们想查找数据的时候,可以先沿着这个新链表进行查找。当碰到比待查数据大的节点时,再回到原来的链表中的下一层进行查找。比如,我们想查找 23,查找的路径是沿着下图中标红的指针所指向的方向进行的:1.23首先和7比较,再和19比较,比它们都大,继续向后比较。2. 但23和26比较的时候,比26要小,因此回到下面的链表(原链表),与 22比较。3. 23比22要大,沿下面的指针继续向后和 26 比较。23 比 26 小,说明待查数据23在原链表中不存在这个查找过程中,由于新增加的指针,我们不再需要与链表中每个节点逐个进行比较了。需要比较的节点数大概只有原来的一半。这就是跳跃表 ↩︎

  15. 我们首先看过期策略!如果让我们自己来做,我们想到的是不是最好能让它主动淘汰,根据过期时间设计一个定时器,到时间就马上淘汰掉,等我讲完,你会发现redis就是抄袭我们的思想。它也没啥了不起的!它也是人弄出来的! ↩︎

  16. 那么当超过24bit能表示的最大时间的时候,它是这样子弄的!它会从头开始计算。这个时候就不是计算数据LRU跟这个lrulock的差值了,而是它的和,这样的话也能知道数据本身的热度了。所以才有lrulock大于lru跟小于lru2种情况! ↩︎

  17. 我们先从LRU的一个缺陷说起!假设A在10秒内被访问了5次,而B在10秒内被访问了3 次。因为 B 最后一次被访问的时间比A要晚,在同等的情况下,A反而先被回收。那么它就是不合理的。所以我们的redis就出了个LFU来解决这个问题!LRU犯的错,LFU来补偿! ↩︎

  18. 增长的速率由lfu-log-factor越大,counter增长的越慢!那么,我们来想下,如果它跟我们传统的LFU算法一样,是不是只会往上面加,不会减!这样就会有问题了啊,就是我们不会考虑到数据的一个时效性,虽然它的点击次数很高,但是总会有过期的那天啊,就像我们的绿地事件一样,虽然它之前点击量很高,但是现在也不是不在热搜榜了么!所以,redis没那么傻,它还有个减的操作!这个减的操作目的就是为了保证我们数据的一个时效性!如果很久没访问了那么我们就慢慢的给你减次数!到这里,我们那个高16位的时间是不是就能想到是干啥的呢,就是来判断我这个数据有多久没访问了!那它怎么减的呢?其实也是根据一个配置来的,lfu-decay-time(分钟)来控制,如果值是1的话,每分钟没有访问就要减少1,10就是每分钟没访问就减少10次。redis.conf配置文件 ↩︎

  19. AOF文件重写并不是对原文件进行重新整理,而是直接读取服务器现有的键值对,然后用一条命令去代替之前记录这个键值对的多条命令,生成一个新的文件后去替换原来的AOF文件,指令是可以触发重写的,但是redis肯定没有那么龊,它肯定有自己的触发机制,我们来看下它怎么操作的。配置文件·redis.conf ↩︎

  20. 如果可以忍受一小段时间内数据的丢失,毫无疑问使用 RDB 是最好的,定时生成RDB快照(snapshot)非常便于进行数据库备份, 并且 RDB 恢复数据集的速度也要比AOF恢复的速度要快。但是一般情况下建议不要单独使用某一种持久化机制,而是应该两种一起用,在这种情况下,当redis 重启的时候会优先载入AOF文件来恢复原始的数据,因为在通常情况下AOF文件保存的数据集要比RDB文件保存的数据集要完整。 ↩︎

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

可乐cc呀

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值