阶段一 · 基础命令与数据结构

五大数据结构(上):String / List / Hash

数据结构总览

Redis 的 value 不是简单的"字符串存储", 而是支持多种数据结构, 每种结构提供专属命令集. 选对结构, 事半功倍.

类型 特征一句话 类比
String 二进制安全的字节序列, 最大 512MB 一个万能变量(存文本, 数字, 序列化对象)
List 有序双向链表, 允许重复 一个双端队列(Deque)
Hash field-value 映射表 一行数据库记录 / 一个小 Map
Set 无序唯一集合 数学中的集合(下篇)
Sorted Set 带分值的有序集合 排行榜(下篇)

String = 螺丝刀(万能但基础).
List = 传送带(先进先出/后进先出).
Hash = 收纳格(一个 key 下按字段分格存放).

选工具的核心原则: 数据的访问模式决定选哪种结构.

1. String(字符串)

String 是 Redis 最基础, 最常用的数据类型. 看名字像只能存文本, 实际上它是二进制安全(Binary Safe) 的--可以存文本, 数字, JSON, 序列化对象, 甚至图片的 base64.

内部编码

Redis 根据 String 的实际内容自动选择编码:

  1. 纯整数 → int(8 字节直存)
  2. 短字符串(≤44 字节) → embstr(连续内存分配)
  3. 长字符串 → raw(SDS 动态字符串)

不需要手动选择, 了解即可理解为什么 INCR 对整数值如此高效.

1.1 基础读写

SET

SET key value [EX seconds | PX ms] [NX | XX] [GET]

设置 key 的值. 可选参数: EX/PX 设置过期时间; NX 仅 key 不存在时设置; XX 仅 key 存在时设置; GET 返回旧值(Redis 6.2+).

GET

GET key

获取 key 的值. key 不存在返回 nil. 仅适用于 String 类型.

SET greeting "Hello Redis"
OK

GET greeting
"Hello Redis"

# 设置时附带 10 秒过期
SET token "abc123" EX 10
OK

# 仅在 key 不存在时设置(原子操作, 分布式锁基础)
SET lock:order_1001 "holder_A" NX EX 30
OK ← 设置成功, 拿到锁

SET lock:order_1001 "holder_B" NX EX 30
(nil) ← key 已存在, 设置失败

GETDEL / GETEX

GETDEL key / GETEX key [EX seconds | PX ms | PERSIST]

GETDEL 获取并删除. GETEX 获取并修改过期时间(Redis 6.2+). 旧版 GETSET 已被 SET ... GET 取代.

1.2 数值操作

当 String 存储的是整数或浮点数时, Redis 提供原子性的加减操作--不需要先 GET 再 SET, 一条命令搞定.

INCR / DECR

INCR key / DECR key

原子递增/递减 1. key 不存在时从 0 开始. 值不是整数时报错.

INCRBY / DECRBY / INCRBYFLOAT

INCRBY key increment / DECRBY key decrement / INCRBYFLOAT key increment

按指定步长增减. INCRBYFLOAT 支持浮点数(如 INCRBYFLOAT price 0.5).

# 计数器: 文章阅读数
INCR article:1001:views → (integer) 1
INCR article:1001:views → (integer) 2
INCRBY article:1001:views 10 → (integer) 12

# key 不存在时自动初始化为 0 再操作
INCR new_counter → (integer) 1

# 浮点数场景: 商品价格调整
SET price "9.99"
INCRBYFLOAT price 0.50 → "10.49"
INCRBYFLOAT price -1.00 → "9.49"

INCR = 高速公路自动计数器 -- 车过一辆加一, 不需要有人先读出数字, 加一, 再写回去. 这个"原子性"在高并发下极其重要: 100 个线程同时 INCR, 最终结果一定是精确的 +100.

1.3 批量操作与条件写入

MSET / MGET

MSET key1 value1 key2 value2 ... / MGET key1 key2 ...

批量设置/获取. 一次网络往返完成多个操作, 减少 RTT 开销. MSET 是原子的(要么全部设置, 要么全不设置).

# 一次写入多个用户信息
MSET user:1:name "Alice" user:1:age "28" user:1:city "Beijing"
OK

# 一次读取多个
MGET user:1:name user:1:age user:1:city
1) "Alice"
2) "28"
3) "Beijing"

