缓存策略:core.cache、Caffeine 与失效设计

系统梳理 Clojure 缓存方案:core.cache 的 TTL/LRU/LU 缓存与 defcache 宏、Caffeine 的 W-TinyLFU 与异步加载、Redis 分布式缓存与单飞(single-flight)模式,以及缓存击穿、穿透、雪崩的防护与失效设计。

缓存(Cache)是性价比最高的一层性能优化:一次数据库查询 20ms,一次内存读取 0.2μs,差距是五个数量级。但缓存也是 bug 高发区——脏读、击穿、雪崩、内存泄漏,几乎每个线上事故都能追溯到某个失效策略没想清楚。Clojure 的不可变数据让缓存值天然线程安全,但「什么时候失效」「并发回源怎么合并」这些语义问题仍然要自己设计。

本文从进程内缓存(core.cache、Caffeine)讲到分布式缓存(Redis),重点放在失效策略与并发回源(single-flight)这两件最容易出事的地方。

1. 缓存的三种层次

层次存储延迟一致性典型工具
进程内JVM 堆~100ns弱(多实例各一份)core.cache、Caffeine
进程外Redis/Memcached~0.5ms较强(共享一份)carmine、jedis
客户端浏览器/CDN0最弱HTTP 缓存头

选择原则:热数据放进程内,共享数据放 Redis,二者可以叠成两级缓存。

2. core.cache:纯 Clojure 实现

2.1 基本用法

core.cache 提供统一的 CacheProtocol,屏蔽了底层实现:

(ns myapp.cache
  (:require [clojure.core.cache :as cache]
            [clojure.core.cache.wrapped :as w]))

(def C (w/lru-cache-factory {} :threshold 1000))

;; 读:miss 返回 nil
(w/lookup C :user-42)

;; 写
(w/miss C :user-42 (fetch-user 42))

;; 删
(w/evict C :user-42)

注意 core.cache 的缓存是不可变值:miss / evict 返回新的缓存对象,必须用 wrapped 命名空间(内部持有一个 atom)或在 swap! 里传递。

2.2 常用策略

工厂函数淘汰策略适用
basic-cache-factory无淘汰数据量固定
lru-cache-factory最近最少使用通用热点
lfu-cache-factory最不经常使用访问频率倾斜
ttl-cache-factory过期时间时效数据
lu-cache-factory最近使用工作集稳定

TTL 缓存的过期是惰性的:只有被访问到时才检查是否过期,不会主动清理。这意味着 ttl-cache-factory 里的键可能已经过期但仍占内存,直到被读一次。

2.3 组合 TTL 与 LRU

真实场景通常要「既过期又限容」。core.cache 允许通过 seed 组合:

(def C
  (w/wrapped-cache
    (-> (cache/ttl-cache-factory {} :ttl 60000)   ;; 60 秒过期
        (cache/lru-cache-factory :threshold 5000))))  ;; 最多 5000 条

2.4 defcache 宏

core.cache 提供 defcache 把函数自动包成缓存版本:

