Redis 是基于内存的键值存储,常被当作缓存、消息队列、分布式锁甚至轻量数据库使用。它之所以通用,关键在于其丰富的数据结构——选对结构,命令和场景往往自然成立。本文是 Redis 使用侧手册,系统梳理各数据结构的核心命令与适用场景;底层编码、Lua 执行模型、过期淘汰、持久化与集群架构等原理见姊妹篇《Redis 底层原理与架构》。

全局视角

Redis 的 key 是二进制安全的字符串,value 则可以是以下几种类型之一。选型时先看用途,再结合访问模式决定结构。

类型典型用途
String缓存、计数器、分布式锁
List消息队列、最新列表
Hash对象存储
Set去重、标签、抽奖
ZSet排行榜、延迟队列
Stream消息流、事件日志
Bitmap签到、活跃统计
HyperLogLog基数统计
Geo附近的人/店
Lua 脚本原子复合操作、分布式锁释放

String

String 是最基础也最被低估的类型。它不仅能存文本,还能存数字、二进制(图片、序列化对象),最大 512MB。

核心命令

1
2
3
4
5
6
7
8
SET key value [EX seconds] [PX ms] [NX|XX]    # NX 仅不存在时写,XX 仅存在时写
GET key
MSET k1 v1 k2 v2 / MGET k1 k2 # 批量,减少往返
INCR key / DECR key # 原子自增自减
INCRBY key 10 / INCRBYFLOAT key 1.5
APPEND key suffix
STRLEN key
SETEX key 60 value / PSETEX(毫秒)

SETNX 选项是构建分布式锁的基础。

适用场景

  1. 缓存对象:序列化后的 JSON、Protobuf。建议加 TTL 防止冷数据堆积。
  2. 计数器:阅读量、点赞数。INCR 原子且高效,单实例可达十万级 QPS。
  3. 分布式锁SET lock:order:123 token NX EX 30,释放时用 Lua 校验 token 再 DEL,避免误删他人锁。
  4. 限流器:固定窗口用 INCR + EXPIRE;滑动窗口可结合 ZSet。

分布式锁建议优先用 Redlock 或 Redisson 封装,单节点 SET NX 在主从故障切换时存在锁丢失风险。

List

双向链表语义的有序字符串集合,支持两端高效 push/pop。

核心命令

1
2
3
4
5
6
7
8
9
LPUSH list a b c     # 头部插入,结果顺序 c b a
RPUSH list a b c # 尾部插入,结果顺序 a b c
LPOP list [n] / RPOP list [n]
LRANGE list 0 -1 # 查看全部
LINDEX list 0 # 按下标取,O(N)
LLEN list
BLPOP list1 list2 30 # 阻塞式弹出,超时 30s,常用于队列
LREM list count elem
LTRIM list 0 99 # 只保留前 100 个

适用场景

  1. 消息队列LPUSH + BRPOP 实现「生产者-消费者」。BRPOP 阻塞避免空轮询。
  2. 最新 N 条列表:朋友圈最新动态、最新登录记录,配合 LPUSH + LTRIM 固定长度。
  3. 栈/队列:同端操作即栈(LPUSH+LPOP),异端操作即队列。
  4. 安全队列RPOPLPUSH(7.0 后 LMOVE)把处理中的消息搬到备份 list,处理失败可回滚。

List 做消息队列缺乏 ack 机制与消费组,复杂场景请用 Stream。

Hash

字段-值映射,值本身也是字符串。一个 Hash 就像一个对象。

核心命令

1
2
3
4
5
6
7
8
HSET user:1 name "Tom" age 30        # 可同时设多对
HGET user:1 name
HMGET user:1 name age
HGETALL user:1
HINCRBY user:1 age 1
HDEL user:1 age
HLEN user:1
HSCAN user:1 0 MATCH 'na*' # 渐进式遍历大 Hash

适用场景

  1. 对象存储:用户、商品属性。相比把整个 JSON 存 String,Hash 可以局部更新而不必读写整体,省带宽。
  2. 计数器集合:一篇文章的点赞、转发、评论各占一个字段,HINCRBY article:1 likes 1
  3. 购物车cart:uid 中 field=商品ID,value=数量。

当字段较少且整体读取频繁时,String + JSON 也合理;当需要字段级独立读写时,Hash 更优。

Set

无序、唯一字符串集合,支持丰富的集合运算。

核心命令

