共计 3936 个字符,预计需要花费 10 分钟才能阅读完成。
问题
你只有用缓存,就可能会波及到缓存与数据库双存储双写,你只有是双写,就肯定会有数据一致性的问题,那么你如何解决一致性问题?
剖析
先做一个阐明,从实践上来说,有两种解决思维,一种需保证数据强一致性,这样性能必定大打折扣;另外咱们能够采纳最终一致性,保障性能的根底上,容许肯定工夫内的数据不统一,但最终数据是统一的。
一致性问题是如何产生的?
对于读取过程:
- 首先,读缓存;
- 如果缓存里没有值,那就读取数据库的值;
- 同时把这个值写进缓存中。
双更新模式:操作不合理,导致数据一致性问题
咱们来看下常见的一个谬误编码方式:
public void putValue(key,value){
// 保留到 redis
putToRedis(key,value);
// 保留到 MySQL
putToDB(key,value);// 操作失败了
}
比方我要更新一个值,首先刷了缓存,而后把数据库也更新了。但过程中,更新数据库可能会失败,产生了回滚。所以,最初“缓存里的数据”和“数据库的数据”就不一样了,也就是呈现了数据一致性问题。
你或者会说:我先更新数据库,再更新缓存不就行了?
public void putValue(key,value){
// 保留到 MySQL
putToDB(key,value);
// 保留到 redis
putToRedis(key,value);
}
这仍然会有问题。
思考到上面的场景:操作 A 更新 a 的值为 1,操作 B 更新 a 的值为 2。因为数据库和 Redis 的操作,并不是原子的,它们的执行时长也不是可管制的。当两个申请的时序产生了错乱,就会产生缓存不统一的状况。
放到实操中,就如上图所示:A 操作在更新数据库胜利后,再更新 Redis;但在更新 Redis 之前,另外一个更新操作 B 执行结束。那么操作 A 的这个 Redis 更新动作,就和数据库外面的值不一样了。
那么怎么办呢?其实,咱们把“缓存更新”改成“删除”就好了。
不再更新缓存,间接删除,为什么?
- 业务角度思考
起因很简略,很多时候,在简单点的缓存场景,缓存不单单是数据库中间接取出来的值。比方可能更新了某个表的一个字段,而后其对应的缓存,是须要查问另外两个表的数据并进行运算,能力计算出缓存最新的值的。
- 性价比角度思考
更新缓存的代价有时候是很高的。如果频繁更新缓存,须要思考这个缓存到底会不会被频繁拜访?
举个栗子,一个缓存波及的表的字段,在 1 分钟内就批改了 20 次,或者是 100 次,那么缓存更新 20 次、100 次;然而这个缓存在 1 分钟内只被读取了 1 次,有大量的冷数据。实际上,如果你只是删除缓存的话,那么在 1 分钟内,这个缓存不过就从新计算一次而已,开销大幅度降低。用到缓存才去算缓存。
“后删缓存”能解决少数不统一
因为每次读取时,如果判断 Redis 里没有值,就会从新读取数据库,这个逻辑是没问题的。
惟一的问题是:咱们是先删除缓存?还是后删除缓存?
答案是后删除缓存。
1. 如果先删缓存
咱们来看一下先删除缓存会有什么问题:
public void putValue(key,value){
// 删除 redis 数据
deleteFromRedis(key);
// 保留到数据库
putToDB(key,value);
}
就和下面的图一样。操作 B 删除了某个 key 的值,这时候有另外一个申请 A 到来,那么它就会击穿到数据库,读取到旧的值, 而后写入 redis,无论操作 B 更新数据库的操作继续多长时间,都会产生不统一的状况。
2. 如果后删缓存
而把删除的动作放在前面,就可能保障每次读到的值都是最新的。
public void putValue(key,value){
// 保留到数据库
putToDB(key,value);
// 删除 redis 数据
deleteFromRedis(key);
}
这就是咱们通常说的 Cache-Aside Pattern,也是咱们平时应用最多的模式。咱们看一下它的具体形式。
先看一下数据的读取过程,规定是“先读 cache,再读 db”,具体步骤如下:
- 每次读取数据,都从 cache 里读;
- 如果读到了,则间接返回,称作 cache hit;
- 如果读不到 cache 的数据,则从 db 外面捞一份,称作 cache miss;
- 将读取到的数据塞入到缓存中,下次读取时,就能够间接命中。
再来看一下写申请,规定是“先更新 db,再删除缓存”,具体步骤如下:
- 将变更写入到数据库中;
- 删除缓存里对应的数据。
大厂高并发,“后删缓存”仍旧不统一
这种状况不存在并发问题么?不是的。假如这会有两个申请,一个申请 A 做查问操作,一个申请 B 做更新操作,那么会有如下情景产生
- 缓存刚好生效
- 申请 A 查询数据库,得一个旧值
- 申请 B 将新值写入数据库
- 申请 B 删除缓存
- 申请 A 将查到的旧值写入缓存
如果产生上述情况,的确是会产生脏数据。
然而,产生这种状况的概率又有多少呢?
产生上述情况有一个先天性条件,就是步骤(3)的写数据库操作比步骤(2)的读数据库操作耗时更短,才有可能使得步骤(4)先于步骤(5)。可是,大家想想,数据库的读操作的速度远快于写操作的,因而步骤(3)耗时比步骤(2)更短,这一情景很难呈现。
这种场景的呈现,不仅须要缓存生效且读写并发执行,而且还须要读申请查询数据库的执行早于写申请更新数据库,同时读申请的执行实现晚于写申请。这种不统一场景产生的条件十分严格,个别业务是达不到这个量级的,所以个别公司不去解决这种状况,但高并发业务就十分常见了。
那如果是读写拆散的场景下呢?如果依照如下所述的执行序列,一样会出问题:
- 申请 A 更新主库
- 申请 A 删除缓存
- 申请 B 查问缓存,没有命中,查问从库失去旧值
- 从库同步结束
- 申请 B 将旧值写入缓存
如果数据库主从同步比较慢的话,同样会呈现数据不统一的问题。事实上就是如此,毕竟咱们操作的是两个零碎,在高并发的场景下,咱们很难去保障多个申请之间的执行程序,或者就算做到了,也可能会在性能上付出极大的代价。
加锁?
能够采纳加锁在写申请中保障“更新数据库 & 删除缓存”的串行执行为原子性操作(同理也可对读申请中缓存的更新加锁)。加锁势必会导致吞吐量的降落,故采取加锁的计划应该对性能的损耗有所预期。
如何解决高并发的不统一问题?
大家看下面这种不统一状况产生的场景,归根结底还是“删除操作”产生在“更新操作”之前了。
延时双删
如果我有一种机制,可能确保删除动作肯定被执行,那就能够解决问题,起码能放大数据不统一的工夫窗口。
罕用的办法就是延时双删,仍然是先更新再删除,惟一不同的是:咱们把这个删除动作,在不久之后再执行一次,比方 5 秒之后。
public void putValue(key,value){putToDB(key,value);
deleteFromRedis(key);
// 数秒后从新执行删除操作
deleteFromRedis(key,5);
}
这个休眠工夫 = 读业务逻辑数据的耗时 + 几百毫秒。为了确保读申请完结,写申请能够删除读申请可能带来的缓存脏数据。
这种计划还算能够,只有休眠那一会,可能有脏数据,个别业务也会承受的。
其实在探讨最初一个计划时,咱们没有思考 操作数据库或者操作缓存可能失败 的状况,而这种状况也是客观存在的。
那么在这里咱们简略探讨下,首先是如果更新数据库失败了,其实没有太大关系,因为此时数据库和缓存中都还是老数据,不存在不统一的问题。假如删除缓存失败了呢?此时的确会存在数据不统一的状况。除了设置缓存过期工夫这种兜底计划之外,如果咱们心愿尽可能保障缓存能够被及时删除,那么咱们必须要思考对删除操作进行重试。
删除缓存重试机制
你当然能够间接在代码中对删除操作进行重试,然而要晓得如果是网络起因导致的失败,立即进行重试操作很可能也是失败的,因而在每次重试之间你可能须要期待一段时间,比方几百毫秒甚至是秒级期待。为了不影响主流程的失常运行,你可能会将这个事件交给一个异步线程来执行。
而删除动作也有多种抉择:
- 如果开线程去执行,会有随着 JVM 过程的死亡,失落更新的危险;
- 如果放在 MQ 中,会减少编码的复杂性。
所以到了这个时候,并没有一个可能行走天下的解决方案。咱们得综合评估很多因素去做设计,比方团队的程度、工期、不统一的忍耐水平等。
异步优化形式:音讯队列
- 写申请更新数据库
- 缓存因为某些起因,删除失败
- 把删除失败的 key 放到音讯队列
- 生产音讯队列的音讯,获取要删除的 key
- 重试删除缓存操作
异步优化形式:基于订阅 binlog 的同步机制
那如果是读写拆散场景呢?咱们晓得数据库(以 Mysql 为例)主从之间的数据同步是通过 binlog 同步来实现的,因而这里能够思考订阅 binlog(能够应用 canal 之类的中间件实现),提取出要删除的缓存项,而后作为音讯写入音讯队列,而后再由生产端进行缓缓的生产和重试。
- 更新数据库数据
- 数据库会将操作信息写入 binlog 日志当中
- 订阅程序提取出所须要的数据以及 key
- 另起一段非业务代码,取得该信息
- 尝试删除缓存操作,发现删除失败
- 将这些信息发送至音讯队列
- 从新从音讯队列中取得该数据,重试操作。
小结
针对 Redis 的缓存一致性问题,咱们聊了很多。能够看到,无论你怎么做,一致性问题总是存在,只是几率缓缓变小了。
随着对不统一问题的忍耐水平越来越低、并发量越来越高,咱们所采纳的计划也越来越极其。个别状况下,到了延时双删这一步,就证实你的并发量曾经够大了;再往下走,无不是对高可用、老本、一致性的衡量,进入到了特事特办的场景,甚至要思考基础设施,对于这些每个公司的策略都是不一样的。
除了 Cache-Aside Pattern,一致性常见的还有 Read-Through、Write-Through、Write-Behind 等模式,它们都有本人的利用场景,你能够再深刻理解一下。
参考
数据库与缓存的双写一致性
数据库与缓存的一致性问题
Redis 缓存一致性设计