Redis-02 五种基本数据类型与命令详解
1. 通用 key 操作
在讲具体类型之前,先掌握与类型无关的 key 级操作。
1.1 存在性与删除
EXISTS key [key ...] # 返回存在的 key 个数(可以传多个,重复的 key 会重复计数)
DEL key [key ...] # 同步删除,返回删除个数。删大 key 会阻塞
UNLINK key [key ...] # 4.0+ 异步删除,主线程只摘链,内存释放交后台线程。推荐
TYPE key # 返回类型:string/list/hash/set/zset/stream/none
EXISTS a a a 如果 a 存在会返回 3,这是个容易被忽略的细节。
DEL 与 UNLINK 的区别(面试常问):DEL 在主线程里同步释放内存,一个 1000 万元素的 hash 可能阻塞几秒;UNLINK 先把 key 从字典摘掉(O(1)),真正的内存回收扔给 bio 后台线程。删大 key 一律用 UNLINK。
1.2 过期时间
EXPIRE key 60 # 60 秒后过期
PEXPIRE key 60000 # 毫秒
EXPIREAT key 1735660800 # 指定 Unix 时间戳(秒)过期
PEXPIREAT key 1735660800000 # 毫秒时间戳
TTL key # 剩余秒数;-1 = 没设过期时间;-2 = key 不存在
PTTL key # 剩余毫秒数
PERSIST key # 移除过期时间,变为永久
EXPIRETIME key # 7.0+ 返回过期的绝对时间戳(秒)
PEXPIRETIME key # 7.0+ 毫秒
# 6.2+ 的条件参数
EXPIRE key 100 NX # 仅当 key 没有过期时间时才设置
EXPIRE key 100 XX # 仅当 key 已有过期时间时才设置
EXPIRE key 100 GT # 仅当新过期时间大于当前时才设置(延长)
EXPIRE key 100 LT # 仅当新过期时间小于当前时才设置(缩短)
关键陷阱:对 key 执行修改值的写操作不会清除 TTL,但 SET 命令会!
SET k v EX 100
INCR k # TTL 保留
APPEND k "x" # TTL 保留
LPUSH list a
EXPIRE list 100
LPUSH list b # TTL 保留
SET k newval # ⚠️ TTL 被清除!变成永久 key
SET k newval KEEPTTL # 6.0+ 保留原 TTL
GETSET k newval # ⚠️ 同样清除 TTL
这个陷阱造成过大量线上事故:本来是带过期的缓存,某处代码用 SET 更新后变成永久 key,内存慢慢涨爆。
1.3 重命名、移动、复制
RENAME old new # 重命名,如果 new 已存在会被覆盖
RENAMENX old new # 仅当 new 不存在时才改名
MOVE key 1 # 移动到 db1(目标 db 已存在同名 key 则失败)
COPY src dst [DB n] [REPLACE] # 6.2+ 复制 key
1.4 遍历与随机
KEYS pattern # ⚠️ O(N),生产禁用
SCAN cursor [MATCH pattern] [COUNT n] [TYPE type]
RANDOMKEY # 随机返回一个 key
DBSIZE # key 总数,O(1)
SCAN 的实战写法(游标从 0 开始,直到返回 0 结束):
127.0.0.1:6379> SCAN 0 MATCH 'user:*' COUNT 100
1) "17" # 下一次的游标
2) 1) "user:1"
2) "user:2"
127.0.0.1:6379> SCAN 17 MATCH 'user:*' COUNT 100
1) "0" # 游标为 0,遍历结束
2) 1) "user:3"
SCAN 的通配符支持:*(任意多字符)、?(一个字符)、[abc](字符集)、[^a](排除)、\ 转义。
1.5 序列化
DUMP key # 序列化 value 为特殊格式(含版本和 CRC 校验)
RESTORE key 0 "\x00\x03abc..." # 反序列化恢复(0 表示不设过期)
MIGRATE host port key db timeout # 把 key 原子地迁移到另一个实例(内部就是 DUMP+RESTORE+DEL)
MIGRATE 是集群 reshard 的底层命令。
1.6 SORT 排序
SORT 可以对 list、set、zset 的元素排序,是 Redis 里参数最复杂、也最容易被误用的命令之一。
SORT key [BY pattern] [LIMIT offset count]
[GET pattern [GET pattern ...]]
[ASC | DESC] [ALPHA] [STORE destination]
时间复杂度 O(N + M*log(M)),N 是集合元素数,M 是返回的元素数。
基本用法:
RPUSH nums 3 1 4 1 5 9 2 6
SORT nums # 1 1 2 3 4 5 6 9(默认按双精度浮点升序)
SORT nums DESC # 降序
SORT nums LIMIT 0 3 # 只要前 3 个:1 1 2
# 【重要】默认按数字排序,元素不是数字会报错
RPUSH words banana apple cherry
SORT words
# (error) ERR One or more scores can't be converted into double
SORT words ALPHA # 加 ALPHA 才能按字典序:apple banana cherry
注意:SORT 不改变原集合,只返回排序后的结果(除非用 STORE)。
BY pattern——按外部 key 的值排序
这是 SORT 最强大也最绕的用法。pattern 里的 * 会被替换成集合中的每个元素:
RPUSH users 1001 1002 1003
MSET weight_1001 30 weight_1002 10 weight_1003 20
SORT users BY weight_* # 按 weight_<id> 的值排序 → 1002 1003 1001
BY 还能引用 hash 的字段(-> 语法):
HSET user:1001 age 30
HSET user:1002 age 10
HSET user:1003 age 20
SORT users BY user:*->age # → 1002 1003 1001
BY nosort(不排序)的技巧:如果 pattern 里不含 *,Redis 认为它是个"常量",于是跳过排序直接返回。常用于"只想用 GET 取关联数据,不需要排序"的场景:
SORT users BY nosort GET user:*->name
GET pattern——返回关联的其他 key 的值
排序后不返回元素本身,而是返回它关联的数据(相当于在 Redis 里做了一次"join"):
MSET name_1001 tom name_1002 jerry name_1003 alice
SORT users BY weight_* GET name_* # → jerry alice tom
SORT users BY weight_* GET # GET name_* # 【# 表示元素本身】→ 1002 jerry 1003 alice 1001 tom
SORT users BY nosort GET # GET user:*->name GET user:*->age # 多个 GET
STORE destination——把结果存成一个 list
SORT users BY weight_* STORE users:sorted # 结果存进 users:sorted(类型是 list)
EXPIRE users:sorted 60 # 可以给它设过期时间,当作"排序结果缓存"
STORE 有个重要副作用:它让 SORT 变成写命令,所以不能在只读从节点上执行。
三个必须知道的坑:
(1)对 set 排序的结果在某些情况下不确定
SADD s 1 2 3
SORT s BY nosort # ⚠️ 返回顺序【不确定】(set 本身无序)
如果用了 BY nosort 或者在集群/从节点等环境下,对 set 的排序可能返回不同顺序。要确定的顺序就不要用 BY nosort。
(2)BY + GET 在 Cluster 模式下基本不可用
BY weight_* 和 GET name_* 引用的是其他 key,而这些 key 极大概率不在同一个 slot,会报 CROSSSLOT 错误(第 12 篇)。除非所有相关 key 都用了相同的 hash tag——但那样又会造成数据倾斜。
所以:Cluster 环境下应该放弃 SORT 的 BY/GET 用法。
(3)性能:O(N + M*logM),大集合上是危险命令
对一个 100 万元素的 set 做 SORT 会阻塞主线程很久(第 2 篇末尾的危险命令表里就有它)。而且 BY/GET 会额外产生 N 次 key 查找。
实践建议:
SORT在现代 Redis 用法里已经很少用了。绝大多数排序需求应该用 zset(ZRANGE,O(logN + M),且可以增量维护);- 需要"按外部字段排序 + 取关联数据"时,正确做法是在业务层用
MGET/HMGET批量取数据后排序——这样可控、可分页、不阻塞 Redis; - 只在"小集合(几百个元素以内)+ 一次性排序"的场景才考虑
SORT; - 7.0+ 提供了
SORT_RO(只读版本,不支持STORE),可以在从节点上执行。
1.7 一个 list 的实用技巧:按下标删除元素
list 没有提供"按下标删除"的命令(只有按值删除的 LREM)。标准的迂回做法是两步:先把目标位置改成一个哨兵值,再按值删除:
RPUSH mylist a b c d
LSET mylist 1 __DELETED__ # 把下标 1 的元素改成哨兵值
LREM mylist 1 __DELETED__ # 按值删除它
LRANGE mylist 0 -1 # a c d
注意:哨兵值必须保证不与真实数据冲突(用一个业务上不可能出现的特殊串)。这两步之间不是原子的,并发场景下要用 Lua 包起来:
redis.call('LSET', KEYS[1], ARGV[1], '__DELETED__')
return redis.call('LREM', KEYS[1], 1, '__DELETED__')
2. String 字符串
最基础的类型。value 是二进制安全的字节串,可以存文本、JSON、序列化对象、甚至图片,上限 512MB。
2.1 基本读写
SET key value
GET key # 不存在返回 nil
STRLEN key # 字节长度
APPEND key "suffix" # 追加,返回新长度
SETRANGE key 5 "abc" # 从偏移量 5 开始覆写(不足会用 \x00 填充)
GETRANGE key 0 -1 # 取子串(闭区间,-1 表示末尾)
GETDEL key # 6.2+ 取出并删除(原子)
GETEX key EX 60 # 6.2+ 取出并顺便设置过期时间
2.2 SET 的完整参数
SET 是最需要熟悉参数的命令:
SET key value [NX | XX] [GET] [EX s | PX ms | EXAT ts | PXAT ts | KEEPTTL]
| 参数 | 含义 |
|---|---|
NX |
仅当 key 不存在时设置(Not eXists)→ 分布式锁的基础 |
XX |
仅当 key 已存在时设置 |
GET |
6.2+ 返回旧值(相当于原子的 GETSET,但兼容 NX/XX) |
EX seconds |
秒级过期 |
PX milliseconds |
毫秒级过期 |
EXAT timestamp |
6.2+ 绝对秒时间戳过期 |
PXAT ms-timestamp |
6.2+ 绝对毫秒时间戳过期 |
KEEPTTL |
6.0+ 保留原有 TTL |
# 分布式锁的标准写法(原子地"加锁 + 设过期")
SET lock:order:1001 <uuid> NX EX 30
# 老版本的 SETNX + EXPIRE 是两条命令,中间宕机会造成锁永不释放,已淘汰
SETNX lock 1 # 等价于 SET lock 1 NX(保留下来的老命令)
SETEX key 60 value # 等价于 SET key value EX 60
PSETEX key 60000 val # 等价于 SET key value PX 60000
2.3 批量操作
MSET k1 v1 k2 v2 k3 v3 # 批量设置(原子)
MGET k1 k2 k3 # 批量获取,不存在的返回 nil
MSETNX k1 v1 k2 v2 # 全部不存在时才设置(原子,一个存在就全失败)
MGET 相比多次 GET 的优势是只有一次网络往返(RTT),在 RTT 为 1ms 的环境下,100 次 GET 要 100ms,一次 MGET 只要 1ms。
集群注意:
MSET/MGET要求所有 key 在同一个 slot,否则报CROSSSLOT错误。需要用 hash tag(如{user1}:name、{user1}:age)把它们固定到同一 slot,详见第 12 篇。
2.4 数值操作
value 是十进制整数字符串时可以做原子加减:
INCR counter # +1,key 不存在则视为 0
DECR counter # -1
INCRBY counter 100 # +100
DECRBY counter 100 # -100
INCRBYFLOAT price 1.5 # 浮点加(没有 DECRBYFLOAT,用负数)
细节:
- 整数范围是 64 位有符号整数,超出报
increment or decrement would overflow; - value 不是合法数字时报
value is not an integer or out of range; INCRBYFLOAT用 long double 计算,精度问题依然存在,金额不要用浮点存,应该用整数存"分"。
2.5 典型应用场景
(1)缓存对象
# 方案 A:整体 JSON(读写整体,简单,适合字段总是一起用)
SET user:1001 '{"id":1001,"name":"tom","age":20}' EX 3600
# 方案 B:字段打平(可单独更新字段,但 key 数量多)
MSET user:1001:name tom user:1001:age 20
# 方案 C:用 hash(推荐,可单字段读写且内存更省)
HSET user:1001 name tom age 20
(2)计数器
INCR article:1001:views # 阅读量
INCRBY user:1001:credit 100 # 积分
# 每天独立的计数器(自动按天分 key)
INCR stat:pv:20260729
EXPIRE stat:pv:20260729 604800 # 保留 7 天
(3)分布式锁
SET lock:pay:order1001 uuid-abc NX EX 30
# 释放必须用 Lua 保证"判断是自己的锁"和"删除"的原子性(详见第 13 篇)
(4)固定窗口限流
# 每个用户每分钟最多 100 次请求
INCR rate:user:1001:202607291530
EXPIRE rate:user:1001:202607291530 60
# 返回值 > 100 就拒绝
# 更好的做法是用 Lua 保证原子(避免 INCR 成功但 EXPIRE 失败导致 key 永久存在)
(5)分布式 ID / 序列号
INCR order:seq # 全局自增,Redis 单线程保证不重复
(6)共享 Session
SET session:<token> '{"uid":1001,"role":"admin"}' EX 1800
(7)位图(Bitmap)——底层就是 string,第 3 篇详讲。
3. List 列表
双向链表语义(底层是 quicklist / listpack,见第 4 篇),元素可重复,有序(按插入顺序),最多 2^32-1 个元素。
3.1 增删
LPUSH key v1 v2 v3 # 左侧依次插入,结果顺序是 v3 v2 v1
RPUSH key v1 v2 v3 # 右侧依次插入,结果顺序是 v1 v2 v3
LPUSHX key v # 仅当 key 存在时左插
RPUSHX key v
LPOP key [count] # 弹出左侧(6.2+ 支持 count 一次弹多个)
RPOP key [count] # 弹出右侧
LINSERT key BEFORE|AFTER pivot value # 在第一个等于 pivot 的元素前/后插入,O(N)
LREM key count value # 删除等于 value 的元素
# count>0:从左往右删 count 个
# count<0:从右往左删 |count| 个
# count=0:删全部
LTRIM key start stop # 只保留 [start,stop] 区间,其余删除
3.2 查询
LLEN key # 长度,O(1)
LINDEX key 0 # 按下标取(0 是第一个,-1 是最后一个),O(N)
LRANGE key 0 -1 # 取区间(闭区间),O(S+N)
LSET key 0 newval # 按下标改值
LPOS key value [RANK n] [COUNT c] # 6.0+ 查找元素的下标
LRANGE key 0 -1 取全部在大 list 上是危险操作,要分页:
LRANGE key 0 99 # 第 1 页
LRANGE key 100 199 # 第 2 页
3.3 阻塞命令
这是 list 做队列的关键:
BLPOP key [key ...] timeout # 阻塞式左弹,timeout 秒(0 = 永久阻塞)
BRPOP key [key ...] timeout
# 返回值是 [key名, 元素值],因为可以监听多个 key
127.0.0.1:6379> BLPOP q1 q2 0
1) "q2"
2) "task-1"
阻塞的几个要点:
- 多个 key 时按参数顺序检查,第一个有数据的 key 先被消费(可用来做优先级队列);
- 多个客户端阻塞在同一 key 上时,按阻塞先后顺序(FIFO)唤醒,先阻塞的先拿到;
- 阻塞期间不占用主线程——Redis 把该客户端挂到 key 的阻塞列表上,主线程继续服务其他请求,等有 push 时再唤醒;
- 在 MULTI 事务里 BLPOP 不会阻塞,空列表直接返回 nil;
- 阻塞的连接不能复用,所以客户端要为阻塞消费单独开连接(连接池里的连接被长时间占用会耗尽池)。
3.4 列表间转移
RPOPLPUSH src dst # 从 src 右弹,压入 dst 左侧(原子)
BRPOPLPUSH src dst 0 # 阻塞版
# 6.2+ 推荐用更灵活的 LMOVE 替代(可指定四种方向组合)
LMOVE src dst RIGHT LEFT
BLMOVE src dst RIGHT LEFT 0
# 7.0+ 阻塞式批量弹出(从多个 key 中弹)
LMPOP 2 q1 q2 LEFT COUNT 10
BLMPOP 0 2 q1 q2 LEFT COUNT 10
RPOPLPUSH src src(源和目标相同)实现循环列表,常用于轮询调度:
RPUSH servers s1 s2 s3
RPOPLPUSH servers servers # 返回 s3,list 变成 s3 s1 s2
RPOPLPUSH servers servers # 返回 s2,list 变成 s2 s3 s1
3.5 典型应用场景
(1)消息队列
# 生产者
LPUSH mq:tasks '{"id":1,"type":"email"}'
# 消费者(阻塞等待,不用轮询)
BRPOP mq:tasks 0
问题:BRPOP 拿到消息后如果消费者崩溃,消息就丢了。改进用可靠队列模式:
# 从任务队列取出的同时放进"处理中"队列
BRPOPLPUSH mq:tasks mq:processing 0
# 处理成功后从 processing 删除
LREM mq:processing 1 '{"id":1,...}'
# 另有守护进程扫 processing 里超时的任务重新投递
真正需要可靠队列时,应该用 Stream 的消费组(第 9 篇)。
(2)最新 N 条记录(时间线、浏览历史)
LPUSH user:1001:history article:88
LTRIM user:1001:history 0 19 # 只保留最新 20 条,是这个模式的关键
LRANGE user:1001:history 0 19
(3)栈与队列
# 栈(LIFO):同侧进出
LPUSH stack a; LPUSH stack b; LPOP stack # 得到 b
# 队列(FIFO):异侧进出
LPUSH queue a; LPUSH queue b; RPOP queue # 得到 a
(4)分页列表缓存
把某个查询结果的 ID 列表缓存成 list,分页时用 LRANGE 取,避免每次查库。
4. Hash 哈希
value 是一个 field-value 的映射表,适合存对象。field 数量上限 2^32-1。
4.1 基本操作
HSET key f1 v1 f2 v2 # 4.0+ 支持多字段(旧版只能一个,多字段用 HMSET)
HGET key f1
HMGET key f1 f2 f3 # 批量获取,不存在的字段返回 nil
HSETNX key f1 v1 # 字段不存在时才设置
HDEL key f1 f2 # 删除字段(删完所有字段则整个 key 消失)
HLEN key # 字段数量,O(1)
HEXISTS key f1 # 字段是否存在
HSTRLEN key f1 # 字段值的长度
4.2 遍历
HGETALL key # ⚠️ 返回所有 field 和 value,O(N),大 hash 上是危险操作
HKEYS key # 所有 field,O(N)
HVALS key # 所有 value,O(N)
HRANDFIELD key [count] [WITHVALUES] # 6.2+ 随机字段
HSCAN key cursor [MATCH p] [COUNT n] [NOVALUES] # 增量遍历,7.4+ 支持 NOVALUES
HGETALL 的坑:HGETALL 在一个有 100 万字段的 hash 上会一次性构造巨大的返回包,既阻塞主线程又打满输出缓冲区和网络带宽,是线上事故常见原因。字段多时用 HSCAN 或明确的 HMGET。
4.3 数值操作
HINCRBY key views 1 # 字段值整数自增
HINCRBYFLOAT key score 1.5 # 浮点自增
4.4 字段级过期(7.4+)
7.4 引入了长期以来社区最想要的特性——给 hash 的单个字段设过期时间:
HEXPIRE key 60 FIELDS 2 f1 f2 # f1、f2 在 60 秒后过期
HPEXPIRE key 60000 FIELDS 1 f1 # 毫秒
HEXPIREAT key 1735660800 FIELDS 1 f1
HTTL key FIELDS 2 f1 f2 # 查询字段剩余 TTL
HPERSIST key FIELDS 1 f1 # 移除字段过期时间
HGETEX key EX 60 FIELDS 1 f1 # 7.4+ 取值并设过期
HGETDEL key FIELDS 1 f1 # 7.4+ 取值并删除字段
在 7.4 之前,「给 hash 字段单独设过期」只能靠业务自己在 value 里存时间戳判断,或者拆成独立的 string key。
4.5 典型应用场景
(1)存储对象(最经典)
HSET user:1001 name tom age 20 city beijing
HGET user:1001 name # 只取需要的字段,比 JSON 整体反序列化更省
HINCRBY user:1001 age 1 # 单字段原子更新,比"读 JSON → 改 → 写回"安全得多
对比三种存对象方式:
| 方式 | 内存 | 单字段读写 | 原子性 | 适用 |
|---|---|---|---|---|
| string 存 JSON | 中(有 JSON 冗余) | 不支持,要全量取改写 | 全量更新有并发覆盖问题 | 字段总是整体读写 |
| 多个 string key | 大(每个 key 都有开销) | 支持 | 单字段原子 | 极少用 |
| hash | 小(listpack 编码时最省) | 支持 | 单字段原子 | 推荐 |
(2)购物车
HSET cart:1001 sku:88 2 sku:99 1 # 商品 → 数量
HINCRBY cart:1001 sku:88 1 # 加一件
HDEL cart:1001 sku:99 # 删除商品
HGETALL cart:1001 # 购物车字段少,可以直接 HGETALL
HLEN cart:1001 # 商品种类数
(3)计数器组
HINCRBY stats:20260729 pv 1
HINCRBY stats:20260729 uv 1
HINCRBY stats:20260729 order 1
HGETALL stats:20260729 # 一次取出当天所有指标
(4)用 hash 分桶节省内存
存 1 亿个 id → value 的映射,如果用 1 亿个 string key,仅 key 本身和 robj/dictEntry 开销就上百字节每条。改成按 id 分桶到 hash:
# 把 id 除以 1000 作为桶号,余数作为 field
HSET bucket:12345 678 value # 对应 id = 12345678
这样每个 hash 只有 1000 个字段,能保持 listpack 编码(连续内存、无指针开销),内存可以节省 5~10 倍。这是 Instagram 著名的存储优化方案。
5. Set 集合
无序、不重复的元素集合,支持交并差运算。上限 2^32-1 个元素。
5.1 基本操作
SADD key m1 m2 m3 # 添加,返回真正新增的个数
SREM key m1 m2 # 删除
SCARD key # 元素个数,O(1)
SISMEMBER key m1 # 是否是成员,O(1)
SMISMEMBER key m1 m2 # 6.2+ 批量判断,返回 0/1 数组
SMEMBERS key # ⚠️ 返回全部元素,O(N),大 set 上危险
SSCAN key cursor [MATCH p] [COUNT n] # 增量遍历
SRANDMEMBER key [count] # 随机取(不删除)
SPOP key [count] # 随机弹出(删除)
SMOVE src dst member # 原子地把元素从 src 移到 dst
SRANDMEMBER 的 count 正负语义不同(面试细节):
count > 0:返回不重复的 count 个元素,如果 count 大于集合大小则返回全部;count < 0:返回 |count| 个元素,允许重复(相当于有放回抽样),一定返回 |count| 个。
5.2 集合运算
SINTER k1 k2 k3 # 交集
SUNION k1 k2 # 并集
SDIFF k1 k2 # 差集(k1 有而 k2 没有的),注意顺序敏感
SINTERCARD 2 k1 k2 [LIMIT n] # 7.0+ 只要交集的数量,不返回元素(LIMIT 可提前中断)
SINTERSTORE dst k1 k2 # 交集结果存到 dst
SUNIONSTORE dst k1 k2
SDIFFSTORE dst k1 k2
性能警告:SINTER/SUNION/SDIFF 的复杂度是 O(N*M) 级别,在大集合上会长时间阻塞主线程。生产要么控制集合规模,要么用 *STORE 版本在从节点上算,要么放到业务层算。
5.3 典型应用场景
(1)标签系统
SADD article:1001:tags redis 数据库 缓存
SADD tag:redis:articles 1001 1002 1003
SINTER tag:redis:articles tag:mysql:articles # 同时属于两个标签的文章
(2)社交关系
SADD user:1001:following 2001 2002 2003
SADD user:2001:followers 1001
SINTER user:1001:following user:2001:following # 共同关注
SDIFF user:2001:following user:1001:following # 他关注但我没关注的(推荐来源)
SISMEMBER user:1001:following 2001 # 我是否关注了他
(3)抽奖
SADD lottery:2026 user1 user2 user3 ...
SRANDMEMBER lottery:2026 3 # 抽 3 个不重复的(不移除,可用于多轮不同奖项)
SPOP lottery:2026 3 # 抽 3 个并移除(一人只能中一次)
(4)去重 / 唯一性判断
SADD article:1001:readers user:2001 # 返回 1 说明是首次阅读
SCARD article:1001:readers # 精确 UV(数据量大时改用 HyperLogLog)
(5)黑白名单
SADD blacklist:ip 1.2.3.4 5.6.7.8
SISMEMBER blacklist:ip 1.2.3.4 # O(1) 判断
6. ZSet 有序集合
每个元素(member)关联一个 double 类型的分数(score),按 score 排序。member 唯一,score 可重复。score 相同时按 member 的字典序排列。
zset 是 Redis 最强大也最常考的类型。
6.1 增删改
ZADD key score member [score member ...]
ZADD key NX 100 m1 # 仅新增,不更新已存在的
ZADD key XX 100 m1 # 仅更新,不新增
ZADD key GT 100 m1 # 6.2+ 仅当新 score 更大时更新(常用于"只涨不跌"的分数)
ZADD key LT 100 m1 # 6.2+ 仅当新 score 更小时更新
ZADD key CH 100 m1 # 返回值改为"被改变的元素数"(含更新),默认只算新增
ZADD key INCR 10 m1 # 对 score 做增量,返回新 score(等价于 ZINCRBY)
ZINCRBY key 10 m1 # score += 10
ZREM key m1 m2 # 删除成员
6.2 查询单个
ZSCORE key m1 # 成员的 score,O(1)
ZMSCORE key m1 m2 # 6.2+ 批量
ZCARD key # 成员总数,O(1)
ZRANK key m1 # 升序排名(0 开始),O(logN)
ZREVRANK key m1 # 降序排名
# 7.2+ 支持 WITHSCORE 一次拿排名和分数
ZRANK key m1 WITHSCORE
6.3 按排名范围查询
ZRANGE key 0 -1 [WITHSCORES] # 升序,按下标区间
ZREVRANGE key 0 9 WITHSCORES # 降序取前 10(Top N 的标准写法)
# 6.2+ ZRANGE 统一了所有范围查询,推荐用新版
ZRANGE key 0 9 REV WITHSCORES # 等价于 ZREVRANGE
ZRANGE key (5 (10 BYSCORE LIMIT 0 10 # 按 score 范围
ZRANGE key [a [c BYLEX # 按字典序范围
ZRANGESTORE dst src 0 -1 # 6.2+ 结果存到新 key
6.4 按 score 范围查询
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]
ZREVRANGEBYSCORE key max min [WITHSCORES] [LIMIT offset count]
ZCOUNT key min max # 范围内数量
ZREMRANGEBYSCORE key min max # 删除范围内的成员
ZREMRANGEBYRANK key 0 9 # 按排名删除
区间语法(很容易记错,面试爱问):
| 写法 | 含义 |
|---|---|
5 |
闭区间,>= 5 |
(5 |
开区间,> 5 |
-inf / +inf |
负无穷 / 正无穷 |
ZRANGEBYSCORE key -inf +inf # 全部
ZRANGEBYSCORE key 60 100 # 60 <= score <= 100
ZRANGEBYSCORE key (60 100 # 60 < score <= 100
ZRANGEBYSCORE key 0 100 LIMIT 20 10 # 第 3 页(每页 10 条)
6.5 按字典序查询
要求所有成员的 score 完全相同,否则结果无意义。
ZRANGEBYLEX key min max [LIMIT offset count]
ZREVRANGEBYLEX key max min
ZLEXCOUNT key min max
ZREMRANGEBYLEX key min max
字典序区间语法:
| 写法 | 含义 |
|---|---|
[a |
闭区间,>= “a” |
(a |
开区间,> “a” |
- |
最小值(负无穷) |
+ |
最大值(正无穷) |
ZADD words 0 apple 0 banana 0 cherry 0 date
ZRANGEBYLEX words - + # 全部
ZRANGEBYLEX words [b [c # >= "b" 且 <= "c" → banana(cherry 的 "ch" > "c")
ZRANGEBYLEX words [ba (c # banana
# 前缀匹配的技巧(自动补全场景)
ZRANGEBYLEX words [ba [ba\xff # 所有以 "ba" 开头的成员
6.6 弹出与阻塞
ZPOPMIN key [count] # 5.0+ 弹出 score 最小的
ZPOPMAX key [count]
BZPOPMIN key [key ...] timeout # 阻塞版
BZPOPMAX key [key ...] timeout
ZMPOP 2 k1 k2 MIN COUNT 5 # 7.0+ 从多个 key 弹
BZMPOP 0 2 k1 k2 MIN COUNT 5
ZRANDMEMBER key [count] [WITHSCORES] # 6.2+ 随机成员
6.7 集合运算
ZUNIONSTORE dst 2 k1 k2 [WEIGHTS 1 2] [AGGREGATE SUM|MIN|MAX]
ZINTERSTORE dst 2 k1 k2 [WEIGHTS 1 2] [AGGREGATE SUM|MIN|MAX]
ZDIFFSTORE dst 2 k1 k2 # 6.2+
ZUNION 2 k1 k2 [WITHSCORES] # 6.2+ 直接返回不存储
ZINTER 2 k1 k2 [WITHSCORES]
ZDIFF 2 k1 k2 [WITHSCORES]
ZINTERCARD 2 k1 k2 [LIMIT n] # 7.0+ 只要交集数量
WEIGHTS 给每个输入集合的 score 设权重(相乘),AGGREGATE 决定 score 如何合并(默认 SUM)。这个组合非常实用:
# 综合排序:销量权重 0.3,好评率权重 0.7
ZADD sales 100 item1 200 item2
ZADD rating 4.5 item1 3.8 item2
ZUNIONSTORE final 2 sales rating WEIGHTS 0.3 0.7
6.8 典型应用场景
(1)排行榜(最经典)
ZADD leaderboard 5000 player1 4800 player2 5200 player3
ZINCRBY leaderboard 100 player1 # 加分
ZREVRANGE leaderboard 0 9 WITHSCORES # Top 10
ZREVRANK leaderboard player1 # 我的排名(从 0 开始,展示要 +1)
ZSCORE leaderboard player1 # 我的分数
ZCOUNT leaderboard 5000 +inf # 5000 分以上有多少人
# 看我前后各 3 名(附近的对手)
myrank=$(redis-cli ZREVRANK leaderboard player1)
redis-cli ZREVRANGE leaderboard $((myrank-3)) $((myrank+3)) WITHSCORES
分数相同要按时间先到先排怎么办? 把时间编进 score 的小数部分:
# score = 得分 * 10^10 + (9999999999 - 时间戳)
# 这样分数相同时,时间戳小(更早)的 score 更大,排名更前
ZADD leaderboard 50001731234567 player1
或者用 member 的字典序(score 相同时 zset 按 member 字典序升序),把时间戳作为 member 前缀。
(2)延迟队列 / 定时任务
# score 存执行的时间戳
ZADD delay:queue 1735660800 '{"task":"send_sms","id":1}'
# 消费者轮询取已到期的任务
ZRANGEBYSCORE delay:queue 0 <当前时间戳> LIMIT 0 10
# 取到后要原子地删除,避免多个消费者重复消费 → 用 Lua
配套 Lua 脚本(保证"取出 + 删除"原子):
local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, ARGV[2])
if #tasks > 0 then
redis.call('ZREM', KEYS[1], unpack(tasks))
end
return tasks
(3)滑动窗口限流
# 限制 60 秒内最多 100 次请求
# 1. 移除窗口外的记录
ZREMRANGEBYSCORE rate:user1001 0 <当前时间戳-60000>
# 2. 统计窗口内的数量
ZCARD rate:user1001
# 3. 未超限则记录本次请求(member 要唯一,用 时间戳+随机数)
ZADD rate:user1001 <当前时间戳> <当前时间戳>-<随机>
EXPIRE rate:user1001 60
Lua 版本(生产用法):
local key, now, window, limit = KEYS[1], tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count >= limit then return 0 end
redis.call('ZADD', key, now, ARGV[4])
redis.call('PEXPIRE', key, window)
return 1
(4)带权重的优先级队列
ZADD priority:queue 1 urgent-task 5 normal-task 10 low-task
ZPOPMIN priority:queue # 优先级数字小的先处理
(5)范围查询索引
用 zset 给数据建"二级索引",比如按年龄查用户:
ZADD idx:user:age 20 user1001 25 user1002 30 user1003
ZRANGEBYSCORE idx:user:age 20 25 # 找 20~25 岁的用户
(6)自动补全 / 前缀搜索
ZADD autocomplete 0 redis 0 redisson 0 rediscluster 0 mysql
ZRANGEBYLEX autocomplete [redis [redis\xff # 所有 redis 前缀的词
7. 五种类型对比与选型
| 类型 | 有序 | 去重 | 核心能力 | 典型场景 |
|---|---|---|---|---|
| string | - | - | 原子计数、二进制安全存储、NX 语义 | 缓存、计数器、分布式锁、限流、Session、Bitmap |
| list | 插入顺序 | 否 | 双端操作、阻塞弹出 | 消息队列、时间线、最新 N 条、栈/队列 |
| hash | 否 | field 去重 | 字段级读写与自增 | 对象存储、购物车、计数器组、内存优化分桶 |
| set | 否 | 是 | 交/并/差、随机取 | 标签、社交关系、抽奖、去重、黑白名单 |
| zset | score 排序 | member 去重 | 排序、范围查询、字典序 | 排行榜、延迟队列、滑动窗口限流、二级索引 |
选型口诀:
- 单值 / 计数 → string
- 对象 / 多字段 → hash
- 队列 / 顺序列表 → list
- 去重 / 集合运算 → set
- 需要排序或范围 → zset
8. 危险命令清单
这些命令时间复杂度是 O(N),N 是集合元素数量,在大 key 上会阻塞主线程,务必牢记:
| 危险命令 | 复杂度 | 替代方案 |
|---|---|---|
KEYS * |
O(N) 全库 | SCAN |
HGETALL / HKEYS / HVALS |
O(N) | HSCAN 或 HMGET 指定字段 |
SMEMBERS |
O(N) | SSCAN |
LRANGE key 0 -1 |
O(N) | 分段 LRANGE |
ZRANGE key 0 -1 |
O(N) | LIMIT 分页 |
SINTER / SUNION / SDIFF |
O(N*M) | *STORE 到从库算,或业务层算 |
DEL 大 key |
O(N) | UNLINK |
FLUSHALL / FLUSHDB |
O(N) | FLUSHALL ASYNC |
SORT 大集合 |
O(N+M*logM) | 业务层排序 |
9. 高频面试题
Q1:Redis 有哪五种基本数据类型?各自的底层实现是什么?
- string:底层是 SDS,编码有
int(能表示为 long 的整数)、embstr(<=44 字节,robj 和 SDS 一次分配、内存连续)、raw(>44 字节,分开分配); - list:3.2~6.x 是
quicklist(ziplist 组成的双向链表),7.0+ 小列表直接用listpack,大列表用 quicklist(内部节点改为 listpack); - hash:小的用
listpack(7.0 前是ziplist),大的用hashtable(dict); - set:全整数且元素少用
intset(有序数组 + 二分查找),少量非整数用listpack(7.2+),否则用hashtable; - zset:小的用
listpack/ziplist,大的用skiplist+dict的组合(跳表支持范围查询,字典支持 O(1) 查 score)。
Q2:DEL 和 UNLINK 有什么区别?
DEL 在主线程同步释放内存,删除一个千万级元素的集合可能阻塞数秒,期间整个实例不可用。
UNLINK(4.0+)先把 key 从数据库字典中摘除(O(1),客户端立即可见 key 已不存在),真正的内存回收交给 bio 后台线程异步做。
注意 UNLINK 有个优化:Redis 会先估算释放代价,如果这个 key 很小(比如只是个 string 或者只有几个元素),异步的调度开销反而更大,就直接同步删了。
生产建议:删大 key 一律 UNLINK,或者开启 lazyfree-lazy-user-del yes 让 DEL 自动表现得像 UNLINK。
Q3:为什么 SET 会清除 TTL,而 INCR 不会?
Redis 的语义规则是:只有"重新创建/替换整个 value"的命令才重置过期时间,“修改现有 value"的命令保留过期时间。
SET 在语义上是"完全覆盖这个 key”,等于创建了一个全新的 key,所以 TTL 被清空。而 INCR、APPEND、LPUSH、HSET、SADD 都是修改已有对象,TTL 保留。
GETSET、SET 都会清除;6.0+ 可以用 SET key val KEEPTTL 显式保留。这个坑造成过很多"缓存变永久,内存泄漏"的线上事故。
Q4:list 和 zset 都能做队列,怎么选?
- list + BRPOP:简单、阻塞消费不用轮询、性能高(O(1)),但不支持延迟/优先级,也没有消息确认机制。适合简单的即时任务队列。
- zset:score 可以放执行时间戳(延迟队列)或优先级(优先级队列),但没有阻塞弹出到期消息的命令——
BZPOPMIN只能弹最小的,不能"等到 score <= now 才弹",所以必须业务侧轮询,还要用 Lua 保证"查询 + 删除"的原子性。
如果需要消息确认、消费组、消息回溯,两个都不合适,应该用 Stream(第 9 篇)。
Q5:BLPOP 阻塞时会占用 Redis 的主线程吗?
不会。这是 Redis 阻塞命令的精妙之处:
当 BLPOP 发现列表为空,Redis 会把这个客户端标记为阻塞状态,加入到该 key 的阻塞客户端链表(server.blocking_keys 字典)里,然后主线程立即返回去处理其他客户端的请求。
当有客户端对这个 key 执行 LPUSH 时,Redis 在命令执行后检查 ready_keys,发现有客户端在等这个 key,就在下一次事件循环的 beforeSleep 阶段唤醒它并把数据给它(按阻塞的 FIFO 顺序)。
所以阻塞的是客户端连接,不是服务端线程。但要注意:阻塞的连接不能被复用,连接池里如果有连接长期阻塞在 BLPOP 上会耗尽池,客户端要为阻塞消费开独立连接。
Q6:zset 为什么用跳表而不是红黑树/B+树?
三个理由:
- 范围查询更自然:跳表最底层是一个有序链表,找到起点后顺着
forward指针走就能顺序输出,ZRANGEBYSCORE天然高效。红黑树做范围查询要中序遍历、维护栈,代码复杂。 - 实现简单得多:跳表的插入删除只需要改几个指针,靠随机层数(Redis 用 1/4 概率上升,最大 32 层)保持平衡;红黑树要处理旋转和变色,代码量和出 bug 的概率高好几倍。
- 并发/内存友好且性能相当:两者查找都是 O(logN)。跳表可以通过调节概率参数在内存和速度间权衡,Redis 选 p=0.25 让平均每个节点只有 1.33 个指针,比红黑树每节点两个指针 + 父指针更省。
至于 B+ 树,那是为磁盘设计的(一个节点一个磁盘页,减少 IO 次数),纯内存场景用它没有意义。
Q7:如果 zset 的 score 相同,如何保证按插入时间排序?
zset 在 score 相同时按 member 的字典序(升序) 排列,所以有两种做法:
- 把时间编进 score:
score = 真实分数 * 10^10 + (MAX_TS - 时间戳)。因为分数占高位,时间只影响同分的次序;用MAX_TS - 时间戳是为了让早提交的分数更大(降序榜单排前面)。要注意 score 是 double,只有 53 位精度(约 9×10^15),编码时不能溢出。 - 把时间放进 member 前缀:如
member = "1731234567:player1",score 只存真实分数,同分时按字符串比较,早的时间戳字典序靠前。缺点是查询单个玩家分数时要知道完整 member。
Q8:HGETALL 为什么危险?怎么办?
三重危害:
- 时间复杂度 O(N),字段多时长时间占用主线程,所有其他命令排队;
- 构造巨大返回包,占用客户端输出缓冲区,可能触发
client-output-buffer-limit被断开,甚至撑爆内存; - 占满网络带宽,一个 100MB 的 hash 传输就是 100MB 流量,千兆网卡直接跑满。
替代:字段确定时用 HMGET 只取需要的;要全量遍历时用 HSCAN 分批(7.4+ 还可以加 NOVALUES 只取字段名);最根本的是从设计上避免大 hash——按业务维度拆分或分桶。
Q9:SRANDMEMBER key 3 和 SRANDMEMBER key -3 有什么区别?
- 正数:返回不重复的 3 个元素(无放回抽样)。如果集合只有 2 个元素,就只返回 2 个。
- 负数:返回 3 个元素,可以重复(有放回抽样)。即使集合只有 1 个元素,也会返回 3 个(都是那一个)。
抽奖场景要用正数(一个人不能中两次同一个奖),做加权随机模拟时可能用负数。
Q10:MSET/MGET 在 Redis Cluster 里能用吗?
默认不能跨 slot 使用。Cluster 把 key 用 CRC16 映射到 16384 个 slot,MGET k1 k2 如果 k1 和 k2 落在不同 slot(很可能在不同节点),Redis 会返回 CROSSSLOT Keys in request don't hash to the same slot 错误。
解决办法是用 hash tag:key 中被 {} 包裹的部分才参与哈希计算。
MGET {user1001}:name {user1001}:age # 都算 "user1001" 的哈希,一定同 slot
但要注意 hash tag 用多了会导致数据倾斜(大量 key 挤在一个 slot/节点上)。另一种方案是让客户端(如 go-redis、Lettuce)自动把 MGET 拆成按节点分组的多个请求并行执行——大部分成熟客户端都实现了这一点。
Q11:怎么用 Redis 实现一个精确的滑动窗口限流?
用 zset:member 存唯一请求标识,score 存请求时间戳(毫秒)。
-- KEYS[1]=限流key ARGV[1]=当前毫秒 ARGV[2]=窗口毫秒 ARGV[3]=限制次数 ARGV[4]=唯一member
local key = KEYS[1]
local now = tonumber(ARGV[1])
redis.call('ZREMRANGEBYSCORE', key, 0, now - tonumber(ARGV[2])) -- 清理窗口外
if redis.call('ZCARD', key) >= tonumber(ARGV[3]) then
return 0
end
redis.call('ZADD', key, now, ARGV[4])
redis.call('PEXPIRE', key, tonumber(ARGV[2]))
return 1
必须用 Lua 保证原子性,否则高并发下"统计"和"写入"之间会有竞态导致超限。
代价:每个请求都要在 zset 里存一条记录,限流阈值高(比如每秒 1 万)时内存和 ZREMRANGEBYSCORE 的开销都不小。所以精确滑动窗口适合"低频高价值"的限流(如短信发送、登录尝试),高频接口限流应该用固定窗口计数(INCR+EXPIRE,最省)或令牌桶(string 存令牌数 + 时间戳,Lua 里按时间差补令牌)。
Q12:一个 key 最大能存多大?
- key 本身:最大 512MB(但实际不该超过几十字节,key 太长浪费内存且影响比较性能);
- string 的 value:最大 512MB(7.0 后理论上可配置更大,但不建议);
- list/set/hash/zset 的元素数量:2^32 - 1(约 42 亿)。
但能存不等于该存。生产上:
- string value 建议 < 10KB,超过 100KB 就算大 key;
- 集合类元素数建议 < 5000,超过 1 万要考虑拆分;
- 单个 key 总内存超过 1MB 就要警惕(
MEMORY USAGE key可查)。
大 key 的危害:删除阻塞、迁移阻塞(Cluster reshard 时 MIGRATE 是同步的)、网络带宽被打满、内存分布不均、访问它的命令必然是慢查询。
小结
- 通用操作:
EXISTS/TYPE/UNLINK(优于DEL)/EXPIRE+TTL/SCAN(替代KEYS)。 SORT支持BY(按外部 key 排序,BY nosort可跳过排序)、GET(取关联数据,#表示元素本身)、STORE(结果存为 list,但会让它变成写命令)、ALPHA(按字典序,非数字元素必须加)。但它是 O(N+M*logM) 的危险命令,且BY/GET在 Cluster 下会CROSSSLOT——现代用法应该优先用 zset 或在业务层排序,7.0+ 有只读的SORT_RO。- TTL 陷阱:
SET、GETSET会清除过期时间,其他写命令(INCR/APPEND/LPUSH/HSET)保留;用SET ... KEEPTTL显式保留。 - string:
SET的NX/XX/EX/GET/KEEPTTL参数要背下来;INCR系列原子计数是 64 位整数;MGET/MSET省 RTT 但集群要用 hash tag。 - list:
LPUSH+LTRIM做固定长度时间线;BRPOP阻塞消费不占主线程但占连接;LMOVE/RPOPLPUSH做可靠队列和循环列表。 - hash:存对象的首选(内存最省 + 字段级原子更新);
HGETALL在大 hash 上危险;7.4+ 有字段级过期HEXPIRE;分桶技巧可省 5~10 倍内存。 - set:去重 + 交并差;集合运算复杂度高要控规模;
SRANDMEMBER正负 count 语义不同。 - zset:
skiplist + dict双结构,O(logN)排序与范围查询;区间语法(开区间、[闭区间、-inf/+inf;ZADD的GT/LT/NX/XX/INCR参数很实用;排行榜、延迟队列、滑动窗口限流三大场景。 - 选型:单值计数用 string,多字段用 hash,顺序队列用 list,去重集合运算用 set,排序范围用 zset。
- 时刻记住那张危险命令表:所有 O(N) 的全量读取命令在大 key 上都会拖垮整个实例。
xingliuhua