Redis复习

Redis复习

笔者是名大三的菜鸡,现正在复习,准备明年的春招,欢迎互关呀,如果文章有错误,欢迎及时指出呀

一,Redis底层数据结构

1.String类型

底层数据结构

String在redis中的底层数据结构是SDS(简单动态字符串),这是一种和传统c字符串( 一个字符数组并且是一个以空字符结尾的字符数组 )不同的数据结构

不同在哪?

先看SDS的底层结构体:

// sds 类型 声明类型别名,可以这么理解
// typedef char *String 
typedef char *sds;

// sdshdr 结构
struct sdshdr {

    // buf 已占用长度
    int len;

    // buf 剩余可用长度
    int free;

    // 实际保存字符串数据的地方
    char buf[];
};

SDS底层也是字符数组组成,那么和传统c字符串的区别在哪呢?

  • 对于传统的c字符串,我们如果想要获取该字符串的长度。我们需要遍历一遍字符数组才行,复杂度是O(N),而在redis中,底层定义了len字段,想要获取字符串长度,只需要sds->len即可,复杂度优化为O(1)

  • 传统c字符串是一个已经分配好内存大小的字符数组,如果遇到不够内存的情况下,需要手动重新分配内存;而redis中的SDS则类似于java中的ArrayList,在数组容量不足时,可以动态扩容。

  • 对于传统c字符串,他通过判断当前字符串是否是空字符来判断是否是字符串结尾,这就要求你的字符串中间不能包含一个空字符,否则会影响判断,导致后边的字符无法读取, 而redis sds 不是通过空字符判断字符串结尾,而是通过 len 字段的值判断字符串的结尾,所以说,sds 还具备二进制安全这个特性,即它可以安全的存储具备特殊格式要求的二进制数据。

2.list类型

list在redis中的实现是双向链表

image

底层结构体:

/*
 * 链表
 */
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的链表的数据类型底层是一个双向链表的实现】
 */
typedef struct listNode {

    // 前驱节点
    struct listNode *prev;

    // 后继节点
    struct listNode *next;

    // 值
    void *value;

} listNode;

redis中的底层链表的操作可以参考我的另一篇博客redis底层链表操作

3.hash字典类型

字典相对于数组,链表来说,是一种较高层次的数据结构,像我们的汉语字典一样,可以通过拼音或偏旁唯一确定一个汉字,在程序里我们管每一个映射关系叫做一个键值对,很多个键值对放在一起就构成了我们的字典结构。

有很多高级的字典结构实现,例如我们 Java 中的 HashMap 底层实现,根据键的 Hash 值均匀的将键值对分散到数组中,并在遇到哈希冲突时,冲突的键值对通过单向链表串联,并在链表结构超过八个节点裂变成红黑树。

那么 redis 中是怎么实现的呢?我们一起来看一看。

redis底层hash字典定义

redis中的hash字典是使用拉链法的哈希表

/*
 * 哈希表节点
 */
typedef struct dictEntry {

    // 键
    void *key;

    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
    } v;

    // 链往后继节点(拉链法)
    struct dictEntry *next; 

} dictEntry;

其本质也是数组加上单链表的形式,数组用来存放key,当遇到哈希冲突不同key映射到数组同一位置时,采取拉链法放于数组同一位置来解决冲突,与hashmap很类似

/*
 * 哈希表
 */
typedef struct dictht {

    // 哈希表节点指针数组(俗称桶,bucket)
    dictEntry **table;      

    // 指针数组的大小
    unsigned long size;     

    // 指针数组的长度掩码,用于计算索引值
    unsigned long sizemask; 

    // 哈希表现有的节点数量
    unsigned long used;     

} dictht;

img

上面就是字典的底层实现----哈希表,其实就是两张哈希表

/*
 * 字典
 *
 * 每个字典使用两个哈希表,用于实现渐进式 rehash
 */
typedef struct dict {

    // 特定于类型的处理函数
    dictType *type;

    // 类型处理函数的私有数据
    void *privdata;

    // 哈希表(2个)
    dictht ht[2];       

    // 记录 rehash 进度的标志,值为-1 表示 rehash 未进行
    int rehashidx;

    // 当前正在运作的安全迭代器数量
    int iterators;      

} dict;

使用两张哈希表作为字典的实现是为了后续字典的扩展rehash用的

img

  • ht[0]:就是平时用来存放普通键值对的哈希表,经常使用字典中的这个
  • ht[2]:是在进行字典扩张时rehash才启用

字典中渐进式rehash

