博客 / 詳情

返回

keycloak~分佈式部署中會話過期清理機制

Keycloak 分佈式部署中會話過期清理機制

在 Keycloak 分佈式部署(使用外部獨立部署的 Infinispan)的架構下,sessionsclientSessions 的過期清理涉及兩種不同的部署模式,機制略有不同:

架構模式 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>  {

清理機制:

  1. 本地緩存自動過期
    • 當會話數據寫入本地嵌入式緩存(DefaultSegmentedDataContainer)時,會同時設置 lifespanmaxIdle 參數
    • Infinispan 的內置過期機制會自動清除過期條目

圖片

  1. 遠程緩存事件同步
    • 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);

            });
        }
    }
  1. 重要限制
    • 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 的過期事件不會通知本地緩存,在以下情況下可能存在短暫的數據不一致

  1. 遠程條目已過期被清除
  2. 但本地緩存的過期時間還沒到
  3. 此時本地可能返回一個"幽靈會話"

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;
    }
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.