Salesforce使用Spring Data Redis記憶體洩漏的經驗教訓
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 Overflow和Spring 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 操作似乎是最佳的。
相關文章
- Spring Boot引起的“堆外記憶體洩漏”排查及經驗總結Spring Boot記憶體
- [譯] Data Binding 庫使用的經驗教訓
- vue使用中的記憶體洩漏Vue記憶體
- 分析記憶體洩漏和goroutine洩漏記憶體Go
- 記憶體洩漏的原因記憶體
- Handler的使用、記憶體洩漏和解決記憶體
- js記憶體洩漏JS記憶體
- Android記憶體洩漏Android記憶體
- Android 記憶體洩漏Android記憶體
- jvm 記憶體洩漏JVM記憶體
- Java記憶體洩漏Java記憶體
- 記憶體洩漏治理實戰:TDengine 研發團隊使用 Windbg 的經驗分享記憶體
- WebView引起的記憶體洩漏WebView記憶體
- Spring Data Redis兩個問題:記憶體洩露和併發 - europaceSpringRedis記憶體洩露
- 記憶體洩漏定位工具之 valgrind 使用記憶體
- valgrind 記憶體洩漏分析記憶體
- 記憶體的分配與釋放,記憶體洩漏記憶體
- 【記憶體洩漏和記憶體溢位】JavaScript之深入淺出理解記憶體洩漏和記憶體溢位記憶體溢位JavaScript
- VueJS SSR 後端繪製記憶體洩漏的相關解決經驗VueJS後端記憶體
- JVM——記憶體洩漏與記憶體溢位JVM記憶體溢位
- Android中的記憶體洩漏模式Android記憶體模式
- [譯] Swift 中的記憶體洩漏Swift記憶體
- Android中常見的記憶體洩漏Android記憶體
- Swift的ARC和記憶體洩漏Swift記憶體
- .NET 記憶體洩漏的爭議記憶體
- iOS檢測記憶體洩漏iOS記憶體
- Android記憶體洩漏場景Android記憶體
- ThreadLocal記憶體洩漏問題thread記憶體
- PHP 記憶體洩漏分析定位PHP記憶體
- 記憶體洩漏除錯工具記憶體除錯
- ThreadLocal真會記憶體洩漏?thread記憶體
- Perfdog 玩轉記憶體洩漏記憶體
- JavaScript之記憶體洩漏【四】JavaScript記憶體
- .Net程式記憶體洩漏解析記憶體
- 1.記憶體優化(一)記憶體洩漏記憶體優化
- JavaScript之記憶體溢位和記憶體洩漏JavaScript記憶體溢位
- 記一次使用 laravel-s 記憶體洩漏Laravel記憶體
- java記憶體溢位和記憶體洩漏的區別Java記憶體溢位