用 Java 实现一个可落地的本地缓存(带 TTL + LRU)并发版

用 Java 实现一个可落地的本地缓存(带 TTL + LRU)并发版

很多项目一开始会直接上 Redis,但在一些场景里,本地缓存能更省钱也更快:比如配置读取、热点字典表、短期验证码状态、接口降级的兜底数据等。问题是:自己写缓存如果不考虑并发、过期、内存增长,很容易“上线后变成隐患”。这篇文章我用 Java 写一个可直接放进项目的本地缓存:支持 TTL 过期LRU 淘汰、并发访问安全,并且尽量保持实现清晰。


1)设计目标与约束

我给这个缓存定了四个目标:

  1. TTL(过期时间):每个 key 可单独设置过期时间
  2. LRU(最近最少使用淘汰):容量满了自动淘汰最久未使用的 key
  3. 并发安全:多线程 get/put 不出错、不乱序
  4. 不依赖后台定时线程(可选):过期清理采用“访问时惰性删除”

注意:这里是“工程可用”的本地缓存,不追求极限性能(极限性能会引入更复杂的数据结构和锁分离策略)。


2)关键思路:HashMap + 双向链表实现 LRU

经典 LRU 的做法:

  • HashMap<K, Node> O(1) 定位节点
  • 用双向链表维护访问顺序

  • 每次 get/put 把节点移到链表头(表示最近使用)

  • 淘汰时从链表尾删除(最久未使用)

TTL 过期的做法:

  • 每个节点记录 expireAt(绝对过期时间毫秒/纳秒)
  • get 时如果已过期:删除并返回 null
  • put 时覆盖旧值并更新 expireAt

3)代码实现:LRU + TTL(并发版,简单可靠)

实现采用一个全局锁 ReentrantLock(比 synchronized 更可控),保证链表与 map 的一致性。对于大多数业务,这个锁足够。

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

public class LocalCache<K, V> {

private final int maxSize;
private final Map<K, Node<K, V>> map;
private final ReentrantLock lock = new ReentrantLock();

// LRU 双向链表的哨兵节点
private final Node<K, V> head = new Node<>(null, null, Long.MAX_VALUE);
private final Node<K, V> tail = new Node<>(null, null, Long.MAX_VALUE);

public LocalCache(int maxSize) {
    if (maxSize <= 0) throw new IllegalArgumentException("maxSize must be > 0");
    this.maxSize = maxSize;
    this.map = new HashMap<>(maxSize * 2);

    head.next = tail;
    tail.prev = head;
}

// put:默认 TTL
public void put(K key, V value, long ttl, TimeUnit unit) {
    if (key == null) throw new IllegalArgumentException("key is null");
    long expireAt = System.currentTimeMillis() + unit.toMillis(ttl);

    lock.lock();
    try {
        Node<K, V> node = map.get(key);
        if (node != null) {
            // 覆盖
            node.value = value;
            node.expireAt = expireAt;
            moveToHead(node);
            return;
        }

        // 插入新节点
        Node<K, V> newNode = new Node<>(key, value, expireAt);
        map.put(key, newNode);
        addToHead(newNode);

        // 超容量淘汰
        if (map.size() > maxSize) {
            evictTail();
        }
    } finally {
        lock.unlock();
    }
}

// get:命中则更新 LRU;过期则删除
public V get(K key) {
    if (key == null) return null;

    lock.lock();
    try {
        Node<K, V> node = map.get(key);
        if (node == null) return null;

        if (isExpired(node)) {
            removeNode(node);
            map.remove(key);
            return null;
        }

        moveToHead(node);
        return node.value;
    } finally {
        lock.unlock();
    }
}

public boolean containsKey(K key) {
    return get(key) != null;
}

public void remove(K key) {
    if (key == null) return;

    lock.lock();
    try {
        Node<K, V> node = map.remove(key);
        if (node != null) {
            removeNode(node);
        }
    } finally {
        lock.unlock();
    }
}

public int size() {
    lock.lock();
    try {
        // 惰性策略:size 不主动清理过期项
        return map.size();
    } finally {
        lock.unlock();
    }
}

// 可选:手动触发一次清理(避免过期项长期堆积)
public int cleanupExpired(int maxScan) {
    lock.lock();
    try {
        int removed = 0;
        // 从尾部开始扫(更可能是旧数据)
        Node<K, V> cur = tail.prev;
        while (cur != head && removed < maxScan) {
            Node<K, V> prev = cur.prev;
            if (isExpired(cur)) {
                map.remove(cur.key);
                removeNode(cur);
                removed++;
            }
            cur = prev;
        }
        return removed;
    } finally {
        lock.unlock();
    }
}

// --- internal helpers ---

private boolean isExpired(Node<K, V> node) {
    return System.currentTimeMillis() >= node.expireAt;
}

private void evictTail() {
    Node<K, V> lru = tail.prev;
    if (lru == head) return;
    map.remove(lru.key);
    removeNode(lru);
}

private void addToHead(Node<K, V> node) {
    node.prev = head;
    node.next = head.next;
    head.next.prev = node;
    head.next = node;
}

private void removeNode(Node<K, V> node) {
    node.prev.next = node.next;
    node.next.prev = node.prev;
    node.prev = null;
    node.next = null;
}

private void moveToHead(Node<K, V> node) {
    removeNode(node);
    addToHead(node);
}

private static class Node<K, V> {
    K key;
    V value;
    long expireAt;
    Node<K, V> prev;
    Node<K, V> next;

