Salesforce使用Spring Data Redis記憶體洩漏的經驗教訓

banq發表於2021-10-28

Salesforce負責全渠道庫存服務的 Commerce Cloud 團隊使用Redis作為遠端快取來儲存適合快取的資料。遠端快取允許我們的多個程式獲得快取資料的同步和單一檢視。
使用模式是生命週期較短、快取命中率高並在例項之間共享的條目。為了與 Redis 互動,我們使用了Spring Data Redis(帶有Lettuce),它一直幫助我們在我們的例項之間共享我們的資料條目,並提供一個與 Redis 互動的低程式碼解決方案。
我們應用程式的後續部署顯示出一個奇怪的現象,Redis 上的記憶體消耗不斷增加,而且沒有減少的跡象。
隨著時間的推移,記憶體消耗幾乎呈線性增長,系統吞吐量增加,但隨著時間的推移沒有顯著的回收。這種情況達到了如此極端,以至於當記憶體增加並接近 100% 時,需要手動重新整理Redis 資料庫。以上似乎表明 Redis 條目發生了記憶體洩漏。
 

調查
第一個懷疑是 Redis 條目要麼沒有配置生存時間 (TTL),要麼配置了超出預期的 TTL 值。這表明我們用於速率限制的 Redis Repository 實體類沒有任何 TTL 配置:

@RedisHash("rate")
public class RateRedisEntry implements Serializable {
    @Id
    private String tenantEndpointByBlock; // A HTTP end point
    ...
}

// CRUD repository.
@Repository
public interface RateRepository extends CrudRepository<RateRedisEntry, String> {}

為了驗證確實未設定 TTL 的資料,與 Redis 伺服器例項建立連線,並使用 Redis 命令 TTL <name entry key> 檢查列出的某些條目的 TTL 值。

TTL "rate:block_00001" 
-1

如上所示,有些條目的 TTL 為 -1,表示未過期。雖然這顯然是手頭問題的嫌疑原因,並且修復它以明確設定 TTL 值以實踐良好的軟體衛生似乎是前進的方向,但由於相對較少的數量,有人懷疑這是問題的真正原因條目和記憶體使用。
新增 TTL 後,入口程式碼如下所示:

@RedisHash("rate")
public class RateRedisEntry implements Serializable {
    @Id
    private String tenantEndpointByBlock;

    @TimeToLive
    private Integer expirationSeconds;
    ...
}


關鍵問題聚焦到:
@RedisHash("rate")

為了檢查它,我們使用了以下 Redis 命令:

KEYS *
1) "rate"
2) "block_00001"

如您所見,有兩個條目。一個是帶有鍵名的條目“rate:block_00001”和一個帶有鍵“rate”。
額外條目“rate:block_00001”是意料之中的,但另一個條目令人驚訝地發現。隨著時間的推移監控系統還表明,與“rate” 金鑰相關的記憶體正在穩步增加。

>MEMORY USAGE "rate"
(integer) 153034
.
.
.
> MEMORY USAGE "rate"
(integer) 153876
.
.
> MEMORY USAGE "rate"
(integer) 163492


除了增加記憶體增長外,“rate”條目上的 TTL為 -1,如下所示:

>TTL "rate"
-1
>TYPE "rate"
set


它清楚地指出了最有可能的嫌疑,即其增長沒有隨著時間的推移而減少的跡象。
那麼,這個條目是什麼,為什麼它會增長?
 
Spring Data Redis 在 Redis 中為每個@RedisHash建立一個 SET 資料型別。SET 的條目充當 CRUD 儲存庫使用的許多 Spring Data Redis 操作的索引。
例如,SET 條目如下所示:

>SMEMBERS "rate"
1) "block_00001"
2) "block_00002"
3) "block_00003"
...


我們決定在 Stack OverflowSpring Data Redis 的 GitHub 頁面上釋出我們的情況,請求社群就如何最好地解決這個問題提供一些幫助——要麼阻止這個 SET 的增長,要麼如何阻止它的建立,如我們真的不需要任何其他索引功能。
在等待社群響應的同時,我們發現啟用Spring Data Redis 註釋EnableRedisRepositories的屬性實際上會使 Spring Data Redis 偵聽KEY事件並隨著時間的推移在收到KEY 過期事件時清理 Set 。

@EnableRedisRepositories( enableKeyspaceEvents 
    = EnableKeyspaceEvents.ON_STARTUP)

啟用此設定後,Spring Data Redis 將確保 Set 的記憶體不會繼續增加,並清除過期條目(有關詳細資訊,請參閱此堆疊溢位問題)。

"rate" 
"rate:block_00001" 
"rate:block_00001:phantom" <--除了基礎之外的幻影條目
......


建立幻像 Phantom Keys 以便 Spring Data Redis 可以將帶有相關資料的RedisKeyExpiredEvent傳播到 Spring Framework 的ApplicationEvent訂閱者。Phantom(或Shadow)條目比它正在隱藏的條目存活時間更長,因此當 Spring Data Redis 接收到主條目過期事件時,它將從 Shadow 條目中獲取值以傳播RedisKeyExpiredEvent,該事件將容納除了金鑰之外的過期域物件。
Spring Data Redis 中的以下程式碼接收幻像Phantom 條目過期並從索引中清除該專案:

static class MappingExpirationListener extends KeyExpirationEventMessageListener {

 private final RedisOperations<?, ?> ops;
 ...
 @Override
 public void onMessage(Message message, @Nullable byte[] pattern) {
    ...
    RedisKeyExpiredEvent event = new RedisKeyExpiredEvent(channel, key, value);
 
    ops.execute((RedisCallback<Void>) connection -> {
        // Removes entry from the Set
        connection.sRem(converter.getConversionService()
            .convert(event.getKeyspace(), byte[].class), event.getId());
        ...
    });
 }
..
}

