详解 Cache 缓存与 DB 数据库一致性
一、概述
缓存可以大幅改善许多高读取应用程序工作负载,降低延迟性和提高吞吐量。可以将高频数据或需要经过复杂计算的结果集放入缓存以实现低延迟访问,从而提高应用程序性能。缓存服务还具体比较快速且易于实施的优点,只需很少成本即可为系统性能提供明显改善。
尽管内存中的缓存具有诸多优势并且非常简单,但如果出现缓存一致性问题,有可能造成非常严重的影响
不同的数据和业务场景具有不同的一致性要求。例如:货币兑换汇率,在兑换结算时要求实时且确切汇率,不适合使用缓存
详细描述各缓存方案优势,分析可能造成不一致的原因和流程。为不同场景和数据推荐合适的缓存方案
二、缓存与数据库一致性策略
1. Cache Aside 模式:先写 DB 再删 Cache
应用程序发起读请求, 如果数据在缓存不存在,则从数据库查询,并将数据写入缓存。
由读取数据的应用程序将数据按需加载到缓存中。
1.1 读写 DB 和缓存流程
读请求:
命中缓存 :直接返回 Redis 缓存数据,无数据库请求
未命中:从数据库查询数据,并将查询结果写入 Redis
CUD 写请求:
更新数据库数据
删除缓存中的数据,缓存将在下次未命中缓存时更新
1.2 数据一致性问题
缓存删除之前读取数据:
假设进程 A 已成功更新 MySQL 中的值,但在删除 Redis 缓存数据之前,另一个进程 B 尝试读取相同 KEY 的值。然后 B 将命中缓存(还未删除),此时 B 将读取到脏数据
缓存删除失败:
假设进程 A 在尝试删除 Redis 中缓存数据异常结束(不正确的控制语句或异常),则缓存中数据将不会被正常删除,之后所有其他进程将读取到脏数据
缓存延迟写入旧值:
假设进程 A 读请求缓存未命中,然后 A 查询 MySQL 并获取返回结果
由于不可知原因,进程 A 写入 Redis 操作卡住一段时间
另一个进程 B 更新 MySQL 并删除 Redis 中的数据
之后 A 恢复并将其旧的查询结果保存到 Redis
所有后续读请求都会读取脏数据
1.3 为什么删除而不更新缓存
删除是幂等操作(对于分布式系统是一个非常好的特性)
多线程请求 Update,可能由于不可预知的原因,导致数据脏写,造成数据不一致
删除简单:简单优于复杂,不需要考虑加锁
为缓存设置一个 TTL(time-to-live) ,能实现缓存数据定期更新,删除不会打乱 TTL 时间
以 Redis 为例:HDEL 删除 Key 时间复杂度为 O(1),HSET 更新为 O(N) N 为 Field/Value(字段和值)对数量
1.4 Cache Aside 应用场景
按需加载:不需要预先写入缓存,按需读取并写缓存
低频更新:更新频率很低的数据,如字典类数据
2. Cache Aside 模式:先删 Cache 再写 DB
与“先写 DB 再删除”缓存相比,仅删除缓存和写 DB 操作先后的区别
2.1 读写 DB 和缓存流程
读请求:
命中缓存 :直接返回 Redis 缓存数据,无数据库请求
未命中:从数据库查询数据,将查询结果写入 Redis,并返回
CUD 写请求:
删除缓存中的数据,缓存将在下次未命中缓存时更新
更新数据库数据
2.2 数据一致性问题
删除缓存成功、写入 DB 延迟
写进程 A 删除缓存成功
写入 DB 由于各种原因延迟
读进程 B 未命中缓存(已删除),从 DB 读取旧数据并写入缓存
进程 A 写入 DB 成功,此时缓存数据已过时
在下次写操作之前不会再被删除
2.3 推荐使用“先删 Cache 后写 DB”
缓存效率高于 RDBMS:缓存操作一般非常快,而写 DB 出问题的可能性更大
可异步删除缓存:可使用异步线程来删除缓存,如引入 MQ 更能确保缓存能正常删除
3. 双删缓存和延迟双删缓存
3.1 读写 DB 和缓存流程
读请求:
命中缓存 :直接返回 Redis 缓存数据,无数据库请求
未命中:从数据库查询数据,将查询结果写入 Redis
CUD 写请求:
删除缓存中的数据
更新数据库数据
删除缓存中的数据或延迟一定时间再删除一次缓存
3.2 延迟删除
Delay 而非 Sleep 并不是将每次写请求进程都强制 sleep(N 毫秒),而是另外再新建一个线程或队列来实现延迟删除。
使用 java.util.concurrent.DelayQueue 来实现延迟删除
java.util.concurrent.ScheduledExecutorService 实现定时删除
使用 MQ 实现 DelayQueue 功能
使用 Redis ZSet 特性实现延迟队列
3.2 Double Delete 双删总结
很大程度上能解决缓存删除失败问题
能有效避免删除缓存后被其它进程写入脏数据的问题
4. 同步双写
4.1 读缓存流程
命中缓存 :直接返回 Redis 缓存数据,无数据库请求
未命中:通知 Cache Service 从数据库读取数据并写入缓存
4.2 同步双写
更新 DB 数据后,将数据同步写入 Redis 缓存
4.3 同步双写数据一致性:
多线程执行顺序问题(线程同步):
线程 A 和线程 B 开始并发更新 DB 和 Cache
线程 A 修改 value = 1,线程 B 修改 value = 2
线程 A 更新缓存延迟,线程 B 完成缓存更新:缓存 value = 2
线程 A 完成缓存更新:缓存 value = 1
4.4 同步双写优化
在同一事务中同步将数据写入 DB 和 Cache,在更新 Cache 后,提交更新 DB 事务,由 MySQL 行锁来确保数据一致性。
注意:
DB 和分布式 Cache 事务需要自己实现
同步写可能造成性能问题
5. 异步双写
消费 binary log 方案
5.1 异步双写实现
MQ 方案:写请求完成后,将 Key 推送至消息队列,消费者更新 cache 数据
异步轮询:由其它异步进程或线程更新缓存或修复缓存数据差异
MySQL Binlog:利用 MySQL 主从复制的特性,消费 binlog 将数据写入缓存
5.2 MQ 方案:
更新缓存流程:
成功更新 DB 数据后,将数据 Key 推送至 MQ
MQ 消费者根据 Key 从 DB 读取数据
更新缓存数据
为什么推送 message 使用 key,而非完整变更数据?
简化推送和消费消息逻辑,减少序列化和反序列化成本
最重要的是充分利用 DB 本身一致性的特性
MQ Message 在推送和消费过程中数据可能已经变更
5.2 MySQL Binlog 读取
alibaba canal :
模拟 Mysql slave 的交互协议,伪装为 Mysql slave,向 master 发送 dump 协议
master 收到 dump 请求,开始推送 binary log 给 canal
Canal 解析 binary log,通过 EventSkin 推送给 1...N 个消费实例
消费并解析 Event,将数据写入 Cache
其它:pusher 原理与 canal 类似;阿里云 DTS 数据订阅(canal 商业化版本)
三、总结
1. 缓存数据不一致可能原因
缓存删除失败
写 DB 后删除缓存延迟
写 DB 后删除缓存,又被其它线程写入脏数据
2. 解决缓存数据不一致的方法
Double Delete 缓存:更新 DB 前删除一次,更新后再删除一次(建议延迟删除)
定时任务:定时任务定期检测数据差异并修复
按需要加载类缓存:在写入缓存引入乐观锁,实现“最后写入者获胜”机制
3. 使用缓存的其它注意事项
考虑为每个 Key 设置不同的 TTL,避免大量缓存在同一时间过期失效,从而给下游数据造成压力
为不同场景和数据选择合适的缓存数据更新策略
关注缓存命中和未命中等指标,特别是过低的命中率缓存
引入缓存时需仔细评估缓存大小、过期策略值、数据结构、Key 字符串设计
版权声明: 本文为 InfoQ 作者【分治实践】的原创文章。
原文链接:【http://xie.infoq.cn/article/13a13b1bd7f947e1736bfce04】。文章转载请联系作者。
评论