Redis 配置

配置好應用後,您可能希望開始自定義一些設定。

使用 JSON 序列化會話

預設情況下,Spring Session 使用 Java 序列化來序列化會話屬性。有時這可能會有問題,特別是當您有多個應用程式使用相同的 Redis 例項,但使用相同類的不同版本時。您可以提供一個 RedisSerializer bean 來自定義會話如何序列化到 Redis 中。Spring Data Redis 提供了 GenericJackson2JsonRedisSerializer,它使用 Jackson 的 ObjectMapper 來序列化和反序列化物件。

配置 RedisSerializer
@Configuration
public class SessionConfig implements BeanClassLoaderAware {

	private ClassLoader loader;

	/**
	 * Note that the bean name for this bean is intentionally
	 * {@code springSessionDefaultRedisSerializer}. It must be named this way to override
	 * the default {@link RedisSerializer} used by Spring Session.
	 */
	@Bean
	public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
		return new GenericJackson2JsonRedisSerializer(objectMapper());
	}

	/**
	 * Customized {@link ObjectMapper} to add mix-in for class that doesn't have default
	 * constructors
	 * @return the {@link ObjectMapper} to use
	 */
	private ObjectMapper objectMapper() {
		ObjectMapper mapper = new ObjectMapper();
		mapper.registerModules(SecurityJackson2Modules.getModules(this.loader));
		return mapper;
	}

	/*
	 * @see
	 * org.springframework.beans.factory.BeanClassLoaderAware#setBeanClassLoader(java.lang
	 * .ClassLoader)
	 */
	@Override
	public void setBeanClassLoader(ClassLoader classLoader) {
		this.loader = classLoader;
	}

}

上面的程式碼片段使用了 Spring Security,因此我們建立了一個使用 Spring Security 的 Jackson 模組的自定義 ObjectMapper。如果您不需要 Spring Security Jackson 模組,可以注入您應用的 ObjectMapper bean 並像這樣使用它

@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer(ObjectMapper objectMapper) {
    return new GenericJackson2JsonRedisSerializer(objectMapper);
}

指定不同的名稱空間

多個應用程式使用同一個 Redis 例項是很常見的。因此,Spring Session 使用一個 namespace(預設為 spring:session)來在需要時分離會話資料。

使用 Spring Boot 屬性

您可以透過設定 spring.session.redis.namespace 屬性來指定它。

application.properties
spring.session.redis.namespace=spring:session:myapplication
application.yml
spring:
  session:
    redis:
      namespace: "spring:session:myapplication"

使用註解屬性

您可以透過在 @EnableRedisHttpSession@EnableRedisIndexedHttpSession@EnableRedisWebSession 註解中設定 redisNamespace 屬性來指定 namespace

@EnableRedisHttpSession
@Configuration
@EnableRedisHttpSession(redisNamespace = "spring:session:myapplication")
public class SessionConfig {
    // ...
}
@EnableRedisIndexedHttpSession
@Configuration
@EnableRedisIndexedHttpSession(redisNamespace = "spring:session:myapplication")
public class SessionConfig {
    // ...
}
@EnableRedisWebSession
@Configuration
@EnableRedisWebSession(redisNamespace = "spring:session:myapplication")
public class SessionConfig {
    // ...
}

RedisSessionRepositoryRedisIndexedSessionRepository 之間選擇

使用 Spring Session Redis 時,您可能需要在 RedisSessionRepositoryRedisIndexedSessionRepository 之間進行選擇。它們都是 SessionRepository 介面的實現,用於在 Redis 中儲存會話資料。然而,它們在會話索引和查詢的處理方式上有所不同。

  • RedisSessionRepository: RedisSessionRepository 是一個基本實現,它將會話資料儲存在 Redis 中,不進行任何額外索引。它使用簡單的鍵值結構來儲存會話屬性。每個會話都會被分配一個唯一的會話 ID,並且會話資料儲存在該 ID 關聯的 Redis 鍵下。當需要檢索會話時,儲存庫使用會話 ID 查詢 Redis 來獲取關聯的會話資料。由於沒有索引,根據會話 ID 以外的屬性或條件查詢會話可能會效率低下。

  • RedisIndexedSessionRepository: RedisIndexedSessionRepository 是一個擴充套件實現,它為儲存在 Redis 中的會話提供索引能力。它在 Redis 中引入了額外的資料結構,以高效地根據屬性或條件查詢會話。除了 RedisSessionRepository 使用的鍵值結構外,它還維護額外的索引以實現快速查詢。例如,它可以根據使用者 ID 或上次訪問時間等會話屬性建立索引。這些索引允許根據特定條件高效地查詢會話,提高效能並啟用高階會話管理功能。此外,RedisIndexedSessionRepository 還支援會話過期和刪除。

在 Redis Cluster 中使用 RedisIndexedSessionRepository 時,您必須注意它只訂閱叢集中一個隨機 Redis 節點的事件,如果事件發生在不同的節點上,這可能導致一些會話索引無法被清理。

