那天晚上十一点半,手机突然开始疯狂弹告警,Redis 内存使用率从 60% 直接干到 87%。我一边骂骂咧咧一边打开电脑,连上跳板机,敲了 redis-cli -h 10.0.0.12 -p 6379 info memory。used_memory 显示 14.2G,used_memory_rss 是 16.8G,mem_fragmentation_ratio 1.18,看起来还好。但 maxmemory 设的是 16G,已经快顶到天花板了。我第一反应是哪个业务又在往 Redis 里塞垃圾数据,赶紧用 redis-cli --bigkeys -i 0.1 扫了一遍。这个命令会遍历所有 key,-i 0.1 表示每扫描 100 个 key 休息 0.1 秒,避免把 CPU 打满。结果出来,最大的一个 key 是 user:profile:hash:123456,类型是 hash,大小 2.1G,里面有 1200 万个 field。我当时就懵了,一个用户画像的 hash 能塞这么多东西?后来问业务方,说是每次用户行为都往这个 hash 里 hset 一个新 field,从来没设过期时间,也没清理过。
我第一反应就是删掉它。直接敲了 DEL user:profile:hash:123456。然后,恐怖的事情发生了——整个 Redis 卡住了。所有客户端连接全部超时,监控上看到 Redis 的 QPS 从 8 万直接掉到 0,持续了大概 10 分钟。我当时以为 Redis 挂了,赶紧看日志,没有崩溃,就是主线程在忙。后来查资料才知道,Redis 是单线程处理命令的,DEL 一个 2GB 的 hash,它要释放 1200 万个 field 的内存,这个过程是同步的,会阻塞主线程。而且释放内存不是简单的 free,还要处理各种数据结构,比如 hash 的渐进式 rehash 可能正在发生,就更慢了。那 10 分钟里,我试了 redis-cli -p 6379 ping,返回 PONG 要等好几秒。后来我学乖了,用 UNLINK 代替 DEL。UNLINK 是 Redis 4.0 引入的,它会先把 key 从 keyspace 里摘除,然后扔到后台线程去异步释放内存。我测试过,UNLINK 一个 2GB 的 hash,主线程阻塞时间不到 1 毫秒。而且如果你用的是 Redis 6.0 以上,还可以配置 lazyfree-lazy-user-del yes,这样连 DEL 都会变成异步删除,不过这个参数默认是 no,我劝你别乱开,因为有些业务依赖 DEL 的返回值来判断是否删除成功,异步了就不好说了。
除了 UNLINK,还有几个批量删除的坑。比如你想删一批 key,用 KEYS pattern 然后 DEL,这是找死。KEYS 会遍历所有 key,时间复杂度 O(N),在几千万 key 的实例上能直接卡死。正确做法是用 SCAN 游标分批查,比如 SCAN 0 MATCH user:profile:* COUNT 100,然后对每一批用 UNLINK 删。COUNT 默认是 10,我一般设 100 到 1000,别太大,不然一次返回太多也占内存。还有 FLUSHALL 和 FLUSHDB,默认也是同步的,会阻塞。Redis 4.0 之后可以用 FLUSHALL ASYNC 和 FLUSHDB ASYNC,也是异步释放。另外,如果你的 Redis 版本低于 4.0,没有 UNLINK,那就只能分批删除。比如删 hash,用 HSCAN key 0 COUNT 100 拿到 field,然后 HDEL key field1 field2 ...,每次删几百个,循环直到删完。删 list 可以用 LTRIM key 0 999 保留前 1000 个,然后慢慢删,或者直接 LTRIM key 1000 -1 从另一头删。反正就是别一次性删大 key。
现在说预防。第一,任何写入 Redis 的 key 都必须设过期时间,哪怕你觉得它永远不会过期。我见过太多因为没设 TTL 导致内存爆掉的案例。用 EXPIRE key 86400 或者 SET key value EX 86400。第二,大 key 拆分。比如上面那个 user:profile:hash,可以按用户 ID 取模拆成 100 个 hash,user:profile:hash:{uid%100},每个 hash 最多 12 万个 field,删起来也快。第三,监控。用 redis-cli --bigkeys 定期扫,或者用 Redis 自带的 MEMORY USAGE key 查看单个 key 的内存占用。注意 MEMORY USAGE 在 Redis 4.0 才有,而且对于嵌套结构,它返回的是采样估算值,不是精确值,但够用了。第四,配置 lazyfree。在 redis.conf 里加上 lazyfree-lazy-eviction yes、lazyfree-lazy-expire yes、lazyfree-lazy-server-del yes。这三个分别对应内存淘汰、过期删除、内部删除(比如 rename 时的删除)的异步化。我线上是这么配的,再也没因为删 key 卡过。最后说个观点:很多人一发现大 key 就想着删,但其实应该先看业务能不能改。如果这个 key 是必需的,删了业务就挂,那你就得考虑用 Redis Cluster 分片,把压力分散到多个节点。单个 Redis 实例的内存最好控制在 10G 到 20G 之间,超过 32G 之后 fork 和 RDB 都会变慢,而且主从同步也容易出问题。