返回文章列表
高并发项目
Redis缓存一致性Canalbinlog

16保证缓存一致性

我们建立了多级缓存,但是如果数据库的数据更新了,我们需要保证 Nginx、JVM、Redis 缓存全部更新,保证每一级缓存中的内容都和数据库一致。

缓存数据同步策略

缓存数据同步的常见方式有三种:

设置有效期

给缓存设置有效期,到期后自动删除,再次查询时更新。

  • 优势:简单、方便
  • 缺点:时效性差,缓存过期之前可能不一致
  • 场景:更新频率较低,时效性要求低的业务

同步双写

在修改数据库的同时,直接修改缓存。

  • 优势:时效性强,缓存与数据库强一致
  • 缺点:有代码侵入,耦合度高
  • 场景:对一致性、时效性要求较高的缓存数据

异步通知

修改数据库时发送事件通知,相关服务监听到通知后修改缓存数据。

  • 优势:低耦合,可以同时通知多个缓存服务
  • 缺点:时效性一般,可能存在中间不一致状态
  • 场景:时效性要求一般,有多个服务需要同步

而异步通知的实现又可以基于 MQ 或 Canal 来实现。

基于MQ的异步通知

基于MQ的异步通知

  • 商品服务完成对数据的修改后,只需要发送一条消息到 MQ 中
  • 缓存服务监听 MQ 消息,然后完成对缓存的更新

这种方式需要商品服务在修改数据库后主动发送消息,有一定的代码侵入。

基于Canal的异步通知

基于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

安装和配置 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 保证实时性。

项目下载地址

gupengzu/high-concurrency-project at 16Caches-Safe