目录

一、概要

二、场景一:先更新数据库,再更新缓存

三、场景二:先更新缓存,再更新数据库

四、场景三:先删除缓存,再更新数据库

五、场景四:先更新数据库,再删除缓存

六、场景五:数据库主从同步导致数据不一致

七、总结


一、概要

缓存跟数据库不一致,指的是缓存中的数据跟数据库的数据出现了不一致,即其中一方存在脏数据的现象。需要注意的是,只有在对同一条数据并发读写的时候,才可能会出现这种问题。

  • 如果系统并发量很低,特别是读并发很低,那么它发生缓存跟数据库数据不一致的情况相对比较少,概率比较低;
  • 如果系统并发量很高,像淘宝、京东等电商平台,每天都是上亿级流量,每秒并发读是几万,每秒都有写请求,这种情况下出现缓存跟数据库不一致的概率就比较高;

下面我们详细分析常见的发生缓存与数据库不一致的场景。

二、场景一:先更新数据库,再更新缓存

假设有 2 个线程A 、B并发「写」id = 1的user数据,在高并发下可能会发生以下场景:

  1. 线程A更新数据库(name = 李四):update user set name = '李四' where id = 1;
  2. 线程B更新数据库(name = 王五):update user set name = '王五' where id = 1;
  3. 线程B更新缓存(name = 王五);
  4. 线程A更新缓存(name = 李四);

可以看到,线程B操作数据库和缓存的时间,却要比线程A的时间短,执行时序发生了「错乱」,此时线程B对缓存的更新就被覆盖掉了,最终导致id = 1的用户user的值在缓存中是"李四",在数据库中是"王五",缓存和数据库数据发生不一致。可见,先更新数据库,再更新缓存,当发生「写」并发时,也会存在数据不一致的情况。

大体过程如下图所示:

image.png

实际项目中通常不采用这种方式,主要基于如下一些原因:

如上分析的执行时序发生「错乱」,最终这条数据的结果是错误的,缓存跟数据库中其中一方的数据是脏数据。

  • 性能问题

如果采用先更新数据库,再更新缓存的方式,假如我们的系统写数据比较多,而读操作比较少,那么缓存将会被频繁地更新,这样导致缓存中的数据压根就没被读请求利用上,浪费性能。

三、场景二:先更新缓存,再更新数据库

这个比较简单,假设线程A需要写数据,如执行update user set name = '李四' where id = 1,此时线程A先更新缓存数据为"李四",然后更新数据库的时候,抛异常了,失败了,导致缓存更新成功,数据库更新失败,这就造成了两者的不一致,此时如果刚好有一个线程过来读数据:select * from user where id = 1,那么从缓存中读取到的数据就是脏数据。

实际项目中通常不采用更新缓存的方式,而采用删除缓存的方式。

  • 如果更新缓存成功,数据库更新失败,此时发生不一致,缓存中的是脏数据;
  • 如果采用删除缓存的方式,如果删除缓存成功,数据库更新失败,这时候查请求大不了去数据库查询到旧值,也总比从缓存中拿到脏数据要好一些;

四、场景三:先删除缓存,再更新数据库

假设有 2 个线程A 、B并发「读写」id = 1的user数据,可能会发生以下场景:

  1. 线程 A 要更新name = 李四(旧值 name = 张三):update user set name = '李四' where id = 1;
  2. 线程 A 先删除缓存;
  3. 线程 B 读缓存,发现不存在,从数据库中读取到旧值(name = 张三);
  4. 线程 A 将新值写入数据库(name = 李四):select * from user where id = 1;
  5. 线程 B 将旧值写入缓存(name = 张三);

最终id = 1的用户user的值在缓存中是"张三(旧值)",在数据库中是"李四(新值)",缓存和数据库数据发生不一致。可见,先删除缓存,后更新数据库,当发生「读+写」并发时,还是存在数据不一致的情况。

大体过程如下图所示:

image.png

前面已经介绍了先删除缓存,再更新数据库导致数据不一致的场景,那么怎么解决呢?答案是采用

【延时双删策略+缓存超时设置】结合起来。

  • 设置缓存过期时间

所有的写操作以数据库为准,只要到达缓存过期时间,则后面的读请求自然会从数据库中读取新值,然后再写入缓存中;

  • 延时双删策略

双删,其实就是删两次缓存的意思。延时,指的是在第一次删完缓存后,延迟一段时间,比如5秒或其他时间,然后再进行第二次删除缓存。

