Redis 和 MySQL 如何实现数据一致性
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 中的对应缓存(而不是直接更新)
- 步骤
- 更新 MySQL 数据
- 删除 Redis 中对应的缓存键(DEL key)
- 下次查询时,从 MySQL 重新加载数据到 Redis
- 优点:避免了更新 Redis 的复杂逻辑,适合读多写少的场景
3.2. 使用事务或消息队列#
- 方法:通过事务或消息队列(如 Kafka、RabbitMQ)确保 MySQL 和 Redis 的更新顺序一致
- 步骤
- 将 MySQL 更新操作写入事务
- 更新成功后,通过消息队列通知 Redis 更新
- 消费消息并更新 Redis
- 优点:解耦 MySQL 和 Redis 操作,支持分布式系统,容错性高
3.3. 双写一致性(Cache Aside 模式)#
- 读数据:先查 Redis,若命中则返回;若未命中,从 MySQL 查询并写入 Redis
- 写数据:先更新 MySQL, 再删除 Redis 缓存
为什么先更新 MySQL, 再删除或更新 Redis 缓存?
在 MySQL 和 Redis 的组合中,MySQL 保存的是“权威数据”(source of truth),而 Redis 是缓存,用于提升性能。缓存的数据本质上是 MySQL 的副本。如果先更新 Redis 而 MySQL 更新失败(例如事务回滚、数据库宕机),会导致 Redis 中的数据是“脏数据”,与 MySQL 不一致。用户后续从缓存读取到错误数据,影响业务逻辑
双写一致性可能导致的问题
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
可以先删除 Redis, 再更新 MySQL, 但更新 MySQL 之后 需要再删一次缓存
-
线程 A 删除 Redis 缓存
-
线程 B 查询时未命中缓存, 从 MySQL 读取旧值并写回 Redis
-
线程 A 更新 MySQL 为新值
-
结果:Redis 又变成了旧值 (与 MySQL 不一致)
延迟双删的目的正是为了在后续操作中纠正这种不一致, 确保系统最终达到一致性, 在 “先删除 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 的窗口)
4. 综合实践 秒杀系统#
延迟双删是为了保证缓存和数据库的一致性, 而 分布式锁机制 是为了 保证数据库的数据一致性, 在秒杀系统中, 商品库存是一个核心数据, 假设某个商品初始库存为100件, 用户通过秒杀活动购买, 每次购买会减少库存, 我们需要确保:
- 数据库的数据一致性:库存不会超卖, 即高并发下不会出现多个线程同时扣减库存导致负值的情况
- 缓存与数据库的一致性:Redis缓存中的库存数据与MySQL数据库中的库存数据最终一致
系统架构
- 数据库(MySQL):存储商品的实际库存
- 缓存(Redis):存储库存的缓存数据,用于快速查询
- 分布式锁:通过Redis(如SETNX命令)实现分布式锁,确保高并发下对数据库的操作是串行化的
操作流程
- 线程 A 尝试获取商品 1001 的分布式锁(例如
lock_{1001}
),抢锁成功 - 线程 A 删除 Redis 缓存(Key =
product_stock_{1001}
) - 线程 A 从数据库查询当前库存(假设查到的是 100),进行库存检查 → 大于 0,则更新库存为 99(
UPDATE ... SET stock = 99 WHERE product_id = 1001
) - 线程 A 提交事务,数据库此时真正更新为库存 99
- 线程 A 处理完数据库更新后,开启一个线程或通过定时任务,延迟一段时间(比如 500ms~1000ms)后,再执行一次“删除 Redis 缓存(Key =
product_stock_{1001}
)”的操作 - 线程 A 释放分布式锁
此时,如果在 A 更新完数据库与“延迟删除缓存”之间,有线程 B进来要操作库存,会发生什么呢?
- 线程 B 试图抢锁,如果抢锁成功了(说明 A 已经释放了锁):
- 线程 B 也会先删缓存,然后去读数据库,此时读到的已经是更新后的库存
99
,并做后续检查 + 更新操作 - 同理,B 更新完也会再延迟双删,以保证缓存后续查询一定能用到最新数据
- 线程 B 也会先删缓存,然后去读数据库,此时读到的已经是更新后的库存
由于有分布式锁的存在,在“检查 + 更新”整个过程都是串行化的,不会出现两条并发写操作抢数据库,导致超卖或脏写。同时,“延迟双删”依然起到确保“缓存最终一致”的作用。
当我们使用了分布式锁, 延迟双删 还有必要吗?
因为我的想法是, 线程A获取分布式锁, 然后检查 + 更新, 而更新由 (比如删除缓存 + 更新数据库)组成, 之后 A 释放锁, 为什么 线程B 会读取到缓存里的旧数据?
我忽略了一个情况, 我们的分布式锁一般都是在业务逻辑上实现的, 比如某个方法, 比如用户购买, 我们为了防止 检查 + 更新 操作造成的数据不一致, 可是, 我忽略了有的方法可能只是为了读数据 (读缓存, 如不存在, 读取数据库, 然后写入缓存), 这样的情况分布式锁是避免不了的, 因为我们不可能所有操作读写都加锁, 这样会限制性能, 所以为了防止 在 线程 A 删除缓存 和 更新数据库 这个时间之间, 有其他线程因 仅读取数据造成 旧数据 又重新写会 缓存, 线程 A 在执行 删除缓存 + 庚勋数据库操作之后, 需要再进行一次删除, 即延迟双删,
除此之外, 当 A 删除缓存后更新数据库 → 与 → B 抢锁失败 / 等待 → 与 → A 延迟再删缓存期间,假如出现一些读请求(比如线程 C),可能读到的还是旧值——如果在线程A第二次删除缓存后, 线程C才进行旧数据回写, 又出现缓存旧数据问题了, 不过这概率很小