1
2
3
4
5
6
7
8
9
10
11
SADD set a b c
SMEMBERS set
SISMEMBER set a # O(1) 判断存在
SREM set a
SCARD set # 元素数
SINTER s1 s2 # 交集
SUNION s1 s2 # 并集
SDIFF s1 s2 # 差集
SINTERSTORE dest s1 s2 # 结果存入 dest
SRANDMEMBER set 3 # 随机取 3 个
SPOP set 2 # 随机弹出

适用场景

  1. 去重:UV 中转存储、敏感词集合。SISMEMBER O(1) 判断。
  2. 标签/共同关注:用户标签用 Set,SINTER 求共同好友、共同标签。
  3. 抽奖SRANDMEMBER 抽取(可重复)或 SPOP 抽取(不可重复)。
  4. 黑白名单:IP 黑名单、令牌白名单。

大集合做 SINTER 较重,可让小集合在前,或用 SINTERSTORE 预计算。

ZSet(Sorted Set)

有序集合,每个元素关联一个 score,按 score 排序且元素唯一。Redis 中表达力最强的结构。

核心命令

1
2
3
4
5
6
7
8
9
10
11
ZADD rank 100 tom 90 jerry 110 spike     # 添加
ZRANGE rank 0 -1 # 按 score 升序全取
ZRANGEBYSCORE rank 90 100 # 按 score 范围取
ZREVRANGE rank 0 2 # 降序取前 3
ZRANK rank tom / ZREVRANK rank tom # 排名
ZSCORE rank tom
ZINCRBY rank 5 tom # 增加分数
ZREM rank tom
ZCARD rank
ZPOPMAX rank / BZPOPMAX rank 30 # 弹出最高分(阻塞版可做延迟队列)
ZUNIONSTORE dest 2 s1 s2 # 并集,可带权重

ZADD 支持 NX(仅新增)、XX(仅更新)、GT/LT(仅当更大/更小时更新)、CH(返回变更数)等修饰符。

适用场景

  1. 排行榜:游戏积分榜、热搜榜。ZADD 更新分数,ZREVRANGE 0 9 取 Top10,ZREVRANK 查个人排名。
  2. 延迟队列:score 存「到期时间戳」,消费者定时 ZRANGEBYSCORE 0 now 取出到期任务并 ZREM。务必用 Lua 保证「取出+删除」原子,避免多消费者重复消费。
  3. 时间线/范围查询:以时间戳为 score,按时间窗口检索消息、日志。
  4. 带权重的并集ZUNIONSTORE 做多维度综合评分。

Stream

5.0 引入,专为消息流设计,可看作「自带持久化、消费组、ack 的日志结构」。是 List 在消息队列场景的正式替代。

Stream 是只追加的日志,每条消息有自动生成的 ID(<毫秒时间戳>-<序号>),可按 ID 或时间范围查询。支持消费组:组内消费者负载均衡,每条消息仅被组内一个消费者处理,ack 后才从待确认列表移除。

核心命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 生产
XADD stream * name tom score 90 # * 让 Redis 生成 ID
XADD stream MAXLEN ~1000 * k v # 限制流长度(~ 近似裁剪,性能好)

# 独立消费(无消费组)
XRANGE stream - + # 全部
XREAD COUNT 10 BLOCK 30000 STREAMS stream 0 # 阻塞读取

# 消费组
XGROUP CREATE stream g1 0 # 创建组,从开头开始
XREADGROUP GROUP g1 c1 COUNT 10 STREAMS stream > # > 表示未投递的新消息
XACK stream g1 <id> # 确认处理完成
XPENDING stream g1 # 查看待确认消息
XCLAIM stream g1 c2 60000 <id> # 转移超时消息给 c2(故障转移)
XTRIM stream MAXLEN 1000 # 裁剪

适用场景

  1. 可靠消息队列:事件驱动架构、领域事件广播。消费组 + ack + XCLAIM 提供了 at-least-once 语义。
  2. 事件溯源 / 审计日志:天然只追加、可回放、按时间范围查询。
  3. 指标流:采集指标后供多消费者各自消费。

Stream 持久化依赖 Redis 的 RDB/AOF,并非独立的 MQ。对消息可靠性要求极高(金融级)仍建议用 Kafka/RabbitMQ;轻量、与缓存同栈的场景 Stream 很合适。

Bitmap

并非独立类型,而是 String 上的位操作。一个 String 最多 512MB,可容纳约 42 亿个 bit。

Bitmap 在 String 字节数组上的位布局

核心命令

1
2
3
4
5
SETBIT days:2024-06 5 1                 # 第 6 天(0 起)签到
GETBIT days:2024-06 5
BITCOUNT days:2024-06 # 统计签到天数
BITOP AND dest src1 src2 # 位运算(连续签到 = 两个月按位与后 count)
BITFIELD days:u5 u4=1 # 用字段操作实现更复杂结构

