【参考文章1】
聊聊db和缓存一致性常见的实现方式 (qq.com)
【参考文章2】
https://mp.weixin.qq.com/s/GUuU5imUl9vhwEcPgBDAvw
数据存储在数据库中,为了加快业务访问的速度,我们将数据库中的一些数据放在缓存中,那么问题来了,如何确保db和缓存中数据的一致性呢?我们列出了5种方法,大家都了解一下,然后根据业务自己选择。
方案1
获取缓存逻辑
使用过定时器,定时刷新redis中的缓存。
db更新数据逻辑
更新数据不用考虑缓存中的数据,直接更新数据就可以了
存在的问题
缓存中数据和db中数据一致性可能没有那么及时,不过最终在某个时间点,数据是一致的。
方案2
获取缓存逻辑
c1:根据key在redis中获取对应的value
c2:如果value存在,直接返回value;若value不存在,继续下面步骤
c3:从数据库获取值,赋值给value,然后将key->value放入redis,返回value
更新db逻辑
u1:开始db事务
u2:更新数据
u3:提交db事务
u4:删除redis中当前数据的缓存
存在的问题
-
上面u3成功,u4失败,会导致删除缓存失败,导致缓存中数据和db数据会不一致。
-
如果同时有很多线程到达c2发现缓存不存在,同时请求c3访问db,会对db造成很大的压力
方案3
获取缓存逻辑
c1:根据key在redis中获取对应的value
c2:如果value存在,直接返回value;若value不存在,继续下面步骤
c3:从数据库获取值,赋值给value,然后将key->value放入redis,返回value
更新db逻辑
u1:删除redis中当前数据的缓存
u2:开始db事务
u3:更新数据
u4:提交db事务
存在的问题
-
更新数据的线程执行u1成功之后,u2还未执行时,此时获取缓存的线程刚好执行了c1到c3的逻辑,此时会将旧的数据放入redis,导致redis和db数据不一致
-
同样存在方案2中说到的问题:如果同时有很多线程到达c2发现缓存不存在,同时请求c3访问db,会对db造成很大的压力
方案4
对方案2做改进,确保db更新成功之后,删除缓存操作一定会执行,我们可以通过可靠消息来实现,可靠消息可以确保更新db操作和删除redis中缓存最终要么都成功要么都失败,依靠的是最终一致性来实现的。
改进之后过程如下。
获取缓存逻辑
c1:根据key在redis中获取对应的value
c2:如果value存在,直接返回value;若value不存在,继续下面步骤
c3:从数据库获取值,赋值给value,然后将key->value放入redis,返回value
更新db逻辑
u1:开始db事务
u2:更新数据
u3:投递删除redis缓存的消息
u4:提交db事务
消息消费者-清理redis缓存的消费者
接受到清理redis缓存的消息之后,将redis中对应的缓存清除。
存在的问题
-
更新db和清理redis中的缓存之间存在一定的时间延迟,这段时间内,redis缓存的数据是旧的,也就是说这段时间内db和缓存数据是不一致的,但是最终会一致,这个不一致的时间可能比较小(这个需要看消息消费的效率了)
-
同样存在方案2中说到的问题:如果同时有很多线程到达c2发现缓存不存在,同时请求c3访问db,会对db造成很大的压力
关于可靠消息的,可以看
方式5
我们先了解一些知识。
redis中几个方法
get(key)
获取key的值,如果存在,则返回;如果不存在,则返回nil
setnx(key,value)
setnx的含义就是SET if Not Exists,该方法是原子的,如果key不存在,则设置当前key成功,返回1;如果当前key已经存在,则设置当前key失败,返回0
del(key)
将key对应的值从redis中删除
数据库相关知识
select v from t where t.key = #key# for update;
update t set v = #v# where t.key = #key#;
上面两个sql会相互阻塞,直到其中一个提交之后,另外一个才可以继续执行。
下面我们就通过上面的知识来实现db和缓存强一致性。
更新数据逻辑
1.打开db事务2.update t set v = #v# where t.key = #key#;3.根据key删除redis中的缓存:RedisUti.del(key);4.提交db事务
获取缓存逻辑
/*公众号:路人甲Java* 工作10年的前阿里P7分享Java、算法、数据库方面的技术干货!* 坚信用技术改变命运,让家人过上更体面的生活。*/public class CacheUtil { //根据key获取缓存中对应的value public static String getCache(String key) throws InterruptedException { String value = RedisUtils.get(key); if (value != null) { return value; } //过期时间为当前时间+5秒 String expireTimeKey = key + "ExpireTime"; long expireTimeValue = System.currentTimeMillis() + 5000; //setnx是原子操作,所以只有一个会成功 int setnx = RedisUtils.setnx(expireTimeKey, expireTimeValue + ""); if (setnx == 0) { expireTimeValue = Long.valueOf(RedisUtils.get(expireTimeKey)); //如果expireTimeValue小于当前时间,说明expireTimeKey过期了,将其删除 if (System.currentTimeMillis() > expireTimeValue) { //将expireTimeKey对应的删除 RedisUtils.del(expireTimeKey); } else { //休眠1秒继续获取 TimeUnit.SECONDS.sleep(1); } //重试 return getCache(key); } else { //1. 开启db事务 start transaction; //2. 执行update t set v = #v# where t.key = #key# for update; 将v的值赋值给value select v from t# where t.key = #key# for update; RedisUtils.set(key, value); //3.提交db事务 commit transaction; } return value; } //redis工具类,内部方法为伪代码 public static class RedisUtils { //根据key获取value public static String get(String key) { return null; } //设置key对应的value public static void set(String key, String value) { } //删除redis中一个key对应的值 public static void del(String key) { } //setnx的含义就是SET if Not Exists,该方法是原子的,如果key不存在, //则设置当前key成功,返回1;如果当前key已经存在,则设置当前key失败,返回0 public static int setnx(String key, String value) { return 1; } }}
=================================================文章二======
更新缓存策略方式常见的有下面几种:
- 先更新缓存,再更新数据库
- 先更新数据库,再更新缓存
- 先删除缓存,再更新数据库
- 先更新数据库,再删除缓存
下面一一介绍!
方案一:更新缓存,更新数据库
这种方式可轻易排除,因为如果先更新缓存成功,但是数据库更新失败,则肯定会造成数据不一致。
方案二:更新数据库,更新缓存
这种缓存更新策略俗称双写,存在问题是:并发更新数据库场景下,会将脏数据刷到缓存
<pre data-tool="mdnice编辑器" style="margin: 10px 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial; caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-size: 16px; text-align: left; text-size-adjust: auto; border-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px;">updateDB(); updateRedis();
</pre>
举例:如果在两个操作之间数据库和缓存又被后面请求修改,此时再去更新缓存已经是过期数据了。
图片方案三:删除缓存,更新数据库
存在问题:更新数据库之前,若有查询请求,会将脏数据刷到缓存
<pre data-tool="mdnice编辑器" style="margin: 10px 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial; caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-size: 16px; text-align: left; text-size-adjust: auto; border-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px;">deleteRedis(); updateDB();
</pre>
举例:如果在两个操作之间发生了数据查询,那么会有旧数据放入缓存。
图片该方案会导致请求数据不一致
如果同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:
-
请求A进行写操作,删除缓存
-
请求B查询发现缓存不存在
-
请求B去数据库查询得到旧值
-
请求B将旧值写入缓存
-
请求A将新值写入数据库
上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。
方案四:更新数据库,删除缓存
存在问题:在更新数据库之前有查询请求,并且缓存失效了,会查询数据库,然后更新缓存。如果在查询数据库和更新缓存之间进行了数据库更新的操作,那么就会把脏数据刷到缓存
<pre data-tool="mdnice编辑器" style="margin: 10px 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial; caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-size: 16px; text-align: left; text-size-adjust: auto; border-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px;">updateDB(); deleteRedis();
</pre>
举例:如果在查询数据库和放入缓存这两个操作中间发生了数据更新并且删除缓存,那么会有旧数据放入缓存。
图片假设有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生
-
缓存刚好失效
-
请求A查询数据库,得一个旧值
-
请求B将新值写入数据库
-
请求B删除缓存
-
请求A将查到的旧值写入缓存
如果发生上述情况,确实是会发生脏数据。但是发生上述情况有一个先天性条件,就是写数据库操作比读数据库操作耗时更短
不过数据库的读操作的速度远快于写操作的
因此这一情形很难出现。
方案对比
方案1和方案2的共同缺点:
并发更新数据库场景下,会将脏数据刷到缓存,但一般并发写的场景概率都相对小一些;
线程安全角度,会产生脏数据,比如:
- 线程A更新了数据库
- 线程B更新了数据库
- 线程B更新了缓存
- 线程A更新了缓存
方案3和方案4的共同缺点:
不管采用哪种顺序,2种方式都是存在一些问题的:
- 主从延时问题:不管是先删除还是后删除,数据库主从延时可能导致脏数据的产生。
- 缓存删除失败:如果缓存删除失败,则都会产生脏数据。
问题解决思路:延迟双删,添加重试机制,下面介绍!
更新缓存还是删除缓存?
1.更新缓存缓存需要有一定的维护成本,而且会存在并发更新的问题
2.写多读少的情况下,读请求还没有来,缓存以及被更新很多次,没有起到缓存的作用
3.放入缓存的值可能是经过复杂计算的,如果每次更新,都计算写入缓存的值,浪费性能的
删除缓存优点:简单、成本低,容易开发;缺点:会造成一次cache miss
如果更新缓存开销较小并且读多写少,基本不会有写并发的时候可以才用更新缓存,否则通用做法还是删除缓存。
总结
方案 | 问题 | 问题出现概率 | 推荐程度 |
---|---|---|---|
更新缓存 -> 更新数据库 | 为了保证数据准确性,数据必须以数据库更新结果为准,所以该方案绝不可行 | 大 | 不推荐 |
更新数据库 -> 更新缓存 | 并发更新数据库场景下,会将脏数据刷到缓存 | 并发写场景,概率一般 | 写请求较多时会出现不一致问题,不推荐使用。 |
删除缓存 -> 更新数据库 | 更新数据库之前,若有查询请求,会将脏数据刷到缓存 | 并发读场景,概率较大 | 读请求较多时会出现不一致问题,不推荐使用 |
更新数据库 -> 删除缓存 | 在更新数据库之前有查询请求,并且缓存失效了,会查询数据库,然后更新缓存。如果在查询数据库和更新缓存之间进行了数据库更新的操作,那么就会把脏数据刷到缓存 | 并发读场景&读操作慢于写操作,概率最小 | 读操作比写操作更慢的情况较少,相比于其他方式出错的概率小一些。勉强推荐。 |
推荐方案
延迟双删
采用更新前后双删除缓存策略
<pre data-tool="mdnice编辑器" style="margin: 10px 0px; padding: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial; caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-size: 16px; text-align: left; text-size-adjust: auto; border-radius: 5px; box-shadow: rgba(0, 0, 0, 0.55) 0px 2px 10px;">public void write(String key,Object data){ redis.del(key); db.update(data); Thread.sleep(1000); redis.del(key); }
</pre>
-
先淘汰缓存
-
再写数据库
-
休眠1秒,再次淘汰缓存
大家应该评估自己的项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上即可。
这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
问题及解法:
1、同步删除,吞吐量降低如何处理
将第二次删除作为异步的,提交一个延迟的执行任务
2、解决删除失败的方式:
添加重试机制,例如:将删除失败的key,写入消息队列;但对业务耦合有些严重;
图片延时工具可以选择:
最普通的阻塞Thread.currentThread().sleep(1000);
Jdk调度线程池,quartz定时任务,利用jdk自带的delayQueue,netty的HashWheelTimer,Rabbitmq的延时队列,等等
实际场景
我们有个商品中心的场景,是读多写少的服务,并且写数据会发送MQ通知下游拿数据,这样就需要严格保证缓存和数据库的一致性,需要提供高可靠的系统服务能力。
写缓存策略
- 缓存key设置失效时间
- 先DB操作,再缓存失效
- 写操作都标记key(美团中间件)强制走主库
- 接入美团中间件监听binlog(美团中间件)变化的数据在进行兜底,再删除缓存
读缓存策略
- 先判断是否走主库
- 如果走主库,则使用标记(美团中间件)查主库
- 如果不是,则查看缓存中是否有数据
- 缓存中有数据,则使用缓存数据作为结果
- 如果没有,则查DB数据,再写数据到缓存
注意
关于缓存过期时间的问题
如果缓存设置了过期时间,那么上述的所有不一致情况都只是暂时的。
但是如果没有设置过期时间,那么不一致问题就只能等到下次更新数据时解决。
所以一定要设置缓存过期时间。
网友评论