五、缓存三大问题(缓存雪崩、缓存穿透、缓存击穿)

本文分别介绍了不使用缓存、使用缓存的方法。 使用缓存带来的三大问题(缓存雪崩、缓存穿透、缓存击穿)及解决办法。同时为了使代码优化优雅,使用模板泛型Template,将缓存的通用公共部分抽象为模板方法,既CacheTemplate。由于是使用的Redis,所以为RedisCacheTemplate

1 没有缓存的情况

1.1 数据库情况:

在MySQL中有一张表

mysql> desc person;
+----------+-------------+------+-----+---------+-------+
| Field    | Type        | Null | Key | Default | Extra |
+----------+-------------+------+-----+---------+-------+
| name     | varchar(32) | YES  |     | NULL    |       |
| brithday | date        | YES  | MUL | NULL    |       |
+----------+-------------+------+-----+---------+-------+
2 rows in set (0.00 sec)

mysql> 
mysql> 
mysql> select * from person;
+-----------+------------+
| name      | brithday   |
+-----------+------------+
| Zhansan   | 1966-12-01 |
| Zhansan02 | 1976-12-01 |
| Lisi      | 1980-12-01 |
+-----------+------------+
3 rows in set (0.00 sec)

1.2 新建Spring Boot工程

使用Spring Boot Initialize (https://start.spring.io), 新建一个全新工程。 这里使用了Idea IDE,使用Eclipse类似。

新建工程时,勾选MybatisMySQLDruid等。 为了简便,我们还引入了lombok, 以直接使用@Data注解避免了人工编写大量的getter/setter和toString等方法。 为了测试方便,还选择了web

全新工程生成完毕后,可以看到pom.xml文件中有以下内容:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.0.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>org.zyp</groupId>
    <artifactId>redistemplate</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>cachetemplate</name>
    <description>Demo project for Spring Boot</description>

    <properties>
        <java.version>1.8</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.1.2</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid</artifactId>
            <version>1.1.22</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.vintage</groupId>
                    <artifactId>junit-vintage-engine</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
        <resources>
            <resource>
                <!-- 存放资源的目录(路径相对pom.xml的相对路径)。 这样maven在build时会自动将这些资源也拷贝到target对应的目录下 -->
                <directory>src/main/java</directory>
                <includes>
                    <include>**/*.xml</include>
                </includes>
            </resource>
        </resources>
    </build>
</project>

注意:在pom.xml 文件中增加了resources的内容。

因为Maven如果没有进行特殊的配置,Maven会按照标准的目录结构查找和处理各种类型文件:src/main/java和src/test/java 这两个目录中的所有*.java文件会分别在comile和test-comiple阶段被编译,并将编译结果.class文件分别放到了target/classestarge/test-classes目录中,但是这两个目录中的其他文件都会被忽略掉。
因此需要在pom.xml文件中指定resource,告诉Maven指定的资源也需要从src/main下复制到target/classestarge/test-classes目录中。

修改pom.xml后,记得使其生效(在Idea中,右键pom.xml文件→Maven→reimport)

默认的application.properties(内容默认为空),将文件名改为application.yml。然后在其中增加以下配置

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://192.168.43.201:3306/dbtest
    username: root
    password: Pwd_1234
    type: com.alibaba.druid.pool.DruidDataSource
    initialSize: 5
    minIdle: 5
    maxActive: 20
    maxWait: 60000
mybatis:
  configuration:
    cache-enabled: false
  # 扫描Mapper接口对应的xml文件。
  mapper-locations: classpath:org/zyp/dao/*Mapper.xml
  # 类型别名,这样在*Mapper.xml文件中,可以不用写类全名。
  type-aliases-package: org.zyp.entity

  # 为了观察Mybatis的 SQL语句情况,开启对应dao层的debug日志。
logging:
  level:
    org:
      zyp:
        dao: debug

1.3 编写Java代码

工程结构为:

∟src

____∟main

_______∟java

____________∟org.zyp

________________∟cachetemplate

____________________∟CacheTemplateApplication Spring Boot 启动类

________________∟controller

____________________∟HelloController 用于测试的Controller

________________∟dao

____________________∟PersonMapper Dao层接口(无需实现类,实现类由Mybatis自动完成)

____________________∟PersonMapper.xml Mybatis映射配置xml文件

________________∟entity

____________________∟Person Entity POJO对象

_______∟resources

_____________∟application.yml Spring Boot 配置yml文件

entity层代码:

package org.zyp.entity;
import lombok.Data;
import java.util.Date;
@Data
public class Person {
    private String name;
    private Date brithday;
}

dao层代码:

package org.zyp.dao;
import org.zyp.entity.Person;
import java.util.List;

public interface PersonMapper {
    public Person selectByName(String name);
    public List<Person> findAll();
}

PersonMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="org.zyp.dao.PersonMapper">
    <select id="selectByName" resultType="Person">
        SELECT * FROM Person where name = #{name};
    </select>
    <select id="findAll" resultType="Person">
        SELECT * FROM Person;
    </select>
</mapper>

Spring Boot启动类中,增加两个Scan注解。

package org.zyp.cachetemplate;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;

@SpringBootApplication
@MapperScan("org.zyp.dao")   //使用MapperScan批量扫描所有的Dao层接口,并自动生成实现类。
@ComponentScan("org.zyp") // 指定要扫描的Component的base package
public class CacheTemplateApplication {
    public static void main(String[] args) {
        SpringApplication.run(CacheTemplateApplication.class, args);
    }
}

用于测试的HelloControlller

package org.zyp.controller;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import org.zyp.dao.PersonMapper;
import org.zyp.entity.Person;

import java.util.List;

@RestController
public class HelloController {
    Logger logger = LoggerFactory.getLogger(HelloController.class);

    @Autowired
    private PersonMapper personMapper;  // 由Spring自动注入

    @RequestMapping(value = "/getOnePerson/{name}", method = RequestMethod.GET)
    public Person getOnePerson(@PathVariable("name") String name) {
        logger.info("This is getOnePerson has been called......");
        return personMapper.selectByName(name);
    }
}

1.4 运行结果

在浏览器中(多次)访问:http://localhost:8080/getOnePerson/Zhansan

运行结果如下:
在这里插入图片描述

可以看到:每访问一次,都会对应一次数据库访问。

这就是通常直接访问数据库的方法(没有使用Redis缓存)。

2 使用Redis缓存

2.1 启动Redis Server

$ redis-server redis.conf 

启动输出如下(端口为6379):

89667:C 08 Jul 2020 00:16:41.940 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
89667:C 08 Jul 2020 00:16:41.940 # Redis version=5.0.8, bits=64, commit=00000000, modified=0, pid=89667, just started
89667:C 08 Jul 2020 00:16:41.940 # Configuration loaded
                _._                                                  
           _.-``__ ''-._                                             
      _.-``    `.  `_.  ''-._           Redis 5.0.8 (00000000/0) 64 bit
  .-`` .-```.  ```\/    _.,_ ''-._                                   
 (    '      ,       .-`  | `,    )     Running in standalone mode
 |`-._`-...-` __...-.``-._|'` _.-'|     Port: 6379
 |    `-._   `._    /     _.-'    |     PID: 89667
  `-._    `-._  `-./  _.-'    _.-'                                   
 |`-._`-._    `-.__.-'    _.-'_.-'|                                  
 |    `-._`-._        _.-'_.-'    |           http://redis.io        
  `-._    `-._`-.__.-'_.-'    _.-'                                   
 |`-._`-._    `-.__.-'    _.-'_.-'|                                  
 |    `-._`-._        _.-'_.-'    |                                  
  `-._    `-._`-.__.-'_.-'    _.-'                                   
      `-._    `-.__.-'    _.-'                                       
          `-._        _.-'                                           
              `-.__.-'                                               

2.2 工程增加Redis依赖和配置

思路:为了把从数据库查询的结果(Java中的Person对象),以DB表名:行主键作为Redis的key,以对象(Java中的Person对象)进行JSON序列化后的字符串作为Redis的value。 JSON序列化工具使用spring boot已经自带的Jackson。

pom.xml中增加以下依赖,以便使用Redis:

(... 省略....)
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
(... 省略....)

修改pom.xml后,记得使其生效(在Idea中,右键pom.xml文件→Maven→reimport)

application.yml中增加:连接Redis的配置和jackson的JSON序列化属性。如下:

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://192.168.43.201:3306/dbtest
    username: root
    password: Pwd_1234
    type: com.alibaba.druid.pool.DruidDataSource
    initialSize: 5
    minIdle: 5
    maxActive: 20
    maxWait: 60000
  # 配置连接Redis服务器的属性
  redis:
    host: 192.168.43.201
    port: 6379
  # 配置Jackson的JSON序列化属性
  jackson:
    time-zone: GMT+8
    date-format: yyyyMMdd HH:mm:ss

mybatis:
  configuration:
    cache-enabled: false
  # 扫描Mapper接口对应的xml文件。
  mapper-locations: classpath:org/zyp/dao/*Mapper.xml
  # 类型别名,这样在*Mapper.xml文件中,可以不用写类全名。
  type-aliases-package: org.zyp.entity

  # 为了观察Mybatis的 SQL语句情况,开启对应dao层的debug日志。
logging:
  level:
    org:
      zyp:
        dao: debug

2.2 修改HelloControlller

修改HelloControlller以使用Redis

package org.zyp.controller;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import org.zyp.dao.PersonMapper;
import org.zyp.entity.Person;

@RestController
public class HelloController {
    Logger logger = LoggerFactory.getLogger(HelloController.class);

    @Autowired
    private PersonMapper personMapper;  // 由Spring自动注入

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private ObjectMapper jacksonMapper;

    @RequestMapping(value = "/getOnePerson/{name}", method = RequestMethod.GET)
    public Person getOnePerson(@PathVariable("name") String name) throws JsonProcessingException, InterruptedException {
        logger.info("This is getOnePerson has been called......");
        String rediskey = "person:" + name;  // 以`DB表名:行主键`作为Redis的key

        // 先查询缓存
        String obj_json = stringRedisTemplate.opsForValue().get(rediskey); // 先查询缓存
        if(null != obj_json ) {
            logger.info("OK,已命中Redis缓存!");
            return jacksonMapper.readValue(obj_json, Person.class);
        }

        // 若未命中Redis缓存,则从数据库查询
        obj_json = stringRedisTemplate.opsForValue().get(rediskey); // 获锁后,再次查询缓存。 之所以在这里再次查询缓存是因为,在等待锁期间缓存很可能已经(由其他线程)创建完成
        if(null != obj_json) {
            return jacksonMapper.readValue(obj_json, Person.class); // 缓存命中直接返回
        }

        Person result_person = personMapper.selectByName(name);
        if(null != result_person) { // 将从数据库查询得到的结果放入Redis缓存
            stringRedisTemplate.opsForValue().set(rediskey, jacksonMapper.writeValueAsString(result_person));
        }
        return result_person;
    }
}

2.3 运行结果

在浏览器中(多次)访问:http://localhost:8080/getOnePerson/Zhansan

运行结果如下:
在这里插入图片描述

可以看到:使用缓存后,仅第一次真正访问了数据库,后面直接使用了Redis缓存。

3 缓存问题一:雪崩

正常情况,缓存承担着大量的请求,有效的保护了存储层。但是如果缓存层由于某种原因不能提供服务(宕机或者缓存集中过期),所有的请求都会转达到存储层。存储层的访问量会暴增,造成存储层宕机。这就是缓存雪崩(stamppeding herd奔逸的野牛),指缓存成宕机后。流量会像奔跑的野牛一样打向后端存储。

解决办法:

  • 保证缓存层高可用。例如使用Redis Cluster机制
  • 错开缓存失效时间。 缓存失效时间增加随机数,避免因大量缓存集中过期而使得缓存集中重建。
  • 提前演练。一方面是演练使用缓存的代码逻辑有误bug,另一方面验证即使在缓存宕机后,后端数据库层可承受的压力。

4 缓存问题一:穿透(不存在穿透)

缓存穿透是指查询一个根本不存在的值(数据库中和缓存中都没有)。由于根本不存在这个此,无法命中数据库,也无法形成缓存。

由于无法形成缓存,所有的查询压力全部要到数据库去执行。如果大量的请求都是这种缓存穿透,则可能导致数据库极度繁忙甚至崩溃。

产生这种情况的原因有二:

  • 自身业务代码或者数据存在bug,导致产生无效请求,或者无法校验无效请求
  • 受到了恶意攻击。

例如,保持上面的代码不变。

在浏览器中(多次)访问:http://localhost:8080/getOnePerson/XXXX (XXXX是一个在数据库中不存在的值)

会发现:每次访问都会查询数据库。

解决缓存穿透的办法主要由两个:

  • 缓存空对象
  • Bloom过滤器

4.1 解决方案A:缓存空值

修改HelloControlller.java中的getOnePerson()方法,已缓存空对象

    @RequestMapping(value = "/getOnePerson/{name}", method = RequestMethod.GET)
    public Person getOnePerson(@PathVariable("name") String name) throws JsonProcessingException, InterruptedException {
        logger.info("This is getOnePerson has been called......");
        String rediskey = "person:" + name;  // 以`DB表名:行主键`作为Redis的key

        // 先查询缓存
        String obj_json = stringRedisTemplate.opsForValue().get(rediskey); // 先查询缓存
        if(null != obj_json ) {
            logger.info("OK,已命中Redis缓存!");
            if(! "NUL_OBJ".equalsIgnoreCase(obj_json)) {
                return jacksonMapper.readValue(obj_json, Person.class);
            }
        }

        // 若未命中Redis缓存,则从数据库查询
        obj_json = stringRedisTemplate.opsForValue().get(rediskey); // 获锁后,再次查询缓存。 之所以在这里再次查询缓存是因为,在等待锁期间缓存很可能已经(由其他线程)创建完成
        if(null != obj_json) {
            return jacksonMapper.readValue(obj_json, Person.class); // 缓存命中直接返回
        }

        Person result_person = personMapper.selectByName(name);
        if(null != result_person) { // 将从数据库查询得到的结果放入Redis缓存
            stringRedisTemplate.opsForValue().set(rediskey, jacksonMapper.writeValueAsString(result_person));
        }else { // 从数据库查询得到的结果为空,则缓存空对象(有过期时间)
            stringRedisTemplate.opsForValue().set(rediskey, "NUL_OBJ", 60, TimeUnit.SECONDS);
        }
        return result_person;
    }

在浏览器中,多次访问不存在的对象,第一次会查询数据库,后面则不再访问数据库,减少了数据库的压力。

但是缓存空对象也存在不足:

  • 以空值做了缓存。如果存在大量的不存在的访问,则在Redis中存在大量的key的value都是空值,意味着Redis需要消耗较多的空间来存放这些空值对象。如果面临攻击,严重时可能导致Redis内存耗光。
  • 缓存层与DB存储层之间会存在一段时间窗口不一致,可能对业务存在负面影响。例如上面代码中设置的key过期时间为60秒,如果过几秒钟后DB存储层增加了数据,既DB中原本不存在的数据现在变为已经存在了。 这时查询访问,由于仍走缓存,仍读出为空值。 在key过期前,缓存层与DB存储层都会不一致。 (这种情况可以通过某种方式, 在DB数据发生改变时,删除key或者重新set key的值)

4.2 解决方案B:Bloom过滤器

如果想判断一个元素是不是在一个集合里,一般想到的是将集合中所有元素保存起来,然后通过比较确定。 但这种比较耗费空间

布隆过滤器(英语:Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。

4.2.1 Bloom原理及特点

如下图,中间是一个BitMap位图。BitMap中初始的默认值为全0.

  • 放入操作。图中上部的a、b、c为三个元素。图中演示了使用3个不同hash函数,分别对每个元素进行运算。既同一个元素得到三个hash结果(例如元素a对应的三根红线),将hash结果对应位置的bit置为1(无论原来是0还还是1)
  • 判断存在性。图中下部元素d、e为即将判断该元素在BitMap是否已存在。与使用3个不同hash函数(与放入操作相同),对元素进行运算得到三个hash结果,如果所有结果对应位置的bit值全部都是1,则视为此元素在BitMap中已经存在(视为存在:真实情况为可能存在,也可能不存在),例如d。如果所有结果对应位置有任何一个的bit值不是1,则此元素一定不存在,例如e。

img

Bloom过滤器原理缺点

  • 可能将实际不存在的,误判已存在。例如以上图为例,d就是误判为已存在。实际上只存在a、b、c,并不存在d。
  • 无法删除。例如希望删除元素a,元素占用的三个bit位,可能有别的元素占用,所以无法将bit值改为0。因此无法删除。

从Bloom过滤器分析,存在性误判的概率高低取决:

  • BitMap的容量。容量越大,hash结果越容易分散,越不容易误判。
  • hash函数的个数。hash函数的个数越多,每个元素hash结果占用的bit也越多,(在BitMap容量较大时),越不容易误判。

实际上:还取决于预计要放入元素的数量,如果放入的数量越少,越不容易误判。

既:误判概率、预计放入元素数量、BitMap的容量、hash函数的个数四方面是相互影响的。

目前Bloom过滤器的实现算法中,一般通过显示指定:预估放入元素数量n、和期望的误判率fpp。算法自动调节BitMap的容量和hash函数的个数以满足预设的指定。

4.2.2 Bloom过滤器的使用

布隆过滤器有许多实现与优化,Guava中就提供了一种Bloom Filter的实现。 https://github.com/google/guava/tree/master/guava。

pom.xml中增加以下依赖,以便使用guava:

(... 省略....)
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>29.0-jre</version>
        </dependency>
(... 省略....)

修改pom.xml后,记得使其生效(在Idea中,右键pom.xml文件→Maven→reimport)

先简单试用Bloom过滤器 新建一个测试类TestBloom,代码如下:

package org.zyp.cachetemplate;

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import java.nio.charset.Charset;

public class TestBloom {
    public static void main(String[] args) {
        /* 第一个入参:Funnels 提供如何把一个具体的对象类型转化为Java基本数据类型
           第二个入参:expectedInsertions 预估要插入数据量
           第三个入参:预期可接受的误判率,必须大于0(不能等于0)
         */
        BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.forName("utf-8")), 100_000, 0.05);
        for (int i = 0; i < 100_000; i++) {
            bloomFilter.put("element"+i ); // 循环放入不同的元素
        }

        int exist_count = 0 ; // 记录 bloom过滤器认为存在的数量
        for (int j = 0; j <= 9999 ; j++) {
            if (bloomFilter.mightContain("XXXXXXXX" + j)) {  // 故意从Bloom过滤器取1万个根本不存在的元素
                exist_count++;
            }
        }

        System.out.println("误判(Bloom认为存在的)的数量:" + exist_count);
    }
}

