缓存双写一致性之更新策略
一、问题的引入
在redis面试的时候,一般会遇到如下问题:
- 只要使用了缓存,就会涉及到redis缓存和数据库双存储双写,只要是双写,就一定有数据一致性的问题,那么针对数据一致性的问题如何解决;
- 双写一致性在实施的时候是先动缓存,还是MySQL数据库,哪一个?
- 在生产中遇到这么一种情况,微服务查询redis没有数据,而mysql有数据,为了保证数据双写一致性回写redis你需要注意什么?
- 双检加锁策略你了解吗?如何避免缓存击穿?
- redis和MySQL双写100%会出问题,做不强一致性,如何保证最终一致性?
二、缓存双写一致性说明
1.如果redis中有数据,需要和数据库中的值相同
2.如果redis中无数据,数据库中的值要是最新值,且准备回写redis
3.缓存按照操作来分,分为两种:
3.1.只读缓存
3.2.读写缓存
(1).同步直写缓存:
- 写数据库后也同步写入到redis缓存,缓存和数据库中的数据一致
- 对于读写缓存来说,要想保证缓存和数据库中的数据一致,就要采用同步直写策略
(2).异步缓写策略
- 正常业务运行中,MySQL数据变动了,但是可以在业务上容许出现一定时间后才作用于redis,例如:仓储物流
- 异常情况出现了,不得不将失败的动作重新修补,有可能需要借助kafka或者rebbitMQ等中间件,实现重试重写
4.双检加锁策略
4.1.业务问题,下面的操作怎么实现?

