我们的风控服务每秒要查 3 万多次规则配置,之前每次都走 Redis,P99 大概 12ms,其中 8~9ms 是网络往返。这些规则变更频率很低(一天几次),完全没必要每次都过网络。加一层进程内缓存后 P99 掉到 2ms 以内。
但这层缓存我改了三版才敢上生产。第一版把堆撑到了 OOM,第二版在压测时把 LRU 双向链表搞成了环——CPU 打满,线程全卡在 get 上。下面把这个类的完整实现、以及每个坑是怎么踩出来的讲清楚。
第一版:无界 Map + 惰性删除就是内存泄漏
最初的写法非常朴素:
// 别用这个版本
private final Map<String, Entry> map = new ConcurrentHashMap<>();
public Object get(String key) {
Entry e = map.get(key);
if (e == null) return null;
if (System.currentTimeMillis() > e.expireAt) {
map.remove(key); // 坑 1
return null;
}
return e.value;
}
问题出在两处。
第一,只有被访问到的 key 才有机会被删。 一个 key 写进去之后再也没人查,它就永远躺在 Map 里。我们那个服务每天会加载几万个租户的配置快照,其中大部分租户一天都没有请求。跑了一周,堆内存从 800MB 涨到 4GB,Full GC 之后也降不下来——全是这种僵尸对象。
第二,map.remove(key) 是错的。 假设线程 A 发现 key 过期了,正准备删;与此同时线程 B 刚 put 了一个新值进去。A 的 remove(key) 会把 B 的新值删掉。这不是理论上的可能,压测时确实出现了「刚写入就查不到」的现象。
正确的做法是用两参数版本做原子删除:
map.remove(key, staleEntry); // 只有当 key 当前映射的正是 staleEntry 时才删
时间源必须是 nanoTime,不是 currentTimeMillis
这个坑不那么直观。System.currentTimeMillis() 依赖系统时钟,NTP 同步、运维手动改时间、虚拟机迁移都可能导致它跳变。
- 时钟往回调 1 小时:接下来 1 小时内,所有过期时间落在这一小时里的缓存都不会失效。
- 时钟往前跳 1 天:所有缓存瞬间全过期,等于缓存被击穿,请求全打到 Redis。
System.nanoTime() 是单调递增的,不受墙钟调整影响。代价是它没有绝对含义,只能在同一进程内做差,不能持久化、不能跨进程比较。
还有个细节:nanoTime 的起始值是任意的,甚至可能是负数,理论上会溢出(约 292 年一轮回)。所以比较的时候永远用减法,别用 >:
// 对:溢出后依然正确
boolean expired = System.nanoTime() - expireAtNanos >= 0;
// 错:溢出那一下会误判
boolean expired = System.nanoTime() > expireAtNanos;
完整实现:Map 存节点,双向链表管顺序
结构上就两样东西:ConcurrentHashMap 负责 O(1) 查找,一条带哨兵的双向链表负责 LRU 顺序。head 旁边是最新的,tail 旁边是最旧的。每次 get 命中就把节点挪到 head 前面,淘汰时从 tail 前面摘。
链表操作必须加锁(因为要同时改三个指针),Map 操作不加锁。锁的粒度是「一次链表增删」,不是整个方法。
package com.geda.cache;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Function;
public final class LocalCache<K, V> {
private static final class Node<K, V> {
final K key;
final V value;
final long expireAtNanos; // 0 表示永不过期
Node<K, V> prev;
Node<K, V> next;
Node(K key, V value, long expireAtNanos) {
this.key = key;
this.value = value;
this.expireAtNanos = expireAtNanos;
}
}
private final int maxSize;
private final long defaultTtlNanos;
private final ConcurrentHashMap<K, Node<K, V>> map;
private final ConcurrentHashMap<K, CompletableFuture<V>> inflight = new ConcurrentHashMap<>();
// 两个哨兵,省掉链表头尾的一堆 null 判断
private final Node<K, V> head = new Node<>(null, null, 0); // head.next 是最新
private final Node<K, V> tail = new Node<>(null, null, 0); // tail.prev 是最旧
private final ReentrantLock chainLock = new ReentrantLock();
private final AtomicLong hitCount = new AtomicLong();
private final AtomicLong missCount = new AtomicLong();
private final ScheduledExecutorService cleaner;
public LocalCache(int maxSize, Duration defaultTtl) {
this(maxSize, defaultTtl, 30);
}
public LocalCache(int maxSize, Duration defaultTtl, int cleanIntervalSeconds) {
if (maxSize <= 0) throw new IllegalArgumentException("maxSize must be > 0");
this.maxSize = maxSize;
this.defaultTtlNanos = defaultTtl == null ? 0 : defaultTtl.toNanos();
this.map = new ConcurrentHashMap<>(Math.min(maxSize * 2, 1 << 16));
head.next = tail;
tail.prev = head;
this.cleaner = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "local-cache-cleaner");
t.setDaemon(true); // 关键:别拦着 JVM 退出
t.setPriority(Thread.MIN_PRIORITY); // 清理是后台活,别抢业务线程的 CPU
return t;
});
// 注意:scheduleAtFixedRate 的任务一旦抛异常,后续调度会被静默取消
this.cleaner.scheduleAtFixedRate(this::cleanExpiredSafely,
cleanIntervalSeconds, cleanIntervalSeconds, TimeUnit.SECONDS);
}
public V get(K key) {
Node<K, V> node = map.get(key);
if (node == null) {
missCount.incrementAndGet();
return null;
}
if (isExpired(node)) {
removeNode(key, node); // 惰性删除,兜住清理线程的间隔期
missCount.incrementAndGet();
return null;
}
touch(node);
hitCount.incrementAndGet();
return node.value;
}
public void put(K key, V value) {
put(key, value, 0);
}
/** ttlNanos <= 0 时使用默认 TTL */
public void put(K key, V value, long ttlNanos) {
long effective = ttlNanos > 0 ? ttlNanos : defaultTtlNanos;
// 加一圈随机抖动,避免同一批 key 同时过期造成雪崩
if (effective > 0) {
effective += ThreadLocalRandom.current().nextLong(effective / 10 + 1);
}
long expireAt = effective > 0 ? System.nanoTime() + effective : 0L;
Node<K, V> node = new Node<>(key, value, expireAt);
Node<K, V> old = map.put(key, node);
chainLock.lock();
try {
if (old != null) unlinkLocked(old);
// 并发 put 同一个 key 时,只有最后落进 map 的那个节点挂上链表
if (map.get(key) == node) linkFirstLocked(node);
} finally {
chainLock.unlock();
}
evictIfNeeded();
}
public boolean remove(K key) {
Node<K, V> node = map.get(key);
return node != null && removeNode(key, node);
}
public int size() {
return map.size();
}
public double hitRate() {
long h = hitCount.get(), m = missCount.get();
return h + m == 0 ? 0 : (double) h / (h + m);
}
public void shutdown() {
cleaner.shutdownNow();
}
// ---------------- 以下为内部实现 ----------------
private boolean isExpired(Node<K, V> node) {
return node.expireAtNanos != 0 && System.nanoTime() - node.expireAtNanos >= 0;
}
/**
* 原子摘除。只有真正把 map 里这条映射删掉的线程才有资格动链表,
* 否则两个线程同时判定过期,会 unlink 两次,把链表指针搞乱。
*/
private boolean removeNode(K key, Node<K, V> node) {
if (!map.remove(key, node)) return false;
chainLock.lock();
try {
unlinkLocked(node);
} finally {
chainLock.unlock();
}
return true;
}
private void touch(Node<K, V> node) {
chainLock.lock();
try {
if (node.prev == null) return; // prev == null 表示已被摘除,别再动它
if (head.next == node) return; // 已经在头部,省一次指针操作
unlinkLocked(node);
linkFirstLocked(node);
} finally {
chainLock.unlock();
}
}
private void evictIfNeeded() {
// map.size() 是近似值,并发下可能偏大,多删一两条不影响正确性
int guard = 0;
while (map.size() > maxSize && guard++ < maxSize) {
Node<K, V> victim;
chainLock.lock();
try {
victim = tail.prev;
if (victim == head) return; // 链表空了,别死循环
unlinkLocked(victim);
} finally {
chainLock.unlock();
}
map.remove(victim.key, victim);
}
}
/** 调用方必须持有 chainLock */
private void linkFirstLocked(Node<K, V> node) {
node.next = head.next;
node.prev = head;
head.next.prev = node;
head.next = node;
}
/** 调用方必须持有 chainLock。用 prev == null 作为「已摘除」标记,保证幂等 */
private void unlinkLocked(Node<K, V> node) {
if (node.prev == null) return;
node.prev.next = node.next;
node.next.prev = node.prev;
node.prev = null;
node.next = null;
}
private void cleanExpiredSafely() {
try {
long now = System.nanoTime();
List<Node<K, V>> expired = new ArrayList<>();
// ConcurrentHashMap 的 values() 是弱一致迭代器,遍历时被并发修改不会抛异常
for (Node<K, V> n : map.values()) {
if (n.expireAtNanos != 0 && now - n.expireAtNanos >= 0) {
expired.add(n);
}
}
for (Node<K, V> n : expired) {
removeNode(n.key, n); // 带上节点引用,避免删掉期间被更新的新值
}
} catch (Throwable t) {
// 必须吞掉:scheduleAtFixedRate 遇到异常会静默停止后续调度
System.err.println("[LocalCache] clean failed: " + t);
}
}
}
压测数据:8 线程、缓存命中率 95% 的场景下,这个类相对直接查 Redis 的吞吐提升约 4 倍,get 单次耗时在百纳秒量级(chainLock 有竞争时会升到微秒级)。如果你的场景写少读多、maxSize 在几千以内,这个锁竞争基本可以忽略;要是 16 核以上还在抢同一把锁,那就该换 Caffeine 了。
三个必须讲清楚的并发细节
1. 为什么 unlink 要做幂等
看 removeNode:只有当 map.remove(key, node) 返回 true 的那个线程才去动链表。这是整个类里最关键的一行。
假设去掉这层保护,两个线程同时 get 到同一个过期节点,都去 unlink(node):第一次摘除后 node.prev 和 node.next 已经被改过,第二次再执行 node.prev.next = node.next,就是在拿脏指针写内存,链表可能直接成环。成环之后,任何一次遍历都会死循环——这就是我第二版 CPU 打满的原因。
prev == null 作为「已摘除」标记,同时解决了幂等和 touch 里的竞态:一个刚被摘掉的节点如果还被人 touch,会被这一行挡住。
2. 清理线程的两个默认坑
this.cleaner.scheduleAtFixedRate(this::cleanExpired, 30, 30, TimeUnit.SECONDS);
坑一:任务抛异常,调度直接被取消,而且没有任何日志。 ScheduledExecutorService 的规范是:任务抛出未捕获异常后,后续执行会被抑制。表现是缓存跑了几小时之后再也不清理了,你从日志里什么都看不出来。所以 cleanExpiredSafely 外面那个 catch (Throwable) 不是可选项。
坑二:非守护线程会拖住 JVM 退出。 单测里创建一个 LocalCache 忘了关,整个测试进程挂在那里不结束。newSingleThreadScheduledExecutor 的默认 ThreadFactory 建的是非守护线程,必须自己传。
3. 遍历清理不能直接删链表
cleanExpiredSafely 里我先收集再删除,没有边遍历边 removeNode。原因是遍历链表必须在锁内(否则并发 touch 会改结构),而持锁做全量扫描会把所有 get 都堵住。用 map.values() 快照式遍历绕开了这个问题——ConcurrentHashMap 的迭代器是弱一致的,遍历期间被删掉的元素最多是多看一眼,不会抛 ConcurrentModificationException。
代价是:清理线程和业务线程可能同时判定同一条过期,removeNode 里那个 map.remove(key, node) 会保证只有一个人成功。
缓存击穿:200 个线程同时回源同一个 key
上面这个类解决不了这个问题。一个热点 key 刚过期,200 个并发请求全部 get 到 null,然后一起去查数据库。数据库那一瞬间的压力等于没加缓存。
常见的错误写法是用 ConcurrentHashMap.computeIfAbsent 一把梭:
// 别这么写
public V getOrLoad(K key, Function<K, V> loader) {
return map.computeIfAbsent(key, k -> loader.apply(k));
}
两个问题。第一,computeIfAbsent 会锁住整个桶(bin),如果 loader 耗时 50ms,落在同一个桶上的其它 key 全被阻塞。第二,如果 loader 内部又去访问同一个 Map 的其它 key,JDK 8 上会直接死锁——它是不可重入的。
用 CompletableFuture 做请求合并更稳:
public V getOrLoad(K key, Function<K, V> loader, long ttlNanos) {
V cached = get(key);
if (cached != null) return cached;
CompletableFuture<V> mine = new CompletableFuture<>();
CompletableFuture<V> existing = inflight.putIfAbsent(key, mine);
if (existing != null) {
// 已经有人在回源了,等它的结果
try {
return existing.get(500, TimeUnit.MILLISECONDS);
} catch (Exception e) {
// 回源线程挂了或者超时,自己兜底查一次
return loader.apply(key);
}
}
try {
V v = loader.apply(key);
if (v != null) put(key, v, ttlNanos);
mine.complete(v);
return v;
} catch (Throwable t) {
mine.completeExceptionally(t);
throw t;
} finally {
inflight.remove(key, mine);
}
}
等待那一侧必须设超时(这里 500ms)。否则回源线程如果卡死,所有等待者会一直挂着,等于把故障放大了一圈。超时后自己查一次,虽然退化成了无合并,但至少不会雪崩。
还有一个细节:loader 返回 null 时不写缓存,所以不存在的 key 每次都会穿透。如果这类 key 的量很大(比如爬虫扫不存在的 ID),加一个短 TTL 的空值哨兵对象,3~5 秒就行。
淘汰策略选型:LRU 怕扫描,LFU 怕新 key
| 策略 | 命中率表现 | 实现复杂度 | 典型失效场景 |
|---|---|---|---|
| FIFO | 最差 | 最低 | 热点数据被周期性刷新增量挤掉 |
| LRU | 常规访问模式下很好 | 中(双向链表) | 一次全量扫描(批量任务、导出)会把缓存冲干净 |
| LFU / W-TinyLFU | 抗扫描,命中率最高 | 高(需要频率统计 + 老化) | 新 key 频率上不来,长期进不了缓存 |
我们的风控服务有个每天凌晨的全量规则校验任务,会顺序扫一遍所有租户的配置。用纯 LRU 的时候,这个任务跑完,热点缓存全没了,接下来的十分钟 Redis QPS 翻三倍。后来加了频率维度(参照 W-TinyLFU 的思路,命中时累加计数,只有频率超过候选者才允许进入)才压住。
如果不想自己实现这些,直接用 Caffeine:
Cache<String, Rule> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats()
.build();
Caffeine 用的是 W-TinyLFU,读路径基本无锁(靠环形缓冲区批量更新频率),还有 caffeine.recordStats() 直接给出命中率。除非有特殊需求,否则没必要手写。
按条数淘汰不等于按内存淘汰
maximumSize(10_000) 控制的是条数,不是字节数。我们第一版的规则对象里带了一份完整的字段元数据,单条 400KB,1000 条就是 400MB。后来做了两件事:
- 缓存里只存真正需要热读的字段,把元数据拆成单独的懒加载结构,单条降到 2KB。
- 在
put里加一道 value 大小检查,超过阈值(我们设的是 64KB)直接不缓存并打日志。
另一种思路是用 SoftReference 包 value,让 GC 在内存紧张时自动回收。但 SoftReference 的回收时机由 GC 决定,JDK 里的策略是「free heap 不足时清」,实际表现是堆还没满但 Full GC 变频繁,P99 抖动明显。我们试过,最后放弃了。
什么时候别用这个类
几个明确的边界:
- 数据量超过几万条,或者 value 是大对象(>100KB)。 进程内缓存的堆是实打实在的,GC 压力会反噬业务。这种场景要么用堆外(OHC、Chronicle Map),要么老老实实放 Redis。
- 单机多实例部署,且数据一致性要求高。 每个实例一份缓存,更新要靠消息广播。如果某个实例广播丢了,它会一直返回旧值直到 TTL 到期。TTL 越长,不一致的窗口越大。
- 读写比低于 3:1。 缓存本身有维护成本(链表操作、锁竞争、清理扫描),写多读少的时候这个成本不划算。
- 需要分布式锁或者强一致的场景。 本地缓存天然做不了这个,别硬凑。
最后说一下 TTL 怎么定。我们踩过的坑是:所有 key 用同一个 TTL,结果整批配置在启动时同时写入,5 分钟后同时过期,Redis 那一瞬间收到几十万 QPS。后来在 put 里加了一圈 10% 的随机抖动(代码里 effective += nextLong(effective / 10 + 1)),把过期时间摊平到半分钟窗口内,峰值就下来了。这个改动只有两行代码,但效果比做任何复杂的预热逻辑都直接。