Redis 是基于内存的键值存储,常被当作缓存、消息队列、分布式锁甚至轻量数据库使用。它之所以通用,关键在于其丰富的数据结构——选对结构,命令和场景往往自然成立。本文系统梳理 Redis 各数据结构的底层实现、核心命令与适用场景,不涉及安装部署。

全局视角

Redis 的 key 是二进制安全的字符串,value 则可以是以下几种类型之一。每种类型对应一组命令,并拥有各自的底层编码(Redis 会在元素数量、体积变化时自动切换编码以平衡内存与性能)。

类型典型用途底层编码(7.x)
String缓存、计数器、分布式锁int / embstr / raw
List消息队列、最新列表listpack / quicklist
Hash对象存储listpack / hashtable
Set去重、标签、抽奖intset / hashtable
ZSet排行榜、延迟队列listpack / skiplist + hashtable
Stream消息流、事件日志radix tree + listpack
Bitmap签到、活跃统计String 上的位操作
HyperLogLog基数统计String 上的稀疏/密集结构
Geo附近的人/店ZSet + GeoHash 编码
Lua 脚本原子复合操作、分布式锁释放内置 Lua 解释器 + 脚本缓存

编码细节会随版本演进,但「小数据用紧凑结构省内存、大数据用高效结构保性能」的思路始终一致。可用 OBJECT ENCODING key 查看实际编码。

String

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

底层编码

  • int:值为整数且能用 long 表示时,直接存整数,不分配 SDS。
  • embstr:短字符串(≤ 44 字节),redisObject 与 SDS 内存连续分配,一次 malloc,缓存友好。
  • raw:长字符串,redisObject 与 SDS 分开分配。

embstr 是只读的——任何修改都会使其转为 raw

核心命令

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。

底层编码

  • listpack:元素少且小时使用,紧凑连续内存。
  • quicklist:以 listpack 为节点的双向链表,兼顾内存紧凑与随机访问,兼顾两者优点。每个 ziplist/listpack 节点大小可配置。

核心命令

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 就像一个对象。

底层编码

  • listpack:字段少且短时使用。
  • hashtable:字段多时转为真正的哈希表,O(1) 读写但内存开销大。

核心命令

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

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

底层编码

  • intset:全为整数且数量少时使用,紧凑有序数组。
  • hashtable:含字符串或元素多时使用。

核心命令

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 中表达力最强的结构。

底层编码

  • listpack:元素少且短时使用。
  • skiplist + hashtable:元素多时使用。跳表提供按 score 的有序遍历与范围查询(O(logN)),hashtable 提供 O(1) 的元素→score 反查。两者共用元素引用,不重复存储字符串。

核心命令

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 做多维度综合评分。

ZSet 的 score 是 double(64 位浮点),存大时间戳(毫秒)足够精确,但极端大整数会有精度损失。

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。

核心命令

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。基于 String 存储稀疏/密集两种表示。

核心命令

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 的范围能力。

核心命令

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

底层实现

Lua 环境初始化

Redis 启动时创建一个 Lua 环境(luaState),并做以下定制:

  1. 替换随机数生成器:用确定性的伪随机算法替换 math.random,保证主从复制结果一致。
  2. 禁用危险函数dofileloadfileioos.execute 等被删除或替换为空操作,防止脚本访问文件系统或执行系统命令。
  3. 注入 redis 全局表:提供 redis.call()redis.pcall()redis.status_reply()redis.error_reply()redis.log()redis.sha1hex() 等 API。
  4. 设置全局保护:7.0 之前脚本可以创建全局变量(易引发难以排查的 bug);7.0+ 默认禁止在脚本中创建全局变量(lua-env-flag 机制),试图赋值全局会报错。

执行模型

1
2
3
4
5
6
7
8
9
10
客户端 → EVAL 脚本 → Redis 主线程

├─ 1. 计算脚本 SHA1,检查缓存(已编译则跳过编译)
├─ 2. 若未缓存,luaL_loadbuffer 编译为 Lua 字节码并存入 script cache
├─ 3. 设置钩子(每执行一定指令数检查超时)
├─ 4. 绑定 KEYS/ARGV 到 Lua 全局表
├─ 5. lua_pcall 执行字节码
│ └─ redis.call() → 把命令打包为 Redis 调用 → 在同一主线程同步执行 → 返回结果
├─ 6. 将 Lua 返回值转为 Redis 回复,发送给客户端
└─ 7. 清理环境(重置全局表、移除钩子)

关键特性

  • 原子性:脚本在 Redis 主线程中执行,期间不处理其他客户端命令。因此脚本必须快——长时间脚本会阻塞整个实例。
  • 超时与 killlua-time-limit(默认 5 秒)超时后,Redis 并不会主动终止脚本(因为 Lua 没有安全的中断机制),而是进入「可被 kill」状态,客户端可执行 SCRIPT KILL(若脚本没写过数据)或 SHUTDOWN NOSAVE(强制停机回滚)。
  • 纯函数约束:为保障主从一致性,脚本中禁止调用 TIMESRANDMEMBERRANDOMKEYSCAN 等非确定性命令。7.0 前 redis.replicate_commands() 可显式声明副作用复制模式绕过此限制;7.0+ 默认采用效果复制(effect replication),不再需要显式声明。

脚本缓存

  • 编译后的字节码以 SHA1 为 key 存在 server 级别的 script_cache 字典中。
  • 缓存不会随 RDB/AOF 持久化——重启后缓存为空,客户端需重新 EVALSCRIPT LOAD
  • SCRIPT FLUSH 清空所有缓存,FUNCTION FLUSH 清空函数缓存,两者互不干扰。

复制模式演进

版本默认复制方式说明
< 3.2整脚本复制主节点把完整脚本发到从节点重放,保证确定性
3.2–6.x可选效果复制redis.replicate_commands() 开启后,只复制写命令本身
7.0+效果复制默认只复制写命令的效果,脚本不再需要是纯函数

效果复制意味着:即使脚本里调用了 TIME 等命令,从节点也不会因结果不一致而出问题——因为只有写操作的结果被传播,而非整个脚本。

注意事项

  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 适合「读-判-写」这种需要中间逻辑的复合操作。

通用机制与选型建议

  • TTLEXPIRE/PEXPIRE 设过期,TTL/PTTL 查剩余。7.0+ 支持 EXPIRE ... GT/LT 条件过期。过期采用「惰性删除 + 定期抽样删除」。
  • 内存淘汰策略noeviction(默认,写报错)、allkeys-lruvolatile-lruallkeys-lfuvolatile-lfuallkeys-randomvolatile-randomvolatile-ttl。缓存场景常用 allkeys-lru/lfu

事务与原子性

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

选型决策

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

一个常见反模式

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