一文搞懂redis
Redis是什么?
NoSQL= not only sql,而不是no sql
Redis = Remote Dictionary Server (远程调用字典服务)
Redis是开源的 内存中的数据结构存储系统,它可以用作 数据库、 缓存和 消息中间件,它支持多种类型的数据结构,如 字符串strings, 散列hashes, 列表lists, 集合sets, 有序集合sorted sets与范围查询, bitmaps, hyperloglogs 和 地理空间(geospatial) 索引半径查询。
Redis还内置了 复制(replication),LUA脚本(Lua ing), LRU驱动事件(LRU eviction),事务(transactions) 和不同级别的 磁盘持久化(persistence), 并通过 Redis哨兵(Sentinel)和自动 分区(Cluster)提供高可用性(high availability)
通用命令
- keys:查询所有的key,支持正则
- dbsize:计算key的总数
- exists key:是否存在key
- del key [key...]:删除key。(del key1,key2...)
- expire key seconds:设置过期时间
- ttl key:查看剩余过期时间
- persist key:去掉过期时间
- type key
数据结构
redis的五个类型
- string:动态字符串
- hash:哈希
- list:列表
- set:集合
- zset:有序集合
redis底层的八种数据结构
REDIS_ENCODING_INT(long 类型的整数)
REDIS_ENCODING_EMBSTR embstr (编码的简单动态字符串)
REDIS_ENCODING_RAW (简单动态字符串)
REDIS_ENCODING_HT (字典)
REDIS_ENCODING_LINKEDLIST (双端链表)
REDIS_ENCODING_ZIPLIST (压缩列表)
REDIS_ENCODING_INTSET (整数集合)
REDIS_ENCODING_SKIPLIST (跳跃表和字典)
redis的整体存储架构是一个大的hashmap,内部是数组实现的hash,key冲突用拉链法,挂链表去解决,每个dicEntry为一个key\value对象,value为自定义的redisObject
head → | head → | head → | head → ... |
↓ | ↓ | ↓ | |
dictEntry | dictEntry | dictEntry | |
↓ | |||
dictEntry |
一、string数据结构
- 保存值为整数,大小不超过long的范围,使用整数存储
- 当字符串长度不超过44字节,用embstr编码,只分配一次内存空间,是连续的内存,只允许读,如果修改数据就变成raw编码
- 大于44字节,用raw编码
为什么是44字节?,内部存储数据的结构体:redisObject和sdshdr8,一个占16字节,一个占8字节,初始最小分配为64字节,64-16-4=44字节
二、List
1、Redis3.2之前的底层实现方式:压缩列表ziplist 或者 双向循环链表linkedlist。当list存储的数据量比较少且同时满足下面两个条件时,list就使用ziplist存储数据:
-
list中保存的每个元素的长度小于 64 字节;
-
列表中数据个数少于512个
2、Redis3.2及之后的底层实现方式:quicklist
quicklist是一个双向链表,而且是一个基于ziplist的双向链表,quicklist的每个节点都是一个ziplist,结合了双向链表和ziplist的优点。
ziplist
ziplist是一种压缩链表,它的好处是更能节省内存空间,因为它所存储的内容都是在连续的内存区域当中的。当列表对象元素不大,每个元素也不大的时候,就采用ziplist存储。但当数据量过大时就ziplist就不是那么好用了。因为为了保证他存储内容在内存中的连续性,插入的复杂度是O(N),即每次插入都会重新进行realloc。如下图所示,redisObject对象结构中ptr所指向的就是一个ziplist。整个ziplist只需要malloc一次,它们在内存中是一块连续的区域。
为什么数据量大时不用ziplist?
因为ziplist是一段连续的内存,插入的时间复杂化度为O(n),而且每当插入新的元素需要realloc做内存扩展;而且如果超出ziplist内存大小,还会做重新分配的内存空间,并将内容复制到新的地址。如果数量大的话,重新分配内存和拷贝内存会消耗大量时间。所以不适合大型字符串,也不适合存储量多的元素
quickList
快速列表是ziplist和linkedlist的混合体,是将linkedlist按段切分,每一段用ziplist来紧凑存储,多个ziplist之间使用双向指针链接。
三、hash
当Hash中数据项比较少的情况下,Hash底层才用压缩列表ziplist进行存储数据,随着数据的增加,底层的ziplist就可能会转成dict
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
int iterators; /* number of iterators currently running */
} dict;
typedef struct dictht {
//指针数组,这个hash的桶
dictEntry **table;
//元素个数
unsigned long size;
unsigned long sizemask;
unsigned long used;
} dictht;
dictEntry大家应该熟悉,在上面有讲,使用来真正存储key->value的地方
typedef struct dictEntry {
// 键
void *key;
// 值
union {
// 指向具体redisObject
void *val;
//
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
个人理解,一个dict字典有两个hash表,一个放旧的,一个放新的。每个hash表 有个指针数组,存hash的桶(拉链法)。
dict:
虽然dict结构有两个hashtable,但是通常情况下只有一个hashtable是有值的。但是在dict扩容缩容的时候,需要分配新的hashtable,然后进行渐近式搬迁,这时候两个hashtable分别存储旧的hashtable和新的hashtable。搬迁结束后,旧hashtable删除,新的取而代之。
渐进式rehash
所谓渐进式rehash是指我们的大字典的扩容是比较消耗时间的,需要重新申请新的数组,然后将旧字典所有链表的元素重新挂接到新的数组下面,是一个O(n)的操作。但是因为我们的redis是单线程的,无法承受这样的耗时过程,所以采用了渐进式rehash小步搬迁,虽然慢一点,但是可以搬迁完毕。
扩容条件
dict结构体的hashtable用了链地址法解决hash冲突,hash表的数组中的每一项都是entry链表的头结点,当hash表中的元素个数等于第一维数组长度的时候就会开始扩容,扩容的大小是原数组的两倍。不过redis在做持久化RDB时,为了减少内存页的过多分离,就不会去扩容,因为系统需要更多的开销去回收内存,但是如果hash表里元素个数达到了第一维数组长度的5倍的时候吗,就会强制扩容,不管是在持久化。
缩容条件
hash表数据逐渐删除的时候,redis会对hash表缩容来减少第一维数组长度的空间占用。条件是元素个数低于数组长度的10%,且缩容时不考虑持久化问题
步骤
- 为ht[1]分配空间,让字典有两个哈希表
- 定时维持一个索引计数器变量rehashidx,设置其值为0,表示rehash开始
- 在rehash时,每次对字典执行CRUD操作时,程序除了操作外还会将ht[0]中的数据rehash到h[1]中,并且将rehashidx加一。
- 当h[0]数据全部转移到了h[1],将rehashidx设置成-1,表示rehash结束。
(采用渐进式rehash 的好处在于它采取分而治之的方式,避免了集中式rehash 带来的庞大计算量。特别的在进行rehash时只能对h[0]元素减少的操作,如查询和删除;而查询是在两个哈希表中查找的,而插入只能在ht[1]中进行,ht[1]也可以查询和删除。)
5、将h[0]释放,然后将h[1]设置成h[0],再分配一个新的空白hash表给h[1]
持久化机制
RDB
将某一时刻的数据保存到硬盘的文件中,有save和bgsave
恢复数据快,使用子进程进行持久化
但是服务器宕机,RDB会使得某一时刻的数据丢失,save命令会阻塞主进程,用bgsave的话是用子进程fork,但是如果数据量太大也还是会阻塞,且fork也耗费内存
AOF
记录每一次写操作命令
追加日志文件,对服务器性能影响小,但生成的日志文件大,恢复数据比RDB慢
为什么使用redis缓存
- 加速读写,因为缓存通常都是全内存的,而存储层通常读写性能不够强悍(例如MySQL),通过缓存的使用可以有效地加速读写,优化用户体验。
- 降低后端负载:帮助后端减少访问量和复杂计算(例如很复杂的SQL语句),在很大程度降低了后端的负载。
缓存更新策略
- LRU/LFU/FIFO算法剔除:剔除算法通常用于缓存使用量超过了预设的最大值时候,如何对现有的数据进行剔除。例如Redis使用maxmemory-policy这个配置作为内存最大值后对于数据的剔除策略。
- 超时剔除:通过给缓存数据设置过期时间,让其在过期时间后自动删除,例如Redis提供的expire命令。如果业务可以容忍一段时间内,缓存层数据和存储层数据不一致,那么可以为其设置过期时间。在数据过期后,再从真实数据源获取数据,重新放到缓存并设置过期时间。但是涉及交易方面的业务,后果可想而知
- 主动更新:应用方对于数据的一致性要求高,需要在真实数据更新后,立即更新缓存数据。例如可以利用消息系统或者其他方式通知缓存更新。
三大缓存问题
一、缓存穿透
不断被访问redis和mysql不存在的数据,导致崩溃
解决方案
- 验证拦截:接口层做校验,如用户权限,或者拦截 id<0的请求
- 缓存空数据:就算没有数据也缓存,但时间要设置短一点,1秒等。
- 布隆过滤器:一个类似hashmap的数据结构,作用是可以确认某个数一定不存在。
当一个元素被加入集合时,通过 K 个 Hash 函数将这个元素映射成一个位阵列(Bit array)中的 K 个点,把它们置为 1。检索时,我们只要看看这些点是不是都是 1 就(大约)知道集合中有没有它了。如果这些点有任何一个 0,则被检索元素一定不在;如果都是 1,则被检索元素很可能在。
缓存空数据与布隆过滤器的比较
缓存空数据与布隆过滤器都能有效解决缓存穿透问题,但使用场景有着些许不同;
-
当一些恶意攻击查询查询的key各不相同,而且数量巨多,此时缓存空数据不是一个好的解决方案。因为它需要存储所有的Key,内存空间占用高。并且在这种情况下,很多key可能只用一次,所以存储下来没有意义。所以对于这种情况而言,使用布隆过滤器是个不错的选择;
-
而对与空数据的Key数量有限、Key重复请求效率较高的场景而言,可以选择缓存空数据的方案。
二、缓存击穿
缓存击穿是指当前热点数据存储到期时,多个线程同时并发访问热点数据。因为缓存刚过期,所有并发请求都会到数据库中查询数据。
解决方案
-
将热点数据设置为永不过期;
-
加互斥锁:互斥锁可以控制查询数据库的线程访问,但这种方案会导致系统的吞吐量下降,需要根据实际情况使用。
三、缓存雪崩
缓存雪崩发生有几种情况,比如大量缓存集中在或者缓存同时在大范围中失效,出现了大量请求去访问数据库,从而导致CPU和内存过载,甚至停机。
一个简单的雪崩过程:
Redis 集群产生了大面积故障;
缓存失败,此时仍有大量请求去访问 Redis 缓存服务器;
在大量 Redis 请求失败后,这些请求将会去访问数据库;
由于应用的设计依赖于数据库和 Redis 服务,很快就会造成服务器集群的雪崩,最终导致整个系统的瘫痪
-
【事前】高可用缓存:高可用缓存是防止出现整个缓存故障。即使个别节点,机器甚至机房都关闭,系统仍然可以提供服务,Redis 哨兵(Sentinel) 和 Redis 集群(Cluster) 都可以做到高可用;
-
【事中】缓存降级(临时支持):当访问次数急剧增加导致服务出现问题时,我们如何确保服务仍然可用。在国内使用比较多的是 Hystrix,它通过熔断、降级、限流三个手段来降低雪崩发生后的损失。只要确保数据库不死,系统总可以响应请求,每年的春节 12306 我们不都是这么过来的吗?只要还可以响应起码还有抢到票的机会;
-
【事后】Redis备份和快速预热:Redis数据备份和恢复、快速缓存预热。
Redis为什么快?
- 全内存操作,内存的读写速度是磁盘的好几倍。
- 单线程操作。通过I\O多路复用技术,将网络请求封成一个队列,单线程地由redis的事件处理器再逐一取出分发到对应的事件,这样就没有了多线程的竞争。加锁和上下文切换等操作。当然其他的地方该多线程还是多线程
- 简单的数据结构和数据操作方法,使得时间复杂度大大降低
过期策略:主动过期+惰性过期