参考作者( 架构师余胜军 ,写的非常好)
Redis 是速度非常快的非关系型(NoSQL)内存键值数据库,可以存储键和五种不同类型的值之间的映射。
键的类型只能为字符串,值支持五种数据类型:字符串、列表、集合、散列表、有序集合。
Redis 支持很多特性,例如将内存中的数据持久化到硬盘中,使用复制来扩展读性能,使用分片来扩展写性能。
数据类型 | 可以存储的值 |
---|---|
STRING | 字符串、整数或者浮点数 |
LIST | 列表 |
SET | 无序集合 |
HASH | 包含键值对的无序散列表 |
ZSET | 有序集合 |
set key value 将字符串值 value 关联到 key ,如果 key 已经持有其他值, SET 就覆写旧值,无视类型。
get key 取key的值value
del key 删除key:删除操作成功 返回(integer)1;删除操作失败 返回(integer)0
高级命令:
mset k1 v1 k2 v2 k3 v3 ... 一次性添加或修改多个键值对
mget k1 k2 k3... 一次性获取k1 k2 k3...的value
strlen k 获取k对应的v的字符串长度
append k v 往k对应的v尾部追加数据,如果不存在就新建,这时候相当于set k v
自增自减操作控制数据库主键:
incr key 对应的value加1
decr key 对应的value减1
incrby key increment 对应的value+increment
decrby key increment 对应的value-increment
incrbyfloat key increment 对应的value+一个浮点数
数据库的热点数据key命名规范:
表名:主键名:主键值:字段名
内部是使用双向链表(double linked list)实现的,所以向列表两端添加元素的时间复杂度为0(1),获取越接近两端的元素速度就越快。这意味着即使是一个有几千万个元素的列表,获取头部或尾部的10条记录也是极快的
lpush key value1 value2 ... 左侧插入
rpush key value1 value2 ... 右侧插入
lrange key start stop 从start开始到stop结束的下标的数据,索引从0开始,如果是负数结束,比如stop=-1,那就是截止到倒数第一个
lindex key index 找到index位置的数据
lpop key 移除并返回第一个元素
rpop key 移除并返回最后一个元素
llen 获取列表中元素个数
lrem key count value 删除list列表中number个value(因为list元素可以重复,所以要指定count) 1)当count>0时, lrem会从列表左边开始删除
2)当count<0时, lrem会从列表后边开始删除
3)当count=0时, lrem删除所有值为value的元素
ltrim key start stop 只保留列表中start开始到stop结束之间指定片段的数据
linsert key before|after pivot value 该命令首先会在列表中从左到右查找值为pivot的元素,然后根据 第二个参数是BEFORE还是AFTER来决定将value插入到该元素的前面还是后面
> rpush list-key item
(integer) 1
> rpush list-key item2
(integer) 2
> rpush list-key item
(integer) 3
> lrange list-key 0 -1
1) "item"
2) "item2"
3) "item"
> lindex list-key 1
"item2"
> lpop list-key
"item"
> lrange list-key 0 -1
1) "item2"
2) "item"
应用场景: 朋友圈评论,按顺序显示评论的朋友
(1) 存储大量数据、查询速度快
(2) 集合中的数据是不重复且没有顺序
(3) 集合类型的Redis内部是使用值为空的散列表实现,所有这些操作的时间复杂度都为0(1)
(4) 集合类型的常用操作是向集合中加入或删除元素、判断某个元素是否存在等,除此之外Redis还提供了多个集合之间的交集、并集、差集的运算。
hash和set的结构:
① hash: key-{field:value}
② set: key-{value:null}
sadd key value1 value2 ... 添加数据,如果重复添加,会添加失败
smembers key 获取全部数据
scard key 获取数据总量
srem key value 删除数据
sismember key value 判断value是否是key集合内的数据
> sadd set-key item
(integer) 1
> sadd set-key item2
(integer) 1
> sadd set-key item3
(integer) 1
> sadd set-key item
(integer) 0
> smembers set-key
1) "item"
2) "item2"
3) "item3"
> sismember set-key item4
(integer) 0
> sismember set-key item
(integer) 1
hash叫散列类型,它提供了字段和字段值的映射。字段值只能是字符串类型,不支持其它类型。
格式:一个存储空间(key)存储多个键值对,底层通过哈希表进行存储。
key {filed1 - v1, filed2 - v2,…}
注意:如果filed数量较少时,会被优化为类数组的结构,如果filed数量多,就是HashMap。
hset key field value 新增/修改某个field的v,新增时返回1,修改时返回0
hsetnx key field value 如果key中没有field字段则设置field值为value,否则不做任何操作
hget key field 获取某个field的v
hgetall key 获取这个key的所有f-v
hdel key field 删除某个field,可以删除一个或多个,返回值是被删除的字段个数
del key 删除整个key
hmset key f1 v1 f2 v2 f3 v3 ... 新增/修改某个field的f1、f2、f3,值分别为v1、v2、v3
hmget key f1 f2 f3... 获取某个field的f1、f2、f3 的值
hlen key 获取key的字段数量,就是field的数量
hexists key field 判断key中是否存在field这个字段
> hset hash-key sub-key1 value1
(integer) 1
> hset hash-key sub-key2 value2
(integer) 1
> hset hash-key sub-key1 value1
(integer) 0
> hdel hash-key sub-key2
(integer) 1
> hdel hash-key sub-key2
(integer) 0
> hget hash-key sub-key1
"value1"
注意事项
(1) hash类型的value只能存储string,不允许嵌套存储。如果获取不到对应的数据,返回的是(nil)。
(2) 每个hash最多存储2^32 -1 个键值对。
(3) hash最初设计不是为了存对象,不要把hash当成对象列表使用。
(4) hgetall 可以获取全部属性,如果field过多,遍历一次会很慢,影响程序效率。
应用场景
(1) 电商购物车:添加购物车、浏览购物车商品、更改购物车商品数量、删除商品、清空商品均可实现。
key : userID
field : 商品ID
value : 商品购买数量
演示案例
127.0.0.1:6379> hmset userid:1001 id 10011 name phone number 10
OK
127.0.0.1:6379> hmset userid:1002 id 10012 name xiaomi number 15
OK
127.0.0.1:6379> hgetall userid:1001
1) "id"
2) "10011"
3) "name"
4) "phone"
5) "number"
6) "10"
zset使用散列表实现
如果添加重复的数据,score会被最后一次的覆盖
zadd key score1 value1 score2 value2.. --添加数据,向有序集合中加入一个元素和该元素的分数,如果该 元素已经存在则会用新的分数替换原有的分数
返回值是新加入到集合中的元素个数,不包含之前已经存在的元素
zrange key start stop [WITHSCORES] 获取数据按照元素分数从小到大的顺序返回索引从start到stop之间的所有元素(包含两端的元素),
如果WITHSCORES在末尾,则会把score也输出出来
zrevrange key srart stop [WITHSCORES] 按照元素分数从大到小的顺序返回索引从start到stop之间的所有 元素,如果WITHSCORES在末尾,则会把score也输出出来。
zcard key 获取数据总量
zcount key min max 获取[min, max]范围内的数据数量
zrem key value 删除数据
> zadd zset-key 728 member1
(integer) 1
> zadd zset-key 982 member0
(integer) 1
> zadd zset-key 982 member0
(integer) 0
> zrange zset-key 0 -1 withscores
1) "member1"
2) "728"
3) "member0"
4) "982"
> zrangebyscore zset-key 0 800 withscores
1) "member1"
2) "728"
> zrem zset-key member1
(integer) 1
> zrem zset-key member1
(integer) 0
> zrange zset-key 0 -1 withscores
1) "member0"
2) "982"
获取排名(索引)
zrank key value 获取value在key中的升序排名,score小的排在前面
zrevrank key value 降序排名
zscore key value 拿到value的score
zincrby key increment value 给value加上对应的increment
127.0.0.1:6379> zadd scores 30 a 50 b 10 c 35 d
(integer) 4
127.0.0.1:6379> zrank scores c
(integer) 0
127.0.0.1:6379> zrevrank scores c
(integer) 3
127.0.0.1:6379> zscore scores b
"50"
127.0.0.1:6379> zincrby scores 5 c
"15"
sds 字符串即 Simple Dynamic String(即简单动态字符串),其中动态的含义是内存的分配是动态的,sds的定义如下: 但是这个sds类型仅作为参数和返回值使用,并不是真正用于操作的类型,真正核心的部分是下面的这些类:
//当长度不同的时候,类型也不一样
struct __attribute__ ((__packed__)) sdshdr5 {
//当字符串长度 <32,用这种
unsigned char flags;
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
//当字符串长度 < 2^8 -1,用这种
uint8_t len;
uint8_t alloc;
unsigned char flags;
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
//当字符串长度 < 2^16 -1,用这种
uint16_t len;
uint16_t alloc;
unsigned char flags;
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len;
uint32_t alloc;
unsigned char flags;
char buf[];
};
redis同时写重写了大量的与sds类型相关的方法,那redis为什么要这么下功夫呢,有以下4个优点:
可以用位图来实现活跃用户的统计
一个列表结构可以有序地存储多个字符串,拥有例如:lpush lpop rpush rpop等操作命令。
ziplist的结构
由表头和N个entry节点和压缩列表尾部标识符zlend组成的一个连续的内存块。然后通过一系列的编码规则,提高内存的利用率,主要用于存储整数和比较短的字符串。实际上,ziplist充分体现了Redis对于存储效率的追求。一个普通的双向链表,链表中每一项都占用独立的一块内存,各项之间用地址指针(或引用)连接起来。这种方式会带来大量的内存碎片,而且地
址指针也会占用额外的内存。而ziplist却是将表中每一项存放在前后连续的地址空间内,一个ziplist整体占用一大块内存。它是一个表(list),但其实不是一个链表(linked list)。另外,ziplist为了在细节上节省内存,对于值的存储采用了变长的编码方式,大概意思是说,对于大的整数,就多用一些字节来存储,而对于小的整数,就少用一些字节来存储。我们接下来很快就会讨论到这些实现细节。
但是由于数据量非常大的话,频繁的进行内存分配和释放,会很麻烦,造成更新效率低下的情况。就引入了一个quicklist的数据结构,配合ziplist。意思就是一个由ziplist组成的双向链表。
每个节点都是以压缩列表ziplist的结构保存着数据,而每个ziplist又可以包含多个entry。也可以说一个quicklist节点保存的是一片数据,而不是一个数据,即每个quicklist节点就是一个ziplist,具备压缩列表的特性。整体上quicklist就是一个双向链表结构,和普通的链表操作一样,插入删除效率很高,但查询的效率却是O(n)。不过,这样的链表访问两端的元素的时间复杂度却是O(1)。所以,对list的操作多数都是poll和push。
Redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。
接下来将分别介绍Redis的哈希表节点、哈希表以及字典的实现。
typedef struct redisDb {
dict *dict;
dict *expires;
dict *blocking_ keys;
dict *ready_ keys;
dict *watched keys;
int id;
long long avg_ tt;
unsigned long expires cursor;
list *defrag_ later;
} redisDb;
//其中dict的结构为:
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; //rehash的索引,没有进行rehash操作时值都为-1.
unsigned long iterators; /* number of iterators currently running */
} dict;
//其中dictht的结构为
typedef struct dictht {
dictEntry **table;//table里面放的就是一个一个dictEntry,使用拉链法解决哈希冲突。
unsigned long size;
unsigned long sizemask;
unsigned long used;
} dictht;
//其中dictEntry的结构为 可以把dictEntry理解为node
typedef struct dictEntry {
void *key; //key就相当于一个下标索引
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next; //指向下一个节点
} dictEntry;
//其中dictEntry中union中的val的数据为redisObject
typedef struct redisObject {
unsigned type:4; //表示值的数据类型。
unsigned encoding:4;//值的编码方式,就是其底层的数据结构实现。
unsigned lru:LRU_BITS;//记录了对象最后一次被访问的时间,用于淘汰过期的键值对。
int refcount;//记录了对象的引用计数。
void *ptr;//指向数据的指针。比如ptr指向String,zest
} robj;
Redis 的字典 dict 中包含两个哈希表 dictht,这是为了方便进行 rehash 操作。在扩容时,将其中一个 dictht 上的键值对 rehash 到另一个 dictht 上面,完成之后释放空间并交换两个 dictht 的角色。
rehash过程:随着操作的不断执行,哈希表保存的键值对有可能增多或者减少,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。
扩展和收缩哈希表的工作可以通过执行rehash(重新散列)操作来完成,步骤如下:
1)为字典的ht[1]哈希表分配空间,空间大小根据实际情况而定;
2)将ht[0]中所有键值对rehash到ht[1]中
注意:rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上
3)释放ht[0],将ht[1]设置为ht[0],并在ht[1]新建一个空表,为下次rehash做准备
rehash 操作不是一次性完成,而是采用渐进方式,这是为了避免一次性执行过多的 rehash 操作给服务器带来过大的负担。渐进式 rehash 通过记录 dict 的 rehashidx 完成,它从 0 开始,然后每执行一次 rehash 都会递增。例如在一次 rehash 中,要把 dict[0] rehash 到 dict[1],这一次会把 dict[0] 上 table[rehashidx] 的键值对 rehash 到 dict[1] 上,dict[0] 的 table[rehashidx] 指向 null,并令 rehashidx++。
是有序集合的底层实现之一。
skiplist的数据结构定义:
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
typedef struct zskiplistNode {
//这个就是一个一个数据结点
robj *obj;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned int span;
} level[];
} zskiplistNode;
跳跃表是基于多指针有序链表实现的,可以看成多个有序链表。
创建结点的时候,为每个节点随机出一个层数(level)。比如,一个节点随机出的层数是3,那么就把它链入到第1层到第3层这三层链表中。下图展示了如何通过一步步的插入操作从而形成一个skiplist的过程:
实际上,Redis中sorted set的实现是这样的:
总结起来,Redis中的skiplist跟前面介绍的经典的skiplist相比,有如下不同:
与红黑树等平衡树相比,跳跃表具有以下优点:
缓存
将热点数据放到内存中,设置内存的最大使用量以及淘汰策略来保证缓存的命中率。
消息队列
List 是一个双向链表,可以通过 lpush 和 rpop 写入和读取消息
不过最好使用 Kafka、RabbitMQ 等消息中间件。
会话缓存
可以使用 Redis 来统一存储多台应用服务器的会话信息。
当应用服务器不再存储用户的会话信息,也就不再具有状态,一个用户可以请求任意一个应用服务器,从而更容易实现高可用性以及可伸缩性。
分布式锁实现
在分布式场景下,无法使用单机环境下的锁来对多个节点上的进程进行同步。
可以使用 Redis 自带的 SETNX 命令实现分布式锁,他跟set相反,set会把值覆盖,而SETNX不会覆盖,会除此之外,还可以使用官方提供的 RedLock 分布式锁实现。
Redis 支持两种持久化策略:RDB 快照和 AOF 日志,而 Memcached 不支持持久化。
redis数据淘汰策略
可以设置内存最大使用量,当内存使用量超出时,会施行数据淘汰策略。
Redis 具体有 6 种淘汰策略:
作为内存数据库,出于对性能和内存消耗的考虑,Redis 的淘汰算法实际实现上并非针对所有 key,而是抽样一小部分并且从中选出被淘汰的 key。
使用 Redis 缓存数据时,为了提高缓存命中率,需要保证缓存数据都是热点数据。可以将内存最大使用量设置为热点数据占用的内存量,然后启用 allkeys-lru 淘汰策略,将最近最少使用的数据淘汰。
Redis 是内存型数据库,为了保证数据在断电后不会丢失,需要将内存中的数据持久化到硬盘上。
在某些时刻,Redis通过fork产生子进程,一个父进程的快照(副本),其中有和父进程当前时刻相同的数据,父进程继续处理client请求执行IO操作。子进程负责将快照(数据副本)写入临时文件,子进程写完后,用临时文件替换原来的快照文件,然后子进程退出。然后呢就会隔一段时间自动进行一次备份。
#save时间,以下分别表示更改了1个key时间隔900s进行持久化存储;更改了10个key300s进行存储;更改10000个key60s进行存储。
save 900 1
save 300 10
save 60 10000
RDB文件存在是以一个压缩后的二进制文件,这个RDB文件一般是保存在Redis安装目录下,通过启动Redis服务器执行rdbLoad函数加载RDB文件,执行rdbSave函数保存RDB文件。 RDB会每个一段时间去更新一下redis的数据,会生成一个二进制文件,RDB会通过一个bgsave的命令,会fork出一个子进程,通过一个写实复制的方式,生成一个RDB文件。
注意:SAVE和BGSAVE两个命令都会调用rdbSave函数。
SAVE(不推荐使用)直接调用rdbSave,阻塞Redis主进程,直到保存完成为止。在主进程阻塞期间,服务器不能处理客户端的任何请求。
BGSAVE则开启一个子进程,子进程负责调用rdbSave,Redis服务器在BGSAVE执行期间仍然可以继续处理客户端的请求,并在保存完成之后向主进程发送信号,通知保存已完成。
snapshot执行流程:
将写命令添加到 AOF 文件(Append Only File)的末尾。
Append-only file,将“操作 + 数据”以格式化指令的方式追加到缓冲区中,然后缓冲区根据对应的策略向硬盘进行同步操作,在append操作返回后(已经写入到文件或者即将写入),才进行实际的数据变更,AOF保存了历史所有的操作过程。当server需要数据恢复时,可以直接加载日志文件,即可还原所有的操作过程。
使用 AOF 持久化需要设置同步选项,从而确保写命令同步到磁盘文件上的时机。这是因为对文件进行写入并不会马上将内容同步到磁盘上,而是先存储到缓冲区,然后由操作系统决定什么时候同步到磁盘。有以下同步选项:Redis目前支持三种AOF保存模式:
everysec: 表示每秒同步一次(默认值,很快,但可能会丢失一秒以内的数据
no:表示等操作系统进行数据缓存同步到磁盘,效率快,但是持久化没保证
always:同步持久化,每次发生数据变更时,立即记录到磁盘,效率慢,严重减低服务器的性能,但是安全
随着服务器写请求的增多,AOF 文件会越来越大。Redis 提供了一种将 AOF 重写的特性,能够去除 AOF 文件中的冗余写命令。我们可以看到经常会对一个key进行多次修改,那么我们可以把这个key的最后一次操作保存起来这样我们就轻易的给AOF"瘦身"。当然我们还有一种方式,就是遍历整个Redis,set每个key和它的值,也跟RDB全备一样我们需要一个子进程读取当前的Redis库。这里会出现一个问题,我们如果是遍历整个Redis需要考虑此时的客户端必定会有指令更改里面的值,此时我们怎么保证AOF重写后不丢下重写后的指令呢?
操作步骤:
AOF创建一个子进程进行AOF重写,其指定内存跟主进程一致
客户端执行写命令,主线程处理指令,指令追加到AOF缓冲区,并且追加到AOF重写缓冲区
AOF重写完成后替换现有的AOF文件
那么为什么会把这个指令同时追加到AOF缓冲区和AOF重写区呢?原因是如果我们在重写的时候突然服务器挂了,那么我们AOF文件中会保存这个指令。追加到AOF缓冲区是为了保证操作指令能及时同步到AOF重写区。
AOF优点:AOF会进行实时的写操作,不管你是每秒钟执行一次还是手动执行,都会将数据写入磁盘,即使系统崩溃,也只会丢失一秒钟的数据,比RDB更适于做更实时的持久化。
AOF缺点:在一直进行写操作,AOF文件会不断增长(可能比快照文件大几倍),在极端情况下,可能会对硬盘空间造成压力,即使有重写机制可能也无法保证他很小。Redis在重启时,需要重新执行一个可能非常大的AOF,时间会很长AOF与RDB的区别
RDB持久化是在指定的时间内将数据写入磁盘,实际操作时主进程fork出一个子进程,让子进程将数据写入,写入成功后在替换点之前的文件。
AOF是一个简短的写指令,在每一秒进行一次写指令,单次的消耗远低于RDB,所以AOF更适合做实时的持久化,将新加入的数据写入文件。RDB执行bgsave时和AOF重写一样,开启一个子进程,他们的内存与父进程共享
当开启混合持久化,把数据以 RDB 的方式写入文件,再将后续的操作命令以 AOF 的格式存入文件,并将新的含有RDB格式和AOF格式的AOF文件替换旧的的AOF文件。既保证了 Redis 重启速度,又降低数据丢失风险。
AOF rewrite 的时候就直接把 RDB的内容写到 AOF 文件开头。AOF 文件内容会变成如下:
这样做的好处是可以结合 RDB和 AOF的优点, 快速加载同时避免丢失过多的数据。
混合 AOF 加载
开启混合存储模式后 AOF文件加载的流程如下:
- AOF文件开头是 RDB的格式, 先加载 RDB内容再加载剩余的 AOF
- AOF文件开头不是 RDB的格式,直接以 AOF格式加载整个文件
优点:混合持久化结合了RDB持久化和AOF持久化的优点,由于绝大部分都是RDB格式,加载速度快,同时结合
AOF,增量的数据以AOF方式保存了,数据更少的丢失。
缺点:兼容性差,在4.0之前版本都不识别该混合持久化AOF文件
为了分担压力,Redis支持主从复制,Redis的主从结构可以采用一主多从或者级联结构,Redis主从同步策略的策略就是先是全量同步,再为增量同步。
全量同步:Redis全量复制一般发生在Slave初始化阶段,这时Slave需要将Master上的所有数据都复制一份。具体步骤如下:
- 从服务器连接主服务器;
- 主服务器接收到命名后,开始执行BGSAVE命令生成RDB文件并使用缓冲区记录此后执行的所有写命令;
- 主服务器BGSAVE执行完后,向所有从服务器发送快照文件,并在发送期间继续记录被执行的写命令;
- 从服务器收到快照文件后丢弃所有旧数据,载入收到的快照;
- 主服务器快照发送完毕后开始向从服务器发送缓冲区中的写命令;
- 从服务器完成对快照的载入,开始接收命令请求,此后主服务器每执行一次写命令,就向从服务器发送相同的写命令(也就是增量同步)。
增量同步:Redis增量复制是指Slave初始化后开始正常工作时主服务器发生的写操作同步到从服务器的过程。 增量复制的过程主要是主服务器每执行一个写命令就会向从服务器发送相同的写命令,从服务器接收并执行收到的写命令。
主从复制的作用:主从复制,读写分离,容灾恢复。一台主机负责写入数据,多台从机负责备份数据。在高并发的场景下,即便是主机挂了,可以用从机代替主机继续工作,避免单点故障导致系统性能问题。读写分离,让读多写少的应用性能更佳。
使用 Redis 主从服务的时候,会有一个问题,就是当 Redis 的主从服务器出现故障宕机时,需要手动进行恢复,为了解决这个问题,Redis 增加了哨兵模式(因为哨兵模式做到了可以监控主从服务器,并且提供自动容灾恢复的功能)。 Sentinel(哨兵)可以监听集群中的服务器,并在主服务器进入下线状态时,自动从从服务器中选举出新的主服务器。
使用哨兵模式在数据上有副本数据做保证,在可用性上又有哨兵监控,一旦master宕机会选举salve节点为master节点,这种已经满足了我们的生产环境需要,那为什么还需要使用集群模式呢?
答:因为主服务器挂掉的时候,要进行主从切换,这瞬间存在访问瞬断的情况。虽然在主从模式下我们可以通过增加salve节点来扩展读并发能力,但是没办法扩展写能力和存储能力,所以为了扩展写能力和存储能力,我们就需要引入集群模式。
redis集群是一个由多个主从节点群组成的分布式服务器群,它具有复制、高可用和分片特性。Redis集群不需要哨兵也能完成节点移除和故障转移的功能。需要将每个节点设置成集群模式,这种集群模式没有中心节点,可水平扩展。
集群中那么多Master节点,redis cluster在存储的时候如何确定选择哪个节点呢?
答:Redis Cluster采用的是类一致性哈希算法实现节点选择的。
Redis Cluster将自己分成了16384个Slot(槽位),哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中,具体执行过程分为两大步。
0~16383
范围内的模数,每个模数代表一个相应编号的哈希槽。每个Redis节点负责处理一部分槽位,假如你有三个master节点 ABC,每个节点负责的槽位如下:
节点 | 处理槽位 |
---|---|
A | 0-5000 |
B | 5001 - 10000 |
C | 10001 - 16383 |
当执行写操作后,需要保证从缓存读取到的数据与数据库中的数据是一致的,因此需要对缓存进行更新。
因为涉及到数据库和缓存两步操作,难以保证更新的原子性。在设计更新策略时,我们需要考虑多个方面的问题,对系统吞吐量的影响、并发安全性、更新失败的影响
更新缓存有两种方式:
更新缓存和更新数据库有两种顺序:
两两组合共有四种更新策略,现在我们逐一进行分析。
先做一个说明,从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案。这种方案下,我们可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存。
若数据库更新成功,删除缓存操作失败,则此后读到的都是缓存中过期的数据,造成不一致问题。
假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生
(1)缓存刚好失效(2)请求A查询数据库,得一个旧值(3)请求B将新值写入数据库(4)请求B删除缓存(5)请求A将查到的旧值写入缓存
假设,有人非要抬杠,有强迫症,一定要解决怎么办?
如何解决上述并发问题?首先,给缓存设置有效时间是一种方案。其次,采用异步延时删除策略,redis自己起一个线程,异步删除保证读请求完成以后,再进行删除操作。
同删除缓存策略一样,若数据库更新成功缓存更新失败则会造成数据不一致问题。反对此方案
同时有请求A和请求B进行更新操作,那么会出现
(1)线程A更新了数据库(2)线程B更新了数据库(3)线程B更新了缓存(4)线程A更新了缓存
这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据,因此不考虑。
有如下两点:
(1)如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。
(2)如果你写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。
接下来讨论的就是争议最大的,先删缓存,再更新数据库。还是先更新数据库,再删缓存的问题。
该方案会导致不一致的原因是。同时有一个请求A进行操作,另一个请求B进行查询操作。那么会出现如下情形:
(1)请求A进行写操作,删除缓存(2)请求B查询发现缓存不存在(3)请求B去数据库查询得到旧值(4)请求B将旧值写入缓存(5)请求A将新值写入数据库
上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。
那么,如何解决呢?
采用延时双删策略:
(1)先淘汰删除缓存(2)再写数据库(这两步和原来一样)(3)休眠1秒,再次淘汰缓存
这么做,可以将1秒内所造成的缓存脏数据,再次删除。
那么,这个1秒怎么确定的,具体该休眠多久呢?这确实需要根据实际情况而定
如果你用了MySQL的读写分离架构怎么办?还是使用延时双删策略。
采用这种同步淘汰策略,吞吐量降低怎么办?ok,那就将第二次删除作为异步的。自己起一个线程,异步删除。
第二次删除,如果删除失败怎么办?这是个非常好的问题,因为第二次删除失败,就会出现如下情形。还是有两个请求,一个请求A进行更新操作,另一个请求B进行查询操作,为了方便,假设是单库:
(1)请求A进行写操作,删除缓存(2)请求B查询发现缓存不存在(3)请求B去数据库查询得到旧值(4)请求B将旧值写入缓存(5)请求A将新值写入数据库(6)请求A试图去删除请求B写入对缓存值,结果失败了。
ok,这也就是说。如果第二次删除缓存失败,会再次出现缓存和数据库不一致的问题。
如何解决呢?
若缓存更新成功数据库更新失败, 则此后读到的都是未持久化的数据。因为缓存中的数据是易失的,这种状态非常危险。
正常情况下的流程是这样的,先查缓存,缓存无就查数据库
缓存雪崩是指缓存中的数据大批量的过期 ,而查询量巨大,造成数据库压力过大而崩溃。
解决方法:
缓存的过期时间随机设置,防止大量数据同时过期。
尽量保证redis集群的高可用性,当发现机器坠机时尽快补上。
选择合适的缓存淘汰策略。
缓存击穿是指缓存中没有数据,而数据库中有数据,一般是缓存中的数据过期了,然后很多用户并发查询该数据,同时在缓存中读取该数据没读取到,就同时去数据库中查,造成数据库压力过大。缓存击穿强调的是一个数据过期,同时并发地去数据库访问该数据;而缓存雪崩是强调大量的数据过期。
解决方法:
设置热点数据永不过期。
加互斥锁。逻辑如下:从缓存中获取当前数据,如果缓存中没有,则尝试去获取锁,如果获取成功则查询数据库,然后写进缓存,然后释放锁。
缓存穿透是指缓存中没有该数据,数据库中也没有该数据。而用户不断地发请求,比如不断发出一些id=-1
或者是根本就很不合理的数据来发生请求。这种一般是别人想攻击你。攻击会导致数据库压力过大。
对于这种情况很好解决,我们可以在redis缓存一个空字符串或者特殊字符串,比如&&,下次我们去redis中查询的时候,当取到的值是空或者&&,我们就知道这个值在数据库中是没有的,就不会在去数据库中查询,ps:这里缓存不存在key的时候一定要设置过期时间,不然当数据库已经新增了这一条记录的时候,这样会导致缓存和数据库不一致的情况
上面这个只是重复查询同一个不存在的值的情况,如果每次查询的不存在的值是不一样的呢?那怎么办,难道自己手动缓存许多特殊字符串吗?别人想攻击你,即使你每次缓存很多特殊字符串也没用,太有概率性了,这时候数据库的压力是相当大,怎么办呢,布隆过滤器就登场了。
布隆过滤器使用场景:
①、原本有10亿个数,现在又来了10万个数,要快速准确判断这10万个数是否在10亿个数库中?
办法一:将10亿个数存入数据库,再数据库查询,查出值为null,代表不存在,准确性有了,但是速度会比较慢。
办法二:将10亿数放入内存中,比如Redis中,这里我们算一下占用内存大小:10亿*8字节=8GB,通过内存查询,准确性和速度都有了,但是大约8GB的内存空间,挺浪费内存空间的。
那么对于类似这种,大数据量集合,如何准确快速的判断某个数据是否在大数据量集合中,并且不占用内存,布隆过滤器应运而生了。
布隆过滤器:使用位图实现,是由一串很长的二进制向量组成,数组中只存在0.1
当要向布隆过滤器中添加一个元素key时,我们通过多个hash函数,算出一个值,然后将这个值所在的方格置为1。 如下图(图片来源)
如何查询是否存在呢?
我们只需要将这个新的数据通过上面自定义的几个哈希函数,分别算出各个值,然后看其对应的地方是否都是1,如果存在一个不是1的情况,那么我们可以说,该新数据一定不存在于这个布隆过滤器中。
反过来说,如果通过哈希函数算出来的值,对应的地方都是1,那么我们能够肯定的得出:这个数据一定存在于这个布隆过滤器中吗?
答案是否定的,因为多个不同的数据通过hash函数算出来的结果是会有重复的,所以会存在某个位置是别的数据通过hash函数置为的1。比如这个d,通过三次计算发现得到的结果也都是1,那么我们能说d在布隆过滤器中是存在的吗,显然是不行的,我们仔细看d得到的三个1其实是f1(a),f1(b),f2©存进去的,并不是d自己存进去的,这个还是哈希碰撞导致的。
结论:布隆过滤器可以判断某个数据一定不存在,但是无法判断一定存在。