缓存与数据库一致性学习笔记

前言

一般而言,现在互联网应用的整体流程,从用户界面(浏览器或者APP界面)到网络转发,应用服务再到存储(数据库或者是文件系统),最后返回到界面呈现内容。

但是随着互联网的逐渐普及,业务量逐渐增大以及复杂,,带来的用户和访问量越来越巨大,我们应用同时需要支撑的并发量也就越来越高,用户的一次查询或者修改操作,简单来说就是一个服务器与数据库交互取得数据经过逻辑计算所呈现的结果,但是往往我们的服务器与数据库资源是有限的,特别是在面对读多写少的高并发环境,如何利用有限的资源来尽可能提供更多的业务吞吐量,一个非常有效的办法便是引入缓存,业务所经过的每个流程都可以从缓存中直接获取目标数据并返回。

需求缘起

缓存是一种提高系统读性能的常见技术,对于读多写少的应用场景,我们经常使用缓存来进行优化。

例如对于用户的余额信息表account(uid, money),业务上的需求是:

  1. 查询用户的余额: SELECT money FROM account WHERE uid = XXX,占90%的用户使用请求
  2. 更改用户余额: UPDATE account SET money = XXX WHERE uid = XXX,占10%的使用请求

所以在这个使用场景中,我们在缓存当中建立uid到money的键值对,能够极大限度的降低数据库的压力。

读操作流程

有了数据库和缓存两个地方存放数据之后(uid -> money),每当我需要读取相关数据时(money),操作流程一般是这样的

  1. 读取缓存中是否有相关数据,uid -> money
  2. 如果缓存中有相关数据money,则返回,所谓的数据命中“hit”
  3. 如果缓存中没有相关数据money,则从数据库读取相关数据money,这里就是所谓的数据未命中“miss”,将查询到的数据放入缓存中uid -> money,再返回

我们这里谈到的缓存的命中率 = 命中缓存的请求个数/总缓存访问的请求个数 = hit / (hit + miss)

我们了解了大概流程,在实际运用当中,问题就出现了

当数据money发生变化的时候:

是更新缓存中的数据,还是淘汰缓存中的数据?

实现操纵数据库中的数据再操纵缓存中的数据,还是先操纵缓存中的数据再操纵数据库中的数据?

缓存与数据库的操作,在架构上是否还有优化的空间?

我们接下来着重讨论一下这三个问题:

更新缓存 VS 淘汰缓存

更新缓存:数据不但写入数据库,还会写入缓存

淘汰缓存:数据只会写入数据库,不会写入缓存,只会把数据淘汰掉

如果是更新缓存的话,缓存永远与数据库保持数据的最新与一致性,不会增加一次miss,命中率很高

淘汰缓存的优势就是在一些场景中必然比更新缓存简单

那到底是什么场景呢?我们来举个例子

例如,上述场景,只是简单的把余额money设置成一个值,那么:

(1)淘汰缓存的操作为deleteCache(uid)

(2)更新缓存的操作为setCache(uid, money)

更新缓存的代价很小,此时我们应该更倾向于更新缓存,以保证更高的缓存命中率

如果余额是通过很复杂的数据计算得出来的,例如业务上除了账户表account,还有商品表product,折扣表discount

account(uid, money)

product(pid, type, price, pinfo)

discount(type, zhekou)

业务场景是用户买了一个商品product,这个商品的价格是price,这个商品从属于type类商品,type类商品在做促销活动要打折扣zhekou,购买了商品过后,这个余额的计算就复杂了,需要:

(1)先把商品的品类,价格取出来:SELECT type, price FROM product WHERE pid=XXX

(2)再把这个品类的折扣取出来:SELECT zhekou FROM discount WHERE type=XXX

(3)再把原有余额从缓存中查询出来money = getCache(uid)

(4)再把新的余额写入到缓存中去setCache(uid, money-price*zhekou)

更新缓存的代价很大,此时我们应该更倾向于淘汰缓存。

因此,淘汰缓存的操作简单,并且带来的副作用只是增加了一次cache miss,因此一般作为通用的处理方式。

先操作数据库 VS 先操作缓存

