Keycloak 分佈式部署中會話過期清理機制
在 Keycloak 分佈式部署(使用外部獨立部署的 Infinispan)的架構下,sessions 和 clientSessions 的過期清理涉及兩種不同的部署模式,機制略有不同:
架構模式 1:Embedded + Remote Store(嵌入式緩存 + 遠程存儲)
這種模式下,Keycloak 節點有本地嵌入式緩存,同時配置了遠程存儲(Remote Store)連接到外部 Infinispan 集羣。
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@ClientListener
public class RemoteCacheSessionListener<K, V extends SessionEntity> {
清理機制:
- 本地緩存自動過期:
- 當會話數據寫入本地嵌入式緩存(
DefaultSegmentedDataContainer)時,會同時設置lifespan和maxIdle參數 - Infinispan 的內置過期機制會自動清除過期條目
- 當會話數據寫入本地嵌入式緩存(
- 遠程緩存事件同步:
RemoteCacheSessionListener通過 Hot Rod Client Listener 機制監聽遠程緩存事件- 當遠程 Infinispan 中條目被刪除時,會觸發
@ClientCacheEntryRemoved事件:
@ClientCacheEntryRemoved
public void removed(ClientCacheEntryRemovedEvent event) {
K key = (K) event.getKey();
if (shouldUpdateLocalCache(event.getType(), key, event.isCommandRetried())) {
this.executor.submit(event, () -> {
// We received event from remoteCache, so we won't update it back
cache.getAdvancedCache().withFlags(Flag.SKIP_CACHE_STORE, Flag.SKIP_CACHE_LOAD, Flag.IGNORE_RETURN_VALUES)
.remove(key);
});
}
}
- 重要限制:
- Infinispan 不發送過期事件(Expiration Event)給 Hot Rod 客户端監聽器!
- 遠程 Infinispan 中的條目過期時,不會主動通知 Keycloak 節點
- 本地緩存的清理完全依賴於本地 Infinispan 的自動過期機制
架構模式 2:Remote Only(純遠程模式)
這種模式下,Keycloak 不維護本地會話緩存,所有會話數據都直接存儲在外部 Infinispan 集羣中。
@Override
public void removeAllExpired() {
//rely on Infinispan expiration
}
@Override
public void removeExpired(RealmModel realm) {
//rely on Infinispan expiration
}
清理機制:
- 完全依賴遠程 Infinispan 的過期機制
- Keycloak 本地 JVM 中沒有
DefaultSegmentedDataContainer,因為不使用嵌入式緩存 - 所有讀取操作直接訪問遠程緩存,過期數據自然不會被讀取到
關鍵代碼:過期時間計算
無論哪種模式,會話的過期時間都通過 SessionTimeouts 計算:
public static long getUserSessionLifespanMs(RealmModel realm, ClientModel client, UserSessionEntity userSessionEntity) {
return getUserSessionLifespanMs(realm, false, userSessionEntity.isRememberMe(), userSessionEntity.getStarted());
}
public static long getUserSessionLifespanMs(RealmModel realm, boolean offline, boolean rememberMe, int started) {
long lifespan = SessionExpirationUtils.calculateUserSessionMaxLifespanTimestamp(offline, rememberMe,
TimeUnit.SECONDS.toMillis(started), realm);
if (offline && lifespan == IMMORTAL_FLAG) {
return IMMORTAL_FLAG;
}
lifespan = lifespan - Time.currentTimeMillis();
if (lifespan <= 0) {
return ENTRY_EXPIRED_FLAG;
}
return lifespan;
}
總結:本地 JVM 中 DefaultSegmentedDataContainer 對象的清理方式
| 場景 | 清理機制 |
|---|---|
| 本地條目自然過期 | Infinispan 嵌入式緩存的內置過期 Reaper 線程自動清理 |
| 遠程條目被刪除 | 通過 RemoteCacheSessionListener 的 @ClientCacheEntryRemoved 事件同步刪除本地條目 |
| 遠程條目自然過期 | 不會主動通知!本地條目依賴自身的過期時間自動失效 |
| Failover 事件 | 觸發 onFailover 回調,清空整個本地緩存(ispnCache::clear)以保證一致性 |
潛在問題
由於遠程 Infinispan 的過期事件不會通知本地緩存,在以下情況下可能存在短暫的數據不一致:
- 遠程條目已過期被清除
- 但本地緩存的過期時間還沒到
- 此時本地可能返回一個"幽靈會話"
Keycloak 的解決方案:
- 在每次從本地緩存獲取會話時,都會檢查
Expiration.isExpired() - 如果計算出的過期時間已經過了,即使緩存條目存在也會被視為無效
public boolean isExpired() {
return maxIdle == SessionTimeouts.ENTRY_EXPIRED_FLAG || lifespan == SessionTimeouts.ENTRY_EXPIRED_FLAG;
}
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
@ClientListener
public class RemoteCacheSessionListener<K, V extends SessionEntity> {
@ClientCacheEntryRemoved
public void removed(ClientCacheEntryRemovedEvent event) {
K key = (K) event.getKey();
if (shouldUpdateLocalCache(event.getType(), key, event.isCommandRetried())) {
this.executor.submit(event, () -> {
// We received event from remoteCache, so we won't update it back
cache.getAdvancedCache().withFlags(Flag.SKIP_CACHE_STORE, Flag.SKIP_CACHE_LOAD, Flag.IGNORE_RETURN_VALUES)
.remove(key);
});
}
}
@Override
public void removeAllExpired() {
//rely on Infinispan expiration
}
@Override
public void removeExpired(RealmModel realm) {
//rely on Infinispan expiration
}
public static long getUserSessionLifespanMs(RealmModel realm, ClientModel client, UserSessionEntity userSessionEntity) {
return getUserSessionLifespanMs(realm, false, userSessionEntity.isRememberMe(), userSessionEntity.getStarted());
}
public static long getUserSessionLifespanMs(RealmModel realm, boolean offline, boolean rememberMe, int started) {
long lifespan = SessionExpirationUtils.calculateUserSessionMaxLifespanTimestamp(offline, rememberMe,
TimeUnit.SECONDS.toMillis(started), realm);
if (offline && lifespan == IMMORTAL_FLAG) {
return IMMORTAL_FLAG;
}
lifespan = lifespan - Time.currentTimeMillis();
if (lifespan <= 0) {
return ENTRY_EXPIRED_FLAG;
}
return lifespan;
}
public boolean isExpired() {
return maxIdle == SessionTimeouts.ENTRY_EXPIRED_FLAG || lifespan == SessionTimeouts.ENTRY_EXPIRED_FLAG;
}