# 不存在的 key 返回 nil
MGET user:1:name user:1:email
1) "Alice"
2) (nil)

MSETNX

MSETNX key1 value1 key2 value2 ...

仅当所有 key 都不存在时才批量设置. 原子性: 只要有一个 key 已存在, 全部不设置.

APPEND / STRLEN

APPEND key value / STRLEN key

APPEND 在已有值末尾追加字符串(key 不存在则等同 SET). STRLEN 返回值的字节长度.

MSET vs Pipeline

MSET 适合同类型批量写入. 如果需要混合不同命令(SET + EXPIRE + INCR 等), 应使用 Pipeline(流水线)打包发送, 同样减少网络往返但更灵活. Pipeline 将在后续篇章详细讲解.

1.4 典型应用场景

场景 实现方式 关键命令
缓存 JSON 序列化后存入 String SET key json EX ttl
计数器 页面 PV, 点赞数, 库存扣减 INCR / DECRBY
分布式锁(基础版) SET NX + 过期时间实现互斥 SET key val NX EX 30
限速器 固定窗口: INCR + EXPIRE INCR + EXPIRE
分布式 Session session_id → 用户数据 JSON SET sid data EX 1800
# 典型计数器: 限制接口每分钟最多 100 次
# key = rate:user_123:api_create
INCR rate:user_123:api_create → 1(第一次请求)
EXPIRE rate:user_123:api_create 60
# ... 后续请求
INCR rate:user_123:api_create → 2, 3, ...
# 当返回值 > 100 时拒绝请求

2. List(列表)

List 是一个有序的字符串序列, 允许重复元素. 底层实现是 quicklist(ziplist 节点组成的双向链表), 兼顾内存效率和操作性能.

List = 一条双向传送带. 可以从左端放东西(LPUSH), 也可以从右端放(RPUSH); 取东西同理. 这让它既能当(LIFO: 同一端进出)又能当队列(FIFO: 一端进另一端出).

2.1 基础操作

LPUSH / RPUSH

LPUSH key element [element ...] / RPUSH key element [element ...]

从左端/右端插入一个或多个元素. key 不存在时自动创建空 List. 返回操作后列表长度.

LPOP / RPOP

LPOP key [count] / RPOP key [count]

从左端/右端弹出元素. Redis 6.2+ 支持 count 参数一次弹出多个. 列表为空时返回 nil.

# 构建一个任务队列
RPUSH tasks "task_a" "task_b" "task_c" → (integer) 3

# 从左端取出(FIFO 队列行为)
LPOP tasks → "task_a"
LPOP tasks → "task_b"

# 列表当前状态: ["task_c"]

# 多元素 LPUSH(注意插入顺序)
LPUSH stack "a" "b" "c" → (integer) 3
# 列表状态: ["c", "b", "a"] ← 最后一个参数在最左端

LPUSH 的插入顺序

LPUSH key a b c 等价于依次执行 LPUSH key a, LPUSH key b, LPUSH key c. 最终列表从左到右是 [c, b, a]. 如果希望保持参数顺序, 用 RPUSH.

LLEN

LLEN key

返回列表长度. O(1) 复杂度(内部维护长度计数).

LINDEX / LSET

LINDEX key index / LSET key index element

LINDEX 按下标获取元素(0-based, 负数从右端计). LSET 修改指定下标的值. 按下标访问是 O(N), 列表很长时慎用.

2.2 范围查询与修剪

LRANGE

LRANGE key start stop

返回指定范围内的元素(闭区间). LRANGE key 0 -1 返回全部元素.

RPUSH colors "red" "green" "blue" "yellow" "purple"

LRANGE colors 0 -1 → ["red","green","blue","yellow","purple"]
LRANGE colors 0 2 → ["red","green","blue"]
LRANGE colors -2 -1 → ["yellow","purple"]

LTRIM

LTRIM key start stop

只保留指定范围内的元素, 其余全部删除. 常用于维护固定长度的列表.

# 保留最近 100 条日志
LPUSH logs "2024-01-01 event_x"
LTRIM logs 0 99 ← 每次插入后修剪, 列表永远不超过 100 条

LREM

LREM key count element

删除列表中与 element 相等的元素. count > 0: 从左往右删最多 count 个; count < 0: 从右往左; count = 0: 删除全部匹配.

2.3 阻塞操作

