✅如何实现多级缓存的一致性?

✅如何实现多级缓存的一致性?

典型回答

当被问到这个问题的时候,有两个情况,一种是面试官想问你关于操作系统的CPU的缓存中的L1\L2\L3的一致性问题,一种是他想问你本地缓存、Redis这种分布式场景下的多级缓存的一致性问题。所以,你要结合语境,分析他想问那个,如果不清楚,大概率是分布式系统的这种一致性。或者你问他一下,想问的是哪个。

如果是CPU的多级缓存的一致性问题,可以直接回答MESI就行了。这里就不展开了。

✅什么是MESI缓存一致性协议

那么如果是分布式场景下的多级缓存,那么一致性问题如何解决呢?

解决多级缓存一致性的核心思想,一定要记住:“放弃强一致性,追求最终一致性”。不可能追求强一致性的!如果真的要这么强的一致性要求,那干脆就不要用多级缓存了,直接分布式缓存,或者数据库就行了。

本地缓存本身就一种牺牲一致性,换区性能的方案了(CAP中选了AP,放弃了CP),不可能做到同时能满足强一致性和性能要求的。

那么,如何保证本地缓存和分布式缓存,我们就拿Caffeine和Redis来举例,其实方案和我们之前讲过的Redis和数据库的一致性大差不差,因为他们要解决的问题是一样的。

✅如何解决Redis和数据库的一致性问题?

和Redis数据库一致性方案的区别

本地缓存和分布式缓存的一致性方案,和,Redis和数据库一致性方案,之间的区别主要有这么几个:

1、一般来说,我们的本地缓存的超时时间会设置的比较短,一般都会借助框架的自动超时和自动刷新的机制。所以相对来时比Redis和数据库的一致性保障中的容错率更高一些,即不太需要用延迟双删的方案。

2、分布式缓存只需要做一次操作就行了,就算是集群的,他也有同步的机制,但是本地缓存是默认无同步机制的,需要自己考虑多个本地缓存之间的一致性问题。

方案1、先更新Redis缓存,再删除本地缓存(常用)

这就是一种最简单的方案了,如果有需要更新缓存的时候,先去更新Redis缓存,成功之后再把本地缓存失效掉。

  1. 写请求到达,先更新Redis。
  2. 删除本地缓存中对应的数据。
  3. 通过某种广播机制,通知集群中的所有其他节点,删除其本地缓存 (L1) 中对应的数据。

这里所谓的广播机制可以借助配置中心或者MQ的广播消息实现,具体可以参考:

✅如何保证本地缓存的一致性?

这里为什么不是更新本地缓存而是要删除本地缓存,这个和Redis数据库一致性中介绍的原因是一样的。

✅如何解决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 触发事件通知(如 delset);

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,则表示 Key user:123 在 DB0 被删除。
  • E:Keyevent 通知:某个事件发生在什么 key 上。 事件命名规则:__keyevent@<db>__:<event>,如 __keyevent@0__:expired事件发送的内容为user:123,表示 Key user: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,则处理,比如删除本地缓存即可。