redis

136 阅读28分钟

CAP

  • consistency(强一致性)
  • availablity(可用性)
  • partition tolerance(分区容错性)
  • cap理论核心:一个分布式系统只能同时较好的满足cap中的其中两个特性
  • nosql数据库分成ca,cp,ap三种
    • ca:可拓展性不强
    • cp:性能不高
    • ap:对一致性要求不高
    • image.png
    • 分区容错性是我们必须要的,一致性和可用性要根据系统做出取舍
    • 大多数分布式系统会选择:ap
  • base
    • 解决关系型数据库强一致性而导致可用性降低的解决方案
    • base含义
      • basically available:基本可用性
      • soft state:软状态
      • Eventually consistency:最终一致性
    • 他的思想就是让系统在某一刻放松对一致性的要求来换取整个系统的可伸缩性和性能上的改观。
  • 分布式系统
    • image.png
    • 简而言之:分布式系统就是在不同服务器上部署不同的服务,他们之间通过rpc、rmi通信和调用,对外提供服务和组内写作
  • 集群:在不同的服务器上部署相同的服务,通过分布式调度软件进行统一调度,对外提供服务

redis安装

  • 安装
    • 将压缩包放置/opt,然后解压
    • 进入redis文件夹,执行make命令,若报错执行以下步骤
      • 能上网:yum install gcc-c++
      • 不能上网:image.png
      • 二次make
      • 若还报错,make distance后再make
    • make install

