Redis原理及使用

296 阅读16分钟

redis数据结构

string(字符串)

struct sdshdr{
//记录buf数组中已使用字节的数量
//等于 SDS 保存字符串的长度
int len;
//记录 buf 数组中未使用字节的数量
int free;
//字节数组,用于保存字符串
char buf[];
    
}

其实redis中string的实现类似于ArrayList,所以获取字符串长度的时间复杂度是O(1).采用预分配的策略,扩容都是加倍扩容,但是大于1MB了,就会每次扩容只扩容1MB,字符串的最大空间是512MB。

应用场景:

  • 缓存用户信息,将JSON序列化成字符串,然后放到redis里面。
  • 计数,如果value是一个整数,可以进行自增操作,自增是有范围的,在signed long的最大值和最小值之间。

list(列表)

相当于java里面的LinkedList,他是一个双线链表的结构,插入和删除操作很快,但是索引定位比较慢。

其实list的底层结构是两个,一个是ziplist(压缩列表),一个是quicklist(快速列表),在元素少的时候会使用ziplist,将分配一个连续的空间,集中存储数据,因为数据少的时候如果用链表会需要prev和next指针,占用空间,在数据多的时候,会将多个ziplist用指针串联成quicklist,这样可以减少内存的碎片化。

应用场景:

  • 经常用来做异步队列,将延后处理的任务结构体序列化成字符串,塞进redis的列表中,另一个线程从列表轮询处理。

hash(字典)

相当于java的hashmap,无序字典,采用数组+链表的二维结构。但是字典的值只能是字符串。

字典在rehash扩容是做了优化,采用了渐进式rehash策略。在rehash的过程中,会保留两个新旧的hash结构,查询的时候会同时查询两个hash结构,会在后续的定时任务以及哈说操作指令中慢慢迁移,迁移完成后会把旧的hash结构删掉,回收内存。

set(集合)

相当于java里面的hashset,简直对是无序的、唯一的。内部实现是一个特殊的字典,所有的value的值都是null,其实就是通过计算hash值,来判断是不是重复了。 应用场景:

  • 去重场景,如果中奖用户id。
  • 交集 并集 差集

zset(有序列表)

类似于sortedSet和HashMap的结合体,一是他是一个set保证了value的唯一性,一个是他给每个value都设置了一个score,代表这个value的排序权重。

zset的内部的排序功能是通过“跳跃列表”来实现的,为什么使用跳表,因为zset的需要支持随机插入和删除,那么就不能使用数组,二分查找可以快速查到插入的定位点,所以快速插入删除是链表的特性,二分查找是数组的特性。

struct zslnode{
    string value;
    double score;
    zslnode*[] forwards;//多层连接指针
    zslnode*[] backward;//回溯指针
}

struct zsl{
    zslnode* header;//跳跃列表头指针
    int maxLevel;//跳跃列表当前访问最高层
    map<string,zslnode*> ht;// hashmap的所有的键值对
}

这里面zsl就是zset的结构,首先这个header指针是一个用来维护跳表结构的,里面的value是null,socre是Double.MIN_VALUE,然后这个forwards数组是用来指向每层的第一个节点的,这样就可以串联起来每层的数据,然后在最底层,使用forward和backward来组成一个双向链表。最后这个map,是用来存储所有的节点的,这个map是为了便于修改做的。其实跳表分层的原理就是,每一层理挑选几个出来,构造出类似于树的结构,然后就能实现O(logn)的复杂度的查询,增加查询的效率。

应用场景:

  • 可以使用zset来实现延时队列,把消息序列化成value,然后保到期处理的时间作为score,然后用多个线程轮询zset获取到期处理的任务。但需要考虑处理失败的补偿策略,因为redis的消息队列不是100%的可靠,如果获取到任务之后服务crash就会导致消息丢失。

