[TOC]
在平時線上 Redis 維護工作中,有時候需要從 Redis 實例成千上萬的 key 中找出特定前綴的 key 列表來手動處理數據,可能是修改它的值,也可能是刪除 key。這里就有一個問題,如何從海量的 key 中找出滿足特定前綴的 key 列表來?
Redis 提供了一個簡單暴力的指令`keys`用來列出所有滿足特定正則字符串規則的 key。
~~~
127.0.0.1:6379> set codehole1 a
OK
127.0.0.1:6379> set codehole2 b
OK
127.0.0.1:6379> set codehole3 c
OK
127.0.0.1:6379> set code1hole a
OK
127.0.0.1:6379> set code2hole b
OK
127.0.0.1:6379> set code3hole b
OK
127.0.0.1:6379> keys *
1) "codehole1"
2) "code3hole"
3) "codehole3"
4) "code2hole"
5) "codehole2"
6) "code1hole"
127.0.0.1:6379> keys codehole*
1) "codehole1"
2) "codehole3"
3) "codehole2"
127.0.0.1:6379> keys code*hole
1) "code3hole"
2) "code2hole"
3) "code1hole"
~~~
這個指令使用非常簡單,提供一個簡單的正則字符串即可,但是有很明顯的兩個**缺點**。
1. 沒有 offset、limit 參數,一次性吐出所有滿足條件的 key,萬一實例中有幾百 w 個 key 滿足條件,當你看到滿屏的字符串刷的沒有盡頭時,你就知道難受了。
2. keys 算法是遍歷算法,復雜度是 O(n),如果實例中有千萬級以上的 key,這個指令就會導致 Redis 服務卡頓,所有讀寫 Redis 的其它的指令都會被延后甚至會超時報錯,因為 Redis 是單線程程序,順序執行所有指令,其它指令必須等到當前的 keys 指令執行完了才可以繼續。
面對這兩個顯著的缺點該怎么辦呢?
Redis 為了解決這個問題,它在 2.8 版本中加入了大海撈針的指令——`scan`。`scan`相比`keys`具備有以下特點:
1. 復雜度雖然也是 O(n),但是它是通過游標分步進行的,不會阻塞線程;
2. 提供 limit 參數,可以控制每次返回結果的最大條數,limit 只是一個 hint,返回的結果可多可少;
3. 同 keys 一樣,它也提供模式匹配功能;
4. 服務器不需要為游標保存狀態,游標的唯一狀態就是 scan 返回給客戶端的游標整數;
5. 返回的結果可能會有重復,需要客戶端去重復,這點非常重要;
6. 遍歷的過程中如果有數據修改,改動后的數據能不能遍歷到是不確定的;
7. 單次返回的結果是空的并不意味著遍歷結束,而要看返回的游標值是否為零;
## scan 基礎使用
在使用之前,讓我們往 Redis 里插入 10000 條數據來進行測試
~~~
import redis
client = redis.StrictRedis()
for i in range(10000):
client.set("key%d" % i, i)
~~~
好,Redis 中現在有了 10000 條數據,接下來我們找出以 key99 開頭 key 列表。
scan 參數提供了三個參數,第一個是`cursor 整數值`,第二個是`key 的正則模式`,第三個是`遍歷的 limit hint`。第一次遍歷時,cursor 值為 0,然后將返回結果中第一個整數值作為下一次遍歷的 cursor。一直遍歷到返回的 cursor 值為 0 時結束。
~~~
127.0.0.1:6379> scan 0 match key99* count 1000
1) "13976"
2) 1) "key9911"
2) "key9974"
3) "key9994"
4) "key9910"
5) "key9907"
6) "key9989"
7) "key9971"
8) "key99"
9) "key9966"
10) "key992"
11) "key9903"
12) "key9905"
127.0.0.1:6379> scan 13976 match key99* count 1000
1) "1996"
2) 1) "key9982"
2) "key9997"
3) "key9963"
4) "key996"
5) "key9912"
6) "key9999"
7) "key9921"
8) "key994"
9) "key9956"
10) "key9919"
127.0.0.1:6379> scan 1996 match key99* count 1000
1) "12594"
2) 1) "key9939"
2) "key9941"
3) "key9967"
4) "key9938"
5) "key9906"
6) "key999"
7) "key9909"
8) "key9933"
9) "key9992"
......
127.0.0.1:6379> scan 11687 match key99* count 1000
1) "0"
2) 1) "key9969"
2) "key998"
3) "key9986"
4) "key9968"
5) "key9965"
6) "key9990"
7) "key9915"
8) "key9928"
9) "key9908"
10) "key9929"
11) "key9944"
~~~
從上面的過程可以看到雖然提供的 limit 是 1000,但是返回的結果只有 10 個左右。因為這個 limit 不是限定返回結果的數量,而是限定服務器單次遍歷的字典槽位數量(約等于)。如果將 limit 設置為 10,你會發現返回結果是空的,但是游標值不為零,意味著遍歷還沒結束。
~~~
127.0.0.1:6379> scan 0 match key99* count 10
1) "3072"
2) (empty list or set)
~~~
## 字典的結構
在 Redis 中所有的 key 都存儲在一個很大的字典中,這個字典的結構和 Java 中的 HashMap 一樣,是一維數組 + 二維鏈表結構,第一維數組的大小總是 2^n(n>=0),擴容一次數組大小空間加倍,也就是 n++。