在redis中,哈希表的扩容或者缩容都需要进行rehash,即将ht[0]中的所有键值对rehash到ht[1]中,但是这个rehash的过程不是一次性完成的,而是分多次,渐进式完成的,为了避免rehash对服务器性能造成的影响,服务器不是一次性将 ht[0] 里面的所有键值对全部 rehash 到 ht[1] , 而是分多次、渐进式地将 ht[0] 里面的键值对慢慢地 rehash 到 ht[1] 。

rehash过程:

  • 为 ht[1] 分配空间, 让字典同时持有 ht[0] 和 ht[1] 两个哈希表
  • 在字典中维持一个索引计数器变量 rehashidx , 并将它的值设置为 0 , 表示 rehash 工作正式开始。
  • 在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1] , 当 rehash 工作完成之后, 程序将 rehashidx 属性的值增一。
  • 随着字典操作的不断执行, 最终在某个时间点上, ht[0] 的所有键值对都会被 rehash 至 ht[1] , 这时程序将 rehashidx 属性的值设为 -1 , 表示 rehash 操作已完成。

渐进式 rehash 的好处在于它采取分而治之的方式, 将 rehash 键值对所需的计算工作均滩到对字典的每个添加、删除、查找和更新操作上, 从而避免了集中式 rehash 而带来的庞大计算量。

4.跳跃链表

redis中的sort set是通过跳跃链表加上我们上边说的字典实现的

  • 字典保存数据和分数score的映射关系,每次插入数据都会从字典中查询,如果已经存在了该key,则不再插入,防止重复
typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

跳跃链表的插入,删除,查找性能和平衡树相当,实现比平衡树要简单。

typedef struct zskiplistNode {

    // 后退指针
    struct zskiplistNode *backward;

    // 分值
    double score;

    // 成员对象
    robj *obj;

    // 层
    struct zskiplistLevel {

        // 前进指针
        struct zskiplistNode *forward;

        // 跨度,跨过多少个节点
        unsigned int span;

    } level[];

} zskiplistNode;

跳表底层:

typedef struct zskiplist {

    // 表头节点和表尾节点
    struct zskiplistNode *header, *tail;

    // 表中节点的数量
    unsigned long length;

    // 表中层数最大的节点的层数
    int level;

} zskiplist;

在这里插入图片描述

为什么redis采用跳表实现而不采用平衡树或者红黑树呢?

  • 算法实现难度上,跳表实现难度小于平衡树,平衡树在插入和删除节点后,需要重新调整平衡,而跳表在插入和删除后只需要修改相邻节点的指针
  • 查找性能上,对於单个节点的查找,跳表和平衡树都是O(logN),但是对于范围查找,平衡树需要进行多次单个节点的查找,而跳表的第一层是一个单向或双向链表,范围查找效率要更高,类似B树和B+树的区别

redis中的跳表和普通跳表的区别

redis的跳表和普通的跳表实现没有多大区别,主要区别在三处:

  • redis的跳表引入了score,且score可以重复

  • 排序不止根据分数,还可能根据成员对象(当分数相同时)

  • 有一个前继指针,因此在第1层,就形成了一个双向链表,从而可以方便的从表尾向表头遍历

5.整数集合

redis对整数存储专门做了优化,intset就是redis用来保存整数值的集合的数据结构,当一个集合中只包含整数时,redis就会用这个来存储

typedef struct intset {

    // 保存元素所使用的类型的长度
    uint32_t encoding;

    // 元素个数
    uint32_t length;    

    // 保存元素的数组
    int8_t contents[];  

} intset;

编码方式(encoding)字段是干嘛用的呢?

  • 如果 encoding 属性的值为 INTSET_ENC_INT16 , 那么 contents 就是一个 int16_t 类型的数组, 数组里的每个项都是一个 int16_t 类型的整数值 (最小值为 -32,768 ,最大值为 32,767 )。
  • 如果 encoding 属性的值为 INTSET_ENC_INT32 , 那么 contents 就是一个 int32_t 类型的数组, 数组里的每个项都是一个 int32_t 类型的整数值 (最小值为 -2,147,483,648 ,最大值为 2,147,483,647 )。
  • 如果 encoding 属性的值为 INTSET_ENC_INT64 , 那么 contents 就是一个 int64_t 类型的数组, 数组里的每个项都是一个 int64_t 类型的整数值 (最小值为 -9,223,372,036,854,775,808 ,最大值为 9,223,372,036,854,775,807 )。

说白了就是根据contents字段来判断用哪个int类型更好,也就是对int存储作了优化。

encoding升级

如果我们有个16位整数集合,现在要将一个32位整数加入这个集合中,那么原来16位整数集合是存储不下来的,所以就需要对整数集合进行升级

升级过程:

