面试官:集群模式下,如何解决本地缓存的数据更新问题?

本文首发于公众号:托尼学长,立个写 1024 篇原创技术面试文章的flag,欢迎过来视察监督~

不得不说,单机模式下的本地缓存是真香,无论是简单直接上手的HashMap集合,还是功能强大的Guava Cache、EhCache和Caffeine等。

这些都比需要额外的服务器进行搭建部署,并引入客户端的Redis性价比高。

尤其是Guava Cache、EhCache和Caffeine,不但在功能上与Redis相差无几,而且节省了硬件成本、提升了研发效率,少了一次网络IO。

不过,一旦从单机模式切换到集群模式下,本地缓存多份数据的更新问题就马上暴露出来了。

如上图所示,将缓存中的Key City从北京变更到上海,在Redis上只需要一次请求即可。

而在本地缓存上,则需要把集群中的所有应用服务器的缓存数据全部变更才行,这也就大大增加了执行难度。

下面我总结了四种本地缓存数据的变更方案,大家可以根据自己系统的特性选择最适合的那种。

所有服务器API调用

我们都知道,在微服务架构中可以通过Ribbon或Spring Cloud LoadBalancer进行负载均衡的。

而一旦负载均衡了就会面临一个问题,该请求只会打到集群中的一台应用服务器上,在本地缓存更新的场景上,也就只有一台应用服务器的缓存会被更新。

如下图所示:

这时,需要我们通过服务发现接口获取所有应用服务器的地址,并全部进行调用访问,才可以更新所有应用服务器的缓存数据。

代码如下所示:

java 复制代码
@RestController
public class ClusterController {

    @Autowired
    private DiscoveryClient discoveryClient; // 服务发现客户端

    @GetMapping("/cluster/data")
    public Mono<Map<String, Object>> getClusterData() {
        // 1. 获取所有实例地址(示例:user-service)
        List<ServiceInstance> instances = discoveryClient.getInstances("user-service");
        List<Mono<Map<String, Object>>> instanceCalls = instances.stream()
            .map(instance -> {
                String url = "http://" + instance.getHost() + ":" + instance.getPort() + "/data";
                return WebClient.create()
                    .get()
                    .uri(url)
                    .retrieve()
                    .bodyToMono(Map.class);
            })
            .collect(Collectors.toList());
        // 2. 并行调用所有实例并聚合结果
        return Flux.merge(instanceCalls)
            .collectMap(
                result -> (String) result.get("nodeId"), // 使用节点ID作为Key
                result -> result
            );
    }
}

这种方案简单容易上手,且不用引入任何外部依赖,数据实时性高,只支持通过程序进行主动更新。

配置中心监听

我们还可以通过配置中心(Nacos、Eureka等)的特性完成本地缓存的数据更新。

当集群中的一台应用服务器收到更新本地缓存的请求,先调用配置中心的API进行配置变更,所有服务器再监听配置变化来更新本地缓存。

如下图所示:

配置变更的代码如下:

(1)添加依赖的SDK

java 复制代码
<dependency>
    <groupId>com.alibaba.nacos</groupId>
    <artifactId>nacos-client</artifactId>
    <version>2.2.3</version>
</dependency>

(2)配置变更代码

java 复制代码
import com.alibaba.nacos.api.NacosFactory;
import com.alibaba.nacos.api.config.ConfigService;
import com.alibaba.nacos.api.exception.NacosException;
import java.util.Properties;

public class NacosSdkConfigUpdater {

    public static void updateConfig(String serverAddr, String dataId, String group, 
                                  String namespaceId, String content) throws NacosException {
        Properties properties = new Properties();
        properties.put("serverAddr", serverAddr);
        properties.put("namespace", namespaceId); 

        ConfigService configService = NacosFactory.createConfigService(properties);

        boolean isPublishOk = configService.publishConfig(dataId, group, content);

        if (isPublishOk) {
            System.out.println("配置更新成功");
        } else {
            System.err.println("配置更新失败");
        }
    }

    public static void main(String[] args) {
        try {
            updateConfig(
                "127.0.0.1:8848",  // Nacos服务器地址
                "example-data",    // dataId
                "DEFAULT_GROUP",   // group
                "",                // namespaceId,默认为空
                "newContent=tony"   // 新配置内容
            );
        } catch (NacosException e) {
            e.printStackTrace();
        }
    }
}

(3)监听配置变更

java 复制代码
import com.alibaba.nacos.api.NacosFactory;
import com.alibaba.nacos.api.config.ConfigService;
import com.alibaba.nacos.api.config.listener.Listener;
import com.alibaba.nacos.api.exception.NacosException;
import java.util.Properties;
import java.util.concurrent.Executor;

