1. MySQL 的数据一致性#

事务(Transaction):MySQL 支持 ACID(原子性、一致性、隔离性、持久性)事务。通过 BEGIN、COMMIT 和 ROLLBACK,可以确保一组操作要么全部成功,要么全部回滚

锁机制:MySQL 使用行锁、表锁或 MVCC(多版本并发控制)来处理并发访问,保证数据一致性。例如,InnoDB 引擎通过 MVCC 避免脏读和不可重复读

主从复制:在主从架构中,MySQL 通过 binlog(二进制日志)记录所有写操作,主库将 binlog 同步到从库。虽然可能存在主从延迟(异步复制),可以通过配置半同步复制或同步复制来提高一致性

2. Redis 的数据一致性#

2.1. 单线程模型 - 天然原子性#

Redis 的核心特性之一是单线程执行命令, 所有命令按顺序执行, 不存在并发竞争问题, 天然保证了操作的原子性和一致性,

假设有两个客户端同时对同一个键 counter 执行 INCR(自增)操作, 由于单线程模型,Redis 会顺序处理这两个请求,最终结果一定是 counter 增加 2,而不是出现并发覆盖导致的错误值

2.2. 超卖 秒杀系统 - 分布式锁 / Lua脚本#

分布式锁 如基于 Redis 的 SETNX 或 ZooKeeper, SETNX 是 Redis 中的一个字符串操作命令, 全称是 “SET if Not eXists”, 常见流程:

  • 获取分布式锁(比如 Redis SETNX lock_key 1

  • 检查库存(SELECT stock FROM inventory WHERE id = 1

  • 如果库存足够,更新库存(UPDATE inventory SET stock = stock - 1 WHERE id = 1 AND stock > 0

  • 释放锁(DEL lock_key

假设库存存储在 MySQL 中, 初始值为 10:

-- 线程 1
SETNX lock_key 1  -- 获取锁成功
SELECT stock FROM inventory WHERE id = 1  -- 返回 10
UPDATE inventory SET stock = stock - 1 WHERE id = 1 AND stock > 0  -- 更新为 9
DEL lock_key  -- 释放锁

-- 线程 2
SETNX lock_key 1  -- 获取锁失败,等待
-- 锁释放后重试上述步骤

分布式锁是传统解决方案, 而 Lua 脚本是一种更高效的替代方案, 需要注意的是 Lua 脚本只能在 Redis 服务器内部执行, 操作的数据必须是 Redis 中的键值对, 因此, 库存数据必须存储在 Redis 中, 更新也仅发生在 Redis 层面, 如果业务需要更新 MySQL 等持久化存储, 还需要额外的同步机制(比如将 Redis 更新结果异步写入 MySQL), 如果宕机或数据未及时同步到 MySQL, 可能丢失更新

而分布式锁的方案中, 我们只需要在业务逻辑获取分布式锁, 然后直接操作 MySQL 就行了

Lua 脚本可以实现的原因是 Redis 是单线程模型, 而 Lua 脚本在 Redis 作为一个整体执行, 所以根本不存在数据竞争问题, 每个 Lua 脚本都是按顺序执行的

2.3. Lua 脚本 - 类似回滚#

Redis 的 Lua 脚本作为一个整体在 Redis 内部执行, 中间不会被打断, 是完全原子性的, Lua 脚本没有显式的“回滚”机制, 单若 Lua 脚本失败时整个脚本不生效, 因此无需回滚

-- Lua 脚本
local key = KEYS[1]
redis.call('SET', key, 'value1')
redis.call('SET', key..'x', 'value2')  -- 假设这里会失败
return 'done'

如果 redis.call('SET', key..'x', 'value2') 失败, 整个脚本中止, key 的值不会被设置为 value1, 这不是回滚,而是脚本整体未提交,

Redis 通过 MULTI 和 EXEC 提供有限的事务支持, 仅保证一组命令原子执行, 但不支持回滚

MULTI
DECRBY account:A 100
INCRBY account:B 100
EXEC

2.4. 持久化 主从复制#

Redis 支持异步主从复制, 主节点将写操作异步同步到从节点, 虽然简单高效, 但可能导致短暂的数据不一致,

Redis 通过持久化(RDB 和 AOF)和主从复制来增强数据一致性和可靠性,虽然这更多是针对数据持久性而非实时一致性

  • RDB:通过生成数据库的快照来实现持久化, 它会将某个时间点 Redis 内存中的数据以二进制格式保存到一个 .rdb 文件中

    • 如果服务器在两次快照之间崩溃,可能会丢失部分数据(取决于快照频率)
    • 数据丢失可接受的场景
  • AOF:记录每条写命令, 当 Redis 宕机并重启时, 读取 AOF 文件, 从头到尾重新执行这些命令, 从而重建内存中的数据状态

    • AOF 记录每条写命令,配合 appendfsync always 几乎不会丢失数据,即使是 everysec 也只可能丢失 1 秒的数据
    • 写命令需要追加到文件,同步策略(如 always)会增加磁盘 I/O,影响性能
  • 主从复制:主节点将写操作同步到从节点,保证多副本一致性(最终一致性)

3. MySQL 和 Redis 数据不同步#

当 MySQL 数据更新(插入、修改、删除)时,Redis 中的缓存未及时更新或未更新,导致查询 Redis 时返回旧数据

3.1. 缓存失效策略#

  • 方法:在更新 MySQL 时,主动删除 Redis 中的对应缓存(而不是直接更新)
  • 步骤
    1. 更新 MySQL 数据
    2. 删除 Redis 中对应的缓存键(DEL key)
    3. 下次查询时,从 MySQL 重新加载数据到 Redis
  • 优点:避免了更新 Redis 的复杂逻辑,适合读多写少的场景

3.2. 使用事务或消息队列#

  • 方法:通过事务或消息队列(如 Kafka、RabbitMQ)确保 MySQL 和 Redis 的更新顺序一致
  • 步骤
    1. 将 MySQL 更新操作写入事务
    2. 更新成功后,通过消息队列通知 Redis 更新
    3. 消费消息并更新 Redis
  • 优点:解耦 MySQL 和 Redis 操作,支持分布式系统,容错性高

3.3. 双写一致性(Cache Aside 模式)#

方法:标准的缓存读写模式

步骤

  1. 读数据:先查 Redis,若命中则返回;若未命中,从 MySQL 查询并写入 Redis
  2. 写数据:先更新 MySQL,再删除或更新 Redis 缓存

优点:逻辑清晰,易于实现

缺点:高并发下可能出现短暂不一致, 需配合锁或延迟双删

为什么先更新 MySQL, 再删除或更新 Redis 缓存?

在 MySQL 和 Redis 的组合中,MySQL 保存的是“权威数据”(source of truth),而 Redis 是缓存,用于提升性能。缓存的数据本质上是 MySQL 的副本。如果先更新 Redis 而 MySQL 更新失败(例如事务回滚、数据库宕机),会导致 Redis 中的数据是“脏数据”,与 MySQL 不一致。用户后续从缓存读取到错误数据,影响业务逻辑。

为什么不反过来(先删除 Redis,再更新 MySQL)?

如果先删除 Redis 缓存,再更新 MySQL,可能在两者之间出现短暂的“缓存穿透”问题:

  • 线程 A 删除 Redis 缓存

  • 线程 B 查询时未命中缓存,从 MySQL 读取旧值并写回 Redis

  • 线程 A 更新 MySQL 为新值

  • 结果:Redis 又变成了旧值,与 MySQL 不一致

解决方案:这就是“延迟双删”策略的由来,即先删除 Redis,更新 MySQL 后再延迟一段时间再次删除 Redis。但这种方式增加了复杂度,通常在“先更新 MySQL,再删除 Redis”已经够用的情况下不推荐。

双写一致性 “高并发下可能出现短暂不一致, 需配合锁或延迟双删”

T1: 线程 A 更新 MySQL,将 balance 改为 200

T2: 线程 B 查询 Redis,发现缓存中 balance 还是 100(因为线程 A 还没来得及删除缓存)

T3: 线程 B 返回旧值 100 给客户端

T4: 线程 A 删除 Redis 缓存

  • 在 T2 到 T4 这段时间,Redis 返回的是旧值 100,而 MySQL 已经是新值 200,出现了短暂的数据不一致
  • 不一致通常只存在于“更新 MySQL”和“删除 Redis”之间的短暂时间(通常是毫秒级
  • 根本原因: 更新 MySQL 和删除 Redis 是两个独立的操作,无法保证原子性

解决方案1:配合锁或延迟双删

思路:通过锁机制(例如分布式锁)确保更新操作和读取操作不会同时发生,避免并发竞争

实现:

  • 在更新操作时,获取一个针对该数据的锁(例如 Redis 分布式锁)
  • 更新 MySQL 和删除 Redis 完成后释放锁
  • 读取操作也需要检查锁,若被占用则等待

方法 2:延迟双删

思路:在“先删除 Redis,再更新 MySQL”的基础上,更新 MySQL 后再延迟一段时间再次删除 Redis 缓存,以清理可能被其他线程回写的脏数据

T1: 线程 A 删除 Redis 缓存

T2: 线程 B 查询,未命中缓存,从 MySQL 读取旧值 100 并写回 Redis

T3: 线程 A 更新 MySQL 为 200

T4: 线程 A 延迟 500ms 后再次删除 Redis 缓存

T5: 下次查询从 MySQL 加载新值 200

缺点:仍然存在短暂不一致的风险(T2 到 T4 的窗口)