scan 指令返回的游標就是第一維數組的位置索引,我們將這個位置索引稱為槽 (slot)。如果不考慮字典的擴容縮容,直接按數組下標挨個遍歷就行了。limit 參數就表示需要遍歷的槽位數,之所以返回的結果可能多可能少,是因為不是所有的槽位上都會掛接鏈表,有些槽位可能是空的,還有些槽位上掛接的鏈表上的元素可能會有多個。每一次遍歷都會將 limit 數量的槽位上掛接的所有鏈表元素進行模式匹配過濾后,一次性返回給客戶端。
## scan 遍歷順序
scan 的遍歷順序非常特別。它不是從第一維數組的第 0 位一直遍歷到末尾,而是采用了高位進位加法來遍歷。之所以使用這樣特殊的方式進行遍歷,是考慮到字典的擴容和縮容時避免槽位的遍歷重復和遺漏。
首先我們用動畫演示一下普通加法和高位進位加法的區別。

從動畫中可以看出高位進位法從左邊加,進位往右邊移動,同普通加法正好相反。但是最終它們都會遍歷所有的槽位并且沒有重復。
## 字典擴容
Java 中的 HashMap 有擴容的概念,當 loadFactor 達到閾值時,需要重新分配一個新的 2 倍大小的數組,然后將所有的元素全部 rehash 掛到新的數組下面。rehash 就是將元素的 hash 值對數組長度進行取模運算,因為長度變了,所以每個元素掛接的槽位可能也發生了變化。又因為數組的長度是 2^n 次方,所以取模運算等價于位與操作。
~~~
a mod 8 = a & (8-1) = a & 7
a mod 16 = a & (16-1) = a & 15
a mod 32 = a & (32-1) = a & 31
~~~
這里的 7, 15, 31 稱之為字典的 mask 值,mask 的作用就是保留 hash 值的低位,高位都被設置為 0。
接下來我們看看 rehash 前后元素槽位的變化。
假設當前的字典的數組長度由 8 位擴容到 16 位,那么 3 號槽位 011 將會被 rehash 到 3 號槽位和 11 號槽位,也就是說該槽位鏈表中大約有一半的元素還是 3 號槽位,其它的元素會放到 11 號槽位,11 這個數字的二進制是 1011,就是對 3 的二進制 011 增加了一個高位 1。

抽象一點說,假設開始槽位的二進制數是 xxx,那么該槽位中的元素將被 rehash 到 0xxx 和 1xxx(xxx+8) 中。 如果字典長度由 16 位擴容到 32 位,那么對于二進制槽位 xxxx 中的元素將被 rehash 到 0xxxx 和 1xxxx(xxxx+16) 中。
## 對比擴容縮容前后的遍歷順序

