iT邦幫忙

2025 iThome 鐵人賽

DAY 18
0
佛心分享-SideProject30

我的時間到底去哪裡了!? – 個人時間數據系統開發挑戰系列 第 18

Day18:Spring Boot 快取優化 — 從 ConcurrentMap 到 Caffeine 精確清除

  • 分享至 

  • xImage
  •  

1. 前言

昨天 Day17 我們用 @CacheEvict(allEntries = true) 這招,直接把所有快取一次清空,雖然簡單粗暴,但其實很沒效率。

為什麼?因為只要有一個活動被更新,所有人的快取都會被清掉。

這感覺就像是段考完公布成績,小明數學只考了八分,結果他媽媽來學校打他,順便連你一起打。

https://ithelp.ithome.com.tw/upload/images/20250930/201602790T32jgZOJx.png

太不合理了! 所以今天 Day18,我們要來優化這個快取系統,讓它更聰明、更省資源!


2. 可能的解決方案比較

在 Spring 生態系,要解決快取問題大致上可以分成三種:

  1. 自訂 ConcurrentMapCacheManager

    Spring Boot 內建的快取實作,背後其實就是一個 ConcurrentHashMap。簡單直接,不需要額外套件。

    • 優點:原生、簡單、無需額外依賴
    • 缺點:不支援 TTL、LRU 自動淘汰、精確刪除,如果想要以上各種功能,需要自訂ConcurrentMapCacheManager,把邏輯寫在裡面,但效能有限
  2. CaffeineCacheManager

    一個高效能的本地快取庫,支援 TTL、LRU 淘汰、統計監控等功能。Spring Boot 原生支援,設定起來很直覺,效能比 ConcurrentMapCacheManager 好。

    • 優點:支援 TTL、自動 LRU 淘汰、效能比較快、Spring Boot 直接支援
    • 缺點:資料存在服務伺服器的記憶體裡,無法跨多台 server 共用
  3. Redis

    這是最常見的分散式快取解法,可以跨多台伺服器共用,功能完整,甚至能持久化資料,是微服架構下很熱門的快取解決方案。不過需要額外安裝 Redis server,維運成本相對較高。

    • 優點:分散式、支援 TTL、可跨多台 server、功能強大
    • 缺點:需額外安裝 Redis、維運成本高、開發複雜度提升

簡單比較表

方案 TTL LRU 跨機 精確刪除 複雜度 適合階段
ConcurrentMapCache 開發/測試
CaffeineCache 小型專案
Redis 中高 大型/分散式

為什麼這階段選 Caffeine?

 - 專案還沒到分散式規模,先用 Caffeine 享受高效能、低複雜度,等未來真的需要再升級 Redis!

註:

LRU = Least Recently Used(最近最少使用) ⇒ 快取空間快滿了,優先移除最久沒被使用的資料
例子:快取容量是3,此時有三個快取:A、B、C存入 ⇒ 此時有個D要進來 ⇒ 刪掉最久沒用的A,如此快取裡就會是:B、C、D。

TTL =Time-To-Live(有效期限),就是設置有效期限,過期就清掉。


3. 實作

(1) pom.xml:加入 Caffeine 依賴

<!-- pom.xml -->
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.1.8</version>
</dependency>

(2) CacheConfig.java:設定 CaffeineCacheManager

為什麼? ⇒ 預設的 ConcurrentMapCacheManager 沒有 TTL、LRU 等功能,得透過實現CaffeineCacheManager ,Spring 才知道你想要的快取是什麼樣子。

@Configuration
@EnableCaching
public class CacheConfig {
    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager();
        cacheManager.setCaffeine(Caffeine.newBuilder()
            .expireAfterWrite(5, TimeUnit.MINUTES)   // 寫入後 5 分鐘自動過期
            .expireAfterAccess(3, TimeUnit.MINUTES)  // 最後訪問後 3 分鐘過期
            .maximumSize(1000)                       // 最多 1000 筆,超過自動 LRU 淘汰
            .recordStats());                         // 啟用快取統計
        cacheManager.setCacheNames(List.of(
            "activityDistribution", "activityTrend", "activityKPIs"
        ));
        return cacheManager;
    }
}

效果

  • 快取自動過期,記憶體不會爆炸
  • 熱門資料會被保留,不常用的自動淘汰
  • 可以用 actuator 或 log 監控快取命中率

(3) CacheEvictionService.java:精確快取清除

因為@CacheEvict(allEntries = true) 把所有快取都清掉,太粗暴。
所以我們要做到「只清除特定 activityId 相關的快取」,不影響其他資料。

@Service
@RequiredArgsConstructor
@Slf4j
public class CacheEvictionService {
    private final CacheManager cacheManager;

    // 精確清除某個 activityId 相關的所有快取
    public void evictByActivityId(UUID activityId) {
        String pattern = activityId.toString();
        String[] cacheNames = {"activityDistribution", "activityTrend", "activityKPIs"};
        for (String cacheName : cacheNames) {
            org.springframework.cache.Cache cache = cacheManager.getCache(cacheName);
            if (cache != null) {
                Object nativeCache = cache.getNativeCache();
                if (nativeCache instanceof Cache) {
                    Cache<Object, Object> caffeineCache = (Cache<Object, Object>) nativeCache;
                    ConcurrentMap<Object, Object> map = caffeineCache.asMap();
                    for (Object key : map.keySet()) {
                        String keyStr = key.toString();
                        // 精確比對 key 格式
                        if (keyStr.startsWith(pattern + "_") || keyStr.contains("_" + pattern + "_")) {
                            caffeineCache.invalidate(key);
                        }
                    }
                }
            }
        }
    }
}

效果

  • 只會清除特定 activityId 相關的快取,不會影響其他活動/用戶

(4) Service 層修改:移除 @CacheEvict,改呼叫 CacheEvictionService

ActivityService.java

public Activity updateActivity(UUID activityId, Long userId, ...) {
    // ... 更新邏輯
    cacheEvictionService.evictByActivityId(activityId); // 精確清除
    // ... 回傳結果
}

ActivityRecordService.java

public RecordResponse createRecord(Long userId, RecordCreateRequest request) {
    // ... 新增邏輯
    cacheEvictionService.evictByActivityId(request.getActivityId());
    // ... 回傳結果
}

(5) 實作完的效果

  • 快取自動過期:5 分鐘沒用就自動失效,記憶體不會爆
  • 精確清除:只刪特定 activityId 相關快取,效能大幅提升
  • LRU 機制:熱門資料會被保留,不常用的自動淘汰
  • 快取命中率可監控:方便後續效能調校
  • 程式碼更乾淨:快取邏輯集中管理,維護容易

5. 結語

目前網站規模不大,使用者不多,Caffeine 已經足夠。等未來需要分散式支援時,再考慮引入 Redis(其實是自己想玩Redis)。

感謝閱讀


上一篇
Day17:快取沒有更新!Spring Boot @CacheEvict 來救場
下一篇
Day19:工程師怎麼和 AI 開 Spec?5 個我自己的心得
系列文
我的時間到底去哪裡了!? – 個人時間數據系統開發挑戰20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言