前言:
缓存,计算机设计优化中很重要的一环,之前看韩老师Netty的视频,说过一句话,任何一个优化 要么就是加缓存 要么就是 分级。 关于缓存数据库更新,看过一些不同的说法,有点乱,加上之前自己学习过的笔记,大致总结一下
首先要知道,这个双写怎么做都很难做到完美,在保证性能的前提下,就好像CAP理论里的A和C,不可能同时做到。
(一般来说,如果允许缓存可以稍微的跟数据库偶尔有不一致的情况,也就是说如果你的系统不是严格要求 “缓存+数据库” 必须保持一致性的话,最好不要做这个方案,即:读请求和写请求串行化,串到一个内存队列里去。
串行化可以保证一定不会出现不一致的情况,但是它也会导致系统的吞吐量大幅度降低,用比正常情况下多几倍的机器去支撑线上请求。)
1.Cache Aside Pattern
**最经典的缓存+数据库读写的模式,就是 Cache Aside Pattern。**Cache Aside Pattern 是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景。
** 读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。**
** 更新的时候,先更新数据库,然后再删除缓存。**
为什么是删除缓存呢,而不是先覆盖这个缓存?
**解释:**缓存的更新 不仅仅是从数据库里取值出来,要进行一些复杂的计算,涉及多个表。
比如更新了某个表的字段,其对应的缓存是需要查询另外两个表的数据,进行运算后才能计算出缓存的最新值,更新缓存代价高。
如果缓存涉及多个表,缓存频繁更新;但是其实缓存里面百分之20的数据占用了百分之80的访问量,很多缓存频繁更新 但是访问却不多。
比如该缓存可能只需要访问一次,删除缓存后,用的时候只需要计算一次,需要用的时候才去计算;就是一个lazy计算的思想,不要每次做重复的复杂的计算,不管会不会用到,让他再使用到的时候再重新计算即可。
1.最初级的缓存不一致问题及解决方案
问题:先修改数据库,再删除缓存。如果删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,数据就出现了不一致。
解决思路:先删除缓存,再修改数据库。如果数据库修改失败了,那么数据库中是旧数据,缓存中是空的,那么数据不会不一致。因为读的时候缓存没有,则读数据库中旧数据,然后更新到缓存中。
2.比较复杂一点的双写问题及解决方案
1、问题描述
在高并发的情况下,如果当删除完缓存的时候,这时去更新数据库,但还没有更新完,另外一个请求来查询数据,发现缓存里没有,就去数据库里查,还是以上面商品库存为例,如果数据库中产品的库存是100,那么查询到的库存是100,然后插入缓存,插入完缓存后,原来那个更新数据库的线程把数据库更新为了99,导致数据库与缓存不一致的情况。
2、解决方案
遇到这种情况,可以用队列的去解决这个问题,创建几个队列,如20个,根据商品的ID去做hash值,然后对队列个数取模,当有数据更新请求时,先把它丢到队列里去,当更新完后再从队列里去取,如果在更新的过程中,遇到以上场景,先去缓存里看下有没有数据,如果没有,可以先去队列里看是否有相同商品ID在做更新,如果有也把查询的请求发送到队列里去,在队列中积压,是顺序执行的,后面一个请求执行的时候,前面的请求已经把数据库里的值修改成功了,然后同步等待缓存更新完成。 可以解决问题 (也就是更新操作和读操作进行串行化,可以保证在读写并发的情况下,是一致的,扔到同一个队列里去了)
基本上可以完美的解决数据库和缓存不一致的问题
可以有一个优化点: 因为读操作先读缓存,发现缓存没有的时候 才会到内部jvm队列里来排队;如果发现有多个相同的读请求积压在后面,是没有必要继续查数据库再更新到缓存里的,(也就是说队列里面有一个更新操作后面跟了一个读操作,那么后面就没必要再往这个队列里加读操作了)
过滤去重操作,如果所有请求大约可以在200ms以内返回,可以设置超时时长,这段时间去缓存里不断的读取,看有没有更新操作,如果读到了就直接返回;如果一直没有读到,那就让库存服务从数据库里的值,当前是什么值就是什么值,返回
高并发的场景下,该解决方案要注意的问题:
1.读请求可能会阻塞很久(如果写很多,就加机器队列,分散写请求压力)
有可能读请求发现缓存里面没有,去请求数据库里的数据,但是其实数据库里压根没有这条数据,可以去内存队列里面去判断一下 是否有数据更新操作,如果没有这条数据的更新操作,说明数据库里可能是空的,可以直接返回。
如果内存队列里有,就等待一会,等待更新操作完成,取到数据以后再返回; 当然要注意超时的问题,必须在超时时间内返回
该解决方案最大的问题是: 可能数据更新会很频繁,导致队列里积压了很多更新操作,然后读请求会大量的超时,直接走数据库
如果一个内存队列中可能积压的更新操作特别多,那么你就要加机器,让每个机器上部署的服务实例处理更少的数据,那么每个内存队列中积压的更新操作就会越少。
2、读请求并发量过高
这里还必须做好压力测试,确保恰巧碰上上述情况的时候,还有一个风险,就是突然间大量读请求会在几十毫秒的延时 hang 在服务上,看服务能不能扛的住,需要多少机器才能扛住最大的极限情况的峰值。
但是因为并不是所有的数据都在同一时间更新,缓存也不会同一时间失效,所以每次可能也就是少数数据的缓存失效了,然后那些数据对应的读请求过来,并发量应该也不会特别大。
3、多服务实例部署的请求路由
还必须保证对同一个商品的读写请求,全部路由到同一台机器上面,根据某个请求参数hash路由,Nginx,hash路由的功能,到同一个内存队列里; 否则也会出现一些问题,也会出现不一致的情况
4、热点商品的路由问题,导致请求的倾斜
万一某个商品的读写请求特别高,全部打到相同的机器的相同的队列里面去了,可能会造成某台机器的压力过大。就是说,因为只有在商品数据更新的时候才会清空缓存,然后才会导致读写并发,所以其实要根据业务系统去看,如果更新频率不是太高的话,这个问题的影响并不是特别大,但是的确可能某些机器的负载会高一些。
2. Read/Write Through Pattern(读写穿透)
Read/Write Through 套路是:服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 DB,从而减轻了应用程序的职责。
- 写(Write Through):先查 cache,cache 中不存在,直接更新 DB。 cache 中存在,则先更新 cache,然后 cache 服务自己更新 DB(同步更新 cache 和 DB)。
- 读(Read Through): 从 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 的问题,对于热点数据可以提前放入缓存中。
3. Write Behind Pattern(异步缓存写入)
Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 DB 的读写。
但是,两个又有很大的不同:Read/Write Through 是同步更新 cache 和 DB,而 Write Behind Caching 则是只更新缓存,不直接更新 DB,而是改为异步批量的方式来更新 DB。
Write Behind Pattern 下 DB 的写性能非常高,尤其适合一些数据经常变化的业务场景比如说一篇文章的点赞数量、阅读数量。 往常一篇文章被点赞 500 次的话,需要重复修改 500 次 DB,但是在 Write Behind Pattern 下可能只需要修改一次 DB 就可以了。
但是,这种模式同样也给 DB 和 Cache 一致性带来了新的考验,很多时候如果数据还没异步更新到 DB 的话,Cache 服务宕机就 gg 了。