哎,最近小黑哥又雙叒叕犯事了。
事情是這樣的,前一段時間小黑哥公司生產交易偶發報錯,一番排查下來最終原因是因為 Redis 命令執行超時。
可是令人不解的是,生產交易僅僅使用 Redis set 這個簡單命令,這個命令講道理是不可能會執行這麼慢。
那到底是什麼導致這個問題那?
為了找出這個問題,我們檢視分析了一下 Redis 最近的慢日誌,最終發現耗時比較多命令為 keys XX*
看到這個命令操作的鍵的字首,小黑哥才發現這是自己負責的應用。可是小黑哥排查一下,雖然自己的程式碼並沒有主動去使用 keys
命令,但是底層使用框架卻在間接使用,於是就有了今天這個問題。
問題原因
小黑哥負責的應用是一個管理後臺應用,許可權管理使用 Shiro 框架,由於存在多個節點,需要使用分散式 Session,於是這裡使用 Redis 儲存 Session 資訊。
畫外音:不知道分散式 Session ,可以看看小黑哥之前寫的 一口氣說出 4 種分散式一致性 Session 實現方式,面試槓槓的~
由於 Shiro 並沒有直接提供 Redis 儲存 Session 元件,小黑哥不得不使用 Github 一個開源元件 shiro-redis。
由於 Shiro 框架需要定期驗證 Session 是否有效,於是 Shiro 底層將會呼叫 SessionDAO#getActiveSessions
獲取所有的 Session 資訊。
而 shiro-redis
正好繼承 SessionDAO
這個介面,底層使用用 keys
命令查詢 Redis 所有儲存的 Session
key。
public Set<byte[]> keys(byte[] pattern){
checkAndInit();
Set<byte[]> keys = null;
Jedis jedis = jedisPool.getResource();
try{
keys = jedis.keys(pattern);
}finally{
jedis.close();
}
return keys;
}
找到問題原因,解決辦法就比較簡單了,github 上查詢到解決方案,升級一下 shiro-redis
到最新版本。
在這個版本,shiro-redis
採用 scan
命令代替 keys
,從而修復這個問題。
public Set<byte[]> keys(byte[] pattern) {
Set<byte[]> keys = null;
Jedis jedis = jedisPool.getResource();
try{
keys = new HashSet<byte[]>();
ScanParams params = new ScanParams();
params.count(count);
params.match(pattern);
byte[] cursor = ScanParams.SCAN_POINTER_START_BINARY;
ScanResult<byte[]> scanResult;
do{
scanResult = jedis.scan(cursor,params);
keys.addAll(scanResult.getResult());
cursor = scanResult.getCursorAsBytes();
}while(scanResult.getStringCursor().compareTo(ScanParams.SCAN_POINTER_START) > 0);
}finally{
jedis.close();
}
return keys;
}
雖然問題成功解決了,但是小黑哥心裡還是有點不解。
為什麼 keys
指令會導致其他命令執行變慢?
為什麼 Keys
指令查詢會這麼慢?
為什麼 Scan
指令就沒有問題?
Redis 執行命令的原理
首先我們來看第一個問題,為什麼 keys
指令會導致其他命令執行變慢?
回答這個問題,我們首先看下 Redis 客戶端執行一條命令的情況:
站在客戶端的視角,執行一條命令分為三步:
- 傳送命令
- 執行命令
- 返回結果
但是這僅僅客戶端自己以為的過程,但是實際上同一時刻,可能存在很多客戶端傳送命令給 Redis,而 Redis 我們都知道它採用的是單執行緒模型。
為了處理同一時刻所有的客戶端的請求命令,Redis 內部採用了佇列的方式,排隊執行。
於是客戶端執行一條命令實際需要四步:
- 傳送命令
- 命令排隊
- 執行命令
- 返回結果
由於 Redis 單執行緒執行命令,只能順序從佇列取出任務開始執行。
只要 3 這個過程執行命令速度過慢,佇列其他任務不得不進行等待,這對外部客戶端看來,Redis 好像就被阻塞一樣,一直得不到響應。
所以使用 Redis 過程切勿執行需要長時間執行的指令,這樣可能導致 Redis 阻塞,影響執行其他指令。
KEYS 原理
接下來開始回答第二個問題,為什麼 Keys
指令查詢會這麼慢?
回答這個問題之前,請大家回想一下 Redis 底層儲存結構。
不太清楚朋友的也沒關係,大家可以回看一下小黑哥之前的文章「阿里面試官:HashMap 熟悉吧?好的,那就來聊聊 Redis 字典吧!」。
這裡小黑哥複製之前文章內容,Redis 底層使用字典這種結構,這個結構與 Java HashMap 底層比較類似。
keys
命令需要返回所有的符合給定模式 pattern
的 Redis 中鍵,為了實現這個目的,Redis 不得不遍歷字典中 ht[0]
雜湊表底層陣列,這個時間複雜度為 O(N)(N 為 Redis 中 key 所有的數量)。
如果 Redis 中 key 的數量很少,那麼這個執行速度還是也會很快。等到 Redis key 的數量慢慢更加,上升到百萬、千萬、甚至上億級別,那這個執行速度就會很慢很慢。
下面是小黑哥本地做的一次實驗,使用 lua 指令碼往 Redis 中增加 10W 個 key,然後使用 keys
查詢所有鍵,這個查詢大概會阻塞十幾秒的時間。
eval "for i=1,100000 do redis.call('set',i,i+1) end" 0
這裡小黑哥使用 Docker 部署 Redis,效能可能會稍差。
SCAN 原理
最後我們來看下第三個問題,為什麼 scan
指令就沒有問題?
這是因為 scan
命令採用一種黑科技-基於遊標的迭代器。
每次呼叫 scan
命令,Redis 都會向使用者返回一個新的遊標以及一定數量的 key。下次再想繼續獲取剩餘的 key,需要將這個遊標傳入 scan 命令, 以此來延續之前的迭代過程。
簡單來講,scan
命令使用分頁查詢 redis 。
下面是一個 scan 命令的迭代過程示例:
scan
命令使用遊標這種方式,巧妙將一次全量查詢拆分成多次,降低查詢複雜度。
雖然 scan
命令時間複雜度與 keys
一樣,都是 O(N),但是由於 scan
命令只需要返回少量的 key,所以執行速度會很快。
最後,雖然scan
命令解決 keys
不足,但是同時也引入其他一些缺陷:
- 同一個元素可能會被返回多次,這就需要我們應用程式增加處理重複元素功能。
- 如果一個元素在迭代過程增加到 redis,或者說在迭代過程被刪除,那個這個元素會被返回,也可能不會。
以上這些缺陷,在我們開發中需要考慮這種情況。
除了 scan
以外,redis 還有其他幾個用於增量迭代命令:
sscan
:用於迭代當前資料庫中的資料庫鍵,用於解決smembers
可能產生阻塞問題hscan
命令用於迭代雜湊鍵中的鍵值對,用於解決hgetall
可能產生阻塞問題。zscan
:命令用於迭代有序集合中的元素(包括元素成員和元素分值),用於產生zrange
可能產生阻塞問題。
總結
Redis 使用單執行緒執行操作命令,所有客戶端傳送過來命令,Redis 都會現放入佇列,然後從佇列中順序取出執行相應的命令。
如果任一任務執行過慢,就會影響佇列中其他任務的,這樣在外部客戶端看來,遲遲拿不到 Redis 的響應,看起來就很阻塞了一樣。
所以不要在生產執行 keys
、smembers
、hgetall
、zrange
這類可能造成阻塞的指令,如果真需要執行,可以使用相應的scan
命令漸進式遍歷,可以有效防止阻塞問題。
歡迎關注我的公眾號:程式通事,獲得日常乾貨推送。如果您對我的專題內容感興趣,也可以關注我的部落格:studyidea.cn