16保证缓存一致性
我们建立了多级缓存,但是如果数据库的数据更新了,我们需要保证 Nginx、JVM、Redis 缓存全部更新,保证每一级缓存中的内容都和数据库一致。
缓存数据同步策略
缓存数据同步的常见方式有三种:
设置有效期
给缓存设置有效期,到期后自动删除,再次查询时更新。
- 优势:简单、方便
- 缺点:时效性差,缓存过期之前可能不一致
- 场景:更新频率较低,时效性要求低的业务
同步双写
在修改数据库的同时,直接修改缓存。
- 优势:时效性强,缓存与数据库强一致
- 缺点:有代码侵入,耦合度高
- 场景:对一致性、时效性要求较高的缓存数据
异步通知
修改数据库时发送事件通知,相关服务监听到通知后修改缓存数据。
- 优势:低耦合,可以同时通知多个缓存服务
- 缺点:时效性一般,可能存在中间不一致状态
- 场景:时效性要求一般,有多个服务需要同步
而异步通知的实现又可以基于 MQ 或 Canal 来实现。
基于MQ的异步通知

- 商品服务完成对数据的修改后,只需要发送一条消息到 MQ 中
- 缓存服务监听 MQ 消息,然后完成对缓存的更新
这种方式需要商品服务在修改数据库后主动发送消息,有一定的代码侵入。
基于Canal的异步通知

