在最近的问题咨询中,有不少的读者朋友谈到缓存就比较懵圈,说是项目都在用,但是却不是很懂,希望可以讲一讲。所以,这次就来个《图解缓存实战五部曲》,从原理到实战,给你安排得明明白白。屁话不多说,我们开整!
我们会以《最新 Spring Cloud Alibaba 实战开发》中完成的微服务实战项目smartcar-project, 为它加入缓存来提升性能作为案例代码。
在项目开发完成后,往往会发现部署后的系统,运行速度不如人意,此时,就需要做性能调优,方法很多,比如给表加索引、动静资源分离、减少不必要的日志打印等等。还有一个很强大的优化方式,那就是 加缓存!
当然,在高并发、高可用、高性能的要求下(虽然大多数项目都是吹牛皮的),我们通过将热点数据放入缓存中,让数据库只需要承担存储的工作,以此来提高系统的访问速度,减轻数据压力。
1.2.哪些数据适合放入缓存中呢?通常来说,我们把 热点数据,放入缓存。
归纳为一下几种:
- 访问量大但更新频率不高的。例如网站首页的友情链接,访问量,不会经常变化。
- 及时性的。例如查询最新的订单物流信息,外卖状态,地理位置信息等等。
- 数据一致性要求不高的。例如门店信息,CSDN的个人资料,都是修改后,数据库中已经改了,x 分钟后缓存中才是最新的,但这不影响功能使用。
其他的,大多数也接触不到,就不一一列举了!
1.3.使用缓存的流程目前,大多数项目中,比如当我们查询某数据时,使用缓存的流程如下:
注意这个缓存组件,你可能会听到:Redis、Memcache、Ehcache、甚至还有用Map、Hibernate 等做缓存的。
我们就先不说这些,因为要使用缓存,最简单的是使用本地缓存。
二、本地缓存我说最简单的缓存使用方式是用本地缓存。
what?估计很多一开始就使用Redis的小伙伴,都不没听过这个东西。
本地缓存,说白了就是在内存中缓存数据,可以用Map、数组等数据结构来缓存数据。(严格来说本地缓存不能用HashMap,因为不是线程安全的,会出问题)。
比如现在有一个需求,前端需要查询计费规则,而计费规则放在网站的首页,假设访问量是非常高的,但又不是经常变化的数据,所以我们将 “计费规则数据” 放到缓存中。
2.2. 不使用缓存时我们先来看下不使用缓存的情况:
- 前端的请求先经过网关,然后请求到对应的微服务,然后查询数据库,返回查询结果。
如图:
对应的核心代码,先自定义一个 API 用来查询计费规则的列表数据,从数据库查询出来后直接返回给前端。
@GetMapping("/list")
public ResponseResult typeList(){
// 从数据库中查询数据
List list = ruleService.list();
return ResponseResult.ok(list);
}
2.3.使用缓存时
再看下使用缓存的情况:
- 前端先经过网关,然后到资源微服务,先判断缓存中有没有数据,如果没有,则查询数据库再更新缓存,最后返回查询到的结果。
如图:
那我们现在创建一个 HashMap 来缓存资源的类型列表:
private Mapcache = new HashMap<>();
先获取缓存中的类型列表:
/从本地缓存中取数据 ListruleCache = (List ) cache.get("ruleList");
如果缓存中没有,则先从数据库中获取。当然,假设第一次查询缓存时,肯定是没有这个数据的。
最终的方法如下:
private Mapcache = new HashMap<>(); @GetMapping("/cacheList") public ResponseResult listLocal(){ List ruleCache = (List ) cache.get("ruleList"); // 如果缓存中没有,则先从数据库中获取 if (ruleCache == null) { System.out.println("缓存为空"); // 从数据库中查询数据 List list = ruleService.list(); // 将数据放入缓存中 ruleCache = list; cache.put("ruleList", list); } System.out.println("缓存的值是:》》》》》》》》》》》》"+cache.get("ruleList")); return ResponseResult.ok(ruleCache); }
采用请求工具请求URL:
http://localhost:8008/chargingRule/cacheList
结果如下,前端输出:
控制台输出:
后面再次查询时,因为缓存中已经有了数据,所以直接走缓存,不会再从数据库中查询数据了。
从上面的例子中不难看出本地缓存有如下优点:
- 加快了程序响应速度。
- 减少和数据库的交互,降低磁盘 I/O 。
- 避免数据库的死锁(不走db自然免除)。
那既然本地缓存又快又好,为啥没看见多少人使用呢?实际上,本地缓存存在一些问题,如:
- 大量占用了本地内存资源。
- 机器发生宕机重启后,缓存数据丢失。
- 可能会存在数据库数据和缓存数据不一致的问题。
- 同一台机器中的多个微服务缓存的数据不一致的问题。
- 集群环境下不同机器中缓存的数据不一致的问题。
对于这些问题,本地缓存要想解决,还得需要增加大量的编码工作,直接采用轮子来解决他不香吗?
于是乎,开发者们引入了缓存组件 Redis 来解决部分问题。
三、缓存 Redis 3.1.安装Redis首先需要安装 Redis(简单跳过),你可以在windwos去下载,也可以在linux上下载,甚至可以通过 Docker 来安装 Redis。
3.2.引用Redis组件然后在自己项目的配置文件 pom.xml 中引入 redis 组件依赖。
3.3.测试Redisorg.springframework.boot spring-boot-starter-data-redis
最后测试redis,可以写一个方法来测试引入的 redis 是否能存数据,以及能否查出存的数据。
在项目开发中,通常,我们都是使用 StringRedisTemplate 库来操作 Redis(里面封装了对redis的CRUD操作),所以可以自动装载下 StringRedisTemplate。
import org.springframework.data.redis.core.StringRedisTemplate; @Autowired StringRedisTemplate stringRedisTemplate;
当然也可以自定义一个RedisTemplate模板,设置序列化方式,一样的装载方式。
然后在测试方法中,测试存储方法:ops.set(),以及 查询方法:ops.get()。
@Test
public void TestStringRedisTemplate() {
// 初始化 redis 组件
ValueOperations ops = stringRedisTemplate.opsForValue();
// 存储数据
ops.set("江湖一点雨", "空空说技术_" + UUID.randomUUID().toString());
// 查询数据
String wukong = ops.get("江湖一点雨");
System.out.println("redis中数据为:"+wukong);
}
说明:
- set 方法的第一个参数是 key,比如示例中的 “江湖一点雨”。
- get 方法的参数也是 key。
最后打印出了 redis 中 key = “江湖一点雨” 的缓存的值,如图:
另外也可以通过客户端工具来查看,如下图所示:
测试正常之后,我们就可以用Redis来替换上一面的本地缓存方案。
3.4.用 Redis 改造业务逻辑用 redis 替换 hashmap ,把用到本地缓存hashmap 改为用 redis 方案。
代码如下:
import org.springframework.data.redis.core.StringRedisTemplate;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@GetMapping("listByRedis")
public List listRedis() {
// 1.初始化 redis 组件
ValueOperations ops = stringRedisTemplate.opsForValue();
// 2.从缓存中查询数据
String ruleList = ops.get("ruleList");
// 3.如果缓存中没有数据
if (StringUtils.isEmpty(ruleList)) {
System.out.println("The cache is empty");
// 4.从数据库中查询数据
List DbList = ruleService.list();
// 5.将从数据库中查询出的数据序列化 JSON 字符串
ruleList = JSON.toJSONString(DbList);
// 6.将序列化后的数据存入缓存中
ops.set("ruleList", ruleList);
return DbList;
}
// 7.如果缓存中有数据,则从缓存中拿出来,并反序列化为实例对象
List entityList = JSON.parseObject(ruleList, new TypeReference>(){});
return entityList;
}
整个流程如下:
- 1.初始化 redis 组件。
- 2.从缓存中查询数据。
- 3.如果缓存中没有数据,执行步骤 4、5、6。
- 4.从数据库中查询数据
- 5.将从数据库中查询出的数据转化为 JSON 字符串
- 6.将序列化后的数据存入缓存中,并返回数据库中查询到的数据。
- 7.如果缓存中有数据,则从缓存中拿出来,并反序列化为实例对象
启动项目,调用接口:
http://localhost:8008/chargingRule/listByRedis
如图:
通过多次测试,第一次请求会稍微慢点,后面几次速度非常快,说明使用缓存后系统性能有所提升。
也可以用 Redis 客户端看下结果:
Redis key = ruleList,Redis value 是一个 JSON 字符串,里面的内容是规则的列表。
注意:从数据库中查询到的数据,必须先要 序列化 成 JSON 字符串后再存入到 Redis 中,从 Redis 中查询数据时,也需要将 JSON 字符串 反序列化 为对象实例。(实际上就是string和list之间的互转)
更加详细的方案,可以参考这个17.分布式缓存下Redis设计。
四、缓存Redis的潜在问题同本地缓存一样,Redis也不是完美的,虽然很多项目已经在使用它了,但是它还是会存在一些潜在的问题。比如常见被提及的缓存穿透、雪崩、击穿的问题。
具体我就直接绘制了图例:
高并发下使用缓存会带来的缓存穿透、雪崩、击穿问题,我们不再这里详细谈(网上太多了~),只说其中一个注意点。
4.1.加锁解决缓存击穿的问题通常一些文章在写,如何处理缓存击穿的问题时,说到如下:
- 加锁,解决缓存击穿问题。
我们就来说说锁的问题。目前锁大体分两种,一种是本地锁,一种是分布式锁。
我们用本地锁的方式(即 synchronized ),来对业务进行加锁,演示如何解决缓存击穿问题。
@PostMapping("/CacheByLock")
public ResponseVo OneByLock(Integer id) {
synchronized (this) {
String typeCache = stringRedisTemplate.opsForValue().get("typeList");
if (!StringUtils.isEmpty(typeCache)) {
// 2.如果缓存中有数据,则从缓存中拿出来,并反序列化为实例对象,并返回结果
List typeEntityList = JSON.parseObject(typeCache, new TypeReference>() {});
return ResultUtil.success("缓存中的信息为:", typeEntityList);
}
// 3.如果缓存中没有数据,从数据库中查询数据
System.out.println("缓存typeCache 为空");
List typeEntityListFromDb = noticeService.listRelease();
typeCache = JSON.toJSONString(typeEntityListFromDb);
// 5.将序列化后的数据存入缓存中,并返回数据库查询结果
stringRedisTemplate.opsForValue().set("typeList", typeCache, 30L, TimeUnit.SECONDS);
return ResultUtil.success("数据库中的信息为:", typeEntityListFromDb);
}
}
此时,采用 jmeter 工具发起批量请求,可以发现每次只会处理一个线程,其他请求会被阻塞等待,导致系统卡主!问题很明显。
4.2.采用本地锁引发的问题因为本地锁只能锁定当前服务的线程,如下图所示,假设分布式下,部署多个相同微服务,而每个微服务用本地锁进行加锁。
如图:
本来本地锁在一般情况下没什么问题,但是这次,我们在实战教程中,所采用的项目,里面设计到一个库存车位的微服务smartcar-resource,用来锁库存就出现了问题:
- 1.当前总库存为 100,被缓存在 Redis 中。
- 2.库存微服务 A 用本地锁扣减库存 1 之后,总库存为 99。
- 3.库存微服务 B 用本地锁扣减库存 1 之后,总库存为 99。
- 4.那库存扣减了 2 次后,还是 99,就超卖了 1 个。
实际上,这就是订单、商场系统中鼎鼎大名的 超卖问题!
那如何解决本地加锁引发的问题呢?请看下文分解。
(原创不易,请读者珍惜,文中部分内容节选自一些网络语句和方案,但图均为本人手绘,请知悉。)
青山不改,绿色长流。如果您觉得这个有点用,那就请多多留言吧,感谢!