多次运行上面的程序,可以看到误判数量大约在500(≈100000*0.05)上下变化。

4.2.3 分析Guava Bloom实现

Guava Bloom的缺点

上面展示了Google Guava Bloom分布式的使用,该实现有缺点:

  • 没有持久化,重启既失效。
  • 数据内部是bitarray,是放在JVM内存中,不支持分布式。
  • 老板的Guava Bloom使用bit数组实现,数组元素最大个数局限于int的最大值,既最多只能存放2亿多个元素(新版Guava Bloom已经改进此问题)

对此,我们参考其算法,基于Redis改造为分布式Bloom过滤器。既使用Redis的String(内部为bit)存放bitmap,使用bitset、bitget位操作Redis的位。

Redis分布式过滤器也有缺点:

  • 需要网络IO,适合局域网等网络比较好的情况。
  • 由于Redis String最大为512MB,既最大支持4亿bit位(=4 * 1024 * 1024 * 1024 bit)

分析参考Guava Bloom过滤器源代码

Guava中,布隆过滤器的实现主要涉及一个类、一个枚举类型:

  • com.google.common.hash.BloomFilter。该类定义了Strategy接口,并定义了四个变量。
  • com.google.common.hash.BloomFilterStrategies 。该枚举类型实现上面的Strategy接口。

