这一篇讲三件事,Redis 到底是个什么东西,怎么把它装起来跑顺,以及它的 key 该怎么设计和管理。读完你应该能自己判断一个需求适不适合放进 Redis、把一套能上线的 Redis 配好、并且在面对几十万个 key 的时候知道该用哪条命令。
我不打算把它写成命令速查表。命令查文档就有了,值得写下来的是取舍,比如为什么 Redis 的删除要分 DEL 和 UNLINK,为什么 KEYS 在线上基本等于自残,为什么大部分团队配的 redis.conf 里总有一两个参数是缺的。
三块内容里,第三块篇幅最重。原因很简单,前两块配错了顶多是性能不好,key 设计和清理这块做错了,是能在几秒钟内把整个实例拖死的。
Redis 是什么,为什么它这么快
Redis 的定位
官方的说法是,Redis 是一个开源的内存数据结构服务器,可以用作数据库、缓存和消息代理。这句话里最容易被读漏的是「数据结构服务器」7个字。
很多人第一次接触 Redis,学到的只有 SET 和 GET,然后脑子里就把它归类成一个放在内存里的字典。等遇到排行榜、会话共享、限流这些需求,才发现它能干的事情比想象中多得多。因为它提供的不是一个字符串映射,而是一组带着语义的数据结构,字符串、哈希、列表、集合、有序集合、位图、HyperLogLog、地理索引、Stream,每一种背后都有成体系的命令,而且每条命令都是原子执行的。
换个说法,Redis 的价值不在于「快」,在于「用对的数据结构,用极短的时间,做一件原本要绕很远的事」。你想想看,一个实时排行榜用 SQL 写要排序、分页、算排名,用 Redis 的有序集合可能就是两三行命令。
Redis 与 MySQL 的区别
把 Redis 和 MySQL 摆在一起比,其实有点不公平,它们服务的不是同一件事。但正因为太多团队在选择时混淆了这两者,所以这张表还是值得过一遍。
| 对比项 | Redis | MySQL |
|---|---|---|
| 定位 | 内存数据结构服务器,缓存与实时计算 | 关系型数据库,持久化的事实来源 |
| 数据模型 | key-value,值可以是九种结构之一 | 表、行、列,有 schema 约束 |
| 存储介质 | 主要在内存,磁盘只是备份 | 主要在磁盘,内存只做缓冲 |
| 持久化 | RDB 快照与 AOF 日志,可关可开 | 写日志与数据文件,事务保证落盘 |
| 事务 | 命令打包执行,不保证回滚 | ACID,支持回滚与隔离级别 |
| 查询能力 | 按 key 取,靠结构提供的命令 | 任意条件、关联、聚合 |
| 扩展方式 | 主从、哨兵、Cluster 分片 | 主从、分库分表、读写分离 |
| 容量边界 | 受内存限制,通常单实例几十 GB | 受磁盘限制,可以很大 |
表格看完要说句公道话,MySQL 能做的事,Redis 大部分做不了,尤其是带条件的复杂查询和严格事务。所以这不是替代关系,是分层关系。MySQL 管「数据的最终形态」,Redis 管「访问数据的最近一次」。真正会出问题的选型,是把 Redis 当唯一存储,或者把 Redis 当 SQL 用,试图在里面模拟 WHERE 和 JOIN。
Redis 常见使用场景
场景这块我不想一次性罗列完,因为那样记不住。下面每个都带一句判断,你可以对照自己的业务看。
缓存是最主流的用法,值通常落在 String 或者 Hash 上。它是 Redis 的舒适区,因为读写比例高、数据可重建、丢一点没关系。要提醒的是,缓存必然带来一致性问题,值过期之后回源的那一瞬间要不要加锁,是单独的话题。
分布式锁用 String 加 SET key value NX EX 就能实现,锁的存在靠 key,过期靠 TTL,释放靠删除。这里有个细节,锁的续期和释放时的身份校验都不能想当然,否则并发下会互相把对方的锁删掉。
计数器落在 String 上,靠 INCR 和 INCRBY。命令执行是单线程的,所以自增天然原子,不需要额外加锁。点赞数、库存、未读数这类高频累加,用它比用数据库里的 UPDATE ... SET n = n + 1 省事得多。
排行榜落在 ZSet 上,score 是分数,member 是主体,ZRANGE 和 ZREVRANGE 直接给出名次区间。这是少数几个用关系型数据库写起来特别别扭、用 Redis 特别自然的场景。
会话共享也落在 String 或 Hash 上。多台应用机器把登录态写进 Redis,任何一台都能读到,应用重启也不会丢登录。代价是每次请求都要多一次网络往返,所以本地缓存和 Redis 之间通常还要再分一层。
消息队列可以用 List 的 LPUSH 加 BRPOP,也可以用 Stream 配合消费者组。坦率的讲,Redis 做队列的可靠投递能力不如 Kafka、RabbitMQ 这类专门的东西,它的优势是轻,适合任务分发这种丢了也能重跑的场景。
限流通常用 String 计数器或者 ZSet 的滑动窗口。单机限流用应用内存就够了,一旦是多机部署,计数必须集中,Redis 就成了最顺手的选择。
签到用 Bitmap,一个用户一年 365 天只占几十个字节,SETBIT 和 GETBIT 就够。UV 统计用 HyperLogLog,它不存具体用户,只估算基数,代价是有误差,好处是百万级 UV 的内存占用小到可以忽略。
这些场景之所以都能在 Redis 里落地,回到刚才那句话,是因为它给的是数据结构,不是一张表。
内存数据库为什么快
「快」这个结论几乎人人都知道,但解释经常含糊。入门阶段记住四条就够用,不用往底层钻。
内存访问的延迟在纳秒级,磁盘随机读在毫秒级,中间差了四到五个数量级。数据常驻内存,就省掉了这一层等待。这是最根本的原因,其他三条都是在它之上做优化。
单线程执行命令,免掉了多线程里最烦人的锁竞争。你想,一个数据结构如果同时只能被一个线程改,那就不需要加锁,不需要处理死锁,也不会有线程切换时 CPU 缓存失效的问题。Redis 把这层复杂度直接取消了。
IO 多路复用让一个线程能同时盯着大量连接。传统阻塞 IO 里,一个连接要占一个线程,几千连接就是几千线程;多路复用下,事件到了才处理,空闲连接几乎不占资源。
高效的数据结构是最后一条。Redis 对不同类型用了不同的底层实现,小对象用紧凑的连续内存,元素多了才升级成哈希表或者跳表。同一份数据,选对编码能省下大量内存和指针跳转。
这四条里,只有第一条是「内存」的功劳,剩下三条都是设计上的取舍。它们加在一起,才构成了那个被反复引用的性能数字。
快不是某一处的胜利。
单线程模型的基本理解
关于单线程,有个误解传播得特别广,就是「Redis 从头到尾只有一个线程」。这个说法在 6.0 之前大致成立,现在是错的。
准确的说法是,执行命令的那条路径是单线程的。所有客户端发来的读写命令,进入同一个事件循环,排队、执行、返回,不会有两个命令同时改同一个 key。这保证了原子性,也保证了你在 Redis 里几乎不用考虑加锁。
网络读写这部分,从 Redis 6.0 开始可以交给多个线程。对应的配置是 io-threads,默认值是 1,也就是不启用;如果要用,官方建议的值是不超过 8。另外还有 io-threads-do-reads,默认是 no,用来决定读操作是否也走多线程。这两个参数在纯缓存、小 value 的场景下收益不明显,value 大、带宽打满的时候才有感觉。
还有一批后台线程,专门处理那些不该堵住主线程的活。最典型的是 UNLINK 触发的异步释放、FLUSHALL ASYNC 的异步清库、AOF 的刷盘和 fsync、以及无盘复制时的数据搬运。这些线程的存在,是为了让耗时的内存释放和磁盘 IO 不占用命令执行的时间片。
所以判断标准很简单,一条命令从进入事件循环到返回结果,这段是单线程的;命令之外的数据搬运和 IO,可以交给别的线程。后面讲 UNLINK 的时候还会回到这个区别上。
Redis 安装与基本使用
前面说的是它是什么,接下来要让它跑起来。安装本身没什么技术含量,真正容易出事的环节是配置文件。
Linux 与 Docker 两种部署方式
先说源码编译。下载发行包,make 一下就能得到 redis-server 和 redis-cli 两个可执行文件,编译过程中需要 gcc 和 make。好处是能装到仓库里还没有的新版本,也能自己调编译参数;代价是后续升级要自己盯着,依赖也要自己处理。我个人觉得,除非你有明确的版本或者编译需求,否则没必要走这条路。
包管理器最省事,Debian 和 Ubuntu 上是 apt install redis-server,CentOS 和 Rocky 上是 yum install redis。装完通常已经配好了 systemd 服务,开箱能用。要注意的是仓库里的版本往往落后于最新稳定版,有些新特性得等,也可能缺安全补丁。
Docker 的好处是干净,不污染宿主机,一行命令就能起。但有几个坑必须躲开,数据要挂卷,否则容器一删数据全没;端口映射默认绑到所有网卡,等于把 Redis 直接放到公网;密码要通过配置文件或者启动参数给进去,别用默认的空密码。
bash
# Docker 启动,数据挂到命名卷,端口只绑本机,配置文件从宿主机挂进去
docker run -d --name redis \
-p 127.0.0.1:6379:6379 \
-v redis-data:/data \
-v /etc/redis/redis.conf:/etc/redis/redis.conf \
redis:7.2 redis-server /etc/redis/redis.conf
这三种方式我一般这么选,本地开发用 Docker 或者包管理器,图快;生产环境用包管理器配合自己的配置管理工具,图稳;需要特定版本或者特殊编译参数时再考虑源码。
redis-server 与 systemd 托管
redis-server 不带参数启动会用内置的默认配置,适合临时验证。只要带上配置文件路径,就会按文件里的配置来,这也是所有生产部署的标准姿势。
bash
# 用指定配置启动
redis-server /etc/redis/redis.conf
# 老式做法,让进程自己转入后台
redis-server /etc/redis/redis.conf --daemonize yes
# 命令行参数会覆盖配置文件里的同名项
redis-server /etc/redis/redis.conf --port 6380
关于 --daemonize yes,值得说一句,现在不太推荐了。它让 Redis 自己 fork 到后台,PID 和日志的归属会变模糊,出问题不好追。在容器里更是反模式,容器需要一个前台进程撑着,后台化之后容器会立刻退出。
所以生产上更稳的是交给 systemd 托管。写一个 unit 文件,用 ExecStart 指向 redis-server 和配置文件,配好 Restart,然后用 systemctl start redis、systemctl status redis 管它,日志走 journalctl -u redis。这样进程崩了能自动拉起,开机也能自启,比手动后台化干净得多。
redis-cli 常用交互
装好之后第一件事是连上去看看。redis-cli 是自带的客户端,参数不多但每个都有用。
bash
# 连远程实例,带密码,关掉密码明文警告
redis-cli -h 10.0.0.12 -p 6379 -a 'your-password' --no-auth-warning
# 用 ACL 用户连接
redis-cli -h 10.0.0.12 -p 6379 --user app_user -a 'your-password'
# 连着敲命令,进交互模式
redis-cli -h 10.0.0.12 -p 6379
-h 是主机,-p 是端口,不传就默认本机 6379。-a 传密码,--no-auth-warning 用来关掉那条提示密码明文写在命令行里的警告,生产环境更推荐用 REDISCLI_AUTH 环境变量或者 ACL 认证。--user 是 Redis 6.0 引入的 ACL 用户名,配合密码使用。
连上之后,PING 是最快的存活检查,正常会回一个 PONG。
redis
PING
确认活着以后,INFO 用来摸清实例的状态,返回按段落分组,Server 段有版本和运行时长,Clients 段有连接数,Memory 段有内存占用,Stats 段有命中率和命令调用次数。排查问题时,INFO memory 和 INFO stats 是最常看的两个。CONFIG GET 和 CONFIG SET 用来读改配置,改的是运行时值,重启就没了,线上临时调参之后记得同步回配置文件。
redis-cli 还有几个很实用的模式。--scan 会走 SCAN 而不是 KEYS,遍历大实例时安全得多。--pipe 用来把命令批量灌进去,导入数据比一条条敲快几个数量级。--bigkeys 会扫一遍找出占用空间最大的 key,这是线上巡检的常用手段,不过它自己也是要遍历的,别在高峰期跑。
必须调的那几个配置
真正决定这套 Redis 能不能上线的,是 redis.conf 里的十几个参数。下面这些我按「不配会怎样」来说,因为这样更容易记住。
bind 决定监听哪些地址。不配或者配成 0.0.0.0,就是所有网卡都收,只要机器能被访问到,Redis 就能被访问到。正确的做法是只绑内网地址或者 127.0.0.1。
protected-mode 默认是 yes。它的作用是,当既没有配密码、又没有限制 bind 的时候,只接受本机连接。很多人为了图方便把它设成 no,那等于把这层兜底也撤了。
conf
# 只监听本机与内网网卡
bind 127.0.0.1 10.0.0.12
# 保持保护模式开启
protected-mode yes
# 设置密码
requirepass your-strong-password
# Redis 6.0 起的 ACL,按应用分配用户和权限范围
# user app_user on >password ~app:* +@read +@write
认证这块,requirepass 是最低门槛,但它的粒度太粗,一个密码所有客户端共用。Redis 6.0 引入 ACL 之后,可以给不同应用开不同的用户,限定它们能访问的 key 前缀和能执行的命令类别。这样某个应用被攻破,损失范围是可控的。
安全这件事我不想讲得太轻描淡写。Redis 暴露在公网被扫到、然后被写入定时任务或者勒索,是这些年反复发生的真实事件,不是理论风险。把 bind 收紧、开着 protected-mode、设强密码、换成非默认端口,这四条是基本盘。有人会想到用 rename-command 把 CONFIG、FLUSHALL 这些危险命令改名,这确实能提高一点门槛,但坦率的讲,它不该是唯一防线,攻击者一旦拿到了认证凭据,能走的路还有很多。真正的防线在网络层和认证层,rename-command 只算锦上添花。
这条线不能只画在配置文件的某一处。
内存相关的两个参数是一对。maxmemory 不配,Redis 会一直往上吃,直到被系统 OOM killer 干掉,或者把机器拖垮。配上之后,还得有 maxmemory-policy 决定内存满了怎么办。noeviction 是拒绝写请求,适合当数据库用;allkeys-lru 和 allkeys-lfu 适合纯缓存,所有 key 都可能被淘汰;volatile-lru 和 volatile-lfu 只在设了过期时间的 key 里淘汰,适合缓存和持久数据混在一个实例的场景。选错了策略,可能出现「该淘汰的没淘汰,不该淘汰的没了」。
持久化相关的,appendonly 决定要不要开 AOF,开了数据更不容易丢,代价是额外的磁盘 IO。save 是 RDB 快照的触发条件,写成空字符串就是彻底关掉快照,很多纯缓存团队这么干。这两个参数是持久化篇的话题,这里只要记住它们存在、别用默认值糊弄过去就行。
剩下三个参数容易被忽略,daemonize、logfile 和 dir。daemonize 前面说过,生产上建议 no,交给 systemd。logfile 留空的话日志直接打到标准输出,在容器里这是对的,在裸机上就容易被丢掉,最好指定成文件。dir 是持久化文件和日志的落盘目录,不配的话用的是进程的工作目录,可能落在空间紧张的盘上,也可能因为没有写权限导致持久化静默失败。这个参数踩坑的人特别多,因为失败了不一定有明显报错。
数据库与 key 管理命令
Redis 的实例默认带 16 个数据库,编号 0 到 15,用 SELECT 切换,数量由 databases 这个配置项决定。听起来像是天然的多租户隔离,实际上不是。
redis
# 切到 1 号库
SELECT 1
# 当前库的 key 数量
DBSIZE
我不建议在生产环境用多库做业务隔离。原因有三个,一是很多客户端和连接池对 SELECT 的支持不一致,切换状态可能被连接复用搞乱;二是 Cluster 模式下只有一个库,多库的代码迁移不过去;三是隔离感是假的,所有库共享同一个实例的内存、CPU 和持久化,一个库里的热点能把其他库一起拖慢。更实际的做法是每个业务独立实例,或者至少用 key 前缀区分。
清库的命令有两个,FLUSHDB 清当前库,FLUSHALL 清所有库,两个都带 ASYNC 变体。它们没有确认步骤,敲下去就执行,这在线上是极其危险的。
redis
# 同步清空所有库,阻塞主线程,线上慎用
FLUSHALL
# 异步清空,把内存释放交给后台线程
FLUSHALL ASYNC
如果确实需要在运维脚本里清库,用 ASYNC 版本,至少不会长时间卡住主线程。至于权限,用 ACL 把 FLUSHALL 这类命令从业务账号里禁掉,比靠人记得住要可靠。
日常巡检最常用的几条命令是 DBSIZE、RANDOMKEY、TYPE 和 OBJECT。DBSIZE 给的是当前库的 key 总数,是 O(1) 的,随时可以查。RANDOMKEY 随机返回一个 key,抽样看看 key 长什么样时有用。TYPE 返回这个 key 的类型,OBJECT ENCODING 返回它当前的底层编码,这个在看内存为什么比预期大的时候特别有用。OBJECT FREQ 需要开启 LFU 淘汰策略才有意义,返回的是访问频率;OBJECT IDLETIME 返回的是空闲时间,需要 LRU 策略。
redis
TYPE user:1001:profile
OBJECT ENCODING user:1001:profile
OBJECT IDLETIME user:1001:profile
顺一下思路,到这里实例已经能跑、能连、能管了。真正决定它会不会出事的,是接下来的这块,key 怎么设计,怎么清理,怎么在几百万个 key 面前保持清醒。
Redis Key 的设计与管理
前面所有内容,最后都会收敛到一个地方,就是 key 本身。命名、生命周期、遍历、批量清理,这四件事里的任何一件做砸,都可能变成一次线上事故。所以这一章是整篇最重的。
Key 命名规范
好的 key 名有一个标准,看到它就知道这数据属于哪个业务、是什么对象、标识是什么。达到这个标准的最省事的方式,是用冒号分层。
redis
# 业务:对象:标识 的基本形态
user:1001:profile
order:20250812:count
session:web:8f3c1a
冒号分层不只是好看,它让按前缀遍历变得可能。你要清理某个业务的数据,SCAN 配合 user:* 的匹配就能圈出范围,而如果命名是 user1001profile 这种拼在一起的,就只能靠人肉猜。
长度和可读性之间要取平衡。key 名本身也占内存,几百字节的 key 名在千万级的量上会变成实打实的开销,日志和监控里显示出来也会很难看。我的习惯是前缀用短的业务缩写,对象名保持可读,标识用原始主键,不要为了可读性把整个类名塞进去。
要避开的还有特殊字符。空格、换行、引号、通配符,都会在命令行和脚本里带来麻烦,* 和 ? 尤其危险,因为它们会参与模式匹配。如果 key 里必须包含用户输入的内容,先做一次编码或者哈希,别直接拼。
还有一条是最容易犯的,不要把可变的值拼进 key。比如把时间戳、随机数、请求 ID 拼进去,key 数量就会无限膨胀,而且每个 key 都几乎不会再被访问,缓存命中率被稀释,内存被一堆死数据占满。要按时间维度组织,用有序集合把时间当 score,而不是把时间当 key 的一部分。
EXISTS 与 DEL、UNLINK
判断 key 存不存在用 EXISTS。它可以一次传多个 key,返回的是存在的个数,不是一个布尔值,这点跟很多人的直觉不一样。
redis
EXISTS user:1001:profile
EXISTS user:1001:profile user:1002:profile user:1003:profile
删除有两条命令,DEL 和 UNLINK,它们的区别是本篇最值得记的一个点。
DEL 是同步删除,命令执行期间就要把内存释放掉。如果删的是一个存了几百万元素的列表或者哈希,主线程会一直卡在这个释放过程里,其他请求全部排队等着。这就是大 key 删除引发毛刺的典型路径。
UNLINK 从 Redis 4.0 开始提供,做法是先把这个 key 从键空间里摘掉,让它立刻对客户端不可见,真正的内存释放交给后台线程去做。命令本身很快就返回,主线程不被拖住。
需要说清楚的是,
UNLINK不保证内存立刻释放。摘掉 key 和释放内存是两步,第二步是异步的,所以执行完之后,INFO memory里的占用可能过一会儿才降下来。它优化的是主线程的响应时间,不是内存的释放速度。如果你的线上确实有大 key,光换命令还不够,还要看
lazyfree-lazy-*那一组配置,比如lazyfree-lazy-expire、lazyfree-lazy-eviction、lazyfree-lazy-server-del,它们让过期删除、内存淘汰、以及被覆盖时的旧值释放也走异步路径。默认值都是 no,需要显式打开。
删得快和放得干净,是两件事。
EXPIRE 与 TTL
过期相关的命令有四个写入口,EXPIRE 和 PEXPIRE 传的是相对时间,单位分别是秒和毫秒;EXPIREAT 和 PEXPIREAT 传的是绝对时间戳,单位同样是秒和毫秒。读入口是 TTL 和 PTTL,返回剩余时间。
redis
EXPIRE user:1001:profile 3600
EXPIREAT user:1001:profile 1767225600
TTL user:1001:profile
PERSIST user:1001:profile
TTL 的返回值有两种特殊情况,很多人搞混。返回 -1 表示这个 key 存在,但没设过期时间;返回 -2 表示这个 key 根本不存在。前者是你漏设了 TTL,后者是你的数据没了,含义完全不同,排查的时候别把 -2 当成永久 key。
PERSIST 用来移除过期时间,让 key 变成永久的。用它之前最好想清楚,因为这是一个不可逆的动作。
SET 系列的坑也在这里。用 SET 覆盖一个已经存在的 key 时,默认会把原来的过期时间清掉,除非你显式带上 KEEPTTL。要设过期时间,可以用 EX、PX、EXAT、PXAT 这几个选项,注意 EX 和 EXAT 是秒、PX 和 PXAT 是毫秒,传错了差三个数量级。
redis
# 覆盖值但保留原过期时间
SET user:1001:profile 'new-value' KEEPTTL
# 覆盖值并重设过期时间为 600 秒
SET user:1001:profile 'new-value' EX 600
还有个不太直观的行为,RENAME 会把原 key 的过期时间一起带到新名字上。如果你指望改名之后重新计时,结果会不符合预期。
至于 Redis 是怎么删过期 key 的,记住两句就够了,一句是惰性删除,访问到的时候发现过期就删;一句是定期删除,后台周期性随机抽查一批设了过期时间的 key,把过期的清掉。这两者配合的细节,不在这篇展开。
SCAN 与 KEYS 的区别
SCAN 是遍历 key 空间的标准姿势。它的设计是游标式的,每次调用返回一小批 key 和一个新的游标,你把游标传回去继续下一批,直到游标回到 0。
redis
SCAN 0 MATCH user:* COUNT 100
SCAN 1024 MATCH user:* COUNT 100
它的语义有几个必须记住的点。游标不保证顺序,也不保证结果稳定;同一批 key 可能被返回两次,所以调用方要能容忍重复;COUNT 只是给服务器的一个提示,不是精确的数量承诺,实际返回多少条由实现决定;判断遍历结束的唯一标准是游标回到 0,不是某一批返回为空。中途断开的话,把上次的游标存下来还能接着走。
对应的还有三个兄弟命令,SSCAN 扫集合,HSCAN 扫哈希,ZSCAN 扫有序集合,语义和 SCAN 一致。
那 SCAN 为什么安全?因为它每次只处理一小批,主线程不会被长时间占用,其他请求该响应还是响应。代价也很实在,一是要多次网络往返,遍历一个大实例的总时间比 KEYS 长得多;二是可能重复,业务逻辑要幂等;三是遍历期间的增删改,它不保证你能看到,也不保证看不到。
回到 KEYS 为什么不能在线上乱用。它的行为是遍历整个 key 空间,把匹配的全捞出来一次返回。在单线程执行命令的前提下,这段遍历期间所有其他请求都得等着。库里有几百万 key 的时候,这个等待时间足够让上游连接池打满、接口大面积超时。它本身的时间复杂度是 O(N),N 是 key 的总数,跟你要找的模式没有关系。
所以替代方案有三条,按场景选。只是想看看有哪些 key,用 SCAN 加 MATCH;需要按业务维度频繁圈定一批 key,在业务侧维护一个索引集合,把相关 key 名写进一个 Set 或者 ZSet,查的时候直接读这个集合;要清理一批 key,用 SCAN 配合分批 UNLINK,下面就给脚本。
大量 key 的管理方式
当 key 的数量到了百万级,管理方式就得变了,靠记忆和手工命令是不行的。
按前缀分组是最基础的一步,它让任何批量操作都有了抓手。在这之上,用 Set 或者 ZSet 维护一份 key 索引,是更主动的做法。索引集合里存的是 key 名,业务写入时顺手加进去,删除时顺手移除,查的时候一次 SMEMBERS 或者 ZRANGE 就能拿到全量。它的代价是索引本身也占内存,而且要和数据保持一致,需要设计好失败时的补偿。
批量删除这块,思路是分批,不是一次性清空。每次取一小批,删完歇一下,让主线程有机会处理别的请求。
下面这个脚本用 SCAN 按前缀圈出 key,每批用 UNLINK 异步删除。它适合在运维机器上跑,脚本里用 --scan 让 redis-cli 走游标遍历而不是 KEYS。
bash
#!/usr/bin/env bash
# 按前缀分批清理 key,依赖 redis-cli 的 --scan 走 SCAN
# 用法: ./clean_keys.sh 'user:2024:*' 500
set -euo pipefail
PATTERN="${1:?需要给一个匹配模式,例如 'user:2024:*'}"
BATCH="${2:-500}" # 每批处理的条数,不宜过大
REDIS_CLI=(redis-cli -h 127.0.0.1 -p 6379 --no-auth-warning)
count=0
# --scan 内部使用 SCAN,遍历期间不会长时间阻塞主线程
while IFS= read -r key; do
[ -z "$key" ] && continue
# UNLINK 把 key 从键空间摘掉,内存释放交给后台线程
"${REDIS_CLI[@]}" UNLINK "$key" > /dev/null
count=$((count + 1))
# 每处理 BATCH 条就暂停一小会儿,给主线程留出喘息空间
if [ $((count % BATCH)) -eq 0 ]; then
sleep 0.1
echo "已删除 $count 个 key"
fi
done < <("${REDIS_CLI[@]}" --scan --pattern "$PATTERN")
echo "完成,共删除 $count 个 key"
如果想把分批逻辑也放到服务端,可以用 Lua 脚本,一次调用只处理有限条数,返回是否还有剩余。注意 Redis 的 Lua 脚本执行期间是阻塞的,所以 COUNT 必须给一个小的值,不能写成一次扫完。
lua
-- 每次调用最多删除 batch 条,返回本次删除数和是否还有剩余
-- KEYS[1] 是匹配模式,ARGV[1] 是本次批量大小
local cursor = '0'
local deleted = 0
local batch = tonumber(ARGV[1]) or 100
repeat
local res = redis.call('SCAN', cursor, 'MATCH', KEYS[1], 'COUNT', batch)
cursor = res[1]
for _, k in ipairs(res[2]) do
redis.call('UNLINK', k)
deleted = deleted + 1
if deleted >= batch then
-- 达到批量上限就提前返回,避免长时间占用主线程
return { deleted, 1 }
end
end
until cursor == '0'
return { deleted, 0 }
脚本这种东西,我自己的感受是能不用就不用,能用工具配置解决的不要靠人跑脚本。但如果真的到了要清理几百万元素的时候,有这么一个能控速的东西在手边,比临时敲命令要安全得多。
回到开头那个判断。Redis 快,是内存、单线程、多路复用和数据结构四件事共同的结果;Redis 好用,是因为它提供的是数据结构而不是一张表;而 Redis 会不会成为事故源头,很大程度上取决于你有没有认真对待 key 这件事。命名有没有规范,删除走的是 DEL 还是 UNLINK,遍历用的是 KEYS 还是 SCAN,这些细节单看都很小,堆在一起就是一次故障和一次平稳运行的区别。