什么是双写一致性问题?

在使用Redis作为MySQL的缓存时,我们经常会遇到双写一致性问题。所谓双写一致性,是指当数据需要同时写入数据库和缓存时,如何保证两者数据的一致性。

双写一致性的主要矛盾

双写一致性问题主要有两个核心矛盾:

  1. 更新完数据库后,是更新缓存还是删除缓存?
  2. 如果选择删除缓存,是先删缓存还是先更新数据库?

解决方案分析

方案1:缓存设置过期时间

设置缓存过期时间是保持最终一致性的基础方案,但无法保证强一致性。

  • 原理:缓存过期后,再次获取缓存时会走数据库,获取到最新数据后再更新缓存
  • 优点:实现简单,不需要额外的操作
  • 缺点:在过期前可能会出现数据不一致的情况

方案2:先更新数据库,再更新缓存

这种做法实际上是不可取的,因为不能保证线程安全。

  • 问题场景

    • 线程A更新了数据库,还没更新缓存
    • 线程B更新了同一数据,并且更新了缓存
    • 线程A再把缓存更新为自己的值
    • 结果:从时效上看,B的更新应该覆盖A的,但实际上A覆盖了B的更新
  • 业务角度:如果写操作频繁,会导致缓存频繁更新,性能浪费严重

方案3:先删除缓存,再更新数据库

这种方案可能导致脏读问题:

  • 问题场景
    • 线程A进行写操作,删除缓存,还没更新数据库
    • 线程B来查询数据,因为缓存被删除,去查数据库,得到旧值并更新到缓存
    • 线程A将新值写入数据库
    • 结果:缓存中是旧数据,数据库是新数据,产生不一致

方案4:延时双删策略

为了解决先删除缓存再更新数据库可能导致的脏读问题,引入延时双删策略:

  1. 先删除缓存
  2. 更新数据库
  3. 延时一段时间(确保读请求完成)
  4. 再次删除缓存

伪代码:

1
2
3
4
5
6
7
8
// 1. 先删除缓存
redis.del(key)
// 2. 更新数据库
db.update(data)
// 3. 延时一段时间
sleep(500ms)
// 4. 再次删除缓存
redis.del(key)
  • 延时时间:应该是读操作的耗时+几百毫秒,确保删除的是读操作产生的脏数据
  • 缺点:延时会导致时间消耗,降低吞吐量

方案5:延时异步双删策略

为了解决延时导致的吞吐量问题,可以采用异步方式:

  1. 先删除缓存
  2. 更新数据库
  3. 新开一个线程,延时后再次删除缓存

伪代码:

1
2
3
4
5
6
7
8
9
// 1. 先删除缓存
redis.del(key)
// 2. 更新数据库
db.update(data)
// 3. 异步延时删除
new Thread(() -> {
sleep(500ms)
redis.del(key)
}).start()

方案6:消息队列重试机制

如果第二次删除失败,可以通过消息队列建立重试机制:

  1. 将删除失败的key放入消息队列
  2. 单独编写一个消费者服务,不断尝试删除key直到成功

方案7:订阅Binlog删除缓存

通过订阅数据库的Binlog,获得需要操作的数据:

  1. 使用MySQL的Canal等工具订阅Binlog
  2. 在应用程序中编写方法接收订阅消息
  3. 根据消息删除对应的缓存

方案8:先更新数据库,再删除缓存(推荐方案)

Facebook等公司采用的策略是先更新数据库再删除缓存:

  1. 从缓存读取数据,没有则从数据库读取并更新缓存
  2. 更新数据时,先更新数据库,然后删除缓存
  • 优点:发生不一致的概率极低
  • 原因:要出现不一致,需要读操作比写操作慢,而实际上读操作通常比写操作快得多

可能的问题场景

  • 线程A查询数据,发现缓存没有,从数据库读取
  • 线程B更新数据库,然后删除缓存
  • 线程A将从数据库读取的旧值更新到缓存
  • 结果:缓存是旧值,数据库是新值

解决方法

  • 如果要解决这个极低概率的问题,可以在线程A更新缓存时增加一个版本号检查
  • 或者采用延时双删策略,在更新数据库后延时再删除一次缓存

各方案对比与选择

方案 优点 缺点 适用场景
缓存过期 简单易实现 无法保证强一致性 对一致性要求不高的场景
先更新DB再更新缓存 直观 线程不安全,性能浪费 不推荐
先删缓存再更新DB 避免频繁更新缓存 可能导致脏读 不推荐
延时双删 解决脏读问题 延时降低吞吐量 对一致性要求高的场景
异步延时双删 不影响主流程性能 实现复杂 高并发且要求一致性的场景
消息队列重试 保证最终一致性 需要额外的消息队列 对最终一致性有保障要求的场景
订阅Binlog 可靠性高 实现复杂,依赖Binlog 大型系统,对一致性要求高的场景
先更新DB再删缓存 不一致概率极低 理论上仍有不一致可能 大多数业务场景(推荐)

最佳实践建议

  1. 一般场景:采用”先更新数据库,再删除缓存”的策略
  2. 高一致性要求:在上述策略基础上增加延时异步二次删除
  3. 超高并发场景:考虑引入消息队列或Binlog订阅机制
  4. 兜底方案:为所有缓存设置合理的过期时间,作为最终的一致性保障

总结

双写一致性是使用Redis作为MySQL缓存时必须面对的问题。没有完美的解决方案,需要根据业务场景和一致性要求选择合适的策略。在大多数场景下,”先更新数据库,再删除缓存”是相对最优的选择,同时设置合理的缓存过期时间作为兜底方案。