利用 Caffeine 缓存不适合存储在配置中心的配置项

前言

分布式配置中心还未普及的时候,通常我们对于项目的配置会放在配置文件中,例如 SpringBoot 项目放在 application.yml 中,以往的 Spring 项目,有些配置可能会存放在一个 properties 文件中。

对于 application.yml 的配置似乎不能动态化,于是后面出现了一些分布式配置中心,例如 nacos 等。我们需要在 application.yml 中配置命名空间、文件扩展名等。然后会把项目配置都放在分布式配置中心的文件中。

然而有一些业务场景,项目的配置并不适合放在分布式配置中心,但是又需要动态配置的功能。

不适合分布式配置中心的场景

随着公司规模的扩大,组织架构划分明确,分布式配置中心由专门的架构组负责管理,普通业务开发者无权限直接访问,仅相关业务部门的负责人具备查看和修改配置文件的权限。这样的话每次业务开发者都需要让负责人去配置中心添加和修改配置,比较麻烦。

而且,通常情况下,分布式配置中心存储的配置内容多为全局性、公共性的配置项,例如 ES、MySQL、MQ、Redis、线程池 等基础组件的配置信息。因为很多配置它并不适合放在配置中心

需要版本、开关控制的配置

如实验性功能开关、A/B 测试配置等。这类配置,尤其是开关和业务紧密相连,不适合放在远程分布式配置中心

配置值是大 JSON

有些配置的参数值会以一个大 JSON 的形式存在,若将其直接放入分布式配置中心,则需要额外维护一个独立的 JSON 配置文件,增加了管理和使用的复杂性。

用于用户交互的配置

部分业务配置涉及对客话术或展示内容,需要向产品或运营人员提供便捷的配置页面。如果让他们直接操作分布式配置中心,不仅不符合其使用习惯,还可能带来不必要的风险和操作复杂性。因此,针对这类需求,需要一种更友好、更贴合业务场景的配置管理方式。

高频动态更新的配置

需要频繁更新的配置,如实时促销活动规则、动态定价策略等。分布式配置中心的设计更适合低频更新的配置,频繁更新可能导致性能问题,活动期间客户端可能需要频繁拉取配置,增加网络开销和延迟。

项目参数配置介绍

针对分布式配置中心不适合的场景,我们需要一个功能,存储针对于业务应用的业务配置参数,这个功能很简单,只需要存储 key-value 键值对即可。让每一个微服务都有自己的专属项目参数配置。示例页面如下

配置列表页面

添加配置

更新配置

看到这里我们就自然而然的想到了实现方式非常简单。在数据库建个表存储,然后查询用 Redis 来做个缓存即可。但是我们仔细思考,似乎使用本地缓存就可以,没有必要使用 Redis。毕竟多引入一个技术组件就多一点复杂性,本地缓存我们可以考虑使用 caffeine

我们日常开发中也应该尽可能多的让自己的代码逻辑处于可配置化,这样当产品有一些相关的需求改动,可以让我们尽可能少的去生产发版

例如延迟消息发送的时间、授信失败 N 天后不允许再次授信等等

caffeine 常用 API 介绍

引入 Maven 依赖

xml 复制代码
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.2.0</version>
</dependency>

如果是 SpringBoot 项目,可以不标版本号,父依赖有引入

基本用法

Caffeine 的核心类是 CacheLoadingCache,分别用于手动加载和自动加载缓存。

创建缓存

java 复制代码
// 创建一个简单的缓存
Cache<String, String> cache = Caffeine.newBuilder()
    .maximumSize(100) // 最大缓存条目数
    .expireAfterWrite(10, TimeUnit.MINUTES) // 写入后10分钟过期
    .build();

添加和获取缓存

java 复制代码
// 添加缓存
cache.put("key1", "value1");

// 获取缓存
String value = cache.getIfPresent("key1");
System.out.println(value); // 输出: value1

使用 get 方法(自动加载)

java 复制代码
// 如果缓存不存在,则通过提供的函数加载
String value = cache.get("key2", k -> "defaultValue");
System.out.println(value); // 输出: defaultValue

