Redis应用问题解决

一、缓存穿透

Redis 连接工具 windos redis 连接工具,带服务器穿透_数据


现象:

  • 传统用户访问服务器,首先会先利用服务器发送请求,服务器根据请求从 redis 缓存中取数据,如果redis中没有,就会向数据库要。
  • 缓存穿透 的意思就是在当应用服务器的压力急剧变大时,而这些请求需要的数据大部分在 redis 中都没有(即redis命中率降低),这时应用服务器的大部分请求会发向数据库。进而导致数据库的压力急剧增加
  • 缓存穿透的可能情况如下:
  • redis查询不到数据库
  • 出现了很多非正常的 url 访问。即查找一个数据库中都没有的id号。

处理方法

  • 1、对空值缓存:
    如果一个查询返回的数据为空(不管是数据是否不存在),我们仍然把这个空结果(null)进行缓存,设置空结果的过期时间会很短,最长不超过五分钟。这样可以避免非正常url的重复访问
  • 2、设置可访问的名单(白名单):
    使用bitmaps 类型定义一个可以访问的名单,名单id作为 bitmaps 的偏移量,每次访问和bitmap 里面的id进行比较,如果访问 id 不在 bitmaps 里面,进行拦截,不允许访问。直接过滤一些非法ip字段
  • 3、采用布隆过滤器:
  • 布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量(位图)和一系列随机映射函数(哈希函数)。
  • 布隆过滤器可以用于检索一个元素是否在一个集合中。
  • 它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。
  • 将所有可能存在的数据哈希到一个足够大的bitmaps中,一个一定不存在的数据会被 这个bitmaps拦截掉,从而避免了对底层存储系统的查询压力。
  • 4、进行实时监控:
    当发现Redis的命中率开始急速降低,需要排查访问对象和访问的数据,和运维人员配合,可以设置黑名单限制服务。 监控 redis命中率,设置黑名单

二、缓存击穿

Redis 连接工具 windos redis 连接工具,带服务器穿透_redis_02


特点:

  • 数据库访问压力瞬间增加
  • redis 里面并没有出现大量的过期 的key
  • redis正常运行

问题造成的原因:

  • redis 中的某个 key过期了,但是大量访问使用了这个key。
  • 这样的话,服务器为了访问这个key就会先数据库发送大量请求。