这里有还有两个过期时间的问题,redis中的过期时间是指的整个结构的,比如hash的过期时间是整个hash,而不是其中的某一个key。同时在设置过期时间之后在进行set,会使之前的过期时间被覆盖消失掉。

redis线程模型

redis是一个单线程的程序,所有的数据都在内存中,所以很多操作都会造成redis卡顿,其实redis走的也是netty的那一套NIO的模型,非阻塞的io,调用操作系统的select、poll、epoll这一套东西,基于事件循环来处理,所以这就是实现了类似于数据库隔离级别的串行化,因为毕竟所有的操作都是串行执行的。单线程操作,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗。

redis事务

redis的事务不是关系型数据库那样的严格事务。

  • multi 开启事务
  • exec 执行事务(提交)
  • discard 丢弃事务(回滚)
  • watch 监视一个或多个key,必须在multi命令开启前使用,如果watch的key在exec执行时发现已经被改过了,那么将会报错

redis的事务在exec的前,将会把之前的命令存到一个事务队列中,在收到exec命令之后,会直接执行队列的任务,因为redis是串行化的单线程,所以可以保证理论上的“原子性”,但是如果中间执行的命令报错,后续的命令还是会执行,所以根本没有原子性,discard指令只是为了丢弃事务队列里面的所有命令不执行。所以事务慎用。

持久化 RDB快照AOF追加写

RDB:

快照其实就是把内存的数据二进制序列化,存储上会非常紧凑,类似于数据库备份,在进行快照的过程中,redis还是要相应请求的,所以会一边备份一边修改内存中的数据。这个时候会用到操作系统多进程的COW(copy on write),来实现快照持久化,其实就是会利用glibc里面的函数fork出来一个子进程,子进程不会修改内存数据,只是会遍历读取,如果这个时候父进程为了相应请求,修改了数据,那么父进程会复制当前的内存页进行修改,所以子进程扫描的数据不会变动。

AOF: 增量追加记录其实是类似于数据库的binlog,会记录运行的命令,如果crash掉了,那么重启的时候会重放AOF日志恢复数据。在进行AOF日志记录的时候,用到了操作系统的fsync函数,也就是内核缓存强制刷入磁盘,保证AOF日志不修饰,但是由于这个操作比较慢,所以会定时间隔1s调用一次,所以可能会有一秒的数据丢失。

redis4.0之后持久化可以使用混合持久化,其实就相当于数据库的定期备份,这样重启重放日志的时候就会加快速度。

主从同步

首先,redis的主从同步保证的CAP里的AP系统,所以保证最终一致性。因为主节点处理完用户请求后就会返回。

主从同步有两种同步方式:增量同步、快照同步。

  • 增量同步:redis的同步时指令流的同步,指令流是在内存的buffer中,buffer是一个定长环形数组,会异步发送到slave节点,但是如果buffer满了就会从头开始覆盖掉之前的命令,这时候就需要用到快照同步了。

  • 快照同步:master会执行bgsave的命令,生成快照,将当前内存中的数据全部快照到磁盘里面,然后把快照文件发送给从节点,从节点接受完之后,进行全量加载,然后在继续进行增量同步。但是有可能会出现一个快照复制的死循环,就是增量同步的buffer满了,出发了快照同步,快照同步的过程中,增量buffer又满了,又进行了快照同步,所以要配置一个合适的buffer大小避免快照复制死循环。

  • 主从同步集群优化:

    1. master节点不进行持久化操作,利用从节点进行持久化操作,master和slave最好处于同一个局域网内。
    2. 主从同步可以利用从从同步,形成链状结构master<--slave1<--slave2<--slave3,方便解决单点故障,也可以减轻主节点同步负担。

Sentinel哨兵模式

其实这个是redis官方提供的一个主从切换的方案,可以视为一个zookeeper的集群来提供的一种见识主从节点健康的方案,一般3-5个节点组成。客户端连接redis集群的时候,首先会连接Sentinel集群,获取主节点的连接信息进行连接,如果主节点挂了,那么重新向Sentinel集群要地址,Sentinel会返回一个最合适的从节点给客户端(因为redis是异步同步,所以有可能有些命令没有同步到从节点)。其实这个过程就类似于Dubbo利用zk来做注册中心的机制。