LoadingCache

java 复制代码
LoadingCache<String, String> loadingCache = Caffeine.newBuilder()
    .maximumSize(100)
    .expireAfterWrite(10, TimeUnit.MINUTES)
    .build(key -> loadFromDatabase(key)); // 自动加载函数

// 模拟从数据库加载
private static String loadFromDatabase(String key) {
    //...从数据库加载
    return "value";
}

缓存淘汰策略

是不是想到了 Redis 的八股文面试中淘汰策略?同为缓存,都是大同小异的

基于容量淘汰

java 复制代码
Cache<String, String> cache = Caffeine.newBuilder()
    .maximumSize(100) // 最大缓存条目数
    .build();

基于时间淘汰

java 复制代码
Cache<String, String> cache = Caffeine.newBuilder()
    .expireAfterWrite(10, TimeUnit.MINUTES) // 写入后10分钟过期
    .expireAfterAccess(5, TimeUnit.MINUTES) // 访问后5分钟过期
    .build();

基于引用淘汰

这个没用过,有点高端的感觉

java 复制代码
Cache<String, String> cache = Caffeine.newBuilder()
    .weakKeys() // 弱引用键
    .weakValues() // 弱引用值
    .softValues() // 软引用值
    .build();

异步缓存

Caffeine 支持异步加载缓存。

创建异步缓存

java 复制代码
AsyncCache<String, String> asyncCache = Caffeine.newBuilder()
    .maximumSize(100)
    .buildAsync();

// 异步获取缓存
CompletableFuture<String> future = asyncCache.get("key4", k -> "asyncValue");
future.thenAccept(value -> System.out.println(value)); // 输出: asyncValue

异步加载缓存

java 复制代码
AsyncLoadingCache<String, String> asyncLoadingCache = Caffeine.newBuilder()
    .maximumSize(100)
    .buildAsync(key -> loadFromDatabase(key));

// 异步获取缓存
CompletableFuture<String> future = asyncLoadingCache.get("key5");
future.thenAccept(value -> System.out.println(value)); // 输出: value_for_key5

缓存统计

Caffeine 还提供了缓存命中率、加载次数等统计信息。

启用统计

java 复制代码
Cache<String, String> cache = Caffeine.newBuilder()
    .maximumSize(100)
    .recordStats() // 启用统计
    .build();

获取统计信息

java 复制代码
com.github.benmanes.caffeine.cache.stats.CacheStats stats = cache.stats();
System.out.println("命中率: " + stats.hitRate());
System.out.println("加载次数: " + stats.loadCount());
System.out.println("命中次数: " + stats.hitCount());
System.out.println("未命中次数: " + stats.missCount());

监听器

Caffeine 支持缓存事件的监听。

添加监听器

java 复制代码
Cache<String, String> cache = Caffeine.newBuilder()
    .maximumSize(100)
    .removalListener((key, value, cause) -> 
        System.out.println("Key " + key + " was removed, cause: " + cause))
    .build();

其他功能

  • 手动移除缓存
java 复制代码
cache.invalidate("key1"); // 失效单个键
cache.invalidateAll(); // 失效所有键
  • 批量操作
java 复制代码
Map<String, String> data = Map.of("key1", "value1", "key2", "value2");
cache.putAll(data);
  • 缓存清理
java 复制代码
cache.cleanUp(); // 手动触发缓存清理

项目参数配置的实现

流程图

建表语句

sql 复制代码
CREATE TABLE `project_config` (
  `id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
  `system_id` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '微服务名',
  `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '参数名(system_id + name  要唯一)',
  `value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci COMMENT '参数值',
  `description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '配置描述',
  `need_check` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否需要审核',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '删除标志',
  PRIMARY KEY (`id`),
  KEY `idx_name` (`name`) USING BTREE COMMENT '参数名索引'
) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

创建缓存

java 复制代码
// 创建一个缓存实例
public static final Cache<String, String> PROJECT_CONFIG_CACHE = Caffeine.newBuilder()
        .expireAfterWrite(600, TimeUnit.MINUTES) // 写入后 600 分钟过期
        .maximumSize(100000) // 最大缓存项数 10 w
        .build();