4.2.4 利用Redis构建分布式Bloom过滤器

通过上面的分析,主要算法和逻辑的部分大体都是一样的,真正需要重构的部分是底层位数组的实现,在Guava中是封装了一个long型的数组,而对于redis来说,本身自带了Bitmaps的“数据结构”(本质上还是一个字符串),已经提供了位操作的接口,因此重构本身并不复杂。
在这里插入图片描述
下面是代码的实现。

准备工作。在pom.xml中增加以下依赖

(... 省略....)
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
        </dependency>
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>29.0-jre</version>
        </dependency>
(... 省略....)

修改pom.xml后,记得使其生效(在Idea中,右键pom.xml文件→Maven→reimport)

新增RedisBloomFilter里面的一些算法参考借鉴了guava

package org.zyp.bloomfilter;

import com.google.common.base.Charsets;
import com.google.common.hash.Hashing;
import com.google.common.primitives.Longs;
import lombok.Data;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.List;

@ConfigurationProperties("bloom.filter")   // 通过application.yml自动装配属性值
@Component
@Data
public class RedisBloomFilter {
    /** 本Bloom过滤器的名字 */
    private String bloomFilterName;

    /** 预估要插入数据量(必须为正数)*/
    private long expectedInsertions;

    /** 预期可接受的误判率,必须大于0(不能等于0) */
    private double fpp;

