缓存(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 |
| 客户端 | 浏览器/CDN | 0 | 最弱 | 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.cache | Caffeine |
|---|---|---|
| 语言 | 纯 Clojure | Java |
| 淘汰算法 | LRU/LFU/TTL | W-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 的漏洞:写库成功、删缓存失败则永久脏读。
工程上的折中:
- 延迟双删:写库前删一次,写库后延迟 500ms 再删一次,覆盖并发读回填的窗口;
- 给缓存值带版本号,写入时 CAS 更新,版本落后则丢弃;
- 缩短 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. 小结
缓存的设计可以归纳为三问:
- 放哪里:热且可容忍短暂不一致 → 进程内(Caffeine);需跨实例共享 → Redis;
- 怎么失效:TTL 兜底 + 写时删除 + 延迟双删,避免永久脏读;
- 并发怎么办:单飞合并回源、TTL 加抖动防雪崩、空值/布隆过滤器防穿透。
工具只是手段:core.cache 胜在纯 Clojure 与可控,Caffeine 胜在算法与并发保证。把失效策略想清楚,比换一个更快的缓存库重要得多。若缓存层成为瓶颈,可结合 GraalVM 与性能调优 中的启动与内存分析进一步优化。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。