一、 需求起因
假设先写数据库,再淘汰缓存:第一步写数据库操作成功,第二步淘汰缓存失败,则会出现
DB 中是新数据,Cache 中是旧数据,数据不一致【如下图:db 中是新数据,cache 中是旧数据】
假设先淘汰缓存,再写数据库:第一步淘汰缓存成功,第二步写数据库失败【如下图: cache中无数据,db 中是旧数据】。
结论:先淘汰缓存,再写数据库。
二、 数据不一致原因
先操作缓存,在写数据库成功之前,如果有读请求发生,可能导致旧数据入缓存,引发数据
不一致。
写流程:
(1)先淘汰 cache
(2)再写 db
读流程:
(1)先读 cache,如果数据命中 hit 则返回
(2)如果数据未命中 miss 则读 db
(3)将 db 中读取出来的数据入缓存
什么情况下可能出现缓存和数据库中数据不一致呢?
在分布式环境下,数据的读写都是并发的,上游有多个应用,通过一个服务的多个部署(为
了保证可用性,一定是部署多份的),对同一个数据进行读写,在数据库层面并发的读写并
不能保证完成顺序,也就是说后发出的读请求很可能先完成(读出脏数据):
(a)发生了写请求 A,A 的第一步淘汰了 cache(如上图中的 1)
(b)A 的第二步写数据库,发出修改请求(如上图中的 2)
(c)发生了读请求 B,B 的第一步读取 cache,发现 cache 中是空的(如上图中的步骤 3)
(d)B 的第二步读取数据库,发出读取请求,此时 A 的第二步写数据还没完成,读出了一个脏数据放入 cache(如上图中的步骤 4)
即在数据库层面,后发出的请求 4 比先发出的请求 2 先完成了,读出了脏数据,脏数据又入了缓存,缓存与数据库中的数据不一致出现了
三、 问题解决思路
能否做到先发出的请求一定先执行完成呢?常见的思路是“串行化”
上图是一个 service 服务的上下游及服务内部详细展开,细节如下:
(1)service 的上游是多个业务应用,上游发起请求对同一个数据并发的进行读写操作,上例中并发进行了一个 uid=1 的余额修改(写)操作与 uid=1 的余额查询(读)操作
(2)service 的下游是数据库 DB,假设只读写一个 DB(3)中间是服务层 service,它又分为了这么几个部分
(3.1)最上层是任务队列
(3.2)中间是工作线程,每个工作线程完成实际的工作任务,典型的工作任务是通过数据库
连接池读写数据库
(3.3)最下层是数据库连接池,所有的 SQL 语句都是通过数据库连接池发往数据库去执行的
工作线程的典型工作流是这样的:
void work_thread_routine() {
Task t = TaskQueue.pop(); // 获取任务
// 任务逻辑处理,生成 sql 语句DBConnection c = CPool.GetDBConnection(); // 从 DB 连接池获取一个 DB 连接
c.execSQL(sql); // 通过 DB 连接执行 sql 语句
CPool.PutDBConnection(c); // 将 DB 连接放回 DB 连接池
}
提问:任务队列其实已经做了任务串行化的工作,能否保证任务不并发执行?
答:不行,因为
(1)1 个服务有多个工作线程,串行弹出的任务会被并行执行
(2)1 个服务有多个数据库连接,每个工作线程获取不同的数据库连接会在 DB 层面并发执行
提问:假设服务只部署一份,能否保证任务不并发执行?
答:不行,原因同上
提问:假设 1 个服务只有 1 条数据库连接,能否保证任务不并发执行?
答:不行,因为
(1)1 个服务只有 1 条数据库连接,只能保证在一个服务器上的请求在数据库层面是串行执
行的
(2)因为服务是分布式部署的,多个服务上的请求在数据库层面仍可能是并发执行的
提问:假设服务只部署一份,且 1 个服务只有 1 条连接,能否保证任务不并发执行?
答:可以,全局来看请求是串行执行的,吞吐量很低,并且服务无法保证可用性
完了,看似无望了
1)任务队列不能保证串行化
2)单服务多数据库连接不能保证串行化
3)多服务单数据库连接不能保证串行化
4)单服务单数据库连接可能保证串行化,但吞吐量级低,且不能保证服务的可用性,几乎不
可行,那是否还有解?
退一步想,其实不需要让全局的请求串行化,而只需要“让同一个数据的访问能串行化”就行。
在一个服务内,如何做到“让同一个数据的访问串行化”,只需要“让同一个数据的访问通过同一条 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 在数据库层面的执行一定是串行的。
稍等稍等,服务可是部署了很多份的,上述方案只能保证同一个数据在一个服务上的访问,
在 DB 层面的执行是串行化的,实际上服务是分布式部署的,在全局范围内的访问仍是并行的 ,
怎么解决呢?能不能做到同一个数据的访问一定落到同一个服务呢?
能否做到同一个数据的访问落在同一个服务上?
上面分析了服务层 service 的上下游及内部结构,再一起看一下应用层上下游及内部结构
上图是一个业务应用的上下游及服务内部详细展开,细节如下:
(1)业务应用的上游不确定是啥,可能是直接是 http 请求,可能也是一个服务的上游调用
(2)业务应用的下游是多个服务 service
(3)中间是业务应用,它又分为了这么几个部分
(3.1)最上层是任务队列【或许 web-server 例如 tomcat 帮你干了这个事情了】
(3.2)中间是工作线程【或许 web-server 的工作线程或者 cgi 工作线程帮你干了线程分派这个事情了】,每个工作线程完成实际的业务任务,典型的工作任务是通过服务连接池进行 RPC 调用
(3.3)最下层是服务连接池,所有的 RPC 调用都是通过服务连接池往下游服务去发包执行的
工作线程的典型工作流是这样的:
voidwork_thread_routine(){
// 获取任务
Task t = TaskQueue.pop();
// 任务逻辑处理,组成一个网络包 packet,调用下游 RPC 接口// 从 Service 连接池获取一个 Service 连接
ServiceConnection c = CPool.GetServiceConnection();// 通过 Service 连接发送报文执行 RPC 请求
c.Send(packet);// 将 Service 连接放回 Service 连接池
CPool.PutServiceConnection(c);
}
似曾相识吧?没错,只要对服务连接池进行少量改动:
获取 Service 连接的 CPool.GetServiceConnection()【返回任何一个可用 Service 连接】改为
CPool.GetServiceConnection(longid)【返回 id 取模相关联的 Service 连接】
这样的话,就能够保证同一个数据例如 uid 的请求落到同一个服务 Service 上。
由于数据库层面的读写并发,引发的数据库与缓存数据不一致的问题(本质是后发生的读请
求先返回了),可能通过两个小的改动解决:
(1)修改服务 Service 连接池,id 取模选取服务连接,能够保证同一个数据的读写都落在同一个后端服务上
(2)修改数据库 DB 连接池,id 取模选取 DB 连接,能够保证同一个数据的读写在数据库层
面是串行的