传统缓存的问题
传统的缓存策略一般是请求到达Tomcat后,先查询Redis,如果未命中则查询数据库,存在下面的问题:• 请求要经过 Tomcat 处理, Tomcat 的性能成为整个系统的瓶颈• Redis 缓存失效时,会对数据库产生冲击
多级缓存方案
多级缓存就是充分利用请求处理的每个环节,分别添加缓存,减轻Tomcat压力,提升服务性能:
①浏览器访问静态资源时,优先读取浏览器本地缓存
②访问非静态资源(ajax查询数据)时,访问服务端
③请求到达Nginx后,优先读取Nginx本地缓存
④如果Nginx本地缓存未命中,则去直接查询Redis(不经过Tomcat)
⑤如果Redis查询未命中,则查询Tomcat
⑥请求进入Tomcat后,优先查询JVM进程缓存
⑦如果JVM进程缓存未命中,则查询数据库
在多级缓存架构中,Nginx内部需要编写本地缓存查询、Redis查询、Tomcat查询的业务逻辑,因此这样的nginx服务不再是一个反向代理服务器,而是一个编写业务的Web服务器了。 因此这样的业务Nginx服务也需要搭建集群来提高并发,再有专门的nginx服务来做反向代理。另外,Tomcat服务将来也会部署为集群模式。
多级缓存的关键有两个:
①一个是在nginx中编写业务,实现nginx本地缓存、Redis、Tomcat的查询
②另一个就是在Tomcat中实现JVM进程缓存其中Nginx编程则会用到OpenResty框架结合Lua这样的语言
目录
1、JVM进程缓存
1.1 导入商品案例
《案例导入说明.md》
1.2 初识Caffeine
缓存在日常开发中启动至关重要的作用,由于是存储在内存中,数据的读取速度是非常快的,能大量减少对数据库的访问,减少数据库的压力。
Caffeine是一个基于Java8开发的,提供了近乎最佳命中率的高性能的本地缓存库。目前Spring内部的缓存使用的就是Caffeine。GitHub地址:GitHub - ben-manes/caffeine: A high performance caching library for Java
1.2.1 Caffeine示例
@Test
void testBasicOps() {
// 构建cache对象
Cache<String, String> cache = Caffeine.newBuilder().build();
// 存数据
cache.put("gf", "迪丽热巴");
// 取数据
String gf = cache.getIfPresent("gf");
System.out.println("gf = " + gf);
// 取数据,包含两个参数:
// 参数一:缓存的key
// 参数二:Lambda表达式,表达式参数就是缓存的key,方法体是查询数据库的逻辑
// 优先根据key查询JVM缓存,如果未命中,则执行参数二的Lambda表达式
String defaultGF = cache.get("defaultGF", key -> {
// 根据key去数据库查询数据
return "柳岩";
});
System.out.println("defaultGF = " + defaultGF);
}
1.2.2 Caffeine提供了三种缓存驱逐策略
Caffeine既然是缓存的一种,肯定需要有缓存的清除策略,不然的话内存总会有耗尽的时候。
【注】在默认情况下,当一个缓存元素过期的时候,Caffeine不会自动立即将其清理和驱逐。而是在一次读或写操作后,或者在空闲时间完成对失效数据的驱逐。
@Test
void testEvictByNum() throws InterruptedException {
// 创建缓存对象
Cache<String, String> cache = Caffeine.newBuilder()
// 设置缓存大小上限为 1
.maximumSize(1)
.build();
// 存数据
cache.put("gf1", "柳岩");
cache.put("gf2", "范冰冰");
cache.put("gf3", "迪丽热巴");
// 延迟10ms,给清理线程一点时间
Thread.sleep(10L);
// 获取数据
System.out.println("gf1: " + cache.getIfPresent("gf1"));
System.out.println("gf2: " + cache.getIfPresent("gf2"));
System.out.println("gf3: " + cache.getIfPresent("gf3"));
}
@Test
void testEvictByTime() throws InterruptedException {
// 创建缓存对象
Cache<String, String> cache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofSeconds(1)) // 设置缓存有效期为 1 秒
.build();
// 存数据
cache.put("gf", "柳岩");
// 获取数据
System.out.println("gf: " + cache.getIfPresent("gf"));
// 休眠一会儿
Thread.sleep(1200L);
System.out.println("gf: " + cache.getIfPresent("gf"));
}
1.3 实现JVM进程缓存
首先,我们需要定义两个Caffeine的缓存对象,分别保存商品、库存的缓存数据。在item-service的com.heima.item.config
包下定义CaffeineConfig
类:
@Configuration
public class CaffeineConfig {
@Bean
public Cache<Long, Item> itemCache(){
return Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(10_000)
.build();
}
@Bean
public Cache<Long, ItemStock> stockCache(){
return Caffeine.newBuilder()
.initialCapacity(100)
.maximumSize(10_000)
.build();
}
}
然后,修改item-service中的com.heima.item.web
包下的ItemController类,添加缓存逻辑:
@RestController
@RequestMapping("item")
public class ItemController {
@Autowired
private IItemService itemService;
@Autowired
private IItemStockService stockService;
@Autowired
private Cache<Long, Item> itemCache;
@Autowired
private Cache<Long, ItemStock> stockCache;
// ...其它略
@GetMapping("/{id}")
public Item findById(@PathVariable("id") Long id) {
return itemCache.get(id, key -> itemService.query()
.ne("status", 3).eq("id", key)
.one()
);
}
@GetMapping("/stock/{id}")
public ItemStock findStockById(@PathVariable("id") Long id) {
return stockCache.get(id, key -> stockService.getById(key));
}
}
2、Lua语法入门
2.1 初识Lua
Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。官网:The Programming Language Lua
2.2 变量和循环
2.2.1 数据类型
2.2.2 变量
加local是局部变量;不加local是全局变量
2.2.3 循环
对于table,我们可以利用for循环来遍历。不过数组和普通table遍历略有差异。
2.3 条件控制、函数
条件控制
3、多级缓存
利用OpenResty完成多级缓存业务的编写
3.1 安装OpenResty
《安装OpenResty.md》
OpenResty® 是一个基于 Nginx的高性能 Web 平台,用于方便地搭建能够处理超高并发、扩展性极高的动态 Web 应用、Web 服务和动态网关。具备下列特点:
• 具备 Nginx 的完整功能• 基于 Lua 语言进行扩展,集成了大量精良的 Lua 库、第三方模块• 允许使用 Lua自定义业务逻辑、自定义库官方网站: OpenResty® - 中文官方站
3.2 OpenResty快速入门
3.3 请求参数处理
3.4 查询Tomcat
3.5 Redis缓存预热
冷启动:服务刚刚启动时,Redis中并没有缓存,如果所有商品数据都在第一次查询时添加缓存,可能会给数据库带来较大压力。
缓存预热:在实际开发中,我们可以利用大数据统计用户访问的热点数据,在项目启动时将这些热点数据提前查询并保存到Redis中。
我们数据量较少,可以在启动时将所有数据都放入缓存中。
缓存预热需要在项目启动时完成,并且必须是拿到RedisTemplate之后。这里我们利用InitializingBean接口来实现,因为InitializingBean可以在对象被Spring创建并且成员变量全部注入后执行。
@Component
public class RedisHandler implements InitializingBean {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private IItemService itemService;
@Autowired
private IItemStockService stockService;
// Spring里的默认Json处理工具
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public void afterPropertiesSet() throws Exception {//该方法在bean创建完,Autowired之后执行
// 初始化缓存
// 1.查询商品信息
List<Item> itemList = itemService.list();
// 2.放入缓存
for (Item item : itemList) {
// 2.1.item序列化为JSON
String json = MAPPER.writeValueAsString(item);
// 2.2.存入redis
redisTemplate.opsForValue().set("item:id:" + item.getId(), json);
}
// 3.查询商品库存信息
List<ItemStock> stockList = stockService.list();
// 4.放入缓存
for (ItemStock stock : stockList) {
// 2.1.item序列化为JSON
String json = MAPPER.writeValueAsString(stock);
// 2.2.存入redis
redisTemplate.opsForValue().set("item:stock:id:" + stock.getId(), json);
}
}
}
3.6 查询Redis缓存
3.7 Nginx本地缓存
4、缓存同步策略
4.1 数据同步策略
缓存数据同步的常见方式有三种:
• 设置有效期 :给缓存设置有效期,到期后自动删除。再次查询时更新• 优势:简单、方便• 缺点:时效性差,缓存过期之前可能不一致• 场景:更新频率较低,时效性要求低的业务• 同步双写 :在修改数据库的同时,直接修改缓存• 优势:时效性强,缓存与数据库强一致• 缺点:有代码侵入,耦合度高;• 场景:对一致性、时效性要求较高的缓存数据• 异步通知: 修改数据库时发送事件通知,相关服务监听到通知后修改缓存数据• 优势:低耦合,可以同时通知多个缓存服务• 缺点:时效性一般,可能存在中间不一致状态• 场景:时效性要求一般,有多个服务需要同步
基于MQ的异步通知 基于Canal的异步通知
4.2 安装Canal
Canal [kə'næl],译意为水道/管道/沟渠,canal是阿里巴巴旗下的一款开源项目,基于Java开发。基于数据库增量日志解析,提供增量数据订阅&消费。GitHub的地址:GitHub - alibaba/canal: 阿里巴巴 MySQL binlog 增量订阅&消费组件
Canal是基于mysql的主从同步来实现的,MySQL主从同步的原理如下:
• MySQL master 将数据变更写入二进制日志 ( binary log ),其中记录的数据叫做 binary log events• MySQL slave 将 master 的 binary log events 拷贝到它的中继日志 (relay log)• MySQL slave 重放 relay log 中事件,将数据变更反映它自己的数据
Canal就是把自己伪装成MySQL的一个slave节点,从而监听master的binary log变化。再把得到的变化信息通知给Canal的客户端,进而完成对其它数据库的同步。
《安装Canal.md》