解决方法:

  • 1、预设热点数据:在redis高峰访问之前,把一些热门数据提前存入到redis里面,加大这些热门数据key的时长
  • 2、实时调整:现场监控哪些数据热门,实时调整 key 的过期时长
  • 3、使用锁:
  • 步骤如下:
  • 简单的说,就是如果redis‘查询结果为空,会一直查询,知道redis查询成功才会释放锁。(即只有当redis 同步了 数据库中的 key 的值,才会释放锁。让其他请求访问

三、缓存雪崩

  • 正常访问
  • Redis 连接工具 windos redis 连接工具,带服务器穿透_redis_03

  • 缓存失效瞬间
  • Redis 连接工具 windos redis 连接工具,带服务器穿透_Redis 连接工具 windos_04

特点:

  • 数据库的压力变大,导致服务器崩溃。
  • 极少时间段内,查询大量 key 集中过期。
  • 进而导致服务器的压力过大,进而挂了,进而redis也挂了,所有服务都挂了。进而发生了雪崩
    -== 缓存雪崩与缓存击穿的区别在于雪崩针对很多key缓存,击穿则是某一个key==

解决方式:

  • 1、构建多级缓存架构:nginx缓存 + redis缓存 +其他缓存(ehcache等)
  • 2、使用锁或队列:
    用加锁或者队列的方式来保证不会有大量的线程对数据库一次性进行读写,从而避免失效时大量的并发请求落到底层存储系统上。但是这种方法,不适用高并发情况。
  • 3、设置过期标志更新缓存:
    记录缓存数据是否过期(设置提前量),如果过期会触发通知另外的线程在后台去更新实际 key 的缓存。
  • 4、将缓存失效时间分散开:
    比如我们可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。

四、分布式锁

  • 单机部署时,我们可以很简单的加锁。
  • 多个服务器时,在A服务器上加的锁就无法在 B中获得。
  • 单纯的 java API 并不能提供分布式锁的能力。
  • 为了了解决这个问题,因此出现了分布式锁的概念。

分布式锁主流的实现方案:

  1. 基于数据库实现分布式锁
  2. 基于缓存(Redis等):该方式的性能和可靠性最高
  3. 基于Zookeeper
1、利用redis实现分布锁
  • 可以将分布式锁理解为共享锁,而共享数据正是 所有服务器中的value值。因此可以利用键值来创建锁。
  • 利用 setnx 以键值的方式上锁。利用 del,已删除相应键的方式释放锁。
  • setnx user 10 设置一个永不过期的user锁
  • ser user 10 nx ex 12: 原子性,设置一个还有12秒过期的锁user
  • 当然除了 setnx 之外,还有 set user px ex 12": 这表示 12毫秒之后锁过期。
  • 获取锁的流程图
2、利用 java 创建分布式锁
@GetMapping("testLock")
public void testLock(){
    //redisTemplate 为springboot 自动注入的 模板模式下的模板类
    //1获取锁,setne
    Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", "111",3,TimeUnit.SECONDES);//此时添加了过期时间3秒,自动释放
    //2获取锁成功、查询num的值
    if(lock){
        Object value = redisTemplate.opsForValue().get("num");
        //2.1判断num为空return
        if(StringUtils.isEmpty(value)){
            return;
        }
        //2.2有值就转成成int
        int num = Integer.parseInt(value+"");
        //2.3把redis的num加1
        redisTemplate.opsForValue().set("num", ++num);
        //2.4释放锁,del
        redisTemplate.delete("lock");

    }else{
        //3获取锁失败、每隔0.1秒再获取
        try {
            Thread.sleep(100);
            testLock();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
  • 压力测试结果正确:
3、优化:防止锁的误删

场景: 如果业务逻辑的执行时间是7s。执行流程如下

  1. index1 业务逻辑没执行完,3秒后锁被自动释放。(可能由于卡顿,但是操作未完,时长已完)
  2. index2获取到锁,执行业务逻辑,3秒后锁被自动释放。
  3. index3获取到锁,执行业务逻辑
  4. index1业务逻辑执行完成,开始调用del释放锁,这时释放的是index3的锁,导致index3的业务只执行1s就被别人释放。

解决方案

  • setnx获取锁时,设置一个指定的唯一值(例如:uuid);释放前获取这个值,判断是否自己的锁。
  • 简单的说,就是将我们设置的锁的value,设置以为一个该线程独有的 uiid:
4、优化:原子性问题:

场景:

  • 假设存在 a ,b 两个操作。
  • 当 A 操作在运行时,A 操作首先上锁,然后进行具体操作,最后进行释放锁。在释放锁时,首先会判断 UID 是否为 A 操作的,发现一样。程序向下执行。此时 A 准备开始删除手上的锁。这时突然锁的过期时间到了,锁进行了自动的释放
  • 此时 线程 B 看到锁已经是否,就开始加自己的锁,然后进行具体操作。如果此时线程A又开始继续自己的程序,那么线程a就会释放锁,而此时释放的锁其实为 b 的锁
  • 综上: 由于判断 uid 和删除锁的两个操作不具备原子性,当锁在判断后删除前过期,其他操作就会拿到锁。而如果此时操作A抢占到CPU 开始执行,那么此时A就会释放B的锁。因为uiid的判断已经进行了。
  • 如下图所示:

解决方法:利用LUA 脚本

  • 由于 LUA 脚本,具有原子性,那么我们可以将判断+删除 这一部分代码写成 lua 脚本:
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
  • 重写我们的 testLockLua,利用 lua 保证我们的删除锁的原子性:
@GetMapping("testLockLua")
public void testLockLua() {
    //1 声明一个uuid ,将做为一个value 放入我们的key所对应的值中
    String uuid = UUID.randomUUID().toString();
    //2 定义一个锁:lua 脚本可以使用同一把锁,来实现删除!
    String skuId = "25"; // 访问skuId 为25号的商品 100008348542
    String locKey = "lock:" + skuId; // 锁住的是每个商品的数据

    // 3 获取锁
    Boolean lock = redisTemplate.opsForValue().setIfAbsent(locKey, uuid, 3, TimeUnit.SECONDS);

    // 第一种: lock 与过期时间中间不写任何的代码。
    // redisTemplate.expire("lock",10, TimeUnit.SECONDS);//设置过期时间
    // 如果true
    if (lock) {
        // 执行的业务逻辑开始
        // 获取缓存中的num 数据
        Object value = redisTemplate.opsForValue().get("num");
        // 如果是空直接返回
        if (StringUtils.isEmpty(value)) {
            return;
        }
        // 不是空 如果说在这出现了异常! 那么delete 就删除失败! 也就是说锁永远存在!
        int num = Integer.parseInt(value + "");
        // 使num 每次+1 放入缓存
        redisTemplate.opsForValue().set("num", String.valueOf(++num));
        /*使用lua脚本来锁*/
        // 定义lua 脚本
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        // 使用redis执行lua执行
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptText(script);
        // 设置一下返回值类型 为Long
        // 因为删除判断的时候,返回的0,给其封装为数据类型。如果不封装那么默认返回String 类型,
        // 那么返回字符串与0 会有发生错误。
        redisScript.setResultType(Long.class);
        // 第一个要是script 脚本 ,第二个需要判断的key,第三个就是key所对应的值。
        redisTemplate.execute(redisScript, Arrays.asList(locKey), uuid);
    } else {
        // 其他线程等待
        try {
            // 睡眠
            Thread.sleep(1000);
            // 睡醒了之后,调用方法。
            testLockLua();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
  • 上面代码中 execute 的参数对应如下:
  • Redis 连接工具 windos redis 连接工具,带服务器穿透_数据库_05

5、总结:分布锁的添加与释放最终版
  • 1、加锁
// 1. 从redis中获取锁,set k1 v1 px 20000 nx
String uuid = UUID.randomUUID().toString();
Boolean lock = this.redisTemplate.opsForValue()
      .setIfAbsent("lock", uuid, 2, TimeUnit.SECONDS);
  • 2、使用 lua 释放锁
// 2. 释放锁 del
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
// 设置lua脚本返回的数据类型
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
// 设置lua脚本返回类型为Long
redisScript.setResultType(Long.class);
redisScript.setScriptText(script);
redisTemplate.execute(redisScript, Arrays.asList("lock"),uuid)
  • 3、重试
Thread.sleep(500);
testLock();

上面这些操作很好的保证了分布式锁的四大条件:

  • 互斥性。在任意时刻,只有一个客户端能持有锁。 互斥性
  • 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。独立性
  • 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。隔离性
  • 加锁和解锁必须具有原子性。原子性

Redis6 的新功能

ACL

参考官网:https://redis.io/topics/acl

  • 在Redis 5版本之前,Redis 安全规则只有密码控制 还有通过rename 来调整高危命令比如 flushdb , KEYS* , shutdown 等。
  • Redis 6 则提供ACL的功能对用户进行更细粒度的权限控制
    (1)接入权限:用户名和密码
    (2)可以执行的命令
    (3)可以操作的 KEY

命令

  • acl list 命令展现用户权限列表
  • Redis 连接工具 windos redis 连接工具,带服务器穿透_redis_06

  • acl cat:查看添加权限指令类别即当前用户能够操作的数据类型
  • Redis 连接工具 windos redis 连接工具,带服务器穿透_数据库_07

  • acl cat:加参数类型名可以查看类型下具体命令:即当前用户针对特定类型能够进行操作的命令集合
  • Redis 连接工具 windos redis 连接工具,带服务器穿透_缓存_08

  • acl whoami 命令查看当前用户
  • Redis 连接工具 windos redis 连接工具,带服务器穿透_数据库_09

  • 使用 acl setuser命令创建和编辑用户ACL
  • 1、通过命令创建新用户默认权限:acl setuser user1
  • Redis 连接工具 windos redis 连接工具,带服务器穿透_缓存_10

  • 在上面的示例中,我根本没有指定任何规则。如果用户不存在,这将使用just created的默认属性来创建用户。如果用户已经存在,则上面的命令将不执行任何操作
  • 2、设置有用户名、密码、ACL权限、并启用的用户:acl setuser user2 on >password ~cached:* +get
  • on: 启用为当前用户
  • >password :表示密码为 password
  • ~cached:* +get : 表示该用户只能进行 以 chached: 开头的 get 的操作。
  • 3、切换用户,验证上面的登录权限:
  • Redis 连接工具 windos redis 连接工具,带服务器穿透_数据库_11

IO多线程

IO多线程 其实指客户端交互部分的网络 IO 交互处理模块多线程,而非执行命令多线程。Redis6执行命令依然是单线程+多路复用。

多线程IO默认也是不开启的,需要再配置文件中配置

  • io-threads-do-reads yes
  • io-threads 4

工具支持 Cluster

之前老版Redis想要搭集群需要单独安装ruby环境Redis 5 将 redis-trib.rb 的功能集成到 redis-cli 。另外官方 redis-benchmark 工具开始支持 cluster 模式了,通过多线程的方式对多个分片进行压测。

Redis总结 思维导图

Redis 连接工具 windos redis 连接工具,带服务器穿透_数据_12