配置 RedisSessionRepository

使用 Spring Boot 屬性

如果您使用 Spring Boot,RedisSessionRepository 是預設實現。但是,如果您想明確指定它,可以在應用程式中設定以下屬性

application.properties
spring.session.redis.repository-type=default
application.yml
spring:
  session:
    redis:
      repository-type: default

使用註解

您可以使用 @EnableRedisHttpSession 註解配置 RedisSessionRepository

@Configuration
@EnableRedisHttpSession
public class SessionConfig {
    // ...
}

配置 RedisIndexedSessionRepository

使用 Spring Boot 屬性

您可以透過在應用程式中設定以下屬性來配置 RedisIndexedSessionRepository

application.properties
spring.session.redis.repository-type=indexed
application.yml
spring:
  session:
    redis:
      repository-type: indexed

使用註解

您可以使用 @EnableRedisIndexedHttpSession 註解配置 RedisIndexedSessionRepository

@Configuration
@EnableRedisIndexedHttpSession
public class SessionConfig {
    // ...
}

監聽會話事件

通常情況下,響應會話事件很有價值,例如,您可能想根據會話生命週期進行某種處理。為此,您必須使用索引儲存庫。如果您不瞭解索引儲存庫和預設儲存庫之間的區別,可以檢視本節

配置好索引儲存庫後,您現在可以開始監聽 SessionCreatedEventSessionDeletedEventSessionDestroyedEventSessionExpiredEvent 事件。Spring 中有幾種監聽應用程式事件的方法,我們將使用 @EventListener 註解。

@Component
public class SessionEventListener {

    @EventListener
    public void processSessionCreatedEvent(SessionCreatedEvent event) {
        // do the necessary work
    }

    @EventListener
    public void processSessionDeletedEvent(SessionDeletedEvent event) {
        // do the necessary work
    }

    @EventListener
    public void processSessionDestroyedEvent(SessionDestroyedEvent event) {
        // do the necessary work
    }

    @EventListener
    public void processSessionExpiredEvent(SessionExpiredEvent event) {
        // do the necessary work
    }

}

查詢特定使用者的所有會話

透過檢索特定使用者的所有會話,您可以跟蹤使用者在不同裝置或瀏覽器上的活動會話。例如,您可以將此資訊用於會話管理目的,例如允許使用者使特定會話失效或登出,或根據使用者的會話活動執行操作。

為此,首先您必須使用索引儲存庫,然後您可以注入 FindByIndexNameSessionRepository 介面,如下所示

@Autowired
public FindByIndexNameSessionRepository<? extends Session> sessions;

public Collection<? extends Session> getSessions(Principal principal) {
    Collection<? extends Session> usersSessions = this.sessions.findByPrincipalName(principal.getName()).values();
    return usersSessions;
}

public void removeSession(Principal principal, String sessionIdToDelete) {
    Set<String> usersSessionIds = this.sessions.findByPrincipalName(principal.getName()).keySet();
    if (usersSessionIds.contains(sessionIdToDelete)) {
        this.sessions.deleteById(sessionIdToDelete);
    }
}

在上面的示例中,您可以使用 getSessions 方法查詢特定使用者的所有會話,並使用 removeSession 方法刪除特定使用者的會話。

配置 Redis 會話對映器

Spring Session Redis 從 Redis 中檢索會話資訊,並將其儲存在 Map<String, Object> 中。此 Map 需要經過對映過程才能轉換為 MapSession 物件,然後該物件在 RedisSession 中使用。

用於此目的的預設對映器稱為 RedisSessionMapper。如果會話 Map 不包含構建會話所需的最小鍵(例如 creationTime),此對映器將丟擲異常。缺少必需鍵的一種可能情況是,當會話鍵在儲存過程進行中時被併發刪除,通常是由於過期。發生這種情況是因為使用了 HSET 命令來設定鍵內的欄位,如果鍵不存在,此命令將建立它。

如果您想自定義對映過程,可以建立自己的 BiFunction<String, Map<String, Object>, MapSession> 實現並將其設定到會話儲存庫中。以下示例展示瞭如何將對映過程委託給預設對映器,但如果丟擲異常,則從 Redis 中刪除會話

  • RedisSessionRepository

  • RedisIndexedSessionRepository

  • ReactiveRedisSessionRepository

@Configuration
@EnableRedisHttpSession
public class SessionConfig {

    @Bean
    SessionRepositoryCustomizer<RedisSessionRepository> redisSessionRepositoryCustomizer() {
        return (redisSessionRepository) -> redisSessionRepository
                .setRedisSessionMapper(new SafeRedisSessionMapper(redisSessionRepository));
    }

    static class SafeRedisSessionMapper implements BiFunction<String, Map<String, Object>, MapSession> {

        private final RedisSessionMapper delegate = new RedisSessionMapper();

        private final RedisSessionRepository sessionRepository;

        SafeRedisSessionMapper(RedisSessionRepository sessionRepository) {
            this.sessionRepository = sessionRepository;
        }