- 商品服务完成商品修改后,业务直接结束,没有任何代码侵入
- Canal 监听 MySQL 变化,当发现变化后,立即通知缓存服务
- 缓存服务接收到 Canal 通知,更新缓存
Canal监听binlog
初识Canal
Canal [kə'næl],译意为水道/管道/沟渠,是阿里巴巴旗下的一款开源项目,基于 Java 开发,基于数据库增量日志解析,提供增量数据订阅与消费。
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
安装和配置 Canal 需要先开启 MySQL 的 binlog,并创建 Canal 专用的 MySQL 账号。Canal 服务端配置中需要指定 MySQL 地址、账号密码以及监听的实例 destination 名称,具体搭建流程参考课前资料文档。
Canal客户端
Canal 提供了各种语言的客户端,当 Canal 监听到 binlog 变化时,会通知 Canal 的客户端。这里使用 GitHub 上的第三方开源的 canal-starter。
引入依赖:
<!--canal-->
<dependency>
<groupId>top.javatool</groupId>
<artifactId>canal-spring-boot-starter</artifactId>
<version>1.2.1-RELEASE</version>
</dependency>编写配置:
canal:
destination: heima # canal实例名称,要跟canal-server运行时设置的destination一致
server: 192.168.150.101:11111 # canal地址编写监听器,监听 Canal 消息:
package com.hema.item.canal;
@CanalTable("tb_item") // 指定要监听的表
@Component
public class ItemHandler implements EntryHandler<Item> { // 指定表关联的实体类
@Override
public void insert(Item item) {
// 新增数据到redis
}
@Override
public void update(Item before, Item after) {
// 更新redis数据
// 更新本地缓存
}
@Override
public void delete(Item item) {
// 删除redis数据
// 清理本地缓存
}
}监听到数据库的增、改、删的消息后,分别处理缓存。
实体映射
Canal 推送给 canal-client 的是被修改的这一行数据(row),引入的 canal-client 会帮我们把行数据封装到 Item 实体类中。这个过程中需要知道数据库与实体的映射关系,要用到 JPA 的几个注解:
@Data
@TableName("tb_item")
public class Item {
@TableId(type = IdType.AUTO)
@Id // 标记表中的id字段
private Long id;
@Column(name = "name") // 标记表中与属性名不一致的字段
private String name;
// ... 其它字段略
private Date updateTime;
@TableField(exist = false)
@Transient // 标记不属于表中的字段
private Integer stock;
@TableField(exist = false)
@Transient
private Integer sold;
}在项目中保证缓存一致性
本项目的缓存一致性采用主动更新的方式:在代码中封装一个方法 evictCache,在每个更新数据的方法处调用该方法,将更新对应 id 的商品数据直接从各级缓存中清除掉,下次查询时再重新回写缓存。
改造ItemController
在 ItemController 中封装 evictCache 方法,并在新增、更新、删除、扣减库存等写操作中调用:
@Api(tags = "商品管理相关接口")
@RestController
@RequestMapping("/items")
@RequiredArgsConstructor
public class ItemController {
private final IItemService itemService;
private final RedisTemplate<String, Object> redisTemplate;
private final Cache<Long, ItemDTO> itemCache;
@ApiOperation("更新商品状态")
@PutMapping("/status/{id}/{status}")
public void updateItemStatus(@PathVariable("id") Long id, @PathVariable("status") Integer status) {
Item item = new Item();
item.setId(id);
item.setStatus(status);
itemService.updateById(item);
// 清除相关缓存
evictCache(id);
}
@ApiOperation("更新商品")
@PutMapping
public void updateItem(@RequestBody ItemDTO item) {
// 不允许修改商品状态,强制设置为null,更新时会忽略该字段
item.setStatus(null);
itemService.updateById(BeanUtils.copyBean(item, Item.class));
// 清除相关缓存
evictCache(item.getId());
}
@ApiOperation("根据id删除商品")
@DeleteMapping("{id}")
public void deleteItemById(@PathVariable("id") Long id) {
itemService.removeById(id);
// 清除相关缓存
evictCache(id);
}
@ApiOperation("批量扣减库存")
@PutMapping("/stock/deduct")
public void deductStock(@RequestBody List<OrderDetailDTO> items) {
itemService.deductStock(items);
// 清除相关商品的缓存(因为库存变化了)
for (OrderDetailDTO item : items) {
evictCache(item.getItemId());
}
}
/**
* 清除指定商品的缓存
*/
private void evictCache(Long itemId) {
// 清除JVM缓存
itemCache.invalidate(itemId);
// 清除Redis缓存
String redisKey = "item:" + itemId;
redisTemplate.delete(redisKey);
// 清除Nginx本地缓存
try {
RestTemplate restTemplate = new RestTemplate();
String url = "http://localhost:18080/api/cache/item/purge?id=" + itemId;
restTemplate.getForObject(url, String.class);
} catch (Exception e) {
System.err.println("清除Nginx缓存失败: " + e.getMessage());
}
}
}evictCache 方法依次清除 JVM 进程缓存(Caffeine)、Redis 分布式缓存,并通过 HTTP 请求通知 Nginx 清除本地缓存。
Nginx清理本地缓存接口
在 nginx 中编写与 http://localhost:18080/api/cache/item/purge?id= 对应的部分,在已写好的查询 location 上方添加一个 location /api/cache/item/purge:
# 新增:清理本地缓存接口
location /api/cache/item/purge {
content_by_lua_block {
local id = ngx.var.arg_id
if not id then
ngx.status = 400
ngx.say('{"msg":"Missing id"}')
return
end
local cache = ngx.shared.item_cache
cache:delete("item:" .. id)
ngx.say('{"msg":"ok"}')
}
}
# 缓存单个商品详情
location ~ ^/api/items/\d+$ {
content_by_lua_file lua/item_detail.lua;
}
# 缓存批量查询
location ~ ^/api/items(\?.*ids=.*)$ {
content_by_lua_file lua/item_batch.lua;
}这样,当数据库数据更新时,主动调用 evictCache 即可清除 JVM、Redis、Nginx 三级缓存中对应 id 的数据,保证下次查询能拿到最新数据。
多级缓存同步总结
针对多级缓存的一致性,常见的几种思路可以根据业务特点组合使用:
- 有效期策略:给每级缓存设置合理的有效期,作为兜底保障
- 主动更新:在写操作处主动清除各级缓存,简单直接但有一定代码侵入
- Canal 监听 binlog:通过监听 MySQL binlog 异步通知缓存服务更新,对业务代码零侵入,适合多服务同步场景
在实际项目中,有效期策略与主动更新(或 Canal)往往结合使用:有效期作为最终一致性的兜底,主动更新/Canal 保证实时性。