线上竞价链路刚切全量,某个粗心的开发在生产环境敲了一句 KEYS * 想确认配置数据,几千万量级的 key 直接把单线程的主节点阻塞,监控面板上的 P99 瞬间飙过 tmax,大盘无情红掉。这种惨痛的学费,每天都在各家公司的研发团队重演。正因如此,在我们深入探究 Redis 的底层源码与高可用架构之前,有必要先回到最基础的起点。
本文是 Redis 系列的第 1 篇(开篇 · 命令速通)。 全系列 5 篇:
- Redis 深挖(开篇)· 五大结构与核心命令(本篇)
- Redis 深挖 · 数据结构与内存模型
- Redis 深挖 · 持久化与高可用
- Redis 深挖 · 线程模型与性能
- Redis 深挖 · 布隆过滤器与穿透防线
一句话定位:先把 5 大数据结构的读写命令与 O(N) 地雷摸清,再下钻编码、持久化与线程模型——命令层踩坑会直接打穿竞价 P99。
TL;DR
| 数据结构 | 基础写入 | 基础读取 | O(N) 危险操作 | 底层编码选型 |
|---|---|---|---|---|
| String | SET / SETNX / INCR | GET / MGET | MGET(单次获取过多) | int / embstr / raw |
| List | LPUSH / RPUSH | LPOP / LRANGE | LINDEX / LINSERT | quicklist(内部含 listpack) |
| Hash | HSET / HMSET | HGET / HGETALL | HGETALL / HKEYS | listpack / hashtable |
| Set | SADD | SMEMBERS / SISMEMBER | SMEMBERS / SUNION | intset / listpack / hashtable |
| ZSet | ZADD | ZRANGE / ZSCORE | ZREMRANGEBYRANK | listpack / skiplist |
Table of contents
Open Table of contents
String:最泛用的键值基石
作为 Redis 最基础的数据结构,String 并不是简单的 C 语言字符数组,而是由动态字符串(SDS)构成的底层实体。它可以存储普通文本、JSON 序列化后的对象、甚至是整数与浮点数。
核心命令与场景
- 常规读写:
SET key value [EX seconds] [NX|XX]与GET key。在做基础的 L1 元数据缓存时,我们通常会将SET与EX(设置过期时间)连用。NX后缀(即SETNX)则确保了「当且仅当 key 不存在时写入」,这是实现分布式锁最核心的原语。
# 写入并设置 60 秒过期
> SET campaign:1001:budget 5000 EX 60
OK
> GET campaign:1001:budget
"5000"
# 分布式锁的雏形(仅不存在时写入)
> SET lock:campaign:1001 "node-1" NX EX 5
OK
- 批量读取:
MGET key1 key2 ...。当竞价节点需要同时拉取多个广告计划的预算快照时,用一条MGET替代多次GET,能够极大地摊薄网络 RTT(往返时延)。
> MSET user:1 "Alice" user:2 "Bob"
OK
> MGET user:1 user:2 user:3
1) "Alice"
2) "Bob"
3) (nil)
- 原子计数:
INCR key/DECR key。由于 Redis 采用主线程串行处理命令,针对整数编码的 String 执行INCR操作是天生原子的。这在实现广告频控计数、API 限流等场景中极为高效。
> 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/RPUSH将元素压入队列头尾,而LPOP/RPOP则弹出首尾元素。
> LPUSH logs "log1" "log2"
(integer) 2
> RPUSH logs "log3"
(integer) 3
> LPOP logs
"log2"
- 阻塞读取:
BLPOP key timeout。当 List 为空时,客户端会被挂起直到有新数据写入或触达超时时间。这让 List 经常被客串用作简单的消息队列或异步任务分发。
# 阻塞等待 10 秒,直到有数据推入
> BLPOP task_queue 10
1) "task_queue"
2) "task_payload"
- 片段拉取:
LRANGE key start stop。在不需要严谨的 MQ 中间件时,一些轻量级的最新用户动态流或日志时间线,可以通过LRANGE分页拉取。
# 0 到 -1 表示拉取整个列表
> LRANGE logs 0 -1
1) "log1"
2) "log3"
危险操作警示
绝不要把 List 当作数组来随机访问。执行 LINDEX 或者中间位置的 LINSERT 操作,时间复杂度都会退化为 O(N)。如果需要频繁按索引获取数据,应该考虑在业务侧使用数组或更换为 Hash 结构。此外,LRANGE 的范围切忌过大,一次性拉取上万条记录同样会招致主线程阻塞。
Hash:结构化对象的精细化治理
当我们只需要修改 JSON 中的某个单一字段(例如用户的某一个属性、或者某个广告计划的状态位)时,如果用 String 存储,就不得不把整个 JSON 反序列化后再写回——这就引出了 Hash 结构的用武之地。
核心命令与场景
- 局部读写:
HSET key field value与HGET key field。Hash 允许针对具体的 field 颗粒度进行读写,这在管理对象属性或是购物车(以用户 ID 为 key、商品 ID 为 field)时非常自然。
> HSET user:1001 name "Alice" age 28
(integer) 2
> HGET user:1001 name
"Alice"
- 原子增减:
HINCRBY key field increment。能够像对 String 计数一样,针对 Hash 内部的单个字段做原子加减。
> HINCRBY user:1001 age 1
(integer) 29
规模膨胀与 O(N) 灾难
在业务增长的初期,用 HGETALL 一把梭读取对象的所有属性看似方便。然而,当单个 Hash 的 field 数量膨胀到成百上千时,HGETALL 就是一场标准的 O(N) 灾难。对于这种场景,必须坚决改为使用 HSCAN 进行游标迭代读取,或者按需通过 HMGET 获取指定的若干 field。同时也要警惕 Hash 内数据倾斜导致的单分片负载不均。
Set:无序集合与交并差运算
Set 是天然去重的无序集合。对于需要快速判断「是否存在」以及进行集合论运算的场景,Set 是不二之选。
核心命令与场景
- 元素读写:
SADD key member与SREM key member用于增删。
> SADD blacklist "ip:1.1.1.1" "ip:2.2.2.2"
(integer) 2
- 存在性校验:
SISMEMBER key member能够在 O(1) 的时间复杂度内判定元素是否在集合中,常用于广告曝光去重或是命中黑白名单判断。
> SISMEMBER blacklist "ip:1.1.1.1"
(integer) 1
- 集合运算:
SINTER(交集)、SUNION(并集)和SDIFF(差集)。在社交场景中计算共同好友,或是给打上多维标签的人群做正交筛选,这些命令能快速得出结果。
> 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 key score member写入元素与权值。
> ZADD leaderboard 1500 "user1" 2000 "user2"
(integer) 2
- 区间拉取:
ZRANGE key start stop [WITHSCORES](按 score 升序拉取),以及倒序的ZREVRANGE。
# 获取排名前 2 的用户(倒序)
> ZREVRANGE leaderboard 0 1 WITHSCORES
1) "user2"
2) "2000"
3) "user1"
4) "1500"
- 延迟队列落地:如果把时间戳作为 score 写入 ZSet,再由消费者轮询执行
ZRANGEBYSCORE拉取当前时间之前的数据,这就实现了一个极其精简且可靠的分布式延迟队列。此外,游戏积分、热搜排行榜等业务无一不是 ZSet 的主场。
开销权衡
功能越强大的结构,背后的时空开销也就越大。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)),从而把回源流量打散在更宽的时间窗口内。