    /** Redis中bit位的总数量(bit数组长度) */
    private long bitSize  = 0;

    /** 每个元素进行执行哈希的函数个数 */
    private int numHashFunctions;

    @Autowired
    RedisTemplate redisTemplate;

    @PostConstruct
    public void init() {
        this.bitSize  = optimalNumOfBits(expectedInsertions, fpp);
        this.numHashFunctions = optimalNumOfHashFunctions(expectedInsertions, this.bitSize );
    }

    /**
     * 将元素放入Bloom过滤器

     */
    public void put(String element) {
        // 先计算Hash(多个函数)获得多个坐标
        long[] bitIndexes = getBitIndexes(element);

        // 然后将结果放入到Redis中(使用pipeline方式,多次bit操作合并一次完成,提升效率)
        redisTemplate.executePipelined(new RedisCallback<Object>() {
                                           @Override
                                           @Nullable
                                           public Object doInRedis(RedisConnection redisConnection) throws DataAccessException {
                                               for (long one_bitIndex : bitIndexes) {
                                                   redisConnection.setBit(bloomFilterName.getBytes(), one_bitIndex, true);
                                               }
                                               redisConnection.close();
                                               return null;
                                           }
                                       }
        );
    }

    /**
     * 判断元素是否在Bloom过滤器中已存在
     * @return true=存在,  false=不存在。
     */
    public boolean mightContain(String element) {
        // 先计算Hash(多个函数)获得多个坐标
        long[] bitIndexes = getBitIndexes(element);

        // 然后将结果放入到Redis中(使用pipeline方式,多次bit操作合并一次完成,提升效率)
        List list = redisTemplate.executePipelined(new RedisCallback<Object>() {
                                           @Override
                                           @Nullable
                                           public Object doInRedis(RedisConnection redisConnection) throws DataAccessException {
                                               for (long one_bitIndex : bitIndexes) {
                                                   redisConnection.getBit(bloomFilterName.getBytes(), one_bitIndex);
                                               }
                                               redisConnection.close();
                                               return null;
                                           }
                                       }

        );

        return !list.contains(false); // 如有包含(一个或多个)fasle,则一定不存在。 否则结果视为存在
    }

