前言
作為一名普通的 Java 程序開發者,日常開發中難免會遇到一些看似簡單但實際排查起來非常棘手的問題。在最近的一個項目中,我遇到了一個 Redis 緩存穿透的問題,導致系統在高并發下性能急劇下降,甚至出現服務響應超時的情況。這個問題雖然不是特別復雜,但排查過程讓我對緩存機制有了更深入的理解,也積累了一些實戰經驗。
本文將從問題現象、排查思路、代碼分析和最終的解決方案幾個方面來記錄這次真實的 bug 排查經歷,希望對同樣使用 Spring Boot 和 Redis 的開發者有所幫助。
問題現象
我們的系統是一個基于 Spring Boot 的微服務架構,其中有一個訂單查詢接口,為了提升性能,我們引入了 Redis 緩存來存儲用戶的歷史訂單數據。正常情況下,這個接口運行良好,但某天早上突然收到監控系統的告警,提示該接口的響應時間異常增加,且錯誤率上升。
初步觀察發現,當請求某些不存在的訂單 ID 時,接口返回了錯誤信息,并且這些請求直接繞過了 Redis,直接訪問了數據庫。這種現象明顯不符合預期,因為按照設計,即使緩存中沒有數據,也應該通過空值緩存(如設置 TTL 為 1 分鐘)來防止頻繁訪問數據庫。
問題分析
首先,我想到的是緩存穿透的可能性。緩存穿透是指查詢一個不存在的數據,由于緩存中沒有,而數據庫也沒有,導致每次請求都去訪問數據庫,從而造成數據庫壓力過大。
進一步查看日志發現,很多請求的 key 是無效的訂單 ID,比如 order:123456789
,而這些訂單 ID 并不存在于數據庫中。這說明確實存在緩存穿透的問題。
接下來,我檢查了 Redis 的配置和代碼邏輯,確認了以下幾點:
- Redis 緩存未設置過期時間:在某些場景下,如果緩存中沒有數據,我們沒有設置任何 TTL,導致緩存永遠不會失效,但也不會被填充。
- 未對非法請求進行過濾:對于一些惡意請求或非法參數,系統沒有做攔截,導致大量無意義的請求直接打到數據庫。
- 緩存策略不合理:在查詢不到數據時,沒有使用“空值緩存”來防止重復查詢。
排查步驟
步驟一:驗證緩存行為
我首先在本地啟動了 Spring Boot 應用,并模擬了多個請求,測試 Redis 是否真的能正確緩存數據。使用 Jedis 客戶端連接 Redis,執行 GET order:123456789
,發現返回結果是 nil
,即緩存中沒有該 key。
public Order getOrderById(String orderId) {String cacheKey = "order:" + orderId;Order order = redisTemplate.opsForValue().get(cacheKey);if (order != null) {return order;}// 如果緩存中沒有,則查詢數據庫order = orderRepository.findById(orderId);// 如果數據庫中也沒有,則不緩存if (order != null) {redisTemplate.opsForValue().set(cacheKey, order, 10, TimeUnit.MINUTES);}return order;
}
從這段代碼可以看出,只有當數據庫中有數據時,才會將數據寫入 Redis,否則不會有任何緩存操作。這就導致了緩存穿透問題。
步驟二:分析 Redis 數據結構
我使用 Redis 的命令行工具查看了相關的 key,發現大量的 order:*
類型的 key 都是空的,也就是說,這些請求根本沒有命中緩存。
步驟三:添加空值緩存邏輯
為了防止緩存穿透,我在代碼中加入了空值緩存邏輯。即當數據庫中沒有數據時,也向 Redis 緩存一個空值,設置較短的 TTL,避免頻繁訪問數據庫。
public Order getOrderById(String orderId) {String cacheKey = "order:" + orderId;Order order = redisTemplate.opsForValue().get(cacheKey);if (order != null) {return order;}// 查詢數據庫order = orderRepository.findById(orderId);// 如果數據庫中也沒有,則緩存一個空值if (order == null) {redisTemplate.opsForValue().set(cacheKey, "", 1, TimeUnit.MINUTES);} else {redisTemplate.opsForValue().set(cacheKey, order, 10, TimeUnit.MINUTES);}return order;
}
這樣,即使請求的是不存在的訂單 ID,Redis 中也會緩存一個空值,后續相同的請求就會直接從緩存中獲取,避免了對數據庫的頻繁訪問。
步驟四:增加請求過濾機制
為了進一步減少無效請求,我還增加了請求參數校驗邏輯,確保傳入的訂單 ID 符合業務規則。
public ResponseEntity<Order> getOrder(@PathVariable String orderId) {if (!isValidOrderId(orderId)) {return ResponseEntity.badRequest().body(null);}Order order = orderService.getOrderById(orderId);return ResponseEntity.ok(order);
}private boolean isValidOrderId(String orderId) {return orderId != null && orderId.matches("\d{8}");
}
這樣可以有效過濾掉一些非法請求,減少不必要的數據庫查詢。
總結
這次緩存穿透問題的排查過程讓我深刻認識到緩存設計的重要性。在實際開發中,不能只關注緩存的命中率,還要考慮如何處理緩存未命中的情況,尤其是針對非法請求的處理。
通過添加空值緩存和請求過濾機制,我們成功解決了緩存穿透問題,提升了系統的穩定性和性能。此外,我也意識到,在高并發環境下,合理的緩存策略和防御機制是保障系統健壯性的關鍵。
總的來說,這次 bug 排查不僅幫助我修復了一個實際問題,也讓我對 Redis 和 Spring Boot 的緩存機制有了更深的理解。