假如现在有2个int16的元素:1和2,新加入1个int32位的元素65535。

  1. 内存重分配,新加入后应该是3个元素,所以分配3*32-1=95位。
  2. 选择最大的数65535, 放到(95-32+1, 95)位这个内存段中,然后2放到(95-32-32+1+1, 95-32)位…依次类推。

注意,整数集合支持升级,不支持降级,即32位整数集合无法降到16位整数集合

另外整数集合中的元素是有序的,所以其查找过程时采用二分查找法进行搜索

6.压缩列表

ziplist是redis为了节约内存而开发的顺序型数据结构。它被用在列表键和哈希键中。一般用于小数据存储

typedef struct entry {
     /*前一个元素长度需要空间和前一个元素长度*/
    unsigned int prevlengh;
     /*元素内容编码*/
    unsigned char encoding;
     /*元素实际内容*/
    unsigned char *data;
}zlentry;

image

  • previous_entry_length:每个节点会使用一个或者五个字节来描述前一个节点占用的总字节数,如果前一个节点占用的总字节数小于 254,那么就用一个字节存储,反之如果前一个节点占用的总字节数超过了 254,那么一个字节就不够存储了,这里会用五个字节存储并将第一个字节的值存储为固定值 254 用于区分。

  • encoding: 压缩列表可以存储 16位、32位、64位的整数以及字符串,encoding 就是用来区分后面的 content 字段中存储于的到底是哪种内容,分别占多少字节

  • **content:**存储的二进制内容

typedef struct ziplist{
     /*ziplist分配的内存大小*/
     uint32_t zlbytes;
     /*达到尾部的偏移量*/
     uint32_t zltail;
     /*存储元素实体个数*/
     uint16_t zllen;
     /*存储内容实体元素*/
     unsigned char* entry[];
     /*尾部标识*/
     unsigned char zlend;
}ziplist;

image

  • **ZIPLIST_BYTES:**四个字节,记录了整个压缩列表总共占用了多少字节数

  • ZIPLIST_TAIL_OFFSET:四个字节,记录了整个压缩列表第一个节点到最后一个节点跨越了多少个字节,通故这个字段可以迅速定位到列表最后一个节点位置,相当于尾指针

  • **ZIPLIST_LENGTH:**两个字节,记录了整个压缩列表中总共包含几个 zlentry 节点

  • **zlentry:**非固定字节,记录的是单个节点,这是一个复合结构,我们等下再说,entry序列

  • **0xFF:**一个字节,十进制的值为 255,标志压缩列表的结尾

连锁更新问题

image

假设原本 entry1 节点占用字节数为 211(小于 254),那么 entry2 的 previous_entry_length 会使用一个字节存储 211,现在我们新插入一个节点 NEWEntry,这个节点比较大,占用了 512 个字节。

那么,我们知道,NEWEntry 节点插入后,entry2 的 previous_entry_length 存储不了 512,那么 redis 就会重分配内存,增加 entry2 的内存分配,并分配给 previous_entry_length 五个字节存储 NEWEntry 节点长度。

看似没什么问题,但是如果极端情况下,entry2 扩容四个字节后,导致自身占用字节数超过 254,就会又触发后一个节点的内存占用空间扩大,非常极端情况下,会导致所有的节点都扩容,这就是连锁更新,一次更新导致大量甚至全部节点都更新内存的分配。

如果连锁更新发生的概率很高的话,压缩列表无疑就会是一个低效的数据结构,但实际上连锁更新发生的条件是非常苛刻的,其一是需要大量节点长度小于 254 连续串联连接,其二是我们更新的节点位置恰好也导致后一个节点内存扩充更新。

基于这两点,且少量的连锁更新对性能是影响不大的,所以这里的连锁更新对压缩列表的性能是没有多大的影响的,可以忽略,但需要知晓。

二,redis事务

2.1redis中的事务

客户端通过和redis服务器两阶段的交互做到了批量命令原子化执行的事务效果

  • 入队阶段:通过MULTI命令开启事务后,客户端将请求发送到服务器端,后者将其暂存在连接对象(即发送请求的客户端)对应的请求队列中,入队阶段出现错误,则不执行后序的exec

  • 执行阶段:发送完一个批次的所有请求后,redis服务器依次执行连接对象队列中的所有请求,由於单个实例的redis仅仅单个线程执行所有请求,所以在批量处理期间不会接受其他客户端的请求

  • 批量处理:EXEC命令可以让批量的请求一次性全部执行,执行结果以一个数组形式发送给客户端,但是如果执行期间有一条命令错误,事务并不会回滚或者整体事务失败,而是会继续执行下去

开启事务:MULTI

执行事务:EXEC

取消事务:DISCARD