    /**
     * 根据元素计算Hash(多个Hash函数),每个Hash算出bit数组中的坐标(多个)
     * 本函数代码实现 参考自com.google.common.hash.BloomFilterStrategies代码中put方法,有改动
     * This strategy uses all 128 bits of {@link Hashing#murmur3_128} when hashing. It looks different
     * than the implementation in MURMUR128_MITZ_32 because we're avoiding the multiplication in the
     * loop and doing a (much simpler) += hash2. We're also changing the index to a positive number by
     * AND'ing with Long.MAX_VALUE instead of flipping the bits.
     * @param element 元素内容
     * @return 返回存放坐标的数组
     */
    private long [] getBitIndexes(String element) {
        // 第1步初步得到Hash
        byte[] bytes = Hashing.murmur3_128().hashString(element, Charsets.UTF_8).asBytes();  // 得到128bit(16字节)的结果
        long hash1 = Longs.fromBytes(bytes[7], bytes[6], bytes[5], bytes[4], bytes[3], bytes[2], bytes[1], bytes[0]); // lowerEight 8字节
        long hash2 = Longs.fromBytes(bytes[15], bytes[14], bytes[13], bytes[12], bytes[11], bytes[10], bytes[9], bytes[8]); // upperEight 8字节
        String aa = this.bloomFilterName;

        // 第2步 根据Hash函数的个数,分别计算。对同一个元素得到不同的hash结果(bit位图中的坐标)
        long[] bitIndexes = new long[this.numHashFunctions]; // 存放坐标的数组
        long combinedHash = hash1;
        for (int i = 0; i < numHashFunctions; i++) {
            // Make the combined hash positive and indexable  通过Long.MAX_VALUE(二进制为0111…1111),直接将开头的符号位去掉,从而转变为正数。
            bitIndexes[i] = (combinedHash & Long.MAX_VALUE) % bitSize;
            combinedHash += hash2;
        }
        return bitIndexes;

    }


    /**
     * 本函数代码实现 拷贝自com.google.common.hash.BloomFilter代码中optimalNumOfHashFunctions方法
     * Computes the optimal k (number of hashes per element inserted in Bloom filter), given the
     * expected insertions and total number of bits in the Bloom filter.
     *
     * <p>See http://en.wikipedia.org/wiki/File:Bloom_filter_fp_probability.svg for the formula.
     *
     * @param expectedInsertions 预估要插入数据量 expected insertions(must be positive)
     * @param numBits bitmap长度  total number of bits in Bloom filter (must be positive)
     * @return 根据入参,估算出需要的hash函数个数。
     */
    private int optimalNumOfHashFunctions(long expectedInsertions, long numBits) {
        // (numBits / expectedInsertions) * log(2), but avoid truncation due to division!
        return Math.max(1, (int) Math.round((double) numBits / expectedInsertions * Math.log(2)));
    }

    /**
     * 本函数代码实现 拷贝自com.google.common.hash.BloomFilter代码中optimalNumOfBits方法
     * Computes m (total bits of Bloom filter) which is expected to achieve, for the specified
     * expected insertions, the required false positive probability.
     *
     * <p>See http://en.wikipedia.org/wiki/Bloom_filter#Probability_of_false_positives for the
     * formula.
     * @param expectedInsertions 预估要插入数据量 expected insertions(must be positive)
     * @param fpp 预期可接受的误判率,false positive rate (must be 0 < p < 1)
     * @return 根据入参,估算出需要的bitmap长度
     */
    private long optimalNumOfBits(long expectedInsertions, double fpp) {
        if (fpp == 0) {
            fpp = Double.MIN_VALUE;
        }
        return (long) (-expectedInsertions * Math.log(fpp) / (Math.log(2) * Math.log(2)));
    }

}