        @Override
        public MapSession apply(String sessionId, Map<String, Object> map) {
            try {
                return this.delegate.apply(sessionId, map);
            }
            catch (IllegalStateException ex) {
                this.sessionRepository.deleteById(sessionId);
                return null;
            }
        }

    }

}
@Configuration
@EnableRedisIndexedHttpSession
public class SessionConfig {

    @Bean
    SessionRepositoryCustomizer<RedisIndexedSessionRepository> redisSessionRepositoryCustomizer() {
        return (redisSessionRepository) -> redisSessionRepository.setRedisSessionMapper(
                new SafeRedisSessionMapper(redisSessionRepository.getSessionRedisOperations()));
    }

    static class SafeRedisSessionMapper implements BiFunction<String, Map<String, Object>, MapSession> {

        private final RedisSessionMapper delegate = new RedisSessionMapper();

        private final RedisOperations<String, Object> redisOperations;

        SafeRedisSessionMapper(RedisOperations<String, Object> redisOperations) {
            this.redisOperations = redisOperations;
        }

        @Override
        public MapSession apply(String sessionId, Map<String, Object> map) {
            try {
                return this.delegate.apply(sessionId, map);
            }
            catch (IllegalStateException ex) {
                // if you use a different redis namespace, change the key accordingly
                this.redisOperations.delete("spring:session:sessions:" + sessionId); // we do not invoke RedisIndexedSessionRepository#deleteById to avoid an infinite loop because the method also invokes this mapper
                return null;
            }
        }

    }

}
@Configuration
@EnableRedisWebSession
public class SessionConfig {

    @Bean
    ReactiveSessionRepositoryCustomizer<ReactiveRedisSessionRepository> redisSessionRepositoryCustomizer() {
        return (redisSessionRepository) -> redisSessionRepository
                .setRedisSessionMapper(new SafeRedisSessionMapper(redisSessionRepository));
    }

    static class SafeRedisSessionMapper implements BiFunction<String, Map<String, Object>, Mono<MapSession>> {

        private final RedisSessionMapper delegate = new RedisSessionMapper();

        private final ReactiveRedisSessionRepository sessionRepository;

        SafeRedisSessionMapper(ReactiveRedisSessionRepository sessionRepository) {
            this.sessionRepository = sessionRepository;
        }

        @Override
        public Mono<MapSession> apply(String sessionId, Map<String, Object> map) {
            return Mono.fromSupplier(() -> this.delegate.apply(sessionId, map))
                .onErrorResume(IllegalStateException.class,
                    (ex) -> this.sessionRepository.deleteById(sessionId).then(Mono.empty()));
        }

    }

}

自定義會話過期儲存

由於 Redis 的特性,如果鍵沒有被訪問,無法保證何時觸發過期事件。更多詳細資訊,請參閱 Redis 關於鍵過期的文件。

為了減輕過期事件的不確定性,會話也與其預期的過期時間一起儲存。這確保了每個鍵可以在預期過期時被訪問。RedisSessionExpirationStore 介面定義了跟蹤會話及其過期時間的通用操作,並提供了一種清理過期會話的策略。

預設情況下,每個會話過期都會跟蹤到最近一分鐘。這允許後臺任務訪問可能已過期的會話,以確保 Redis 過期事件以更確定的方式觸發。

例如

SADD spring:session:expirations:1439245080000 expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe
EXPIRE spring:session:expirations:1439245080000 2100

然後,後臺任務將使用這些對映顯式請求每個會話的過期鍵。透過訪問鍵而不是直接刪除它,我們可以確保 Redis 僅在 TTL 過期時刪除鍵。

透過自定義會話過期儲存,您可以根據需要更有效地管理會話過期。為此,您應該提供一個 RedisSessionExpirationStore 型別的 bean,它將被 Spring Session Data Redis 配置識別

  • SessionConfig

import org.springframework.session.data.redis.SortedSetRedisSessionExpirationStore;

@Configuration
@EnableRedisIndexedHttpSession
public class SessionConfig {

    @Bean
    public RedisSessionExpirationStore redisSessionExpirationStore(RedisConnectionFactory redisConnectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setKeySerializer(RedisSerializer.string());
        redisTemplate.setHashKeySerializer(RedisSerializer.string());
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        redisTemplate.afterPropertiesSet();
        return new SortedSetRedisSessionExpirationStore(redisTemplate, RedisIndexedSessionRepository.DEFAULT_NAMESPACE);
    }

}

在上面的程式碼中,使用了 SortedSetRedisSessionExpirationStore 實現,它使用有序集合(Sorted Set)來儲存會話 ID,並將其過期時間作為分數。

我們不會顯式刪除鍵,因為在某些情況下可能存在競爭條件,導致錯誤地將未過期的鍵識別為已過期。除了使用分散式鎖(這會嚴重影響效能)之外,沒有辦法確保過期對映的一致性。透過簡單地訪問鍵,我們確保只有當該鍵的 TTL 過期時才刪除它。但是,對於您的實現,您可以選擇最適合您的策略。