当写操作发生时,假设淘汰缓存作为对缓存的通用处理方式,又面临着两种选择:

  1. 先写数据库,再淘汰缓存
  2. 先淘汰缓存,再写数据库

应该采用哪种时序呢?

对于一个不能保证事务性的操作,一定涉及“那个任务先做,那个任务后做”的问题,解决这个问题的方向是:

如果出现不一致,谁先做对业务的影响比较小,就谁先执行

由于写数据库与淘汰缓存两个操作不能保证原子性,谁先谁后同样要遵循上述原则

  • 假设我们先写数据库,再淘汰缓存

第一步写数据库操作成功,第二步淘汰缓存失败,则就会出现DB中是新数据,但Cache中是旧数据的情况,数据不一致,导致下一次查询出现问题

  • 假设我们先淘汰缓存,再写数据库

第一步淘汰缓存成功,第二不写数据库失败,则只会引发一次Cache miss

显而易见,数据库与缓存的操作时序是清晰地,先淘汰缓存,再写数据库

数据不一致问题

回顾一下上一篇文章中对缓存、数据库进行读写操作的流程。

写流程:

(1)先淘汰cache

(2)再写db

读流程:

(1)先读cache,如果数据命中hit则返回

(2)如果数据未命中miss则读db

(3)将db中读取出来的数据入缓存

什么情况下可能出现缓存和数据库中数据不一致呢?

在分布式环境下,数据的读写都是并发的,上游有多个应用,通过一个服务的多个部署(为了保证可用性,一定是部署多份的),对同一个数据进行读写,在数据库层面并发的读写并不能保证完成顺序,也就是说后发出的读请求很可能先完成(读出脏数据):

(a)发生了写请求A,A的第一步淘汰了cache

(b)A的第二步写数据库,发出修改请求

(c)发生了读请求B,B的第一步读取cache,发现cache中是空的

(d)B的第二步读取数据库,发出读取请求,此时A的第二步写数据还没完成,读出了一个脏数据放入cache

即在数据库层面,后发出的请求4比先发出的请求2先完成了,读出了脏数据,脏数据又入了缓存,缓存与数据库中的数据不一致出现了

能否做到先发出的请求一定先执行完成呢,常见的思路就是“串行化”,让同一个数据的访问能串行化

在一个服务内,如何做到“让同一个数据的访问串行化”,只需要“让同一个数据的访问通过同一条DB连接执行”就行。

如何做到“让同一个数据的访问通过同一条DB连接执行”,只需要“在DB连接池层面稍微修改,按数据取连接即可”

获取DB连接的CPool.GetDBConnection()【返回任何一个可用DB连接】改为

CPool.GetDBConnection(longid)【返回id取模相关联的DB连接】

这个修改的好处是:

(1)简单,只需要修改DB连接池实现,以及DB连接获取处

(2)连接池的修改不需要关注业务,传入的id是什么含义连接池不关注,直接按照id取模返回DB连接即可

(3)可以适用多种业务场景,取用户数据业务传入user-id取连接,取订单数据业务传入order-id取连接即可

这样的话,就能够保证同一个数据例如uid在数据库层面的执行一定是串行的

缓存架构优化

主流优化方案是服务化:加入一个服务层,向上游提供帅气的数据访问接口,向上游屏蔽底层数据存储的细节,这样业务线不需要关注数据是来自于cache还是DB。

总结

(1)淘汰缓存是一种通用的缓存处理方式

(2)先淘汰缓存,再写数据库的时序是毋庸置疑的

(3)服务化是向业务方屏蔽底层数据库与缓存复杂性的一种通用方式

(4)由于数据库层面的读写并发,引发的数据库与缓存数据不一致的问题(本质是后发生的读请求先返回了),可能通过两个小的改动解决:

​ 修改服务Service连接池,id取模选取服务连接,能够保证同一个数据的读写都落在同一个后端服务上

​ 修改数据库DB连接池,id取模选取DB连接,能够保证同一个数据的读写在数据库层面是串行的

参考:

缓存架构设计细节二三事

基本上大量借鉴了,算是学习后的总结,欢迎大家阅读原文,连接如下

https://www.w3cschool.cn/architectroad/