public class NacosConfigListener {

    public static void main(String[] args) throws NacosException {
        // Nacos服务器地址
        String serverAddr = "127.0.0.1:8848";
        // 配置的Data ID
        String dataId = "example-data";
        // 配置的分组
        String group = "DEFAULT_GROUP";
        // 命名空间ID(可选)
        String namespaceId = "";

        // 1. 创建配置服务
        Properties properties = new Properties();
        properties.put("serverAddr", serverAddr);
        if (namespaceId != null && !namespaceId.isEmpty()) {
            properties.put("namespace", namespaceId);
        }

        ConfigService configService = NacosFactory.createConfigService(properties);

        // 2. 添加监听器
        configService.addListener(dataId, group, new Listener() {
            @Override
            public void receiveConfigInfo(String configInfo) {
                // 当配置变更时,会调用这个方法
                System.out.println("配置发生变更,新内容为:");
                System.out.println(configInfo);
                //更新本地缓存
            }
            @Override
            public Executor getExecutor() {
                // 返回执行器,如果返回null,则使用默认的执行器
                return null;
            }
        });

        // 保持程序运行,以便持续监听
        while (true) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

这种方案实现起来麻烦一些,需要强依赖于配置中心,并存在一定的数据时延性,但可以通过程序进行主动更新,也可以登录配置中心页面进行手动更新。

消息队列广播

这种方案的实现方式与配置中心监听的方式大同小异。

当集群中的一台应用服务器收到更新本地缓存的请求,就往消息队列中发送一条广播模式的消息,所有服务器消费这条消息来更新本地缓存。

这种方案实现起来麻烦一些,需要强依赖于消息队列,并存在一定的数据时延性,只支持通过程序进行主动更新,并没有明显的优势。

XXL-JOB分片广播

前三种方案都是以用户请求为驱动来触发的,而这种方案则是通过定时任务的方案进行触发的。

XXL-JOB是一个分布式任务调度平台,其分片广播模式的实现机制是,通过调度中心往各个执行器发送请求来执行业务逻辑。

btw:这里所说的调度中心就是集群中的各个应用服务器。

实现代码如下:

java 复制代码
import com.xxl.job.core.biz.model.ReturnT;
import com.xxl.job.core.handler.IJobHandler;
import com.xxl.job.core.handler.annotation.JobHandler;
import org.springframework.stereotype.Component;

@JobHandler(value="shardingJobHandler")
@Component
public class ShardingJobHandler extends IJobHandler {
    @Override
    public ReturnT<String> execute(String param) throws Exception {
        // 获取分片参数
        int shardIndex = XxlJobHelper.getShardIndex();  // 当前分片序号(从0开始)
        int shardTotal = XxlJobHelper.getShardTotal();  // 总分片数

        System.out.println("分片参数: 当前分片=" + shardIndex + ", 总分片数=" + shardTotal);

        // 更新本地缓存

        return ReturnT.SUCCESS;
    }
}

如果XXL-JOB的调度中心挂了,可以直接向执行器发送请求即可触发任务,或者通过一个操作系统任务定时发送请求。

格式如下:

java 复制代码
POST http://执行器IP:端口/run
Content-Type: application/json
{
  "jobId": 任务ID,
  "executorHandler": "任务处理器名称",
  "executorParams": "任务参数",
  "logId": 日志ID(可随机生成),
  "broadcastIndex": 0,
  "logDateTime": 当前时间戳
}

这种方案适用于对数据一致性要求不高的场景,通过XXL-JOB对本地缓存进行定期更新,方案复杂度适中。

相关推荐
胡gh1 小时前
什么是瀑布流?用大白话给你讲明白!
前端·javascript·面试
C4程序员1 小时前
北京JAVA基础面试30天打卡06
java·开发语言·面试
Mike_小新1 小时前
【Mike随想】未来更看重架构能力和业务经验,而非单纯编码能力
后端·程序员
掘金安东尼1 小时前
前端周刊第426期(2025年8月4日–8月10日)
前端·javascript·面试
Abadbeginning1 小时前
FastSoyAdmin导出excel报错‘latin-1‘ codec can‘t encode characters in position 41-54
前端·javascript·后端
很小心的小新1 小时前
五、SpringBoot工程打包与运行
java·spring boot·后端
ACGkaka_2 小时前
SpringBoot 集成 MapStruct
java·spring boot·后端
anthem372 小时前
12、Python项目实战
后端
anthem372 小时前
7、Python高级特性 - 提升代码质量与效率
后端