觀察這張圖,我們發現采用高位進位加法的遍歷順序,rehash 后的槽位在遍歷順序上是相鄰的。
假設當前要即將遍歷 110 這個位置 (橙色),那么擴容后,當前槽位上所有的元素對應的新槽位是 0110 和 1110(深綠色),也就是在槽位的二進制數增加一個高位 0 或 1。這時我們可以直接從 0110 這個槽位開始往后繼續遍歷,0110 槽位之前的所有槽位都是已經遍歷過的,這樣就可以避免擴容后對已經遍歷過的槽位進行重復遍歷。
再考慮縮容,假設當前即將遍歷 110 這個位置 (橙色),那么縮容后,當前槽位所有的元素對應的新槽位是 10(深綠色),也就是去掉槽位二進制最高位。這時我們可以直接從 10 這個槽位繼續往后遍歷,10 槽位之前的所有槽位都是已經遍歷過的,這樣就可以避免縮容的重復遍歷。不過縮容還是不太一樣,它會對圖中 010 這個槽位上的元素進行重復遍歷,因為縮融后 10 槽位的元素是 010 和 110 上掛接的元素的融合。
## 漸進式 rehash
Java 的 HashMap 在擴容時會一次性將舊數組下掛接的元素全部轉移到新數組下面。如果 HashMap 中元素特別多,線程就會出現卡頓現象。Redis 為了解決這個問題,它采用**漸進式 rehash**。
它會同時保留舊數組和新數組,然后在定時任務中以及后續對 hash 的指令操作中漸漸地將舊數組中掛接的元素遷移到新數組上。這意味著要操作處于 rehash 中的字典,需要同時訪問新舊兩個數組結構。如果在舊數組下面找不到元素,還需要去新數組下面去尋找。
scan 也需要考慮這個問題,對與 rehash 中的字典,它需要同時掃描新舊槽位,然后將結果融合后返回給客戶端。
## 更多的 scan 指令
scan 指令是一系列指令,除了可以遍歷所有的 key 之外,還可以對指定的容器集合進行遍歷。比如 zscan 遍歷 zset 集合元素,hscan 遍歷 hash 字典的元素、sscan 遍歷 set 集合的元素。
它們的原理同 scan 都會類似的,因為 hash 底層就是字典,set 也是一個特殊的 hash(所有的 value 指向同一個元素),zset 內部也使用了字典來存儲所有的元素內容,所以這里不再贅述。
## 大 key 掃描
有時候會因為業務人員使用不當,在 Redis 實例中會形成很大的對象,比如一個很大的 hash,一個很大的 zset 這都是經常出現的。這樣的對象對 Redis 的集群數據遷移帶來了很大的問題,因為在集群環境下,如果某個 key 太大,會數據導致遷移卡頓。另外在內存分配上,如果一個 key 太大,那么當它需要擴容時,會一次性申請更大的一塊內存,這也會導致卡頓。如果這個大 key 被刪除,內存會一次性回收,卡頓現象會再一次產生。
**在平時的業務開發中,要盡量避免大 key 的產生**。
如果你觀察到 Redis 的內存大起大落,這極有可能是因為大 key 導致的,這時候你就需要定位出具體是那個 key,進一步定位出具體的業務來源,然后再改進相關業務代碼設計。
**那如何定位大 key 呢?**
為了避免對線上 Redis 帶來卡頓,這就要用到 scan 指令,對于掃描出來的每一個 key,使用 type 指令獲得 key 的類型,然后使用相應數據結構的 size 或者 len 方法來得到它的大小,對于每一種類型,保留大小的前 N 名作為掃描結果展示出來。
上面這樣的過程需要編寫腳本,比較繁瑣,不過 Redis 官方已經在 redis-cli 指令中提供了這樣的掃描功能,我們可以直接拿來即用。
~~~
redis-cli -h 127.0.0.1 -p 7001 –-bigkeys
~~~
如果你擔心這個指令會大幅抬升 Redis 的 ops 導致線上報警,還可以增加一個休眠參數。
~~~
redis-cli -h 127.0.0.1 -p 7001 –-bigkeys -i 0.1
~~~
上面這個指令每隔 100 條 scan 指令就會休眠 0.1s,ops 就不會劇烈抬升,但是掃描的時間會變長。
## 擴展閱讀
感興趣可以繼續深入閱讀[美團近期修復的Scan的一個bug](https://link.juejin.im/?target=https%3A%2F%2Fmp.weixin.qq.com%2Fs%2FufoLJiXE0wU4Bc7ZbE9cDQ)
- 一.JVM
- 1.1 java代碼是怎么運行的
- 1.2 JVM的內存區域
- 1.3 JVM運行時內存
- 1.4 JVM內存分配策略
- 1.5 JVM類加載機制與對象的生命周期
- 1.6 常用的垃圾回收算法
- 1.7 JVM垃圾收集器
- 1.8 CMS垃圾收集器
- 1.9 G1垃圾收集器
- 2.面試相關文章
- 2.1 可能是把Java內存區域講得最清楚的一篇文章
- 2.0 GC調優參數
- 2.1GC排查系列
- 2.2 內存泄漏和內存溢出
- 2.2.3 深入理解JVM-hotspot虛擬機對象探秘
- 1.10 并發的可達性分析相關問題
- 二.Java集合架構
- 1.ArrayList深入源碼分析
- 2.Vector深入源碼分析
- 3.LinkedList深入源碼分析
- 4.HashMap深入源碼分析
- 5.ConcurrentHashMap深入源碼分析
- 6.HashSet,LinkedHashSet 和 LinkedHashMap
- 7.容器中的設計模式
- 8.集合架構之面試指南
- 9.TreeSet和TreeMap
- 三.Java基礎
- 1.基礎概念
- 1.1 Java程序初始化的順序是怎么樣的
- 1.2 Java和C++的區別
- 1.3 反射
- 1.4 注解
- 1.5 泛型
- 1.6 字節與字符的區別以及訪問修飾符
- 1.7 深拷貝與淺拷貝
- 1.8 字符串常量池
- 2.面向對象
- 3.關鍵字
- 4.基本數據類型與運算
- 5.字符串與數組
- 6.異常處理
- 7.Object 通用方法
- 8.Java8
- 8.1 Java 8 Tutorial
- 8.2 Java 8 數據流(Stream)
- 8.3 Java 8 并發教程:線程和執行器
- 8.4 Java 8 并發教程:同步和鎖
- 8.5 Java 8 并發教程:原子變量和 ConcurrentMap
- 8.6 Java 8 API 示例:字符串、數值、算術和文件
- 8.7 在 Java 8 中避免 Null 檢查
- 8.8 使用 Intellij IDEA 解決 Java 8 的數據流問題
- 四.Java 并發編程
- 1.線程的實現/創建
- 2.線程生命周期/狀態轉換
- 3.線程池
- 4.線程中的協作、中斷
- 5.Java鎖
- 5.1 樂觀鎖、悲觀鎖和自旋鎖
- 5.2 Synchronized
- 5.3 ReentrantLock
- 5.4 公平鎖和非公平鎖
- 5.3.1 說說ReentrantLock的實現原理,以及ReentrantLock的核心源碼是如何實現的?
- 5.5 鎖優化和升級
- 6.多線程的上下文切換
- 7.死鎖的產生和解決
- 8.J.U.C(java.util.concurrent)
- 0.簡化版(快速復習用)
- 9.鎖優化
- 10.Java 內存模型(JMM)
- 11.ThreadLocal詳解
- 12 CAS
- 13.AQS
- 0.ArrayBlockingQueue和LinkedBlockingQueue的實現原理
- 1.DelayQueue的實現原理
- 14.Thread.join()實現原理
- 15.PriorityQueue 的特性和原理
- 16.CyclicBarrier的實際使用場景
- 五.Java I/O NIO
- 1.I/O模型簡述
- 2.Java NIO之緩沖區
- 3.JAVA NIO之文件通道
- 4.Java NIO之套接字通道
- 5.Java NIO之選擇器
- 6.基于 Java NIO 實現簡單的 HTTP 服務器
- 7.BIO-NIO-AIO
- 8.netty(一)
- 9.NIO面試題
- 六.Java設計模式
- 1.單例模式
- 2.策略模式
- 3.模板方法
- 4.適配器模式
- 5.簡單工廠
- 6.門面模式
- 7.代理模式
- 七.數據結構和算法
- 1.什么是紅黑樹
- 2.二叉樹
- 2.1 二叉樹的前序、中序、后序遍歷
- 3.排序算法匯總
- 4.java實現鏈表及鏈表的重用操作
- 4.1算法題-鏈表反轉
- 5.圖的概述
- 6.常見的幾道字符串算法題
- 7.幾道常見的鏈表算法題
- 8.leetcode常見算法題1
- 9.LRU緩存策略
- 10.二進制及位運算
- 10.1.二進制和十進制轉換
- 10.2.位運算
- 11.常見鏈表算法題
- 12.算法好文推薦
- 13.跳表
- 八.Spring 全家桶
- 1.Spring IOC
- 2.Spring AOP
- 3.Spring 事務管理
- 4.SpringMVC 運行流程和手動實現
- 0.Spring 核心技術
- 5.spring如何解決循環依賴問題
- 6.springboot自動裝配原理
- 7.Spring中的循環依賴解決機制中,為什么要三級緩存,用二級緩存不夠嗎
- 8.beanFactory和factoryBean有什么區別
- 九.數據庫
- 1.mybatis
- 1.1 MyBatis-# 與 $ 區別以及 sql 預編譯
- Mybatis系列1-Configuration
- Mybatis系列2-SQL執行過程
- Mybatis系列3-之SqlSession
- Mybatis系列4-之Executor
- Mybatis系列5-StatementHandler
- Mybatis系列6-MappedStatement
- Mybatis系列7-參數設置揭秘(ParameterHandler)
- Mybatis系列8-緩存機制
- 2.淺談聚簇索引和非聚簇索引的區別
- 3.mysql 證明為什么用limit時,offset很大會影響性能
- 4.MySQL中的索引
- 5.數據庫索引2
- 6.面試題收集
- 7.MySQL行鎖、表鎖、間隙鎖詳解
- 8.數據庫MVCC詳解
- 9.一條SQL查詢語句是如何執行的
- 10.MySQL 的 crash-safe 原理解析
- 11.MySQL 性能優化神器 Explain 使用分析
- 12.mysql中,一條update語句執行的過程是怎么樣的?期間用到了mysql的哪些log,分別有什么作用
- 十.Redis
- 0.快速復習回顧Redis
- 1.通俗易懂的Redis數據結構基礎教程
- 2.分布式鎖(一)
- 3.分布式鎖(二)
- 4.延時隊列
- 5.位圖Bitmaps
- 6.Bitmaps(位圖)的使用
- 7.Scan
- 8.redis緩存雪崩、緩存擊穿、緩存穿透
- 9.Redis為什么是單線程、及高并發快的3大原因詳解
- 10.布隆過濾器你值得擁有的開發利器
- 11.Redis哨兵、復制、集群的設計原理與區別
- 12.redis的IO多路復用
- 13.相關redis面試題
- 14.redis集群
- 十一.中間件
- 1.RabbitMQ
- 1.1 RabbitMQ實戰,hello world
- 1.2 RabbitMQ 實戰,工作隊列
- 1.3 RabbitMQ 實戰, 發布訂閱
- 1.4 RabbitMQ 實戰,路由
- 1.5 RabbitMQ 實戰,主題
- 1.6 Spring AMQP 的 AMQP 抽象
- 1.7 Spring AMQP 實戰 – 整合 RabbitMQ 發送郵件
- 1.8 RabbitMQ 的消息持久化與 Spring AMQP 的實現剖析
- 1.9 RabbitMQ必備核心知識
- 2.RocketMQ 的幾個簡單問題與答案
- 2.Kafka
- 2.1 kafka 基礎概念和術語
- 2.2 Kafka的重平衡(Rebalance)
- 2.3.kafka日志機制
- 2.4 kafka是pull還是push的方式傳遞消息的?
- 2.5 Kafka的數據處理流程
- 2.6 Kafka的腦裂預防和處理機制
- 2.7 Kafka中partition副本的Leader選舉機制
- 2.8 如果Leader掛了的時候,follower沒來得及同步,是否會出現數據不一致
- 2.9 kafka的partition副本是否會出現腦裂情況
- 十二.Zookeeper
- 0.什么是Zookeeper(漫畫)
- 1.使用docker安裝Zookeeper偽集群
- 3.ZooKeeper-Plus
- 4.zk實現分布式鎖
- 5.ZooKeeper之Watcher機制
- 6.Zookeeper之選舉及數據一致性
- 十三.計算機網絡
- 1.進制轉換:二進制、八進制、十六進制、十進制之間的轉換
- 2.位運算
- 3.計算機網絡面試題匯總1
- 十四.Docker
- 100.面試題收集合集
- 1.美團面試常見問題總結
- 2.b站部分面試題
- 3.比心面試題
- 4.騰訊面試題
- 5.哈羅部分面試
- 6.筆記
- 十五.Storm
- 1.Storm和流處理簡介
- 2.Storm 核心概念詳解
- 3.Storm 單機版本環境搭建
- 4.Storm 集群環境搭建
- 5.Storm 編程模型詳解
- 6.Storm 項目三種打包方式對比分析
- 7.Storm 集成 Redis 詳解
- 8.Storm 集成 HDFS 和 HBase
- 9.Storm 集成 Kafka
- 十六.Elasticsearch
- 1.初識ElasticSearch
- 2.文檔基本CRUD、集群健康檢查
- 3.shard&replica
- 4.document核心元數據解析及ES的并發控制
- 5.document的批量操作及數據路由原理
- 6.倒排索引
- 十七.分布式相關
- 1.分布式事務解決方案一網打盡
- 2.關于xxx怎么保證高可用的問題
- 3.一致性hash原理與實現
- 4.微服務注冊中心 Nacos 比 Eureka的優勢
- 5.Raft 協議算法
- 6.為什么微服務架構中需要網關
- 0.CAP與BASE理論
- 十八.Dubbo
- 1.快速掌握Dubbo常規應用
- 2.Dubbo應用進階
- 3.Dubbo調用模塊詳解
- 4.Dubbo調用模塊源碼分析
- 6.Dubbo協議模塊