Skip to content
Charles Shao
Go back

Redis 深挖(开篇)· 五大结构与核心命令

–views

线上竞价链路刚切全量,某个粗心的开发在生产环境敲了一句 KEYS * 想确认配置数据,几千万量级的 key 直接把单线程的主节点阻塞,监控面板上的 P99 瞬间飙过 tmax,大盘无情红掉。这种惨痛的学费,每天都在各家公司的研发团队重演。正因如此,在我们深入探究 Redis 的底层源码与高可用架构之前,有必要先回到最基础的起点。

本文是 Redis 系列的第 1 篇(开篇 · 命令速通)。 全系列 5 篇:

  1. Redis 深挖(开篇)· 五大结构与核心命令(本篇)
  2. Redis 深挖 · 数据结构与内存模型
  3. Redis 深挖 · 持久化与高可用
  4. Redis 深挖 · 线程模型与性能
  5. Redis 深挖 · 布隆过滤器与穿透防线

一句话定位:先把 5 大数据结构的读写命令与 O(N) 地雷摸清,再下钻编码、持久化与线程模型——命令层踩坑会直接打穿竞价 P99。

TL;DR

数据结构基础写入基础读取O(N) 危险操作底层编码选型
StringSET / SETNX / INCRGET / MGETMGET(单次获取过多)int / embstr / raw
ListLPUSH / RPUSHLPOP / LRANGELINDEX / LINSERTquicklist(内部含 listpack)
HashHSET / HMSETHGET / HGETALLHGETALL / HKEYSlistpack / hashtable
SetSADDSMEMBERS / SISMEMBERSMEMBERS / SUNIONintset / listpack / hashtable
ZSetZADDZRANGE / ZSCOREZREMRANGEBYRANKlistpack / skiplist

Table of contents

Open Table of contents

String:最泛用的键值基石

作为 Redis 最基础的数据结构,String 并不是简单的 C 语言字符数组,而是由动态字符串(SDS)构成的底层实体。它可以存储普通文本、JSON 序列化后的对象、甚至是整数与浮点数。

核心命令与场景

# 写入并设置 60 秒过期
> SET campaign:1001:budget 5000 EX 60
OK
> GET campaign:1001:budget
"5000"

# 分布式锁的雏形(仅不存在时写入)
> SET lock:campaign:1001 "node-1" NX EX 5
OK
> MSET user:1 "Alice" user:2 "Bob"
OK
> MGET user:1 user:2 user:3
1) "Alice"
2) "Bob"
3) (nil)
> SET rate_limit:ip:192.168.1.1 0
OK
> INCR rate_limit:ip:192.168.1.1
(integer) 1
> INCR rate_limit:ip:192.168.1.1
(integer) 2

避坑指南

需要强调的是,String 的泛用性往往也会带来滥用。许多开发喜欢把极大的 JSON 直接塞进一个 String 中,一旦 value 大小超过 10KB,就会被判定为 bigkey。对这种 bigkey 进行读写,不仅会打满集群节点的网卡带宽,还会使得耗时上升,进而阻塞主线程,导致其后的请求在事件循环上排队甚至超时。

List:双端队列与基础信息流

List 是一个保持写入顺序的字符串列表。在底层结构上演进到 quicklist 后,它具备了极其优秀的头尾增删性能。

核心命令与场景

> LPUSH logs "log1" "log2"
(integer) 2
> RPUSH logs "log3"
(integer) 3
> LPOP logs
"log2"
# 阻塞等待 10 秒,直到有数据推入
> BLPOP task_queue 10
1) "task_queue"
2) "task_payload"
# 0 到 -1 表示拉取整个列表
> LRANGE logs 0 -1
1) "log1"
2) "log3"

危险操作警示

绝不要把 List 当作数组来随机访问。执行 LINDEX 或者中间位置的 LINSERT 操作,时间复杂度都会退化为 O(N)。如果需要频繁按索引获取数据,应该考虑在业务侧使用数组或更换为 Hash 结构。此外,LRANGE 的范围切忌过大,一次性拉取上万条记录同样会招致主线程阻塞。

Hash:结构化对象的精细化治理

当我们只需要修改 JSON 中的某个单一字段(例如用户的某一个属性、或者某个广告计划的状态位)时,如果用 String 存储,就不得不把整个 JSON 反序列化后再写回——这就引出了 Hash 结构的用武之地。

核心命令与场景

> HSET user:1001 name "Alice" age 28
(integer) 2
> HGET user:1001 name
"Alice"
> HINCRBY user:1001 age 1
(integer) 29

