我写 Solon 已经有一段时间了,早期让我感到惊讶的一点是它的缓存层有多干净。并不是因为它做了什么革命性的事情——它并没有——而是因为它 不妨碍你,同时还能处理你在生产中实际遇到的边缘情况。

让我来分享一下我学到的东西。

三个注解

Solon 只提供了三个缓存注解。不多不少:

  • @Cache — 读透缓存。如果 key 存在,直接返回;否则执行方法并缓存结果。
  • @CachePut — 始终执行方法,然后用结果更新缓存。
  • @CacheRemove — 执行方法,然后清除一个或多个缓存条目。

下面是一个展示全部三个注解的最小示例:

@Controller
public class CacheController {
    @Cache(key = "user:${userId}", seconds = 30)
    @Mapping("/user/{userId}")
    public User getUser(int userId) {
        return userService.findById(userId);
    }

    @CachePut(key = "user:${userId}")
    @Mapping("/user/{userId}/refresh")
    public User refreshUser(int userId) {
        return userService.findById(userId);
    }

    @CacheRemove(tags = "users")
    @Mapping("/user/{userId}/delete")
    public String deleteUser(int userId) {
        userService.delete(userId);
        return "deleted";
    }
}

Enter fullscreen mode Exit fullscreen mode

@CacheRemove(tags = "users") 这行值得仔细看看——它实现了大多数基于注解的缓存系统都很难做到的事。

Tags 技巧

大多数缓存框架只提供基于 key 的清除。你知道 key,就删除它。这对简单场景没问题,但如果是“清除所有与用户相关的缓存”呢?

这时 tags 就派上用场了。当你用 @Cache(tags = "users") 标记一个方法时,Solon 不仅把值存到计算出的 key 下,还会把这个 key 记录到 tag 索引中。之后当你调用 @CacheRemove(tags = "users") 时,它会找到与该 tag 关联的所有 key 并全部清除。

@Cache(key = "user:${userId}", tags = "users")
@Mapping("/user/{userId}")
public User getUser(int userId) { ... }

@Cache(key = "user:${userId}:profile", tags = "users")
@Mapping("/user/{userId}/profile")
public Profile getProfile(int userId) { ... }

// 一次操作清除两个缓存
@CacheRemove(tags = "users")
@Mapping("/admin/users/flush")
public String flushUsers() { ... }

Enter fullscreen mode Exit fullscreen mode

我经常用这个模式来处理分页列表。你缓存搜索结果的第 1 页、第 2 页、第 3 页——都打上 "search" tag。当新数据进来时,一次 @CacheRemove(tags = "search") 就能全部清除。

文档中提醒过一点:tags 通过把收集到的 key 存成列表来工作,所以不要给同一个 tag 标记成千上万个 key。对于大多数真实场景——每个 tag 几十到几百个 key——完全没问题。

键模板:超越简单字符串

key 参数支持 ${} 表达式,可解析方法参数:

// 简单参数
@Cache(key = "user:${userId}")
public User getUser(int userId) { ... }

// 嵌套属性
@Cache(key = "user:${user.id}")
public User getUser(UserDto user) { ... }

// Map 键
@Cache(key = "result:${params.type}")
public List<Result> search(Map<String, String> params) { ... }

Enter fullscreen mode Exit fullscreen mode

如果你不指定 key,Solon 会根据所有参数名和值的 MD5 自动生成一个。它能用,但我建议始终提供明确的 key——这能让调试和手动检查更容易。

还有一个特殊的 ${.property} 语法用于引用返回值,主要用于 @CachePut

@CachePut(key = "order:${.orderId}")
public Order createOrder(CreateRequest req) {
    // ... 创建订单并返回
}

Enter fullscreen mode Exit fullscreen mode

让缓存可替换的接口

底层所有缓存都通过 CacheService 完成:

public interface CacheService {
    void store(String key, Object obj, int seconds);
    Object get(String key);
    void remove(String key);
}

Enter fullscreen mode Exit fullscreen mode

