Redis与MySQL的双写一致性

2896 字
6 分钟阅读

在日常开发中,我们经常会碰到这样一个经典的场景:为了提升性能,系统会优先查询Redis。如果Redis命中,则直接返回;如果Redis未命中,则查询MySQL,将结果返回给客户端的同时,再同步回写到Redis中。

image

这个看似简单的架构,背后却隐藏着数据一致性的挑战。只要你用到了缓存,就必然会面临缓存与数据库双写时的数据一致性问题。

经典面试问题

准备好了吗?面试官通常会这样拷问你:

  • 只要是双写,就一定有数据一致性的问题,你如何解决?
  • 你选择先操作缓存(Redis)还是先操作数据库(MySQL)?为什么?
  • 延时双删策略你用过吗?它有什么潜在的问题?
  • 在高并发场景下,如何尽量避免缓存击穿的问题?双重检查锁定(Double-Checked Locking)了解吗?
  • 如果一个请求发现Redis无数据而MySQL有数据,在回写缓存时需要注意什么?
  • 我们都知道,Redis和MySQL的双写做不到100%的强一致性,你如何保证最终一致性?

什么是双写一致性?

简单来说,双写一致性追求的目标是:

  1. 如果Redis中有数据,那么这个数据必须和数据库中的值相同。
  2. 如果Redis中无数据,那么数据库中的值必须是最新值,后续请求会将这个最新值回写到Redis。

为了实现这个目标,业界探索出了多种更新策略。接下来,我们将深入探讨四种主流的策略,分析它们的优劣和风险。


四种更新策略大PK

策略一:先更新数据库,再更新缓存

这是一种非常符合直觉的策略,但它在并发场景下存在致命缺陷。

  • 异常情况1(单线程)

    1. 线程将MySQL中某商品的库存从100更新为99,更新成功
    2. 接着,线程尝试更新Redis,但此时更新Redis失败(例如,网络抖动或Redis宕机)。
    3. 结果:数据库中的库存是99,而Redis中的库存依然是100,数据出现不一致。后续的读请求会从Redis读到脏数据。
  • 异常情况2(多线程)
    假设A、B两个线程同时更新同一个数据:

    1. 线程A将数据库更新为100。
    2. 线程B将数据库更新为80。
    3. 线程B更新Redis缓存为80。
    4. 由于网络延迟等原因,线程A现在才成功更新Redis缓存为100。
    5. 最终结果:数据库中的值是80,而Redis中的值是100。数据再次出现严重不一致!o(T_T)o

结论: 无论哪种情况,该策略都存在数据不一致的风险,不推荐使用。

策略二:先更新缓存,再更新数据库

这种策略的风险更大。缓存的写入速度通常远快于数据库,但数据库的成功与否才是业务的底线。

  • 异常情况(多线程)

    1. 线程A更新Redis缓存为100。
    2. 线程B更新Redis缓存为80。
    3. 线程B将数据库更新为80。
    4. 由于网络延迟,线程A现在才将数据库更新为100。
    5. 最终结果:数据库中的值是100,而缓存中的值是80。数据不一致。

最关键的问题是,我们通常将MySQL等关系型数据库作为数据底单,它是数据最终正确性的保证。如果先更新了缓存,但数据库更新失败,那么缓存中的就是一条“未来”才会存在的脏数据,业务逻辑会彻底混乱。

结论: 风险极高,业务上通常不允许这样做。

策略三:先删除缓存,再更新数据库

这种策略看似解决了更新的问题,但在“读+写”的并发场景下,会引入更隐蔽的坑。

  • 并发读写异常

    1. 写请求A进来,第一步先将Redis缓存删除
    2. 写请求A去更新数据库,但由于网络或数据库负载原因,这个操作耗时较长,尚未提交。
    3. 此时,一个读请求B进来,发现Redis中没有数据(缓存缺失)
    4. 读请求B只能去查询数据库,查到的是旧值(因为A的更新还没完成)。
    5. 读请求B将这个旧值回写到Redis缓存中,以便下次读取。
    6. 写请求A终于完成了数据库的更新。