redis六种底层数据结构

  • SDS(简单动态字符串)
    • redis没有直接使用c语言字符串来表示,而是实现了一种名为简单动态字符串来表示,其用作redis的默认字符串
    • 定义
      struct sdshdr {
       //记录buf数组中已使用字节的数量
       //等于SDS所保存字符串的长度
       int len;
       //记录buf数组中未使用字节的数量
       int free;
       //字节数组, 用于保存字符串
       //最后一个字节则保存了空字符'\0',遵循C字符串以空字符结尾的惯例 
       //不计算在SDS的len属性里面 
       char buf[];
      };
      
    • 优点
      • 常数复杂度获取字符串长度
      • 杜绝缓冲区溢出。C字符串不记录字符串长度,有些不安全的API,比如char *strcat(char *dest, const char *src);,将一个src字符串拼接到dest后面可能会导致缓存区溢出。而SDS在执行拼接字符串操作时,会检查dest字符串的长度是否足够,如果不够,会先扩大长度,再进行拼接操作
      • 减少修改字符串带来的内存重新分配次数。C字符串,比如长度为N的字符串底层实现就是N+1长度的数组(末尾多了一个保存空字符的字符空间),字符串长度和数组的长度有对应的相关关系,每次修改时候,都需要重新分配数组的空间,会导致频繁的内存重分配,效率低;SDS保存字符串的数组存在未使用的字节,SDS 实现了空间预分配(扩大数组时分配额外的空间)和惰性空间释放(缩小数组时不释放多余的空间)两种优化策略
      • 二进制安全。C 字符串中的字符必须符合某种编码(比如 ASCII), 并且除了字符串的末尾之外, 字符串里面不能包含空字符, 否则最先被程序读入的空字符将被误认为是字符串结尾 —— 这些限制使得 C 字符串只能保存文本数据, 而不能保存像图片、音频、视频、压缩文件这样的二进制数据。而 SDS API 都会以处理二进制的方式来处理 SDS 存放在 buf 数组里的数据, 程序不会对其中的数据做任何限制、过滤、或者假设 —— 数据在写入时是什么样的,它被读取时就是什么样。
      • 兼容部分C字符串函数。SDS一样遵循C字符串以空字符结尾的惯例,这样有些C字符串函数是可以兼容用的
  • 链表
    • 节点定义
       typedef struct listNode {
            //前置节点
            struct listNode * prev;
            //后置节点
            struct listNode * next;
            //节点的值
           void * value;
       }listNode;
      
      
    • 链表定义
          typedef struct list {
            //表头节点
            listNode * head;
            //表尾节点
            listNode * tail;
            //链表所包含的节点数量
            unsigned long len;
            //节点值复制函数
            void *(*dup)(void *ptr);
            //节点值释放函数
            void (*free)(void *ptr);
            //节点值对比函数
            int (*match)(void *ptr,void *key);
          } list;
      
    • 链表被广泛用于实现Redis的各种功能,比如列表键(最基本、最常用)、发布与订阅、慢查询、监视器等
  • 字典
    • 字典在高级语言中是一种很普遍的数据结构,比如Java的map实现,它是一种保存key-value(键值对)的抽象数据结构,C语言是没有的,Redis构建了自己的字典实现。Redis字典所使用的哈希表由dict.h中的dictht定义
    • 哈希表定义(table属性是一个数组,数组中的每一个元素都是指向dict.h/dicEntry结构的指针,每个dicEntry结构保存着一个键值对)
        typedef struct dictht {
            //哈希表数组
            dictEntry **table;
            //哈希表大小
            unsigned long size;
           //哈希表大小掩码, 用于计算索引值
           //总是等于size-1
            unsigned long sizemask;
           //该哈希表已有节点的数量
           unsigned long used;
       } dictht;
      
    • 哈希表节点
          typedef struct dictEntry {
               //键
               void *key;
               //值
               union {
                void *val;
                uint64_tu64;
                int64_ts64;
              } v;
           //指向下个哈希表节点, 形成链表
          struct dictEntry *next;
          } dictEntry;
      
      • 解决哈希冲突有好几种方法,这里是采用链地址法,next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接起来在一起。
    • 字典定义
      typedef struct dict {
           //类型特定函数
           dictType *type;
           //私有数据
           void *privdata;
           //哈希表
           dictht ht[2];
           // rehash索引
           //当rehash不在进行时, 值为-1
           in trehashidx; /* rehashing not in progress if rehashidx == -1 */
      } dict;
      
      • type: type属性是一个指向 dictType 结构的指针, 每个 dictType 结构保存了一簇用于操作特定类型键值对的函数,Redis 会为用途不同的字典设置不同的类型特定函数
      • privdata: privdata 属性保存了需要传给那些类型特定函数的可选参数
      • ht: 这是包含了两个项的数组,数组中的每一项都是一个dictht哈希表,为什么需要两个项呢?熟悉Java hashmap的人都知道,在一定条件下(元素长度超过数组长度的75%),会触发rehash的操作。随着操作的不断执行, 哈希表保存的键值对会逐渐地增多或者减少, 为了让哈希表的负载因子(load factor)维持在一个合理的范围之内, 当哈希表保存的键值对数量太多或者太少时, 程序需要对哈希表的大小进行相应的扩展或者收缩。一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会对ht[0]哈希表进行rehash使用
      • trehashidx: trehashidx记录了rehash的进度,如果目前没有进行rehash,它的值为-1
    • 重点特性:
      • 字典被广泛用于实现 Redis 的各种功能, 其中包括数据库和哈希键。
      • Redis 中的字典使用哈希表作为底层实现, 每个字典带有两个哈希表, 一个用于平时使用, 另一个仅在进行 rehash 时使用
      • 当字典被用作数据库的底层实现, 或者哈希键的底层实现时, Redis 使用 MurmurHash2 算法来计算键的哈希值
      • 哈希表使用链地址法来解决键冲突, 被分配到同一个索引上的多个键值对会连接成一个单向链表。
      • 在对哈希表进行扩展或者收缩操作时, 程序需要将现有哈希表包含的所有键值对 rehash 到新哈希表里面, 并且这个 rehash 过程并不是一次性地完成的, 而是渐进式地完成的。
  • 跳表
    • 跳表节点定义
      typedef struct zskiplistNode {
           //层
         struct zskiplistLevel {
           //前进指针
           struct zskiplistNode *forward;
           //跨度
           unsigned int span;
         } level[];
         //后退指针
         struct zskiplistNode *backward;
         //分值
         double score;
         //成员对象
         robj *obj;
        } zskiplistNode;
      
      • 层:层(level[])可以包含多个元素,这里的多个元素的数值都是一样的,相当于冗余数据了,每个元素都包含一个指向其他节点的指针, 程序可以通过这些层来加快访问其他节点的速度, 一般来说, 层的数量(数量是随机的,介于1-32)越多层,访问其他节点的速度就越快,是用空间换时间的做法。
      • 前进指针:就是指向其他节点的指针,包含在层里面
      • 跨度: 用于记录两个节点的距离,和遍厉其实无关,是用于计算排位(rank)的,排位可以理解为距离大小
      • 后退指针: 用于从表尾向表头方向访问节点: 跟可以一次跳过多个节点的前进指针不同, 因为每个节点只有一个后退指针, 所以每次只能后退至前一个节点
      • 分值: 是一个 double 类型的浮点数, 跳跃表中的所有节点都按分值从小到大来排序
      • 成员对象: 是一个指针, 它指向一个字符串对象
    • 跳表定义
      typedef struct zskiplist {
      
          // 表头节点和表尾节点
          struct zskiplistNode *header, *tail;
      
          // 表中节点的数量
          unsigned long length;
      
          // 表中层数最大的节点的层数
          int level;
      
      } zskiplist;
      
    • 重点总结:
      • 跳跃表是有序集合的底层实现之一
      • Redis的跳跃表实现由zskiplist和zskiplistNode两部分组成,其中zskiplist用于保存跳跃表的信息,如表头、表尾节点信息,长度;zskiplistNode则表示跳跃表节点
      • 跳跃表的节点按照分值大小进行排序,当分值相同时,节点按照成员对象大小进行排序
  • 整数集合
    • 整数集合是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,redis就会使用整数集合作为集合键的底层实现
    • 定义:
      typedef struct intset { 
          //编码方式 
          uint32_t encoding; 
          //集合包含的元素数量 
          uint32_t length; 
          //保存元素的数组 
          int8_t contents[]; 
      } intset;
      
    • 升级
      • 每当我们要将一个新元素添加到整数集合里面, 并且新元素的类型比整数集合现有所有元素的类型都要长时, 整数集合需要先进行升级(upgrade),然后才能将新元素添加到整数集合里面。
        • 根据新元素的类型, 扩展整数集合底层数组的空间大小, 并为新元素分配空间。
        • 将底层数组现有的所有元素都转换成与新元素相同的类型, 并将类型转换后的元素放置到正确的位上, 而且在放置元素的过程中, 需要继续维持底层数组的有序性质不变
        • 将新元素添加到底层数组里面。升级的动机是很明显的,Redis作为一个内存型数据库,内存资源是很重要的,Redis的每一个设计都是想极大的提高内存利用效率,整数集合保存元素的数组的元素类型是根据存储的元素大小所变化的,动态适应,提高内存利用效率
    • 重点总结
      • 整数集合是集合键的底层实现之一
      • 整数集合的底层实现为数组, 这个数组以有序、无重复的方式保存集合元素, 在有需要时, 程序会根据新添加元素的类型, 改变这个数组的类型
      • 升级操作为整数集合带来了操作上的灵活性, 并且尽可能地节约了内存
      • 整数集合只支持升级操作,不支持降级操作
  • 压缩列表
    • 压缩列表是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项,并且每个列表项要么是小整数值,要么是长度比较短的字符串,那么redis就会使用压缩列表来做列表键的底层实现
    • 压缩列表节点的构成
      • 每个压缩列表节点可以保存一个字节数组或者一个整数值

      • 字节数组可以是以下三种长度的其中一种

        • 长度小于等于 63 (2^{6}-1)字节的字节数组;
        • 长度小于等于 16383 (2^{14}-1) 字节的字节数组;
        • 长度小于等于 4294967295 (2^{32}-1)字节的字节数组;
      • 而整数值则可以是以下六种长度的其中一种:

        • 4 位长,介于 0 至 12 之间的无符号整数;
        • 1 字节长的有符号整数;
        • 3 字节长的有符号整数;
        • int16_t 类型整数;
        • int32_t 类型整数;
        • int64_t 类型整数。
      • 每个压缩列表节点都由 previous_entry_length(以字节为单位, 记录了压缩列表中前一个节点的长度。) 、 encoding 、 content 三个部分组成, 如图所示image.png

    • 重点总结
      • 压缩列表是一种为了节约内存而开发的顺序型数据结构。压缩列表被用作列表键和哈希键的底层实现之一
      • 压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者整数值
      • 添加新节点到压缩列表,或者从列表删除节点可能会引发连锁的更新操作,但这种操作出现的几率并不高

