目录

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 环境下应该放弃 SORTBY/GET 用法。

(3)性能:O(N + M*logM),大集合上是危险命令

对一个 100 万元素的 set 做 SORT 会阻塞主线程很久(第 2 篇末尾的危险命令表里就有它)。而且 BY/GET 会额外产生 N 次 key 查找。

实践建议

  • SORT 在现代 Redis 用法里已经很少用了。绝大多数排序需求应该用 zsetZRANGE,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"

阻塞的几个要点:

  1. 多个 key 时按参数顺序检查,第一个有数据的 key 先被消费(可用来做优先级队列);
  2. 多个客户端阻塞在同一 key 上时,按阻塞先后顺序(FIFO)唤醒,先阻塞的先拿到;
  3. 阻塞期间不占用主线程——Redis 把该客户端挂到 key 的阻塞列表上,主线程继续服务其他请求,等有 push 时再唤醒;
  4. 在 MULTI 事务里 BLPOP 不会阻塞,空列表直接返回 nil;
  5. 阻塞的连接不能复用,所以客户端要为阻塞消费单独开连接(连接池里的连接被长时间占用会耗尽池)。

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) HSCANHMGET 指定字段
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:DELUNLINK 有什么区别?

DEL 在主线程同步释放内存,删除一个千万级元素的集合可能阻塞数秒,期间整个实例不可用。

UNLINK(4.0+)先把 key 从数据库字典中摘除(O(1),客户端立即可见 key 已不存在),真正的内存回收交给 bio 后台线程异步做。

注意 UNLINK 有个优化:Redis 会先估算释放代价,如果这个 key 很小(比如只是个 string 或者只有几个元素),异步的调度开销反而更大,就直接同步删了。

生产建议:删大 key 一律 UNLINK,或者开启 lazyfree-lazy-user-del yesDEL 自动表现得像 UNLINK

Q3:为什么 SET 会清除 TTL,而 INCR 不会?

Redis 的语义规则是:只有"重新创建/替换整个 value"的命令才重置过期时间,“修改现有 value"的命令保留过期时间

SET 在语义上是"完全覆盖这个 key”,等于创建了一个全新的 key,所以 TTL 被清空。而 INCRAPPENDLPUSHHSETSADD 都是修改已有对象,TTL 保留。

GETSETSET 都会清除;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+树?

三个理由:

  1. 范围查询更自然:跳表最底层是一个有序链表,找到起点后顺着 forward 指针走就能顺序输出,ZRANGEBYSCORE 天然高效。红黑树做范围查询要中序遍历、维护栈,代码复杂。
  2. 实现简单得多:跳表的插入删除只需要改几个指针,靠随机层数(Redis 用 1/4 概率上升,最大 32 层)保持平衡;红黑树要处理旋转和变色,代码量和出 bug 的概率高好几倍。
  3. 并发/内存友好且性能相当:两者查找都是 O(logN)。跳表可以通过调节概率参数在内存和速度间权衡,Redis 选 p=0.25 让平均每个节点只有 1.33 个指针,比红黑树每节点两个指针 + 父指针更省。

至于 B+ 树,那是为磁盘设计的(一个节点一个磁盘页,减少 IO 次数),纯内存场景用它没有意义。

Q7:如果 zset 的 score 相同,如何保证按插入时间排序?

zset 在 score 相同时按 member 的字典序(升序) 排列,所以有两种做法:

  1. 把时间编进 scorescore = 真实分数 * 10^10 + (MAX_TS - 时间戳)。因为分数占高位,时间只影响同分的次序;用 MAX_TS - 时间戳 是为了让早提交的分数更大(降序榜单排前面)。要注意 score 是 double,只有 53 位精度(约 9×10^15),编码时不能溢出。
  2. 把时间放进 member 前缀:如 member = "1731234567:player1",score 只存真实分数,同分时按字符串比较,早的时间戳字典序靠前。缺点是查询单个玩家分数时要知道完整 member。

Q8:HGETALL 为什么危险?怎么办?

三重危害:

  1. 时间复杂度 O(N),字段多时长时间占用主线程,所有其他命令排队;
  2. 构造巨大返回包,占用客户端输出缓冲区,可能触发 client-output-buffer-limit 被断开,甚至撑爆内存;
  3. 占满网络带宽,一个 100MB 的 hash 传输就是 100MB 流量,千兆网卡直接跑满。

替代:字段确定时用 HMGET 只取需要的;要全量遍历时用 HSCAN 分批(7.4+ 还可以加 NOVALUES 只取字段名);最根本的是从设计上避免大 hash——按业务维度拆分或分桶。

Q9:SRANDMEMBER key 3SRANDMEMBER 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 陷阱SETGETSET 会清除过期时间,其他写命令(INCR/APPEND/LPUSH/HSET)保留;用 SET ... KEEPTTL 显式保留。
  • stringSETNX/XX/EX/GET/KEEPTTL 参数要背下来;INCR 系列原子计数是 64 位整数;MGET/MSET 省 RTT 但集群要用 hash tag。
  • listLPUSH+LTRIM 做固定长度时间线;BRPOP 阻塞消费不占主线程但占连接;LMOVE/RPOPLPUSH 做可靠队列和循环列表。
  • hash:存对象的首选(内存最省 + 字段级原子更新);HGETALL 在大 hash 上危险;7.4+ 有字段级过期 HEXPIRE;分桶技巧可省 5~10 倍内存。
  • set:去重 + 交并差;集合运算复杂度高要控规模;SRANDMEMBER 正负 count 语义不同。
  • zsetskiplist + dict 双结构,O(logN) 排序与范围查询;区间语法 ( 开区间、[ 闭区间、-inf/+infZADDGT/LT/NX/XX/INCR 参数很实用;排行榜、延迟队列、滑动窗口限流三大场景。
  • 选型:单值计数用 string,多字段用 hash,顺序队列用 list,去重集合运算用 set,排序范围用 zset。
  • 时刻记住那张危险命令表:所有 O(N) 的全量读取命令在大 key 上都会拖垮整个实例。