MySQL 和 Redis 数据一致性,以及 Redis 与 ZooKeeper 分布式锁对比

引言

在高并发系统中,两个问题非常常见:

  • MySQL 和 Redis 如何尽量保持数据一致
  • 分布式场景下应该选择 Redis 锁还是 ZooKeeper 锁

这两个问题本质上都和"状态一致性"有关。前者关注数据库与缓存之间的数据同步,后者关注多个节点之间的互斥访问控制。

本文将把这两部分内容放在一起梳理,重点说明:

  • 更新数据时缓存一致性为什么容易出问题
  • 常见方案分别解决了什么问题
  • Redis 分布式锁和 ZooKeeper 分布式锁各自适合什么场景
  • 分布式锁为什么会失效,以及常见解决思路是什么

如何保证 MySQL 和 Redis 数据一致性

先说结论:在使用缓存时,真正需要重点关注一致性的,通常是"更新"和"删除"场景,而不是"新增"场景。

因为新增数据时,通常只需要先写入 MySQL,后续查询不到缓存时再回源加载即可;而更新和删除场景中,旧缓存很容易和新数据库内容不一致。

先删 Redis,再更新 MySQL,会有什么问题

这是一种很多人第一反应会想到的方案:

  1. 先删除 Redis 缓存
  2. 再更新 MySQL

看起来逻辑很顺,但在高并发场景下会有一个经典问题。

假设有两个线程:

  • 线程 A:删除 Redis 成功,准备更新 MySQL
  • 线程 B:在线程 A 更新 MySQL 之前,读取数据

这时线程 B 会发现缓存不存在,于是去 MySQL 里查数据。由于线程 A 还没有更新完数据库,线程 B 读到的还是旧值,然后线程 B 又把旧值重新写回 Redis。

结果就是:

  • MySQL 里是新值
  • Redis 里又变成了旧值

这就产生了缓存脏数据问题。

延迟双删是什么

为了解决上面的问题,常见思路是"延迟双删"。

典型流程如下:

  1. 先删除 Redis
  2. 再更新 MySQL
  3. 等待一小段时间
  4. 再次删除 Redis

这样做的目的是:

  • 防止并发线程在数据库更新期间把旧数据重新写回缓存

如果确实有线程在这个时间窗口里把旧值写回去了,那么第二次删除可以把这份脏缓存再清掉。

延迟双删的问题

延迟双删不是银弹,核心难点在于"延迟多久"。

如果睡眠时间太短:

  • 可能数据库主从同步、业务回写缓存等动作还没完成
  • 第二次删除过早执行,脏数据仍可能再次进入缓存

如果睡眠时间太长:

  • 一致性窗口可能更小
  • 但接口耗时会明显变长,影响系统吞吐和响应时间

所以延迟双删只能缓解问题,不能 100% 保证强一致。

先更新 MySQL,再删除 Redis,是更常见的做法

相比"先删缓存再更新数据库",工程上更常见的是:

  1. 先更新 MySQL
  2. 再删除 Redis

这样做的原因是:

  • 数据库通常是最终事实来源
  • 先把数据库改对,再让缓存失效,后续读请求自然会从数据库加载新值

这种方案整体上更符合大多数业务系统的设计习惯,所以也是更常见的实践。

如果删除 Redis 失败怎么办

即使采用"先更新 MySQL,再删除 Redis"的策略,也有一个不能回避的问题:

  • 如果数据库更新成功了,但 Redis 删除失败怎么办

这时数据库和缓存仍然会不一致。

方案一:通过 MQ 做失败重试

常见做法是:

  1. 先更新 MySQL
  2. 删除 Redis
  3. 如果删除失败,就把重试任务投递到 MQ
  4. 消费端异步重试删除缓存

这种方式的优点是:

  • 时效性较强
  • 适合高并发系统
  • 可以把失败处理和主流程解耦

这也是工程上比较推荐的方案。

方案二:通过 Canal 订阅 binlog 同步缓存

另一种思路是:

  1. 只更新 MySQL
  2. 使用 Canal 订阅 MySQL binlog
  3. 根据 binlog 变更去删除或更新 Redis

这个方案的优点是:

  • 业务代码侵入性低
  • 数据变更链路更统一

但它的缺点也比较明显:

  • 链路更长
  • 时效性通常不如直接删缓存或 MQ 重试
  • 运维复杂度更高

各方案优缺点怎么理解

可以把常见方案简单归纳为下面三类。

方案一:延迟双删

优点:

  • 思路简单
  • 实现门槛低