redis集群方案

  • codis:codis是豌豆荚开源的分时中间件,其实原理是转发代理中间件,客户端发送请求到codis然后codis将key经过crc32运算hash,codis默认将key化为1024个槽位,将hash对1024取模得余数,就是这个key对应的槽位,每一个槽位会映射一个redis,一个redis可能对应多个槽位。一个redis管理哪几个槽位的信息,存储在codis内置的zookeeper中,用来各个redis实例同步信息。codis的问题:因为key分散在多个redis实例,所以他不支持事务,作为中间转发层,网络开销比较大,不过可以通过增加codis实例的数量来缓解。运维成本高。

  • redis cluster:和codis不同的是,每一个redis节点是对等的,也就是槽位分布的信息在每一个redis节点上都会存储,客户端访问的时候,会先获取一份槽位信息放在内存里,然后直接定位到目标节点进行操作。

redis过期key删除策略

  • 定时扫描:redis会将设置了过期时间的key放到一个独立的hash字典中,redis默认每秒进行十次过期扫描。他不是遍历所有的key,而是使用了简单的贪心策略。(1)过期字典中随机选择20个key(2)删除这20个已经过期的key(3)如果过期key的比例超过1/4,那就继续重新选择20个key进行又一轮的删除。同时为了不时间过长,redis增加了默认的超时时间是25ms。

定时扫描的问题就是,如果大批量key同时过期,那么如果有请求过来,那么会等待25ms,所以失效时间需要随机不能在同一时间过期。

  • 惰性删除:因为redis是单线程的,所以如果删除一个包含了成千上万个元素的大key比如hash结构的数据,那么直接调用del删除,是会造成线程卡顿的。这时候redis会执行unlink命令,将hash结构的引用删除,放到一个线程安全的异步任务队列里,交给子线程慢慢删除,这就是懒删除的策略。

缓存淘汰策略

当redis超出物理内存限制时,会进行内存淘汰,只会淘汰设置了过期时间的key,最常用的有以下几个:

  • LRU(最长时间未被使用):redis实现的LRU算法是近似LRU的算法,会给每个key加上一个额外的24bit的小字段用来记录更新时间。而且LRU只有惰性删除,当一个写请求进来了,发现超出内存,就会发起一次LRU,随机找出5个key,然后淘汰掉最旧的key,如果淘汰了还是超出,那么就继续随机采样找出5个淘汰掉最旧的。
  • LFU(最近最少使用):LRU的问题是,如果一个已经排在队尾己经被删除掉的key,突然被访问一次,这个key会变成热key,但是访问频度只有1,这种访问频度低的key,反而可能把其他访问频度高的key给淘汰掉。在LRU中,key的对象的24bit存储了更新时间,在LFU中会用16bit存储时间,精度更低,剩下的8bit会存储访问次数,这个访问次数会随着时间衰减,衰减淘汰的逻辑,会在内存达到max的时候触发。

java面试中经常会问使用双链表加map来实现LRU算法。可以参考这边文章实现用LinkedHashMap实现LRU(Java面试常考)。其实原理就是LinkedHashMap继承了HashMap,LinkedHashMap自己又用map的键值维护了一个双线链表,新建节点的时候重新包裹了一下节点,加入了前后指针,存储map的时候会向双链表里面插入,这时候如果发现超出大小限制了,就可以把队尾的元素删掉,并且删除map里面的值,这样就完成了最旧未用淘汰的策略。

redis分布式锁

(1)redis的锁可以使用setnx命令,设置一个string的值,然后在使用expire命令设置key的过期时间,避免程序出现异常无法释放锁的问题。同时可以将string的value设置成一个随机值,一般可以设置时间戳。这样当程序A出现阻塞,超过过期时间锁自动释放掉,然后程序B重新获取了锁,这是线程A又来释放锁,这时候把程序B的锁释放掉了。