主要过程如下:

  1. 先删除缓存;
  2. 再写数据库;
  3. 休眠一段时间;
  4. 再次删除缓存;

至于需要休眠多少,这个延迟时间很难评估,读者需要根据自己项目读数据响应时间具体给一个大概的值,然后延迟时间一般在读数据响应时间基础上,再加上几百毫秒,或者一秒即可,这样就可以确保写请求可以删除读请求造成的缓存脏数据

参考建议:

1、第一次删除缓存可以采用同步方式删除,第二次删除缓存如果读者朋友担心同步删除会影响性能的话,可以采用异步线程去删;

2、很多时候,都是凭借经验大致估算这个延迟时间,例如笔者项目中通常延迟5s后第二次删除缓存,然后配置最大重试次数,确保缓存删除成功,当然只能尽可能地降低不一致的概率,极端情况下还是会发生不一致;

3、如果第二次删除缓存失败了怎么办?当然是不断地循环尝试删除缓存,可以将删除失败的记录发送到消息队列,然后可以不断重试删除,可以配置最大重试次数,配置告警,直到删除成功。

五、场景四:先更新数据库,再删除缓存

a、第一种情况:

线程A需要修改数据,update user set name = '李四' where id = 1,数据更新完成后,在删除缓存的时候,数据库宕机或者服务宕机,导致没有删除掉缓存,此时数据库和缓存的数据也会出现不一致,数据库中是新数据,缓存还是旧数据。

b、第二种情况:

假设有 2 个线程A 、B并发「读写」id = 1的user数据,可能会发生以下场景:

  1. 线程A查询时,缓存刚好过期了;
  2. 线程A从数据库中查询到旧值(name = 张三):select * from user where id = 1;
  3. 线程B更新数据库(name = 李四):update user set name = '李四' where id = 1;
  4. 线程B删除缓存,因为缓存已经过期,所以删除操作实际上什么都没做;
  5. 线程A将旧值(张三)写入缓存;

最终id = 1的用户user的值在缓存中是"张三(旧值)",在数据库中是"李四(新值)",缓存和数据库数据发生不一致。可见,先更新数据库,再删除缓存,当发生「读+写」并发时,同样也会存在数据不一致的情况。

大体过程如下图所示:

image.png

仔细分析一下,这种情况真的会发生么?准确地说,只是理论上会发生,概率很小,仔细想想,数据库的读操作的速度肯定是远快于写操作的,主要是以下原因:

  • 缓存刚好已失效读请求 + 写请求并发更新数据库 + 删除缓存的时间(步骤 3和4),要比读数据库 + 写缓存时间短(步骤 2 和 5);
  • 写数据库一般会先「加锁」,所以写数据库,通常是要比读数据库的时间更长;

经过前面的分析,我们发现这种方式发生数据不一致的概率相对较少,实际项目中可以采用这种方式,同样可以使用延迟双删策略,但为了保证两步都成功执行,最好再配合「消息队列」或「订阅变更日志」等重试方式,更加可靠。

六、场景五:数据库主从同步导致数据不一致

假设有 2 个线程A 、B并发「读写」id = 1的user数据,可能会发生以下场景:

  1. 线程A进行写操作,删除缓存;
  2. 线程A更新数据库:update user set name = '李四' where id = 1;
  3. 线程B查询缓存发现,缓存中没有值:select * from user where id = 1;
  4. 线程B去从库查询,这时,还没有完成主从同步,因此线程B在从库查询到的是其实是旧值(name = 张三);
  5. 线程B将旧值写入缓存(name = 张三);
  6. 数据库完成主从同步,从库变为新值(name = 李四);

可以看到,由于主从数据库同步的延时,也会导致缓存与数据库数据不一致。

如何解决呢?

同样可以采用延迟双删的方式,只是第二次删除缓存的休眠时间设置为【主从数据库同步的延迟时间 + 几百ms】,当然也可以通过Canal监听从库binlog日志的方式,将数据库变更信息发送到消息队列中,然后我们去监听消息队列,异步删除Redis对应的缓存。

七、总结

前面详细介绍了缓存与数据库发生不一致的场景,通常情况下,我们可以选择使用【先删除缓存,再更新数据库】或者【先更新数据库,再删除缓存】其中一种,如果是延迟双删的话,第二次删除缓存尽量采用异步线程池去删,并结合一些重试机制,再加上最大重试次数,尽可能避免缓存与数据库数据发生不一致。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