前言
关于 Redis 的常规操作与一些概念, 在上一篇中已经讲完了, 本篇内容主要说一下数据库和缓存中的双写一致问题
思考这样一个问题 : 在做电商项目, 例如秒杀场景下, 一般会将库存预加载到缓存中, 此时设想这样一个业务场景, 数据库中库存的数量还剩 9 个, 缓存中的库存剩余数量为 10 个, 那么当页面去请求数量时, 肯定获取到的是缓存中的数据, 也就是说在未来的一段时间内, 页面拿到的会一直是缓存中 10 个的库存, 但是实际上数据库中的实际库存只剩余 9 个, 这种问题我们要怎么去解决 ?
问题分析
很明显双写一致的概念已经通过上面的问题浮现出来了 : 缓存中数据与数据库数据不一致
那么我们该从哪些角度去解决这个问题呢 ?
首先我们都知道, Redis 是可以设置过期时间的, 只要我们设置了过期时间, 就是可以保证数据库和缓存数据的最终一致性, 因为缓存数据过期了就会被删除掉, 因为缓存中没有了库存数据, 再次从数据库读取的时候就会读取到数据库的实际库存放到缓存中
当然如果都靠这种最终一致性的解决方案是不行的, 我们必须要尽量避免数据库和缓存中的数据出现不一致的情况, 能减少一分钟就减少一分钟, 能减少一秒就减少一秒, 设置过期时间是最基本的解决方案
Cache Aside Pattern
在了解解决方案前我们先了解一下业界普遍的缓存+数据库的读写模式 (Cache Aside Pattern) :
读的时候, 先读缓存, 缓存没有的话, 就读数据库, 然后取出数据后放入缓存, 同时返回响应
更新的时候, 先更新数据库, 然后再删除缓存
这是标准的 design pattern, 包括 FaceBook 的论文 Scaling Memcache at Facebook 也是使用的这种策略
Scaling Memcache at Facebook
https://www.usenix.org/system/files/conference/nsdi13/nsdi13-final170_update.pdf
解决方案
究其原因, 出现双写一致问题一般情况下是在对数据更新操作的情况下出现的, 在进行更新操作时, 无非以下两种方式 :
先更新数据库, 再删除缓存
先删除缓存, 再更新数据库
因为关于分布式的知识 loger 的公众号中还没有讲过, 但是有过相关了解的小伙伴一定马上就发现了, 这实际上是一个分布式事务的问题, 无论采取上述两种方案的哪一种, 都必须要保证他们的原子性, 也就是要么同时成功, 要么同时失败(当第一个步骤失败后, 返回 Exception, 不执行第二个步骤)
大家有注意到, loger 在上面写两种方案时, 对缓存的操作用的是删除, 而不是更新, 这主要有以下两点原因 :
简单操作, 避免在更新缓存中再次出现数据不一致问题
因为在高并发的环境下, 本身就有一定的概率出现数据不一致的问题, 对于数据库因为不可能去对库存做 delete 操作, 所以更新在所难免, 如果此时再加上缓存的操作, 无疑更加添加了造成数据不一致的可能
性能问题
当缓存数据不存在时, 按照业界常规的处理方案本身就会去查询数据库来更新缓存, 如果每次更新数据库的时候都要同步的更新缓存, 还不如直接删除掉, 在用到数据的时候发现缓存中不存在, 自然而然的就回去到数据库加载这个数据, 这种方式也叫做懒加载
包括在 Quora 上的回答 :
Why does Facebook use delete to remove the key-value pair in Memcached instead of updating the Memcached during write request to the backend ?
https://www.quora.com/Why-does-Facebook-use-delete-to-remove-the-key-value-pair-in-Memcached-instead-of-updating-the-Memcached-during-write-request-to-the-backend
方案分析
01
先更新数据库, 再删除缓存
在此种方案下, 预想成功执行过程 :
先更新数据库, 执行成功
然后删除缓存, 执行成功
原子性破坏执行过程 :
情况一 :
更新数据库, 失败
返回 Exception
此时不会出现数据不一致问题
情况二 :
更新数据库, 成功
删除缓存, 失败
导致数据库内存在的是新数据, 缓存中存的是旧数据
情况三 (高并发场景) :
缓存刚好失效
线程 A 查询数据库, 获取一个旧的数据
线程 B 将新数据写入数据库
线程 B 删除缓存
线程 A 此时查询到旧数据, 写入缓存
上述情况中, 高并发场景下的情况很难遇到, 因为这是一系列偶然碰撞出来的问题, 情况三发生的条件极其苛刻, 首先要在读取缓存时, 缓存正好失效, 并且此时还要有一个并发的写入缓存操作, 而实际上数据库的写操作会比读操作慢得多, 而且还要锁表,而读操作必需在写操作前进入数据库操作, 而又要晚于写操作更新缓存, 所有的这些条件都具备的概率基本并不大
删除失败时的解决思路 :
将需要删除的 key 发送到消息队列中
自己消费消息, 获得需要删除的 key
不断重试删除操作, 直到成功
02
先删除缓存, 再更新数据库
在此种方案下, 预想成功执行过程 :
删除缓存, 执行成功
更新数据库, 执行成功
原子性破坏执行过程 :
情况一 :
删除缓存, 失败
返回 Exception
同样不会出现数据不一致问题
情况二 :
删除缓存, 成功
删除数据库, 失败
查询缓存时, 发现缓存不存在读取数据库数据, 不会出现不一致问题
情况三 (高并发场景) :
线程 A 删除缓存
线程 B 查询缓存, 发现不存在, 去数据库更新旧值到缓存中
线程 A 写入新值到数据库中
事实证明, 在高并发场景下, 还是会出现数据不一致问题
解决思路 :
保证操作执行顺序, 将上述一系列操作 (删除缓存, 更新数据库, 读取缓存) 操作放入队列, 串行化执行
03
延时双删
此种方案实际上是先更新数据库, 再删除缓存的 "变种", 唯一不同的是, 将删除缓存的操作的在一段时间之后再次执行一遍
伪代码 :
public void set(key, value) {updateDatabase(key, value);deleteRedis(key);// 一段时间后deleteRedis(key);}
重点是这 "一段时间", 需要考虑两个问题 :
延时时间要大于数据库一次写操作的时间
如果 Redis 和 数据库配置了主从, 需要考虑 Redis 和数据库的主从同步时间
总结
对比上述几种策略我们会发现 :
先删除缓存,再更新数据库
在高并发下表现不如意,在原子性被破坏时表现优异
先更新数据库,再删除缓存 (Cache Aside Pattern 设计模式)
在高并发下表现优异,在原子性被破坏时表现不如意
所以在我们决定去更好的解决双写一致问题时, 一定要结合自身业务场景, 以及各种综合因素去设计出一个最为合理的方案, 而不是照搬照抄
结尾
最近 loger 发现之前在调整 GitHub 目录和公众号的时候忘记把思维导图加上了 ... 这就很尴尬, 之前有看过思维导图去背面试题的小伙伴突然找不到了, 今天也是重新将思维导图添加到了公众号和 GitHub 中, 导图用的 ProcessOn 画的, 大家可以在公众号回复 "思维导图" 就会有地址和思维导图的密码, 因为都是平时想到哪记到哪的东西, 所以有的知识体系还不完善, 请大家见谅
我是 loger, 关注我看更多技术知识分享, 下期再见啦兄弟们