(2)redis2.6之后可以使用2.8之后set指令增加了设置超市时间操作,将设置值和超时时间合并为一个原子操作。但是还是不能解决超时之后两个线程同时持有锁的问题,同时还有一个删除锁的问题,因为删除节点不是一个原子操作,当对比了随机值相同之后还是有可能超时,线程A的锁删除的时候超时了,对比随机值准备删除了,但是这时候过期了,然后线程B获取了锁,然后这时候线程A执行del,就会删除掉B的锁。所以可以使用lua脚本,将对比和删除的操作设置成同一个原子操作。所以redis锁不适合运行耗时长的操作。

(3)在集群环境下,如果涉及到了主从切换,会存在如果主节点有锁,但是锁没有同步到从节点,那么当主节点挂了,从节点收到获取锁请求,就会批准加锁。Redlock算法的思想就是在多个实例上获取锁,这些实例没有主从关系,加锁时会向过半节点发送set指令,超过半数加锁成功,才会认为成功。因为涉及到多个节点,所以肯定比单个节点性能差。

redis消息队列

  • 异步队列

    可以使用list作为队列,可以使用blpop/brpop实现阻塞读,这样如果队列空了,线程不需要空轮训,节省性能,但是redis客户端链接闲置过久,服务器就会主动断开连接,这个时候客户端就需要捕获异常,进行重试继续消费。

  • 延时队列

    延时队列可以使用zset实现,将消息序列化成字符串作为zset的value,过期时间作为score,zet的特性是唯一和有序,然后用多个线程轮询zset获取到期任务进行处理,多个线程是为了可用性,万一挂了还至少有一个线程继续处理。因为会涉及到多个线程调用,这时候可以使用jedis的zrem方法,zrem方法是用来移除zset的一个或多个成员,若果移除成功了说明这个元素被本线程占有了,其实就是和list的pop一样,就是弹出,在此之前,可以使用zrangebyscore来获取队列的到本时刻已经到时的任务,然后遍历zrem弹出,这样就可以进行顺序消费,实现延时队列。

redis布隆过滤器

可以用来防止缓存击穿,大量查询不存在的key的场景,所以如果查询需要过滤大量的不存在的key,可以使用这个方案。可以使用的有guava的基于内存的布隆过滤器,但是是基于内存的,重启失效,不能适用分布式场景,redis的布隆过滤器可以扩展,不存在重启失效的问题(开了持久化),但是需要网络io。

原理,其实就是将一个key进行多次hash函数运算,然后映射到一个bitmap里面,这样这个key在bitmap里面就有可能存在多个bit值为1,所以他的特点就是,如果这个key不存在bitmap,一下就能发现,但是如果hash的结果和别的key重复,那么就不能判断key是不是存在,所以存在一个误判率的问题。误判率是和hash函数和bitmap的大小有关系的。

布隆过滤器的空间占用统计的公式是这样的: k=0.7*(l/n) f=0.6185^(l/n) 有两个入参,n是预计的元素数量,第二个是错误率f。这样这两个方程可以算出来一个是l储存空间的大小(bit),第二个是f也就是hash函数的最佳数量k。

若果实际元素的数量超过了预计元素的数量,这里有另外一个公式来计算: f=(1-0.5^t)^k 这里引入了一个参数t,就是实际元素数量和预计元素数量的倍数。 所以t越大,错误率越大。

scan 大key扫描

redis默认查询key列表的命令是keys pattern,就是一个正则表达式去匹配,keys的原理是遍历查找,如果要从成千上万个key找到模式匹配的很花时间,并且redis是单线程的,keys的操作会阻塞其他线程。 keys的缺点:

  1. 没有offset limit这种分页的参数,导致他会一次性查找所有满足表达式的条件。
  2. keys是遍历的逻辑,复杂度是O(n)。这样导致单线程的redis无法响应其他请求。