缺点:

  • 增加接口耗时
  • 对睡眠时间敏感
  • 不能 100% 消除不一致窗口

方案二:更新 MySQL 后删除 Redis,失败时通过 MQ 重试

优点:

  • 效率较高
  • 实时性较好
  • 更适合生产环境

缺点:

  • 需要额外引入 MQ 和重试机制
  • 需要处理消息可靠性和幂等问题

方案三:Canal 监听 binlog 同步缓存

优点:

  • 业务层改动较少
  • 变更同步链路集中

缺点:

  • 时效性相对弱一些
  • 部署和维护成本更高

如果只看大多数业务系统的综合权衡,通常会更倾向于:

"先更新 MySQL,再删除 Redis,删除失败时通过 MQ 补偿重试。"

新增数据时要不要处理 Redis 一致性

一般来说,新增数据场景不需要像更新和删除那样重点处理缓存一致性。

常见做法是:

  1. 直接写 MySQL
  2. 不主动写缓存
  3. 等后续读请求触发缓存加载

因为这里不存在"旧缓存残留"的问题,所以复杂度会低很多。

Redis 分布式锁和 ZooKeeper 分布式锁有什么区别

这两种方案都能实现分布式锁,但它们的设计基础不同,因此在性能、释放机制和一致性方面差异明显。

性能

Redis

Redis 基于内存操作,读写性能非常高,在高并发场景下通常比 ZooKeeper 更快。

因此如果你的核心诉求是:

  • 高吞吐
  • 低延迟
  • 更轻量的接入成本

Redis 分布式锁通常会更有优势。

ZooKeeper

ZooKeeper 的锁实现通常依赖节点创建、删除和会话管理,这些操作需要经过一致性协议协调,因此整体性能通常不如 Redis。

所以从纯性能角度看:

  • Redis 更强
  • ZooKeeper 更稳

锁释放机制

Redis

Redis 锁通常依赖过期时间来防止死锁。

这意味着你在加锁时,需要考虑:

  • 锁超时时间设置多长
  • 业务执行时间是否可能超过这个时间

如果超时时间太短,业务还没执行完,锁就自动释放了;如果超时时间太长,客户端异常退出后,其他线程需要等较久才能重新获取锁。

很多实现会配合 Watch Dog 自动续期机制,尽量减少这类问题。

ZooKeeper

ZooKeeper 通常基于临时节点实现锁。

当客户端会话断开,比如:

  • 进程崩溃
  • 机器宕机
  • 网络断开导致会话失效

ZooKeeper 会自动删除对应的临时节点,从而自动释放锁。

这也是 ZooKeeper 锁在可靠性上非常重要的一个优势。

可靠性和一致性

Redis

Redis 更强调性能和可用性,但在主从切换、复制延迟、故障恢复等场景下,锁的严格一致性不如 ZooKeeper。

因此在一些对锁可靠性要求非常高的场景里,Redis 锁需要格外谨慎设计。

ZooKeeper

ZooKeeper 是强一致协调组件,天然更适合做:

  • 分布式协调
  • 选主
  • 配置管理
  • 高可靠分布式锁

所以如果业务对锁的正确性要求极高,ZooKeeper 往往更稳妥。

分布式锁为什么会失效

无论是 Redis 锁还是其他实现,锁失效问题都要重点关注。

常见锁失效原因

自动续期失败

如果使用的是带自动续期机制的 Redis 锁,比如 Redisson Watch Dog,那么在下面这些场景里可能续期失败:

  • 客户端宕机
  • 网络异常
  • Redis 压力过大
  • 线程长时间阻塞

一旦续期失败,锁可能在业务执行完成前提前过期。

Redis 键过期或被误删

Redis 锁本质上还是一个键。如果:

  • 过期时间配置不合理
  • 被其他代码误删

那么锁就会提前失效。

Redis 集群故障或主从切换

如果 Redis 发生主从切换、复制延迟或部分节点故障,锁状态可能出现短暂不一致。

这也是 Redis 分布式锁争议最大的地方之一。

没有正确释放锁

如果业务执行时抛出异常,而代码又没有在 finally 中执行 unlock(),就会导致锁释放异常。

所以正确写法通常都是:

java 复制代码
lock.lock();
try {
    // 业务逻辑
} finally {
    lock.unlock();
}

锁自动过期时间和业务执行时间不匹配

如果锁超时时间短于实际业务执行时间,那么业务还没做完,锁就已经失效,其他线程可能会再次获取锁。

这会直接破坏互斥性。

如何解决主节点宕机导致的锁失效问题

