缓存的目的是为了减少数据库的压力,提升性能,但由于2个数据源之间是没有事务的,使用缓存可能会导致数据不一致的问题。
从理论上来说,给缓存设置过期时间,是保证最终一致性的解决方案。
常见的缓存读写策略有:
- Cache Aside Pattern(旁路缓存模式)
- Read/Write Through Pattern(读写穿透模式)
- Write Behind Pattern(异步缓存写入模式)
Cache Aside Pattern(旁路缓存模式)
Cache Aside Pattern 是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景。
写流程
- 先更新DB
- 再删除缓存
读流程
- 从 cache 中读取数据,读取到就直接返回
- cache中读取不到的话,就从 DB 中读取数据返回
- 再把数据放到 cache 中
缺点
首次请求数据一定不在 cache 的问题
解决办法:
- 可以将热点数据可以提前放入cache 中。
写操作比较频繁的话导致cache中的数据会被频繁被删除,这样会影响缓存命中率 。
解决办法:
- 数据库和缓存数据强一致场景 :更新DB的时候同样更新cache,不过我们需要加一个锁/分布式锁来保证更新cache的时候不存在线程安全问题。
- 可以短暂地允许数据库和缓存数据不一致的场景 :更新DB的时候同样更新cache,但是给缓存加一个比较短的过期时间,这样的话就可以保证即使数据不一致的话影响也比较小。
方案分析比较
写流程理论上有四种方案:
- 先更新缓存,在更新数据库
- 先更新数据库,再更新缓存
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
先更新缓存,在更新数据库
问题:如果更新缓存后,数据库回滚了,那缓存如何处理?
解决思路:数据发生了回滚,即出现异常,这里做一个异常回调,删除对应的缓存。
总结:不推荐,代码的侵入性太大,首先你要记下来 redis 之前的值,回滚的时候再写回去,如果是 insert,你得做一次 delete,如果是update,你需要update回去,如果delete你得insert,并且如果回调中发生了异常怎么办,非常麻烦。
先更新数据库,再更新缓存
问题一(线程安全角度),同时有请求A和请求B进行更新操作,可能会出现:
- 线程A更新了数据库
- 线程B更新了数据库
- 线程B更新了缓存
- 线程A更新了缓存
请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据。
解决思路:加锁,比如修改id为1的学生姓名,更新数据库之前,先对id=1的资源加锁,更新完数据库和缓存后,再释放锁。
总结:不推荐,加锁降低了系统的并发度,也使得系统更复杂。
问题二(业务场景角度),主要有两点:
- 如果写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。
- 如果写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。显然,删除缓存更为适合。
先删除缓存,再更新数据库
这样更容易造成数据不一致,如下场景:
- 线程A先删除缓存
- 线程B读取数据,从缓存中读取失败,然后从 DB 中读取数据并放入缓存中
- 线程A更新DB
最终,缓存和DB中的数据不一致。
解决思路:延迟双删策略,伪代码如下:
1 | public void write(String key,Object data){ |
转化为中文描述就是
- 先淘汰缓存
- 再写数据库(这两步和原来一样)
- 休眠1秒,再次淘汰缓存。这么做,可以将1秒内所造成的缓存脏数据,再次删除
那么,这个1秒怎么确定的,具体该休眠多久呢?
针对上面的情形,应该自行评估项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百ms即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
采用这种同步淘汰策略,吞吐量降低怎么办?
那就将第二次删除作为异步的。自己起一个线程,异步删除。这样,写的请求就不用沉睡一段时间后了,再返回。这么做,加大吞吐量。
如果第二次删除,如果删除失败怎么办?
采用重试机制,详见方案4。
先更新数据库,再删除缓存
依然会存在数据不一致,但概率相对较小,如下场景:
- 线程A从DB中读取数据
- 线程B先更新DB,然后删除缓存
- 线程A将数据放入缓存中
最终,缓存和DB中的数据也不一致。
仔细对比一下方案3和方案4发生数据不一致的场景:
方案3:在删除缓存之后,更新数据库之前,如果有其他线程进行查询操作
方案4:在查询数据库之后,写入缓存之前,如果有其他线程进行更新操作
但由于写缓存要比写DB要快,所以先更新DB,再删除缓存这种方式数据不一致的概率相对较小。
那么如何解决上述由于并发导致的数据不一致问题呢?
使用锁/分布式锁,读写串行执行(强一致性)
性能低,不推荐。
异步延迟双删+失败重试(最终一致性)
结合MQ实现,流程如下:
- 更新数据库数据
- 将需要删除的key发送至消息队列
- 消费MQ中的消息,删除缓存后提交ACK
该方案有一个缺点:对业务线代码造成大量的侵入。
结合 Canal + MQ 实现,流程如下:
- 更新数据库数据
- canal订阅程序提取出所需要的数据并发送至消息队列
- 消费MQ中的消息,删除缓存后提交ACK
这种方式在成功删除缓存之前,仍存在数据不一致的问题,并且实现较为复杂。
给缓存设置一个比较短的过期时间(最终一致性)
实现简单,推荐使用。
Read/Write Through Pattern(读写穿透模式)
Read/Write Through Pattern中,服务端把缓存作为主要数据存储。应用程序跟数据库缓存交互,都是通过抽象缓存层完成的。
Read/Write Through Pattern 平时开发中非常少见。抛去性能方面的影响,还有就是常见的分布式缓存(如Redis)都没有直接和数据库交互的方案。
写流程
- 先查 cache,cache 中不存在,直接更新 DB
- cache 中存在,则先更新 cache,然后 cache 服务自己更新 DB(同步更新 cache 和 DB)
读流程
- 从 cache 中读取数据,读取到就直接返回
- cache 中读取不到的话,cache 服务自己从 DB 加载,写入到 cache 后并返回
Read-Through Pattern 实际只是在 Cache-Aside Pattern 之上进行了封装。在 Cache-Aside Pattern 下,发生读请求的时候,如果 cache 中不存在对应的数据,是由客户端自己负责把数据写入 cache,而 Read Through Pattern 则是 cache 服务自己来写入缓存的,这对客户端是透明的。
和 Cache Aside Pattern 一样, Read-Through Pattern 也有首次请求数据一定不再 cache 的问题,对于热点数据可以提前放入缓存中。
Write Behind Pattern(异步缓存写入)
Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 DB 的读写。
但是,两个又有很大的不同:Read/Write Through 是同步更新 cache 和 DB,而 Write Behind Caching 则是只更新缓存,不直接更新 DB,而是改为异步批量的方式来更新 DB。
很明显,这种方式对数据一致性带来了更大的挑战,比如cache数据可能还没异步更新DB的话,cache服务可能就就挂掉了。
这种策略在我们平时开发过程中也非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 InnoDB Buffer Pool 机制都用到了这种策略。
Write Behind Pattern 下 DB 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。