Jade Dungeon

缓存同步策略

后删缓存

而把删除的动作放在后面,就能够保证每次读到的值都是最新的。

public void putValue(key,value){
    putToDB(key, value);    // 保存到数据库
    deleteFromRedis(key);   // 删除redis缓存的旧数据
}

写请求:

  • 将变更写入到数据库中;
  • 删除缓存里对应的数据。

读取过程:

  • 每次读取数据,都从 cache 里读;
  • 如果读到了,则直接返回,称作 cache hit;
  • 如果读不到 cache 的数据,则从 db 里面捞一份,称作 cache miss;
  • 将读取到的数据塞入到缓存中,下次读取时,就可以直接命中。

高并发下“后删缓存”依旧不一致

假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生

  1. 缓存刚好失效
  2. 请求A查询数据库,得一个旧值
  3. 请求B将新值写入数据库
  4. 请求B删除缓存
  5. 请求A将查到的旧值写入缓存,发生脏数据

一般情况下,读取操作都是比写入操作快的,但要考虑两种极端情况:

  • 一种是这个读取操作 A,发生在更新操作 B 的尾部。(比如写操作执行1s, 读操作耗时100ms,读操作在写操作执行到800ms的时候开始执行, 在写操作执行到900ms的时候结束,所以实际上读操作仅仅比写操作快了100ms而已)
  • 一种是操作 A 的这个 Redis 的操作时长,耗费了非常多的时间。比如, 这个节点正好发生了 STW。(Java中Stop-The-World机制简称STW, 是在执行垃圾收集算法时,Java应用程序的其他所有线程都被挂起 (除了垃圾回收器之外)。Java中一种全局暂停现象,全局停顿,所有Java代码停止, native代码可以执行,但不能与JVM交互;这些现象多半是由于gc引起)

读写分离的场景下如果数据库主从同步比较慢的话,同样会出现数据不一致的问题。

延时双删

假如能够确保删除动作一定被执行,起码能缩小数据不一致的时间窗口。

常用的方法就是延时双删,依然是先更新再删除,唯一不同的是: 我们把这个删除动作,在不久之后再执行一次,比如 5 秒之后。

public void putValue(key,value){
    putToDB(key,value);
    deleteFromRedis(key);
    // 数秒后重新执行删除操作
    deleteFromRedis(key,5);
}

异步优化方式:消息队列

  1. 写请求更新数据库
  2. 缓存因为某些原因,删除失败
  3. 把删除失败的key放到消息队列
  4. 消费消息队列的消息,获取要删除的key
  5. 重试删除缓存操作

异步优化方式:基于订阅binlog的同步机制

那如果是读写分离场景呢?我们知道数据库(以Mysql为例) 主从之间的数据同步是通过binlog同步来实现的, 因此这里可以考虑订阅binlog(可以使用canal之类的中间件实现), 提取出要删除的缓存项,然后作为消息写入消息队列,然后再由消费端进行慢慢的消费和重试。

  1. 更新数据库数据
  2. 数据库会将操作信息写入binlog日志当中
  3. 订阅程序提取出所需要的数据以及key
  4. 另起一段非业务代码,获得该信息
  5. 尝试删除缓存操作,发现删除失败
  6. 将这些信息发送至消息队列
  7. 重新从消息队列中获得该数据,重试操作。

小结

针对 Redis 的缓存一致性问题,我们聊了很多。可以看到,无论你怎么做, 一致性问题总是存在,只是几率慢慢变小了。

随着对不一致问题的忍受程度越来越低、并发量越来越高, 我们所采用的方案也越来越极端。一般情况下,到了延时双删这一步, 就证明你的并发量已经够大了;再往下走,无不是对高可用、成本、一致性的权衡, 进入到了特事特办的场景,甚至要考虑基础设施, 关于这些每个公司的策略都是不一样的。

除了 Cache-Aside Pattern,一致性常见的还有 Read-Through、Write-Through、 Write-Behind 等模式,它们都有自己的应用场景,你可以再深入了解一下。