1.输入redis有数据,则直接从redis读取数据;
2.redis没有数据,则需要从MySQL获取数据;
3.步骤二从MySQL读取数据完成后,将MySQL数据回写进入redis;
4.2.双检加锁策略实现上述操作?
多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个 互斥锁来锁住它。其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。
案例代码如下:下面的findUserById2方法就是采用的双检加锁
便笺 package com.augus.redis.service; import com.augus.redis.entities.User; import com.augus.redis.mapper.UserMapper; import io.swagger.models.auth.In; import lombok.extern.slf4j.Slf4j; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.stereotype.Service; import org.springframework.web.bind.annotation.PathVariable; import javax.annotation.Resource; import java.util.concurrent.TimeUnit; @Service @Slf4j public class UserService { public static final String CACHE_KEY_USER = "user:"; @Resource private UserMapper userMapper; @Resource private RedisTemplate redisTemplate; /** * 对于公司(QPS《=1000)可以使用,但是大厂则是不行 * @param id * @return */ public User findUserById(Integer id) { User user = null; String key = CACHE_KEY_USER+id; //1 先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql user = (User) redisTemplate.opsForValue().get(key); if(user == null) { //2 redis里面无,继续查询mysql user = userMapper.selectByPrimaryKey(id); if(user == null) { //3.1 redis+mysql 都无数据 //这里需要在具体细化,防止多次穿透,业务规定,记录下导致穿透的这个key回写redis return user; }else{ //3.2 mysql有,需要将数据写回redis,保证下一次的缓存命中率 redisTemplate.opsForValue().set(key,user); } } return user; } /** * 加强补充,避免突然key失效了,导致mysql宕机,做一下预防,尽量不出现击穿的情况。 * @param id * @return */ public User findUserById2(Integer id) { User user = null; String key = CACHE_KEY_USER+id; //1 先从redis里面查询,如果有直接返回结果,如果没有再去查询mysql, // 第1次查询redis,加锁前 user = (User) redisTemplate.opsForValue().get(key); if(user == null) { //2对于高QPS的优化,进来就先加锁,保证一个请求操作,让外面的redis等待一下,避免击穿mysql synchronized (UserService.class){ //第2次查询redis,加锁后 user = (User) redisTemplate.opsForValue().get(key); //3 二次查redis还是null,可以去查mysql了(mysql默认有数据) if (user == null) { //4 查询mysql拿数据(mysql默认有数据) user = userMapper.selectByPrimaryKey(id); if (user == null) { return null; }else{ //5 mysql里面有数据的,需要回写redis,完成数据一致性的同步工作 redisTemplate.opsForValue().setIfAbsent(key,user,7L,TimeUnit.DAYS); } } } } return user; } }
三、数据库和缓存一致性的更新策略
3.1.数据库和缓存一致性的更新策略的作用是什么?
目的在于保证数据的一致性,给缓存设置过期时间,定期清理缓存并回写,是保证最终一致性的解决方案。
可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存,达到一致性,切记,要以mysql的数据库写入库为准。
上述方案和后续落地案例是调研后的主流+成熟的做法,但是考虑到各个公司业务系统的差距,不是100%绝对正确,不保证绝对适配全部情况。
3.2.数据更新的策略
3.2.1.策略选择
数据更新策略采用的思路是:DB拥有数据正确性的唯一解释权,所以一定要先操作DB,否则大概率出现并发问题。唯一可用策略:先更新数据库,后删除缓存
- 为了防止删缓存失败,引入MQ等中间件保障删缓存的一致性。
- 如果真的要求一致性应该考虑是否真的引入缓存。
- 如果真的需要引入缓存,只能通过分布式锁来解决。
- 常见的分布式事务问题,只要保证最终的数据一致性即可,例如:充值花费,先短信通知充值成功,在过几分钟只要充值成功就可以。
3.2.2.上述策略(先更新数据库,后删除缓存)的解决方案:
实现思路工作流程如下:
- 更新数据库中数据;
- 数据库会将数据的变更操作信息写入binlog日志当中;
- 订阅程序提取出所需要的数据以及key;
- 另起一段非业务代码,获得该信息;
- 尝试删除缓存操作,发现删除失败;
- 将这些信息发送至消息队列;
- redis重新从消息队列中获得该数据,重试操作。
上述解决方案需要注意的内容:
- 可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用Kafka/RabbitMQ等)。
- 当程序没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
- 如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了,否则还需要再次进行重试
- 如果重试超过的一定次数后还是没有成功,我们就需要向业务层发送报错信息了,通知运维人员。
3.2.3.canal
上述的订阅binlog程序在mysql中有现成的中间件叫canal,可以完成订阅binlog日志的功能。参考地址:https://github.com/alibaba/canal/wiki/,它可以监听MySQLbinlog日志中记录的数据变更,如果发现MySQL中数据发生了变化,立即写入到redis中
四.canal
4.1.1.canal是什么?
canal,主要用途是用于 MySQL 数据库增量日志数据的订阅、消费和解析,是阿里巴巴开发并开源的,采用Java语言开发;早期阿里巴巴因为杭州和美国双机房部署,存在跨机房数据同步的业务需求,实现方式主要是基于业务 trigger(触发器) 获取增量变更。从2010年开始,阿里巴巴逐步尝试采用解析数据库日志获取增量变更进行同步,由此衍生出
4.1.2.canal能做什么?
- 数据库镜像
- 数据库实时备份
- 索引构建和实时维护(拆分异构索引、倒排索引等)
- 业务cache刷新
- 带业务逻辑的增量数据处理
4.1.3.canal工作原理

工作原理
- canal 模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送 dump 协议
- MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal )
- canal 解析 binary log 对象(原始为 byte 流)
五、MySQL、canal和redis实现实现双写一致性
5.1.MySQL设置
这里推荐大家使用高版本的MySQL,本次案例使用的是MySQL8.0版本,
5.1.1.开启MySQL的binlog写入功能
在 MySQL配置文件 my.ini 中添加如下内容:
log-bin=mysql-bin #开启 binlog binlog-format=ROW #选择 ROW 模式 server_id=1 #配置MySQL replaction需要定义,不要和canal的 slaveId重复
对于模式选择说明如下:
- ROW模式: 除了记录sql语句之外,还会记录每个字段的变化情况,能够清楚的记录每行数据的变化历史,但会占用较多的空间。
- STATEMENT模式:只记录了sql语句,但是没有记录上下文信息,在进行数据恢复的时候可能会导致数据的丢失情况;
- MIX模式:比较灵活的记录,理论上说当遇到了表结构变更的时候,就会记录为statement模式。当遇到了数据更新或者删除情况下就会变为row模式;
如下图:

5.1.2.查看是否开启了MySQL的binlog写入功能
上面配置完成后需要重启MySQL服务,然后再执行命令查看:
SHOW VARIABLES LIKE 'log_bin';
如下图:ON表示已经打开

5.1.3.创建MySQL账户canal
默认是没有canal账户的,创建账户授权,用于canal连接MySQL,创建的SQL语句如下:
DROP USER IF EXISTS 'canal'@'%'; CREATE USER 'canal'@'%' IDENTIFIED BY 'canal';
GRANT ALL PRIVILEGES ON *.* TO 'canal'@'%' IDENTIFIED BY 'canal'; FLUSH PRIVILEGES;
创建后查询是否创建该账户:
SELECT * FROM mysql.user;
如下图所示:

