数据一致性的一些思考
结论
没有银弹,需要根据自己的业务场景做取舍。
- 业务量有多少,需要主从读写分离么,需要分库分表么?
- 是读多还是写多?
- 是要最终一致性,还是强一致性?
- 对缓存一致性的要求是多少?1分钟?一秒钟?
- 查询结构是怎样的。是需要多表合并,还是多行合并,还是多库合并?
- 该如何容灾?更新、删除缓存失败你能不能接受?写数据库失败怎么办?
- 如果删除缓存失败,你还允不允许更新数据库?
要根据实际业务场景来定制方案。
DB主从一致
大部分业务场景都是读多写少,而且数据库(mysql)写很少看到写挂的,都是读有瓶颈。 所以主从读写分离就出现了,写的时候写主库,然后同步到从库,读的时候就读从库。
那么,很明显从库不是最新数据,就会有不一致的情况。
为了解决主从数据库读取旧数据的问题,常用的方案有四种:
(1)半同步复制 (2)强制读主 (3)数据库中间件 (4)缓存记录写key
解决方案1:半同步复制
不一致是因为写完成后,主从同步有一个时间差,假设是500ms,这个时间差有读请求落到从库上产生的。有没有办法做到,等主从同步完成之后,主库上的写请求再返回呢?
答案是肯定的,就是大家常说的“半同步复制”semi-sync: (1)系统先对DB-master进行了一个写操作,写主库 (2)等主从同步完成,写主库的请求才返回 (3)读从库,读到最新的数据(如果读请求先完成,写请求后完成,读取到的是“当时”最新的数据)
方案优点:利用数据库原生功能,比较简单 方案缺点:主库的写请求时延会增长,吞吐量会降低
解决方案2:强制读主库
如果不使用“增加从库”的方式来增加提升系统的读性能,完全可以读写都落到主库,这样就不会出现不一致了:
方案优点:“一致性”上不需要进行系统改造 方案缺点:只能通过cache来提升系统的读性能。
解决方案3:数据库中间件
如果有了数据库中间件,所有的数据库请求都走中间件,这个主从不一致的问题可以这么解决:
(1)所有的读写都走数据库中间件,通常情况下,写请求路由到主库,读请求路由到从库 (2)记录所有路由到写库的key,在经验主从同步时间窗口内(假设是500ms),如果有读请求访问中间件,此时有可能从库还是旧数据,就把这个key上的读请求路由到主库 (3)经验主从同步时间过完后,对应key的读请求继续路由到从库
方案优点:能保证绝对一致 方案缺点:数据库中间件的成本比较高
解决方案4:缓存记录写key法
既然数据库中间件的成本比较高,有没有更低成本的方案来记录某一个库的某一个key上发生了写请求呢?很容易想到使用缓存,当写请求发生的时候:
(1)将某个库上的某个key要发生写操作,记录在cache里,并设置“经验主从同步时间”的cache超时时间,例如500ms (2)修改数据库
而读请求发生的时候:
(1)先到cache里查看,对应库的对应key有没有相关数据 (2)如果cache hit,有相关数据,说明这个key上刚发生过写操作,此时需要将请求路由到主库读最新的数据 (3)如果cache miss,说明这个key上近期没有发生过写操作,此时将请求路由到从库,继续读写分离
方案优点:相对数据库中间件,成本较低 方案缺点:为了保证“一致性”,引入了一个cache组件,并且读写数据库时都多了一步cache操作
数据库和缓存 最终一致性方案
数据库和缓存由于是异步更新,所以必然会有不一致的情况,要根据自己的业务场景去抉择。
根本原因:
操作数据库慢,操作缓存快。
- 先更新数据库,再更新缓存
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
- 先更新缓存,再更新数据库
第一种:先更新数据库,再更新缓存
并发问题
(1)线程A更新了数据库 (2)线程B更新了数据库 (3)线程B更新了缓存 (4)线程A更新了缓存
解决:序列化达到最终一致
序列化,用一个消息队列顺序刷新缓存。会达到最终一致性
优缺点
好处:
- 不存在并发写库的问题。
- 写流程容灾分析
- 写1.1 DEL缓存失败:没关系,后面会覆盖
- 写1.4 写MQ失败:没关系,Databus或Canal都会重试
- 消费MQ的:1.5 || 1.6 失败:没关系,重新消费即可
- 读流程容灾分析
- 读2.3 异步写MQ失败:没关系,缓存为空,是OK的,下次还读库就好了
缺点:
- 会有一点延迟
- 会有ABA的问题,比如关注--取关--关注,操作的人再刷新可能还是未关注(缓存停留在第二步)。
第二种:先删缓存,再更新数据库
并发问题
(1)请求A进行写操作,删除缓存 (2)请求B查询发现缓存不存在 (3)请求B去数据库查询得到旧值 (4)请求A将新值写入缓存 (5)请求B将旧值写入缓存(覆盖了)
解决 :延时双删
在上面的基础上加上一个延时,再更新一次。 (6)延时一段时间,请求A将新值写入缓存(再次覆盖)
优缺点
- 优点
- 实现简单
- 通常不会有错误数据(删缓存很少有问题,而更新缓存肯定涉及到逻辑,可能会有bug)
- 异步刷新,补缺补漏 (如果删缓存有问题,后面会有更新缓存顶上)
- 缺点
- 如果删和更新都失败,这个脏数据会停留比较长时间。(缓存设置过期时间不就行了?)
- 并发问题难以完美解决。
第三种:先更新数据库,再删缓存
由于数据库比缓存慢,所以更新完数据库再更新缓存,不一致的时间的较短的。
- 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
- 命中:应用程序从cache中取数据,取到后返回。
- 更新:先把数据存到数据库中,成功后,再让缓存失效。
并发问题
(1)缓存刚好失效 (2)请求A查询数据库,得一个旧值 (3)请求B将新值写入数据库 (4)请求B删除缓存 (5)请求A将查到的旧值写入缓存
这种情况需要满足:
- 缓存刚好失效的时候有人写库。
- 写库比读库快。
解决
首先,给缓存设有效时间是一种方案。 其次,采用异步延时删除策略,保证读请求完成以后,再进行删除操作也可以。
第四种:先更新缓存,再更新数据库
应该没人会选择吧。
更新数据库失败了,脏缓存咋办。。。
对于删缓存失败的解决方案
方案一:
如下图所示
流程如下所示
(1)更新数据库数据; (2)缓存因为种种问题删除失败 (3)将需要删除的key发送至消息队列 (4)自己消费消息,获得需要删除的key (5)继续重试删除操作,直到成功 然而,该方案有一个缺点,对业务线代码造成大量的侵入。
于是有了方案二,在方案二中,启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。
方案二:
流程如下图所示:
(1)更新数据库数据 (2)数据库会将操作信息写入binlog日志当中 (3)订阅程序提取出所需要的数据以及key (4)另起一段非业务代码,获得该信息 (5)尝试删除缓存操作,发现删除失败 (6)将这些信息发送至消息队列 (7)重新从消息队列中获得该数据,重试操作。
备注说明:上述的订阅binlog程序在mysql中有现成的中间件叫canal,可以完成订阅binlog日志的功能。如果对一致性要求不是很高,直接在程序中另起一个线程,每隔一段时间去重试即可,这些大家可以灵活自由发挥,只是提供一个思路。
数据库和缓存强一致性方案
强一致性,包含两种含义:
缓存和DB数据一致 缓存中没有数据(或者说:不会去读缓存中的老版本数据)
首先我们来分析一下,既然已经实现了“最终一致性”,那它和“强一致性”的区别是什么呢?
没错,就是“时间差”,所以:
“最终一致性方案” + “时间差” = “强一致性方案”
那我们的工作呢,就是加上时间差, 实现方式:我们加一个缓存,将近期被修改的数据进行标记锁定。 读的时候,标记锁定的数据强行走DB,没锁定的数据,先走缓存
优缺点
容灾完善
写流程容灾分析
- 写1.1 标记失败:没关系,放弃整个更新操作
- 写1.3 DEL缓存失败:没关系,后面会覆盖
- 写1.5 写MQ失败:没关系,Databus或Canal都会重试
- 消费MQ的:1.6 || 1.7 失败:没关系,重新消费即可
读流程容灾分析
- 读2.1 读Cache_0失败:没关系,直接读主库
- 读2.3 异步写MQ失败:没关系,缓存为空,是OK的,下次还读库就好了
无并发问题
这个方案让“读库 + 刷缓存”的操作串行化,这就不存在老数据覆盖新数据的并发问题了
缺点剖析
- 增加Cache_0强依赖
这个其实有点没办法,你要强一致性,必然要牺牲一些的。 但是呢,你这个可以吧Cache_0设计成多机器多分片,这样的话,即使部分分片挂了,也只有小部分流量透过Cache直接打到DB上,这是完全是可接受的
-
复杂度是比较高的
-
吞吐量大大降低。
总结
常规最终一致性解决方案:
- 读先读缓存,缓存不在,读库。
- 写库写队列,或者订阅biglog,然后回刷缓存。
参考
blog.kido.site/2018/12/01/… www.cnblogs.com/rjzheng/p/9… blog.kido.site/2018/12/09/… zhuanlan.zhihu.com/p/59167071