引入缓存提高性能
如果你的业务处于起步阶段,流量非常小,那无论是读请求还是写请求,直接操作数据库即可,这时你的架构模型是这样的:
但随着业务量的增长,你的项目请求量越来越大,这时如果每次都从数据库中读数据,那肯定会有性能问题。
这个阶段通常的做法是,引入「缓存」来提高读性能,架构模型就变成了这样:
如何提高缓存利用率?
想要缓存利用率「最大化」,我们很容易想到的方案是,缓存中只保留最近访问的「热数据」。
- 写请求依旧只写数据库
- 读请求先读缓存,如果缓存不存在,则从数据库读取,并重建缓存
- 同时,写入缓存中的数据,都设置失效时间
缓存中不经常访问的数据,随着时间的推移,都会逐渐「过期」淘汰掉,最终缓存中保留的,都是经常被访问的「热数据」,缓存利用率得以最大化。
如何保证缓存和数据库数据的一致性?
更新缓存和数据库有四种方案:
- 先更新缓存,再更新数据库
- 先更新数据库,再更新缓存
- 先删除缓存,后更新数据库
- 先更新数据库,后删除缓存
更新缓存
优点:
如果每次数据变化都能被及时更新,那么查询数据时不容易出现不命中的情况,
缺点:
1、如果数据的计算复杂,频繁的更新会造成服务器性能的消耗比较大
2、如果数据并不是被频繁使用,那么频繁更新也只是浪费服务器性能,对业务没有多大的帮助
删除缓存
优点:不需要顾忌数据的复杂性,直接删除即可
缺点:查询数据时,增大未命中的几率,从而增大数据库的访问压力
适用于数据使用频率不高的场景
先更新缓存,再更新 DB
这个方案一般不考虑。原因是更新缓存成功,更新数据库出现异常了, 导致缓存数据与数据库数据完全不一致,而且很难察觉,因为缓存中的数据一直都存在。
先更新 DB,再更新缓存
如果数据库更新成功了,但缓存更新失败,那么此时数据库中是最新值,缓存中是「旧值」。之后的读请求读到的都是旧数据,只有当缓存「失效」后,才能从数据库中得到正确的值。这种方案一般不考虑。
并发引发的一致性问题
同时有请求 A 和请求 B 进行更新操作,那么会出现
(1)线程 A 更新了数据库
(2)线程 B 更新了数据库
(3)线程 B 更新了缓存
(4)线程 A 更新了缓存
线程1 虽然先于 线程2 发生,但 线程2 操作数据库和缓存的时间,却要比 线程1 的时间短,执行时序发生「错乱」,最终这条数据结果是不符合预期的。
如果是写多读少的场景,采用 这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。
先删除缓存,后更新 DB
如果出现这样情况:
1、线程A更新数据库中的数据
2、线程A删除缓存中的数据,删除失败
3、线程B查询缓存中的数据,查询到旧数据
4、线程A异步重试删除缓存
这里,删除缓存中数据失败后就会造成线程B获取到缓存中的旧数据,从而导致数据不一致的情况。
还有并发情况:
来了两个请求,请求 A(更新操作) 和请求 B(查询操作)
请求 A 会先删除 Redis 中的数据,然后去数据库进行更新操作;
此时请求 B 看到 Redis 中的数据时空的,会去数据库中查询该值,更新到Redis 中;
但是此时请求 A 并没有更新成功,或者事务还未提交,请求 B 去数据库查询得到旧值;
可见,先删除缓存,后更新数据库,当发生「读+写」并发时,还是存在数据不一致的情况。
如何解决呢?其 实最简单的解决办法就是延时双删的策略。
(1)先淘汰缓存
(2)再写数据库
(3)休眠 1 秒,再次淘汰缓存 这么做,可以将 1 秒内所造成的缓存脏数据,再次删除。
那么,这个 1 秒怎么确定的,具体该休眠多久呢? 针对上面的情形,自行评估自己的项目的读数据业务逻辑的耗时。然后写数 据的休眠时间则在读数据业务逻辑的耗时基础上,加几百 ms 即可。这么做的目 的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
如果Mysql 的读写分离的架构的话,那么其实主从同步之间也会有时间差。
请求 A(更新操作) 和请求 B(查询操作)
- 线程 A 更新主库 X = 2(原值 X = 1)
- 线程 A 删除缓存
- 线程 B 查询缓存,没有命中,查询「从库」得到旧值(从库 X = 1)
- 从库「同步」完成(主从库 X = 2)
- 线程 B 将「旧值」写入缓存(X = 1)
此时的解决办法有两个:
1、还是使用双删延时策略。只是,睡眠时间修改为在主从同步的延时时间基础上,加几百 ms。
2、就是如果是对 Redis 进行填充数据的查询数据库操作,那么就强制将其指向主库进行查询。
这个「延迟删除」缓存,延迟时间到底设置要多久呢?
- 问题1:延迟时间要大于「主从复制」的延迟时间
- 问题2:延迟时间要大于线程 B 读取数据库 + 写入缓存的时间
但是,这个时间在分布式和高并发场景下,其实是很难评估的。
很多时候,我们都是凭借经验大致估算这个延迟时间,例如延迟 1-5s,只能尽可能地降低不一致的概率。
所以你看,采用这种方案,也只是尽可能保证一致性而已,极端情况下,还是有可能发生不一致。
所以实际使用中,建议采用「先更新数据库,再删除缓存」的方案,同时,要尽可能地保证「主从复制」不要有太大延迟,降低出问题的概率。
先更新数据库,后删除缓存
先分析有失败情况:
1、线程A更新数据库中的数据
2、线程A删除缓存中的数据,删除失败
3、线程B查询缓存中的数据,查询到旧数据
4、线程A异步重试删除缓存
这里,删除缓存中数据失败后就会造成线程B获取到缓存中的旧数据,从而导致数据不一致的情况
假设这会有两个请求,一个请求 A 做查询操作,一个请求 B 做 更新操作,那么会有如下情形产生
(1)缓存刚好失效
(2)请求 A 查询数据库,得一个旧值
(3)请求 B 将新值写入数据库
(4)请求 B 删除缓存
(5)请求 A 将查到的旧值写入缓存
其实概率「很低」,这是因为它必须满足 3 个条件:
- 缓存刚好已失效
- 读请求 + 写请求并发
- 更新数据库 + 删除缓存的时间(步骤 3-4),要比读数据库 + 写缓存(步骤 2 和 5)时间短
仔细想一下,条件 3 发生的概率其实是非常低的。
因为写数据库一般会先「加锁」,所以写数据库,通常是要比读数据库的时间更长的。这么来看,「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的。
**更新数据库成功了,但是在删除缓存的阶段出错了没有删除成功怎么办?**这个问题,在删除缓存类的方案都是存在的,那么此时再读取缓存的时候每次都是错误的数据了。
此时解决方案有两个,一是就是利用消息队列进行删除的补偿:
1、请求 A 先对数据库进行更新操作
2、在对 Redis 进行删除操作的时候发现报错,删除失败
3、此时将 Redis 的 key 作为消息体发送到消息队列中
4、系统接收到消息队列发送的消息后
5、再次对 Redis 进行删除操作
第二种方案,订阅数据库变更日志,再操作缓存。
具体来讲就是,我们的业务应用在修改数据时,「只需」修改数据库,无需操作缓存。
那什么时候操作缓存呢?这就和数据库的「变更日志」有关了。
拿 MySQL 举例,当一条数据发生修改时,MySQL 就会产生一条变更日志(Binlog),我们可以订阅这个日志,拿到具体操作的数据,然后再根据这条数据,去删除对应的缓存。
想要保证数据库和缓存一致性,推荐采用「先更新数据库,再删除缓存」方案,并配合「消息队列」或「订阅变更日志」的方式来做。
总结
引入缓存后,需要考虑缓存和数据库一致性问题,可选的方案有:「更新数据库 + 更新缓存」、「更新数据库 + 删除缓存」
更新数据库 + 更新缓存方案,在「并发」场景下无法保证缓存和数据一致性,且存在「缓存资源浪费」和「机器性能浪费」的情况发生
在更新数据库 + 删除缓存的方案中,「先删除缓存,再更新数据库」在「并发」场景下依旧有数据不一致问题,解决方案是「延迟双删」,但这个延迟时间很难评估,所以推荐用「先更新数据库,再删除缓存」的方案
在「先更新数据库,再删除缓存」方案下,为了保证两步都成功执行,需配合「消息队列」或「订阅变更日志」的方案来做,本质是通过「重试」的方式保证数据一致性
在「先更新数据库,再删除缓存」方案下,「读写分离 + 主从库延迟」也会导致缓存和数据库不一致,缓解此问题的方案是「延迟双删」,凭借经验发送「延迟消息」到队列中,延迟删除缓存,同时也要控制主从库延迟,尽可能降低不一致发生的概率。
掌握缓存和数据库一致性问题,核心问题有 3 点:缓存利用率、并发、缓存 + 数据库一起成功问题。
对于并发几率很小的数据(如个人维度的订单数据、用户数据等),这种几乎不用考虑这个问题,很少会发生缓存不一致,可以给缓存数据加上过期时间,每隔一段时间触发读的主动更新即可。
就算并发很高,如果业务上能容忍短时间的缓存数据不一致(如商品名称,商品分类菜单等),缓存加上过期时间依然可以解决大部分业务对于缓存的要求。
如果不能容忍缓存数据不一致,可以通过加分布式读写锁保证并发读写或写写的时候按顺序排好队,读读的 时候相当于无锁。