redis五大数据类型

  • String
    • 二进制安全,可存jpg文件或序列化的对象
    • 最大可存512m
  • Hash
    • 类似于java中的Map<String,Object>
    • 特别适合存对象
  • List
    • 底层是链表
    • 是String类型的列表,元素可插头可插尾
  • Set
    • 底层是hashtable
    • 无序无重复的String集合
  • Zset
    • 每个元素绑定一个double类型的分数,按照该分数排序
    • 分数可重复
    • 有序无重复的String集合
  • 高级数据结构
    • 位图(海量数据存储 )
      • 本质上就是一个很大很大的位数组,可理解为bit[]
      • 每一位有0,1两种状态
      • 可用来存放某种状态的表示,适用于大规模数据,但是状态数不多的数据
    • HyperLogLog
      • 用来做不精确的统计(标准误差率为0.81)
      • 底层是字符串
      • 占用内存比set少几个数量级
    • GEO
      • 实现摇一摇,附近的人的功能(和定位相关)
      • 采用GEOHash算法
      • zest实现

reids应用

  • 实现消息中间件
    • brpop方法阻塞式读取
  • 实现延时队列DelayQueue
    • 利用zset每个元素会绑定一个score特性实现
  • 布隆过滤器(用于海量数据的比较)
    • 用来判断某个元素是否在某个集合里面,具有很好的空间和时间效率(判断存在的不一定存在,判断不存在的一定不存在)
    • 位图实现
    • 将元素hash之后映射到位图上,进行比较(为了避免hash冲突,采用多个hash函数的进行hash(但是也还是会存在误判率),位数组长度也会影响误判率)
    • 位数组长度m,要放入布隆过滤器的元素个数n,hash函数的个数k
      • image.png
    • 支持分布式:redisson的布隆过滤器、仿谷歌的布隆过滤器(实现算法时用到谷歌造好的轮子)
    • 不支持分布式:谷歌布隆过滤器
    • 删除数据很麻烦。由于不是存储完整数据,存取快。

