我给本地缓存加了 TTL 和 LRU,踩了三个并发坑

格 格达软件 2026-11-02 1 次阅读

我们的风控服务每秒要查 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。后来做了两件事:

  1. 缓存里只存真正需要热读的字段,把元数据拆成单独的懒加载结构,单条降到 2KB。
  2. 在 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)),把过期时间摊平到半分钟窗口内,峰值就下来了。这个改动只有两行代码,但效果比做任何复杂的预热逻辑都直接。

格
格达软件

专注软件定制开发,分享技术实践与行业观察。有开发需求?欢迎随时咨询。

咨询我们

有软件开发需求?我们 24 小时内响应

提交您的需求,格达软件将为您提供免费的需求评估与技术方案建议,咨询全程不收取任何费用。