redis 127.0.0.1:6379> MULTI
OK

redis 127.0.0.1:6379> SET book-name "Mastering C++ in 21 days"
QUEUED

redis 127.0.0.1:6379> GET book-name
QUEUED

redis 127.0.0.1:6379> SADD tag "C++" "Programming" "Mastering Series"
QUEUED

redis 127.0.0.1:6379> SMEMBERS tag
QUEUED

redis 127.0.0.1:6379> EXEC
1) OK
2) "Mastering C++ in 21 days"
3) (integer) 3
4) 1) "Mastering Series"
   2) "C++"
   3) "Programming"

单个redis的命令是原子性的,但是redis没有在事务上增加原子性,所以redis的事务不具备原子性,中间某条命令失败了,并不会导致整个事务失败,其他命令照常执行,所以redis没有回滚机制使得redis的事务实现大大简化

  • 无需为事务引入数据版本机制
  • 无需为每个操作引入逆向操作

这也说明,redis的事务并不一致

如何解决这个问题

redis通过watch机制来解决上述的一致性问题

*WATCH命令可以监控一个或多个键,一旦其中有一个键被其他客户端修改(或删除),之后的事务就不会执行。监控一直持续到EXEC命令(事务中的命令是在EXEC之后才执行的,所以在MULTI命令后可以修改WATCH监控的键值)*

这里开启两个客户端

客户端1
set key1 value1
watch key1
multi
set key2 value2
exec

客户端2在客户端1执行到set key2 value2时执行
set key1 val

此时客户端1的watch机制检测到key1被修改过,所以EXEC直接失败,拒绝执行

最后,无论exec是否执行,都会UNWATCH所有原来注册的key

三,缓存雪崩,穿透,击穿

通过一个查询业务来分析下缓存雪崩,穿透和击穿。

流程如下:

在这里插入图片描述

1.缓存雪崩

缓存雪崩一句话概括就是大量的key在同一时间失效,造成大多数请求直接落到数据库,将数据库打崩

场景

同一时间key大面积失效,那一瞬间Redis跟没有一样,那这个数量级别的请求直接打到数据库

在这里插入图片描述

解决方法

  • 在批量往redis存数据的时候,key的过期时间都加上一个随机值,防止大量的key在同一时间过期

    setRedis(Key,value,time + Math.random() * 10000);
    
  • 或者将访问频繁的热点数据设置永不过期,热点数据有更新,则缓存数据库一并更新就好,但是这样操作就会变得复杂

2.缓存穿透

缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求这些缓存合数据库中都没有的数据,造成数据库压力增大 ,严重会击垮数据库。

场景

利用一些不存在key(数据库也没有该记录的数据)发起查询请求,那么这些不存在的key就会直接跳过redis,直接请求数据库,而数据库也没有相关记录(比如主键id为-1的,一般我们主键id是从1开始递增的),当请求比较频繁时,数据库压力也会随着增大

在这里插入图片描述