redis线程模型

性能瓶颈

  • redis是基于内存操作的,cpu不是redis的性能瓶颈
    • redis是基于单Reactor单进程模型设计的
      • 没有网络io的阻塞
      • 没有多线程的必要,cpu就不是redis的性能瓶颈(上下文切换很耗时)
    • 瓶颈在于内存和网络带宽

线程模型

image.png

  1. 文件事件处理器使用io多路复用程序来同时监听多个套接字,将套接字的fd注册在epoll上,当监听的套接字准备好执行连接应答accept,读取read,写入write,关闭close等操作,与操作相对应的文件事件就会产生
  2. 多个文件事件会并发出现,但是io多路复用会将所有产生事件的套接字推到一个队列里,通过队列保证有序、同步、每次一个套接字的方式向文件事件分派器推送套接字
  3. 文件事件处理器(单线程运行)会调用套接字之前关联好的事件处理器去处理这些事件
  • 注意:redis使用单线程处理命令,每一条到达服务器端的命令都会进入队列,一条条按顺序执行,不会产生并行问题

慢查询

  • 设置慢查询
    • 设置慢查询的时间(根据系统的吞吐量
      • 单次会话有效:config set slowlog-log-slower-than 时间
      • 持久化有效:修改Redis.conf,找到slowlog-log-slower-than,修改设置即可
    • slow-max-len:存放慢查询记录的队列长度(通常设大些)
  • 相关命令
    • 检索慢查询记录:slowlog get
    • 当前慢查询列表中所含记录数:slowlog len

Pipeline(提升性能)

  • 封装n个redis命令进行操作,降低总传输往返时间(nRTT->RTT
  • 封装太多redis指令也不好,受限于(一个tcp包的大小是1460kb(MTU(1500)-20-20(ip头、tcp头))、内核的输入输出缓冲区大小4k-8k)

事务

  • redis事务(很弱,只有语法错误的时候才回滚)
    • 开启事务multi
    • 关闭事务exec
    • watch命令,执行multi前如果用watch监控某个键值对,在exec时,如果被监控键值对被修改了,回滚事务(类似悲观锁)
  • reids事务与pipeline区别,事务是服务器端的行为,pipeline是客服端的行为

Lua(c语言写的)

  • 使用lua脚本的好处
    • 减少网络开销,一个lua脚本可以将多个命令放在同一个脚本一起执行
    • 原子操作,redis会将lua脚本作为一个整体执行,期间不背打断
    • 复用性,redis可以将lua脚本存起来,供其他客服端使用
  • 安装image.png

发布、订阅(不可靠)

  • 命令:publish、subscribe
  • 特点:发送既忘,当没有订阅者时,发送出去就丢失了,没有缓存机制

Redis Stream

  • 可以理解为管理消息的数据结构
  • kafka底层使用

一些redis相关问题

其他

什么是redis

  • 一个key-value类型的内存数据库,整个数据库是在加载内存中进行操作,定期异步将数据flush到硬盘中,io速度快,每秒可进行十万次读写

redis相比Memcached有哪些优势

  • redis支持更丰富的数据类型,Memcached支持最简单的字符串
  • redis的速度比Mencached快
  • redis可以持久化数据

redis有哪几种数据淘汰策略

  • noeviction:返回错误当内存限制达到并且客户端尝试执行会让更多内存被使用的命令(大部分的写入指令,但del和几个例外)
  • allkeys-lru:尝试回收最少使用的键,使得新添加的数据有空间存放(最常用)
  • volatile-lru:尝试回收最少使用的键,但仅限于在过期集合里的键,使得新添加的数据有空间存放
  • allkeys-random:回收随机的键使得新添加的数据有空间存放
  • volatile-random:回收随机的键使得新添加的数据有空间存放,但仅限于在过期集合里的键
  • volatile-ttl:回收在过期集合里的键,并且优先回收存活时间ttl较短的键,使得新添加的数据有空间存放

一个字符串类型的值最大容量是多少

  • 512M

如何保证缓存和数据库双写时的数据一致性

  • 无论是先写缓存还是先写数据,都可能出现数据不一致的情况,因此可以先写数据库,再删除缓存(因为删除缓存到更新数据库的时间可以用毫秒计算,正常的并发影响不大。但如果是达到上亿级访问,在这时间段内,会出现读请求在写请求更新数据库之前执行,导致数据库与缓存不一致)
  • 方案二(并发高下不建议):读请求和写请求串行化到队列中,但系统吞吐量下降,需要多几部机器去维持线上的请求

为什么要用redis

  • 高性能
  • 高并发

为什么要用redis而不用map/Guava做缓存

  • map、guava属于本地缓存,生命周期随着jvm的销毁而结束,并且多实例时,每个实例各自保存着一份缓存,缓存不具有一致性
  • redis属于分布式缓存,多实例时,每个实例共用一份缓存,缓存具有一致性,缺点是要保证redis高可用

redis为什么快

  • 完全基于内存、绝大多数请求是纯粹的内存操作,很快
  • 数据结构简单、数据操作也简单
  • 采用多路复用io
  • 使用底层模型不一样,redis直接构建了vm机制,因为一般的系统调用系统函数,会浪费一定时间去移动和请求

redis有哪些数据类型

  • string
    • 简单的键值缓存
  • list
    • 存储列表形数据结构
  • set
    • 交集、并集、差集操作(比如实现共友功能)
  • hash
    • 结构化数据、比如一个对象
  • zset
    • 去重且排序

redis应用场景

  • 计数器
  • 缓存
  • 会话缓存
  • 全页缓存(FPC)
  • 查找表
  • 消息队列(发布/订阅):通过list双向链表,通过lpush、rpop写入和读取。不过最好还是使用kafka、rabbitmq实现(redis的pubsub不会将消息持久化、宕机后消息丢失。没有ack保证、可靠性不高
  • 分布式锁
  • 排行榜、交集、并集...

redis过期键的删除策略

  • 定时过期:到了过期时间立即删除,对内存友好,但是会占用大量的cpu资源去处理过期的数据,从而影响缓存的响应时间和吞吐量
  • 惰性过期:当访问一个key时,判断key是否过期,过期则删除。对内存不友好,最大化节省cpu资源
  • 定期过期:每隔一定时间扫描一定数量的expires字典中的一定数量的key,并清除其中过期的key。前两者的折中方式,可以调整扫描间隔和每次扫描的限定耗时
  • redis同时使用惰性过期和定期过期策略

Redis key的过期时间和永久有效证明设置

  • expire和persist命令

如何保证redis中的数据都是热点数据

  • redis的内存数据集大小到达一定的大小后,就会施行数据淘汰策略

redis主要消耗什么物理资源

  • 内存

redis内存优化

  • 利用集合类型数据,尽可能将小key-value紧凑放在一起。尽可能多用散列表(使用内存小)

redis持久化

redis持久化机制是什么

  • redis提供两种持久化机制RDB(默认)和AOF机制
  • 注意:
    • 因aof文件的更新频率会比rdb文件更新频率高
      • 如果服务器开启了aof持久化功能,服务器优先使用aof文件来还原数据
      • 只有服务器aof关闭时才会使用rdb文件还原

image.png

RDB?

  • RDB是Redis DataBase缩写快照
  • RDB数Redis默认的持久化方式。按照一定时间将内存的数据以快照的形式保存在硬盘中,产生的数据文件未dump.rdb。通过配置文件的save参数来定义快照周期

image.png

  • 优点
    • 就一个dump.rdb,方便持久化
    • 容灾性好,一个文件可以安全保存到硬盘中
    • 性能最大化,fork子进程完成写操作,主进程继续处理命令,io最大化
    • 数据集大时,比AOF启动效率更高
  • 缺点
    • 数据安全性低,由于是间隔一段时间才持久化,如果两次持久化之间redis宕机了,会丢失数据

RDB文件的创建和载入

image.png

创建
  • 两个命令可生成RDB文件
    • SAVE
      • 阻塞redis进程,直到rdb文件创建完毕,阻塞期间,不接受任何请求
    • BGSAVE
      • 派生出一个子进程,然后由子进程负责创建rdb文件,主进程继续处理请求
  • 创建rdb文件实际工作由rdb.c/rdbSave函数完成,上述两个命令以不同方式调用该函数
载入
  • redis服务器启动时自动执行,没有具体命令,启动时若检测到有rdb文件存在则自动载入
  • 调用rdb.c/rdbLoad函数完成

AOF

  • AOF即Append Only File持久化,将redis执行的每次写命令记录到单独的日志文件中,重启redis时读取日志文件恢复数据
  • 当两种方式同时开启时,数据恢复优先选择AOF恢复

image.png

  • 优点
    • 数据安全,aof持久化可以配置appendfsync属性,可配置always使得每进行一次命令操作就记录到aof文件一次
    • 即使写aof文件时系统宕机,可通过redis-check-aof工具解决一致性问题
    • aof机制的rewrite模式。aof文件没有被rewrite之前,可以删除其中的某些命令(比如误操作的flush all)
  • 缺点
    • aof文件比rdb文件大,恢复速度慢
    • 数据集大时,启动效率比rdb低

aof实现

  • 命令追加(append)
    • 服务器执行完一个写命令后,会以协议格式将被执行的写命令追加到服务器状态的aof_buf缓冲区末尾
  • 文件写入与同步(sync)
    • redis服务进程实际上就是一个事件循环
      • 服务器每次结束一个事件循环之前,都会调用flushAppendOnlyFile函数,考虑是否需要把aof——buf缓存区内的内容写入保存在aof文件中
        • 该函数的行为有服务器配置的appendfsync选项来决定
          • always:将aof_buf缓冲区所有的的数据写入并同步到aof文件
          • everysec(默认):将aof_buf缓存区所有的数据写入到aof文件,如上次同步aof文件事件距离现在超过1秒,则再次同步,并且这个同步操作是由一个线程专门负责执行
          • no:将aof_buf缓冲区所有的的数据写入到aof文件,何时同步操作系统自行决定(线代操作系统write时会先把写入数据存在内存缓冲区,空间被填满或超过指定期限才真正写入磁盘)

aof文件的载入与数据还原

image.png

  • aof文件中包含了重建数据库状态所需的所有写命令,服务器只要读入并重新执行一遍,即可还原状态
  • 详细步骤
    • 创建一个不带网络连接的伪客户端来执行aof文件保存的写命令(因reids命令只能在客户端上下文中执行,而aof文件不是网络连接因为不需要网络连接)。
      • 伪客户端和带网络连接的客户端执行效果完全一样
    • 从aof中分析并读取出一条去执行
    • 使用伪客户端执行并读出的写命令
    • 重复23步骤,直至所有写命令执行完毕

aof重写

  • 为什么需要aof重写?
    • 随着服务器运行,aof文件中的内容越发增加,文件体积就增大,这会影响redis服务器、整个宿主机有影响
    • 解决?
      • 重写aof文件
        • 新旧aof文件所保存的数据库状态完全相同,但新aof文件不包含任何浪费空间的冗余命令,通常比旧aof文件小
          • 实现
            • 不需要对现有aof文件任何读取分析或写入,通过读取服务器当前数据库状态实现的(首先从数据库中读取键所有的值,然后一条命令去记录键值对,代替之前这个键值对的多条命令)
            • aof_rewrite函数完成
              • 新生成的aof文件只包含还原当前数据库状态所必须的命令,所以新的aof文件不浪费硬空间
  • aof后台重写
    • 用来解决aof_write函数会进行大量写操作,因redis是单个线程处理命令请求,所以直接调用aof——write会导致rewrite期间服务器无法处理客户端发来的请求
      • 因此将aof重写程序放入子进程中,主进程继续处理请求
      • 子进程带有服务器进程的数据副本,使用子进程而不是线程,可以避免用锁,同时保证数据安全性
        • 怎么解决子进程进行aof重写期间,服务器接收新的请求并修改现有数据库状态,导致不一致的问题?
          • 设置了一个aof重写缓冲区,这个缓冲区是在子进程重写aof开始使用,redis服务器在执行完一个写命令后,同时将这个写命令发给aof缓冲区和aof重写缓冲区
          • 子进程完成aof重写后,会向父进程发送一个信号,父进程收到信号执行信号处理函数,将aof重写缓冲区的所有内容写到新的aof文件,并对新aof改名并原子覆盖旧文件

两种持久化优缺点

  • aof比rdb更新频率高,优先使用aof恢复数据
  • aof比rdb更安全更大
  • rdb比aof性能好
  • 两个都配优先加载aof

如何选择合适的持久化方式

  • 如果想达到PostgreSQL的数据安全性,应同时使用两种持久化方式
  • 非常关心数据,但是可以忍受数分钟内数据丢失,可以使用rdb持久化
  • 只使用aof持久化是不推荐的,定时生成rdb快照非常便于数据库备份,且恢复数据集速度比aof快,使用rdb还可以避免aof程序的bug
  • 如果只希望数据只在服务器运行时存在,可以不使用持久化

Redis持久化数据和缓存怎么做扩容

  • 如果redis被当做缓存使用,使用一致性哈希实现动态扩容缩容
  • 如果redis被当做持久化存储使用,必须固定keys-to-nodes的映射关系,节点的数量一旦确定不能变化,否则的话,必须使用可以在运行时进行数据再平衡的一套系统,而当前只有redis集群可以做到这样

缓存异常

什么是redis穿透

  • 用户请求透过redis去请求mysql服务器,导致mysql压力过载。
  • 解决方法
    • 从缓存取不到并且从数据库里也取不到,可以将key-value写为key-null,且缓存有效时间可以设置短点(如30s,太长会导致正常情况也无法使用),可有效避免用户反复用同一个id暴力攻击。
    • 接口层增加校验,如用户鉴权,id基础校验,id<=0直接拦截
    • 采用布隆过滤器,将可能存在的数据哈希到一个足够大的bitmap中去,一个一定不存在的数据就会被这个bitmap拦截,从而避免对底层存储系统的查询压力。

什么是redis雪崩

  • redis服务负载过大而宕机,导致mysql也负载过大而宕机,最终整个web系统瘫痪
  • 解决方法
    • redis集群
    • 数据不要设置相同的过期时间,不然过期时redis压力会很大

redis击穿

  • 高并发下,由于一个key失效了,导致多个线程去mysql查同一个业务数据并存到redis(并发下,存了多份数据),而一段时间后,多份数据同时失效,导致redis压力骤增
  • 解决方法:
    • 使用互斥锁(常用)
      • 在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。
      public String get(key) { 
          String value = redis.get(key); 
          if (value == null) { //代表缓存值过期 
          //设置3min的超时,防止del操作失败的时候,下次缓存过期一直不能load db 
              if (redis.setnx(key_mutex, 1, 3 * 60) == 1) { //代表设置成功 
                  value = db.get(key); 
                  redis.set(key, value, expire_secs); 
                  redis.del(key_mutex); 
              } else { 
                  //这个时候代表同时候的其他线程已经load db并回设到缓存了,这时候重试获取缓存值即可
                  sleep(50);
                  get(key); //重试 
                } 
          } else { return value; } }
      
    • 分级缓存(缓存两份数据,第二份数据生存时间长一点作为备份,第一份数据用于被请求命中,如果第二份数据被命中说明第一份数据已经过期了,要去mysql请求数据重新缓存两份数据)
    • 计划任务(加入数据生存时间为30分钟,计划任务就20分钟执行一次更新缓存)

缓存热点key

  • 缓存中的一个Key(比如一个促销商品),在某个时间点过期的时候,恰好在这个时间点对这个Key有 大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时 候大并发的请求可能会瞬间把后端DB压垮。 解决方案
  • 对后端DB加载数据并回设到缓存加锁,如果KEY不存在,就加锁,然后查DB入缓存,然后解锁;其他进程如果发现有锁就等待,然后等解锁后返回数据或者进入DB查询

缓存预热

  • 缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的 时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!
  • 解决方案
    1. 直接写个缓存刷新页面,上线时手工操作一下;
    2. 数据量不大,可以在项目启动的时候自动进行加载;
    3. 定时刷新缓存;

分布式锁

原形

  • 使用setNx命令
if (jedis.setnx(lockKey, val) == 1) {
   jedis.expire(lockKey, timeout);
}
  • 带来问题:加锁操作和设置超时时间是分卡的,并非原子操作

解决非原子操作,忘记释放锁

  • 使用set命令,该命令后可指定多个参数
try{
  String result = jedis.set(lockKey, requestId, "NX""PX", expireTime);
  if ("OK".equals(result)) {
      return true;
  }
  return false;
} finally {
    unlock(lockKey);
}
  • lockKey:锁的标识
  • requestId:请求id
  • NX:只在键不存在时,才对键进行设置操作。
  • PX:设置键的过期时间为 millisecond 毫秒。
  • expireTime:过期时间

解决释放了别人的锁

  • 在使用set命令加锁时,处理使用lockKey标识,再多设置一个参数requestId,释放锁的时候使用 伪代码如下:
if (jedis.get(lockKey).equals(requestId)) {
    jedis.del(lockKey);
    return true;
}
return false;
  • 这里为什么要用requestId,用userId不行吗?
    • 用userId的话对于请求来说是不唯一的,多个不用的请求,可能来自于一个userId,而requestId是全局唯一的,不存在加锁和释放锁乱掉的情况
  • 此处也可使用lua脚本来解决释放了别人的锁的问题(更推荐,因为lua脚本能保证查询锁是否存在和删除锁是原子操作
if redis.call('get', KEYS[1]) == ARGV[1] then 
 return redis.call('del', KEYS[1]) 
else 
  return 0 
end
  • 注意加锁的时候也是建议用lua脚本的
//redisson框架的加锁代码
if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hset', KEYS[1], ARGV[2], 1)
    redis.call('pexpire', KEYS[1], ARGV[1])
 return nil
end
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1)
   redis.call('hincrby', KEYS[1], ARGV[2], 1)
   redis.call('pexpire', KEYS[1], ARGV[1])
  return nil
end
return redis.call('pttl', KEYS[1]);

解决大量失败请求

  • 场景一:如果有1万的请求同时去竞争那把锁,可能只有一个请求是成功的,其余的9999个请求都会失败。造成每1万个请求,有1个成功。再1万个请求,有1个成功。如此下去,直到库存不足。这就变成均匀分布的秒杀了,跟我们想象中的不一样。
  • 场景二:有两个线程同时上传文件到sftp,上传文件前先要创建目录。假设两个线程需要创建的目录名都是当天的日期,比如:20210920,如果不做任何控制,直接并发的创建目录,第二个线程必然会失败。但是第二个请求如果加锁失败了,接下来,是返回失败,还是返回成功呢?
  • 使用自旋锁

try {
  Long start = System.currentTimeMillis();
  while(true) {
     String result = jedis.set(lockKey, requestId, "NX""PX", expireTime);
     if ("OK".equals(result)) {
        if(!exists(path)) {
           mkdir(path);
        }
        return true;
     }
     
     long time = System.currentTimeMillis() - start;
      if (time>=timeout) {
          return false;
      }
      try {
          Thread.sleep(50);
      } catch (InterruptedException e) {
          e.printStackTrace();
      }
  }
} finally{
    unlock(lockKey,requestId);
}  
return false;

锁重入问题

  • 场景:假设在某个请求中,需要获取一颗满足条件的菜单树或者分类树。我们以菜单为例,这就需要在接口中从根节点开始,递归遍历出所有满足条件的子节点,然后组装成一颗菜单树。

需要注意的是菜单不是一成不变的,在后台系统中运营同学可以动态添加、修改和删除菜单。为了保证在并发的情况下,每次都可能获取最新的数据,这里可以加redis分布式锁。

加redis分布式锁的思路是对的。但接下来问题来了,在递归方法中递归遍历多次,每次都是加的同一把锁。递归第一层当然是可以加锁成功的,但递归第二层、第三层...第N层,不就会加锁失败了?

//错误代码,第二层递归加锁失败直接,第一层递归直接释放锁了
private int expireTime = 1000;

public void fun(int level,String lockKey,String requestId){
  try{
     String result = jedis.set(lockKey, requestId, "NX""PX", expireTime);
     if ("OK".equals(result)) {
        if(level<=10){
           this.fun(++level,lockKey,requestId);
        } else {
           return;
        }
     }
     return;
  } finally {
     unlock(lockKey,requestId);
  }
}
  • 使用可重入锁
  • 以redissoon框架为例,redisson在redis分布式锁中江湖地位高
//伪代码,大致思路是这样
private int expireTime = 1000;

public void run(String lockKey) {
  RLock lock = redisson.getLock(lockKey);
  this.fun(lock,1);
}

public void fun(RLock lock,int level){
  try{
      lock.lock(5, TimeUnit.SECONDS);
      if(level<=10){
         this.fun(lock,++level);
      } else {
         return;
      }
  } finally {
     lock.unlock();
  }
}
  • redisson可重入锁的实现
    • 加锁

if (redis.call('exists', KEYS[1]) == 0) 
then  
   redis.call('hset', KEYS[1], ARGV[2], 1);        redis.call('pexpire', KEYS[1], ARGV[1]); 
   return nil
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) 
then  
  redis.call('hincrby', KEYS[1], ARGV[2], 1)
  redis.call('pexpire', KEYS[1], ARGV[1])
  return nil
end;
return redis.call('pttl', KEYS[1]);

其中:

  • KEYS[1]:锁名
  • ARGV[1]:过期时间
  • ARGV[2]:uuid + ":" + threadId,可认为是requestId
  1. 先判断如果锁名不存在,则加锁。
  2. 接下来,判断如果锁名和requestId值都存在,则使用hincrby命令给该锁名和requestId值计数,每次都加1。注意一下,这里就是重入锁的关键,锁重入一次值就加1。
  3. 如果锁名存在,但值不是requestId,则返回过期时间。
    • 释放锁

if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) 
then 
  return nil
end
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) 
then 
    redis.call('pexpire', KEYS[1], ARGV[2])
    return 0
 else 
   redis.call('del', KEYS[1])
   redis.call('publish', KEYS[2], ARGV[1])
   return 1
end
return nil
  1. 先判断如果锁名和requestId值不存在,则直接返回。
  2. 如果锁名和requestId值存在,则重入锁减1。
  3. 如果减1后,重入锁的value值还大于0,说明还有引用,则重试设置过期时间。
  4. 如果减1后,重入锁的value值还等于0,则可以删除锁,然后发消息通知等待线程抢锁。