缓存同步策略
后删缓存
而把删除的动作放在后面,就能够保证每次读到的值都是最新的。
public void putValue(key,value){ putToDB(key, value); // 保存到数据库 deleteFromRedis(key); // 删除redis缓存的旧数据 }
写请求:
- 将变更写入到数据库中;
- 删除缓存里对应的数据。
读取过程:
- 每次读取数据,都从 cache 里读;
- 如果读到了,则直接返回,称作 cache hit;
- 如果读不到 cache 的数据,则从 db 里面捞一份,称作 cache miss;
- 将读取到的数据塞入到缓存中,下次读取时,就可以直接命中。
高并发下“后删缓存”依旧不一致
假设这会有两个请求,一个请求A做查询操作,一个请求B做更新操作,那么会有如下情形产生
- 缓存刚好失效
- 请求A查询数据库,得一个旧值
- 请求B将新值写入数据库
- 请求B删除缓存
- 请求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); }
异步优化方式:消息队列
- 写请求更新数据库
- 缓存因为某些原因,删除失败
- 把删除失败的key放到消息队列
- 消费消息队列的消息,获取要删除的key
- 重试删除缓存操作
异步优化方式:基于订阅binlog的同步机制
那如果是读写分离场景呢?我们知道数据库(以Mysql为例) 主从之间的数据同步是通过binlog同步来实现的, 因此这里可以考虑订阅binlog(可以使用canal之类的中间件实现), 提取出要删除的缓存项,然后作为消息写入消息队列,然后再由消费端进行慢慢的消费和重试。
- 更新数据库数据
- 数据库会将操作信息写入binlog日志当中
- 订阅程序提取出所需要的数据以及key
- 另起一段非业务代码,获得该信息
- 尝试删除缓存操作,发现删除失败
- 将这些信息发送至消息队列
- 重新从消息队列中获得该数据,重试操作。
小结
针对 Redis 的缓存一致性问题,我们聊了很多。可以看到,无论你怎么做, 一致性问题总是存在,只是几率慢慢变小了。
随着对不一致问题的忍受程度越来越低、并发量越来越高, 我们所采用的方案也越来越极端。一般情况下,到了延时双删这一步, 就证明你的并发量已经够大了;再往下走,无不是对高可用、成本、一致性的权衡, 进入到了特事特办的场景,甚至要考虑基础设施, 关于这些每个公司的策略都是不一样的。
除了 Cache-Aside Pattern,一致性常见的还有 Read-Through、Write-Through、 Write-Behind 等模式,它们都有自己的应用场景,你可以再深入了解一下。