最终结果: 数据库里是最新值,但Redis里却是刚刚被回写的旧值。数据不仅不一致,而且这个脏数据还会一直存在,直到下一次更新或缓存过期。A线程直接懵了 o(T_T)o。

image

解决方案:延迟双删策略

为了解决上述问题,可以引入“延迟双删”:在更新完数据库后,再延迟一段时间,进行第二次的缓存删除。

image

核心思想: 延迟的这段时间(比如sleep几百毫秒),就是为了等待上面场景中的读请求B完成“查询旧值并回写缓存”的操作。随后,写请求A的第二次删除操作,就能将这个刚被污染的脏数据再次从缓存中清除。

  • 延迟时间如何确定?
    这是一个棘手的问题。你需要评估自己项目中“读数据+写缓存”的平均耗时,然后设置一个比这个时间更长的延迟。这缺乏科学依据,只能靠估算。
  • 新问题:吞吐量下降
    让写请求sleep等待,会显著降低系统的吞吐量,这在高性能系统中是难以接受的。一个优化方案是异步进行第二次删除,即主线程更新完数据库后立即返回,另外启动一个独立的线程或线程池来执行延迟删除操作。

策略四:先更新数据库,再删除缓存(推荐)

这是业界公认的、相对最优的方案,也被称为 Cache-Aside Pattern

image

  • 正常流程

    1. 更新数据库。
    2. 成功后,删除缓存。
  • 为什么是删除而不是更新?

    • 性能开销:有时缓存的数据是经过复杂计算得出的,更新缓存的成本很高。而删除操作则非常轻量。
    • 懒加载思想:删除缓存后,让下一次读请求在需要时,自然地从数据库加载最新数据到缓存中,确保了数据的时效性。
  • 是否存在风险?
    理论上,依然存在一个极小概率的风险:

    1. 写请求A更新了数据库。
    2. 写请求A准备删除缓存,但此时发生延迟。
    3. 读请求B此时进来,读到了缓存的旧值。
    4. 写请求A完成缓存删除。
      这种情况下,读请求B会读到一次旧数据,但后续的请求会重新从数据库加载,所以只会造成短暂的不一致。

微软的官方云文档也推荐了这种 Cache-Aside 模式,它指出:应用程序通过修改数据存储,并使缓存中的相应项失效(删除)来遵循该策略。当下一次需要该数据时,Cache-Aside策略会从数据存储中检索更新的数据并将其添加回缓存中。

image


如何应对高并发下的缓存击穿?

即使采用了“先更新数据库,再删除缓存”的策略,当一个热点Key的缓存被删除后,瞬时的大量请求会同时涌向数据库,造成缓存击穿

解决方案:双重检查锁定(Double-Checked Locking)

image

当多个线程同时发现缓存未命中时,我们可以用一个互斥锁来保证只有一个线程能去查询数据库:

  1. 第一个线程获取锁,去查询数据库,并将结果写入缓存,最后释放锁。
  2. 其他线程在获取锁之前会等待。当它们获取到锁之后,再次检查缓存,此时会发现缓存已经被第一个线程填充了,于是可以直接从缓存中获取数据,无需再查询数据库。

如何保证最终一致性?

以上所有策略都无法做到100%的强一致性。在分布式系统中,我们更多追求的是最终一致性

  1. 为缓存设置过期时间(TTL)
    这是最重要的兜底方案。所有写入缓存的数据都应该设置一个合理的过期时间。即使在更新过程中出现了不一致,当缓存过期后,下一次读请求也能从数据库获取最新数据,从而实现自我修复。

  2. 基于消息队列的异步方案
    对于一致性要求极高的场景,可以引入消息队列(如Kafka、RabbitMQ)和数据库的binlog来实现。

    • 业务方只负责修改数据库。
    • 订阅数据库的binlog(可以使用Canal等中间件),将数据变更消息发送到消息队列。
    • 下游服务消费这些消息,去可靠地执行删除缓存的操作。
      这种架构将缓存管理与业务逻辑解耦,通过消息队列的重试机制,可以极大地提高缓存同步的成功率,保证最终一致性。

相关文章

jvm

3415 字

MySql

28205 字
最后更新:2025年09月18日
分享: