本文分别介绍了不使用缓存、使用缓存的方法。 使用缓存带来的三大问题(缓存雪崩、缓存穿透、缓存击穿)及解决办法。同时为了使代码优化优雅,使用模板泛型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类似。
新建工程时,勾选Mybatis
、MySQL
、Druid
等。 为了简便,我们还引入了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/classes
和targe/test-classes
目录中,但是这两个目录中的其他文件都会被忽略掉。
因此需要在pom.xml
文件中指定resource,告诉Maven指定的资源也需要从src/main下复制到target/classes
和targe/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。
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模板方法是通用的,不同的地方都可以使用。