如果你仍然希望使用 Redis 锁,又想降低单点主节点故障带来的风险,一个常见思路是 RedLock。

RedLock 的基本思路

RedLock 的核心思想是:

  • 不依赖单个 Redis 实例
  • 而是依赖多个彼此独立的 Redis Master 节点共同判定是否加锁成功

通常建议使用奇数个节点,比如 5 个。

这些节点之间:

  • 相互独立
  • 不依赖主从复制
  • 不依赖单一协调者

RedLock 的加锁流程

一个典型流程如下:

  1. 客户端依次向多个 Redis 实例申请同一把锁
  2. 加锁时使用相同的 key 和随机值
  3. 每次请求都设置较短的超时时间,避免某个实例挂掉后长时间等待
  4. 客户端统计成功加锁的实例数量和总耗时
  5. 只有在"超过半数节点加锁成功"并且"总耗时小于锁有效期"时,才认为加锁成功

例如当有 5 个节点时,至少要在 3 个节点上获取成功,才算真正持有锁。

如果最终获取失败,就应该主动去所有节点执行解锁,哪怕某些节点其实根本没有加锁成功。

RedLock 的问题要怎么看

RedLock 的目标是提升 Redis 锁在故障场景下的可用性和可靠性,但它并不等于"绝对正确"。

所以在工程上通常可以这样理解:

  • 如果你要的是高性能分布式锁,Redis 或 Redisson 仍然很常见
  • 如果你要的是更强的协调一致性,ZooKeeper 往往更适合

也就是说,RedLock 是一种增强方案,但不是所有场景下的最终答案。

Redisson RedLock 示例

下面是一个常见写法:

java 复制代码
RLock lock1 = redissonInstance1.getLock("lock1");
RLock lock2 = redissonInstance2.getLock("lock2");
RLock lock3 = redissonInstance3.getLock("lock3");

RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);

lock.lock();
try {
    // 业务逻辑
} finally {
    lock.unlock();
}

它的核心思路就是:

  • 同时在多个独立 Redis 节点上尝试加锁
  • 只要大多数节点成功,就认为整体加锁成功

实际选型怎么做

如果你的业务更看重:

  • 性能
  • 吞吐
  • 接入简单

那么 Redis 分布式锁通常更适合。

如果你的业务更看重:

  • 强一致性
  • 锁释放的确定性
  • 分布式协调可靠性

那么 ZooKeeper 往往更适合。

所以很多时候并不是"哪个更强",而是:

"哪个更适合你的业务容错边界。"

总结

这篇文章可以压缩成几条核心结论:

  1. MySQL 和 Redis 一致性问题,重点在更新和删除场景
  2. 更常见的实践是"先更新 MySQL,再删除 Redis"
  3. 删除缓存失败时,MQ 重试通常是更实用的补偿方案
  4. Redis 锁性能更好,但在故障场景下的一致性和可靠性要谨慎评估
  5. ZooKeeper 锁性能较弱,但自动释放和强一致性更适合高可靠场景
  6. RedLock 可以增强 Redis 锁的可靠性,但不等于所有问题都能完全消失

真正落地时,不要只看技术名词,而要结合业务对性能、一致性、可恢复性的要求做权衡。


如果这篇文章对你有帮助,欢迎继续阅读本系列后续内容。若文中有不准确或需要补充的地方,也欢迎指出。

相关推荐
恋喵大鲤鱼3 小时前
MySQL 某个表字段实现分布式锁
mysql·分布式锁
lifewange3 小时前
GaussDB /openGauss 与 MySQL、Oracle、PostgreSQL 核心对比表
mysql·oracle·gaussdb
fundoit4 小时前
MySQL Workbench中的权限设置不生效
数据库·mysql
ZzzZZzzzZZZzzzz…4 小时前
MySQL备份还原方法2----LVM
linux·运维·数据库·mysql·备份还原
M--Y4 小时前
Redis常用数据类型-2
数据库·redis
Devin~Y4 小时前
大厂 Java 面试实战:从电商微服务到 AI 智能客服(含 Spring 全家桶、Redis、Kafka、RAG/Agent 解析)
java·spring boot·redis·elasticsearch·spring cloud·docker·kafka
qq_396227954 小时前
Git 分布式版本控制
分布式·git
富士康质检员张全蛋4 小时前
Kafka JMS
分布式·kafka
路baby4 小时前
Pikachu安装过程中常见问题(apache和MySQL无法正常启动)
计算机网络·mysql·网络安全·adb·靶场·apache·pikachu