這種方法的主要問題是 Spring Data Redis 必須使用過期的事件流並執行清理而產生的額外處理開銷。還應該注意的是,由於 Redis Pub/Sub 訊息不是永續性的,如果條目在應用程式關閉時過期,則不會處理過期事件,並且這些條目不會從 SET 中清除。
有效地使用 CRUDRepository 意味著為每個條目建立更多的影子/支援條目,從而導致更多的 Redis 伺服器資料庫記憶體消耗。如果條目過期時不需要 Spring Boot 應用程式中的過期詳細資訊,您可以透過對EnableRedisRespositories註釋進行以下更改來禁用 Phantom 條目的生成。

@EnableRedisRepositories(.. shadowCopy = ShadowCopy.OFF )


上述的最終效果是 Spring Data Redis 將不再建立影子副本,但仍會訂閱 Keyspace 事件並清除條目的 SET。傳播的 Spring Boot 應用程式事件將只包含 KEY 而不是完整的域物件。
 
有了以上關於效能和額外記憶體儲存的所有發現,我們認為對於我們正在處理的用例,Redis CRUDRepository 和 KEY Space 事件增加的額外開銷對我們沒有吸引力。出於這個原因,我們決定探索一種更精簡的方法。
我們製作了一個概念驗證應用程式來測試使用 CrudRepository 或直接使用RedisTemplate公開 Redis 伺服器操作的類之間的響應時間差異。透過測試我們觀察RedisTemplate到更有利。
透過連續執行 GET 操作五分鐘並取完成操作所用時間的平均值來進行比較。我們看到的是,幾乎所有使用 CRUDRepository 的 GET 操作都在毫秒範圍內,而沒有 CRUDRepository 的概念驗證主要在納秒範圍內。我們注意到的另一件事是 CRUDRepository 在執行操作時也有更多上升的趨勢,增加了執行其操作的延遲。
 

解決方案
根據研究,我們的前進方向如下:

  • Spring Data Redis CrudRepository:啟用Redis Repository的key space事件,啟用Spring Data Redis清除過期條目的Set型別。這種方法的好處是它是一種低程式碼解決方案,透過在註解上設定一個值,讓 Spring Data Redis 訂閱 KEY 過期事件並在後臺進行清理。不利的一面是,對於我們的案例,我們從未使用過的東西會額外使用記憶體,即 SET 索引和 Spring Data Redis 訂閱 Keyspace 事件並執行清理所產生的處理開銷。
  • 使用RedisTemplate自定義Repository:在不使用CRUD Repository的情況下處理Redis I/O操作,使用RedisTemplate,構建基本需要的操作。好處是它導致只建立我們在 Redis 中需要的資料,即雜湊條目,而不是其他工件,如 SET 索引。我們避免了 Spring Data Redis 訂閱和處理 Keyspace 事件以進行清理的處理開銷。不利的一面是,我們不再利用 Spring Data Redis 的 CRUD 儲存庫的低程式碼魔法及其在幕後所做的工作,而是使用程式碼來完成所有工作。

在考慮了我們所有的發現之後,尤其是圍繞概念驗證應用程式和我們的系統的指標,以及我們對團隊的需求(更多的是關於快速響應時間和低記憶體使用率)之後,我們採用的方向不是使用CrudRepository,而是使用RedisTemplate與 Redis 伺服器互動。由於程式碼更透明且功能更直接,因此它提供了一種解決方案,其中包含的未知行為要少得多。
我們的程式碼最終看起來像這樣:

public class RateRedisEntry implements Serializable {
   private String tenantEndpointByBlock;
   private Integer expirationSeconds;
    ...
}
@Bean
public RedisTemplate<String, RateRedisEntry> redisTemplate() {
   RedisTemplate<String, RateRedisEntry> template = new RedisTemplate<>();
        
   template.setConnectionFactory(getLettuceConnectionFactory());
        
   return template;
}
public class RedisCachedRateRepositoryImpl implements RedisCachedRateRepository {

    private final RedisTemplate<String, RateRedisEntry> redisTemplate;

    public RedisCachedRateRepositoryImpl(RedisTemplate<String, RateRedisEntry> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }
    
    public Optional<RateRedisEntry> find(String key, Tags tags) {
        return Optional.ofNullable(this.redisTemplate.opsForValue()
        .get(composeHeader(key)));
    }
    
    public void put(final @NonNull RateRedisEntry rateEntry, Tags tags) {
        this.redisTemplate.opsForValue().set(composeHeader(rateEntry.getTenantEndpointByBlock()),
            rateEntry, Duration.ofSeconds(rateEntry.getRedisTTLInSeconds()));
    }

    private String composeHeader(String key) {
        return String.format("rate:%s", key);
    }
}

透過以這種方式使用它,我們直接處理條目,因此不存在儲存不需要的索引或結構的風險。
部署我們的解決方案後,我們的記憶體使用量完全下降並保持穩定,在條目的 TTL 達到 0 後,任何峰值都會下降。

 

結論
Spring Data Redis Crud Operations 的魔力是透過建立額外的資料結構(如用於索引的 SET)來實現的。當專案過期而不啟用Spring Data Redis 以偵聽 KEY 空間事件時,不會清除這些額外的資料結構。對於條目非常長或條目集易於處理且有限的快取模式,帶有 CrudRepositories 的 Spring Data Redis 為 Redis 的 CRUD 操作提供了低程式碼解決方案。
但是,對於資料由多個程式快取和共享的快取模式,以及條目具有可以快取它們的較小視窗的快取模式,避免偵聽 KEY 事件並使用RedisTemplate為所需的 CRUD 操作執行 Redis 操作似乎是最佳的。


 

相關文章