增加一个BloomFilterInitData用于全量初始化加载数据至Bloom过滤器

package org.zyp.bloomfilter;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.zyp.dao.PersonMapper;
import org.zyp.entity.Person;
import javax.annotation.PostConstruct;
import java.util.List;

@Component
public class BloomFilterInitData {
    @Autowired
    PersonMapper personMapper;

    @Autowired
    RedisBloomFilter redisBloomFilter;

    @PostConstruct
    public void initdata() {
        List<Person> persons = personMapper.findAll();
        for(Person person : persons) {
            redisBloomFilter.put("person:" + person.getName());
        }
    }
}

4.2.5 使用Redis分布式Bloom过滤器

application.yml增加bloom.filter的配置。结果如下

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://192.168.43.201:3306/dbtest
    username: root
    password: Pwd_1234
    type: com.alibaba.druid.pool.DruidDataSource
    initialSize: 5
    minIdle: 5
    maxActive: 20
    maxWait: 60000
  # 配置连接Redis服务器的属性
  redis:
    host: 192.168.43.201
    port: 6379
  # 配置Jackson的JSON序列化属性
  jackson:
    time-zone: GMT+8
    date-format: yyyyMMdd HH:mm:ss

mybatis:
  configuration:
    cache-enabled: false
  # 扫描Mapper接口对应的xml文件。
  mapper-locations: classpath:org/zyp/dao/*Mapper.xml
  # 类型别名,这样在*Mapper.xml文件中,可以不用写类全名。
  type-aliases-package: org.zyp.entity

  # 为了观察Mybatis的 SQL语句情况,开启对应dao层的debug日志。
logging:
  level:
    org:
      zyp:
        dao: debug
bloom:
  filter:
    bloomFilterName: RedisBloomFilter
    expectedInsertions: 100000
    fpp: 0.01F

使用Redis分布式,修改HelloController以使用Bloom过滤器。

@RestController
public class HelloController {
    Logger logger = LoggerFactory.getLogger(HelloController.class);

    @Autowired
    private PersonMapper personMapper;  // 由Spring自动注入

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private ObjectMapper jacksonMapper;

    @Autowired
    RedisBloomFilter redisBloomFilter;

    @RequestMapping(value = "/getOnePerson/{name}", method = RequestMethod.GET)
    public Person getOnePerson(@PathVariable("name") String name) throws JsonProcessingException, InterruptedException {
  
        logger.info("This is getOnePerson has been called......");
        String rediskey = "person:" + name;  // 以`DB表名:行主键`作为Redis的key

        boolean isExistBloom = redisBloomFilter.mightContain(rediskey); // 先查询在Bloom过滤器是否存在
        logger.info( "元素[" +rediskey+"]在Bloom过滤器中是否存在:" +isExistBloom);
        if(!isExistBloom) {
            return null;
        }

        // 先查询缓存
        String obj_json = stringRedisTemplate.opsForValue().get(rediskey); // 先查询缓存
        if(null != obj_json) {
            logger.info("OK,已命中Redis缓存!");
            return jacksonMapper.readValue(obj_json, Person.class); // 缓存命中直接返回
        }

        // 若未命中Redis缓存,则从数据库查询
        obj_json = stringRedisTemplate.opsForValue().get(rediskey); // 获锁后,再次查询缓存。 之所以在这里再次查询缓存是因为,在等待锁期间缓存很可能已经(由其他线程)创建完成
        if(null != obj_json) {
            return jacksonMapper.readValue(obj_json, Person.class); // 缓存命中直接返回
        }

        Person result_person = personMapper.selectByName(name);
        if(null != result_person) { // 将从数据库查询得到的结果放入Redis缓存
            stringRedisTemplate.opsForValue().set(rediskey, jacksonMapper.writeValueAsString(result_person));
        }
        return result_person;
    }
}

4.3 两种解决方案总结

使用Bloom过滤器实现,在进行访问前对请求的过滤。如果Bloom过滤器存在,才进行后续访问(从缓存或者从数据读取),如果Bloom过滤器不存在,则直接返回,避免压力向后传递。

Bloom过滤器适合数据命中不高、数据相对固定,实时性低的场景。

缓存穿透方案适合场景维护成本
缓存空对象数据命中不高。数据频繁变化实时性高代码维护较简单。需要过多空间,数据短期不一致
Bloom过滤器数据命中不高。数据相对固定实时性低代码维护略复杂。缓存空间相对较少。

5 缓存问题三:击穿(高并发访问)

使用“缓存+过期时间”基本能满足绝大部分使用。但是在以下两个情况仍存在问题:

  • 高并发访问的热点数据。由于并发大,缓存建立过程需要时间(几毫秒或者更长),前面的大量请求无法使用缓存。
  • 建立缓存非常耗时。例如需要复杂的计算或者慢SQL查询,使得有较长时间都无法使用缓存。

在这两个问题发生时,还由于大量请求到后端数据库,不仅造成后端负载压力过大,还存在大量重建缓存的线程。 这个问题就是缓存击穿问题。

从某种角度讲,缓存击穿问题只有并发才有可能存在。因此有时称为:缓存击穿问题就是并发问题

5.1 击穿现象

为了演示击穿现象,分别进行:

1、修改HelloController,增加一句sleep模拟从数据读数据创建缓存需要时间:

    @RequestMapping(value = "/getOnePerson/{name}", method = RequestMethod.GET)
    public Person getOnePerson(@PathVariable("name") String name) throws JsonProcessingException, InterruptedException {
        
        logger.info("This is getOnePerson has been called......");
        String rediskey = "person:" + name;  // 以`DB表名:行主键`作为Redis的key

        boolean isExistBloom = redisBloomFilter.mightContain(rediskey); // 先查询在Bloom过滤器是否存在
        logger.info( "元素[" +rediskey+"]在Bloom过滤器中是否存在:" +isExistBloom);
        if(!isExistBloom) {
            return null;
        }

        // 先查询缓存
        String obj_json = stringRedisTemplate.opsForValue().get(rediskey); // 先查询缓存
        if(null != obj_json) {
            logger.info("OK,已命中Redis缓存!");
            return jacksonMapper.readValue(obj_json, Person.class); // 缓存命中直接返回
        }

        // 若未命中Redis缓存,则从数据库查询
        obj_json = stringRedisTemplate.opsForValue().get(rediskey); // 获锁后,再次查询缓存。 之所以在这里再次查询缓存是因为,在等待锁期间缓存很可能已经(由其他线程)创建完成
        if(null != obj_json) {
            return jacksonMapper.readValue(obj_json, Person.class); // 缓存命中直接返回
        }

        Person result_person = personMapper.selectByName(name);
        TimeUnit.MILLISECONDS.sleep(500); // 临时增加的测试代码,用于模拟从数据读数据创建缓存需要时间
        if(null != result_person) { // 将从数据库查询得到的结果放入Redis缓存
            stringRedisTemplate.opsForValue().set(rediskey, jacksonMapper.writeValueAsString(result_person));
        }
        return result_person;
    }

2、使用JMeter模拟100个用户同时并发
在这里插入图片描述

3、在Redis中删除已存在的缓存后,使用JMeter并发测试。

​ 可以看到并发请求中,大量的请求均访问了后台数据库,缓存未命中。既发生了缓存击穿。

5.2 解决方案

解决的办法主要有:

  • 缓存永不过期。包括①不设置过期时间;②不使用Redis过期,而是由业务层面根据逻辑去主动删除。
  • 增加锁机制(通常使用Redis分布式锁)。

两种解决办法对比:

解决方案优点维护成本
永不过期基本杜绝热点key不保证数据长时间一致性;业务层面根据逻辑删除维护较为复杂
分布式锁思路简单,保证一致性代码复制;存在死锁风险;线程池阻塞风险。

5.3 分布式锁方案

我们使用分布式锁来解决缓存击穿问题。

我们使用Redisson,因此在pom.xml中增加以下依赖

(... 省略....)
        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson-spring-boot-starter</artifactId>
            <version>3.13.2</version>
        </dependency>
(... 省略....)

修改pom.xml后,记得使其生效(在Idea中,右键pom.xml文件→Maven→reimport)

修改HelloController,增加分布式锁以解决缓存穿透。

@RestController
public class HelloController {
    Logger logger = LoggerFactory.getLogger(HelloController.class);

    @Autowired
    private PersonMapper personMapper;  // 由Spring自动注入

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private ObjectMapper jacksonMapper;

    @Autowired
    RedisBloomFilter redisBloomFilter;

    @Autowired
    RedissonClient redissonClient;

    @RequestMapping(value = "/getOnePerson/{name}", method = RequestMethod.GET)
    public Person getOnePerson(@PathVariable("name") String name) throws InterruptedException, JsonProcessingException {
        logger.info("This is getOnePerson has been called......");
        String rediskey = "person:" + name;  // 以`DB表名:行主键`作为Redis的key

        // 先查询在Bloom过滤器是否存在
        boolean isExistBloom = redisBloomFilter.mightContain(rediskey);
        if(!isExistBloom) {
            return null;
        }

        // 先查询缓存
        String obj_json = stringRedisTemplate.opsForValue().get(rediskey); // 先查询缓存
        if(null != obj_json) {
            logger.info("OK,已命中Redis缓存!");
            return jacksonMapper.readValue(obj_json, Person.class); // 缓存命中直接返回
        }

        // 若未命中Redis缓存,则从数据库查询
        RLock lock = redissonClient.getLock("redissonLock:" + rediskey);
        lock.lock(15, TimeUnit.SECONDS); // 最多锁15秒
        try {
            obj_json = stringRedisTemplate.opsForValue().get(rediskey); // 获锁后,再次查询缓存。 之所以在这里再次查询缓存是因为,在等待锁期间缓存很可能已经(由其他线程)创建完成
            if(null != obj_json) {
                return jacksonMapper.readValue(obj_json, Person.class); // 缓存命中直接返回
            }

            Person result_person = personMapper.selectByName(name);
            TimeUnit.MILLISECONDS.sleep(500); // 临时增加的测试代码,用于模拟从数据读数据创建缓存需要时间
            if(null != result_person) { // 将从数据库查询得到的结果放入Redis缓存
                stringRedisTemplate.opsForValue().set(rediskey, jacksonMapper.writeValueAsString(result_person));
            }
            return result_person;
        }finally {
            lock.unlock(); // 解锁
        }
    }
}

注:在上面代码中,先查询了一次缓存(第①次查),在lock加锁后再一次查询了一次缓存(第②次查),为什么?

  • 如果直接去掉第②次查缓存,那么无法解决缓存击穿问题。因为虽然因为锁使得创建缓存过程是串行进行,但仍有大量的请求进行重复创建缓存。因为在等待锁的过程中,可能有其他线程已经完成缓存创建,这时拿到锁后应该马上再查一查缓存,看看是否有其他线程完成了缓存创建。
  • 如果在第①次查缓存之前加锁,可不需要第②次查缓存就可解决缓存击穿。但是性能有较大负面影响,因为所有情况都需要现拿锁(包括无缓存击穿的情况),无法并发读缓存。
  • 如果

然后使用JMeter并发测试, 可以看到并发请求中,除了一个请求访问数据库进行查询外,其他的请求均通过缓存读取数据,避免了缓存击穿。

6 优化代码

​ 在HelloController.java中,getOnePerson()方法中其实最重要的逻辑是获得数据,但是为了使用缓存,并解决缓存的三大问题,写了大量的代码。这些代码也成为胶水代码(非业务逻辑本身的代码)。如果每个使用查询缓存的地方都需要这么多代码,项目代码极其凌乱。

从上面的代码可以发现,大部分的代码是通用的,只有查询数据库(涉及不同的表)是变化的。 这符合涉及模式中“模板模式”,我们可以将通用不变的代码提炼出来。

我们先定义一个抽象类(抽象加载不同的数据库表的动作,具体有子类实现)

package org.zyp.template;

public abstract class CacheLoadble<T> {
    abstract public  T load();
}

然后把上面getOnePerson()方法中的通用代码抽离出来,形成模板类。

由于不同的缓存对象不同,下面代码使用泛型。 其中把原来直接从具体的Table中获得数据,改为调用CacheLoadble的load()方法

package org.zyp.template;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.zyp.bloomfilter.RedisBloomFilter;

import java.lang.reflect.ParameterizedType;
import java.util.concurrent.TimeUnit;

/**
 * @author ZhangYuPing
 * @date 2020/7/11 21:25
 */
