清理共享 Redis 里的旧缓存 key:Lua 分批 UNLINK、SLOWLOG 验证,以及 ElastiCache 和 cluster 模式下的差异
目录
一个服务改写了它的 Redis 缓存。新代码写的 key 是 my-service:users:v1:<id> 这种带服务名和版本号的小写名字,每个都设了 TTL;旧代码按 Users:<id>、ApiKeys:<key> 这样的名字写,没有 TTL,不会过期。新版本上线之后没有代码再读这些旧 key,它们只占内存。ApiKeys: 更麻烦一些:key 名就是原始 API key,值是对应的 secret。
这次要在测试环境把它们删掉。那里的 Redis 是一台自建的单节点 Redis 7.4.2,几个服务共用,各自占一个 database 号,所以删除有两个要求:只删这个服务的旧 key,删的过程不能让同节点的其他服务变慢。下面几节都在这台单机上做;同样的步骤放到 ElastiCache 和 cluster 模式上要改哪些地方,放在最后一节。
先确认旧 key 没人读、这个库里没有别的服务#
删缓存不会丢数据,数据源在数据库里,缓存没了会重新加载。要防的是两种情况:还有进程在读写这些 key,或者这个库里混着别的服务的 key。
前一种看部署。测试环境只剩新版本的一个 Pod,滚动更新留下的旧 Pod 都已经退出,新版本的代码里也搜不到任何一个旧名字。
后一种要从两头核对。应用配置里写着 host 和 database 号,Redis 那边则用 CLIENT LIST 看实际连着谁,它会列出每个连接所在的库和客户端库的名字。筛出这个库上的连接:
rcli() { redis-cli -h "$REDIS_HOST" --no-auth-warning "$@"; }
rcli CLIENT LIST | tr -d '\r' | grep ' db=3 ' | tr ' ' '\n' \
| grep -E '^(db|lib-name|lib-ver|age|idle|cmd)=' | paste -sd' ' -
age=32321 idle=10 db=3 cmd=info lib-name=Lettuce(spring-data-redis_v4.1.2) lib-ver=7.5.2.RELEASE/5728917
这个库上只有一个连接,是 Spring Data Redis 4.1.2 用的 Lettuce,连接时长和那个 Pod 的启动时间一致。同节点的其他连接都在别的库上。
还有一种容易漏掉的情况:两个环境的 key 名里都不带环境名,又恰好配到了同一个实例的同一个库,那么在测试环境删掉的就是另一个环境正在用的缓存。这次两个环境用的是两台不同的 Redis。
盘点:一次 SCAN,只输出前缀和数量#
KEYS * 一次遍历整个库,执行期间其他命令都要等,官方文档对它的提醒是在生产环境里要「extreme care」(KEYS)。SCAN 按游标分批遍历,从游标 0 开始,一直调到游标重新回到 0,就是一次完整遍历(SCAN)。
另一个约束是 key 名不能打印出来:ApiKeys: 后面跟的是 API key,Users: 后面是用户名。所以盘点放在服务端做。一段 Lua 脚本执行一步 SCAN,把每个 key 换成「前缀 + 有没有 TTL」再返回,客户端只负责计数:
local res = redis.call("SCAN", ARGV[1], "COUNT", ARGV[2])
local out = {res[1]}
for _, k in ipairs(res[2]) do
local p = "(no-colon)"
local a, b = string.match(k, "^([^:]*):([^:]*)")
if a then
p = a
if a == ARGV[3] then p = a .. ":" .. b end
end
out[#out + 1] = p .. (redis.call("TTL", k) == -1 and " no-ttl" or " ttl")
end
return out
ARGV[3] 是服务前缀 my-service,带这个前缀的 key 多保留一段,这样新缓存的 my-service:users 和过渡版本写下的 my-service:Users 能分开统计。盘点结果:
redis: redis_version:7.4.2 db=3 2026-11-04T15:05:45Z
before: db3:keys=73,expires=0,avg_ttl=0,subexpiry=0
clients on db3: 2
prefix / ttl / count:
67 Users no-ttl
4 Currencies no-ttl
1 CurrencyMappings no-ttl
1 ApiKeys no-ttl
clients on db3: 2 里有一个是 redis-cli 自己,它用 -n 3 选了这个库。INFO keyspace 那一行只是计数,不遍历 key,它给出的 73 和四种前缀加起来的数一致:全是旧名字,全没有 TTL,库里没有别的服务的 key。新缓存的 key 一个都没有:新缓存最长的 TTL 是一小时,说明这一小时里没有请求用到缓存。
删除:每一步在 Lua 里 SCAN、判断 TTL、UNLINK#
旧代码一共用过七个缓存名。升级过程中有一个过渡版本把旧名字写在了服务前缀下面(my-service:Users:…),所以每个名字查两种写法,一共 14 个匹配模式。Redis 的 glob 匹配区分大小写,新缓存的 my-service:users:v1:… 匹配不到 my-service:Users:*。
每个模式重复执行同一段脚本:
local res = redis.call("SCAN", ARGV[1], "MATCH", ARGV[2], "COUNT", ARGV[3])
local seen, nottl, removed = 0, 0, 0
for _, k in ipairs(res[2]) do
seen = seen + 1
if redis.call("TTL", k) == -1 then
nottl = nottl + 1
if ARGV[4] == "run" then removed = removed + redis.call("UNLINK", k) end
end
end
return {res[1], seen, nottl, removed}
一段脚本在 Redis 里是原子执行的,执行期间服务端的其他活动全部阻塞(Scripting with Lua)。所以每次 EVAL 只处理一步 SCAN 拿到的那批 key,COUNT 500 把每批控制在几百个左右(文档不保证每批的具体数量),两次 EVAL 之间停 50 ms,让其他客户端的命令有机会执行。
只删 TTL 返回 -1 的 key,也就是没有设过期时间的。旧 key 都没有 TTL,新缓存的 key 一定有,就算哪个匹配模式写错了,也删不到新缓存。匹配上但带 TTL 的 key 留着,它们会自己过期。
删除用 UNLINK。它在主线程里只把 key 从 keyspace 里摘掉,内存回收放到另一个线程里做(UNLINK)。这台 Redis 的 lazyfree-lazy-user-del 是 no,DEL 会在主线程里同步释放内存(redis.conf)。缓存值都不大,但用了 UNLINK 就不用再去确认值的大小。
key 名始终留在服务端,脚本返回给客户端的只有游标和三个计数。key 名里有空格或引号也没关系;常见的 redis-cli --scan | xargs redis-cli unlink 遇到这样的名字,会把一个 key 拆成几个参数。
这种写法和官方文档的一条要求有冲突:脚本访问的 key 都应该通过 KEYS 参数传进去,不该访问运行时才拿到的 key 名,文档给的理由是让脚本在单机和 cluster 部署下都能正确执行(Scripting with Lua)。在单机上访问 SCAN 拿到的 key 没有问题,这次的运行和后面的本地测试都是这个结果;到了 cluster 上会遇到什么,最后一节有实测。
ApiKeys: 和 Credentials: 排在最前面,这两类带着明文 secret,最先删掉。
外层的 shell 循环对每个模式从游标 0 调起,直到游标回到 0:
clean() {
for n in $OLD_NAMES; do
for pat in "$n:*" "$SVC:$n:*"; do
cursor=0 seen=0 nottl=0 removed=0
while :; do
out=$(r EVAL "$CLEAN_LUA" 0 "$cursor" "$pat" "$COUNT" "$1")
set -- "$1" $out
numeric "${2:-}" || { echo "EVAL failed on $pat: $(printf '%s' "$out" | head -1 | cut -c1-160)"; exit 1; }
cursor=$2 seen=$((seen + $3)) nottl=$((nottl + $4)) removed=$((removed + $5))
[ "$cursor" = 0 ] && break
sleep "$PAUSE"
done
printf '%-40s matched=%-6s no-ttl=%-6s unlinked=%s\n' "$pat" "$seen" "$nottl" "$removed"
done
done
}
完整脚本在 cleanup.sh,有三种模式。inventory 是上一节的盘点;dry 逐个模式统计匹配数,什么都不删;run 才真正删除,而且必须多带一个参数 confirm。
脚本开头先 PING 一次,stderr 有任何输出就停下,因为 redis-cli 的 -n 选库失败时只在 stderr 打一行,然后在 db 0 上继续执行。本地用一个超出范围的库号试过:
连接或选库失败,未做任何操作:SELECT 20 failed: ERR DB index is out of range
不加这个检查,库号写错的那次运行会在 db 0 上删同名前缀的 key。脚本在 redis:7.4-alpine 镜像里跑,密码放在 REDISCLI_AUTH 环境变量里,这是 redis-cli 文档推荐的传法(redis-cli)。docker run -e REDISCLI_AUTH 只写变量名,值从当前 shell 的环境带进容器,不会出现在命令行和 shell 历史里:
docker run --rm -i -e REDISCLI_AUTH -e REDIS_HOST -e REDIS_DB \
redis:7.4-alpine sh -s dry < cleanup.sh
dry run 匹配到 1 + 67 + 4 + 1 = 73 个,和盘点一致,然后才跑 run confirm:
redis: redis_version:7.4.2 db=3 2026-11-04T15:06:53Z
before: db3:keys=73,expires=0,avg_ttl=0,subexpiry=0
ApiKeys:* matched=1 no-ttl=1 unlinked=1
my-service:ApiKeys:* matched=0 no-ttl=0 unlinked=0
Credentials:* matched=0 no-ttl=0 unlinked=0
my-service:Credentials:* matched=0 no-ttl=0 unlinked=0
Users:* matched=67 no-ttl=67 unlinked=67
my-service:Users:* matched=0 no-ttl=0 unlinked=0
Tokens:* matched=0 no-ttl=0 unlinked=0
my-service:Tokens:* matched=0 no-ttl=0 unlinked=0
Currencies:* matched=4 no-ttl=4 unlinked=4
my-service:Currencies:* matched=0 no-ttl=0 unlinked=0
Products:* matched=0 no-ttl=0 unlinked=0
my-service:Products:* matched=0 no-ttl=0 unlinked=0
CurrencyMappings:* matched=1 no-ttl=1 unlinked=1
my-service:CurrencyMappings:* matched=0 no-ttl=0 unlinked=0
after: db3: (empty)
prefix / ttl / count:
脚本在测试环境之外也用一个本地 Redis 7.4.2 跑过一遍:库里有 1200 个 Users: 旧 key,外加一个名字带空格的旧 key、一个带 TTL 的旧名字、一个新缓存的 key、一个别的应用的 key,另一个库里还有一个同样前缀的 key。1200 个 key 要分好几步 SCAN 才能走完,跑完只删掉了旧名字里没有 TTL 的那些,其余都还在。这一遍和最后一节 cluster 上的几次实验整理成了 redis-lab 里的一个案例:standalone.sh 和 cluster.sh 各自用 Docker 起 Redis、写数据、跑 cleanup.sh 并核对结果,输出存在 results/ 下。
删完确认没有阻塞节点、没有动到别的库#
SCAN 的 MATCH 是在元素取出来之后、返回给客户端之前才过滤的(SCAN),所以每个模式里 SCAN 这一部分的开销跟库里 key 的总数成正比,跟匹配到多少个无关。14 个模式等于把这个库扫了 14 遍,每遍 73 个 key。SCAN 只遍历当前选中的库,同节点其他库的 key 不在里面。
事后从三个地方确认。
SLOWLOG 记录执行时间超过阈值的命令。这个时间不含和客户端收发数据的 I/O,只算命令本身执行的部分(SLOWLOG GET),正好就是其他客户端要排队等的那段时间。看这台 Redis 的阈值和最新一条:
rcli CONFIG GET slowlog-log-slower-than
rcli CONFIG GET slowlog-max-len
rcli SLOWLOG LEN
rcli SLOWLOG GET 1 | head -2
slowlog-log-slower-than
10000
slowlog-max-len
128
128
1170
1793761200
阈值是 10 ms,慢日志已经存满 128 条,最新一条(id 1170)的时间戳 1793761200 是 2026-11-04 03:00:00 UTC,比清理早了十二个小时。清理期间如果有哪一次 EVAL 超过 10 ms,它会成为最新的一条。所以删除那一轮的每一次 EVAL 都在 10 ms 以内,同一时段节点上也没有别的命令超过 10 ms。
INFO keyspace 前后对比,db3 那一行没了,其余几个库的 key 数没变。清理前:
db0:keys=102,expires=102,avg_ttl=26615994,subexpiry=0
db1:keys=124,expires=0,avg_ttl=0,subexpiry=0
db3:keys=73,expires=0,avg_ttl=0,subexpiry=0
db4:keys=1,expires=1,avg_ttl=24997617,subexpiry=0
清理后:
db0:keys=102,expires=102,avg_ttl=26592724,subexpiry=0
db1:keys=124,expires=0,avg_ttl=0,subexpiry=0
db4:keys=1,expires=1,avg_ttl=24936430,subexpiry=0
应用这边,Pod 一直 Ready,没有重启,清理之后没有新的 ERROR 日志。
换到 ElastiCache 或 cluster 模式时要改的步骤#
前面的步骤跑在一台自建的单机 Redis 上。放到 ElastiCache 上,差别来自两层:托管服务禁掉了一部分命令,而且只能在 VPC 里访问;cluster 模式又把 key 分散到了多个节点上,这一层自建的 Redis Cluster 也一样。按步骤对比三种部署:
| 步骤 | 自建单机 | ElastiCache(cluster mode disabled) | ElastiCache(cluster mode enabled) |
|---|---|---|---|
| 从哪里连 | 任何能连上的机器 | VPC 里的机器,连 primary endpoint | VPC 里的机器,逐个直连每个主节点 |
| 服务之间怎么隔开 | 库号 | 库号 | 只有 db 0,只能靠前缀 |
| 盘点和删除 | 跑一次 | 跑一次 | 每个主节点各跑一次 |
看 slowlog 阈值和 lazyfree-lazy-user-del |
CONFIG GET |
查 parameter group | 查 parameter group |
| 删完的验证 | SLOWLOG、INFO keyspace |
SLOWLOG 或投递出去的慢日志、INFO keyspace |
每个主节点的 SLOWLOG 和 INFO keyspace |
| 不能执行的时段 | 没有特别限制 | 没有特别限制 | 在线加减 shard、rebalance 期间 |
ElastiCache(cluster mode disabled)#
ElastiCache 禁掉了 CONFIG 命令(受限命令清单),CONFIG GET slowlog-log-slower-than 这一步跑不通,阈值和 lazyfree-lazy-user-del 要从集群挂的 parameter group 里查。redis7 默认参数组里,这几个参数的值和这次那台自建 Redis 一样:
aws elasticache describe-engine-default-parameters --cache-parameter-group-family redis7 \
--query "EngineDefaults.Parameters[?ParameterName=='slowlog-log-slower-than' || ParameterName=='slowlog-max-len' || ParameterName=='lazyfree-lazy-user-del'].[ParameterName,ParameterValue,IsModifiable,MinimumEngineVersion]" \
--output text
lazyfree-lazy-user-del no True 7.0.0
slowlog-log-slower-than 10000 True 7.0.0
slowlog-max-len 128 True 7.0.0
集群挂的是自定义参数组的话,把命令换成 describe-cache-parameters --cache-parameter-group-name <参数组名>,查询条件不变。
脚本用到的 INFO、CLIENT LIST、EVAL、SCAN、TTL、UNLINK 和验证用的 SLOWLOG 都不在受限清单里。CLIENT LIST 的结果里会多出几个 ElastiCache 自己的连接,AWS 的说法是它用 4 到 6 个连接监控集群(CurrConnections),核对连接时别把它们当成别的服务。
ElastiCache 的 node-based 集群没有公网地址,只能从同一个 VPC 或打通的 VPC 里的机器访问(Accessing your cluster),脚本要放在 VPC 里的机器或 Pod 上跑。写操作走 primary endpoint(Finding connection endpoints);连到只读副本上,dry run 照样能跑,run 会在第一次 UNLINK 时报错停下。开了传输加密的集群,设 REDIS_TLS=1,脚本会给 redis-cli 加上 --tls。
执行期间可以盯 CloudWatch 的 EngineCPUUtilization,它是 Redis 引擎线程的 CPU,再加上 EvalBasedCmdsLatency 和 KeyBasedCmdsLatency 两个延迟指标;这些指标都按节点计算(Metrics for Valkey and Redis OSS)。CurrItems 是所有库的 key 数之和,只清理一个库的时候,还是用 INFO keyspace 看那一个库。慢日志除了 SLOWLOG GET,还可以打开 log delivery,投递到 CloudWatch Logs 或 Amazon Data Firehose(Log delivery),事后按时间段查。
ElastiCache Serverless 又多禁了 CLIENT LIST、SLOWLOG、KEYS 等命令,也不支持 log delivery(同样见上面两份文档),前面的连接核对和慢日志验证在那里都做不了,这篇没有覆盖 Serverless。
ElastiCache(cluster mode enabled)和自建 Redis Cluster#
这一部分在本地一个三主节点的 Redis 7.4.12 cluster 上测过,脚本是 redis-lab 那个案例里的 cluster.sh:Docker 里起三个 redis-server --cluster-enabled yes,用 cluster 客户端写进 3000 个 Users: 旧 key,再混进几个带 TTL 的 key、新缓存的 key 和别的应用的 key。ElastiCache 的 cluster mode enabled 要求客户端支持 Redis Cluster 协议(Finding connection endpoints),下面几处差别在它上面同样存在,不过这次没有在 ElastiCache 上实际跑过。要改的地方有三处。
第一处是库号。cluster 只有 db 0,不允许 SELECT(Redis cluster specification)。redis-cli 的 -n 在这里的表现和库号越界时一样,打一行错误,命令照常执行:
redis-cli -h "$CLUSTER_HOST" --no-auth-warning -n 3 PING 2>&1
SELECT 3 failed: ERR SELECT is not allowed in cluster mode
PONG
拿单机时的库号去跑 cluster,删的就是整个 keyspace 里同名前缀的 key。脚本开头的选库检查在这里同样会拦下来:
连接或选库失败,未做任何操作:SELECT 3 failed: ERR SELECT is not allowed in cluster mode
只有一个库,也就没法按库号确认「这个库里没有别的服务」,盘点结果里有没有别的服务的前缀成了唯一的依据。
第二处是 SCAN 的范围,它只遍历当前连接的那个节点上的 key。盘点和删除都要对每个主节点各跑一次,直连节点,不加 -c。主节点从 CLUSTER NODES 里取:
redis-cli -h "$CLUSTER_HOST" --no-auth-warning CLUSTER NODES \
| awk '$3 ~ /master/ && $3 !~ /fail/ {split($2, a, "@"); split(a[1], b, ":"); print b[1], b[2]}' \
| while read -r host port; do
echo "--- $host:$port"
docker run --rm -i -e REDISCLI_AUTH -e REDIS_HOST="$host" -e REDIS_PORT="$port" -e REDIS_DB=0 \
redis:7.4-alpine sh -s inventory < cleanup.sh
done
本地三个主节点分别盘点出 993、1001、1006 个 Users: key,加起来正好是写进去的 3000 个。INFO keyspace、SLOWLOG、CLIENT LIST 也都是每个节点各一份,redis-cli 的 --cluster call 可以在所有主节点上执行同一条命令:
redis-cli --no-auth-warning --cluster call "$CLUSTER_HOST:6379" INFO keyspace --cluster-only-masters \
| tr -d '\r' | grep -E 'db0|^[0-9]'
192.168.107.2:6379: # Keyspace
db0:keys=1006,expires=0,avg_ttl=0,subexpiry=0
192.168.107.4:6379: # Keyspace
db0:keys=994,expires=1,avg_ttl=3599692,subexpiry=0
192.168.107.3:6379: # Keyspace
db0:keys=1006,expires=3,avg_ttl=3599791,subexpiry=0
第三处是 Lua 脚本碰上 slot 迁移。不带 #! 头的 EVAL 跑在兼容模式下,Redis 允许它访问不同 slot 的 key(Eval flags,实现见 script.c;加了 #!lua 头的脚本默认不允许,要声明 allow-cross-slot-keys,见 Script flags)。SCAN 又只返回本节点的 key,所以平时脚本对每个主节点都能直接用。本地把一个装着 Users: key 的 slot 设成 MIGRATING 之后,在源节点上跑就报错了:
EVAL failed on Users:*: ERR Script attempted to access a non local key in a cluster node script: 5b74e86c7b81e5ce82dbcd85f7b6ffdf98eb9585, on @user_script:6.
脚本第 6 行是 TTL,它碰到迁移中的 slot 里的 key 时报错,这次 EVAL 中止,外层循环跟着停下。报错之前已经执行的 UNLINK 不会回滚:那次 run 停下时,源节点从 1006 个 key 变成了 973 个,删掉的 33 个都是 Users: 旧 key,只是那一行的计数没来得及打印。slot 恢复稳定之后重跑,三个节点都删完了,剩下的正好是带 TTL 的旧名字、新缓存的 key 和别的应用的 key。脚本可以重复跑,第二次只会处理剩下的 key。
ElastiCache 上 slot 会在在线加减 shard 和 rebalance 的时候移动,这两种操作都在集群继续服务的同时,把 keyspace 在 shard 之间挪动(Scaling clusters)。清理前先确认复制组的状态是 available,没有处在 modifying(ReplicationGroup);跑到一半报了上面的错,等扩缩容结束再重跑。
小结#
删一批缓存 key 之前,要先回答三个问题:还有没有进程在用、库里有没有别人、一共有多少。三个问题都有了答案,删除本身只是一段十行左右的 Lua:每一步只处理一次 SCAN 的那批 key,只动没有 TTL 的旧名字,用 UNLINK,key 名不出服务端。删完看 SLOWLOG,它记录的正是会阻塞其他客户端的那段执行时间。
这次的库只有 73 个 key,库大的时候还要再改几处:14 个模式改成一遍 SCAN,在 Lua 里一次判断所有前缀,免得同一个大库被扫 14 遍;非 secret 的前缀更稳妥的做法是设一个随机的几小时 TTL,让它们自己过期,给回滚留出窗口,secret 类照样立刻删;执行时选低峰时段,节点 CPU 过高就暂停。
换到 ElastiCache,删除的做法不变,变的是周边:配置从 parameter group 查,脚本放到 VPC 里跑,执行期间看 CloudWatch。cluster 模式下要逐个主节点执行,只能用 db 0,并且避开 slot 迁移。redis-cli 选库失败之后会在 db 0 上照常执行,单机和 cluster 上都可能遇到,脚本开头那个检查最好留着。