目录
大多数情况下,浏览器查询到的都是缓存数据,如果缓存数据与数据库数据存在较大差异,可能会产生比较严重的后果。
所以我们必须保证数据库数据、缓存数据的一致性,这就是缓存与数据库的同步。
数据同步策略
缓存数据同步的常见方式有三种:
设置有效期:给缓存设置有效期,到期后自动删除。再次查询时更新
-
优势:简单、方便
-
缺点:时效性差,缓存过期之前可能不一致
-
场景:更新频率较低,时效性要求低的业务
同步双写:在修改数据库的同时,直接修改缓存
-
优势:时效性强,缓存与数据库强一致
-
缺点:有代码侵入,耦合度高;
-
场景:对一致性、时效性要求较高的缓存数据
异步通知:修改数据库时发送事件通知,相关服务监听到通知后修改缓存数据
-
优势:低耦合,可以同时通知多个缓存服务
-
缺点:时效性一般,可能存在中间不一致状态
-
场景:时效性要求一般,有多个服务需要同步
而异步实现又可以基于MQ或者Canal来实现:
1)基于MQ的异步通知:
解读:
-
商品服务完成对数据的修改后,只需要发送一条消息到MQ中。
-
缓存服务监听MQ消息,然后完成对缓存的更新
依然有少量的代码侵入。
2)基于Canal的通知
解读:
-
商品服务完成商品修改后,业务直接结束,没有任何代码侵入
-
Canal监听MySQL变化,当发现变化后,立即通知缓存服务
-
缓存服务接收到canal通知,更新缓存
代码零侵入
认识Canal
Canal [kə'næl],译意为水道/管道/沟渠,canal是阿里巴巴旗下的一款开源项目,基于Java开发。基于数据库增量日志解析,提供增量数据订阅&消费。GitHub的地址:GitHub - alibaba/canal: 阿里巴巴 MySQL binlog 增量订阅&消费组件
Canal是基于mysql的主从同步来实现的,MySQL主从同步的原理如下:
-
1)MySQL master 将数据变更写入二进制日志( binary log),其中记录的数据叫做binary log events
-
2)MySQL slave 将 master 的 binary log events拷贝到它的中继日志(relay log) io thread
-
3)MySQL slave 读取 relay log 中事件,将数据变更到自己的db利用sql thread
而Canal就是把自己伪装成MySQL的一个slave节点,从而监听master的binary log变化。再把得到的变化信息通知给Canal的客户端,进而完成对其它数据库的同步
监听Canal
Canal提供了各种语言的客户端,当Canal监听到binlog变化时,会通知Canal的客户端。
我们可以利用Canal提供的Java客户端,监听Canal通知消息。当收到变化的消息时,完成对缓存的更新。
不过这里我们会使用GitHub上的第三方开源的canal-starter客户端。地址:GitHub - NormanGyllenhaal/canal-client: spring boot canal starter 易用的canal 客户端 canal client
与SpringBoot完美整合,自动装配,比官方客户端要简单好用很多。
引入依赖:
<dependency>
<groupId>top.javatool</groupId>
<artifactId>canal-spring-boot-starter</artifactId>
<version>1.2.1-RELEASE</version>
</dependency>
编写配置:
//yaml
canal:
destination: heima # canal的集群名字,要与安装canal时设置的名称一致
server: 192.168.150.101:11111 # canal服务地址
修改Item实体类
Canal推送给canal-client的是被修改的这一行数据(row),而我们引入的canal-client则会帮我们把行数据封装到ltem实体类中。这个过程中需要知道数据库与实体的映射关系,要用到JPA的几个注解:
通过@Id、@Column、等注解完成Item与数据库表字段的映射:
@id 标记表的id 主键 @column 标记数据表的列命(如果列名和属性名一致可以不用写) @Transient标记改字段不是数据表的字段 提高了 这个通常可以在需要这个属性做一些其他的判断 但是这个属性有不需要在数据库中保存 可以这样写 提高了代码的复用性(mp中的 @TableField(exist = false) 一样 ) 例如之前做的点赞 把islike是否点赞过了保存在bean中 用于做点赞的标记(通过对redis的socketset插入数据判断是否点赞过了)
编写监听器
通过实现EntryHandler<T>
接口编写监听器,监听Canal消息。注意两点:
-
实现类通过
@CanalTable("tb_item")
指定监听的表信息 -
EntryHandler的泛型是与表对应的实体类
@CanalTable("tb_item")
@Component
public class ItemHandler implements EntryHandler<Item> {
@Autowired
private RedisHandler redisHandler;
@Autowired
private Cache<Long, Item> itemCache;
@Override
public void insert(Item item) {
// 写数据到JVM进程缓存
itemCache.put(item.getId(), item);
// 写数据到redis
redisHandler.saveItem(item);
}
@Override
public void update(Item before, Item after) {
// 写数据到JVM进程缓存
itemCache.put(after.getId(), after);
// 写数据到redis
redisHandler.saveItem(after);
}
@Override
public void delete(Item item) {
// 删除数据到JVM进程缓存
itemCache.invalidate(item.getId());
// 删除数据到redis
redisHandler.deleteItemById(item.getId());
}
}
在这里对Redis的操作都封装到了RedisHandler这个对象中,是我们之前做缓存预热时编写的一个类,内容如下: 封装了一下对redis操作
@Component
public class RedisHandler implements InitializingBean {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private IItemService itemService;
@Autowired
private IItemStockService stockService;
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public void afterPropertiesSet() throws Exception {
// 初始化缓存
// 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);
}
}
public void saveItem(Item item) {
try {
String json = MAPPER.writeValueAsString(item);
redisTemplate.opsForValue().set("item:id:" + item.getId(), json);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
public void deleteItemById(Long id) {
redisTemplate.delete("item:id:" + id);
}
}