@Component
public class RedisCacheTemplate<T>  {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private ObjectMapper jacksonMapper;

    @Autowired
    RedisBloomFilter redisBloomFilter;

    @Autowired
    RedissonClient redissonClient;

    public T findCache(String key, long expire_ms, CacheLoadble<T> cacheLoadble, boolean useBloom ) throws JsonProcessingException {
        // 这两句话是为了获得泛型T,在运行过程中的真实class类型。
        ParameterizedType parameterizedType = (ParameterizedType)cacheLoadble.getClass().getGenericSuperclass();
        Class<T> TClass =  (Class<T>) parameterizedType.getActualTypeArguments()[0];

        // 先查询在Bloom过滤器是否存在
        if(useBloom) {
            boolean isExistBloom = redisBloomFilter.mightContain(key);
            if(!isExistBloom) {
                return null;
            }
        }

        // 先查询缓存
        String obj_json = stringRedisTemplate.opsForValue().get(key); // 先查询缓存
        if(null != obj_json) {
            return jacksonMapper.readValue(obj_json, TClass ); // 缓存命中直接返回
        }

        // 若未命中Redis缓存,则从数据库查询
        RLock lock = redissonClient.getLock("redissonLock:" + key);
        lock.lock(15, TimeUnit.SECONDS); // 最多锁15秒
        try {
            obj_json = stringRedisTemplate.opsForValue().get(key); // 获锁后,再次查询缓存。 之所以在这里再次查询缓存是因为,在等待锁期间缓存很可能已经(由其他线程)创建完成
            if(null != obj_json) {
                return jacksonMapper.readValue(obj_json, TClass); // 缓存命中直接返回
            }

            T loadresult = cacheLoadble.load(); // 调用实现接口的子类方法(具体加载什么内容由子类实现)
            if(null != loadresult) { // 将从数据库查询得到的结果放入Redis缓存
                stringRedisTemplate.opsForValue().set(key, jacksonMapper.writeValueAsString(loadresult));
            }
            return loadresult;
        }finally {
            lock.unlock(); // 解锁
        }
    }
}

由于使用“模板方法”,可以对原HelloController的代码简化为:

package org.zyp.controller;

import com.fasterxml.jackson.core.JsonProcessingException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import org.zyp.dao.PersonMapper;
import org.zyp.entity.Person;
import org.zyp.template.CacheLoadble;
import org.zyp.template.RedisCacheTemplate;

/**
 * @author ZhangYuPing
 * @date 2020/7/7 17:56
 */
@RestController
public class HelloController {
    Logger logger = LoggerFactory.getLogger(HelloController.class);

    @Autowired
    private PersonMapper personMapper;  // 由Spring自动注入

    @Autowired
    private RedisCacheTemplate<Person> redisCacheTemplate;

    @RequestMapping(value = "/getOnePerson/{name}", method = RequestMethod.GET)
    public Person getOnePerson(@PathVariable("name") String name) throws InterruptedException, JsonProcessingException {
        logger.info("This is getOnePerson has been called......");
        return redisCacheTemplate.findCache("person:" + name, 30_000, new CacheLoadble<Person>() {
            @Override
            public Person load() {
                return personMapper.selectByName(name);
            }
        }, true);
    }
}

可以看到使用Template模板方法后,使用的代码大幅精简。 同时Template模板方法是通用的,不同的地方都可以使用。

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值