給你1億的Redis key,如何高效統(tǒng)計(jì)?
前言
有些小伙伴在工作中,可能遇到過這樣的場景:老板突然要求統(tǒng)計(jì)Redis中所有key的數(shù)量,你隨手執(zhí)行了KEYS *
命令,下一秒監(jiān)控告警瘋狂閃爍——整個Redis集群徹底卡死,線上服務(wù)大面積癱瘓。
今天這篇文章就跟大家一起聊聊如果給你1億個Redis key,如何高效統(tǒng)計(jì)這個話題,希望對你會有所幫助。
1.為什么不建議使用KEYS命令?
Redis的單線程模型是其高性能的核心,但也是最大的軟肋。
當(dāng)Redis執(zhí)行 KEYS *
命令時,內(nèi)部的流程如下:
圖片
Redis的單線程模型是其高性能的核心,但同時也帶來一個關(guān)鍵限制:所有命令都是串行執(zhí)行的。
當(dāng)我們執(zhí)行 KEYS * 命令時:
Redis必須遍歷整個key空間(時間復(fù)雜度O(N))
在遍歷完成前,無法處理其他任何命令
對于1億個key,即使每個key查找只需0.1微秒,總耗時也高達(dá)10秒!
致命三連擊:
- 時間復(fù)雜度:1億key需要10秒+(實(shí)測單核CPU 0.1μs/key)
- 內(nèi)存風(fēng)暴:返回結(jié)果太多可能撐爆客戶端內(nèi)存
- 集群失效:在Cluster模式中只能查當(dāng)前節(jié)點(diǎn)的數(shù)據(jù)。
如果Redis一次性返回的數(shù)據(jù)太多,可能會有OOM問題:
127.0.0.1:6379> KEYS *
(卡死10秒...)
(error) OOM command not allowed when used memory > 'maxmemory'
超過了最大內(nèi)存。
那么,Redis中有1億key,我們要如何統(tǒng)計(jì)數(shù)據(jù)呢?
2.SCAN命令
SCAN
命令通過游標(biāo)分批遍歷,每次只返回少量key,避免阻塞。
Java版基礎(chǔ)SCAN的代碼如下:
public long safeCount(Jedis jedis) {
long total = 0;
String cursor = "0";
ScanParams params = new ScanParams().count(500); // 每批500個
do {
ScanResult<String> rs = jedis.scan(cursor, params);
cursor = rs.getCursor();
total += rs.getResult().size();
} while (!"0".equals(cursor)); // 游標(biāo)0表示結(jié)束
return total;
}
使用游標(biāo)查詢Redis中的數(shù)據(jù),一次掃描500條數(shù)據(jù)。
但問題來了:1億key需要多久?
- 每次SCAN耗時≈3ms
- 每次返回500key
- 總次數(shù)=1億/500=20萬次
- 總耗時≈20萬×3ms=600秒=10分鐘!
3.多線程并發(fā)SCAN方案
現(xiàn)代服務(wù)器都是多核CPU,單線程掃描是資源浪費(fèi)。
看多線程優(yōu)化方案如下:
圖片
多線程并發(fā)SCAN代碼如下:
public long parallelCount(JedisPool pool, int threads) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(threads);
AtomicLong total = new AtomicLong(0);
// 生成初始游標(biāo)(實(shí)際需要更智能的分段)
List<String> cursors = new ArrayList<>();
for (int i = 0; i < threads; i++) {
cursors.add(String.valueOf(i));
}
CountDownLatch latch = new CountDownLatch(threads);
for (String cursor : cursors) {
executor.execute(() -> {
try (Jedis jedis = pool.getResource()) {
String cur = cursor;
do {
ScanResult<String> rs = jedis.scan(cur, new ScanParams().count(500));
cur = rs.getCursor();
total.addAndGet(rs.getResult().size());
} while (!"0".equals(cur));
latch.countDown();
}
});
}
latch.await();
executor.shutdown();
return total.get();
}
使用線程池、AtomicLong和CountDownLatch配合使用,實(shí)現(xiàn)了多線程掃描數(shù)據(jù),最終將結(jié)果合并。
性能對比(32核CPU/1億key):
方案 | 線程數(shù) | 耗時 | 資源占用 |
單線程SCAN | 1 | 580s | CPU 5% |
多線程SCAN | 32 | 18s | CPU 800% |
4.分布式環(huán)境的分治策略
如果你的系統(tǒng)重使用了Redis Cluster集群模式,該模式會將數(shù)據(jù)分散在16384個槽(slot)中,統(tǒng)計(jì)就需要節(jié)點(diǎn)協(xié)同。
流程圖如下:
圖片
每一個Redis Cluster集群中的master服務(wù)節(jié)點(diǎn),都負(fù)責(zé)統(tǒng)計(jì)一定范圍的槽(slot)中的數(shù)據(jù),最后將數(shù)據(jù)聚合起來返回。
集群版并行統(tǒng)計(jì)代碼如下:
public long clusterCount(JedisCluster cluster) {
Map<String, JedisPool> nodes = cluster.getClusterNodes();
AtomicLong total = new AtomicLong(0);
nodes.values().parallelStream().forEach(pool -> {
try (Jedis jedis = pool.getResource()) {
// 跳過從節(jié)點(diǎn)
if (jedis.info("replication").contains("role:slave")) return;
String cursor = "0";
do {
ScanResult<String> rs = jedis.scan(cursor, new ScanParams().count(500));
total.addAndGet(rs.getResult().size());
cursor = rs.getCursor();
} while (!"0".equals(cursor));
}
});
return total.get();
}
這里使用了parallelStream,會并發(fā)統(tǒng)計(jì)Redis不同的master節(jié)點(diǎn)中的數(shù)據(jù)。
5.毫秒統(tǒng)計(jì)方案
方案1:使用內(nèi)置計(jì)數(shù)器
如果只想統(tǒng)計(jì)一個數(shù)量,可以使用Redis內(nèi)置計(jì)數(shù)器,瞬時但非精確。
127.0.0.1:6379> info keyspace
# Keyspace
db0:keys=100000000,expires=20000,avg_ttl=3600
優(yōu)點(diǎn):毫秒級返回。
缺點(diǎn):包含已過期未刪除的key,法按模式過濾數(shù)據(jù)。
方案2:實(shí)時增量統(tǒng)計(jì)
實(shí)時增量統(tǒng)計(jì)方案精準(zhǔn)但復(fù)雜。
基于鍵空間通知的實(shí)時計(jì)數(shù)器,具體代碼如下:
@Configuration
publicclass KeyCounterConfig {
@Bean
public RedisMessageListenerContainer container(RedisConnectionFactory factory) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(factory);
container.addMessageListener((message, pattern) -> {
String event = new String(message.getBody());
if(event.startsWith("__keyevent@0__:set")) {
redisTemplate.opsForValue().increment("total_keys", 1);
} elseif(event.startsWith("__keyevent@0__:del")) {
redisTemplate.opsForValue().decrement("total_keys", 1);
}
}, new PatternTopic("__keyevent@*"));
return container;
}
}
使用監(jiān)聽器統(tǒng)計(jì)數(shù)量。
成本分析:
- 內(nèi)存開銷:額外存儲計(jì)數(shù)器
- CPU開銷:增加5%-10%處理通知
- 網(wǎng)絡(luò)開銷:集群模式下需跨節(jié)點(diǎn)同步
6.如何選擇方案?
本文中列舉出了多個統(tǒng)計(jì)Redis中key的方案,那么我們在實(shí)際工作中如何選擇呢?
下面用一張圖給大家列舉了選擇路線:
圖片
各方案的時間和空間復(fù)雜度如下:
方案 | 時間復(fù)雜度 | 空間復(fù)雜度 | 精度 |
KEYS命令 | O(n) | O(n) | 精確 |
SCAN遍歷 | O(n) | O(1) | 精確 |
內(nèi)置計(jì)數(shù)器 | O(1) | O(1) | 不精確 |
增量統(tǒng)計(jì) | O(1) | O(1) | 精確 |
硬件法則:
- CPU密集型:多線程數(shù)=CPU核心數(shù)×1.5
- IO密集型:線程數(shù)=CPU核心數(shù)×3
- 內(nèi)存限制:控制批次大小(count參數(shù))
常見的業(yè)務(wù)場景:
- 電商實(shí)時大屏:增量計(jì)數(shù)器+RedisTimeSeries
- 離線數(shù)據(jù)分析:SCAN導(dǎo)出到Spark
- 安全審計(jì):多節(jié)點(diǎn)并行SCAN
終極箴言:? 精確統(tǒng)計(jì)用分治? 實(shí)時查詢用增量? 趨勢分析用采樣? 暴力遍歷是自殺
真正的高手不是能解決難題的人,而是能預(yù)見并規(guī)避難題的人。
在海量數(shù)據(jù)時代,選擇比努力更重要——理解數(shù)據(jù)本質(zhì),才能駕馭數(shù)據(jù)洪流。