您的位置:首页 > 手游攻略 > Redis键管理和数据库管理示例完整指南

Redis键管理和数据库管理示例完整指南

作者:互联网  时间: 2026-11-12 10:10:01  

平时做技术实践时,很多问题不是概念不会,而是细节没串起来。拿“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),不拷贝值
4sdsdup 新 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 / PTTLEXPIRETIME / 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 / EXPIRETIMEO(1)(覆盖大 key 时 DEL 为 O(N))
RANDOMKEYO(1)
DUMP / RESTORE / MIGRATEO(value 大小)
KEYSO(N)
SCAN 单次摊还 O(1),整轮 O(N)
FLUSHDB / FLUSHALLO(N);ASYNC 版本 O(1) 得到

总结

落到代码里,总的来说,redis键管理适合结合实际项目边做边理解。先抓住核心思路,再逐步补上细节和边界处理,最后效果会更稳定,也更容易复用。

相关资讯

最新游戏

更多

Copyright©2010-2019. All rights reserved | 波波三国游戏官网|[email protected]

备案编号:湘ICP备2022015115号-4