Redis大Key排查实战:如何用Python脚本+SCAN命令找出内存杀手
那天下午,监控系统突然告警,Redis内存使用率飙到了95%。团队里几个同事围在屏幕前,看着那根几乎要冲破顶线的内存曲线,气氛有点凝重。这不是第一次了——每次业务高峰期,总有那么几个“内存杀手”在Redis里悄悄膨胀,吃掉宝贵的资源。我们试过手动排查,但面对千万级别的key,就像大海捞针。直到后来,我们摸索出了一套基于Python和SCAN命令的自动化排查方案,才真正解决了这个问题。
如果你也遇到过类似场景——测试环境内存突然爆满,生产环境偶尔出现响应延迟,或者只是想提前预防潜在风险——那么这篇文章就是为你准备的。我不会给你一堆理论,而是直接分享我们踩过坑、验证过的实战方案,从原理到代码,从单机到集群,手把手教你如何安全、高效地找出那些隐藏在Redis中的“大Key”。
1. 为什么KEYS命令是生产环境的“禁忌”?
很多开发者第一次接触Redis键空间查询时,用的都是KEYS *这个命令。它简单直接,输入一个模式,返回所有匹配的key。在本地测试环境,几十上百个key的情况下,这命令确实方便。但把这个习惯带到生产环境,可能就是灾难的开始。
Redis的核心设计是单线程模型。这意味着所有命令都在一个主线程中顺序执行。当你在一个存有百万级key的实例上执行KEYS *时,Redis必须遍历整个键空间,这个操作的时间复杂度是O(N)。在这几秒甚至几十秒的遍历期间,Redis无法处理其他任何请求——所有后续的读写操作都会被阻塞,客户端开始超时,业务系统出现卡顿。
更糟糕的是,如果这个实例承载着核心业务,比如用户会话存储或实时缓存,这种阻塞可能导致级联故障。我们曾经有个惨痛教训:一个开发同学在线上执行了KEYS user:*,当时实例有800多万个key,整个服务中断了12秒。12秒对于互联网业务来说,足够让用户流失、订单失败、监控告警响成一片。
那么,有没有替代方案?这就是SCAN命令存在的意义。但在我深入讲解SCAN之前,先看看这个对比表格,直观感受两者的差异:
| 特性 | KEYS命令 | SCAN命令 |
|---|---|---|
| 执行方式 | 一次性全量遍历 | 增量式迭代遍历 |
| 阻塞风险 | 高(遍历期间完全阻塞) | 低(每次只处理少量数据) |
| 时间复杂度 | O(N),N为总key数 | O(N),但分多次执行 |
| 内存影响 | 可能返回大量数据导致客户端内存溢出 | 每次返回可控数量的key |
| 适用场景 | 仅限测试、开发环境 | 生产环境安全使用 |
| 游标支持 | 无 | 有,支持断点续查 |
| 模式匹配 | 支持 | 支持 |
| COUNT参数 | 无 | 有,可控制每次迭代数量 |
注意:即使SCAN命令不会长时间阻塞Redis服务,它仍然会消耗CPU资源。在高并发场景下,频繁执行SCAN也可能影响性能,建议在业务低峰期执行。
理解了KEYS的危险性,我们再来看看为什么SCAN能成为安全的替代品。SCAN的核心思想是“分而治之”——它不一次性返回所有结果,而是使用游标(cursor)进行分批次迭代。每次调用只处理哈希表的一部分槽位,处理完就返回,把控制权交还给Redis主线程处理其他请求。
2. SCAN命令的深度解析与实战技巧
SCAN命令的语法看起来简单:SCAN cursor [MATCH pattern] [COUNT count]。但真正用好它,需要理解背后的几个关键机制。
2.1 游标机制:不只是简单的页码
很多人把SCAN的游标理解为传统分页的页码,这是第一个误区。Redis的游标实际上是对哈希表槽位的编码。Redis内部使用哈希表存储键值对,SCAN遍历的是这个哈希表的槽位数组。
# 错误的认知:像分页一样使用SCAN
cursor = 0
page = 1
while True:
cursor, keys = redis_server.scan(cursor, count=100)
print(f"第{page}页,找到{len(keys)}个key")
page += 1
if cursor == 0:
break
# 正确的理解:游标是哈希槽位的状态标记
# 游标为0表示开始新迭代
# 返回的游标用于下次继续
# 游标再次为0表示迭代完成
这种设计带来一个重要特性:SCAN不保证遍历期间数据的一致性。如果在遍历过程中有key被添加或删除,SCAN可能返回重复的key,也可能漏掉一些key。对于大Key排查这种场景,这通常不是问题——我们关心的是找到那些占用内存大的key,而不是获取某个时间点的精确快照。
2.2 COUNT参数:不是你想的那样
COUNT参数可能是最容易被误解的部分。文档上说它“提示(hint)服务器每次迭代应该返回多少元素”,但实际行为往往让人困惑。
# COUNT参数的实际行为示例
cursor = 0
total_keys = 0
iterations = 0
while True:
# 设置COUNT=1000,但实际返回可能远少于1000
cursor, keys = redis_server.scan(cursor, count=1000)
total_keys += len(keys)
iterations += 1
print(f"迭代{iterations}: 请求COUNT=1000,实际返回{len(keys)}个key")
if cursor == 0:
break
print(f"总共{total_keys}个key,经过{iterations}次迭代完成")
为什么COUNT不准确?因为SCAN是按哈希槽位遍历的,不是按key数量。COUNT=1000意味着“请扫描大约1000个哈希槽位”,但每个槽位可能挂载0个、1个或多个key(哈希冲突时形成链表)。所以实际返回的key数量可能小于、等于或偶尔大于COUNT值。
在实际的大Key排查中,我通常这样设置COUNT:
- 对于key数量较少(<10万)的实例:COUNT=500
- 对于中等规模(10万-100万):COUNT=1000
- 对于大型实例(>100万):COUNT=5000
但要注意,COUNT值越大,单次SCAN消耗的CPU时间越长。需要在遍历速度和系统影响之间找到平衡点。
2.3 MATCH过滤:遍历后的筛选
MATCH参数允许我们按模式过滤key,比如SCAN 0 MATCH user:* COUNT 1000。但这里有个关键细节:MATCH过滤是在遍历出key之后进行的,不是在遍历过程中。
这意味着什么?假设你的Redis有1000万个key,但只有100个匹配user:*模式。如果你使用SCAN 0 MATCH user:* COUNT 100,可能前几次迭代都返回空列表,直到遍历到包含目标key的槽位。从客户端的角度看,就是“为什么我设置了MATCH,却一直拿不到结果?”
# MATCH过滤的实际情况演示
cursor = 0
empty_iterations = 0
found_keys = []
while True:
cursor, keys = redis_server.scan(cursor, match="special:key:*", count=100)
if not keys:
empty_iterations += 1
print(f"第{empty_iterations}次空迭代,游标={cursor}")
else:
found_keys.extend(keys)
print(f"找到{len(keys)}个匹配的key")
if cursor == 0:
break
print(f"总共进行了{empty_iterations}次空迭代,最终找到{len(found_keys)}个key")
这种设计虽然可能导致空迭代,但保证了SCAN的算法效率。如果要在遍历过程中过滤,就需要遍历每个槽位时都检查模式匹配,这会显著增加CPU开销。
3. 构建生产级大Key排查脚本
理解了SCAN的原理,现在我们来构建一个真正能在生产环境使用的大Key排查工具。这个工具需要满足几个要求:
- 安全:不能阻塞Redis服务
- 高效:能快速找到真正的大Key
- 灵活:支持不同数据类型的size计算
- 可配置:允许调整各种参数适应不同场景
3.1 基础版本:找出Top N大Key
先从最简单的版本开始,这个脚本可以找出内存占用最大的N个key:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Redis大Key排查工具 - 基础版本
适用于单机Redis实例
"""
import redis
import sys
from typing import List, Tuple, Optional
class RedisBigKeyScanner:
def __init__(self, host: str = 'localhost', port: int = 6379,
password: Optional[str] = None, db: int = 0):
"""初始化Redis连接"""
self.redis_pool = redis.ConnectionPool(
host=host, port=port, password=password, db=db,
decode_responses=False # 保持bytes类型,避免编码问题
)
self.redis_client = redis.Redis(connection_pool=self.redis_pool)
def get_key_size(self, key: bytes) -> int:
"""获取key的内存占用大小(字节)"""
try:
# memory_usage命令需要Redis 4.0+
size = self.redis_client.memory_usage(key, samples=0)
return size if size else 0
except redis.exceptions.ResponseError:
# 如果memory_usage不可用,回退到估算方法
return self._estimate_key_size(key)
def _estimate_key_size(self, key: bytes) -> int:
"""估算key的大小(当memory_usage不可用时)"""
key_type = self.redis_client.type(key)
if key_type == b'string':
# string类型:key长度 + value长度 + 开销
value_len = self.redis_client.strlen(key)
return len(key) + value_len + 64 # 近似开销
elif key_type == b'list':
# list类型:估算每个元素
list_len = self.redis_client.llen(key)
if list_len == 0:
return len(key) + 48
# 取样前10个元素估算平均大小
sample_size = min(10, list_len)
total_elem_size = 0
for i in range(sample_size):
elem = self.redis_client.lindex(key, i)
if elem:
total_elem_size += len(elem)
avg_elem_size = total_elem_size / sample_size if sample_size > 0 else 0
return len(key) + int(avg_elem_size * list_len) + 96
elif key_type == b'hash':
# hash类型:估算所有field-value对
hash_len = self.redis_client.hlen(key)
if hash_len == 0:
return len(key) + 64
# 使用hscan取样
cursor = 0
total_size = 0
sample_count = 0
while True:
cursor, items = self.redis_client.hscan(key, cursor, count=10)
for field, value in items.items():
total_size += len(field) + len(value)
sample_count += 1
if sample_count >= 10:
break
if cursor == 0 or sample_count >= 10:
break
avg_item_size = total_size / sample_count if sample_count > 0 else 0
return len(key) + int(avg_item_size * hash_len) + 80
elif key_type == b'set':
# set类型:类似hash的估算
set_len = self.redis_client.scard(key)
return len(key) + set_len * 64 + 80 # 近似估算
elif key_type == b'zset':
# zset类型:最复杂,需要估算成员和分值
zset_len = self.redis_client.zcard(key)
return len(key) + zset_len * 128 + 96 # 近似估算
else:
return len(key) + 32 # 未知类型的保守估计
def scan_for_big_keys(self, top_n: int = 10,
count: int = 1000,
match_pattern: Optional[str] = None) -> List[Tuple[bytes, int]]:
"""
扫描并找出最大的N个key
Args:
top_n: 返回前N个大Key
count: 每次SCAN的COUNT参数
match_pattern: key的模式匹配,如"user:*"
Returns:
列表,每个元素是(key, size)元组,按size降序
"""
print(f"开始扫描大Key,模式={match_pattern or '*'},目标找到前{top_n}个")
cursor = 0
key_sizes = []
scanned_keys = 0
# 使用最小堆来维护Top N,避免存储所有key的大小
import heapq
min_heap = [] # 存储(-size, key)的元组,Python的heapq是最小堆
scan_args = {'count': count}
if match_pattern:
scan_args['match'] = match_pattern
try:
while True:
cursor, keys = self.redis_client.scan(cursor, **scan_args)
scanned_keys += len(keys)
for key in keys:
size = self.get_key_size(key)
# 维护Top N的最小堆
if len(min_heap) < top_n:
heapq.heappush(min_heap, (size, key))
elif size > min_heap[0][0]: # 比堆中最小的还大
heapq.heapreplace(min_heap, (size, key))
# 进度提示
if scanned_keys % 10000 == 0:
print(f"已扫描 {scanned_keys} 个key,当前最小候选大小: {min_heap[0][0] if min_heap else 0} 字节")
if cursor == 0:
break
except KeyboardInterrupt:
print("\n扫描被用户中断")
except Exception as e:
print(f"扫描过程中发生错误: {e}")
return []
# 从堆中提取结果并排序
result = [(key, size) for size, key in min_heap]
result.sort(key=lambda x: x[1], reverse=True)
print(f"扫描完成,共扫描 {scanned_keys} 个key")
return result
def format_size(self, size_bytes: int) -> str:
"""格式化字节大小为易读格式"""
for unit in ['B', 'KB', 'MB', 'GB']:
if size_bytes < 1


354

被折叠的 条评论
为什么被折叠?