    Node(K key, V value, long expireAt) {
        this.key = key;
        this.value = value;
        this.expireAt = expireAt;
    }
}

}


4)使用示例:热点数据 + TTL

下面演示一个常见场景:缓存某个接口的“短期结果”,减少重复计算。

import java.util.concurrent.TimeUnit;

public class CacheDemo { public static void main(String[] args) { LocalCache<String, String> cache = new LocalCache<>(3);

    cache.put("user:1", "Alice", 5, TimeUnit.SECONDS);
    cache.put("user:2", "Bob", 5, TimeUnit.SECONDS);

    System.out.println(cache.get("user:1")); // 命中
    cache.put("user:3", "Carol", 5, TimeUnit.SECONDS);

    // 再放一个会触发 LRU 淘汰(容量 3)
    cache.put("user:4", "Dave", 5, TimeUnit.SECONDS);

    // 由于 user:2 可能是最久未访问的,它更容易被淘汰
    System.out.println(cache.get("user:2")); // 可能为 null(取决于访问顺序)

    // 可选:定期人工清理一部分过期项(例如放在健康检查里)
    int removed = cache.cleanupExpired(50);
    System.out.println("cleanup removed=" + removed);
}

}


5)线上常见坑与改进建议

  1. 过期项不会自动消失? 是的,这里用惰性删除:只有 get 才会清理那个 key。 如果你的 key 写入很多但读取少,建议:

  2. 每隔一段时间调用 cleanupExpired(maxScan) 扫尾部

  3. 或在业务线程里按请求量触发一次小清理(例如每 1000 次请求扫 20 个)

  4. 全局锁会不会成为瓶颈? 大多数业务的本地缓存访问量并没有高到需要分段锁。 如果你要极致性能,可以做:

  5. 分段缓存(按 hash 分多个 LocalCache)

  6. 或用 ConcurrentHashMap + 链表锁分离(复杂度更高)

  7. TTL 精度问题 currentTimeMillis 精度足够;如果你需要更稳定的时间差,可以改用 nanoTime 计算“相对过期”,但实现会更绕。

  8. 缓存穿透 / 击穿 本地缓存也会有:某个 key 不存在导致重复打到下游。 工程上常用“空值缓存”策略:把 null/不存在的结果缓存一个很短 TTL(比如 30s)。


总结

这套实现不追求花哨,核心价值是:结构简单、行为可预测、足够工程化。TTL 解决“数据新鲜度”,LRU 解决“内存上限”,并发锁保证一致性。你可以先在单机服务里用它兜底热点数据,再根据访问规模升级为分段缓存或分布式缓存。

评论 0