✅如何实现多级缓存的一致性?
典型回答
当被问到这个问题的时候,有两个情况,一种是面试官想问你关于操作系统的CPU的缓存中的L1\L2\L3的一致性问题,一种是他想问你本地缓存、Redis这种分布式场景下的多级缓存的一致性问题。所以,你要结合语境,分析他想问那个,如果不清楚,大概率是分布式系统的这种一致性。或者你问他一下,想问的是哪个。
如果是CPU的多级缓存的一致性问题,可以直接回答MESI就行了。这里就不展开了。
那么如果是分布式场景下的多级缓存,那么一致性问题如何解决呢?
解决多级缓存一致性的核心思想,一定要记住:“放弃强一致性,追求最终一致性”。不可能追求强一致性的!如果真的要这么强的一致性要求,那干脆就不要用多级缓存了,直接分布式缓存,或者数据库就行了。
本地缓存本身就一种牺牲一致性,换区性能的方案了(CAP中选了AP,放弃了CP),不可能做到同时能满足强一致性和性能要求的。
那么,如何保证本地缓存和分布式缓存,我们就拿Caffeine和Redis来举例,其实方案和我们之前讲过的Redis和数据库的一致性大差不差,因为他们要解决的问题是一样的。
和Redis数据库一致性方案的区别
本地缓存和分布式缓存的一致性方案,和,Redis和数据库一致性方案,之间的区别主要有这么几个:
1、一般来说,我们的本地缓存的超时时间会设置的比较短,一般都会借助框架的自动超时和自动刷新的机制。所以相对来时比Redis和数据库的一致性保障中的容错率更高一些,即不太需要用延迟双删的方案。
2、分布式缓存只需要做一次操作就行了,就算是集群的,他也有同步的机制,但是本地缓存是默认无同步机制的,需要自己考虑多个本地缓存之间的一致性问题。
方案1、先更新Redis缓存,再删除本地缓存(常用)
这就是一种最简单的方案了,如果有需要更新缓存的时候,先去更新Redis缓存,成功之后再把本地缓存失效掉。
- 写请求到达,先更新Redis。
- 删除本地缓存中对应的数据。
- 通过某种广播机制,通知集群中的所有其他节点,删除其本地缓存 (L1) 中对应的数据。
这里所谓的广播机制可以借助配置中心或者MQ的广播消息实现,具体可以参考:
这里为什么不是更新本地缓存而是要删除本地缓存,这个和Redis数据库一致性中介绍的原因是一样的。
方案2、基于Canal异步失效缓存(大厂常用)
这是一个非常优雅且对业务代码侵入性极小的方案。Canal 模拟 MySQL Slave,监听数据库的 binlog。当有数据变更时,Canal 可以从 binlog 中解析出变更的数据和表名。Canal 将变更信息发送给 MQ。所有的应用节点消费 MQ 中的消息,解析出哪些数据发生了变更,然后同时删除 Redis 中的数据和自己的本地缓存。
这个方案我们在介绍Redis数据库一致性的时候就提过,比较常见的方案,叫做Cache-Aside,好处就是同步链路上不需要操作缓存,只需要操作数据库就行了, 其他的靠binlog监听的方式异步保证。
而再加上本地缓存之后,就还有个好处了,那就是天然就可以借助MQ的广播机制,来实现多个节点上的本地缓存的一致性删除了。
但是,这个方案有个关键的限制,那就是一定要依赖数据库,如果是那种单纯是本地缓存+分布式缓存的存储架构,就不适合这种方案了。可以用下面的方案。
方案3,借助Redis的事件Pub/Sub机制(复杂,不建议)
借助 Redis 的事件通知(Keyspace Notifications)+ Pub/Sub 确实能够帮助实现多级缓存一致性
Redis 提供了 Keyspace Notifications 功能,可以对数据库中某些事件(如 key 被修改、过期、删除)发布消息,客户端通过 Pub/Sub 订阅相应的频道来感知。简单的流程如下:
1、应用删除/更新 Redis 中的缓存;
2、Redis 触发事件通知(如 del 或 set);
3、订阅该事件的应用实例清理/更新自己的本地缓存。
优点就是借助Redis就能实现,不需要依赖MQ,缺点就是Redis的Pub/Sub 是“即发即弃”的,如果客户端掉线会漏消息。
使用方法:
修改 redis.conf 或者运行时设置: config set notify-keyspace-events KEA
K:Keyspace 通知:某个 key 发生了什么事件。 事件命名规则:__keyspace@<db>__:<key>。如_keyspace@0__:user:123事件,发送的内容为del,则表示 Keyuser:123在 DB0 被删除。E:Keyevent 通知:某个事件发生在什么 key 上。 事件命名规则:__keyevent@<db>__:<event>,如__keyevent@0__:expired事件发送的内容为user:123,表示 Keyuser:123在 DB0 过期 。A:所有事件
代码示例,通过keyspace监听实现(基于Jedis实现):
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPubSub;
public class RedisKeyspaceListener {
public static void main(String[] args) {
Jedis jedis = new Jedis("localhost", 6379);
new Thread(() -> {
jedis.psubscribe(new JedisPubSub() {
@Override
public void onPMessage(String pattern, String channel, String message) {
System.out.println("Pattern: " + pattern
+ " | Channel: " + channel
+ " | Message: " + message);
// channel 格式: __keyspace@0__:test:hollis:cache
// message 内容: set / del / expired
if (channel.equals("__keyspace@0__:test:hollis:cache")) {
switch (message) {
case "set":
System.out.println("Key 更新: test:hollis:cache");
break;
case "del":
System.out.println("Key 删除: test:hollis:cache");
break;
case "expired":
System.out.println("Key 过期: test:hollis:cache");
break;
}
}
}
},
"__keyspace@0__:test:hollis:cache"); // 订阅该 key 的所有事件
}).start();
}
}监听test:hollis:cache这个key的所有事件,然后针对更新、过期、删除做处理,即删除本地缓存即可。
代码示例,通过keyevent监听实现:
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPubSub;
public class RedisKeyListener {
public static void main(String[] args) {
Jedis jedis = new Jedis("localhost", 6379);
// 启动一个新线程监听
new Thread(() -> {
jedis.psubscribe(new JedisPubSub() {
@Override
public void onPMessage(String pattern, String channel, String message) {
// 根据事件处理逻辑
if (channel.equals("__keyevent@0__:expired") && message.equals("test:hollis:cache")) {
System.out.println("Key 过期: " + message);
} else if (channel.equals("__keyevent@0__:del") && message.equals("test:hollis:cache")) {
System.out.println("Key 删除: " + message);
} else if (channel.equals("__keyevent@0__:set") && message.equals("test:hollis:cache")) {
System.out.println("Key 更新: " + message);
}
}
},
"__keyevent@0__:expired", // 订阅过期事件
"__keyevent@0__:del", // 订阅删除事件
"__keyevent@0__:set"); // 订阅 set 事件
}).start();
}
}监听所有key的过期、删除、更新时间,然后判断如果是我们关心的test:hollis:cache,则处理,比如删除本地缓存即可。