普通的 LPOP/RPOP 在列表为空时立即返回 nil. 但在消息队列场景中, 消费者需要"等待"新消息到来--轮询浪费 CPU, 这时需要阻塞版本.

BLPOP / BRPOP

BLPOP key [key ...] timeout / BRPOP key [key ...] timeout

阻塞式弹出: 列表非空时立即返回; 列表为空时阻塞等待, 直到有新元素被 PUSH 进来或超时(timeout=0 表示无限等待). 可同时监听多个 key.

# 终端 1(消费者)-- 阻塞等待
BLPOP job_queue 30
# ... 此处阻塞, 等待中 ...

# 终端 2(生产者)-- 推送任务
RPUSH job_queue "send_email:user_42"

# 终端 1 立即收到响应:
1) "job_queue" ← 来自哪个 key
2) "send_email:user_42" ← 弹出的元素

BLPOP = 餐厅叫号 -- 你坐在那里等(阻塞), 厨房做好一道菜(RPUSH)时喊你的号, 你立刻取走. 比起每隔 1 秒跑去柜台问"好了没"(轮询), 阻塞等待既省力又实时.

BLPOP 的多 key 优先级

BLPOP key1 key2 key3 0 同时监听多个列表时, Redis 按参数顺序检查--谁先有数据就从谁那取. 这可以用来实现优先级队列: 高优先级的 key 放前面.

2.4 典型应用场景

场景 实现模式 关键命令
消息队列 生产者 RPUSH, 消费者 BLPOP RPUSH + BLPOP
最近 N 条记录 LPUSH + LTRIM 固定长度 LPUSH + LTRIM
栈(LIFO) 同一端 PUSH 和 POP LPUSH + LPOP
Timeline 每人一个 List, 关注的人发动态时 LPUSH LPUSH + LRANGE

List 做消息队列的局限

List 实现的队列是点对点(一条消息只能被一个消费者取走), 不支持消费者组, 消息确认(ACK), 重新投递. 如果需要更完整的消息队列语义, Redis 5.0+ 的 Stream 类型是更好的选择(将在扩展数据类型篇讲解).

3. Hash(哈希)

Hash 是一个 key 下的字段-值映射表(field → value). 适合存储对象--一个 key 代表一个实体, 每个 field 是该实体的一个属性.

String 存对象 = 把整个 JSON 塞进一个格子.
Hash 存对象 = 给这个对象开了一排小抽屉, 每个抽屉有标签(field), 可以单独打开某个抽屉取/改, 而不用把整个对象搬出来. 修改单个字段时效率远高于 String 的"读-改-写".

3.1 基础操作

HSET / HGET

HSET key field value [field value ...] / HGET key field

HSET 设置一个或多个 field 的值(Redis 4.0+ 支持多字段). HGET 获取单个 field 的值.

# 存储一个用户对象
HSET user:1001 name "Alice" age "28" city "Beijing" role "engineer"
→ (integer) 4 ← 新增了 4 个 field

HGET user:1001 name → "Alice"
HGET user:1001 age → "28"
HGET user:1001 email → (nil) ← field 不存在

HDEL

HDEL key field [field ...]

删除一个或多个 field. 返回实际删除的数量.

HEXISTS / HLEN

HEXISTS key field / HLEN key

HEXISTS 判断 field 是否存在(返回 0 或 1). HLEN 返回 Hash 中 field 的数量.

3.2 批量操作与遍历

HMGET

HMGET key field [field ...]

一次获取多个 field 的值. 不存在的 field 返回 nil. 比多次 HGET 减少网络往返.

HGETALL / HKEYS / HVALS

HGETALL key / HKEYS key / HVALS key

HGETALL 返回所有 field 和 value(交替排列). HKEYS 只返回所有 field 名. HVALS 只返回所有 value.

HMGET user:1001 name city role
1) "Alice"
2) "Beijing"
3) "engineer"

HGETALL user:1001
1) "name"
2) "Alice"
3) "age"
4) "28"
5) "city"
6) "Beijing"
7) "role"
8) "engineer"

HKEYS user:1001 → ["name","age","city","role"]
HVALS user:1001 → ["Alice","28","Beijing","engineer"]

HGETALL 的性能陷阱