获取配置

java 复制代码
/**
 * 获取项目参数配置
 * */
public String getValue(String systemId, String name, String defaultValue) {
    return PROJECT_CONFIG_CACHE.get(systemId + SEPARATOR + name, k -> projectConfigService.getValue(systemId, name, defaultValue));
}

缓存不命中就查询数据库

java 复制代码
@Transactional(rollbackFor = Exception.class)
public String getValue(String systemId, String name, String defaultValue) {
    log.info("ProjectConfigService#getValue systemId-{},name-{}", systemId, name);
    //排他锁
    ProjectConfig projectConfig = projectConfigMapper.selectOne(Wrappers.<ProjectConfig>lambdaQuery().eq(ProjectConfig::getSystemId, systemId).eq(ProjectConfig::getName, name).last(" for update"));
    if (projectConfig != null) {
        return projectConfig.getValue();
    }
    projectConfig = new ProjectConfig();
    projectConfig.setSystemId(systemId);
    projectConfig.setName(name);
    projectConfig.setValue(defaultValue);
    projectConfig.setCreateTime(LocalDateTime.now());
    projectConfig.setUpdateTime(LocalDateTime.now());
    projectConfig.setNeedCheck(0);
    projectConfigMapper.insert(projectConfig);
    return projectConfig.getValue();
}

获取集合类型配置,有时业务配置是一个 List

java 复制代码
    /**
     * 获取集合配置
     * */
    public <T> List<T> getList(String systemId, String name, List<T> defaultValue) {
        String value = getValue(systemId, name, JSON.toJSONString(defaultValue));
        return JSON.parseObject(value, new TypeReference<>() {
        });
    }

获取 Map 类型配置,有时业务配置是一个 Map

java 复制代码
    /**
     * 获取 Map 配置
     * */
    public <K,V> Map<K, V> getMap(String systemId, String name, Map<K, V> defaultValue) {
        String value = getValue(systemId, name, JSON.toJSONString(defaultValue));
        return JSON.parseObject(value, new TypeReference<>() {
        });
    }

获取开关类型配置,有时我们需要根据开关来走不同的业务逻辑

java 复制代码
    /**
     * 获取开关配置
     * */
    public Boolean getSwitch(String systemId,String name,Boolean defaultValue) {
        String value = getValue(systemId, name, String.valueOf(defaultValue));
        return "1".equals(value) || "true".equals(value);
    }

源码分享

源码已分享到 github project-param-config

结语

本篇文章的内容很简单,主要是针对一些和业务强耦合的配置,不适合放在分布式配置中心的场景处理,从而使用本地项目参数配置。为了防止过度访问数据库,在数据库前面再加一层缓存处理。

如果这篇文章对你有帮助,记得点赞加关注。你的支持就是我继续创作的动力!

相关推荐
涡能增压发动积1 天前
同样的代码循环 10次正常 循环 100次就抛异常?自定义 Comparator 的 bug 让我丢尽颜面
后端
云烟成雨TD1 天前
Spring AI Alibaba 1.x 系列【6】ReactAgent 同步执行 & 流式执行
java·人工智能·spring
Wenweno0o1 天前
0基础Go语言Eino框架智能体实战-chatModel
开发语言·后端·golang
行乾1 天前
鸿蒙端 IMSDK 架构探索
架构·harmonyos
于慨1 天前
Lambda 表达式、方法引用(Method Reference)语法
java·前端·servlet
石小石Orz1 天前
油猴脚本实现生产环境加载本地qiankun子应用
前端·架构
swg3213211 天前
Spring Boot 3.X Oauth2 认证服务与资源服务
java·spring boot·后端
tyung1 天前
一个 main.go 搞定协作白板:你画一笔,全世界都看见
后端·go
gelald1 天前
SpringBoot - 自动配置原理
java·spring boot·后端
殷紫川1 天前
深入理解 AQS:从架构到实现,解锁 Java 并发编程的核心密钥
java