Spring Data Redis兩個問題:記憶體洩露和併發 - europace
我們最近將會話管理從 MongoDB 遷移到了 Redis。遷移本身是由我們使用 MongoDB 的經驗推動的,它不能特別好地處理高頻率更新和更頻繁的讀取。另一方面,Redis 被稱為經過驗證的儲存,可以準確處理該用例。
資料庫遷移並不總是那麼容易,因為我們需要學習其他服務的新模式、最佳實踐和怪癖。我們的目標是讓我們的 Java 服務層儘可能簡單,使其穩定且面向未來:會話管理當然是具有相當穩定功能集的服務之一,並且不會經常觸及其程式碼。因此,對於幾年後窺探它的任何人來說,保持它的簡單易懂是一個重要方面。
我們面臨兩個問題:
- Spring Data 實現二級索引的概念以及失效問題,這些導致Redis記憶體使用量不斷增長。
- 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方法更新需要改變的屬性。
相關文章
- 解決git記憶體洩露問題Git記憶體洩露
- 記憶體溢位和記憶體洩露記憶體溢位記憶體洩露
- Spring Boot heapdump洩露記憶體分析方法Spring Boot記憶體
- Salesforce使用Spring Data Redis記憶體洩漏的經驗教訓SalesforceSpringRedis記憶體
- react 記憶體洩露常見問題解決方案React記憶體洩露
- 使用Windbg快速分析應用記憶體洩露問題記憶體洩露
- SHBrowseForFolder 記憶體洩露記憶體洩露
- Linux記憶體洩露案例分析和記憶體管理分享Linux記憶體洩露
- JVM 常見線上問題 → CPU 100%、記憶體洩露 問題排查JVM記憶體洩露
- Lowmemorykiller記憶體洩露分析記憶體洩露
- 小題大做 | Handler記憶體洩露全面分析記憶體洩露
- Web 前端開發日誌(三):HTML 節點的記憶體洩露問題Web前端HTML記憶體洩露
- BufferedImage記憶體洩漏和溢位問題記憶體
- 一個 Vue 頁面的記憶體洩露分析Vue記憶體洩露
- 一個Vue頁面的記憶體洩露分析Vue記憶體洩露
- 簡單的記憶體“洩露”和“溢位”記憶體
- ThreadLocal記憶體洩漏問題thread記憶體
- 使用 mtrace 分析 “記憶體洩露”記憶體洩露
- 實戰Go記憶體洩露Go記憶體洩露
- Android 記憶體洩露詳解Android記憶體洩露
- 用 TDengine 3.0 碰到“記憶體洩露”?定位問題原因很關鍵記憶體洩露
- 前端面試題51----JS記憶體洩露前端面試題JS記憶體洩露
- ThreadLocal原始碼解讀和記憶體洩露分析thread原始碼記憶體洩露
- redisson記憶體洩漏問題排查Redis記憶體
- ArkTS 的記憶體快照與記憶體洩露除錯記憶體洩露除錯
- nodejs爬蟲記憶體洩露排查NodeJS爬蟲記憶體洩露
- Pprof定位Go程式記憶體洩露Go記憶體洩露
- Go坑:time.After可能導致的記憶體洩露問題分析Go記憶體洩露
- win10驅動記憶體洩露如何解決_win10記憶體洩露處理方法Win10記憶體洩露
- 線上問題排查例項分析|關於Redis記憶體洩漏Redis記憶體
- 線上問題排查例項分析|關於 Redis 記憶體洩漏Redis記憶體
- android Handler導致的記憶體洩露Android記憶體洩露
- netty 堆外記憶體洩露排查盛宴Netty記憶體洩露
- 乾貨分享:淺談記憶體洩露記憶體洩露
- 線上記憶體洩露定位--memleak工具記憶體洩露
- java中如何檢視記憶體洩露Java記憶體洩露
- 記一次 redis 事件註冊不當導致的記憶體洩露Redis事件記憶體洩露
- 從記憶體洩露、記憶體溢位和堆外記憶體,JVM優化引數配置引數記憶體洩露記憶體溢位JVM優化