平时做技术实践时,很多问题不是概念不会,而是细节没串起来。拿“Redis键管理和数据库管理示例”来说,它看着像小点,放到项目里常会牵出环境、配置、兼容性和维护成本。下面按实际采用顺序,把思路、关键写法和容易踩坑的地方讲清楚,便于大家直接对照操作。
一、单个键的管理
1.1 重命名 RENAME / RENAMENX
RENAME key newkey
RENAMENX key newkey
| 命令 | 行为 |
|---|---|
RENAME | 改名;newkey 已存在则原值被覆盖(先隐式 DEL newkey) |
RENAMENX | 仅当 newkey 不存在时才改名成功,得到 0/1 |
注意:
- key 不存在时 RENAME 直接报错;
- newkey 很大时,可能会阻塞(隐式 DEL)
- 改名后 TTL 会转移到新名字上。
1.1.1 为什么 RENAME 本身是 O(1):哈希表里存的是指针,不是对象本身
要理解这一点,先看 Redis 全局哈希表的结构:
dict
├─ dictht ht[0]
│ └─ table[] 桶数组(每个元素是 dictEntry* 指针)
│ ├─ [0] ──► dictEntry
│ ├─ [1] ──► NULL
│ └─ [2] ──► dictEntry ──► dictEntry(链地址法)
└─ dictht ht[1] // rehash 时启用
桶数组里放的是指向 entry 的指针在这个场景下,,而 entry 结构(源码 dict.h):
typedef struct dictEntry {
void *key; // 指向 key 的 SDS
union {
void *val; // 指向 redisObject(真正的值)
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next; // 同桶冲突链
} dictEntry;
重点:key 和 val 都是 void * 指针理解这一步时,。真正的大对象(大 hash、大 list、大 string……)在内存的另一个位置,哈希表本身只占"一堆指针 + entry 结构"。
RENAME 做的是指针搬家,不是拷贝 value:
重命名前:
桶[2] ──► entry ──► key ──► SDS "oldkey"
val ──► redisObject(可能几个 GB,完全不动)
重命名后:
桶[5] ──► entry ──► key ──► SDS "newkey"(新建的短字符串)
val ──► 同一个 redisObject ◄── 指针直接指过去
(旧 entry 被摘除,旧 SDS 释放)
源码 db.c 的 renameGenericCommand 核心逻辑:
o = lookupKeyWrite(c->db, oldkey); // o 是 redisObject 指针
dbAdd(c->db, newkey, o); // 新 entry 的 val 直接存 o
dbAsyncDelete(c->db, oldkey); // 摘旧 entry,o 仍被新 entry 引用,不会释放
字典类型没有定义 valDup,所以新 entry 里是 entry->v.val = val,直接复制 8 字节地址,不复制对象。
key 字符串能不能原地改?不能。
- 因为 key 是哈希计算的输入,桶位置是
hash(key) % 桶数算出来的;原地改字符串会导致哈希值对不上,以后再也定位不到。 - 从实现思路看,所以要对新 key 重新算哈希、定位新桶、插入新 entry,再删旧 entry;新 key 用
sdsdup复制一份,旧 SDS 释放。key 通常很短,这个成本是常数级。
| 步骤 | 动作 | 成本 |
|---|---|---|
| 1 | 查找 oldkey 的 entry | 平均 O(1) |
| 2 | 对 newkey 算哈希、定位桶、新建 entry | 平均 O(1) |
| 3 | 新 entry 的 val 指针 = 原 redisObject 地址 | O(1),不拷贝值 |
| 4 | sdsdup 新 key、释放旧 SDS | 常数级 |
| 5 | 摘除旧 entry | 平均 O(1) |
那之前说的阻塞风险在哪?唯一的 O(N) 来自 newkey 已存在:要先释放 newkey 原来的 redisObject,如果那个旧对象是大 list/大 hash,释放内部海量元素需 O(N),这才是大 key 场景下重命名要小心的地方,与被迁移的 value 大小无关。
1.2 随机得到一个键 RANDOMKEY
RANDOMKEY # 随机返回一个 key,不删除;库为空返回 nil
1.3 键过期
过期设置命令
| 命令 | 单位/基准 |
|---|---|
EXPIRE key seconds | 相对时间,秒 |
PEXPIRE key ms | 相对时间,毫秒 |
EXPIREAT key timestamp | 绝对时间点,秒级时间戳 |
PEXPIREAT key ms-timestamp | 绝对时间点,毫秒级 |
所有过期命令底层都会转成 PEXPIREAT 存一个毫秒级到期时间。
剩余时间查询:TTL / PTTL 与 EXPIRETIME / PEXPIRETIME 的区别
这两组命令都与"过期"有关,但语义不同:
| 对比项 | TTL / PTTL | EXPIRETIME / PEXPIRETIME(7.0+) |
|---|---|---|
| 得到内容 | 剩余存活时间(相对值,倒计时) | 过期的绝对时间戳(Unix 时间戳) |
| 单位 | TTL 秒 / PTTL 毫秒 | EXPIRETIME 秒 / PEXPIRETIME 毫秒 |
| 多次查询 | 每次都在变小 | 固定不变,就是当初设置的到期点 |
| 无 TTL | 得到 -1 | 得到 -1 |
| key 不存在 | 得到 -2 | 得到 -2 |
一句话:TTL 回答"还能活多久",EXPIRETIME 回答"什么时候死"。
典型用途:
- TTL/PTTL:判断 key 是否快过期、做缓存预热;
- EXPIRETIME:排查"为什么这个 key 突然没了",直接拿到它被设置的准确到期时间点。
1.4 删除过期设置 PERSIST
PERSIST key # 移除 TTL,key 变永久;成功返回 1
7.4 起 Hash 已兼容二级数据结果内部元素过期
HEXPIRE user:1 60 FIELDS 1 name # 给 name 字段设 60 秒
HPEXPIRE / HEXPIREAT / HPEXPIREAT
HTTL / HPTTL
HPERSIST user:1 FIELDS 1 name
List、Set、ZSet 的内部元素仍然不能单独设过期。
1.5 迁移键
方式一:MOVE(同一实例内跨数据库)
同一个 Redis 进程
┌──────────────┐ ┌──────────────┐
│ db0 │ MOVE │ db1 │
│ key:msg ───┼────────►│ key:msg │
│ (源端删除) │ │ (目标写入) │
└──────────────┘ └──────────────┘
MOVE key db
- 用来同实例的 16 个库之间迁移;
- 结合项目来看,源库删除、目标库写入;目标已有同名 key 则 MOVE 失败(得到 0)。
实例:
127.0.0.1:6379> select 0
127.0.0.1:6379> set msg hello
127.0.0.1:6379> move msg 1
(integer) 1
127.0.0.1:6379> get msg
(nil)
127.0.0.1:6379> select 1
127.0.0.1:6379[1]> get msg
"hello"
方式二:DUMP + RESTORE(跨实例,非原子)
源实例 6379 目标实例 6380
┌────────────┐ ① DUMP 序列化 ┌────────────┐
│ │ (RDB 格式二进制) │ │
│ key ─────┼──────────────────────┼──►RESTORE │
│ │ ② 客户端搬运结果 │ 写回内存 │
└────────────┘ └────────────┘
③ 源 key 需手动 DEL
# 源实例:序列化
DUMP key
# 目标实例:恢复,ttl=0 表示永久
RESTORE key ttl serialized-value
实例:
# 源 6379
127.0.0.1:6379> set user:1 tom
127.0.0.1:6379> dump user:1
"x00x03tomx0bx00x8ax8d..."
# 目标 6380(把上面的序列化结果粘进去)
127.0.0.1:6380> restore user:1 0 "x00x03tomx0bx00x8ax8d..."
OK
# 源端确认后手动删除
127.0.0.1:6379> del user:1
方式三:MIGRATE(组合命令,原子完成)
源实例 6379 目标实例 6380
┌──────────────────────────────────────────────────────────┐
│ MIGRATE 一条命令 │
│ 内部 DUMP ──► 网络传输 ──► RESTORE ──► (默认) DEL 源 │
│ 阻塞等待目标 ACK,整体一个命令完成 │
└──────────────────────────────────────────────────────────┘
MIGRATE host port key|"" destination-db timeout
[COPY] [REPLACE] [AUTH password] [KEYS key [key ...]]
| 参数 | 含义 |
|---|---|
host port | 目标实例地址 |
key 或 "" | 单 key 写 key;多 key 写空串,再用 KEYS |
destination-db | 目标库编号 |
timeout | 超时时间,毫秒 |
COPY | 保留源 key(默认迁移后删除源) |
REPLACE | 目标已存在同名 key 时覆盖 |
结合项目来看,它把 DUMP+RESTORE+DEL 封进一个命令,目标侧 RESTORE 原子执行,流程简化且不会漏删源 key;注意调用期间源实例会同步阻塞等待目标得到。
实例:
# 单 key 迁移到 6380,保留源副本并允许覆盖
127.0.0.1:6379> migrate 127.0.0.1 6380 user:1 0 5000 copy replace
OK
# 多 key 迁移
127.0.0.1:6379> migrate 127.0.0.1 6380 "" 0 5000 keys user:1 user:2
OK
二、遍历键
2.1 全量遍历 KEYS(线上禁用)
KEYS pattern
- 一次扫描全部 key,O(N),期间单线程被独占,key 多时会阻塞整个实例;
- 线上环境绝对不要采用,排查也应在从节点或用 SCAN 替代。
2.2 渐进式遍历 SCAN
SCAN cursor [MATCH pattern] [COUNT number] [TYPE type]
基本用法
127.0.0.1:6379> scan 0
"17" # 下一次要用的游标
"key:a"
"key:b" ...
127.0.0.1:6379> scan 17 match key:* count 100
"0" # 游标回到 0,表示整轮遍历结束
...
cursor到底是什么
一、先建立最轻松的模型:cursor 就是 "桶号"
先忽略 rehash,只看一个稳定的 dict:
全局 dict 底层是一个桶数组,大小永远是 2 的幂(比如 8 个桶,编号 0~7);
key 经哈希后靠
hash & (size-1)定位到某个桶,桶上挂一条冲突链;cursor 这个数字,本质上就是桶号:
SCAN 0:从 0 号桶开始;落到代码里,扫描该桶(COUNT 决定一次扫多少个桶),得到的新 cursor = 下一个要访问的桶号;
cursor 回到 0,表示绕数组走完一圈,遍历结束。
所以 cursor 不是 key 的序号,它回答的是 "我现在走到桶数组的哪个位置了"。
二、关键问题:为什么不能按 0,1,2,3... 顺序走
因为 dict 会 rehash 扩容(桶数翻倍)。以 8 桶 → 16 桶为例:
8 桶时,桶号由 hash 的低 3 位决定;
16 桶时,由低 4 位决定;
于是旧桶
i的内容会被拆到新桶i和i+8(新多出来的那一位决定去哪个)。
假设已经按普通顺序扫到 3,此时扩容:
已扫: 旧桶 0,1,2,3 → 它们拆出的新桶 8,9,10,11
继续: 从新桶 4 开始 → 新桶 8,9,10,11 永远不会再被访问
结果:拆分到 "高位桶" 的 key 全部漏掉。这就是不能用普通 +1 的原因。
三、反向二进制位迭代是什么
Redis 让 cursor 不按普通 +1 走,而是做一次 "二进制位反转 → 加 1 → 再反转"。
8 桶时实际访问顺序:
步骤 | 桶号 | 二进制 |
|---|---|---|
1 | 0 | 000 |
2 | 4 | 100 |
3 | 2 | 010 |
4 | 6 | 110 |
5 | 1 | 001 |
6 | 5 | 101 |
7 | 3 | 011 |
8 | 7 | 111 |
9 | 0 | 000(结束) |
观察二进制规律:最高位以 0,1 交替变化,随后是次高位,最后才是最低位落到代码里,—— 相当于 "从高位到低位" 枚举,而不是普通加法的 "从低位到高位"。
四、为什么这个顺序扩容后不重不漏
16 桶的序列是:
0,8, 4,12, 2,10, 6,14, 1,9, 5,13, 3,11, 7,15
注意 关键性质:旧桶 i 拆出的新桶 i 和 i+8,在新序列里总是相邻的一对:
旧桶0 → 新桶(0, 8) 相邻
旧桶4 → 新桶(4,12) 相邻
旧桶2 → 新桶(2,10) 相邻
...
这意味着扩容发生时,cursor 能够无缝衔接:
旧阶段扫过的桶,在新序列里恰好对应 "完整的一对",不会重复扫;
还没扫的桶,其拆分出的两个新桶都在后续序列里等着,不会漏;
结合项目来看,无论在哪一步发生扩容,"已扫集合" 和 "未扫集合" 都能干净地映射到新数组。
五、源码里的三行
dict.c 中 cursor 的推进就是这几行:
v |= ~m0; // 有效桶位之外的位全部置 1
v = rev(v); // 反转二进制位
v++; // 普通加 1
v = rev(v); // 再反转回来,得到下一个桶号
从实现思路看,rehash 正在进行时(渐进式 rehash,key 分散在 ht [0] 和 ht [1] 两张表),SCAN 会同时扫描两张表,保证 key 不管落在哪张表都能被找到。
六、缩容与 "可能重复"
缩容(16 → 8)是多个桶合同时,反向顺序同样保证不漏,但可能把某些 key 扫到两次;
这正是 SCAN 文档明确 "可能得到重复 key,需客户端去重" 的根本原因 —— 它优先保证不漏,代价是允许重复。
COUNT 为什么只是"大概值"
- SCAN 按桶结合项目来看,推进,而每个桶上挂的 key 数量不固定(链地址法,有的桶空、有的挂多个);
- 空桶、过期 key、已删除 key 会被跳过;
- rehash 期间要同时扫描新旧两张表;
- 落到代码里,小对象用 intset/listpack 编码时,扫描策略不同。 所以 COUNT 是"工作量提示"而非精确得到条数。
SCAN 的两个重要特性
- 可能得到重复 key:客户端必须自己去重;
- 遍历期间新增/删除的 key,不保证是否被遍历到;
- 对应容器版本:
HSCAN(Hash)、SSCAN(Set)、ZSCAN(ZSet)。
三、数据库管理
3.1 多数据库与切换
- 一个 Redis 实例默认
databases 16个库,编号 0~15,名字就是编号,不能按业务命名; - 默认采用 0 号库:
SELECT dbindex
- Cluster 模式下只能采用 0 号库,不兼容 SELECT 多库。
3.2 清空数据库
FLUSHDB # 清空当前库
FLUSHALL # 清空所有库
FLUSHDB ASYNC # 4.0+ 后台线程释放,立即返回,不阻塞
FLUSHALL ASYNC
- 同步 FLUSH 会逐个释放对象,库大时严重阻塞,线上慎用;
- 删少量 key 优先用 DEL/UNLINK;确需清库用 ASYNC 版本。
四、时间复杂度汇总
| 命令 | 复杂度 |
|---|---|
| RENAME / RENAMENX / MOVE / PERSIST / EXPIRE / TTL / EXPIRETIME | O(1)(覆盖大 key 时 DEL 为 O(N)) |
| RANDOMKEY | O(1) |
| DUMP / RESTORE / MIGRATE | O(value 大小) |
| KEYS | O(N) |
| SCAN 单次 | 摊还 O(1),整轮 O(N) |
| FLUSHDB / FLUSHALL | O(N);ASYNC 版本 O(1) 得到 |
总结
落到代码里,总的来说,redis键管理适合结合实际项目边做边理解。先抓住核心思路,再逐步补上细节和边界处理,最后效果会更稳定,也更容易复用。