1. 项目概述:为什么我们需要一个轻量级的本地存储方案?
在开发单机服务,比如一个后台管理工具、一个数据批处理脚本,或者一个轻量级的API网关时,我们经常会遇到一个看似简单却让人纠结的问题:一些临时数据,比如用户登录后的Token、短信验证码、第三方接口的调用凭证,该存到哪里?这些数据有几个共同特点:数据量极小(可能就是几个字符串)、生命周期短(几分钟到几小时)、不需要严格持久化(服务重启丢了可以重新生成),但对读写性能和便捷性要求却很高。
第一时间,很多开发者会想到Redis或者Memcached。没错,它们是分布式缓存的标杆,性能强悍,功能丰富。但为了存几个Token而引入一个Redis服务,就像为了喝杯牛奶而养一头奶牛。你需要额外维护一个服务进程,考虑它的高可用、内存配置、网络连接,对于单机部署的小型服务来说,这无疑是架构上的过度设计,增加了部署复杂度和运维成本。尤其是在一些资源受限的边缘计算环境、快速原型验证,或者希望分发出去就是一个可执行JAR包的工具中,这种“重型”依赖显得尤为不合时宜。
那么,用数据库行吗?哪怕是轻量级的SQLite或者H2?对于每秒可能产生上百次的Token校验请求,频繁的数据库IO操作会成为明显的性能瓶颈,而且同样引入了额外的依赖和复杂度。
因此,这个项目的核心价值就凸显出来了:在Java单机服务中,实现一个无需任何第三方中间件(如Redis、Memcached、数据库)的、轻量级、高性能的临时数据存储方案。它瞄准的就是上述那个“尴尬”的场景——数据不值得兴师动众,但又必须有个地方高效地暂存。这个方案的核心诉求很明确:零外部依赖、内存级速度、实现简单、资源消耗极低。接下来,我们就深入拆解如何从零构建这样一个“瑞士军刀”式的本地存储组件。
2. 核心需求解析与技术选型背后的逻辑
在动手写代码之前,我们必须把需求掰开揉碎,搞清楚我们要的究竟是什么,以及为什么某些技术路径比另一些更合适。
2.1 需求场景的深度剖析
数据特性与生命周期管理:
- Token(如JWT):通常具有明确的过期时间(exp)。存储核心是高效的
key-value查询(根据Token查用户信息)和自动过期清理。Token一旦签发,在有效期内基本是只读的,失效后需立即清理。 - 验证码:生命周期极短(60-180秒),过期后必须失效。并发场景下需注意防重放攻击(同一个验证码不能使用两次)。存储需要支持简单的
key-value,并可能附带生成时间戳。 - 接口调用凭证(如AccessToken):这类凭证往往有调用频率限制和过期时间。存储需要支持
key-value,并可能需要记录最后刷新时间或已调用次数。
它们的共性是:都是
key-value结构,且value通常是一个可序列化的对象(字符串、JSON对象等);都具有强烈的时效性。- Token(如JWT):通常具有明确的过期时间(exp)。存储核心是高效的
“轻量级”与“无依赖”的权衡:
- “无依赖”的边界:我们指的是不依赖外部服务(Redis)或需要额外启动的嵌入式服务。但我们可以充分利用JDK自带的标准库,这是“零成本”的依赖。
- “轻量级”的体现:内存占用小、GC友好、API简洁、线程安全开销低。这意味着我们要谨慎选择JDK中的数据结构,避免不必要的对象创建和锁竞争。
2.2 技术方案选型:为什么是ConcurrentHashMap + ScheduledExecutorService?
面对这个需求,JDK给我们提供了几个候选工具,我们需要逐一分析其优劣:
方案一:纯
HashMap/ConcurrentHashMap- 优点:绝对的简单、速度最快。
ConcurrentHashMap提供了高效的并发读写。 - 致命缺点:无法自动处理过期数据。过期条目会永远占据内存,导致内存泄漏。我们需要自己实现一个“清扫”逻辑。
- 优点:绝对的简单、速度最快。
方案二:
WeakHashMap- 原理:以键(Key)为弱引用,当键对象不再被其他地方引用时,条目会被自动GC。这听起来很适合存Token?
- 现实骨感:Token的Key(如token字符串本身)只要还在被外部请求引用,就不会被GC。而我们希望的是基于时间的过期,而非基于引用。因此
WeakHashMap不适用。
方案三:Guava Cache / Caffeine
- 优点:功能强大,支持基于时间和大小的过期、异步刷新、统计等,是生产级选择。
- 缺点:引入了第三方库依赖。虽然Guava/Caffeine非常优秀且广泛使用,但这违背了我们“无第三方依赖”的硬性约束。对于追求极致纯净的单机工具,这个依赖可能不被接受。
我们的选择:
ConcurrentHashMap+ScheduledExecutorService- 核心思想:用
ConcurrentHashMap提供高性能并发存储,用一个后台定时调度线程,定期扫描并清理过期的条目。 - 为什么是它:
- 完全满足“无依赖”:两者都是
java.util.concurrent包下的标准JDK组件。 - 可控性强:我们可以精确控制清理策略(如每隔30秒扫描一次)、过期判断逻辑,甚至实现更复杂的策略(如惰性删除)。
- 性能平衡:
ConcurrentHashMap的读写在大多数情况下是常数时间复杂度。定期的清理任务在数据量不大时开销极低。 - 资源消耗清晰:只有一个额外的后台线程,内存中只有一份Map结构,非常透明。
- 完全满足“无依赖”:两者都是
注意:这个方案不是银弹。它的一个潜在问题是,如果过期数据量巨大,单次扫描清理可能会引起短暂的延迟。但对于我们设定的“数据量小”的场景(几千到几万条),这个影响微乎其微。如果数据量真的增长到十万级以上,那可能意味着这个场景已经超出了“轻量级临时存储”的范畴,需要重新评估架构。
- 核心思想:用
3. 核心设计与实现细节
确定了技术方案,我们来设计这个本地缓存的核心组件。我们将它命名为SimpleLocalCache。
3.1 数据结构设计:存储的不只是值
我们不能只存value,还必须存下它的“出生时间”和“寿命”,这样才能判断是否过期。
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; /** * 轻量级本地缓存,适用于存储Token、验证码等临时数据。 * @param <K> 键类型 * @param <V> 值类型 */ public class SimpleLocalCache<K, V> { // 核心存储结构:键 -> 缓存条目(值 + 过期时间戳) private final ConcurrentHashMap<K, CacheEntry<V>> cache = new ConcurrentHashMap<>(); // 后台清理线程池(单线程即可) private final ScheduledExecutorService cleanupScheduler = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "LocalCache-CleanupThread"); t.setDaemon(true); // 设置为守护线程,防止阻止JVM关闭 return t; }); // 缓存条目内部类 private static class CacheEntry<V> { final V value; final long expireAt; // 过期时间点(毫秒时间戳) CacheEntry(V value, long expireAt) { this.value = value; this.expireAt = expireAt; } boolean isExpired() { return System.currentTimeMillis() > expireAt; } } // ... 后续方法 }设计要点解析:
CacheEntry封装:将值和过期时间打包。expireAt是绝对时间戳,比存储“存活时长”更直观,且避免了每次访问都要计算。- 守护线程:清理线程设置为
Daemon线程,这样当主线程退出时,JVM不会因为这个线程而等待,可以正常关闭。这对于单机工具非常重要。 ConcurrentHashMap:选择它是因为我们的读写操作很可能来自多个线程(如Web容器的线程池)。它提供了最好的并发性能。
3.2 初始化与清理策略的实现
缓存需要启动清理任务,并提供优雅关闭的接口。
public class SimpleLocalCache<K, V> { // ... 接上文字段定义 /** * 创建缓存实例 * @param cleanupIntervalSeconds 后台清理任务执行间隔(秒) */ public SimpleLocalCache(long cleanupIntervalSeconds) { // 启动定时清理任务 cleanupScheduler.scheduleAtFixedRate(this::cleanupExpiredEntries, cleanupIntervalSeconds, cleanupIntervalSeconds, TimeUnit.SECONDS); } /** * 清理过期条目 */ private void cleanupExpiredEntries() { long now = System.currentTimeMillis(); cache.entrySet().removeIf(entry -> { CacheEntry<V> cacheEntry = entry.getValue(); // 如果条目已过期,则移除 return cacheEntry != null && cacheEntry.expireAt <= now; }); } /** * 手动触发一次清理(可用于特殊场景,如内存紧张时) */ public void cleanupNow() { cleanupExpiredEntries(); } /** * 关闭缓存,释放资源(非常重要!) */ public void shutdown() { cleanupScheduler.shutdown(); try { // 等待现有任务完成,最多等5秒 if (!cleanupScheduler.awaitTermination(5, TimeUnit.SECONDS)) { cleanupScheduler.shutdownNow(); // 强制关闭 } } catch (InterruptedException e) { cleanupScheduler.shutdownNow(); Thread.currentThread().interrupt(); // 恢复中断状态 } cache.clear(); } // ... 后续的put/get方法 }实操心得:
- 清理间隔:
cleanupIntervalSeconds不宜过短(如1秒),会造成无意义的CPU空转;也不宜过长(如10分钟),会导致过期数据长时间占据内存。根据业务场景,30秒到5分钟是一个合理的范围。对于验证码这种60秒过期的数据,间隔设为30秒可以保证数据在过期后最多残留30秒。 shutdown()方法必须调用:特别是在Web应用(如Spring Boot)中,当应用关闭时,务必在@PreDestroy或DisposableBean中调用shutdown(),以关闭后台线程,避免线程泄漏。这是一个容易被忽略但至关重要的步骤。
3.3 核心API:put、get与删除
这是缓存对外的核心接口,设计时要考虑线程安全和性能。
public class SimpleLocalCache<K, V> { // ... 接上文 /** * 存入缓存 * @param key 键 * @param value 值 * @param ttl 存活时间,单位毫秒 */ public void put(K key, V value, long ttl) { if (ttl <= 0) { // TTL小于等于0,视为不缓存或立即过期,直接返回或可选择存入一个立即过期的条目 // 这里选择直接返回,不存储 return; } long expireAt = System.currentTimeMillis() + ttl; CacheEntry<V> entry = new CacheEntry<>(value, expireAt); cache.put(key, entry); } /** * 获取缓存值 * @param key 键 * @return 值,如果不存在或已过期则返回null */ public V get(K key) { CacheEntry<V> entry = cache.get(key); if (entry == null) { return null; // 键不存在 } // 惰性过期检查:在获取时也检查是否过期 if (entry.isExpired()) { // 如果发现已过期,立即移除(惰性删除) cache.remove(key, entry); // 使用remove(key, oldValue)保证原子性 return null; } return entry.value; } /** * 删除指定键的缓存 * @param key 键 */ public void remove(K key) { cache.remove(key); } /** * 清空所有缓存 */ public void clear() { cache.clear(); } /** * 获取当前缓存中的有效条目数量(近似值,因为可能有已过期但未被清理的) */ public int size() { // 注意:此size可能包含已过期的条目,直到下次清理或get时被惰性删除 return cache.size(); } }关键细节与避坑指南:
- TTL的处理:
put方法接收ttl(存活时间),我们内部将其转换为绝对的expireAt时间戳。这样在检查过期时,只需要和当前时间比较一次,效率更高。一定要处理ttl <= 0的情况,避免存入一个“过去”的过期时间。 - 惰性删除(Lazy Eviction):在
get方法中,我们不仅从Map里取数据,还检查是否过期。如果过期,我们立即移除它。这是一种惰性删除策略。它结合了后台定时清理,构成了双重过期保障:- 后台定时清理:定期扫描,清理“僵尸”条目(那些再也不会被访问的过期数据)。
- 惰性删除:在每次访问时检查并清理。这保证了当你获取一个key时,拿到的绝对是未过期的数据,即使它刚好在两次定时清理之间过期。
- 原子性移除:
cache.remove(key, entry)使用了ConcurrentHashMap的remove(Object key, Object value)方法。这个方法只有在当前Map中key对应的值恰好是entry时才移除。这可以防止一种极端情况:在检查过期和移除之间,另一个线程刚好用新的值更新了这个key。使用这个方法可以避免误删新数据。 size()方法的准确性:由于惰性删除的存在,size()方法返回的数量可能包含已过期但未被触发的条目。因此,它只是一个近似值。如果业务需要精确的有效数量,可以遍历所有条目进行计数,但性能损耗大,一般不推荐。
4. 高级特性与生产环境考量
一个基础的缓存已经完成,但要用于实际生产,我们还需要考虑更多边界情况和增强功能。
4.1 容量限制与淘汰策略
我们的场景是“数据量小”,但为了防止意外(如验证码遭受攻击被刷入大量垃圾数据),最好加入简单的容量限制。
public class SimpleLocalCache<K, V> { private final int maxCapacity; private final AtomicInteger currentSize = new AtomicInteger(0); // 近似大小 public SimpleLocalCache(long cleanupIntervalSeconds, int maxCapacity) { // ... 初始化清理任务 this.maxCapacity = maxCapacity; } public void put(K key, V value, long ttl) { if (ttl <= 0) return; // 容量检查(简易版) if (currentSize.get() >= maxCapacity) { // 触发清理,尝试腾出空间 cleanupExpiredEntries(); // 清理后再次检查 if (currentSize.get() >= maxCapacity) { // 如果仍然满,可以采取简单策略:拒绝写入或淘汰一个(如随机淘汰) // 这里选择拒绝写入并记录日志 System.err.println("[LocalCache] Capacity exceeded, reject put for key: " + key); return; } } long expireAt = System.currentTimeMillis() + ttl; CacheEntry<V> oldEntry = cache.put(key, new CacheEntry<>(value, expireAt)); // 更新大小计数器 if (oldEntry == null) { // 如果是新增,计数器+1 currentSize.incrementAndGet(); } // 如果是覆盖,大小不变 } private void cleanupExpiredEntries() { long now = System.currentTimeMillis(); int removedCount = 0; Iterator<Map.Entry<K, CacheEntry<V>>> iterator = cache.entrySet().iterator(); while (iterator.hasNext()) { Map.Entry<K, CacheEntry<V>> entry = iterator.next(); if (entry.getValue().expireAt <= now) { iterator.remove(); removedCount++; } } if (removedCount > 0) { currentSize.addAndGet(-removedCount); } } // 在get方法中惰性删除时,也需要更新计数器 public V get(K key) { CacheEntry<V> entry = cache.get(key); if (entry == null) return null; if (entry.isExpired()) { if (cache.remove(key, entry)) { currentSize.decrementAndGet(); // 原子递减 } return null; } return entry.value; } // remove和clear方法也需要同步更新currentSize }注意事项:
currentSize是一个近似值,因为在并发环境下,精确维护大小代价很高。我们用它做一个快速的容量预判。- 淘汰策略这里实现的是容量满则拒绝写入。更复杂的策略如LRU(最近最少使用)需要维护额外的数据结构(如
LinkedHashMap),会引入复杂性和性能开销,与“轻量级”初衷相悖。对于临时数据,拒绝写入通常是可接受的,因为客户端会收到错误,可以重试或等待缓存空间释放。
4.2 监听器与事件通知(可选)
在某些场景下,我们可能想知道缓存条目何时被移除(过期或手动删除),以便执行一些后续逻辑(如记录日志、通知其他组件)。
public class SimpleLocalCache<K, V> { // 监听器接口 public interface RemovalListener<K, V> { void onRemoval(K key, V value, RemovalCause cause); } public enum RemovalCause { EXPLICIT, // 显式调用remove EXPIRED, // 过期 REPLACED, // 被新值覆盖 CAPACITY // 因容量限制被淘汰(如果实现了的话) } private final List<RemovalListener<K, V>> listeners = new CopyOnWriteArrayList<>(); public void addRemovalListener(RemovalListener<K, V> listener) { listeners.add(listener); } // 在删除条目时触发监听器 private void notifyRemoval(K key, V value, RemovalCause cause) { for (RemovalListener<K, V> listener : listeners) { try { listener.onRemoval(key, value, cause); } catch (Exception e) { // 避免一个监听器的异常影响其他监听器和主流程 System.err.println("[LocalCache] Error in removal listener: " + e.getMessage()); } } } // 修改remove方法和清理逻辑,在真正删除前调用notifyRemoval private boolean removeEntry(K key, CacheEntry<V> entry, RemovalCause cause) { if (entry == null) return false; V value = entry.value; if (cache.remove(key, entry)) { currentSize.decrementAndGet(); notifyRemoval(key, value, cause); return true; } return false; } // 在get中触发过期删除 public V get(K key) { CacheEntry<V> entry = cache.get(key); if (entry == null) return null; if (entry.isExpired()) { removeEntry(key, entry, RemovalCause.EXPIRED); return null; } return entry.value; } // 在put中,如果覆盖了旧值,也需要通知 public void put(K key, V value, long ttl) { // ... 容量检查等逻辑 CacheEntry<V> newEntry = new CacheEntry<>(value, expireAt); CacheEntry<V> oldEntry = cache.put(key, newEntry); if (oldEntry == null) { currentSize.incrementAndGet(); } else { // 覆盖旧值,触发监听 notifyRemoval(key, oldEntry.value, RemovalCause.REPLACED); } } }使用场景:例如,你可以添加一个监听器来打印日志,监控缓存的淘汰情况;或者在Token过期被移除时,尝试异步刷新(但注意,在单机缓存中做刷新逻辑要谨慎,避免循环依赖)。
4.3 序列化与持久化思考(非必需)
标题要求“不需要持久化”,所以我们默认所有数据都在内存中。但在某些边缘场景,比如希望服务重启后某些凭证能恢复(尽管可能已过期),我们可以提供一个可选的、简单的持久化层。
重要提示:这违背了“轻量级”的初衷,仅作为思路扩展。
public class SimpleLocalCache<K, V> { // 假设K和V都是可序列化的 public void saveToFile(String filePath) throws IOException { try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(filePath))) { // 只保存未过期的条目 Map<K, CacheEntry<V>> snapshot = new HashMap<>(); long now = System.currentTimeMillis(); for (Map.Entry<K, CacheEntry<V>> entry : cache.entrySet()) { if (!entry.getValue().isExpired(now)) { snapshot.put(entry.getKey(), entry.getValue()); } } oos.writeObject(snapshot); } } @SuppressWarnings("unchecked") public void loadFromFile(String filePath) throws IOException, ClassNotFoundException { try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(filePath))) { Map<K, CacheEntry<V>> loaded = (Map<K, CacheEntry<V>>) ois.readObject(); cache.clear(); cache.putAll(loaded); // 需要重新计算currentSize currentSize.set(cache.size()); } } }强烈建议:除非有非常强烈的理由,否则不要为这种临时缓存添加持久化。它极大地增加了复杂性(序列化兼容性、文件锁、数据一致性),并且与数据的“临时”性质相悖。Token、验证码这类数据,丢了就重新生成,这才是最简洁的设计。
5. 在典型场景中的集成与应用示例
现在,我们有了一个功能相对完善的SimpleLocalCache,来看看如何在文章开头提到的几个典型场景中使用它。
5.1 场景一:存储与验证JWT Token
假设我们有一个简单的用户认证服务,用户登录后生成JWT Token,我们需要在服务端缓存一部分Token信息(如用户ID与Token的映射),以便快速实现Token吊销或单点登录踢出。
public class TokenService { // 缓存实例:键为Token字符串,值为用户ID private final SimpleLocalCache<String, String> tokenCache = new SimpleLocalCache<>(60, 10000); // 60秒清理一次,最大容量10000 /** * 用户登录成功,缓存Token * @param token JWT Token * @param userId 用户ID * @param expiresInSeconds Token有效期(秒) */ public void cacheUserToken(String token, String userId, long expiresInSeconds) { // 将有效期转换为毫秒存入缓存 tokenCache.put(token, userId, expiresInSeconds * 1000); } /** * 验证Token是否有效(是否在缓存中且未过期) * @param token * @return 对应的用户ID,如果无效则返回null */ public String validateToken(String token) { return tokenCache.get(token); // get方法已包含过期检查 } /** * 用户登出或管理员吊销Token * @param token */ public void invalidateToken(String token) { tokenCache.remove(token); } // 应用关闭时清理资源 @PreDestroy public void destroy() { tokenCache.shutdown(); } }实操要点:
- 缓存什么?这里缓存的是
Token -> UserId的映射。你不需要缓存完整的JWT payload,因为JWT本身是无状态的,验证其签名即可。缓存映射的目的是为了支持服务端的主动吊销。 - TTL设置:缓存的有效期应该略短于JWT Token本身的过期时间(例如,JWT过期是3600秒,缓存设置3500秒)。这样可以确保缓存条目先过期,避免出现缓存里还有,但JWT已过期的情况。
- 容量规划:
maxCapacity应根据你的最大并发用户数来设置。对于后台管理系统,10000可能足够;对于高并发C端服务,这个方案可能就不适用了。
5.2 场景二:短信验证码的生成与校验
这是本地缓存最经典的应用场景之一。
public class SmsCodeService { // 缓存实例:键为手机号,值为验证码和生成时间(封装在一个对象里) private final SimpleLocalCache<String, SmsCodeInfo> smsCache = new SimpleLocalCache<>(30, 50000); // 30秒清理,容量5万 static class SmsCodeInfo { String code; // 验证码 long generateTime; // 生成时间戳 // 还可以加尝试次数等字段 } /** * 生成并发送验证码 * @param phoneNumber 手机号 * @return 生成的验证码(仅用于演示,实际应调用短信服务商接口) */ public String generateAndSendCode(String phoneNumber) { // 1. 生成随机6位数字码 String code = String.format("%06d", ThreadLocalRandom.current().nextInt(1000000)); // 2. 封装信息 SmsCodeInfo info = new SmsCodeInfo(); info.code = code; info.generateTime = System.currentTimeMillis(); // 3. 存入缓存,有效期120秒 smsCache.put(phoneNumber, info, 120 * 1000); // 4. 模拟发送短信(实际应调用短信网关) System.out.println("发送验证码 " + code + " 至手机 " + phoneNumber); return code; } /** * 校验验证码 * @param phoneNumber 手机号 * @param userInputCode 用户输入的验证码 * @return 是否验证成功 */ public boolean verifyCode(String phoneNumber, String userInputCode) { SmsCodeInfo info = smsCache.get(phoneNumber); if (info == null) { return false; // 验证码不存在或已过期 } // 可选:增加额外校验,比如验证码是否在1分钟内使用(防重放) long currentTime = System.currentTimeMillis(); if (currentTime - info.generateTime > 60 * 1000) { smsCache.remove(phoneNumber); // 超过1分钟,即使未到120秒也强制失效 return false; } // 核心校验:验证码是否匹配 boolean success = info.code.equals(userInputCode); if (success) { // 验证成功,立即移除验证码,防止重复使用 smsCache.remove(phoneNumber); } else { // 验证失败,可以记录失败次数,达到阈值后使验证码失效 // ... 此处可扩展 } return success; } }避坑指南:
- 防重放攻击:上面代码中有一个校验:验证码生成后超过1分钟即失效,即使总有效期是2分钟。这是为了防止用户慢速攻击。更完善的机制可以引入“尝试次数”,失败3次后验证码自动失效。
- 原子性校验:在超高并发下,可能存在同一手机号连续发送两次验证码,后一个覆盖前一个的情况。
SimpleLocalCache的put操作是原子的,可以保证最后一次存入的生效。如果业务要求“不允许频繁发送”,需要在generateAndSendCode方法外加分布式锁或使用更精确的限流器,这已超出本地缓存范畴。 - 验证码移除时机:一定要在验证成功后立即移除(
remove),这是保证验证码一次性使用的关键。
5.3 场景三:缓存第三方API的AccessToken
调用微信、支付宝等第三方API时,通常需要先获取一个有过期时间的AccessToken,并在有效期内重复使用。
public class WechatApiClient { private final SimpleLocalCache<String, AccessToken> tokenCache = new SimpleLocalCache<>(300, 10); // 5分钟清理,容量10(通常只存1个) private final HttpClient httpClient = HttpClient.newHttpClient(); static class AccessToken { String token; long expiresIn; // 有效期,单位秒(从API返回) long fetchTime; // 获取时间戳 } /** * 获取AccessToken(带缓存) * @return 有效的token字符串 */ public String getAccessToken() throws Exception { String cacheKey = "wechat_access_token"; AccessToken cached = tokenCache.get(cacheKey); // 如果缓存中有且未过期(预留5分钟缓冲),直接返回 if (cached != null && !isTokenAboutToExpire(cached)) { return cached.token; } // 缓存无效,重新获取 synchronized (this) { // 简单的同步锁,防止多个线程同时刷新token // 双重检查锁定模式 cached = tokenCache.get(cacheKey); if (cached != null && !isTokenAboutToExpire(cached)) { return cached.token; } AccessToken newToken = fetchNewAccessTokenFromApi(); // 存入缓存,TTL设置为实际过期时间(毫秒) tokenCache.put(cacheKey, newToken, newToken.expiresIn * 1000); return newToken.token; } } private boolean isTokenAboutToExpire(AccessToken token) { // 判断token是否在5分钟内过期 long timeElapsed = System.currentTimeMillis() - token.fetchTime; long timeLeft = token.expiresIn * 1000 - timeElapsed; return timeLeft < 5 * 60 * 1000; // 剩余时间小于5分钟 } private AccessToken fetchNewAccessTokenFromApi() throws Exception { // 模拟调用微信API String response = ""; // 实际为HTTP请求结果 // 解析response,得到token和expires_in AccessToken token = new AccessToken(); token.token = "模拟的AccessToken"; token.expiresIn = 7200; // 微信通常是7200秒 token.fetchTime = System.currentTimeMillis(); return token; } }核心技巧:
- 缓冲期:
isTokenAboutToExpire方法中,我们判断剩余时间是否小于5分钟。这意味着我们不会等到token完全过期才刷新,而是提前刷新。这避免了在token过期的瞬间,大量请求同时触发刷新,也避免了在临界点拿到一个刚过期的token去调用接口。 - 同步锁:使用
synchronized防止多个线程同时去刷新token,造成重复请求和资源浪费。这是一个简单的单机锁。在分布式环境下,需要更复杂的分布式锁机制,但这又超出了本地缓存的范畴。 - 容量设置:这种全局唯一的token,缓存容量设置为10都绰绰有余。
6. 性能测试、监控与常见问题排查
即使是一个简单的组件,我们也需要了解它的表现和可能遇到的问题。
6.1 简易性能压测思路
我们可以写一个简单的测试,模拟并发读写。
public class SimpleLocalCacheBenchmark { public static void main(String[] args) throws InterruptedException { SimpleLocalCache<String, String> cache = new SimpleLocalCache<>(60, 100000); int threadCount = 10; int operationsPerThread = 10000; ExecutorService executor = Executors.newFixedThreadPool(threadCount); CountDownLatch latch = new CountDownLatch(threadCount); long start = System.currentTimeMillis(); for (int i = 0; i < threadCount; i++) { final int threadId = i; executor.submit(() -> { try { for (int j = 0; j < operationsPerThread; j++) { String key = "key-" + threadId + "-" + j; String value = "value-" + j; // 80%读,20%写 if (ThreadLocalRandom.current().nextDouble() < 0.8) { cache.get(key); } else { cache.put(key, value, 60000); // 1分钟过期 } } } finally { latch.countDown(); } }); } latch.await(); long end = System.currentTimeMillis(); executor.shutdown(); System.out.println("总操作数: " + (threadCount * operationsPerThread)); System.out.println("总耗时(ms): " + (end - start)); System.out.println("吞吐量(ops/ms): " + (threadCount * operationsPerThread * 1.0 / (end - start))); System.out.println("最终缓存大小: " + cache.size()); cache.shutdown(); } }在我的开发机(8核)上,这个简单的测试能达到每秒数十万次的操作吞吐量,对于单机临时存储场景完全够用。性能瓶颈主要在于ConcurrentHashMap本身,而它是高度优化的。
6.2 监控与日志
在生产环境中,我们可能需要知道缓存的使用情况。
添加统计信息:可以在
SimpleLocalCache内部增加计数器。private final AtomicLong hitCount = new AtomicLong(0); private final AtomicLong missCount = new AtomicLong(0); private final AtomicLong putCount = new AtomicLong(0); public V get(K key) { CacheEntry<V> entry = cache.get(key); if (entry == null) { missCount.incrementAndGet(); return null; } if (entry.isExpired()) { removeEntry(key, entry, RemovalCause.EXPIRED); missCount.incrementAndGet(); return null; } hitCount.incrementAndGet(); return entry.value; } // 在put、remove等方法中也更新对应计数器 public CacheStats getStats() { return new CacheStats(hitCount.get(), missCount.get(), putCount.get(), currentSize.get()); }定期打印状态:可以注册一个监听器,或者通过JMX暴露这些统计信息,方便监控。
6.3 常见问题与排查清单
| 问题现象 | 可能原因 | 排查与解决方案 |
|---|---|---|
| 内存持续增长(OOM) | 1. 过期数据未正确清理。 2. 缓存容量 maxCapacity设置过大或未生效,导致数据无限堆积。3. 存入的value对象本身很大(如大字符串、大对象)。 | 1. 检查清理线程是否正常启动(日志)。检查cleanupExpiredEntries逻辑。2. 确认 maxCapacity参数已传入并生效。检查容量满时的拒绝策略是否工作。3. 使用Profiler工具(如VisualVM)分析堆内存,确认大对象来源。确保只存必要的小数据。 |
| 获取到的数据为null,但确信刚存入 | 1. 数据已过期(TTL设置过短或时间计算错误)。 2. 并发情况下被其他线程移除。 3. Key的 hashCode或equals方法实现有问题,导致存入和获取时定位不到同一个桶。 | 1. 打印expireAt和当前时间戳,核对TTL计算逻辑。检查服务器时间是否同步。2. 检查业务逻辑中是否有其他地方调用了 remove。3. 确保作为Key的对象是不可变的,并且正确重写了 hashCode和equals方法。 |
| 后台清理线程未关闭,导致应用无法正常退出 | 忘记调用shutdown()方法,或者shutdown()被异常中断。 | 1. 确保在应用关闭的钩子(如Spring的@PreDestroy、Servlet的contextDestroyed)中调用shutdown()。2. 将清理线程设置为守护线程(我们在构造函数中已设置 t.setDaemon(true)),这样即使未调用shutdown,也不会阻止JVM退出。但显式关闭仍是好习惯。 |
| 高并发下,size()不准或出现奇怪行为 | ConcurrentHashMap的size()本身是近似值,且我们的currentSize在并发更新下也是近似值。惰性删除和定时清理之间存在时间窗口。 | 理解并接受size()的不精确性。对于临时缓存,我们通常只关心其是否在正常工作,而不需要精确的条目数。如果业务强依赖精确计数,这个方案可能不适用。 |
| 缓存穿透(大量请求查询不存在的key) | 恶意攻击或业务bug,频繁查询一个不存在或已过期的key,请求直达底层(虽然我们这里没底层)。 | 对于本地缓存,穿透问题影响较小,但会浪费CPU。可在get方法内,对查询不到的key,短暂地存入一个表示“空值”的条目(TTL很短,如5秒),防止瞬间重复穿透。 |
6.4 与Spring框架集成
在Spring Boot项目中,我们可以很方便地将SimpleLocalCache作为一个Bean来管理其生命周期。
@Configuration public class CacheConfig { @Bean public SimpleLocalCache<String, Object> localCache() { // 配置清理间隔和最大容量 return new SimpleLocalCache<>(60, 5000); } } @Service public class MyService { @Autowired private SimpleLocalCache<String, Object> localCache; public void businessMethod() { // 使用localCache localCache.put("key", someObject, 300000); } // Spring容器关闭时,会自动调用@PreDestroy方法 @PreDestroy public void cleanup() { if (localCache != null) { localCache.shutdown(); } } }集成要点:通过@Bean创建单例,通过@PreDestroy确保资源释放,这是最优雅的集成方式。
经过以上从设计到实现,再到应用和问题排查的完整拆解,我们得到了一个真正贴合“单机、轻量、无依赖”场景的Java本地临时存储方案。它没有Redis强大,但正因为其简单,所以在特定场景下显得更加锋利和趁手。记住,没有最好的工具,只有最合适的工具。当你下次面对需要暂存几个Token或验证码的场景时,不妨考虑一下这个自研的轻量级方案,它可能会让你的项目部署包更清爽,架构更简洁。