一、redis5种数据结构:
1 string结构
string类型常用作单值缓存,分布式锁,线程安全的计数器(INCR key;DECR key)、Web集群session共享、分布式系统全局序列号等
- SET key value :存入字符串键值对
- GET key:获取一个字符串键值
- SETNX key value :key 不存在,设置 value,返回1 ; key 存在,不做任何动作,返回0(分布式锁)
- DEL key key ... :删除给定的一个或多个 key 。不存在的 key 会被忽略,返回值为被删除 key 的数量
- EXPIRE key seconds:设置一个键的过期时间(秒)
- MSET key value ... key value :同时为多个键设置值,是一个原子性(atomic)操作, 所有给定键都会在同一时间内被设置(集群不可用)
- MGET key ... key:获取多个建对应的值(集群不可用)
- INCR key:将key中储存的数字值加1
- DECR key:将key中储存的数字值减1
- INCRBY key increment:/将key所储存的值加上increment
- DECRBY key decrement:将key所储存的值减去decrement
2 hash结构
- HSET key field value: 存储一个哈希表key的键值
- HSETNX key field value: 存储一个不存在的哈希表key的键值
- HMSET key field value [field value ...]:在一个哈希表key中存储多个键值对
- HGET key field:获取哈希表key对应的field键值
- HMGET key field [field ...]: 批量获取哈希表key中多个field键值
- HDEL key field [field ...]: 删除哈希表key中的field键值
- HLEN key:/返回哈希表key中field的数量
- HGETALL key:返回哈希表key中所有的键值
- HINCRBY key field increment:为哈希表key中field键的值加上增量increment
3 list结构
redis上做栈、队列、阻塞队列,微博和微信公号消息流等(我关注的发表信息,加入到我的list,每次查询消息顺序不变)
- LPUSH key value [value ...]:将一个或多个值value插入到key列表的表头(最左边)
- RPUSH key value [value ...]:/将一个或多个值value插入到key列表的表尾(最右边)
- LPOP key:移除并返回key列表的头元素
- RPOP key:移除并返回key列表的尾元素
- LRANGE key start stop:返回列表key中指定区间内的元素,区间以偏移量start和stop指定
- BLPOP key [key ...] timeout:从key列表表头弹出一个元素,若列表中没有元素,阻塞等待timeout秒,如果timeout=0,一直阻塞等待
- BRPOP key [key ...] timeout:从key列表表尾弹出一个元素,若列表中没有元素,阻塞等待timeout秒,如果timeout=0,一直阻塞等待
4 set结构
常用操作包括加入集合,确认集合存在某个value,从集合中获取n个value(可删,可不删),取交集、并集、差集,可以做关注、点赞,可以做人脉关系模型等(共同关注的人),电商筛选(根据条件设置集合,多条件下获取交集)
- SADD key member [member ...]:往集合key中存入元素,元素存在则忽略,若key不存在则新建
- SREM key member [member ...]:从集合key中删除元素
- SMEMBERS key:获取集合key中所有元素
- SCARD key:获取集合key的元素个数
- SISMEMBER key member:判断member元素是否存在于集合key中
- SRANDMEMBER key [count]:从集合key中选出count个元素,元素不从key中删除
- SPOP key [count]:从集合key中选出count个元素,元素从key中删除
- SINTER key [key ...] :交集运算
- SINTERSTORE destination key [key ..]:将交集结果存入新集合destination中
- SUNION key [key ..] :并集运算
- SUNIONSTORE destination key [key ...]:/将并集结果存入新集合destination中
- SDIFF key [key ...]:差集运算
- SDIFFSTORE destination key [key ...]:将差集结果存入新集合destination中
5 zset结构··
带分值的set,可根据分值正序和倒序输出。top10,七日内点赞排序等
- ZADD key score member [[score member]…]:/往有序集合key中加入带分值元素
- ZREM key member [member …]:/从有序集合key中删除元素
- ZSCORE key member:/返回有序集合key中元素member的分值
- ZINCRBY key increment member:为有序集合key中元素member的分值加上increment
- ZCARD key:返回有序集合key中元素个数
- ZRANGE key start stop [WITHSCORES]:正序获取有序集合key从start下标到stop下标的元素
- ZREVRANGE key start stop [WITHSCORES]:倒序获取有序集合key从start下标到stop下标的元素
- ZUNIONSTORE destkey numkeys key [key ...]:/并集计算
- ZINTERSTORE destkey numkeys key [key …]:交集计算
二、数据结构底层实现
0 全局hash表
不管使用何种数据接口,redis最根本是一个k-v数据库。所有的键值对保存在一个全局hash表中,所以查找操作是O(1)级别。
哈希冲突解决:拉链法(过长退化通过rehash操作解决)
高效rehash:渐进式rehash处理
Redis默认使用了两个全局哈希表:哈希表1和哈希表2。一开始,当你刚插入数据时,默认使用哈希表1,此时的哈希表2并没有被分配空间。随着数据逐步增多,Redis开始执行rehash,这个过程分为三步:
- 给哈希表2分配更大的空间,例如是当前哈希表1大小的两倍;
- 把哈希表1中的数据重新映射并拷贝到哈希表2中;在拷贝数据时,Redis仍然正常处理客户端请求,每处理一个请求时,从哈希表1中的第一个索引位置开始,顺带着将这个索引位置上的所有entries拷贝到哈希表2中;等处理下一个请求时,再顺带拷贝哈希表1中的下一个索引位置的entries拷贝,直到完成
- 释放哈希表1的空间。
到此,我们就可以从哈希表1切换到哈希表2,用增大的哈希表2保存更多数据,而原来的哈希表1留作下一次rehash扩容备用。
1 SDS(简单动态字符串)
- 基本结构:int len; int alloc; char flag; char[];包含当前已使用长度,剩余空间长度,和字符数组
- 优点:
长度计数方式不同:c字符串需要便利;sds保存当前使用长度
杜绝缓冲期溢出:根据剩余空间判断拼接操作是否空间足够,不够扩容
减少字符串内存重分配次数:空间预分配;惰性空间释放
二进制安全:
2 ZSET 底层实现(跳表,字典,压缩链表)
zset底层的存储结构包括ziplist或skiplist,在同时满足以下两个条件的时候使用ziplist,其他时候使用skiplist,两个条件如下:
- 有序集合保存的元素数量小于128个
- 有序集合保存的所有元素的长度小于64字节
当ziplist作为zset的底层存储结构时候,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,第二个元素保存元素的分值。
当skiplist作为zset的底层存储结构的时候,使用skiplist按序保存元素及分值,使用dict来保存元素和分值的映射关系。
3 set
redis的集合对象set,底层使用了intset和hashtable两种数据结构存储的,intset我们可以理解为数组,hashtable就是普通的哈希表(key为set的值,value为null)
set的底层存储intset和hashtable是存在编码转换的,使用intset存储必须满足下面两个条件,否则使用hashtable,条件如下(增加数据时实时判断,不满足直接转码更换存储格式):
- 结合对象保存的每个元素都是整数值
- 集合对象保存的元素数量不超过512个
intset的数据结构
intset内部其实是一个数组(int8_t coentents[]数组),而且存储数据的时候是有序的,因为在查找数据的时候是通过二分查找来实现的
4 list
redis list数据结构底层采用压缩列表ziplist或linkedlist两种数据结构进行存储,首先以ziplist进行存储,在不满足ziplist的存储要求后转换为linkedlist列表。
当列表对象同时满足以下两个条件时,列表对象使用ziplist进行存储,否则用linkedlist存储。
- 列表对象保存的所有字符串元素的长度小于64字节
- 列表对象保存的元素数量小于512个。
5 hash
redis的哈希对象的底层存储可以使用ziplist(压缩列表)和hashtable。当hash对象可以同时满足一下两个条件时,哈希对象使用ziplist编码。
- 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节
- 哈希对象保存的键值对数量小于512个
三、持久化和主从
3.1 RDB快照(snapshot)
在默认情况下, Redis 将内存数据库快照保存在名字为 dump.rdb 的二进制文件中。可设置触发条件:“ N 秒内数据集至少有 M 个改动”,自动保存一次数据集。关闭RDB只需要将所有的save保存策略注释掉即可;
save 60 1000
还可以手动执行命令生成RDB快照,进入redis客户端执行命令save或bgsave可以生成dump.rdb文件,每次命令执行都会将所有redis内存快照到一个新的rdb文件里,并覆盖原有rdb快照文件。save是同步命令,bgsave是异步命令,bgsave会从redis主进程fork(fork()是linux函数)出一个子进程专门用来生成rdb快照文件
为什么 Redis 快照使用子进程
- 内存空间有着完全相同的内容,且后续操作独立:当 fork 发生时两者的内存空间有着完全相同的内容,对内存的写入和修改、文件的映射都是独立的,两个进程不会相互影响
- 写时拷贝:将拷贝推迟到写操作真正发生时,这也就避免了大量无意义的拷贝操作
- 共享物理上的内存空间:在 fork 函数调用时,父进程和子进程会被 Kernel 分配到不同的虚拟内存空间中,所以在两个进程看来它们访问的是不同的内存;在真正访问虚拟内存空间时,Kernel 会将虚拟内存映射到物理内存上,所以父子进程共享了物理上的内存空间;当父进程或者子进程对共享的内存进行修改时,共享的内存才会以页为单位进行拷贝,父进程会保留原有的物理空间,而子进程会使用拷贝后的新物理空间
3.2 AOF(append-only file)
快照功能因为触发条件,设置过于频繁会影响服务器性能,设置间隔时间太长则容易丢失大量数据。AOF 持久化会将修改的每一条指令记录进文件appendonly.aof中你可以通过修改配置文件来打开 AOF 功能:
appendonly yes
从现在开始, 每当 Redis 执行一个改变数据集的命令时(命令就会被追加到 AOF 文件的末尾。 当 Redis 重新启动时, 程序就可以通过重新执行 AOF 文件中的命令来达到重建数据集的目的。追加策略:
- appendfsync always:每次有新命令追加到 AOF 文件时就执行一次 fsync ,非常慢,也非常安全。
- appendfsync everysec:每秒 fsync 一次,足够快(和使用 RDB 持久化差不多),并且在故障时只会丢失 1 秒钟的数据。
- appendfsync no:从不 fsync ,将数据交给操作系统来处理。更快,也更不安全的选择。
推荐(并且也是默认)的措施为每秒 fsync 一次, 这种 fsync 策略可以兼顾速度和安全性。
AOF重写
AOF文件里可能有太多没用指令,所以AOF会定期根据内存的最新数据生成aof文件,如下两个配置可以控制AOF自动重写频率
- auto-aof-rewrite-min-size 64mb //aof文件至少要达到64M才会自动重写,文件太小恢复速度本来就很快,重写的意义不大
- auto-aof-rewrite-percentage 100 //aof文件自上一次重写后文件大小增长了100%则再次触发重写
3.3 Redis 4.0 混合持久化
重启 Redis 时,我们很少使用 RDB来恢复内存状态,因为会丢失大量数据。我们通常使用 AOF 日志重放,但是重放 AOF 日志性能相对 RDB来说要慢很多,这样在 Redis 实例很大的情况下,启动需要花费很长的时间。 Redis 4.0 为了解决这个问题,带来了一个新的持久化选项——混合持久化。通过如下配置可以开启混合持久化:
aof-use-rdb-preamble yes
如果开启了混合持久化,AOF在重写时,不再是单纯将内存数据转换为RESP命令写入AOF文件,而是将重写这一刻之前的内存做RDB快照处理,并且将RDB快照内容和增量的AOF修改内存数据的命令存在一起,都写入新的AOF文件,新的文件一开始不叫appendonly.aof,等到重写完新的AOF文件才会进行改名,原子的覆盖原有的AOF文件,完成新旧两个AOF文件的替换。
于是在 Redis 重启的时候,可以先加载 RDB 的内容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,因此重启效率大幅得到提升。
四、集群
4.1 主从架构
- slave向master发送数据同步命令
- master收到SYNC命令后,通过bgsave生成最新的rdb快照文件,持久化期间,继续接收客户端的请求,缓存在内存中;
- 当持久化进行完毕以后,master会把这份rdb文件数据集发送给slave,slave会把接收到的数据进行持久化生成rdb,然后再加载到内存中。
- master再将之前缓存在内存中的命令发送给slave。
当master与slave之间的连接由于某些原因而断开时,slave能够自动重连Master,如果master收到了多个slave并发连接请求,它只会进行一次持久化,而不是一个连接一次,然后再把这一份持久化的数据发送给多个并发连接的slave。
断开重连后,一般都会对整份数据进行复制。持部分复制
- master会在其内存中创建一个复制数据用的缓存队列,缓存最近一段时间的数据
- master和它所有的slave都维护了复制的数据下标offset和master的进程id,当网络连接断开后,slave会请求master继续进行未完成的复制,从所记录的数据下标开始。
- 如果master进程id变化了,或者从节点数据下标offset太旧,已经不在master的缓存队列里了,那么将会进行一次全量数据的复制。
4.2 集群、分配、布隆过滤器
Redis Cluster 将所有数据划分为 16384 个 slots(槽位),每个节点负责其中一部分槽位。槽位的信息存储于每个节点中。
当 Redis Cluster 的客户端来连接集群时,它也会得到一份集群的槽位配置信息并将其缓存在客户端本地。这样当客户端要查找某个 key 时,可以直接定位到目标节点。同时因为槽位的信息可能会存在客户端与服务器不一致的情况,还需要纠正机制来实现槽位信息的校验调整
槽位定位算法
Cluster 默认会对 key 值使用 crc16 算法进行 hash 得到一个整数值,然后用这个整数值对 16384 进行取模来得到具体槽位。
HASH_SLOT = CRC16(key) mod 16384
跳转重定位
当客户端向一个错误的节点发出了指令,该节点会发现指令的 key 所在的槽位并不归自己管理,这时它会向客户端发送一个特殊的跳转指令携带目标操作的节点地址,告诉客户端去连这个节点去获取数据。客户端收到指令后除了跳转到正确的节点上去操作,还会同步更新纠正本地的槽位映射表缓存,后续所有 key 将使用新的槽位映射表。
Redis集群选举原理分析
当slave发现自己的master变为FAIL状态时,便尝试进行Failover,以期成为新的master。由于挂掉的master可能会有多个slave,从而存在多个slave竞争成为master节点的过程, 其过程如下:
- slave发现自己的master变为FAIL
- 将自己记录的集群currentEpoch加1,并广播FAILOVER_AUTH_REQUEST 信息
- 其他节点收到该信息,只有master响应,判断请求者的合法性,并发送FAILOVER_AUTH_ACK,对每一个epoch只发送一次ack
- 尝试failover的slave收集master返回的FAILOVER_AUTH_ACK
- slave收到超过半数master的ack后变成新Master(这里解释了集群为什么至少需要三个主节点,如果只有两个,当其中一个挂了,只剩一个主节点是不能选举成功的)
- slave广播Pong消息通知其他集群节点。
从节点并不是在主节点一进入 FAIL 状态就马上尝试发起选举,而是有一定延迟,一定的延迟确保我们等待FAIL状态在集群中传播,slave如果立即尝试选举,其它masters或许尚未意识到FAIL状态,可能会拒绝投票
•延迟计算公式:
DELAY = 500ms + random(0 ~ 500ms) + SLAVE_RANK * 1000ms
•SLAVE_RANK表示此slave已经从master复制数据的总量的rank。Rank越小代表已复制的数据越新。这种方式下,持有最新数据的slave将会首先发起选举(理论上)
五、缓存穿透、击穿、雪崩
5.1 缓存穿透
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求,如发起为id为“-1”的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。解决方案:
- 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;
- 从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击
5.2 缓存击穿
缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力
- 设置热点数据永远不过期。
- 加互斥锁:先获取缓存数据,若数据未缓存,尝试获取redis锁,然后查询数据库缓存数据。加锁期间,同时刻的请求会被阻挡,等缓存建立成功后解锁
- 提前缓存热点数据
5.3 缓存雪崩
缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。解决方案:
- 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
- 如果缓存数据库是分布式部署,将热点数据均匀分布在不同搞得缓存数据库中。
- 设置热点数据永远不过期。
- 接口降级
六、回收策略
内存淘汰:
- 当前已用内存超过maxmemory限定时,根据最大内存淘汰策略(maxmemory-policy),触发主动清理
- volatile-lru:默认策略,即超过最大内存后,在过期键中使用lru算法进行key的剔除,保证不过期数据不被删除,但是可能会出现OOM问题。
- allkeys-lru:根据LRU算法删除键,不管数据有没有设置超时属性,直到腾出足够空间为止。
- allkeys-random:随机删除所有键,直到腾出足够空间为止。
- volatile-random: 随机删除过期键,直到腾出足够空间为止。
- volatile-ttl:根据键值对象的ttl属性,删除最近将要过期数据。如果没有,回退到noeviction策略。
- noeviction:不会剔除任何数据,拒绝所有写入操作并返回客户端错误信息"(error) OOM command not allowed when used memory",此时Redis只响应读操作
过期失效
- 惰性删除:当读/写一个已经过期的key时,会触发惰性删除策略,直接删除掉这个过期key
- 定期删除:每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。(expires字典会保存所有设置了过期时间的key的过期时间数据,其中,key是指向键空间中的某个键的指针,value是该键的毫秒精度的UNIX时间戳表示的过期时间。键空间是指该Redis集群中保存的所有键。)
七、分布式锁实现:
7.1 基础实现版本
核心是使用redis 的 setnx 命令,只在键 key 不存在的情况下,将键 key 的值设置为 value,其他情况返回0。因为redis是一个单线程应用,所以多线程争抢时只会有一个线程设置成功,认为是抢占到锁。其他线程获取不到锁时会有两种方案:
1.直接报错,强迫用户重试,或提醒失败;
2.做类似于自旋的操作,循环获取锁,在一定时间或次数后,返回失败
public void testRedisKey(String key) {
String myName = UUID.randomUUID().toString();
try {
Boolean getKey = redisTemplate.opsForValue()
.setIfAbsent(key, myName, 30, TimeUnit.SECONDS);
if (!getKey) {
throw new RuntimeException(key+"获取失败");
}
// do something
} finally {
if (myName.equals(redisTemplate.opsForValue().get(key))) {
redisTemplate.delete(key);
}
}
}
相应的设计考量:
- 原子的方式获取锁并设置失效时间,防止程序崩溃key一直存在
- finally 执行删除操作释放锁,防止程序抛出异常不删除锁
- 增加myName标识当前线程,防止删除其他程序加的锁
- 获取失败直接抛异常
存在的问题:
- finally内部先检查后执行,不是原子操作仍然存在删除其他线程锁的问题
- 若业务执行时间超出有效时间30s其他线程会提前获取到锁,导致分布式锁功能失效
- 若为增加锁的有效时间,系统崩溃后,锁失效的时间也变长
综上:该版本的分布式锁实现只是个基础版本,能够满足很多小型系统的需求。但是对于并发量高的系统可能不那么合适
7.2 redisson实现版本
redisson首先根据传入的key构造了一个RLock对象,然后通过redissonLock.lock()进行加锁操作,内部通过lua脚本将加锁和设置时间,判断是否可以加锁等操作变成原子操作。
public String deductStock() throws InterruptedException {
String lockKey = "product_001";
RLock redissonLock = redisson.getLock(lockKey);
try {
// 加锁,实现锁续命功能
redissonLock.lock();
//do something
}finally {
redissonLock.unlock();
}
return "end";
}
7.3 redLock(主从失效的原理)
大致思想,系统配有多个redis实例(不同集群或主从),在每一个实例中都执行加锁操作,然后在实际获取锁时,需要半数以上节点加锁成功才认为获得锁。
public String redlock() throws InterruptedException {
String lockKey = "product_001";
RLock lock1 = redisson1.getLock(lockKey);
RLock lock2 = redisson2.getLock(lockKey);
RLock lock3 = redisson3.getLock(lockKey);
/**
* 根据多个 RLock 对象构建 RedissonRedLock (最核心的差别就在这里)
*/
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
try {
boolean res = redLock.tryLock(10, 30, TimeUnit.SECONDS);
if (res) {
//成功获得锁,在这里处理业务
}
} catch (Exception e) {
throw new RuntimeException("lock fail");
} finally {
//无论如何, 最后都要解锁
redLock.unlock();
}
return "end";
}