数据结构总览
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 的实际内容自动选择编码:
- 纯整数 →
int(8 字节直存) - 短字符串(≤44 字节) →
embstr(连续内存分配) - 长字符串 →
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 类型.
|
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 = 高速公路自动计数器 -- 车过一辆加一, 不需要有人先读出数字, 加一, 再写回去. 这个"原子性"在高并发下极其重要: 100 个线程同时 INCR, 最终结果一定是精确的 +100.
1.3 批量操作与条件写入
MSET / MGET
MSET key1 value1 key2 value2 ... / MGET key1 key2 ...
批量设置/获取. 一次网络往返完成多个操作, 减少 RTT 开销. MSET 是原子的(要么全部设置, 要么全不设置).
|
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 |
|
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.
|
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 返回全部元素.
|
LTRIM
LTRIM key start stop
只保留指定范围内的元素, 其余全部删除. 常用于维护固定长度的列表.
|
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.
|
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 的值.
|
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.
|
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 上.
|
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 不能独立过期) |
| 适用场景 | 对象整体读写, 字段极少变动 | 频繁修改部分字段, 需要原子数值操作 |
实践经验
- 如果对象字段经常变动或需要单字段原子操作(如计数器), 选 Hash
- 如果对象总是整体读写且字段固定, String + JSON 更简单
- 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 是否存在 |
动手练习
- String 计数器: 创建
article:2001:views, 用 INCR 累加 5 次, 再用 DECRBY 减 2, 验证最终值是否为 3. - String 分布式锁: 用
SET lock:resource NX EX 10尝试加锁, 然后再次执行同一命令验证返回 nil(锁已被持有). 等待过期后再试. - List 队列: 用 RPUSH 向
task_queue推入 5 个任务, 然后用 LPOP 逐个取出, 观察 FIFO 顺序. - List 固定长度: 用 LPUSH + LTRIM 实现一个只保留最近 3 条消息的列表, 连续推入 5 条后用 LRANGE 0 -1 确认只剩最新 3 条.
- Hash 对象: 用 HSET 创建
product:5001(含 name, price, stock 字段), 然后用 HGET 读取 price; 用 HINCRBY 将 stock 减 1; 用 HMGET 一次获取 name 和 stock; 用 HGETALL 查看完整对象. - 思考题: 为什么 Redis 不支持对 Hash 中单个 field 设置过期时间? 如果业务确实需要"部分字段过期"的效果, 如何设计?