Redis 数据结构全解
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 | |
SET 的 NX 选项是构建分布式锁的基础。
适用场景
- 缓存对象:序列化后的 JSON、Protobuf。建议加 TTL 防止冷数据堆积。
- 计数器:阅读量、点赞数。
INCR原子且高效,单实例可达十万级 QPS。 - 分布式锁:
SET lock:order:123 token NX EX 30,释放时用 Lua 校验 token 再DEL,避免误删他人锁。 - 限流器:固定窗口用
INCR+EXPIRE;滑动窗口可结合 ZSet。
分布式锁建议优先用 Redlock 或 Redisson 封装,单节点
SET NX在主从故障切换时存在锁丢失风险。
List
双向链表语义的有序字符串集合,支持两端高效 push/pop。
底层编码
- listpack:元素少且小时使用,紧凑连续内存。
- quicklist:以 listpack 为节点的双向链表,兼顾内存紧凑与随机访问,兼顾两者优点。每个 ziplist/listpack 节点大小可配置。
核心命令
1 | |
适用场景
- 消息队列:
LPUSH+BRPOP实现「生产者-消费者」。BRPOP阻塞避免空轮询。 - 最新 N 条列表:朋友圈最新动态、最新登录记录,配合
LPUSH+LTRIM固定长度。 - 栈/队列:同端操作即栈(
LPUSH+LPOP),异端操作即队列。 - 安全队列:
RPOPLPUSH(7.0 后LMOVE)把处理中的消息搬到备份 list,处理失败可回滚。
List 做消息队列缺乏 ack 机制与消费组,复杂场景请用 Stream。
Hash
字段-值映射,值本身也是字符串。一个 Hash 就像一个对象。
底层编码
- listpack:字段少且短时使用。
- hashtable:字段多时转为真正的哈希表,O(1) 读写但内存开销大。
核心命令
1 | |
适用场景
- 对象存储:用户、商品属性。相比把整个 JSON 存 String,Hash 可以局部更新而不必读写整体,省带宽。
- 计数器集合:一篇文章的点赞、转发、评论各占一个字段,
HINCRBY article:1 likes 1。 - 购物车:
cart:uid中 field=商品ID,value=数量。
当字段较少且整体读取频繁时,String + JSON 也合理;当需要字段级独立读写时,Hash 更优。
Set
无序、唯一字符串集合,支持丰富的集合运算。
底层编码
- intset:全为整数且数量少时使用,紧凑有序数组。
- hashtable:含字符串或元素多时使用。
核心命令
1 | |
适用场景
- 去重:UV 中转存储、敏感词集合。
SISMEMBERO(1) 判断。 - 标签/共同关注:用户标签用 Set,
SINTER求共同好友、共同标签。 - 抽奖:
SRANDMEMBER抽取(可重复)或SPOP抽取(不可重复)。 - 黑白名单:IP 黑名单、令牌白名单。
大集合做
SINTER较重,可让小集合在前,或用SINTERSTORE预计算。
ZSet(Sorted Set)
有序集合,每个元素关联一个 score,按 score 排序且元素唯一。Redis 中表达力最强的结构。
底层编码
- listpack:元素少且短时使用。
- skiplist + hashtable:元素多时使用。跳表提供按 score 的有序遍历与范围查询(O(logN)),hashtable 提供 O(1) 的元素→score 反查。两者共用元素引用,不重复存储字符串。
核心命令
1 | |
ZADD 支持 NX(仅新增)、XX(仅更新)、GT/LT(仅当更大/更小时更新)、CH(返回变更数)等修饰符。
适用场景
- 排行榜:游戏积分榜、热搜榜。
ZADD更新分数,ZREVRANGE 0 9取 Top10,ZREVRANK查个人排名。 - 延迟队列:score 存「到期时间戳」,消费者定时
ZRANGEBYSCORE 0 now取出到期任务并ZREM。务必用 Lua 保证「取出+删除」原子,避免多消费者重复消费。 - 时间线/范围查询:以时间戳为 score,按时间窗口检索消息、日志。
- 带权重的并集:
ZUNIONSTORE做多维度综合评分。
ZSet 的 score 是 double(64 位浮点),存大时间戳(毫秒)足够精确,但极端大整数会有精度损失。
Stream
5.0 引入,专为消息流设计,可看作「自带持久化、消费组、ack 的日志结构」。是 List 在消息队列场景的正式替代。
结构
Stream 是只追加的日志,每条消息有自动生成的 ID(<毫秒时间戳>-<序号>),可按 ID 或时间范围查询。支持消费组:组内消费者负载均衡,每条消息仅被组内一个消费者处理,ack 后才从待确认列表移除。
核心命令
1 | |
适用场景
- 可靠消息队列:事件驱动架构、领域事件广播。消费组 + ack + XCLAIM 提供了 at-least-once 语义。
- 事件溯源 / 审计日志:天然只追加、可回放、按时间范围查询。
- 指标流:采集指标后供多消费者各自消费。
Stream 持久化依赖 Redis 的 RDB/AOF,并非独立的 MQ。对消息可靠性要求极高(金融级)仍建议用 Kafka/RabbitMQ;轻量、与缓存同栈的场景 Stream 很合适。
Bitmap
并非独立类型,而是 String 上的位操作。一个 String 最多 512MB,可容纳约 42 亿个 bit。
核心命令
1 | |
适用场景
- 签到 / 打卡:以
uid或「月份+uid」为 key,1 bit 表示一天,省内存。 - 活跃用户统计:每天一个 Bitmap,bit 位对应 uid。
BITCOUNT算日活,BITOP AND/OR算留存、连续活跃。 - 布隆过滤器:用多个 hash +
SETBIT实现近似去重,用于缓存穿透防护、海量 URL 判重。
Bitmap 适合「用户 id 连续且稠密」的场景,否则空间浪费。稀疏场景可考虑 RoaringBitmap(需客户端实现)。
HyperLogLog
基数(去重后元素个数)的近似统计算法,标准误差约 0.81%,每个 key 固定占 12KB。基于 String 存储稀疏/密集两种表示。
核心命令
1 | |
适用场景
- UV / 独立 IP 数:千万级去重统计,相比 Set 节省几个数量级内存。
- 日活 / 月活合并:
PFMERGE把每日 HLL 合并为月活。
误差在 1% 以内对统计类指标完全可接受。需要精确去重请用 Set 或 Bitmap;只关心「大概多少」用 HLL。
Geo
地理位置结构,基于 ZSet + GeoHash 将经纬度编码为 score,从而复用 ZSet 的范围能力。
核心命令
1 | |
GEOSEARCH(6.2+ 取代 GEORADIUS)支持按半径/矩形搜索、按距离排序、限制数量。
适用场景
- 附近的人 / 附近的店:外卖、打车、社交 LBS 查询。
- 距离计算:物流 ETA、配送范围判定。
- 围栏判断:判断点是否在某区域半径内。
Geo 底层是单个 ZSet,元素不宜过多(百万级以上范围查询会变慢),且只支持球面距离近似。
Lua 脚本
Redis 从 2.6 起内置 Lua 解释器,允许将多条命令打包为一段脚本原子执行——整个脚本期间 Redis 不会切换到其他客户端,天然避免了竞态条件。这是构建复杂原子操作的首选方案。
基本用法
1 | |
重要:所有 key 必须通过
KEYS传入,不能在脚本里硬编码或拼接。Redis Cluster 依赖此约定将脚本路由到正确的分片;单节点也建议遵守,以便未来迁移。
脚本缓存与 EVALSHA
每次 EVAL 都会发送完整脚本,网络开销大。Redis 会自动缓存脚本的 SHA1 摘要,后续可用 EVALSHA 只发摘要:
1 | |
客户端库(如 Redisson、lettuce)通常封装了「先 EVALSHA,NOSCRIPT 时回退 EVAL」的重试逻辑,开发者无感知。
SCRIPT 调试
Redis 7.0+ 引入了函数机制(FUNCTION LOAD/CALL),可命名、可持久化,是脚本的进化版;但 EVAL 仍然完全可用且更简单。调试脚本可用:
1 | |
经典模式
安全的分布式锁释放
1 | |
1 | |
滑动窗口限流
1 | |
延迟队列消费
1 | |
底层实现
Lua 环境初始化
Redis 启动时创建一个 Lua 环境(luaState),并做以下定制:
- 替换随机数生成器:用确定性的伪随机算法替换
math.random,保证主从复制结果一致。 - 禁用危险函数:
dofile、loadfile、io、os.execute等被删除或替换为空操作,防止脚本访问文件系统或执行系统命令。 - 注入
redis全局表:提供redis.call()、redis.pcall()、redis.status_reply()、redis.error_reply()、redis.log()、redis.sha1hex()等 API。 - 设置全局保护:7.0 之前脚本可以创建全局变量(易引发难以排查的 bug);7.0+ 默认禁止在脚本中创建全局变量(
lua-env-flag机制),试图赋值全局会报错。
执行模型
1 | |
关键特性:
- 原子性:脚本在 Redis 主线程中执行,期间不处理其他客户端命令。因此脚本必须快——长时间脚本会阻塞整个实例。
- 超时与 kill:
lua-time-limit(默认 5 秒)超时后,Redis 并不会主动终止脚本(因为 Lua 没有安全的中断机制),而是进入「可被 kill」状态,客户端可执行SCRIPT KILL(若脚本没写过数据)或SHUTDOWN NOSAVE(强制停机回滚)。 - 纯函数约束:为保障主从一致性,脚本中禁止调用
TIME、SRANDMEMBER、RANDOMKEY、SCAN等非确定性命令。7.0 前redis.replicate_commands()可显式声明副作用复制模式绕过此限制;7.0+ 默认采用效果复制(effect replication),不再需要显式声明。
脚本缓存
- 编译后的字节码以 SHA1 为 key 存在 server 级别的
script_cache字典中。 - 缓存不会随 RDB/AOF 持久化——重启后缓存为空,客户端需重新
EVAL或SCRIPT LOAD。 SCRIPT FLUSH清空所有缓存,FUNCTION FLUSH清空函数缓存,两者互不干扰。
复制模式演进
| 版本 | 默认复制方式 | 说明 |
|---|---|---|
| < 3.2 | 整脚本复制 | 主节点把完整脚本发到从节点重放,保证确定性 |
| 3.2–6.x | 可选效果复制 | redis.replicate_commands() 开启后,只复制写命令本身 |
| 7.0+ | 效果复制 | 默认只复制写命令的效果,脚本不再需要是纯函数 |
效果复制意味着:即使脚本里调用了 TIME 等命令,从节点也不会因结果不一致而出问题——因为只有写操作的结果被传播,而非整个脚本。
注意事项
- 保持脚本短小:长脚本阻塞所有客户端,影响可用性。经验法则:脚本执行时间 < 1ms,绝对不超过
lua-time-limit。 - KEYS 规范:始终通过
KEYS传入 key 名,不要在 ARGV 或字符串拼接中构造 key——这对集群模式是硬性要求。 - 避免全局变量:7.0+ 默认禁止,但仍需注意不要在脚本间意外共享状态(Lua 环境是单实例复用的)。
- 错误处理:生产脚本建议用
redis.pcall包裹可能失败的命令,检查返回值的err字段,而非让整个脚本崩溃。 - 替代方案:如果只需要简单原子性(如
SET NX EX),优先用原生命令而非 Lua;Lua 适合「读-判-写」这种需要中间逻辑的复合操作。
通用机制与选型建议
- TTL:
EXPIRE/PEXPIRE设过期,TTL/PTTL查剩余。7.0+ 支持EXPIRE ... GT/LT条件过期。过期采用「惰性删除 + 定期抽样删除」。 - 内存淘汰策略:
noeviction(默认,写报错)、allkeys-lru、volatile-lru、allkeys-lfu、volatile-lfu、allkeys-random、volatile-random、volatile-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 用的好的核心。


