一、先别慌,咱们得承认这个事实
很多刚接手高并发系统的同学,看到MySQL主从延迟或者数据不一致,第一反应是“是不是配置错了?”“是不是网络卡了?”其实,只要用了主从架构,延迟就是必然存在的。哪怕你是SSD、万兆光纤、同机房部署,只要写操作发生在Master,读操作发生在Slave,中间就存在一个“时间差”。
这个时间差,轻则毫秒,重则几分钟甚至更久。而一旦业务逻辑依赖“写完立刻读得出来”,或者像双写场景那样同时写主库和缓存/另一套存储,麻烦就来了。
咱们今天不聊虚的,直接上干货,把主从延迟的本质、双写一致性怎么保、以及那些让人头疼的坑,一个个掰开揉碎讲清楚。
二、主从延迟的根源:你以为是同步,其实是异步
MySQL的主从复制,默认是异步复制(Async Replication)。
1. 异步复制的工作流程
想象一下你在银行排队存钱:
- 你把钱交给柜员(主库写binlog)。
- 柜员把存折盖个章,告诉你存好了(返回ACK给客户端)。
- 钱被送到后台金库(Slave拉取binlog并重放)。
问题是:第2步和第3步之间,可能有几分钟甚至几小时。你在第2步已经拿到“存好了”的凭证,但金库里可能还没这笔钱。
-- 主库执行
BEGIN;
INSERT INTO users (id, name) VALUES (1, 'Alice');
COMMIT;
-- 客户端立刻收到成功响应
-- 此时Slave可能还没收到这条binlog
-- 如果从库立刻读,可能读到旧数据
2. 半同步复制(Semi-Sync)能解决吗?
半同步复制要求至少一个Slave在事务提交前确认收到并落盘了binlog。这确实降低了延迟,但:
- 性能下降明显:写操作要等网络往返,高并发下RT飙升。
- 不是零延迟:Slave宕机、网络抖动,主库会阻塞甚至超时。
- 依然有窗口期:从“Slave确认”到“Slave执行完毕”,依然有微小延迟。
所以,不要幻想通过配置完全消除延迟。真正的工程思路是:承认延迟,设计容忍延迟的系统。
三、双写场景:主库和缓存/从库同时写,谁说了算?
双写,通常指同时写入主库和缓存(如Redis),或者主库和从库业务库。目的是提高读性能,避免每次读都穿透到数据库。
但双写带来的最大问题:顺序不一致。
场景演示
假设用户修改头像:
- 客户端请求改头像。
- 应用先写主库MySQL。
- 应用再删除Redis缓存。
- 同时,Slave从主库同步数据,延迟500ms。
- 另一个请求过来,从Slave读,发现头像还是旧的。
- 缓存也还没更新。
结果:用户看到了旧头像,或者主库和从库数据不一致。
双写一致性的核心矛盾
| 操作顺序 | 问题 |
|---|---|
| 先删缓存,后写库 | 删了缓存但库没写完,高并发下可能读到旧数据(缓存空窗口期) |
| 先写库,后删缓存 | 库写成功,缓存还没删,读请求可能拿到旧缓存(短暂不一致) |
| 先写库,再写缓存 | 如果写缓存失败,数据不一致 |
没有完美的顺序,只有权衡和兜底策略。
四、保障最终一致性的5个实战策略
策略1:延迟双删(Delay Double Delete)
这是最经典的缓存一致性方案,专门解决“先删缓存,后写库”导致的竞态条件。
流程:
- 删除缓存。
- 更新数据库。
- ** sleep 500ms**(根据主从延迟预估)。
- 再次删除缓存。
// 伪代码示例
public void updateUserHeadPic(Long userId, String newHeadPic) {
// 1. 先删缓存
redisClient.del("user:head:" + userId);
// 2. 更新数据库
mysqlClient.update("UPDATE users SET head_pic = ? WHERE id = ?", newHeadPic, userId);
// 3. 延迟一段时间,确保从库同步完成
Thread.sleep(500);
// 4. 再次删缓存,防止从库数据更新后缓存还是旧值
redisClient.del("user:head:" + userId);
}
为什么有效?
- 如果并发请求在步骤2之前读了缓存,它会读到旧缓存,但步骤1已经删了,所以它会去查库(假设库是主库,数据已更新)。
- 如果并发请求在步骤2之后、步骤3之前读了,它可能读到主库新数据,但缓存被步骤4再次删除,下次请求会重新加载。
- 延迟时间要大于主从同步的最大延迟,否则步骤4删缓存时,从库还没同步完,新请求可能从从库读到旧数据并回填到缓存。
坑点: sleep时间难确定,网络抖动时500ms可能不够,2秒又太长影响性能。
策略2:订阅Binlog,异步更新缓存(Canal + Redis)
这是目前大厂主流方案,把缓存更新和业务逻辑解耦。
原理:
- 业务代码只写主库,不碰缓存。
- 部署Canal Agent,伪装成MySQL Slave,实时订阅主库binlog。
- Canal将变更消息投递到MQ(如Kafka/RocketMQ)。
- 消费者监听MQ,更新Redis。
// Canal消费者伪代码
@RocketMQMessageListener(topic = "user_update", consumerGroup = "cache_sync_group")
public class UserCacheSyncConsumer implements RocketMQListener<UserBinlogEvent> {
@Override
public void onMessage(UserBinlogEvent event) {
String userId = event.getUserId();
String headPic = event.getNewHeadPic();
// 更新缓存
redisClient.set("user:head:" + userId, headPic);
// 可选:发送确认消息,用于补偿
mqClient.send("user_cache_sync_ack", userId);
}
}
优势:
- 业务代码干净,只关注数据库。
- 缓存更新与主库同步解耦,天然容忍延迟。
- 可以通过MQ重试机制保证最终一致。
坑点:
- 复杂度上升:多了Canal、MQ,运维成本提高。
- 乱序问题:binlog是按事务顺序的,但如果MQ消费乱序,可能导致缓存数据错乱。解决方案:使用消息队列的顺序消息,或在消费者端做版本号校验。
- 重复消费:MQ可能重复投递,缓存更新要做幂等(如set而不是incr)。
策略3:读写分离 + 强制主库读取
对于强一致性要求的场景,不要读从库。
做法:
- 写操作后,下一次读操作强制路由到主库。
- 可以通过ThreadLocal标记“刚写过,下次读主库”。
public class ReadPreference {
private static final ThreadLocal<Boolean> FORCE_MASTER = new ThreadLocal<>();
public static void forceMasterRead() {
FORCE_MASTER.set(true);
}
public static boolean needMaster() {
return Boolean.TRUE.equals(FORCE_MASTER.get());
}
public static void clear() {
FORCE_MASTER.remove();
}
}
// 服务层调用
@Transactional
public void updateAndRefresh(Long userId) {
// 更新主库
userMapper.update(userId, newData);
// 标记下次读主库
ReadPreference.forceMasterRead();
// 同步刷新缓存
redisClient.set("user:" + userId, newData);
}
// 查询服务
public User getUser(Long userId) {
if (ReadPreference.needMaster()) {
return userMapper.selectFromMaster(userId); // 强制读主库
}
return userMapper.selectFromSlave(userId); // 读从库
}
优势: 简单粗暴,一致性最强。
坑点: 主库压力大,读请求多时主库可能成为瓶颈。适合写多读少或关键数据。
策略4:版本号/时间戳比较
在数据中增加版本号或更新时间戳,缓存和数据库都存。
流程:
- 更新数据库,版本号+1。
- 更新缓存,版本号+1。
- 读的时候,比较缓存版本号和数据库版本号。
-- 数据库表
ALTER TABLE users ADD COLUMN version INT DEFAULT 0;
-- 更新
UPDATE users SET head_pic = ?, version = version + 1 WHERE id = ?;
// 缓存更新
public void updateUser(Long userId, String headPic) {
int newVersion = userMapper.incrementVersion(userId, headPic);
redisClient.setex("user:version:" + userId, 3600, newVersion);
redisClient.setex("user:head:" + userId, 3600, headPic);
}
// 读取校验
public User getUser(Long userId) {
String cachedHead = redisClient.get("user:head:" + userId);
Integer cachedVersion = redisClient.get("user:version:" + userId);
// 查数据库版本号
Integer dbVersion = userMapper.getVersion(userId);
// 如果版本不一致,说明缓存过期,重新加载
if (cachedVersion == null || !cachedVersion.equals(dbVersion)) {
User user = userMapper.selectFromMaster(userId); // 强制读主库
redisClient.setex("user:head:" + userId, 3600, user.getHeadPic());
redisClient.setex("user:version:" + userId, 3600, user.getVersion());
return user;
}
return buildUserFromCache(cachedHead, cachedVersion);
}
优势: 能检测到不一致并自动修复。
坑点: 每次读都要多查一次版本号,性能有影响。
策略5:最终一致性补偿任务
对于非实时敏感数据,可以接受短暂不一致,通过定时任务扫描修复。
做法:
- 记录数据变更日志(或者直接用binlog)。
- 定时任务对比主库和从库/缓存的数据。
- 发现不一致,自动修复。
-- 示例:每小时对比一次
SELECT m.id, m.head_pic, s.head_pic AS slave_head_pic
FROM users m
LEFT JOIN slave_users s ON m.id = s.id
WHERE m.head_pic != s.head_pic OR s.head_pic IS NULL;
优势: 兜底方案,确保长期一致性。
坑点: 实时性差,只适合最终一致场景。
五、常见坑点排查清单
坑点1:从库SQL线程阻塞
现象: Seconds_Behind_Master 数值很大,且持续增长。
原因: Slave上某个慢查询或大事务阻塞了SQL线程,导致后续binlog无法执行。
排查:
-- 主库查看从库状态
SHOW SLAVE STATUS\G
-- 关注这些字段:
-- Seconds_Behind_Master: 延迟秒数
-- Slave_SQL_Running: 是否为Yes
-- Last_SQL_Error: 是否有错误
解决:
- 分析慢查询,优化SQL或加索引。
- 避免在Slave上执行写操作(双写架构要注意)。
- 考虑使用GTID模式,便于跳过错误继续同步。
坑点2:网络分区导致主从脑裂
现象: 主库和从库数据差异巨大,且无法自动恢复。
原因: 网络抖动,主库认为从库在线,从库认为主库挂了,各自独立服务。
解决:
- 使用MHA、Orchestrator等主从切换工具,避免脑裂。
- 配置
skip-slave-start,手动控制从库启动。 - 定期备份,确保能快速重建从库。
坑点3:大事务导致同步延迟
现象: 偶尔出现长时间延迟,然后瞬间追平。
原因: 一个大事务(如批量删除10万条数据)在Master执行时,Slave需要顺序执行,耗时极长。
解决:
- 避免大事务,拆分小批次操作。
- 使用
pt-online-schema-change等工具进行DDL操作。 - 考虑使用并行复制(MySQL 5.7+支持)。
-- 开启并行复制
SET GLOBAL slave_parallel_type = 'LOGICAL_CLOCK';
SET GLOBAL slave_parallel_workers = 4;
坑点4:双写时的“写穿”失败
现象: 缓存更新了,但数据库没更新成功,或者反之。
原因: 事务边界不明确,或者分布式事务未正确处理。
解决:
- 使用本地消息表或TCC模式保证原子性。
- 优先保证数据库成功,缓存失败可补偿。
- 监控缓存和数据库的一致性。
// 本地消息表示例
@Transactional
public void updateUserWithMessage(Long userId, String headPic) {
// 1. 更新数据库
userMapper.update(userId, headPic);
// 2. 插入消息表(同一事务)
messageMapper.insert(new Message(userId, "UPDATE_CACHE", headPic));
// 3. 业务逻辑...
}
// 定时任务发送消息
@Scheduled(fixedRate = 5000)
public void sendMessage() {
List<Message> pendingMessages = messageMapper.selectPending();
for (Message msg : pendingMessages) {
try {
// 更新缓存
redisClient.set("user:head:" + msg.getUserId(), msg.getContent());
// 标记成功
messageMapper.markSuccess(msg.getId());
} catch (Exception e) {
// 记录错误,等待重试
log.error("Cache update failed", e);
}
}
}
坑点5:监控缺失,发现时已造成用户投诉
现象: 用户反馈数据不一致,运维才发现主从延迟严重。
解决:
- 实时监控
Seconds_Behind_Master,设置告警阈值(如>5秒)。 - 定期采样对比主从数据,计算差异率。
- 建立数据一致性告警体系。
# 简单监控脚本示例
while true; do
delay=$(mysql -h master -e "SHOW SLAVE STATUS\G" | grep Seconds_Behind_Master | awk '{print $2}')
if [ "$delay" -gt 5 ]; then
echo "Alert: Slave delay is ${delay}s" | mail -s "MySQL Replication Alert" admin@example.com
fi
sleep 30
done
六、总结:没有银弹,只有权衡
主从延迟和双写一致性,是分布式系统的经典难题。没有完美的解决方案,只有适合业务场景的权衡。
- 如果业务对一致性要求极高(如金融交易),不要读从库,强制主库读取,接受性能损耗。
- 如果业务能容忍短暂不一致(如社交动态、商品详情),延迟双删或Binlog订阅是性价比最高的选择。
- 如果数据量巨大,并行复制和读写分离是基础设施必备。
最重要的是:建立监控,快速发现,从容应对。别让数据不一致悄悄侵蚀你的用户信任。
希望这篇内容能帮你理清思路。如果还有具体问题,欢迎继续交流!