适用场景

  1. 签到 / 打卡:以 uid 或「月份+uid」为 key,1 bit 表示一天,省内存。
  2. 活跃用户统计:每天一个 Bitmap,bit 位对应 uid。BITCOUNT 算日活,BITOP AND/OR 算留存、连续活跃。
  3. 布隆过滤器:用多个 hash + SETBIT 实现近似去重,用于缓存穿透防护、海量 URL 判重。

Bitmap 适合「用户 id 连续且稠密」的场景,否则空间浪费。稀疏场景可考虑 RoaringBitmap(需客户端实现)。

HyperLogLog

基数(去重后元素个数)的近似统计算法,标准误差约 0.81%,每个 key 固定占 12KB。

HyperLogLog 密集结构与 Hash 拆分

核心命令

1
2
3
PFADD uv:2024-06 user1 user2 user3      # 记录访问
PFCOUNT uv:2024-06 # 估算独立访客数
PFMERGE uv:2024 uv:2024-06 uv:2024-05 # 合并多个 HyperLogLog

适用场景

  1. UV / 独立 IP 数:千万级去重统计,相比 Set 节省几个数量级内存。
  2. 日活 / 月活合并PFMERGE 把每日 HLL 合并为月活。

误差在 1% 以内对统计类指标完全可接受。需要精确去重请用 Set 或 Bitmap;只关心「大概多少」用 HLL。

Geo

地理位置结构,基于 ZSet + GeoHash 将经纬度编码为 score,从而复用 ZSet 的范围能力。

Geo 从经纬度编码到 ZSet 的过程

核心命令

1
2
3
4
GEOADD points 116.40 39.90 "beijing" 121.47 31.23 "shanghai"
GEODIST points beijing shanghai km # 两点距离
GEOSEARCH points FROMLONLAT 116.40 39.90 BYRADIUS 1000 km ASC COUNT 10
GEOPOS points beijing

GEOSEARCH(6.2+ 取代 GEORADIUS)支持按半径/矩形搜索、按距离排序、限制数量。

适用场景

  1. 附近的人 / 附近的店:外卖、打车、社交 LBS 查询。
  2. 距离计算:物流 ETA、配送范围判定。
  3. 围栏判断:判断点是否在某区域半径内。

Geo 底层是单个 ZSet,元素不宜过多(百万级以上范围查询会变慢),且只支持球面距离近似。

Lua 脚本

Redis 从 2.6 起内置 Lua 解释器,允许将多条命令打包为一段脚本原子执行——整个脚本期间 Redis 不会切换到其他客户端,天然避免了竞态条件。这是构建复杂原子操作的首选方案。

基本用法

1
2
3
4
5
6
7
8
9
10
11
# EVAL "脚本" key个数 key1 key2 ... arg1 arg2 ...
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey myvalue

# redis.call —— 命令出错直接抛异常,脚本中止
# redis.pcall —— 命令出错返回错误对象,脚本继续
EVAL "return redis.pcall('GET', KEYS[1])" 1 mykey

# 在脚本中访问参数
# KEYS[1], KEYS[2], ... —— 键名,必须通过此数组传入(便于集群路由)
# ARGV[1], ARGV[2], ... —— 附加参数,可以是任意值
EVAL "return {KEYS[1], ARGV[1], ARGV[2]}" 1 user:1 Tom 30

重要:所有 key 必须通过 KEYS 传入,不能在脚本里硬编码或拼接。Redis Cluster 依赖此约定将脚本路由到正确的分片;单节点也建议遵守,以便未来迁移。

脚本缓存与 EVALSHA

每次 EVAL 都会发送完整脚本,网络开销大。Redis 会自动缓存脚本的 SHA1 摘要,后续可用 EVALSHA 只发摘要:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 加载脚本到缓存,返回 SHA1
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
# → "a420b3b1896e36c8dbdb1a5e98a5c19e0c07e0e1"

# 用 SHA1 调用(节省带宽)
EVALSHA a420b3b1896e36c8dbdb1a5e98a5c19e0c07e0e1 1 mykey

# 检查脚本是否在缓存中
SCRIPT EXISTS a420b3b1... e3c7d9f2...
# → 1 0 (第一个在,第二个不在)

# 清空脚本缓存
SCRIPT FLUSH [ASYNC|SYNC]

客户端库(如 Redisson、lettuce)通常封装了「先 EVALSHA,NOSCRIPT 时回退 EVAL」的重试逻辑,开发者无感知。

