缓存读写操作,很多人不假思索:
-
先读Cache,Cache不命中就查DB,查到再回种Cache
-
先删Cache,再更新DB,而后续操作会把数据再装载到缓存
这是错误的!
看最简单的两个并发操作:更新&查询。 更新操作删除Cache后,查询操作没有命中Cache,先把老数据读出来后放到Cache,然后更新操作更新了DB。于是,Cache还是老数据,导致缓存数据是脏的,而且将一直脏下去。
因此,针对不同业务场景,缓存读写策略也不同。本文默认更新数据库、缓存的操作都能成功执行完成。
1 Cache Aside(旁路缓存)服务器分别与 DB 和 Cache 交互,DB 和 Cache之间不直接交互。代表:Memcached + MySQL。
应用程序代码直接使用缓存,互联网应用最常用模式。
访问DB的应用代码应先查Cache:
- 若Cache包含数据,则直接从Cache返回,绕过DB
- 否则,应用代码须从DB获取数据,将数据存储在Cache,然后返回
缓存须和DB一起更新:
- 失效 应用先从cache取数据,未命中,则从DB取数据,成功后,放入cache
- 命中 应用程序从cache取数据,取到后直接返回
- 更新 先把数据存到DB,成功后,再让Cache失效
核心就是先更新DB,成功后,再失效缓存!
1.4 伪代码 ① 读数据v = cache.get(k) if (v == null) { v = db.get(k) cache.put(k, v) }② 写数据
v = newV db.put(k, v) cache.put(k, v)1.5 一个查询操作,一个更新操作的并发
首先不是删除Cache,而是先更新DB,此时,Cache仍有效,所以,并发的查询操作拿的是未更新的数据,但更新操作马上让Cache失效了,后续查询操作再把数据从DB拉出来。而不会像本文开头那种逻辑导致后续查询操作都在取老数据。
这是标准的design pattern,包括Facebook的论文《Scaling Memcache at Facebook》也使用了这个策略。为什么不是写DB后更新缓存?可以看一下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?》,主要是怕两个并发的写操作导致脏数据。
那Cache Aside就没有并发问题吗?有的!比如,一个是读操作,但未命中Cache,然后就到DB取数据,此时来了个写操作,写完DB后,让Cache失效,然后,之前的那个读操作再把老数据放进去,造成脏数据。
该场景理论上是会出现,但实际出现概率非常低,因为需要发生在读缓存时Cache失效,且并发着有个写操作。 而实际上DB写操作比读操作慢得多,而且还要锁表,而读操作必须在写操作前进入DB操作,而又要晚于写操作更新Cache,所有这些条件都具备的概率实在很小!
这也就是Quora上的那个答案所说,要么通过2PC或Paxos协议保证一致性,要么就是拼命降低并发时脏数据的概率,而Facebook使用了这个降低概率的玩法,因为2PC太慢,Paxos太复杂。当然,最好还是为缓存设置上过期时间。
最经典的缓存+数据库读写的模式,cache aside pattern
1、Cache Aside Pattern
(1)读的时候,先读缓存,缓存没有的话,那么就读数据库,然后取出数据后放入缓存,同时返回响应
(2)更新的时候,先删除缓存,然后再更新数据库
2、为什么是删除缓存,而不是更新缓存呢?
原因很简单,很多时候,复杂点的缓存的场景,因为缓存有的时候,不简单是数据库中直接取出来的值
商品详情页的系统,修改库存,只是修改了某个表的某些字段,但是要真正把这个影响的最终的库存计算出来,可能还需要从其他表查询一些数据,然后进行一些复杂的运算,才能最终计算出
现在最新的库存是多少,然后才能将库存更新到缓存中去
比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据,并进行运算,才能计算出缓存最新的值的
更新缓存的代价是很高的
是不是说,每次修改数据库的时候,都一定要将其对应的缓存去跟新一份?也许有的场景是这样的,但是对于比较复杂的缓存数据计算的场景,就不是这样了
如果你频繁修改一个缓存涉及的多个表,那么这个缓存会被频繁的更新,频繁的更新缓存
但是问题在于,这个缓存到底会不会被频繁访问到???
举个例子,一个缓存涉及的表的字段,在1分钟内就修改了20次,或者是100次,那么缓存跟新20次,100次; 但是这个缓存在1分钟内就被读取了1次,有大量的冷数据
28法则,黄金法则,20%的数据,占用了80%的访问量
实际上,如果你只是删除缓存的话,那么1分钟内,这个缓存不过就重新计算一次而已,开销大幅度降低
每次数据过来,就只是删除缓存,然后修改数据库,如果这个缓存,在1分钟内只是被访问了1次,那么只有那1次,缓存是要被重新计算的,用缓存才去算缓存
其实删除缓存,而不是更新缓存,就是一个lazy计算的思想,不要每次都重新做复杂的计算,不管它会不会用到,而是让它到需要被使用的时候再重新计算
mybatis,hibernate,懒加载,思想
查询一个部门,部门带了一个员工的list,没有必要说每次查询部门,都里面的1000个员工的数据也同时查出来啊
80%的情况,查这个部门,就只是要访问这个部门的信息就可以了
先查部门,同时要访问里面的员工,那么这个时候只有在你要访问里面的员工的时候,才会去数据库里面查询1000个员工
2 Read/Write Through(Cache Through)Cache Aside 时,应用代码需维护两个数据存储:
- Cache
- DB
应用程序较繁琐。
而该模式把更新DB操作由Cache自己代理,对开发人员更简便。应用认为后端就是个单一存储,而存储自己维护自己的Cache。
服务器只和 Cache 沟通,Cache 负责去沟通 DB,把数据持久化。
业界典型代表:Redis(可理解为 Redis 里包含了一个 Cache 和一个 DB)
缺点RedisKV存储结构,无法适应复杂应用场景。所以使用 Cache Aside 较多,Cache 和 DB 都可以自由的搭配组合。
2.1 Read Through(直读)Cache配置个加载程序组件,其知道如何从DB加载数据。
当查询时Cache未命中,Cache将调用加载程序从DB检索值,然后缓存该值,再将其返回给调用方。
当下次查询同一K的V时,即可在不使用加载程序的情况下从Cache返回该值(除非该节点已被淘汰或过期)。
RT就是在查询操作中更新Cache,即当Cache失效时(过期或被LRU换出)
- Cache Aside是由 调用方 负责将数据加载入Cache
- Read Through则用 缓存服务 自己加载,从而对【应用方】是透明的
在直写模式下,Cache配置了一个写组件,该组件知道如何将数据写DB。
当执行更新操作时,Cache将调用写组件将值存储在DB,并更新Cache。
类似,只不过是在更新数据时才发生。当有数据更新时:
- 若Cache未命中,直接更新DB,然后返回
- 若命中Cache,则更新Cache,然后再由Cache自己更新DB(这是个同步操作)
下图中Memory可理解为我们例子里的DB
又叫Write Back。更新数据时:
-
只更新缓存
-
不更新DB
缓存会异步批量更新DB
该模式更改了写DB时机。与其在进行更新的线程等待(如直写)时,不如将待写入的数据放入队列,以便在之后写入。这能让用户线程执行更快,但代价是在DB更新前,会带来一些时间延迟。
秒杀等高性能场景使用较多。
3.1 优点- 让数据的I/O操作飞快(直接操作内存 )
- 因为异步,write back还可合并对同一个数据的多次操作,对性能提高相当可观
- 数据非强一致性,可能丢失(Linux非正常关机会导致数据丢失)
- 实现逻辑复杂,因为需track哪些数据是被更新的,待刷到DB
- os的write back会在仅当该cache需失效时,才会被真正持久化,如内存不够或进程退出等情况,这又叫lazy write
如在向磁盘中写数据时采用的也是这种策略,无论是:
- os层面的Page Cache
- 日志的异步刷盘
- MQ中消息的异步写盘
大多采用该策略,因为性能优势明显,直接写内存,避免直接写盘造成的大量缓慢的随机写。
-
A write-back cache with write allocation
参考
- https://coolshell.cn/articles/17416.html
- https://www.ehcache.org/documentation/3.10/caching-patterns.html
- https://blog.cdemi.io/design-patterns-cache-aside-pattern/