我寫 Solon 已經有一段時間了,早期讓我驚訝的一點是它的快取層有多乾淨。不是因為它做了什麼革命性的事——它沒有——而是因為它 在處理生產環境中真正遇到的邊緣案例時,仍然能「不礙手礙腳」

讓我來分享一下我學到的東西。

三個註解

Solon 只提供了三個快取註解。不多也不少:

  • @Cache — 讀取快取。如果金鑰存在就直接回傳;否則執行方法並儲存結果。
  • @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") 這一行值得進一步探討——它完成了一項大多數基於註解的快取系統都難以做到的事。

標籤技巧

大多數快取框架只提供基於金鑰的清除。你知道金鑰,就能移除它。對簡單情境來說沒問題,但如果是「讓所有與使用者相關的快取失效」呢?

這就是標籤的用武之地。當你用 @Cache(tags = "users") 註解方法時,Solon 不只會將值儲存在計算後的金鑰之下——它還會把該金鑰記錄到標籤索引中。之後當你呼叫 @CacheRemove(tags = "users") 時,它會找出所有與該標籤關聯的金鑰並一次清除它們。

@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) { ... }

// Both caches get cleared in one shot
@CacheRemove(tags = "users")
@Mapping("/admin/users/flush")
public String flushUsers() { ... }

Enter fullscreen mode Exit fullscreen mode

我經常使用這個模式來處理分頁列表。你可以快取搜尋結果的第 1、2、3 頁——全部都標記為 "search"。當有新資料進來時,一個 @CacheRemove(tags = "search") 就能全部清除。

文件提醒了一件事:標籤是透過將收集到的金鑰儲存為列表來運作,因此不要將數千個金鑰標記在同一個標籤下。對於大多數真實世界的場景——每個標籤有幾十到幾百個金鑰——都沒問題。

金鑰範本:超越簡單字串

key 參數支援 ${} 表達式,可解析方法參數:

// Simple parameter
@Cache(key = "user:${userId}")
public User getUser(int userId) { ... }

// Nested property
@Cache(key = "user:${user.id}")
public User getUser(UserDto user) { ... }

// Map key
@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) {
    // ... create order, return it
}

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 (built-in) 預設,無需額外依賴
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;
    }

    // Local L1 + remote L2, with a 5-second buffer
    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

不同的資料庫、不同的金鑰前綴,而註解只需要一個 service 屬性就能選擇正確的快取服務。

我想改變的地方

如果我有個小地方要挑剔,那就是當你不指定 key 時,自動產生的金鑰是所有參數的 MD5。實際上,我總是指定明確的 key,所以這對我來說不是問題。但對框架新手來說,當他們在快取存放區中看到不透明的雜湊字串時,可能會造成混淆。

結論

Solon 的快取層簡單得令人耳目一新。三個註解、一個介面,以及一個比我用過的大多數框架都更能處理批次失效的標籤系統。CacheService 介面實作非常簡單,這意味著你幾乎可以插入任何你想要的後端——或者為你的特定使用情境撰寫自訂實作。

如果你已經在使用 Solon,快取註解位於 solon-data 模組中,你可能已經將其作為依賴項目了。只要加上 @Cache 就完成了。