Spring Data Redis兩個問題:記憶體洩露和併發 - europace

banq發表於2021-12-07

我們最近將會話管理從 MongoDB 遷移到了 Redis。遷移本身是由我們使用 MongoDB 的經驗推動的,它不能特別好地處理高頻率更新和更頻繁的讀取。另一方面,Redis 被稱為經過驗證的儲存,可以準確處理該用例。

資料庫遷移並不總是那麼容易,因為我們需要學習其他服務的新模式、最佳實踐和怪癖。我們的目標是讓我們的 Java 服務層儘可能簡單,使其穩定且面向未來:會話管理當然是具有相當穩定功能集的服務之一,並且不會經常觸及其程式碼。因此,對於幾年後窺探它的任何人來說,保持它的簡單易懂是一個重要方面。

我們面臨兩個問題:

  1. Spring Data 實現二級索引的概念以及失效問題,這些導致Redis記憶體使用量不斷增長。 
  2. Redis 的原子性範圍和 Spring Data 的更新機制

本文總結了我們在使用 Spring Data 作為持久層的瘦 Java 服務中採用 Redis 的經驗。

 

帶有二級索引和 EXPIRE/TTL 的 Spring Data Redis

在 Redis 中採用 Spring Data可直接開始:您需要的只是 Gradle 或 Maven 構建的依賴項以及@EnableRedisRepositoriesSpring Boot 應用程式中的註釋。Spring Boot 的大多數預設設定都是有意義的,並且可以讓您非常順利地執行 Redis 例項。

但是會遭遇:Redis記憶體使用量不斷增長的問題,下面看看這個認識過程:

不需要通用儲存庫的實際實現,因為 Spring Data 允許您interface在執行時宣告一個簡單的通向通用例項。我們的儲存庫是這樣開始的:

import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface SessionDataCrudRepository extends CrudRepository<SessionData, String> {
}

我們由該儲存庫管理的實體也開始變得儘可能簡單:

import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.TimeToLive;

import java.util.concurrent.TimeUnit;

@RedisHash("SessionData")
public class SessionData {

  @Id
  private String sessionId;
  @TimeToLive(unit = TimeUnit.MINUTES)
  private Long ttl;

  ...
}

您會注意到我們選擇對ttl屬性建模,該屬性被@TimeToLive轉換為EXPIRE實體。我們不想手動跟蹤過期會話,但希望 Redis 透明地刪除過期會話。該ttl會定期重新整理使用者活動期間,如果手工刪除,可能會誤被登出。

當使用者實際按下注銷按鈕時會發生什麼,或者我們如何禁用使用者帳戶並使正在執行的會話無效?簡單:我們也有一個userId作為會話資料SessionData的一部分,並且可以執行以userId查詢查詢每個會話。上述類所需的更改如下所示:

SessionDataCrudRepository:

@Repository
public interface SessionDataCrudRepository extends CrudRepository<SessionData, String> {

   List<SessionData> findByUserId(String userId);
}

SessionData:

+import org.springframework.data.redis.core.index.Indexed;

@RedisHash("SessionData")
public class SessionData {

    @Id
    private String sessionId;
    @TimeToLive(unit = TimeUnit.MINUTES)
    private Long ttl;

+    @Indexed
+    private String userId;

    ...
}

@Indexed註解在 Spring Data 中觸發了一個特殊的行為:該註解實際上告訴 Spring Data在實體上建立和維護另一個索引,以便我們可以根據給定userId查詢SessionData. 

但是,二級索引和實體自動到期的組合使設定變得更加複雜。當引用的實體被刪除時,Redis 不會自動更新二級索引,因此 Spring Data 需要處理這種情況。

然而,Spring Data 不會經常查詢 Redis 的過期實體(鍵),這就是為什麼 Spring Data 依賴於R Redis Keyspace Notifications for expiring keys所謂的 Phantom Copies(幻影副本)來失效過期鍵:

當到期時間設定為正值時,將執行相應的 EXPIRE 命令。除了保留原始副本外,Redis 中還保留了一個幻影副本,並設定為在原始副本之後 5 分鐘過期。這樣做是為了使 Repository 支援釋出 RedisKeyExpiredEvent,只要一個鍵過期expiring key,就會在 Spring 的 ApplicationEventPublisher 中儲存過期的值,即使原始值已經被刪除。

下一段有一個小細節需要注意:

預設情況下,初始化應用程式時禁用expiring keys偵聽器。可以在 @EnableRedisRepositories 或 RedisKeyValueAdapter 中調整啟動模式,以使用應用程式或在第一次插入具有 TTL 的實體時啟動偵聽器。有關可能的值,請參閱 EnableKeyspaceEvents。

遺憾的是,當時我們還沒有閱讀到這點。這就是為什麼我們體驗到啟用EXPIRE禁用的expiring keys偵聽器以及不斷增長的二級索引的效果的原因。長話短說:我們觀察到越來越多的鍵和不斷增長的記憶體使用量 - 直到達到 Redis 的記憶體限制。

檢查 Redis 鍵可以很明顯地找到配置錯誤的位置,最終啟用鍵空間事件的註釋@EnableRedisRepositories使我們修復了記憶體洩露。

我們還禁用了 的自動伺服器配置notify-keyspace-events property,因為我們在伺服器端啟用了該設定:

import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;

import static org.springframework.data.redis.core.RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP;

@EnableRedisRepositories(enableKeyspaceEvents = ON_STARTUP, keyspaceNotificationsConfigParameter = "")
@SpringBootApplication
public class SessionManagementApplication {

   ...
}

我們還必須手動清理陳舊的資料,所以我們還要提一下,在處理大型資料集時,您應該總是更選擇SCAN而不是KEYS。Netflix 的nf-data-explorer可能會有所幫助,如果您不喜歡使用本機redis-cli.

 

