上篇文章聊了聊redis与数据库主从一致性的方案,本篇文章主要介绍redis缓存与数据库之间的一致性问题,以及常用的设计模式
缓存模式
- Cache-as-SoR
- Read/Write Through
- Write behind caching
- Cache Aside
Cache-as-SoR(system-of-record)
Read Through
流程
读(Read Through)
- 查询缓存模块
- 缓存模块命中直接返回
- 缓存模块未命中,缓存模块查询数据库
- 更新缓存模块并返回
Write Through
流程
写(Write Through)
- 更新缓存模块数据
- 缓存模块更新数据库**(同步)**
Write behind caching
流程
写(Write behind caching)
- 更新缓存模块数据
- 缓存模块更新数据库**(异步)**
Cache Aside
流程
读
- 应用查询缓存模块
- 缓存模块命中直接返回
- 缓存模块未命中,应用查询数据库
- 应用更新缓存模块并返回
写
- 应用更新数据库
- 应用删除缓存模块数据
应用删除缓存模块数据动作可以根据实际一致性要求进行一些变形改造,例如最终一致性设计常用方法:监听binlog来触发执行删除缓存key动作
问题
- 线程A读取数据,未命中,查询数据库数据,线程A此时各种原因hold住(比如:GC,网络延迟增高)
- 线程B写数据,更新数据库,删除缓存数据,因为此时缓存中不存在,删除完成
- 线程A恢复,更新缓存
此时缓存与数据库不一致
解决方案
方案1
“读流程”步骤3,4放在一个事务中,并且读操作与“写流程”步骤1互斥,互斥方案可以选择加共享锁(lock in share mode)。
优点:问题彻底解决
缺点:性能差
方案2
延迟删除缓存,“写流程”步骤3延迟一定时间执行。
优点:性能好
缺点:问题依然可能出现,方案仅仅是尽可能降低复现的概率。并且更新后不立即删除,脏读问题
方案3
“写流程”步骤2删除操作改为put一个value为null的key,并设置适当的超时时间。“读流程”步骤4更新缓存时判断是否即将超时,如果是则丢弃缓存。
优点:问题彻底解决
缺点:实现复杂度高,加大了缓存击穿的概率,性能差
总结
- 问题的解决方案最终需要对性能与安全的做一个权衡来选择
- Cache Aside“写流程”步骤2为什么是删除缓存而不是更新缓存?更新缓存复杂度更高,更新缓存存在并发更新问题,需要处理更新顺序避免覆盖写问题。删除缓存则不需要考虑该场景,并发删除不存在数据覆盖问题
- 为什么不先删除缓存再更新数据库?两个并发操作,一个是更新操作,另一个是查询操作,更新操作删除缓存后,查询操作没有命中缓存,先把老数据读出来后放到缓存中,然后更新操作更新了数据库。于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的
- Cache Aside与Cache-as-SoR模式一个较大的区别在于,前者由应用层维护缓存(读写缓存),后者由一个中间层(缓存模块)维护缓存。后者应用层不感知缓存与数据库一致性逻辑,前者应用层需要感知缓存与数据库一致性逻辑。设计差异如下图
参考
- https://hazelcast.com/blog/a-hitchhikers-guide-to-caching-patterns/
- https://docs.oracle.com/cd/E13924_01/coh.340/e13819/readthrough.htm
- https://docs.microsoft.com/en-us/azure/architecture/patterns/cache-aside
- https://www.ehcache.org/documentation/3.5/caching-patterns.html#read-through
- https://coolshell.cn/articles/17416.html
- http://www.cs.utah.edu/~stutsman/cs6963/public/papers/memcached.pdf