一、为什么要把MySQL数据同步到Redis?
很多互联网业务都有一个特点:读多写少,比如你逛电商商品页、查自己的用户信息、看社区帖子,每次请求都是读数据,很少会改这些数据。这时候如果每次都去MySQL查,就像你每次买奶茶都要去工厂取原料一样,效率极低,用户等的时间长,体验差。 所以我们会把MySQL的核心数据,放到Redis这种内存数据库里做缓存,就像你平时买奶茶存个积分卡,下次直接用积分卡取,不用再去工厂查。这种缓存的好处是读速度快,延迟只有几毫秒,能扛住大流量的读请求,同时把MySQL的压力降下来。而主从复制的MySQL环境,刚好可以做主存储,负责写和最终的数据持久化,Redis做缓存,负责高速读,两者结合就很合适。
二、怎么用Binlog和Cache Aside做同步?
2.1 先搞懂Binlog是什么
MySQL有个“操作日志”,叫Binlog,就像你点外卖后收到的订单回执,每一次对数据的变更(增删改)都会记在这个日志里,而且不会随便删除,只会按顺序追加。主从复制的原理,就是主库把Binlog发给从库,从库执行日志里的操作,让自己和主库的数据保持一致。 我们要同步MySQL到Redis,其实就是监听这个Binlog,知道MySQL什么时候改了数据,然后同步这个变更到Redis里,不用自己去解析MySQL的底层变更,省了很多麻烦。目前常用的监听工具是Canal,阿里开源的,很多大厂都在用,稳定可靠。
2.2 Cache Aside模式怎么玩?
Cache Aside(缓存旁置模式)是最常用的缓存读写模式,逻辑很简单,就像你买奶茶的流程:
- 读数据:先查你的积分卡(Redis),有就直接用;没有就去柜台(MySQL)取,然后把取到的结果写到积分卡(Redis)里,下次再用。
- 写数据:先去柜台(MySQL)改数据,然后把积分卡(Redis)里的旧数据撕掉(删除),这样下次别人买的时候,就会去柜台取最新的数据,再写到积分卡里。 这个模式的优点是逻辑简单,容易理解和维护,缺点就是如果写的顺序搞反了,就会出现脏读和数据漂移的问题,这也是我们要重点解决的。
三、怎么避免脏读和数据漂移?
3.1 先搞懂什么是脏读和数据漂移
脏读就像你用旧的积分卡买奶茶,积分卡上记的是旧的信息,奶茶店的系统已经更新了,但你拿到的还是旧的积分卡信息,最后拿到的奶茶不对。放到技术里,就是缓存里的数据和MySQL里的数据不一样,请求拿到了旧的缓存数据,就是脏读。 数据漂移比脏读更严重,是指缓存和MySQL的数据越来越偏,比如用户改了10次信息,缓存里只更新了2次,慢慢的两个数据完全对不上,变成两个“不同的数据源”,给业务带来错误的数据。
3.2 具体的解决方案(附完整Java示例)
核心就是两个关键点:写的时候先改MySQL,再删Redis缓存,还有给Redis设合理的过期时间,另外还要用Canal监听Binlog做兜底的同步。 首先明确本次示例的技术栈,保证单一技术栈:Java 1.8 + MySQL 5.7 + Redis 3.2 + Canal 1.1.4。
示例1:Binlog监听同步Redis的核心代码
这个代码的作用是监听MySQL的user表的Binlog,一旦有增删改操作,就删除对应的Redis缓存,避免脏读。
// 技术栈:Java 1.8 + MySQL 5.7 + Redis 3.2 + Canal 1.1.4
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 redis.clients.jedis.Jedis;
import java.net.InetSocketAddress;
public class CanalSyncRedisHandler {
// 配置项,根据自己的环境修改
private static final String REDIS_HOST = "127.0.0.1";
private static final int REDIS_PORT = 6379;
private static final String CANAL_HOST = "127.0.0.1";
private static final int CANAL_PORT = 11111;
private static final String CANAL_DEST = "example"; // Canal实例名
private static final String MONITOR_TABLE = "user"; // 监听的MySQL表名
public static void main(String[] args) {
// 1. 创建Canal连接,用于监听MySQL Binlog
CanalConnector canalConnector = CanalConnectors.newSingleConnector(
new InetSocketAddress(CANAL_HOST, CANAL_PORT),
CANAL_DEST,
"",
""
);
// 2. 连接Canal并订阅指定表的Binlog
canalConnector.connect();
canalConnector.subscribe(".*\\." + MONITOR_TABLE); // 监听所有库的user表
// 3. 创建Redis连接
Jedis jedis = new Jedis(REDIS_HOST, REDIS_PORT);
try {
// 循环拉取Binlog消息
while (true) {
// 批量获取Binlog消息,每次最多100条
Message message = canalConnector.getWithoutAck(100);
long batchId = message.getId();
int entrySize = message.getEntries().size();
// 没有新消息,休眠1秒再拉,避免空循环占用资源
if (batchId == -1 || entrySize == 0) {
Thread.sleep(1000);
continue;
}
// 处理每一条Binlog条目
for (CanalEntry.Entry entry : message.getEntries()) {
// 只处理行变更类型的Binlog(增删改),忽略其他类型(比如DDL语句)
if (entry.getEntryType() != CanalEntry.EntryType.ROWDATA) {
continue;
}
// 解析行变更的具体内容
CanalEntry.RowChange rowChange = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
// 获取当前操作的表名
String currentTable = entry.getHeader().getTableName();
// 只处理我们要监听的user表
if (!MONITOR_TABLE.equals(currentTable)) {
continue;
}
// 获取操作类型:INSERT/UPDATE/DELETE
CanalEntry.EventType eventType = rowChange.getEventType();
// 处理每一行的变更
for (CanalEntry.RowData rowData : rowChange.getRowDatasList()) {
String userId;
// 根据操作类型取用户ID(假设user表的主键是id,在第一列)
if (eventType == CanalEntry.EventType.DELETE) {
// 删除操作:从旧数据行取ID
userId = rowData.getBeforeColumnsList().get(0).getValue();
} else {
// 更新/插入操作:从新数据行取ID
userId = rowData.getAfterColumnsList().get(0).getValue();
}
// 删除Redis中对应的缓存key,让下次请求重新查MySQL
String redisKey = "user:" + userId;
jedis.del(redisKey);
System.out.println("已删除Redis缓存key:" + redisKey);
}
}
// 确认已处理的Binlog,提交偏移量,避免重复拉取
canalConnector.ack(batchId);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
// 关闭连接
canalConnector.disconnect();
jedis.close();
}
}
}
示例2:Cache Aside模式的服务层代码
这个代码是业务层的读写逻辑,严格按照Cache Aside的顺序,避免脏读。
import redis.clients.jedis.Jedis;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import com.google.gson.Gson;
// 用户服务类,实现Cache Aside模式
public class UserService {
// 依赖注入,实际项目中会用Spring的@Autowired
private Jedis jedis = new Jedis("127.0.0.1", 6379);
private JdbcTemplate jdbcTemplate = new JdbcTemplate();
private Gson gson = new Gson(); // 用于对象和JSON互转
// 根据用户ID查询用户信息(读操作:Cache Aside读流程)
public User getUserById(Long userId) {
String redisKey = "user:" + userId;
// 1. 先查Redis缓存
String cacheJson = jedis.get(redisKey);
if (cacheJson != null && !cacheJson.isEmpty()) {
// 缓存命中,直接返回,把JSON转成User对象
return gson.fromJson(cacheJson, User.class);
}
// 2. 缓存未命中,查MySQL数据库
String sql = "SELECT id, name, age FROM user WHERE id = ?";
User user = jdbcTemplate.queryForObject(sql, new Object[]{userId},
new BeanPropertyRowMapper<>(User.class));
// 3. 把查询结果写入Redis缓存,设置过期时间1800秒(30分钟)
if (user != null) {
jedis.setex(redisKey, 1800, gson.toJson(user));
}
return user;
}
// 更新用户信息(写操作:Cache Aside写流程)
public void updateUser(User user) {
// 1. 先更新MySQL数据库,这是核心顺序的第一步,不能反过来
String sql = "UPDATE user SET name = ?, age = ? WHERE id = ?";
jdbcTemplate.update(sql, user.getName(), user.getAge(), user.getId());
// 2. 删除Redis中的旧缓存,让下次请求重新加载最新数据
String redisKey = "user:" + user.getId();
jedis.del(redisKey);
System.out.println("已删除用户" + user.getId() + "的缓存,下次请求将加载最新数据");
}
// 用户实体类,对应MySQL的user表
public static class User {
private Long id;
private String name;
private Integer age;
// get/set方法省略,实际项目中需要写
}
}
3.3 注意事项
上面的示例已经解决了核心的脏读问题,但还有几个细节要注意,不然还是会出现数据漂移:
- 过期时间不能乱设:过期时间是兜底机制,比如设30分钟,就算同步工具没删缓存,30分钟后也会自动过期,避免一直用旧数据。太长的话数据延迟高,太短的话每次都查MySQL,所以根据业务调整,比如商品详情设1小时,用户信息设15分钟。
- Canal的异常处理:如果Canal挂了,要自动重启,而且要设置重试次数,避免Binlog丢失。如果监听中断了,会出现一段时间的缓存没有同步,这时候可以做一个定时任务,每天凌晨把全量数据同步到Redis,作为兜底。
- 缓存Key的命名规范:用“表名:主键值”的格式,比如user:1001,不要用“user1001”或者其他格式,方便排查问题,也能避免和其他业务的Key冲突。
- 不要用更新缓存代替删除缓存:很多人会在写的时候,直接把新数据写进Redis,代替删除,这会导致并发问题,比如两个请求同时改数据,会覆盖对方的更新,所以删除缓存更安全,逻辑也更简单。
四、这个方案的优缺点
4.1 优点
- 读性能极高:Redis是内存数据库,读速度是MySQL的几十倍,能扛住大流量的读请求,适合电商、社区等场景。
- 逻辑简单:Cache Aside模式是最容易理解的缓存模式,Canal监听Binlog的方式也稳定,不需要复杂的分布式事务。
- 减轻MySQL压力:大部分读请求都落在Redis上,MySQL只负责写和极少的读请求,能延长MySQL的生命周期。
4.2 缺点
- 最终一致性:不是强一致,改数据后,缓存要等过期或者下次请求才会更新,适合允许短时间延迟的场景,比如商品信息、用户资料,不适合需要强一致的场景,比如银行转账。
- 运维成本:需要维护Canal集群、Redis集群,还要处理网络异常、数据同步的问题,对运维有一定要求。
- 监控复杂度:要监控Canal的状态、Redis的命中率、数据同步的延迟,不然出问题很难排查。
五、总结
MySQL主从复制结合Binlog监听和Redis Cache Aside的方案,是目前互联网企业用的最多的缓存方案之一,核心就是解决读多写少场景的性能问题。只要严格按照“先改MySQL,再删Redis缓存”的顺序,设置合理的过期时间,处理好Canal和Redis的异常,就能很好的避免脏读和数据漂移。这个方案适合大多数不需要强一致的业务场景,只要根据业务的特点调整参数,就能快速落地,提升系统的性能和用户体验。
评论
围绕“在主从复制环境中,MySQL数据变更实时同步到Redis缓存,基于Binlog监听与Cache Aside模式结合的工程经验,又该如何避免脏读与数据漂移发酵。”参与讨论