redis2.8版本中加入了scan命令,可以设置扫描范围,匹配模式,起点游标,这样就能分批次查询。但是由于查询的过程中还会有数据插入所以有可能会重复,因为数据下标在移动,需要客户端去重。

在平时业务开发中,要尽量避免大key的产生。

redis阻塞原因

  1. 数据结构使用不合理bigkey,比如涉及到key删除,查找一类的
  2. CPU饱和
  3. 持久化阻塞,rdb fork子线程,aof每秒刷盘等,因为这个会设计操作系统调用和磁盘互通

缓存穿透

  • 产生原因:对于系统中不存在的值进行请求,有可能是恶意攻击,导致缓存查询不命中,穿透到数据库去。
  • 解决方法:可以对不存在的key值进行缓存,value为null,这样就不需要去请求数据库,但是可能会缓存大量无用数据。在一个可以使用布隆过滤器,这样就可以缓存已经存在的值,然后如果不存在可以直接拒绝,也不用存储无用的key。

缓存击穿

  • 产生原因:当有一个或者几个热key失效了这个时候就想在缓存屏障上凿了一个洞,直接请求了数据库。
  • 解决方法:当缓存更新的时候,可以使用互斥锁进行更新,这里涉及到一个双写一致性的问题下面说,针对同一个数据的请求不会同时请求到数据库。第二个就是使用随机退避,失效时随机sleep一段时间,再次重试查询,如果失败就再次获取更新锁进行更新。第三个如果有大量的热key同时失效,就需要在缓存的超时时间上设置随机数,避免大量key失效。

缓存雪崩

  • 产生原因:其实缓存雪崩也是因为大面积的key失效,其实大量热key失效也可以归类为缓存雪崩,除此之外缓存挂掉,也会导致所有的请求都访问到数据库。、 -解决方法:使用快速失败直接熔断,减少数据库压力,可以使用主从或者集群模式来保证服务的高可用。

缓存双写一致性

缓存+数据库读写模式:

  • 读的时候先读缓存,缓存没有,再读数据库,然后取出数据放到缓存里,同时返回相应结果。
  • 更新的时候,先更新数据库,然后删除缓存数据。

之所以需要删除数据,是基于一个懒加载的思想,因为有时候缓存的数据是需要去计算的,有可能更新完了并不需要获取缓存数据,如果这个时候直接计算完了放到缓存一个是会影响返回时间,一个是浪费计算资源。

双写一致性读取的时候,可以使用互斥锁来避免多个线程同时操作缓存,其他的请求可以休眠一个随机值,采用随机退让的方法,来获取缓存。

public static String getData(String Key){
	String result = getDataByKV(Key);
    if(StringUtils.isBlank(result)){
    	if(reenLock.tryLock()){
        	result = getDataByDB(Key);
        }
        if(StringUtils.isBlank(result)){
        	setDataToKV(Key,result);
        }
        //这里实际场景下面会在finally里面释放
        reenLock.UnLock();
    }else{
    	//线程休息一会儿再去获取数据避免多线程竞争
    	Thread.Sleep(1000L);
        result = getData(Key);
    }
    return result;
}

热key访问倾斜

如果一个key在集群中一个节点的话,那么压力会倾斜到一个redis实例上。

解决办法:

  • 使用guava这种本地缓存策略,进行缓存,这样直接访问的就是本地缓存
  • 利用分片算法,给hot key加上前缀,或者后缀,把一个hotkey的数量变成redis实例个数N的M倍,其实就是按倍数增长,这样分片散列的时候就会散列到多个key上,这样可以利用倍数信息进行随机分片访问,分散访问压力。

redis应用场景

  1. 会话缓存
  2. 排行榜/计数器
  3. 发布/订阅
  4. pub/sub(类似于mq广播消费多队列模式)
  5. Geohash附近的人
  6. 限流器