SCRIPT 调试

Redis 7.0+ 引入了函数机制(FUNCTION LOAD/CALL),可命名、可持久化,是脚本的进化版;但 EVAL 仍然完全可用且更简单。调试脚本可用:

1
2
3
4
5
6
7
# 交互式调试(需要 redis-cli --ldb)
redis-cli --ldb --eval myscript.lua , arg1 arg2

# 设置断点、单步、查看变量——类似 GDB
# > break 3 # 在第 3 行设断点
# > step # 单步执行
# > print KEYS # 打印变量

经典模式

安全的分布式锁释放

1
2
3
4
5
6
-- 释放锁:仅当持有人匹配时才删除
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
1
2
EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" \
1 lock:order:123 my-token-abc

滑动窗口限流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- key = 限流标识,ARGV[1] = 窗口大小(秒),ARGV[2] = 最大请求数,ARGV[3] = 当前时间戳
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local min = now - window * 1000

redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', min)
local count = redis.call('ZCARD', KEYS[1])
if count < limit then
redis.call('ZADD', KEYS[1], now, now .. '-' .. math.random(1, 1000000))
redis.call('EXPIRE', KEYS[1], window)
return 1 -- 允许
else
return 0 -- 拒绝
end

延迟队列消费

1
2
3
4
5
6
-- 原子地取出并删除到期任务
local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, 10)
if #tasks > 0 then
redis.call('ZREM', KEYS[1], unpack(tasks))
end
return tasks

注意事项

  1. 保持脚本短小:长脚本阻塞所有客户端,影响可用性。经验法则:脚本执行时间 < 1ms,绝对不超过 lua-time-limit
  2. KEYS 规范:始终通过 KEYS 传入 key 名,不要在 ARGV 或字符串拼接中构造 key——这对集群模式是硬性要求。
  3. 避免全局变量:7.0+ 默认禁止,但仍需注意不要在脚本间意外共享状态(Lua 环境是单实例复用的)。
  4. 错误处理:生产脚本建议用 redis.pcall 包裹可能失败的命令,检查返回值的 err 字段,而非让整个脚本崩溃。
  5. 替代方案:如果只需要简单原子性(如 SET NX EX),优先用原生命令而非 Lua;Lua 适合「读-判-写」这种需要中间逻辑的复合操作。

键过期(TTL)

为 key 设置过期时间,是 Redis 控制内存、回收冷数据的核心手段。

核心命令

1
2
3
4
5
6
7
8
9
10
11
12
EXPIRE key 60              # 秒
PEXPIRE key 60000 # 毫秒
EXPIREAT key 1700000000 # Unix 时间戳(秒)
PEXPIREAT key 1700000000000
TTL key # 剩余秒数(-1 无过期,-2 不存在)
PTTL key # 剩余毫秒
PERSIST key # 移除过期,转为永久 key
# 7.0+ 条件过期
EXPIRE key 60 GT # 仅当现有 TTL > 60 才设为 60
EXPIRE key 60 LT # 仅当现有 TTL < 60 或无过期才设为 60
# SET 时直接带过期,避免「先写后过期」的时间窗
SET key value EX 60

重新 SET 同名 key 会清除其过期时间,需再次指定;EXPIRE 只改到期时间,不触碰 value。

注意事项

  1. 避免大批量同时过期:同一刻大量 key 失效会引发「缓存雪崩」,请求穿透到 DB。可对 TTL 加随机抖动(base + random(0, 300))打散。
  2. 从库过期:删除由主库主导,完成后传播 DEL 给从库;3.2+ 从库读取过期 key 时也会返回不存在(惰性),但实际删除仍以主库为准,避免主从不一致。

键遍历(SCAN)

KEYS pattern 一次性遍历整个 keyspace 并阻塞主线程,百万级 key 可致服务级联超时,生产环境应视为禁用。SCAN 系列以游标为基础分批增量遍历,是生产遍历 key 的唯一推荐方式。

核心命令

1
2
3
4
SCAN cursor [MATCH pattern] [COUNT n] [TYPE type]
HSCAN key cursor ... # 遍历 Hash 字段
SSCAN key cursor ... # 遍历 Set 成员
ZSCAN key cursor ... # 遍历 ZSet 成员(含 score)

SCAN 返回 [next_cursor, elements],当 next_cursor0 时遍历完成。

与 KEYS 对比

维度KEYSSCAN
阻塞全程阻塞主线程单次短阻塞
复杂度O(N) 一次性O(N) 分摊多次
一致性弱一致同样弱一致
生产可用禁用推荐

