多键对应同一值的缓存驱逐问题及授权会话存储问询
这个问题踩过坑的人都懂——当多个sessionId绑定同一个Session实例时,直接用Guava Cache自带的驱逐回调绝对会出问题:只要某一个sessionId过期被清,就把共享的Session给删了,剩下关联的其他sessionId直接就找不到有效数据了。我给你分享几个实用的解决方案:
方案一:给Session加引用计数(最直接的思路)
核心就是给每个Session实例维护一个线程安全的引用计数器,只有当计数器降到0(也就是没有任何sessionId再关联它)的时候,才真正执行清理逻辑。
- 具体操作:
- 在你的
Session类里加一个原子整数计数器:public class Session { private final AtomicInteger refCount = new AtomicInteger(1); // 其他会话数据... } - 每当新增一个sessionId关联到这个Session时(比如用户刷新会话生成新的sessionId),先把原Session的计数器加1,再把新键存入缓存:
public String refreshSession(String oldSessionId) { Session session = cache.getIfPresent(oldSessionId); if (session != null) { session.refCount.incrementAndGet(); String newSessionId = generateNewSessionId(); cache.put(newSessionId, session); return newSessionId; } // 处理会话不存在的情况... return null; } - 配置缓存的移除监听器,在驱逐时先递减计数器,只有计数器为0时才清理:
Cache<String, Session> cache = CacheBuilder.newBuilder() .expireAfterAccess(30, TimeUnit.MINUTES) .removalListener((RemovalNotification<String, Session> notification) -> { Session session = notification.getValue(); if (session != null && session.refCount.decrementAndGet() == 0) { // 执行真正的清理逻辑:比如释放资源、同步状态到数据库等 cleanUpSession(session); } }) .build();
- 在你的
- 注意点:一定要确保所有新增关联键的操作都正确递增计数器,同时用原子类避免多线程下的竞态问题。
方案二:维护反向映射(Session到关联sessionId的集合)
单独维护一个并发安全的映射,记录每个Session对应的所有sessionId,这样就能精准知道还有多少键关联着这个Session。
- 具体操作:
- 定义一个反向映射容器:
private final ConcurrentMap<Session, Set<String>> sessionToSessionIds = new ConcurrentHashMap<>(); - 新增sessionId时,同步更新反向映射:
public void addSession(String sessionId, Session session) { // 用并发集合存sessionId,避免多线程操作异常 sessionToSessionIds.compute(session, (s, ids) -> { Set<String> idSet = ids == null ? new ConcurrentSkipListSet<>() : ids; idSet.add(sessionId); return idSet; }); cache.put(sessionId, session); } - 在移除监听器里处理:
.removalListener((RemovalNotification<String, Session> notification) -> { String removedSessionId = notification.getKey(); Session session = notification.getValue(); if (session == null) return; Set<String> associatedIds = sessionToSessionIds.get(session); if (associatedIds != null) { associatedIds.remove(removedSessionId); // 如果没有剩余关联的sessionId,就清理Session并移除反向映射 if (associatedIds.isEmpty()) { sessionToSessionIds.remove(session); cleanUpSession(session); } } })
- 定义一个反向映射容器:
- 注意点:如果有主动删除会话的场景(比如用户主动注销),要遍历反向映射里的所有sessionId,把它们从缓存中移除,再清理Session,不能只删单个键。
方案三:弱引用+引用队列(适合对清理时机要求不那么严格的场景)
把Session用弱引用包装,当没有任何sessionId引用它时,GC会自动回收这个Session,然后通过引用队列捕获回收事件来执行清理。不过这个方案要结合缓存的过期策略使用,因为Guava Cache的过期是基于键的,所以缓存的value可以设为WeakReference<Session>:
Cache<String, WeakReference<Session>> cache = CacheBuilder.newBuilder() .expireAfterAccess(30, TimeUnit.MINUTES) .build(); // 引用队列,用来接收被GC回收的Session引用 ReferenceQueue<Session> referenceQueue = new ReferenceQueue<>(); // 可以开一个后台线程监听队列,处理清理 new Thread(() -> { while (true) { try { WeakReference<Session> ref = (WeakReference<Session>) referenceQueue.remove(); Session session = ref.get(); if (session != null) { cleanUpSession(session); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } }).start();
- 注意点:这个方案的清理时机由GC决定,如果你需要精确的30分钟闲置后清理,前两个方案更靠谱,适合对资源占用敏感但清理时机不严格的场景。
通用注意事项
- 不管用哪个方案,主动删除会话的逻辑要同步处理:比如用户注销时,要把所有关联的sessionId都从缓存中移除,再触发Session清理。
- 所有涉及多线程操作的容器(计数器、反向映射)都要用并发安全的实现,比如
AtomicInteger、ConcurrentHashMap、ConcurrentSkipListSet等。 - 缓存的移除监听器是在缓存的内部线程执行的,如果你的
cleanUpSession逻辑比较耗时(比如调用外部接口、写数据库),建议把它放到异步线程池里执行,避免阻塞缓存的正常操作。
内容的提问来源于stack exchange,提问作者Oleg Sklyar
相关产品推荐
相关产品推荐