解决方法

  • 请求参数校验

    可以在controller利用自定义注解去拦截请求,然后对请求参数做一个基础的校验,降低风险

    具体步骤:

    • 编写校验注解
    • 编写校验逻辑
    • aop对controller的方法进行增加,在执行原方法前,利用反射扫描出有注解的方法参数,对这些参数执行校验

    实现如下:

    这里我做一个自定义的参数非空注解:

    注解

    @Target(ElementType.PARAMETER)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface ParamCheck {
        /**
         * 是否非空,默认不能为空
         */
        boolean notNull() default true;
    }
    

    AOP

    @Aspect
    @Component
    public class AopValidation {
    
        /**
         * 切入点表达式
         *      1.格式:方法修饰符 + 返回值类型 + 包名 + 类名 + 方法名 +方法参数
         */
        @Pointcut("execution(public * com.springboot.learning.controller.*.*(..))")
        public void validation(){
    
        }
    
        /**
         * 注解解析增强
         * @param joinPoint
         * @return
         */
        @Around("validation()")
        public Object arround(ProceedingJoinPoint joinPoint) throws Throwable {
    
            //反射获取拦截到方法上的注解
            MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
            //获取拦截的方法
            Method method = methodSignature.getMethod();
            //获取方法参数注解,返回二维数组是因为某些参数可能存在多个注解
            Annotation[][] parameterAnnotations = method.getParameterAnnotations();
            //如果没有注解则直接执行方法
            if (parameterAnnotations == null || parameterAnnotations.length == 0) {
                return joinPoint.proceed();
            }
            //获取参数值
            Object[] paranValues = joinPoint.getArgs();
            for (int i = 0; i < parameterAnnotations.length; i++) {
                for (int j = 0; j < parameterAnnotations[i].length; j++) {
                    System.out.println("当前遍历到注解:------>"+parameterAnnotations[i][j].annotationType().getName());
                    //如果该参数前面的注解是ParamCheck的实例,并且notNull()=true,则进行非空校验
                    if (parameterAnnotations[i][j] != null
                            && parameterAnnotations[i][j] instanceof ParamCheck
                            //注解中定义的规则
                            && ((ParamCheck) parameterAnnotations[i][j]).notNull()) {
                        paramIsNull(paranValues[i]);
                        break;
                    }
                }
            }
            return joinPoint.proceed();
        }
    
        /**
         * 参数非空校验,如果参数为空,则抛出ParamIsNullException异常
         */
        private void paramIsNull(Object value) {
            if (value == null || "".equals(value.toString().trim())) {
                //throw new ParamIsNullException(paramName, parameterType);
                System.out.println("参数非空校验,如果参数为空,则抛出ParamIsNullException异常");
            }
        }
    }
    

    controller

        @RequestMapping("/test/mongodb")
        public String test(@RequestParam @ParamCheck String value){
            System.out.println("test");
            return "test";
        }
    

    测试

    在这里插入图片描述

    控制台:

    在这里插入图片描述

    如果觉得Aop的方式有点麻烦,还可以使用springboot的自定义校验功能,也是基于自定义注解实现

    @Target({ElementType.PARAMETER,ElementType.FIELD})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    //声明使用哪个校验器
    @Constraint(validatedBy = {NotBlankValidator.class })
    public @interface NotBlank {
    
        boolean required() default true;//是否需要传
    
        //下面三个必须要加
        String message() default "不能为空";//提示信息
    
        //解决自定义注解异常
        Class<?>[] groups() default { };
        Class<? extends Payload>[] payload() default { };
    }
    

    校验器:实现ConstraintValidator接口即可

    public class NotBlankValidator implements ConstraintValidator<NotBlank,String> {
    
        private boolean require = false;
    
        /**
         * 初始化得到注解数据
         * @param constraintAnnotation
         */
        @Override
        public void initialize(NotBlank constraintAnnotation) {
            this.require = constraintAnnotation.required();
        }
    
        /**
         * 校验逻辑
         * @param value
         * @param context
         * @return
         */
        @Override
        public boolean isValid(String value, ConstraintValidatorContext context) {
            if (require){
                System.out.println("---->"+value);
                if(value.isEmpty() || value.length() == 0 ){
                    //context.getDefaultConstraintMessageTemplate()获取注解内定义的message
                    System.out.println("value 为空"+context.getDefaultConstraintMessageTemplate());
                    //如果是空参数则抛出异常,可以使用全局异常处理器捕获,然后返回错误信息json给前端
                    throw new RuntimeException();
                }else{
                    return true;
                }
            }else{
                System.out.println("----222>"+value);
                return true;
            }
        }
    }
    

    实体类:

    @Data
    public class Test {
    
        @NotBlank
        private String num;
    }
    

    然后在controller加上@Valid开启校验

        @RequestMapping("/test/mongodb")
        public String test(@RequestParam @ParamCheck String value, @ModelAttribute @Valid Test t){
            System.out.println("test");
            return "test";
        }
    
  • 布隆过滤器

布隆过滤器

对于我个人的理解,布隆过滤器其实是一种能在一定的空间下快速检索一个元素是否存在于一个较大的元素集合中的一个数据结构。

原理

布隆过滤器其实本质上是一个只包含0和1的大数组(位图),当一个key要被加入到这个大数组中时,该key会被k个哈希函数运算得到k个hash值,然后将这k个hash值映射到数组对应的位置,这些对应位置设为1。

当查询某个key在不在大数组时,我们就看对应的应设点是否全为1,如果全为1则有可能存在(哈希可能冲突),如果有一个位置是0,则一定不存在。

布隆

所以,布隆过滤器只能肯定的判断某元素不在该集合中,而不能准确的判断判断某元素一定在该集合

注意,布隆过滤器不支持删除,因为删除操作的话,可能会影响到其他的key(如果其他的key经过哈希函数后映射到需要删除的位置)这样就会影响其他元素的判断。

应用

img

我之前用的是guava的布隆过滤器

        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>19.0</version>
        </dependency>

