用 Java 实现一个可落地的本地缓存(带 TTL + LRU)并发版
用 Java 实现一个可落地的本地缓存(带 TTL + LRU)并发版
很多项目一开始会直接上 Redis,但在一些场景里,本地缓存能更省钱也更快:比如配置读取、热点字典表、短期验证码状态、接口降级的兜底数据等。问题是:自己写缓存如果不考虑并发、过期、内存增长,很容易“上线后变成隐患”。这篇文章我用 Java 写一个可直接放进项目的本地缓存:支持 TTL 过期、LRU 淘汰、并发访问安全,并且尽量保持实现清晰。
1)设计目标与约束
我给这个缓存定了四个目标:
- TTL(过期时间):每个 key 可单独设置过期时间
- LRU(最近最少使用淘汰):容量满了自动淘汰最久未使用的 key
- 并发安全:多线程 get/put 不出错、不乱序
- 不依赖后台定时线程(可选):过期清理采用“访问时惰性删除”
注意:这里是“工程可用”的本地缓存,不追求极限性能(极限性能会引入更复杂的数据结构和锁分离策略)。
2)关键思路:HashMap + 双向链表实现 LRU
经典 LRU 的做法:
- 用
HashMap<K, Node>O(1) 定位节点 -
用双向链表维护访问顺序
-
每次
get/put把节点移到链表头(表示最近使用) - 淘汰时从链表尾删除(最久未使用)
TTL 过期的做法:
- 每个节点记录
expireAt(绝对过期时间毫秒/纳秒) get时如果已过期:删除并返回 nullput时覆盖旧值并更新 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&lt;K, Node&lt;K, V&gt;&gt; map;
private final ReentrantLock lock = new ReentrantLock();
// LRU 双向链表的哨兵节点
private final Node&lt;K, V&gt; head = new Node&lt;&gt;(null, null, Long.MAX_VALUE);
private final Node&lt;K, V&gt; tail = new Node&lt;&gt;(null, null, Long.MAX_VALUE);
public LocalCache(int maxSize) {
if (maxSize &lt;= 0) throw new IllegalArgumentException("maxSize must be &gt; 0");
this.maxSize = maxSize;
this.map = new HashMap&lt;&gt;(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&lt;K, V&gt; node = map.get(key);
if (node != null) {
// 覆盖
node.value = value;
node.expireAt = expireAt;
moveToHead(node);
return;
}
// 插入新节点
Node&lt;K, V&gt; newNode = new Node&lt;&gt;(key, value, expireAt);
map.put(key, newNode);
addToHead(newNode);
// 超容量淘汰
if (map.size() &gt; maxSize) {
evictTail();
}
} finally {
lock.unlock();
}
}
// get:命中则更新 LRU;过期则删除
public V get(K key) {
if (key == null) return null;
lock.lock();
try {
Node&lt;K, V&gt; 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&lt;K, V&gt; 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&lt;K, V&gt; cur = tail.prev;
while (cur != head &amp;&amp; removed &lt; maxScan) {
Node&lt;K, V&gt; 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&lt;K, V&gt; node) {
return System.currentTimeMillis() &gt;= node.expireAt;
}
private void evictTail() {
Node&lt;K, V&gt; lru = tail.prev;
if (lru == head) return;
map.remove(lru.key);
removeNode(lru);
}
private void addToHead(Node&lt;K, V&gt; node) {
node.prev = head;
node.next = head.next;
head.next.prev = node;
head.next = node;
}
private void removeNode(Node&lt;K, V&gt; node) {
node.prev.next = node.next;
node.next.prev = node.prev;
node.prev = null;
node.next = null;
}
private void moveToHead(Node&lt;K, V&gt; node) {
removeNode(node);
addToHead(node);
}
private static class Node&lt;K, V&gt; {
K key;
V value;
long expireAt;
Node&lt;K, V&gt; prev;
Node&lt;K, V&gt; 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)线上常见坑与改进建议
-
过期项不会自动消失? 是的,这里用惰性删除:只有
get才会清理那个 key。 如果你的 key 写入很多但读取少,建议: -
每隔一段时间调用
cleanupExpired(maxScan)扫尾部 -
或在业务线程里按请求量触发一次小清理(例如每 1000 次请求扫 20 个)
-
全局锁会不会成为瓶颈? 大多数业务的本地缓存访问量并没有高到需要分段锁。 如果你要极致性能,可以做:
-
分段缓存(按 hash 分多个 LocalCache)
-
或用
ConcurrentHashMap+ 链表锁分离(复杂度更高) -
TTL 精度问题
currentTimeMillis精度足够;如果你需要更稳定的时间差,可以改用nanoTime计算“相对过期”,但实现会更绕。 -
缓存穿透 / 击穿 本地缓存也会有:某个 key 不存在导致重复打到下游。 工程上常用“空值缓存”策略:把 null/不存在的结果缓存一个很短 TTL(比如 30s)。
总结
这套实现不追求花哨,核心价值是:结构简单、行为可预测、足够工程化。TTL 解决“数据新鲜度”,LRU 解决“内存上限”,并发锁保证一致性。你可以先在单机服务里用它兜底热点数据,再根据访问规模升级为分段缓存或分布式缓存。
评论 0