redis的数据结构以及使用场景分析
1. string
a. 底层结构
string的数据结构存储的是key-value类型, value不仅可以是string,也可以是数字。
redis中的String是可以修改的,称为动态字符串(SDS),其实就是维护了一个预分配的字节数组,如下
1 | struct SDS{ |
b. 常用命令
1 | set [key] [value] 给指定key设置值(set 可覆盖老的值) |
c. 使用场景举例
缓存
简单key-value存储
分布式锁
setnx key value,当key不存在时,将 key 的值设为 value ,返回1
若给定的 key 已经存在,则setnx不做任何动作,返回0。
当setnx返回1时,表示获取锁,做完操作以后del key,表示释放锁,如果setnx返回0表示获取锁失败,整体思路大概就是这样
计数器
如知乎每个问题的被浏览器次数
全局标志位
例如售罄标志,防止超卖
2. hash
a. 底层结构
Redis中的Hash和 Java的HashMap更加相似,都是数组+链表的结构,当发生 hash 碰撞时将会把元素追加到链表上,下面是hash存储的一个KV结构
1 | key=JavaUser293847 |
(可以看作是 map< key, map<key, value> >)
b. 常用指令
1 | hset [key] [field] [value] 新建字段信息 |
c. 应用场景
可以存储对象( (对象名,成员变量名,值) )
3. list
a. 底层结构
Redis中的list和Java中的LinkedList很像,底层都是一种链表结构,list的插入和删除操作非常快,时间复杂度为 0(1),不像数组结构插入、删除操作需要移动数据。
像归像,但是redis中的list底层可不是一个双向链表那么简单。
在redis3.2版本之前,对list的实现是:
- 当数据量较少的时候它的底层存储结构为一块连续内存,称之为ziplist(压缩列表),它将所有的元素紧挨着一起存储,分配的是一块连续的内存;(保存的是entry数组)
1
2
3
4
5
6
7struct ziplist<T>{
int32 zlbytes; //压缩列表占用字节数
int32 zltail_offset; //最后一个元素距离起始位置的偏移量,用于快速定位到最后一个节点
int16 zllength; //元素个数
T[] entries; //元素内容
int8 zlend; //结束位 0xFF
}
- 当数据量较多的时候,它的底层结构是使用linkedlist来做的,是离散的。
1 | typedef struct listNode{ |
1 | typedef struct list{ |
重新引入了一个 quicklist 的数据结构,列表的底层都由quicklist实现。
quicklist其实就是又对linkedlist和ziplist进行了一层抽象,变为quicklistnode, 它可以指向压缩后的list,也可以指向未压缩的list, 如下图
1 | typedef struct quicklist { |
1 | typedef struct quicklistNode { |
b. 常用指令
1 | rpush [key] [value1] [value2] ...... 链表右侧插入 |
c. 应用场景
由于list它是一个按照插入顺序排序的列表,所以应用场景相对还较多的,例如:
消息队列
lpop和rpush(或者反过来,lpush和rpop)能实现队列的功能
朋友圈用户消息列表
例如想拿最近发得10条动态,就可以使用 lrange 来拿
4. set
a. 底层结构
Redis中的set和Java中的HashSet有些类似,它内部的键值对是无序的、唯一 的。它的内部实现相当于一个特殊的字典,字典中所有的value都是一个值 NULL。当集合中最后一个元素被移除之后,数据结构被自动删除,内存被回收。
b. 常用指令
1 | sadd [key] [value] 向指定key的set中添加元素 |
d. 高频指令
求交集:sinterstore key1 key2 key3
将交集存在key1内
c. 应用场景
在微博应用中,可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。Redis 可以非常方便的实现如共同关注、共同粉丝、共同喜好、共同好友等功能。这个过程也就是求交集的过程。
(关键字:共同)
5. zset ( sorted set )
和 set 相比,sorted set 增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列。
a. 底层结构
zset是基于skiplist(跳表)实现的。
b. 常用指令
1 | zadd [key] [score] [value] 向指定key的集合中增加元素 |
c. 应用场景
zset可以用做排行榜,但是和list不同的是zset它能够实现动态的排序,例如: 可以用来存储粉丝列表,value 值是粉丝的用户 ID,score 是关注时间,我们可以对粉丝列表按关注时间进行排序。
zset还可以用来存储学生的成绩,value值是学生的 ID,score是他的考试成绩。 我们对成绩按分数进行排序就可以得到他的名次。
(关键字:排行榜)
d. 高频指令
- 求名次为[a,b]的分数的玩家 :
zrevrange [key] [start_index] [end_index]
ps: zrange是升序,zrevrange是降序 - 求某一个玩家的排名 :
zrank
- 求分数为[a,b]的玩家 :
zrangebyscore [key] [score1] [score2]
- 求某一个玩家的分数 :
zscore [key] [value]
( zscore player_set czf )
Redis缓存雪崩/穿透/击穿/无底洞问题
缓存穿透 - 大量请求不命中
问题:
当请求到来时,会先去查缓存,缓存中没有,然后才会“穿过”缓存层访问数据库,如果数据库中存有请求的结果,那么会将结果数据写到缓存中。 但如果访问的是数据库中也没有的记录,那么缓存中也不会存储。 当有大量请求访问数据库中不存在的数据时,那么缓存也就形同虚设,大量的并发直接落在了数据库上。
产生的原因:
1. 业务代码自身的问题
例如对于一些不合理的查询请求在业务代码层面上没有过滤掉等。
2. 恶意攻击、爬虫等
如何发现:
1. 业务的响应时间
如果出现了穿透,请求打到了存储层上,响应时间一定会收到影响。
2. 采集相关指标(对缓存进行监控):总调用数、缓存层命中数、存储层名中数
解决方法
方法一:把查询空结果也给缓存起来
- 但这样会出现两个问题:
- 对于恶意攻击来说,他们可以通过组合不同的键来查询空结果,所以穿透依然无法避免。
- 如果在查询某个关键的key的时候,查询接口因为一些意外原因(如网络延迟过大)而导致了查询到了空结果,在把这个空结果给缓存了之后,在其失效之前,对于这个key的查询得到的结果总是空的,但这个期间,有可能查询接口又恢复正常了(但却因为缓存缓存了空结果,所以还是查询不到)。
方法二: 布隆过滤器拦截
布隆过滤器是一种数据结构,比较巧妙的概率型数据结构(probabilistic data
structure),特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”。
因此,在查询的时候,先去布隆过滤器查询,只有对于通过了布隆过滤器的查询,才会真正的去执行查询. 但这并不能100%的过滤掉所有空查询,因为布隆过滤器可以保证不通过的一定不存在,但不能保证通过的一定存在。
缓存穿透应该是
当数据库中没有某个key对应的value时,缓存中也不会有该value的缓存。所以大量的对该value的查询该的请求会绕过缓存,直接查询数据库。缓存中一般存的是 key+value ,但是布隆过滤器却可以告诉你 key
对应的value在数据库中存不存在,如果不存在就不用查询数据库了。拿redis为例子: 请注意,用 redis 也可以做到判断 key 对应的value
在数据库中存不在,那就是把数据库里的所有value对应的key都储存在redis
中,而value可以为空,然后判断下key.IsExists()就可以了,但是这无疑会浪费大量空间,因为存储了数据库中所有的key。而且这也不符合缓存的初衷:咱不能暴力的把所有key都存下来,而是查询了啥key,我们缓存啥key。而布隆过滤器是一种非常高效的数据结构,把所有数据库的value对应的key
存储到布隆过滤器里,几乎不消耗什么空间,而且查询也是相当的快!但是请注意,它只能判断 key 是否存在(而且会有一定的误差)。所以一个查询先通过布隆顾虑器判断key是否存在(key 对应的value是否存在数据库中),如果不存在直接返回空就好了。
那么布隆过滤器是怎么做到几乎不消耗空间来储存所有的key,并快速判断特定的key是否存在呢?
其实原理很简单,布隆过滤器 只是一个 byte数组,再加上几个映射函数。
每个key 都通过一系列映射函数,得到一系列的的值k,然后在这个byte数组上的把k下标的值变成1。
当要判断key是否存在时,通过映射函数映射得到的一系列k,查看byte数组相应下标k对应的值是否为1,如果有一个不为1,那么一定不存在。如果都是1
,那么可能存在。为什么可能而不是一定呢?因为这是一个误差问题,有可能别的key把某个k的位置变成了1,key越多时,误差越大。但是放心不会很大的,这是可以控制的,byte数组越长,误差越小。
缓存雪崩
问题
一般情况下,缓存层将接受大量的服务请求,存储层只接受比较少的服务请求,但当缓存层发生异常/脱机(总之暂时无法工作)或是是指在某一个时间段,缓存集中过期失效,那么流量直接压向后端组件(例如数据库,或第三方API),造成级联故障。
级联故障的解释:
网络中,一个或少数几个节点或连线的失效会通过节点之间的耦合关系引发其他节点也发生失效,进而产生级联效应,最终导致相当一部分节点甚至整个网络的崩溃,这种现象就称为级联失效,有时也形象称之为“雪崩” 。
解决方案
保证缓存的高可用性
例如 Redis的主从机制,主机挂了从机上.
- Redis Sentinel
- Redis Cluster
- 主从漂移
依赖隔离组件为后端限流
- Hystrix这种隔离组件
- 使用线程池/信号量隔离组件
- 使用Guava提供的限流API(令牌桶,漏桶)
提前演练:例如压力测试
数据预热
数据加热的含义就是在正式部署之前,我先把可能的数据先预先访问一遍,这样部分可能大量访问的数据就会加载到缓存中。在即将发生大并发访问前手动触发加载缓存不同的key,设置不同的过期时间,让缓存失效的时间点尽量均匀。
缓存击穿
问题
一个超级热点数据如果因为超时失效或其他原因而从redis中被删除,那么短期大量的流量就会打到数据库上.
解决办法:
- 对于热点数据不设置失效时间
- 提前将热点数据预存到redis中
- 使用第三方缓存或本地缓存(例如Guava的Cache),如果是热点数据的话,频繁被访问的情况话就不会被置换出去
- 限流(线程池、信号量、RateLimiter。。。)熔断(to study)
无底洞问题优化*
问题
加机器之后,性能不但没有提升,反而下降了。(因为加的机器多了,网络请求次数也多了,开销也大了)
优化IO的几种方法
- 命令本身优化:例如慢查询keys、hgetall bigkey
- 减少网络通信次数(无底洞问题主要优化的位置)
- 降低接入成本:例如客户端长连接/连接池、NIO等
四种批量优化的方法
- 串行meget
- 串行io
- 并行io
- hash_tag
Redis对过期key的删除策略
如果假设你设置了一批 key 只能存活 1 个小时,那么接下来 1 小时后,redis 是怎么对这批 key 进行删除的?
定期删除 + 惰性删除
定期删除:
redis是默认每隔100ms就随机抽取一些设置了过期时间的key,检查是否过期,如果过期就删除。注意!这里是随机抽取, 这样即使在redis中存储了很多数据的情况下,依然能够保证性能.
惰性删除:
懒惰删除就如字面意思,每次在获取key的时候,会排查这个key是否过期,如果过期了就删除。
Redis内存淘汰机制:
考虑一下这种场景,定期删除漏掉了许多过期的key,同时也没有去及时去排查,也就没触发惰性删除,这时,大量的过期key就会堆积在内存里,导致redis内存块耗尽…… 而解决这个问题的办法就是redis内存淘汰机制。
Redis提供6种数据淘汰策略
volatile-lru:从已经设置了过期时间的数据集中挑选最近最少使用的数据淘汰
volatile-ttl:从已经设置了过期时间的数据集中挑选即将过期的的数据淘汰
volatile-random:从已经设置了过期时间的数据集中挑选随机挑选数据淘汰
allkeys-lru:从所有数据集中挑选最近最少使用的数据淘汰 (最常用)
allkeys-random:从所有数据集中挑选随机挑选数据淘汰
no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。
4.0版本之后增加了以下两种:
- volatile-lfu:从已经设置了过期时间的数据集中挑选最不经常使用的数据淘汰
allkeys-lfu:从所有数据集中挑选最不经常使用的数据淘汰
Redis事务
Redis的事务其实就是将一组命令打包,然后一次性执行完,期间不允许被打断,执行完毕后才能去执行其他客户端的命令。
所以Redis的事务满足:
- 不支持回滚的原子性
- 一致性
- 隔离性(因为是串行的)
如果运行在特性的持久化模式下,也会具有一定程度的持久性。
redis 同一个事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚。(来自issue:关于 Redis 事务不是原子性问题 )
Redis的使用场景
从海量Key里查询出某一固定前缀的Key
kyes pattern: 查找所有符合给定模式pattern的key
- 一次性返回所有匹配的key
- 键的数量过大会使服务卡顿
可以使用scan cursor [Match pattern][COUNT count]
:
- 基于游标的迭代器,需要基于上一次的游标延续之前的迭代过程。
- 以0作为游标开始一次新的迭代,直到命令返回游标0完成一次遍历。
- 不保证每次执行都返回给定数量的元素(count数大于key总数的时候)一次返回数量不可控,只能是大概率符合count。
- 支持模糊查询,即能够返回满足pattern匹配的key
例如:scan 0 match k1* count 10
,注意,返回的值有可能的是重复的! 因此需要去重!(例如写程序的时候用hashset)
blpop
通过Redis实现分布式锁
即分布式系统中,访问共同资源时的一种锁的实现。
在分布式场景下,无法使用单机环境下的锁来对多个节点上的进程进行同步。
可以使用 Redis 自带的 SETNX 命令实现分布式锁,setnx key val 就是如果不存在key的话,那么就设置key为val。设置成功返回1, 失败返回0
SET lock_key random_value NX PX 5000
一定要放到一个语句里,保证“获取锁”和“设置超时时间”的原子性。如果设置完setnx以后,程序就挂掉了,那么这个key(锁)就一直被占用!
使用Redis做异步队列
除了使用list的rpush, lpop, blpop 以外,可以用pub/sub:主题订阅者模式,来做。
例子:
- 订阅一个频道:
- redis-cli1:
subscribe myTopic
,之后进入监听状态 - redis-cli2:
subscribe myTopic
,之后进入监听状态 - redis-cli3:
subscribe myTopic
,之后进入监听状态 - redis-cli4:
publish myTopic "hello!!"
:
这条消息发送出去之后,监听myTopic的3个客户端都收到了“hello!!”这个字符串。
- redis-cli1:
缺点:
但需要注意的是,消息的发布是无状态的,也就是无法保证可达。对于发布者来说,消息是即发即失的。 若想解决这个问题,需要用专业的消息队列,如kafka,rocketmq等。
Redis如何做持久化
RDB ( 快照 ) 持久化:保存某一个时间点的全数据快照.
redis服务器加载时,会启用reids.conf文件中的配置信息,里面的:
1 | .... |
可以根据不同的情况来合理配置。 src
目录下的dump.rdb
文件,就是redis系统定期备份的rdb文件. 它是一个二进制文件。
生成RDB备份文件的方式:
主动生成
- SAVE: 阻塞Redis的服务器进程,直到RDB文件被创建完毕。 很少被使用,因为占用了主线程!! 主线程是用来处理client的请求的!!
- BGSAVE:Fork一个子进程创建RBD文件,不阻塞服务器进程!此时,主进程依然继续工作,子进程将内存中的数据写入临时文件中,因为copy-on-write的机制,父子进程此时会共享相同的物理页面,当(主)父进程处理写请求时,os会为父进程要写的页面创建一个副本(这个副本用于备份),而不是写入共享的页面! RDB文件的载入,一般情况下是自动的,redis服务器启动时,若检测到rdb文件的存在,那么会载入这个文件
在fork时,子进程和父进程共享同一块资源空间,只有当父进程对此空间进行修改时,才会触发给子进程的资源复制,这一机制为copy-on-write. ,juc的copyOnWriteArrayList也是使用的这一原理。
被动生成
- 根据redis.conf里的save m n 定时触发 (用的是BGSAVE)
- 主从复制时,主节点自动触发(主节点发送rdb文件给从结点,这时,主节点会触发一次!)
- 执行debug reload
- 执行shutdown且没有开启AOF持久化,那么会触发一次RDB持久化
RDB持久化的缺点:
- 内存数据的全部同步! 数据量大的时候会因为IO而严重影响性能!
- 可能会因为redis挂掉而丢失从当前到最近一次备份期间的所有数据!
AOF ( Append-Only-File )持久化:保存写状态
- 记录下除了查询以外的所有变更数据库状态的指令
- 以append的形式追加保存到AOF文件中
- AOF持久化默认是关闭的,可以修改redis.conf来让其生效:
- appendonly yes # 启动 aof
- appendfsync everysec/always/no:
- always: 一旦缓存区发生改变,就立刻将内容写到文件中!
- everysec: 每隔1s,写入一次
- no: 什么时候写交给os判断, 一般是等缓存区写满了就写入一次。
日志重写解决AOF文件大小不断增大的问题(例如100条incr 可以用一条add 100实现), 其原理如下:
- 调用fork(), 创建一个子进程。
- 子进程把新的AOF写到一个临时文件里,新的AOF是根据内存数据生成对应的命令,并不需要区依赖原来的AOF文件。
- 主进程持续将新的变动写到内存中,并更新到“旧”的AOF文件里。
- 重写结束之后,会给主进程一个信号,然后把内存的buff追加到新生成的AOF文件。
- 用新的AOF替换掉旧的AOF。
从Redis中恢复数据
其实只要重启就可以了。。
- 检查AOF是否存在,若存在则直接加载AOF,不再去找RDB
- 若不存在AOF,则尝试加载RDB
RDB和AOF的优缺点
RDB优点:创建RDB那一瞬间的全部内存数据快照,文件小,恢复快
RDB缺点:无法保存最近一次快照之后的数据
AOF优点:可读性高,适合保存增量数据,数据不易丢失
AOF缺点:文件体积大,恢复时间长
redis 4.0之后的备份就是混合模式,即RDB-AOF.
rdb用于全量备份,aof用于增量备份,为redis4.0之后的默认备份方式。
bgsave做全量持久化,aof做增量持久化