为什么要引入缓存?
让我们从基本开始讲起。
当系统的流量较小时,那么无论是读请求还是写请求,直接操作数据库便好了。
此时我们的操作是这样子的:
可是随着公司发展,客户越来越多,业务越来越大,网站请求也越来越多,响应也开始变慢了起来。
尽管我们还在努力的优化sql,分库分表来加快响应,但大功臣【数据库】却跟掀桌子了,凭什么要我一直加班,干不了一点点。
那完蛋了,大功臣一旦不干,查询怎么办?保存怎么办?这一大家子全靠他撑着呢!
没办法,公司便安排你去慰问一下,在经过涨薪和协谈下,【数据库】还是答应回来工作,但是要求多招聘一个人来分担一下读压力,让数据库轻松一点。
此时操作便是以下流程:
那么在经过多次面试和性价比考察下,我们便发现了 Redis,这位新晋员工不仅性能优秀,还能高效的满足业务需求。
那么在招聘 Redis 之后,新的矛盾就出现了: 之前数据都是存在数据库中的,现在要放入缓存中,该怎么存呢?
此时数据库提出了一个想法,为了避免各位同事加班,那么我全量数据刷新到缓存中:
- 将数据库中的数据,全量存入缓存中
- 写请求更新数据库,不更新缓存
- 设置定时任务,定时更新缓存中的数据
说干就干,很快第一版本方案便完成了。
查询确实快了很多,毕竟此时读请求都可以直接命中缓存的,不再需要数据库IO,性能非常快。 但是也有两个明显的问题:
- 缓存利用率低:全量刷新到缓存,不常用的数据也在缓存中
- 数据不一致:由于是定时更新的,缓存和数据存在不一致。
好吧,你缓存也招引进了,数据库也没罢工了,但是在高并发的情况下还有问题,那就是人的问题了。
为了保住自己的工作,我们只好开始动脑子想办法了。
缓存利用率问题
如何提高缓存利用率?
很简单,就和水多了加盐,盐多了加水一样。我们去除多余的 Key,只保留最近访问的热点数据就好了。
那么就可以基于以上思想小小优化一下
- 读请求先读缓存,如果缓存不存在,再去数据库中查询。
- 将查询到的数据存入缓存中,并且设置过期时间
- 写请求依旧只改数据库。
这样一来,保存在缓存中的数据便是近期访问的数据,便保证了利用率的最大化。
一致性问题
更新缓存,还是删除缓存,这是一个严重的问题。
针对缓存操作
如果要保证数据一致问题,则需要在操作数据库数据同时,也要修改缓存中的数据。
前文提到,引入缓存便是位了降低数据库压力,那么在高并发的情况下,我们进行更新缓存操作,可能会出现一下情况呢?
图有点粗糙,但是也不是很难理解。以上实/虚线标识两位用户操作。
- A 用户保存 x = 1,B用户保存 x = 2。
- 此时 B 线程走完,提前更新缓存库。
- 此时 A 线程走完,再次更新缓存库。
- 查询,返回 x = 1 。
哦吼,此时数据库中 x = 2,数据有不一致了。
通过分析,我们能明白以上问题产生的关键在于:缓存被存回了旧值,并不能保证最新数据。
那么为了避免以上情况的发生,更新缓存我们便只能舍弃了。所以此时需要考虑另外一种方案:删除缓存
那么删除操作也有先后顺序,如下:
- 先删除缓存,在更新数据库
- 先更新数据库,在删除缓存
先删除缓存,在更新数据库
还是由并发情况来分析以上情况。(x = 0)
- 用户 A 开启 x = 1 事务,先删除缓存
- 用户 B 查询,发现缓存不在,从数据库中获取 x = 0,预备更新缓存
- 用户 A 提交事务,更新缓存 x = 1
- 用户 B 更新缓存, x = 0。
好吧,讨论了这么多还是回到了老问题: 缓存被存回了旧值。
那么该如何解决此类问题呢? 通用办法就是 延迟双删 。
延迟双删策略
顾名思义,该操作就是删除两次。在更新完数据库操作后,使操作休眠一段时间,在对缓存进行第二次删除。
这样,下次查询时,便是从数据库中获取最新值,写入缓存。
但是问题又来了,这个休眠时间,到底该休眠多久呢?
根据以上分析,缓存操作不能线程 B 完成操作之前,不然休眠休眠了个寂寞。理论上应该是休眠时间 > 线程B 读取数据库 + 更新缓存的时间
但是,这个时间让我们来预测,也只能根据经验来设计时间,让其尽可能的减低读取到旧值的概率。
因此,该方案也只是尽可能的保证一致性而已。
先更新数据库,在删除缓存。
依旧是并发情况来分析。(x = 0)
- 用户 A 更新 x = 1,删除缓存
- 用户 B 查询,发现缓存不在,从数据库中获取(x = 1)
好吧,此时的你肯定很懵逼,你之前的分析都是线程提交时间不一样,现在就这么草率?
开个玩笑,我们来分析失败的情况。
- 缓存失效,用户 A 从数据库中获取 x = 0
- 线程 B 更新数据库 x = 1,并且删除缓存
- 线程 A 就旧值写入缓存中
这才对位嘛。那以上两种思路都不行吗?
错啦,以上方法需要满足一下条件才存在。
- 缓存失效
- 更新数据库 + 删除缓存时间 < 读数据库 + 写缓存时间
我们都知道,在更新数据库的过程中,会自动加上意向锁,甚至是行锁。所以通常来说写数据库是要比读久的。只有超级小概率下才会发生以上条件。
错误补偿
经过以上分析,我们明白了针对缓存的操作。那么二者要是执行顺利的情况下,是能保证实现任务的。
但关键是错误了怎么办?
更新数据库错误
其实错误就错误了,也没什么问题,此时我们更新数据库失败,缓存还没更新呢,并不影响其他用户获取最新值。
删除缓存错误
在系统中,如果一个操作错误了,我们通常会先道歉,然后让客户再次重试操作
当然,我们此时也可以使用重试操作,如果删除缓存失败了,我们便能发起重试,尽可能的做出补偿。
就像客户存在耐心阈值,我们也不可能一直异步重试。
通常我们是把重复请求写入到消息队列中,这样就可以由专门的消费者进行消费,直到成功。
当然也可以更加牛逼一点,将修改缓存放入到消息队列中,由消费者来进行缓存操作。
因为消息队列能保证以下优点:
- 保证可靠性:成功消费前并不会丢失(重启项目也不担心)
- 保证消息成功投递:成功消费后才会删除信息
但是这样子公司就又招了一个新员工,工资开销又多了一笔,维护成本也提高了,这点优略点就要自己想清楚啦。
订阅 Cannal 组件
当然,有人会说,我就是都不想写,有没有什么一键绑定部署的,那就是通用答案:订阅数据库变更日志,在操作缓存
让我们拿 Mysql 举例,当一条数据发生修改,Mysql 会生成一条日志(Binlog),然后我们获取这条日志的相关信息,再去进行相应的缓存操作。
以上优点就是:
- 无需考虑消息队列失败情况
- 自动投递到下游
当然,我们要是关注数据库更新说明,其实各大库都在缓慢提供订阅日志功能,相比以后会有接口实现的。
想要保证数据库和缓存一致性,推荐采用「先更新数据库,再删除缓存」方案,并配合「消息队列」或「订阅变更日志」的方式来做。
可以做到强一致性吗?
嗨,朋友,世界上没有完美的圆。
因此,世界上也没有完美的系统设计,我们只是让他尽力让他变得更完美而已。
我们也可以使用 2PC、3PC、Raft 这种一致性协议,也可以使用分布式锁,但他的性能也比较差,也会让架构方案变得更加复杂。
并且,不要忘记我们为什么要引入缓存的原因,那就是提升性能
所以不要捡了芝麻丢了西瓜,忘记了我们引入缓存的初心。
当然,如果真有客户获取到了旧值,我们还有最终方案,那就是红豆泥私密马赛,然后进行人工维护。