当 Hash 内有大量 field(比如几万个)时, HGETALL 会一次返回全部数据, 阻塞主线程且产生大量网络传输. 此时应使用 HSCAN 渐进式遍历, 与 KEYS vs SCAN 的道理一样.

HSCAN

HSCAN key cursor [MATCH pattern] [COUNT hint]

渐进式遍历 Hash 内的 field-value 对. 用法与顶层 SCAN 一致.

3.3 数值操作

HINCRBY / HINCRBYFLOAT

HINCRBY key field increment / HINCRBYFLOAT key field increment

对 Hash 中某个 field 的值做原子加减. 与 String 的 INCRBY 类似, 但作用在单个 field 上.

# 购物车: 商品数量增减
HSET cart:user_42 item_1001 2 item_1002 1
HINCRBY cart:user_42 item_1001 3 → (integer) 5 ← 2+3=5
HINCRBY cart:user_42 item_1001 -1 → (integer) 4 ← 减一件

# 浮点数场景
HSET product:1001 price "99.00"
HINCRBYFLOAT product:1001 price -10.50 → "88.5"

3.4 典型应用场景

场景 实现方式 优势
对象缓存 每个实体一个 Hash, user:{id} → field 为属性 可部分读取/更新, 无需整体序列化
购物车 cart:{uid} → field 为商品 ID, value 为数量 增减数量用 HINCRBY, 删除商品用 HDEL
用户配置 config:{uid} → field 为配置项名 读取/修改单个配置项 O(1)
聚合计数 stats:page_views → field 为页面 ID 所有页面的计数集中在一个 key, 减少 key 数量

Hash vs String 存对象: 怎么选?

维度 String(JSON) Hash
读取方式 只能整体读取整个 JSON 可按 field 精确读取
修改单字段 GET → 反序列化 → 修改 → 序列化 → SET HSET key field newVal 一条命令
内存效率 字段少时 JSON 更紧凑 field 少(<128 个且值<64 字节)时用 ziplist 编码, 非常省内存
过期粒度 整个 key 过期 整个 key 过期(单个 field 不能独立过期)
适用场景 对象整体读写, 字段极少变动 频繁修改部分字段, 需要原子数值操作

实践经验

  1. 如果对象字段经常变动或需要单字段原子操作(如计数器), 选 Hash
  2. 如果对象总是整体读写且字段固定, String + JSON 更简单
  3. Hash 的单个 field 不支持独立设置 TTL--如果需要字段级过期, 要么拆成多个 String key, 要么在应用层处理

快速回顾

类型 核心命令 记忆要点
String SET / GET 基础读写, SET 支持 EX/NX/XX 等选项
INCR / INCRBY 原子递增, 计数器首选
MSET / MGET 批量操作减少 RTT
SET key val NX EX 分布式锁基础范式
List LPUSH / RPUSH 左/右端插入
LPOP / RPOP 左/右端弹出
BLPOP / BRPOP 阻塞式弹出, 消息队列核心
LRANGE / LTRIM 范围查询 + 固定长度裁剪
Hash HSET / HGET 单 field 读写
HMGET / HGETALL 批量获取, 大 Hash 用 HSCAN
HINCRBY field 级原子递增(购物车等)
HDEL / HEXISTS 删除/判断 field 是否存在

动手练习

  1. String 计数器: 创建 article:2001:views, 用 INCR 累加 5 次, 再用 DECRBY 减 2, 验证最终值是否为 3.
  2. String 分布式锁: 用 SET lock:resource NX EX 10 尝试加锁, 然后再次执行同一命令验证返回 nil(锁已被持有). 等待过期后再试.
  3. List 队列: 用 RPUSH 向 task_queue 推入 5 个任务, 然后用 LPOP 逐个取出, 观察 FIFO 顺序.
  4. List 固定长度: 用 LPUSH + LTRIM 实现一个只保留最近 3 条消息的列表, 连续推入 5 条后用 LRANGE 0 -1 确认只剩最新 3 条.
  5. Hash 对象: 用 HSET 创建 product:5001(含 name, price, stock 字段), 然后用 HGET 读取 price; 用 HINCRBY 将 stock 减 1; 用 HMGET 一次获取 name 和 stock; 用 HGETALL 查看完整对象.
  6. 思考题: 为什么 Redis 不支持对 Hash 中单个 field 设置过期时间? 如果业务确实需要"部分字段过期"的效果, 如何设计?