併發讀取和寫入期間缺少實體

隨著記憶體使用量不斷增長的問題得到解決,我們最終將新服務作為我們會話的主要來源。

當請求擊中我們的安全鏈時,我們總是驗證使用者的會話是否有效。這些驗證是在會話管理中的簡單查詢sessionId。通常,404 NOT FOUND會話管理的狀態指示sessionId無效(未知)或會話已過期(並被 Redis 刪除)。

除了使用新 API 的應用程式中的一些相關更改外,我們還觀察到了另一種奇怪的行為:無法找到某些會話,儘管我們 100% 確定會話應該仍然有效(已知且未過期)。在會話查詢失敗後,大多數重試都成功了,所以我們知道資料沒有丟失,只是無法找到。

我們無法主動重現錯誤行為,收集日誌、指標和跟蹤也沒有起到作用。在此過程中,我們新增了快取和其他解決方法,並進行了一些更改以改進整體行為,但我們實際上並未解決該問題。

如果您仔細閱讀本文的第一部分,您可能還記得有關我們重新整理ttl. 我們不僅重新整理ttl,而且還重新整理作為SessionData的一部分lastResponse時間戳:

@RedisHash("SessionData")
public class SessionData {

  @Id
  private String sessionId;
  @TimeToLive(unit = TimeUnit.MINUTES)
  private Long ttl;
  private LocalDateTime lastResponse;

  @Indexed
  private String userId;

  ...
}

因此,讓我們更詳細地瞭解有關會話管理的請求處理。使用者傳送一個請求,以及一個sessionId,表明他們已登入。我們使用它執行查詢sessionId以驗證使用者的會話。如果會話被認為是有效的,則應用程式可以繼續執行請求的操作。應用程式處理完請求後,安全鏈會定期更新會話,重置ttl和寫入當前lastResponse時間戳。通常,使用者執行多個請求——可能不是真正的人,而是在瀏覽器中執行的前端應用程式。該前端應用程式並不真正關心它傳送新請求的頻率,因此我們可以假設多個請求可能同時到達我們的後端。

正在驗證多個請求。多個請求觸發會話重新整理以及SessionData的寫操作.

我們仍然使用 Spring DataCrudRepository來讀取和更新會話,使用以下程式碼:

讀:
SessionDataCrudRepository repository;

public Optional<SessionDto> getSession(String sessionId) {
  Optional<SessionData> session = repository.findById(sessionId);
  ...
  return session;
}

更新:
SessionDataCrudRepository repository;

public Optional<Long> refreshSessionTtl(String sessionId) {
  Optional<SessionData> session = repository.findById(sessionId);

  AtomicLong updatedTtl = new AtomicLong();
  session.ifPresent(data -> {
    data.setLastResponse(LocalDateTime.now(clock).truncatedTo(SECONDS));
    data.setTtl(SESSION_TIMEOUT.toMinutes());

    SessionData saved = repository.save(data);
    updatedTtl.set(saved.getTtl());
  }
  return Optional.of(updatedTtl.longValue());
}

有時,repository.findById(...)沒有產生任何東西,所以我們專注於那部分。不過,問題是由repository.save(...)電話引發的。經過幾周的谷歌搜尋並盯著日誌和跟蹤,我們發現了refreshSessionTtl和getSession呼叫之間的相關性。

網際網路上的許多文章已經訓練我們將 Redis 視為單執行緒服務,按順序執行每個請求。谷歌搜尋“spring data redis concurrent writes”,我們找到了stackoverflow和spring-projects/spring-data-redis/issues/1826中的問題,在那裡描述甚至解釋了我們的問題 - 以及修復.

長話短說:Spring Data 將更新實現為DEL和HMSET兩個步驟時,沒有任何事務保證。換句話說:通過 CrudRepositories 更新實體不提供原子性。我們的HGETALL請求有時恰好發生在DEL和之間HMSET,導致空結果,或者有時有結果,但結果為負ttl

我們的問題現在可以通過整合測試重現並使用PartialUpdate

所以上面的實現改為:

KeyValueOperations keyValueOperations;

public Optional<Long> refreshSessionTtl(String sessionId) {
  Optional<SessionData> session = repository.findById(sessionId);

  AtomicLong updatedTtl = new AtomicLong(-3);
  session.ifPresent(data -> {
    PartialUpdate<SessionData> update = new PartialUpdate<>(data.getSessionId(), SessionData.class)
        .refreshTtl(true)
        .set("ttl", SESSION_TIMEOUT.toMinutes())
        .set("lastResponse", LocalDateTime.now(clock).truncatedTo(SECONDS));
    keyValueOperations.update(update);
    Optional<SessionData> saved = repository.findById(data.getSessionId());
    if (saved.isPresent()) {
      updatedTtl.set(saved.get().getTtl());
    }
  }
  return Optional.of(updatedTtl.longValue());
}

概括

過期鍵、二級索引和將所有魔法委託給 Spring Data Redis 的組合需要正確配置鍵空間事件偵聽器。否則,由於幻影副本,您使用的記憶體會隨著時間的推移而增長。考慮@EnableRedisRepositories(enableKeyspaceEvents = ON_STARTUP)在您的應用中使用類似的配置。

在併發讀取和更新的環境,提防Spring Data的CrudRepository工具的更新的過程分為兩個步驟DEL和HMSET。如果您觀察到零星丟失的鍵或結果為負值TTL,則您可能遇到了併發問題。檢查您的寫入操作並考慮使用 PartialUpdate和 Spring Data 的RedisKeyValueTemplateupdate方法更新需要改變的屬性

 

相關文章