5.2.canal服务端配置
5.2.1.下载
下载地址如下:https://github.com/alibaba/canal/releases

5.2.2.解压
在Linux根目录下创建 /mycanal 目录,将下载下来的文件上传,然后解压后如下:

5.2.3.配置
修改/mycanal/conf/example路径下instance.properties文件内容,需要设置有如下内容:
- 设置连接的MySQL的master主机的ip地址
- 设置连接MySQL的账户名和密码

5.2.4.启动
进入到 /mycanal/bin 目录下,执行如下命令进行启动:

5.2.5.查看
判断canal是否启动成功,查看server日志:

查看样例example日志:

5.3.canal客户端实现
5.3.1.创建表
在MySQL中db2022库中创建表 t_user,代码如下:
DROP TABLE IF EXISTS `t_user`; CREATE TABLE `t_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `userName` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, `passwd` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB AUTO_INCREMENT = 18 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci ROW_FORMAT = COMPACT;
5.3.2.创建springboot模块
在项目中创建springboot模块,操作如下:

设置模块信息:

设置模块名称:

5.3.3.导入依赖
在pom中导入依赖如下:
<properties> <java.version>1.8</java.version> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <junit.version>4.12</junit.version> <log4j.version>1.2.17</log4j.version> <lombok.version>1.16.18</lombok.version> <mysql.version>5.1.47</mysql.version> <druid.version>1.1.16</druid.version> <mapper.version>4.1.5</mapper.version> <mybatis.spring.boot.version>1.3.0</mybatis.spring.boot.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <!--canal--> <dependency> <groupId>com.alibaba.otter</groupId> <artifactId>canal.client</artifactId> <version>1.1.4</version> </dependency> <!--swagger2--> <dependency> <groupId>io.springfox</groupId> <artifactId>springfox-swagger2</artifactId> <version>2.9.2</version> </dependency> <dependency> <groupId>io.springfox</groupId> <artifactId>springfox-swagger-ui</artifactId> <version>2.9.2</version> </dependency> <!--SpringBoot与Redis整合依赖--> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency> <!--Mysql数据库驱动--> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.47</version> </dependency> <!--SpringBoot集成druid连接池--> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.1.10</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>${druid.version}</version> </dependency> <!--mybatis和springboot整合--> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>${mybatis.spring.boot.version}</version> </dependency> <!--jedis--> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>3.8.0</version> </dependency> </dependencies>
5.3.4.修改配置文件
在 application.yml 配置文件中添加内容如下:
server: port: 5556 # ========================druid===================== spring: datasource: type: com.alibaba.druid.pool.DruidDataSource driver-class-name: com.mysql.jdbc.Driver url: jdbc:mysql://localhost:3306/db2022?useUnicode=true&characterEncoding=utf-8&useSSL=false username: root password: 123456 druid: test-while-idle: false
5.3.5.创建配置类
创建包 utils在下面创建 RedisUtils 类,用于连接操作redis,代码如下:
package com.canal.demo.utils; import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; import redis.clients.jedis.JedisPoolConfig; public class RedisUtils { public static final String REDIS_IP_ADDR = "192.168.42.132"; public static final String REDIS_pwd = "123456"; public static JedisPool jedisPool; static { JedisPoolConfig jedisPoolConfig=new JedisPoolConfig(); jedisPoolConfig.setMaxTotal(20); jedisPoolConfig.setMaxIdle(10); jedisPool=new JedisPool(jedisPoolConfig,REDIS_IP_ADDR,6379,10000,REDIS_pwd); } public static Jedis getJedis() throws Exception { if(null!=jedisPool){ return jedisPool.getResource(); } throw new Exception("Jedispool is not ok"); } }
5.3.6.创建canal配置类
创建包 biz 在下面创建 RedisCanalClientExample 类,用于连接操作redis,代码如下:
package com.canal.demo.biz; import com.alibaba.fastjson.JSONObject; import com.alibaba.otter.canal.client.CanalConnector; import com.alibaba.otter.canal.client.CanalConnectors; import com.alibaba.otter.canal.protocol.CanalEntry.*; import com.alibaba.otter.canal.protocol.Message; import com.canal.demo.utils.RedisUtils; import redis.clients.jedis.Jedis; import java.net.InetSocketAddress; import java.util.List; import java.util.UUID; import java.util.concurrent.TimeUnit; public class RedisCanalClientExample { public static final Integer _60SECONDS = 60; public static final String REDIS_IP_ADDR = "192.168.42.132"; private static void redisInsert(List<Column> columns) { JSONObject jsonObject = new JSONObject(); for (Column column : columns) { System.out.println(column.getName() + " : " + column.getValue() + " update=" + column.getUpdated()); jsonObject.put(column.getName(),column.getValue()); } if(columns.size() > 0) { try(Jedis jedis = RedisUtils.getJedis()) { jedis.set(columns.get(0).getValue(),jsonObject.toJSONString()); }catch (Exception e){ e.printStackTrace(); } } } private static void redisDelete(List<Column> columns) { JSONObject jsonObject = new JSONObject(); for (Column column : columns) { jsonObject.put(column.getName(),column.getValue()); } if(columns.size() > 0) { try(Jedis jedis = RedisUtils.getJedis()) { jedis.del(columns.get(0).getValue()); }catch (Exception e){ e.printStackTrace(); } } } private static void redisUpdate(List<Column> columns) { JSONObject jsonObject = new JSONObject(); for (Column column : columns) { System.out.println(column.getName() + " : " + column.getValue() + " update=" + column.getUpdated()); jsonObject.put(column.getName(),column.getValue()); } if(columns.size() > 0) { try(Jedis jedis = RedisUtils.getJedis()) { jedis.set(columns.get(0).getValue(),jsonObject.toJSONString()); System.out.println("---------update after: "+jedis.get(columns.get(0).getValue())); }catch (Exception e){ e.printStackTrace(); } } } public static void printEntry(List<Entry> entrys) { for (Entry entry : entrys) { if (entry.getEntryType() == EntryType.TRANSACTIONBEGIN || entry.getEntryType() == EntryType.TRANSACTIONEND) { continue; } RowChange rowChage = null; try { //获取变更的row数据 rowChage = RowChange.parseFrom(entry.getStoreValue()); } catch (Exception e) { throw new RuntimeException("ERROR ## parser of eromanga-event has an error,data:" + entry.toString(),e); } //获取变动类型 EventType eventType = rowChage.getEventType(); System.out.println(String.format("================> binlog[%s:%s] , name[%s,%s] , eventType : %s", entry.getHeader().getLogfileName(), entry.getHeader().getLogfileOffset(), entry.getHeader().getSchemaName(), entry.getHeader().getTableName(), eventType)); for (RowData rowData : rowChage.getRowDatasList()) { if (eventType == EventType.INSERT) { redisInsert(rowData.getAfterColumnsList()); } else if (eventType == EventType.DELETE) { redisDelete(rowData.getBeforeColumnsList()); } else {//EventType.UPDATE redisUpdate(rowData.getAfterColumnsList()); } } } } public static void main(String[] args) { System.out.println("--------- main方法-----------"); //================================= // 创建链接canal服务端 /** * REDIS_IP_ADDR 这个是redis的服务器ip * 11111 则是一个没有被占用的端口号即可 */ CanalConnector connector = CanalConnectors.newSingleConnector(new InetSocketAddress(REDIS_IP_ADDR, 11111), "example", "", ""); int batchSize = 1000; //空闲空转计数器 int emptyCount = 0; System.out.println("---------------------canal 初始化完成,开始监听mysql------"); try { connector.connect(); //connector.subscribe(".*\\..*"); connector.subscribe("db2022.t_user"); //需要监听的是那个表 connector.rollback(); int totalEmptyCount = 10 * _60SECONDS; while (emptyCount < totalEmptyCount) { System.out.println("我是canal,每秒一次正在监听:"+ UUID.randomUUID().toString()); Message message = connector.getWithoutAck(batchSize); // 获取指定数量的数据 long batchId = message.getId(); int size = message.getEntries().size(); if (batchId == -1 || size == 0) { emptyCount++; try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } } else { //计数器重新置零 emptyCount = 0; printEntry(message.getEntries()); } connector.ack(batchId); // 提交确认 // connector.rollback(batchId); // 处理失败, 回滚数据 } System.out.println("已经监听了"+totalEmptyCount+"秒,无任何消息,请重启重试......"); } finally { connector.disconnect(); } } }
5.3.7.测试
启动redis和canal,确保连通,然后启动springboot模块,执行上面的main方法,如下图:

打开Navicat ,连接MySQL数据,在t_user表中添加数据,后查看idea控制台,会显示本次的操作以及对应的数据:

查看Redis,发现当MySQL中出现数据变化的时候,canal会监控该变化,将其写入到redis中去:

补充java程序下connector.subscribe配置的过滤正则


浙公网安备 33010602011771号