使用canal订阅binlog,实现缓存更新的小demo

870 阅读3分钟

前言

数据库和缓存的一致性问题由来已久,我个人认为订阅binlog来实现缓存更新是比较靠谱且优雅的一个方案,所以写个小demo记录一下。

1.使用docker搭建环境

version: '2.2'
services:
  mysql:
    image: mysql:latest
    container_name: canal_mysql
    ports:
      - "3306:3306"
    environment:
      - MYSQL_ROOT_PASSWORD=12345
    networks:
      - canal_example
  redis:
    image: redis:latest
    container_name: canal_redis
    ports:
      - "6379:6379"
    networks:
      - canal_example
  canal:
    image: canal/canal-server:v1.1.5
    container_name: canal_server
    environment:
      - canal.instance.master.address=canal_mysql:3306
      - canal.instance.dbUsername=root
      - canal.instance.dbPassword=12345
      - canal.instance.connectionCharset=UTF-8
      - canal.instance.tsdb.enable=true 
      - canal.instance.gtidon=false 
    ports:
      - "11110:11110"
      - "11111:11111"
      - "11112:11112"
      - "9100:9100"
    networks:
      - canal_example

networks:
  canal_example:
    driver: bridge

编写docker compose 文件,借助docker-compose一键搭建测试环境。

2.编写代码进行测试

  1. 创建spring项目,连接数据库与redis
@Data
@Entity
@Table(name = "book")
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;

    private String name;

    private String author;

    private Integer price;

}

随便创建一个实体类,使用jpa自动映射到数据库中(docker启动数据库之后需要先创建一个库,在通过代码连接)。

  1. 编写代码实现有缓存则访问缓存,无缓存则访问数据库功能
@RestController
@AllArgsConstructor
@RequestMapping("book")
public class CanalController {

    private BookRepository bookRepository;

    private RedisTemplate<String, String> redisTemplate;

    @GetMapping("count")
    public Long queryBookCount() {
        String bookCount = redisTemplate.opsForValue().get(Constant.REDIS_BOOK_COUNT_KEY);
        if (bookCount != null) {
            log.info("query book count hit cache");
            return Long.parseLong(bookCount);
        }
        return countBook();
    }

    @PostMapping("add")
    public String addBook(@RequestBody Book book) {
        bookRepository.save(book);
        return "success";
    }

    private synchronized long countBook() {
        String bookCount = redisTemplate.opsForValue().get(Constant.REDIS_BOOK_COUNT_KEY);
        if (bookCount != null) {
            log.info("query book count hit cache");
            return Long.parseLong(bookCount);
        }
        log.info("query book count miss cache,query database and set cache!");
        long count = bookRepository.count();
        redisTemplate.opsForValue().set(Constant.REDIS_BOOK_COUNT_KEY, String.valueOf(count));
        return count;
    }

}
  1. 使用canal订阅mysql的binlog日志,实现缓存更新逻辑
public class SimpleCanalClientTask implements Runnable {

    private CanalConnector connector;

    private RedisTemplate<String, String> redisTemplate;

    public SimpleCanalClientTask(RedisTemplate<String, String> redisTemplate) {
        this.redisTemplate = redisTemplate;
        this.connector = CanalConnectors.newSingleConnector(new InetSocketAddress(AddressUtils.getHostIp(),
                11111), "example", "", "");
    }

    @Override
    public void run() {
        int batchSize = 100;
        try {
            connector.connect();
            connector.subscribe(".*");
            connector.rollback();
            while (true) {
                Message message = connector.getWithoutAck(batchSize); // 获取指定数量的数据
                long batchId = message.getId();
                int size = message.getEntries().size();
                if (batchId == -1 || size == 0) {
                    try {
                        //如果没有获取到数据,等待5秒继续
                        Thread.sleep(5000);
                    } catch (InterruptedException e) {
                        break;
                    }
                } else {
                    handleEntry(message.getEntries());
                }
                connector.ack(batchId); // 提交确认
            }

        } finally {
            connector.disconnect();
        }
    }

    private void handleEntry(List<Entry> entries) {
        for (Entry entry : entries) {
            if (entry.getEntryType() == EntryType.TRANSACTIONBEGIN || entry.getEntryType() == EntryType.TRANSACTIONEND) {
                continue;
            }

            RowChange rowChange;
            try {
                rowChange = RowChange.parseFrom(entry.getStoreValue());
            } catch (Exception e) {
                throw new RuntimeException("ERROR ## parser of eromanga-event has an error , data:" + entry.toString(),
                        e);
            }

            EventType eventType = rowChange.getEventType();

            String schema = entry.getHeader().getSchemaName();
            String table = entry.getHeader().getTableName();
            
            //如果获取到了指定表的更新操作,则删除缓存,让下一次查询直接查数据库
            if (Constant.CONTROL_SCHEMA.equals(schema) && Constant.CONTROL_TABLE.equals(table)) {
                if (eventType == EventType.DELETE || eventType == EventType.INSERT) {
                    log.info("receive change in book ,delete cache!");
                    redisTemplate.delete(Constant.REDIS_BOOK_COUNT_KEY);
                }
            }

        }
    }

}

接下来就是测试环节了,首先初始化的时候在表里放一些数据

INSERT INTO `canal`.`book` (`id`, `author`, `name`, `price`) VALUES (1, '吴承恩', '西游记', 15);
INSERT INTO `canal`.`book` (`id`, `author`, `name`, `price`) VALUES (2, '曹雪芹', '红楼梦', 12);
INSERT INTO `canal`.`book` (`id`, `author`, `name`, `price`) VALUES (3, '罗贯中', '三国演义', 17);
INSERT INTO `canal`.`book` (`id`, `author`, `name`, `price`) VALUES (4, '施耐庵', '水浒传', 13);

通过日志和打印出的sql可以判断请求是否查询了缓存

2022-02-28 10:45:23.745  INFO 2092 --- [nio-8080-exec-1] c.v.s.c.c.controller.CanalController     : query book count miss cache,query database and set cache!
Hibernate: select count(*) as col_0_0_ from book book0_
2022-02-28 10:46:30.308  INFO 2092 --- [nio-8080-exec-3] c.v.s.c.c.controller.CanalController     : query book count hit cache

接下来可以手动修改数据库信息,随便添加或删除一条数据

receive change in book ,delete cache!

可以看到日志中触发了删除缓存的逻辑,也就是订阅到了binlog。接下来再次查询的话则又会去查数据库了。这样通过订阅binlog,我们就可以控制缓存的更新逻辑。

该项目代码都已经上传github,欢迎讨论