只有三个方法。默认实现是 LocalCacheService——一个适用于单实例应用的内存缓存。

但真正的威力在于你可以用一个简单的 bean 定义来替换它:

@Configuration
public class Config {
    @Bean
    public CacheService cache(@Inject("${cache}") Properties props) {
        return new MemCacheService(props);
    }
}

Enter fullscreen mode Exit fullscreen mode

一旦你定义了 CacheService bean,它就会全局替换默认实现。所有 @Cache@CachePut@CacheRemove 注解都会自动使用新的后端。

通过配置切换后端

Solon 还提供了一个 CacheServiceSupplier,可以根据配置选择后端:

demo.cache1:
  driverType: "local"
  # driverType: "redis"
  # driverType: "memcached"

Enter fullscreen mode Exit fullscreen mode

@Bean
public CacheService cache1(@Inject("${demo.cache1}") CacheServiceSupplier supplier) {
    return supplier.get();
}

Enter fullscreen mode Exit fullscreen mode

开箱即用的支持后端:

后端 插件 说明
Local solon-data(内置) 默认,无需额外依赖
Redis solon-cache-jedissolon-cache-redisson 任选其一
Memcached solon-cache-spymemcached 老牌但仍在使用

二级缓存:本地 + 远程

我在读密集型服务中发现的一个有用模式是二级缓存。它在远程缓存(Redis、Memcached)前加一层本地内存缓存:

@Bean
public CacheService cache1(@Inject("${demo.cache1}") CacheServiceSupplier supplier) {
    CacheService remote = supplier.get();

    if (remote instanceof LocalCacheService) {
        return remote;
    }

    // 本地 L1 + 远程 L2,带 5 秒缓冲
    LocalCacheService local = new LocalCacheService();
    return new SecondCacheService(local, remote, 5);
}

Enter fullscreen mode Exit fullscreen mode

“缓冲”意味着:当本地缓存未命中但远程缓存命中时,结果会在本地存储 5 秒。这 5 秒内的后续读取会直接命中本地缓存——无需网络往返。对于热点数据,这可以显著减少 Redis 负载。

分区设计

当多个服务共享同一个 Redis 实例时,你会想要对缓存进行分区。Solon 通过命名缓存服务支持这一点:

demo.cache.user:
  keyHeader: "user"
  server: "localhost:6379"
  db: 0

demo.cache.order:
  keyHeader: "order"
  server: "localhost:6379"
  db: 1

Enter fullscreen mode Exit fullscreen mode

@Configuration
public class Config {
    @Bean("cache_user")
    public CacheService cacheUser(@Inject("${demo.cache.user}") RedisCacheService cache) {
        return cache.enableMd5key(false);
    }

    @Bean("cache_order")
    public CacheService cacheOrder(@Inject("${demo.cache.order}") RedisCacheService cache) {
        return cache.enableMd5key(false);
    }
}

Enter fullscreen mode Exit fullscreen mode

然后在服务中使用它们:

@Component
public class OrderService {
    @Cache(service = "cache_order")
    public String hello(String name) {
        return String.format("Hello %s!", name);
    }
}

Enter fullscreen mode Exit fullscreen mode

不同的数据库、不同的 key 前缀,注解只需一个 service 属性就能选择正确的缓存服务。

我想改进的地方

如果要挑一个毛病,那就是当你不指定 key 时,自动生成的 key 是所有参数的 MD5。实践中我总是指定明确的 key,所以这对我不是问题。但对框架新手来说,看到缓存存储中不透明的哈希字符串可能会造成困惑。

总结

Solon 的缓存层非常简洁。三个注解、一个接口,以及一个比我用过的多数框架都更好地处理批量失效的 tags 系统。CacheService 接口实现起来非常简单,这意味着你可以接入几乎任何你想要的后端——或者为你的特定用例编写自定义实现。

如果你已经在使用 Solon,缓存注解位于 solon-data 模块中,你可能已经有这个依赖了。只需添加 @Cache 即可开始使用。