(require '[clojure.core.cache :as cache])

(defcache cached-user [cache]
  (fetch-user [id]
    (if-let [hit (cache/lookup cache id)]
      hit
      (let [v (db/fetch-user id)]
        (cache/miss cache id v)
        v))))

不过 defcache 需要手动管理状态,生产里更常用显式的 memoize + TTL 包装。

3. Caffeine:JVM 最强进程内缓存

3.1 为什么用 Caffeine

Caffeine 是 Java 库,用 Clojure 的 Java 互操作直接调用即可。它的 W-TinyLFU 淘汰算法在命中率上普遍优于 LRU,且提供:

  • 自动异步加载(AsyncLoadingCache);
  • 基于大小的驱逐(maximumSize)与基于权重的驱逐(maximumWeight);
  • expireAfterWrite / expireAfterAccess;
  • 淘汰监听器与统计(hitRate)。

3.2 基础封装

(ns myapp.caffeine
  (:import [com.github.benmanes.caffeine.cache Caffeine CacheLoader]
           [java.time Duration]))

(defn loading-cache [loader-fn ttl-seconds max-size]
  (-> (Caffeine/newBuilder)
      (.maximumSize (long max-size))
      (.expireAfterWrite (Duration/ofSeconds (long ttl-seconds)))
      (.recordStats)
      (.build
        (reify CacheLoader
          (load [_ k] (loader-fn k))))
      ;; 用 caffeine 的 get 保证并发只回源一次
      ))

(def user-cache (loading-cache #(fetch-user %) 300 10000))

3.3 读取与统计

(.get ^com.github.benmanes.caffeine.cache.LoadingCache user-cache 42)
;; => 若 miss 则调用 loader,多个线程并发请求同一 key 时只回源一次

(.hitRate (.stats user-cache))
;; => 0.973

LoadingCache.get 的关键保证是:同一 key 的并发 miss 只触发一次 loader 调用,其他线程阻塞等待结果。这就是进程内的单飞(single-flight),省掉了自己写锁的麻烦。

3.4 与 core.cache 的取舍

维度core.cacheCaffeine
语言纯 ClojureJava
淘汰算法LRU/LFU/TTLW-TinyLFU
并发回源需自己实现内置
统计无hitRate/missCount
惰性过期是否(内部维护时间轮)

结论:性能敏感、并发高的场景用 Caffeine;想要纯 Clojure、可控性强的场景用 core.cache。

4. 失效设计:缓存的灵魂

4.1 三种失效模式

模式写法一致性复杂度
过期失效写 TTL,到期自动淘汰弱(有窗口)低
写时失效数据变更时 evict强中
写时更新数据变更时同时更新缓存最强高

大多数系统用「过期失效 + 写时失效」的组合:TTL 兜底,写操作主动删缓存。

4.2 先删缓存还是先写库

经典的 Cache-Aside 顺序问题:

;; 方案 A:先删缓存,再写库(推荐)
(defn update-user! [id data]
  (cache/evict! user-cache id)
  (db/update-user! id data))

;; 方案 B:先写库,再删缓存
(defn update-user! [id data]
  (db/update-user! id data)
  (cache/evict! user-cache id))

两者都不是绝对安全。方案 A 的漏洞:删除后、写库前有读请求把旧值重新载入缓存。方案 B 的漏洞:写库成功、删缓存失败则永久脏读。

工程上的折中:

  1. 延迟双删:写库前删一次,写库后延迟 500ms 再删一次,覆盖并发读回填的窗口;
  2. 给缓存值带版本号,写入时 CAS 更新,版本落后则丢弃;
  3. 缩短 TTL,让脏读窗口有上界。

4.3 缓存击穿与单飞

热点 key 过期的瞬间,成千上万请求同时回源,把数据库打垮。这就是击穿(Cache Stampede)。解决办法是让只有一个请求回源,其余等待或返回旧值:

(defn memo-single-flight [f cache]
  (let [locks (java.util.concurrent.ConcurrentHashMap.)]
    (fn [k]
      (if-let [hit (cache/lookup cache k)]
        hit
        (let [lock (.computeIfAbsent locks k (fn [_] (Object.)))]
          (locking lock
            (or (cache/lookup cache k)           ;; 二次检查
                (let [v (f k)]
                  (cache/miss cache k v)
                  v))))))))

进程内用 Caffeine 的 LoadingCache 最省事;跨实例场景需要分布式锁(Redis SET NX)或「逻辑过期 + 异步刷新」。

4.4 逻辑过期方案

给缓存值附一个逻辑过期时间,过期后先返回旧值,再异步刷新:

{:value user-data
 :expire-at (+ (System/currentTimeMillis) 300000)}

(defn get-with-logical-expiry [k]
  (let [{:keys [value expire-at]} (redis/get k)]
    (when (and value (< (System/currentTimeMillis) expire-at))
      (future (refresh-async k))   ;; 异步刷新,不阻塞读
      value)))

代价是有一段时间返回旧数据,适合对「最终一致」可接受的场景,如商品详情页。

5. 分布式缓存:Redis

5.1 carmine 客户端

(ns myapp.redis
  (:require [taoensso.carmine :as car :refer [wcar]]))

(def conn {:pool {} :spec {:host "127.0.0.1" :port 6379}})

(defmacro wcar* [& body] `(car/wcar conn ~@body))

(defn cache-get [k]
  (wcar* (car/get k)))

(defn cache-set! [k v ttl-sec]
  (wcar* (car/set k v "EX" ttl-sec)))

5.2 序列化选择

Redis 存的是字节串。Clojure 数据推荐用 Nippy 序列化(比 EDN 快、支持更多类型),或对可读性有要求时用 EDN / JSON。序列化选型直接影响缓存的读写开销与跨版本兼容性。

(require '[taoensso.nippy :as nippy])

(defn cache-set! [k v ttl-sec]
  (wcar* (car/set k (nippy/freeze v) "EX" ttl-sec)))

(defn cache-get [k]
  (some-> (wcar* (car/get k)) nippy/thaw))

5.3 缓存穿透防护

查询一个不存在的 key(如恶意构造的 user-id=-1),缓存永远 miss、每次都打库,这叫穿透。两种防护:

  • 缓存空值:miss 时写入一个短 TTL 的 ::null 哨兵,避免重复回源;
  • 布隆过滤器(Bloom Filter):启动时把全部合法 key 灌进去,查询先过过滤器,判定「一定不存在」就直接返回。
(defn get-user [id]
  (if-let [v (cache-get (str "user:" id))]
    (if (= v ::null) nil v)
    (if-let [u (db/fetch-user id)]
      (do (cache-set! (str "user:" id) u 300) u)
      (do (cache-set! (str "user:" id) ::null 60) nil))))  ;; 空值缓存 60 秒

5.4 雪崩防护

大量 key 同一时刻过期(例如统一 EX 3600),会引发集体回源。对策是给 TTL 加随机抖动:

(defn jitter-ttl [base-sec]
  (+ base-sec (rand-int (quot base-sec 10))))   ;; 基础 TTL 上叠加 0~10%

同时 Redis 本身要做高可用(哨兵/集群),避免单点故障导致缓存层整体不可用——那时全部流量会直接压到数据库。

6. 两级缓存与一致性

6.1 L1 + L2 结构

请求 -> L1(Caffeine,进程内,TTL 30s)
          | miss
          v
        L2(Redis,共享,TTL 300s)
          | miss
          v
        数据库

L1 的代价是多实例之间不一致:实例 A 更新了数据,实例 B 的 L1 还留着旧值,直到 TTL 过期。缓解办法是用 Redis 的 Pub/Sub 广播失效消息:

;; 更新方
(wcar* (car/publish "cache-invalidate" (pr-str {:key (str "user:" id)})))

;; 各实例订阅
(defn start-invalidation-subscriber! []
  (future
    (car/with-new-pubsub-listener (:spec conn) {"cache-invalidate"
      (fn [_ msg] (cache/evict! l1 (read-string msg)))})))

6.2 何时不该缓存

  • 强一致性要求(账户余额、库存扣减):用数据库行锁或 Redis 原子操作,别用读缓存;
  • 写多读少:缓存命中率低,反而增加复杂度;
  • 数据量小且查询快:索引优化可能比缓存更简单。

缓存与数据库访问层往往一起设计,连接池、事务与缓存失效的配合可参考 数据库访问模式 。

7. 小结

缓存的设计可以归纳为三问:

  1. 放哪里:热且可容忍短暂不一致 → 进程内(Caffeine);需跨实例共享 → Redis;
  2. 怎么失效:TTL 兜底 + 写时删除 + 延迟双删,避免永久脏读;
  3. 并发怎么办:单飞合并回源、TTL 加抖动防雪崩、空值/布隆过滤器防穿透。

工具只是手段:core.cache 胜在纯 Clojure 与可控,Caffeine 胜在算法与并发保证。把失效策略想清楚,比换一个更快的缓存库重要得多。若缓存层成为瓶颈,可结合 GraalVM 与性能调优 中的启动与内存分析进一步优化。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「clojure」更多文章

  1. Clojure 桌面 UI:cljfx 与 JavaFX 实战
  2. JSON/EDN 序列化与数据格式互操作
  3. JVM 调优与容器化部署:GC、JFR 与 Docker