规模膨胀与 O(N) 灾难

在业务增长的初期,用 HGETALL 一把梭读取对象的所有属性看似方便。然而,当单个 Hash 的 field 数量膨胀到成百上千时,HGETALL 就是一场标准的 O(N) 灾难。对于这种场景,必须坚决改为使用 HSCAN 进行游标迭代读取,或者按需通过 HMGET 获取指定的若干 field。同时也要警惕 Hash 内数据倾斜导致的单分片负载不均。

Set:无序集合与交并差运算

Set 是天然去重的无序集合。对于需要快速判断「是否存在」以及进行集合论运算的场景,Set 是不二之选。

核心命令与场景

> SADD blacklist "ip:1.1.1.1" "ip:2.2.2.2"
(integer) 2
> SISMEMBER blacklist "ip:1.1.1.1"
(integer) 1
> SADD tags:sports "user1" "user2"
(integer) 2
> SADD tags:tech "user2" "user3"
(integer) 2
> SINTER tags:sports tags:tech
1) "user2"

运算转移法则

集合交并差运算属于典型的 CPU 密集型操作。一旦单个 Set 包含成千上万个 member,执行 SINTER 就会导致 CPU 满载并阻塞后续请求。线上的最佳实践是:将这类重度运算从 Redis 主节点剥离出来,要么下放给只读从节点(结合读写分离),要么将数据用 SMEMBERS 或 SSCAN 拉到应用层,在业务代码里利用本地内存计算。

ZSet:多维度的有序竞价场

相比于 Set,ZSet 给每个元素挂载了一个浮点数权值(score)。正是凭借这个维度的拓展,ZSet 在底层利用跳表(skiplist)和哈希表实现了高效的区间查找。

核心命令与场景

> ZADD leaderboard 1500 "user1" 2000 "user2"
(integer) 2
# 获取排名前 2 的用户(倒序)
> ZREVRANGE leaderboard 0 1 WITHSCORES
1) "user2"
2) "2000"
3) "user1"
4) "1500"

开销权衡

功能越强大的结构,背后的时空开销也就越大。ZSet 的大部分写入和范围查找操作,其时间复杂度都带有 O(log(N)) 甚至 O(N+M) 的因子。当 ZSet 中的元素数极多时,无论是插入带来的跳表指针维护成本,还是 ZREMRANGEBYRANK 这类批量删除带来的连带开销,都不容小觑。

通用键值与运维命令避坑

了解了数据结构的特性后,我们最后来看三个最容易踩坑的通用命令。在线上环境排查问题或是清理数据时,这些指令往往决定了系统的生死。

KEYS *:绝对的生产禁令

正如开篇所说,KEYS pattern 会在事件循环上触发 KEYS 阻塞式全量遍历。在包含数百万个 key 的线上实例中执行它,主线程将彻底僵死,直到遍历完成。**在生产环境,必须将 KEYS 命令在配置层面禁用(rename-command KEYS "")。**如果需要排查数据,应使用无阻塞的游标迭代命令 SCAN,并通过游标返回结果在业务代码侧自行拼接。

DEL vs UNLINK:大对象删除的演进

早期版本中,我们习惯使用 DEL key 清理缓存。但这带来了一个致命问题:如果删除的是一个包含百万元素的 Hash 或 List 大 key,释放内存的过程是同步执行的,会直接阻塞主线程。

正因如此,从 Redis 4.0 开始引入了 UNLINK 命令。它会在主线程中快速将该 key 从键空间字典中摘除(使得命令立刻返回),随后将回收内存的脏活累活丢给后台的 bio 线程去异步执行。在面对大 key 淘汰时,用 UNLINK 替代 DEL 是线上稳妥的标配操作。

EXPIRE:打散缓存雪崩的集中失效

EXPIRE key seconds 用于设置生存时间,它是实现业务 TTL 到期失效的核心机制。但在应对大促或者定时预热等场景时,最忌讳拍脑袋定一个固定的数值(例如统一设定为 3600 秒)。这会导致缓存到了特定时间点发生集中失效,巨量回源流量直压数据库,引发缓存雪崩。正确的做法是在业务代码里,将 TTL 值加上一段随机抖动(例如 3600 + random(0, 300)),从而把回源流量打散在更宽的时间窗口内。


–views
Share this post on:

Previous Post
LangGraph:State 不是一份字典,是一组通道
Next Post
LangGraph:用 create_agent 挂上人确认