注意事项

  1. 生产禁用 KEYS:用 SCAN/HSCAN 等替代,后台清理任务尤其要注意。
  2. MATCH 为服务端过滤:在 COUNT 抽样之后再筛,因此 SCAN 0 MATCH user:* COUNT 100 可能返回 0 个但 cursor 非零,必须继续。
  3. TYPE 过滤(6.0+):SCAN ... TYPE string 只返回指定类型,省去客户端二次判断。
  4. 集群模式:需对每个节点分别 SCAN,或用集群客户端的统一遍历接口。
  5. 大 key 探测:遍历中可结合 MEMORY USAGE 定位大 key,但该命令本身有开销,避免高频调用。

集群(Cluster)

Redis Cluster 通过数据分片将 key 分散到多个主节点,提供横向扩容与自动故障转移。本节仅介绍使用侧命令与约束,架构与故障转移原理见姊妹篇《Redis 底层原理与架构》。

核心命令

1
2
3
4
5
6
7
8
CLUSTER INFO                      # 集群状态、槽分配概况
CLUSTER NODES # 节点列表、角色、槽范围、主从关系
CLUSTER SLOTS # 槽到节点的映射(含主从)
CLUSTER COUNTKEYSINSLOT slot # 指定槽的 key 数
CLUSTER KEYSLOT key # 计算 key 所属槽
CLUSTER GETKEYSINSLOT slot count # 取指定槽中的若干 key(迁移用)
CLUSTER FAILOVER # 手动故障转移(在从节点执行)
CLUSTER RESET HARD # 重置节点配置(清空集群信息,慎用)

Hash Tag

slot 默认对 key 整体做 CRC16,因此任意两个不同 key 都可能落在不同槽。但有些操作要求多 key 必须在同一节点:MGETSUNIONMULTI 事务、Lua 脚本等。为此 Redis 支持 Hash Tag——若 key 中包含 {...},则仅花括号内的子串参与哈希:

1
2
3
user:{1000}:profile   →  CRC16("1000") mod 16384
user:{1000}:cart → CRC16("1000") mod 16384
user:{1000}:sessions → CRC16("1000") mod 16384

三个 key 槽位相同,必落在同一节点,于是可安全执行 MGET、事务或 Lua 复合操作。原则:把逻辑上需要原子/聚合访问的一组 key 绑定同一 tag,无需关心前缀后缀差异。

注意避免数据倾斜:若 tag 取值集中(如都用 {shard0}),相关 key 会全压在一个节点,失去分片意义。tag 应选择高基数、访问均匀的业务维度(如用户 ID、订单 ID),让数据天然散开。

注意事项

  1. 容量规划:单节点内存上限即整体的单分片上限;某分片数据过多会触发淘汰或 OOM,需通过 Hash Tag 设计与 reshard 预平衡。
  2. 重定向成本:缓存槽映射后稳定期几乎零重定向,但大规模 reshard 期间 MOVED/ASK 会上升,应避开业务高峰。
  3. 从节点读:默认从节点读会返回 MOVED 指回主节点;执行 READONLY 后从节点可处理读请求,分散读压力。
  4. 何时上 Cluster:数据量未达单机瓶颈、高可用可用 Sentinel 解决时,单机 + 主从 + Sentinel 更简单;真正需要水平扩展写与存储时才上 Cluster。

通用机制与选型建议

事务与原子性

  • MULTI/EXEC:命令打包顺序执行,不支持回滚(某条出错其余仍执行)。
  • WATCH:乐观锁,监视 key 在 EXEC 前若被修改则整个事务失败。
  • Lua 脚本:原子执行多条命令,是构建复杂原子操作(如安全分布式锁、延迟队列消费)的首选。详见上方 Lua 脚本 章节。

选型决策

  • 需要单值缓存/计数/锁 → String
  • 需要有序、两端操作、队列 → List
  • 需要字段级读写对象 → Hash
  • 需要去重/集合运算 → Set
  • 需要按分数排序/排行榜/范围 → ZSet
  • 需要可靠消费组队列 → Stream
  • 需要海量布尔位/布隆 → Bitmap
  • 需要海量基数近似统计 → HyperLogLog
  • 需要地理位置 → Geo
  • 需要原子复合操作(读-判-写) → Lua 脚本

一个常见反模式

用 String 存对象 JSON 并整体读写,在「字段多、只改其中一两个、写频繁」时,会浪费带宽并放大并发覆盖问题——此时应改用 Hash,按字段独立更新。反之,对象小且总是一起读写时,String + JSON 反而更简单高效。结构与访问模式匹配,才是 Redis 用的好的核心。