简单实现:

    /**
     * 布隆过滤器测试类
     */

    //预计要插入的数据量(给位数组分配的空间,空间越大,误判率越小)
    private static int EXPECT_SIZE = 1000000;

    //误判率
    private static double FPP = 0.1;

    private static BloomFilter<Integer> bloomFilter = BloomFilter
            .create(Funnels.integerFunnel(),EXPECT_SIZE,FPP);

    public static void main(String[] args) {
        //插入数据
        for (int i = 0; i < 1000000; i++) {
            bloomFilter.put(i);
        }
        double count = 0;
        //故意用1000000个不在集合中的数据,测试下误判率和自定义的误判率的差别
        for (int i = 1000000; i < 2000000; i++) {
            if (bloomFilter.mightContain(i)) {
                count++;
                System.out.println(i + "误判了");
            }
        }
        System.out.println("总共的误判数:" + count);
        System.out.println("误判率
                           :"+count / 1000000);
    }

在这里插入图片描述

实际处理流程:

因为我没有在实际项目中使用过,所以我能想到的只有这样,怪我太菜了【捂脸】

在这里插入图片描述

这样的话,其实是由缺点的,如果数据库中有某个key删除了,布隆过滤器因为不支持删除,所以别人也可以通过先删除这个key,在不断请求这个key,以跳过redis直接到mysql。

3.缓存击穿

缓存击穿就是指一个Key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个Key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个完好无损的桶上凿开了一个洞。

场景

有个很抢手的商品,请求该商品的频率很高,一旦该商品的key失效,那么所有的请求将会一下子涌到数据库,数据库压力一下子增高承受不了而挂掉。道理就像你一直捶打水桶的某一个点,当他顶不住了,水桶就被击穿了。

解决方法

  • 热点数据永不过期
  • 互斥锁,分布式redis的话得靠lua脚本

四,数据一致性问题

只要使用缓存,就一定会涉及到数据库和缓存的双储存和双写,就一定会有数据一致性问题

最经典的缓存和数据库的读写模式就是:

  • 读的时候,先读缓存,缓存没有的话,再读数据库,然后取出数据放入缓存,同时返回响应

在这里插入图片描述

  • 写的时候,先更新数据库,再删除缓存

在这里插入图片描述

为什么是删除缓存,而不是更新缓存?

在很多时候,复杂的场景下,缓存不单单是数据库中直接取出来的值

比如,我更新了一个表的某个字段,但是对应的缓存是需要查询其他两个表的数据进行计算的出来的,那么,如果我想更新缓存的话,我更新了这个表的字段后,我还需要去查另外两个表,然后去计算新缓存的值,如果这个缓存被频繁更新,那么我就得频繁的查表,计算,所以,这里可以分为两种情况去考虑的:

  • 如果更新缓存的代价很小,那么可以先更新缓存,这个代价很小的意思是我不需要很复杂的计算去获得最新的余额数字。

  • 如果是更新缓存的代价很大,意味着需要通过多个接口调用和数据查询才能获得最新的结果,那么可以先淘汰缓存。淘汰缓存以后后续的请求如果在缓存中找不到,自然去数据库中检索

五,redis持久化

1.何为持久化?

持久化就是将内存中的数据同步到磁盘,redis的数据是存储在内存中的,如果redis一旦挂掉,不进行持久化同步到磁盘的话,redis中的数据就会丢失

2.redis中的持久化

2.1AOF

AOF对每条写入命令作为日志,以append—only模式追加到日志文件中,因为这个模式是只追加的方式,所以没有任何磁盘寻址的开销,所以很快,有点像mysql 的binlog

2.1.1AOF流程
  • 追加写入命令到缓冲区

    redis将每一条写通过redis通讯协议添加到缓冲区aof_buf中,等缓冲区满了在根据策略一次性写入磁盘,减少了磁盘IO

  • 同步到磁盘

    同步策略: 由配置参数appendfsync决定

    • no:不使用fsync方法同步,使用系统函数write去执行同步写入到文件中, 在linux操作系统中大约每30秒刷一次缓冲。这种情况下,缓冲区数据同步不可控,并且在大量的写操作下,aof_buf缓冲区会堆积会越来越严重,一旦redis出现故障,数据丢失严重。

    • always:表示每次有写操作都调用fsync方法强制内核将数据写入到aof文件。这种情况下由于每次写命令都写到了文件中, 虽然数据比较安全,但是因为每次写操作都会同步到AOF文件中,所以在性能上会有影响,同时由于频繁的IO操作,硬盘的使用寿命会降低。

    • everysec:数据将使用调用操作系统write写入文件,并使用fsync每秒一次从内核刷新到磁盘。 这是折中的方案,兼顾性能和数据安全,所以redis默认推荐使用该配置。

  • 文件重写

当开启的AOF时,随着时间推移,AOF文件会越来越大,当然redis也对AOF文件进行了优化,即触发AOF文件重写条件(后续会说明)时候,redis将使用bgrewriteaof对AOF文件进行重写。这样的好处在于减少AOF文件大小,同时有利于数据的恢复。

为什么重写?比如先后执行了“set foo bar1 set foo bar2 set foo bar3” 此时AOF文件会记录三条命令,这显然不合理,因为文件中应只保留“set foo bar3”这个最后设置的值,前面的set命令都是多余的,下面是一些重写时候策略:

  • 重复或无效的命令不写入文件
  • 过期的数据不再写入文件
  • 多条命令合并写入(当多个命令能合并一条命令时候会对其优化合并作为一个命令写入,例如“RPUSH list1 a RPUSH list1 b" 合并为“RPUSH list1 a b” )

重写流程:

img

2.1.2AOF配置文件
auto-aof-rewrite-min-size 64mb
#AOF文件最小重写大小,只有当AOF文件大小大于该值时候才可能重写,4.0默认配置64mb。

auto-aof-rewrite-percentage  100
#当前AOF文件大小和最后一次重写后的大小之间的比率等于或者等于指定的增长百分比,如100代表当前AOF文件是上次重写的两倍时候才重写。

appendfsync everysec
#no:不使用fsync方法同步,而是交给操作系统write函数去执行同步操作,在linux操作系统中大约每30秒刷一次缓冲。这种情况下,缓冲区数据同步不可控,并且在大量的写操作下,aof_buf缓冲区会堆积会越来越严重,一旦redis出现故障,数据
#always:表示每次有写操作都调用fsync方法强制内核将数据写入到aof文件。这种情况下由于每次写命令都写到了文件中, 虽然数据比较安全,但是因为每次写操作都会同步到AOF文件中,所以在性能上会有影响,同时由于频繁的IO操作,硬盘的使用寿命会降低。
#everysec:数据将使用调用操作系统write写入文件,并使用fsync每秒一次从内核刷新到磁盘。 这是折中的方案,兼顾性能和数据安全,所以redis默认推荐使用该配置。

aof-load-truncated yes
#当redis突然运行崩溃时,会出现aof文件被截断的情况,Redis可以在发生这种情况时退出并加载错误,以下选项控制此行为。
#如果aof-load-truncated设置为yes,则加载截断的AOF文件,Redis服务器启动发出日志以通知用户该事件。
#如果该选项设置为no,则服务将中止并显示错误并停止启动。当该选项设置为no时,用户需要在重启之前使用“redis-check-aof”实用程序修复AOF文件在进行启动。

appendonly no 
#yes开启AOF,no关闭AOF

appendfilename appendonly.aof
#指定AOF文件名,4.0无法通过config set 设置,只能通过修改配置文件设置。

dir /etc/redis
#RDB文件和AOF文件存放目录
2.1.3优缺点

优点:

  • AOF在对日志文件进行操作的时候是以append-only的方式去写的,他只是追加的方式写数据,自然就少了很多磁盘寻址的开销了,写入性能惊人,文件也不容易破损。

  • AOF的日志是通过一个叫非常可读的方式记录的,这样的特性就适合做灾难性数据误删除的紧急恢复了,比如公司的实习生通过flushall清空了所有的数据,只要这个时候后台重写还没发生,你马上拷贝一份AOF日志文件,把最后一条flushall命令删了就完事了。

缺点:

  • 一样的数据,AOF文件比RDB还要大。

2.2RDB

RDB则是类似定时任务,对redis中的数据进行周期的持久化到磁盘

RDB持久化是通过快照的方式进行的,何为快照,就是某个时间点对数据进行一次照相,数据就相当于保存在了这张照片中。

2.2.1快照触发方式
  • 自动触发

    • 根据配置文件save m n规则自动快照
    • 客户端在执行数据库清空命令flushall时,触发快照
    • 客户端在执行shutdown关闭redis时,触发快照
  • 手动触发

    • 客户端执行命令bgsave和save时会生成快照(客户端在执行save命令时,处于阻塞状态,不接受其他命令,直到RDB完成,谨慎使用)

bgsave命令,可以理解为后台执行快照

bgsave执行过程:

  • 客户端执行bgsave命令,redis主进程收到指令后先判断此时是否在进行AOF重写,如果此时正在执行,则不fork子进程,直接返回;
  • 主进程调用fork方法创建子进程,在fork子进程过程中redis阻塞,不响应客户端请求
  • 子进程创建完成后,bgsave返回 “Background saving started”,此时标志着redis可以响应客户端请求了
  • 子进程执行内存数据快照,快照完成后替换原来快照文件
  • 子进程发送信号给redis主进程完成快照操作,主进程更新统计信息(info Persistence可查看),子进程退出;

img

关于save m n

save m n :在指定的m秒内,redis中有n个键发生改变,则自动触发bgsave , 该规则默认在redis.conf中进行了配置,并且可组合使用,满足其中一个规则,则触发bgsave , 以save 900 1为例,表明当900秒内至少有一个键发生改变时候,redis触发bgsave操作。

flushall命令

flushall命令用于清空数据库,请慎用,当我们使用了则表明我们需要对数据进行清空,那redis当然需要对快照文件也进行清空

关于shutdown

redis在关闭前处于安全角度将所有数据全部保存下来,以便下次启动会恢复

2.2.2RDB配置文件
save m n
#配置快照(rdb)促发规则,格式:save <seconds> <changes>
#save 900 1  900秒内至少有1个key被改变则做一次快照
#save 300 10  300秒内至少有300个key被改变则做一次快照
#save 60 10000  60秒内至少有10000个key被改变则做一次快照
#关闭该规则使用svae “” 

dbfilename  dump.rdb
#rdb持久化存储数据库文件名,默认为dump.rdb

stop-write-on-bgsave-error yes 
#yes代表当使用bgsave命令持久化出错时候停止写RDB快照文件,no表明忽略错误继续写文件。

rdbchecksum yes
#在写入文件和读取文件时是否开启rdb文件检查,检查是否有无损坏,如果在启动是检查发现损坏,则停止启动。

dir "/etc/redis"
#数据文件存放目录,rdb快照文件和aof文件都会存放至该目录,请确保有写权限

rdbcompression yes
#是否开启RDB文件压缩,该功能可以节约磁盘空间
2.2.3优缺点
  • 优点:

    • 适合做冷备
  • 缺点:

    • RDB在生成快照文件时,如果文件很大,客户端会暂停几毫秒甚至几秒
    • RDB都是快照文件,都是默认五分钟甚至更久的时间才会生成一次,这意味着你这次同步到下次同步这中间五分钟的数据都很可能全部丢失掉

总结

RDB做镜像全量持久化,AOF做增量持久化。因为RDB会耗费较长时间,不够实时,在停机的时候会导致大量丢失数据,所以需要AOF来配合使用。在redis实例重启时,会使用RDB持久化文件重新构建内存,再使用AOF重放近期的操作指令来实现完整恢复重启之前的状态。

这里很好理解,把RDB理解为一整个表全量的数据,AOF理解为每次操作的日志就好了,服务器重启的时候先把表的数据全部搞进去,但是他可能不完整,你再回放一下日志,数据不就完整了嘛。不过Redis本身的机制是 AOF持久化开启且存在AOF文件时,优先加载AOF文件;AOF关闭或者AOF文件不存在时,加载RDB文件;加载AOF/RDB文件城后,Redis启动成功; AOF/RDB文件存在错误时,Redis启动失败并打印错误信息

六,LRU和LFU

6.1 LFU最近最少使用(统计维度)

LFU每一个数据块都有一个引用计数器,所有数据块按照引用计数排序,引用计数相同的则按照时间先后排序

img

  1. 新加入数据插入到队列尾部(因为引用计数为1);

  2. 队列中的数据被访问后,引用计数增加,队列重新排序;

  3. 当需要淘汰数据时,将已经排序的列表最后的数据块删除。

6.2LRU最近最久未使用(时间维度)

img

  1. 新数据插入到链表头部;

  2. 每当缓存命中(即缓存数据被访问),则将数据移到链表头部;

  3. 当链表满的时候,将链表尾部的数据丢弃。

redis采用的过期策略是:定期删除加惰性删除, 定期好理解,默认100ms就随机抽一些设置了过期时间的key,去检查是否过期,过期了就删了。

七,Redis的IO模型

7.1 IO多路复用

I/O多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作

  • (1)select==>时间复杂度O(n)

    它仅仅知道了,有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。所以select具有O(n)的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。

  • (2)poll==>时间复杂度O(n)

    poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的.

  • (3)epoll==>时间复杂度O(1)

    epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))

但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

7.2 redis中的IO模型

为什么redis是单线程,但是却能处理那么多并发的客户端连接呢?

  • 因为Redis采用了多路IO复用非阻塞IO技术,( 采用死循环方式轮询每一个流,如果有IO事件就处理,这样可以使得一个线程可以处理多个流,但是效率不高 ) 多路IO复用模型是利用select、poll、epoll可以同时监察多个流的IO事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有I/O事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。
  • 多路指的是多个网络连接,复用指的是复用同一个线程。
    采用多路IO复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗),且Redis在内存中操作数据的速度非常快(内存内的操作不会成为这里的性能瓶颈),主要以上两点造就了Redis具有很高的吞吐量。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章