Redis 数据结构
Redis 数据结构
简介
Redis 提供了丰富的数据结构(String、Hash、List、Set、Sorted Set、Stream、Bitmap、HyperLogLog、Geo 等),每种结构都有专门的底层编码实现和适用场景。选择正确的数据结构不仅影响内存占用,更决定了操作的复杂度和性能上限。
Redis 的数据结构设计哲学是"面向场景":不是提供通用的数据容器,而是针对特定使用模式进行深度优化。例如,Sorted Set 同时支持按分值排序和按成员查询,底层用跳表(Skip List)实现,使得插入、删除、查找都能在 O(log N) 时间内完成。
Redis 数据结构全景
Redis 数据类型与底层编码映射:
String → SDS(Simple Dynamic String)或 int(纯数字时)
→ 编码:int / embstr / raw
Hash → 压缩列表或哈希表
→ 编码:listpack / hashtable
List → 快速列表(双向链表 + 压缩列表节点)
→ 编码:quicklist
Set → 整数集合或哈希表
→ 编码:intset / hashtable
Sorted Set → 压缩列表或跳表 + 哈希表
→ 编码:listpack / skiplist
Stream → 基数树(Radix Tree)+ 列表包
→ 编码:listpacks / list3
Bitmap → 基于 String 的位操作
HyperLogLog → 基于 String 的概率计数
Geo → 基于 Sorted Set 的地理编码核心数据结构详解
String(字符串)
String 是 Redis 最基础的数据类型,最大支持 512MB。它不仅可以存储文本,还可以存储序列化的 JSON、二进制数据(图片、视频片段)和纯数字。
# String:缓存用户信息(JSON 序列化)
SET user:1001 '{"name":"张三","age":28,"role":"admin"}' EX 3600
GET user:1001
# String:计数器(文章阅读数、API 限流)
INCR article:2001:views # +1
INCRBY article:2001:views 10 # +10
DECR article:2001:stock # -1
GET article:2001:views # 获取当前值
# String:分布式锁(带过期时间的 SET NX)
SET lock:order:1001 "unique-token-abc" NX EX 30
# NX: 只有 key 不存在时才设置(互斥)
# EX: 设置过期时间 30 秒(防止死锁)
# 返回 OK 表示获取成功,返回 nil 表示锁已被占用
# String:批量设置与获取(减少网络往返)
MSET user:1001:name "张三" user:1001:age "28" user:1001:role "admin"
MGET user:1001:name user:1001:age user:1001:role
# String 底层编码查看
OBJECT ENCODING user:1001:name
# 可能返回 "embstr"(短字符串,< 44 字节)或 "raw"(长字符串)String 底层编码策略:
| 条件 | 编码方式 | 说明 |
|---|---|---|
值为整数且范围在 long 内 | int | 直接存储整数,INCR/DECR 操作 O(1) |
| 字符串长度 <= 44 字节 | embstr | RedisObject 和 SDS 一起分配,一次内存分配 |
| 字符串长度 > 44 字节 | raw | RedisObject 和 SDS 分开分配,两次内存分配 |
Hash(哈希)
Hash 是键值对集合,适合存储对象的多个属性。相比将对象序列化为 JSON 存入 String,Hash 的优势在于可以单独读写某个字段,无需序列化/反序列化。
# Hash:存储对象属性
HSET user:1001 name "张三" age 28 role "admin" city "北京"
HGET user:1001 name # 获取单个字段
HMGET user:1001 name age role # 获取多个字段
HGETALL user:1001 # 获取所有字段(慎用,大 Hash 会阻塞)
HINCRBY user:1001 age 1 # 数值字段自增
HDEL user:1001 city # 删除字段
HLEN user:1001 # 字段数量
# Hash vs String JSON 的选择
# Hash 优势:可以单独读写字段,不需要全量序列化/反序列化
# String JSON 优势:可以存储嵌套结构,适合一次性读写
# Hash 适用场景对比
# 场景 1:用户信息,字段固定且需要单独修改 → Hash
# 场景 2:商品详情,结构复杂有嵌套 → String JSON
# 场景 3:计数器集合(阅读数、点赞数、评论数)→ Hash
# Hash 字段数量限制与编码切换
# field 数 < hash-max-listpack-entries(默认 512)
# 且所有 field 和 value 的字节数 < hash-max-listpack-value(默认 64)
# → 使用 listpack 编码(内存紧凑)
# 否则 → 使用 hashtable 编码(O(1) 操作)
OBJECT ENCODING user:1001List(列表)
List 是有序的字符串列表,支持从两端推入和弹出。Redis 的 List 底层使用 quicklist(快速列表),是一个双向链表,每个节点是一个 listpack,平衡了内存效率和操作性能。
# 最新文章列表(LPUSH + LTRIM 保持固定长度)
LPUSH latest_articles "article:3001"
LPUSH latest_articles "article:3002"
LPUSH latest_articles "article:3003"
LTRIM latest_articles 0 99 # 只保留最新 100 篇
LRANGE latest_articles 0 9 # 获取最新 10 篇
LLEN latest_articles # 列表长度
# 简单消息队列(BRPOP 阻塞消费)
LPUSH queue:email "send_email_task_001"
LPUSH queue:email "send_email_task_002"
BRPOP queue:email 30 # 阻塞等待最多 30 秒,返回第一个元素
# 生产者-消费者模式
RPUSH queue:task '{"type":"export","file":"report.xlsx"}'
LPOP queue:task # 非阻塞消费
RPOPLPUSH queue:task queue:processing # 原子操作:从源队列弹出并推入目标队列
# List 的局限性
# 1. 不支持按索引高效查找:LINDEX O(N)
# 2. 不支持消费组确认:消息可能丢失
# 3. 不支持持久化消费位点
# → 对于可靠消息队列场景,应使用 Stream
# List 性能特点
# LPUSH / RPUSH / LPOP / RPOP:O(1)
# LINDEX(按索引查找):O(N)
# LINSERT(在指定位置插入):O(N)
# LRANGE(范围查找):O(S+N),S 为偏移量Sorted Set(有序集合)
Sorted Set 是 Redis 中最强大的数据结构之一。每个元素关联一个 score(分值),自动按 score 排序。它同时支持:
- 按分值范围查询(ZRANGEBYSCORE)
- 按排名查询(ZRANGE/ZREVRANGE)
- 按成员查询分值(ZSCORE)
- 按成员判断是否存在(ZSCORE 返回 nil 表示不存在)
# 排行榜:实时积分排名
ZADD leaderboard 1500 "player:A" 2300 "player:B" 1800 "player:C"
ZINCRBY leaderboard 100 "player:A" # A 加 100 分
ZREVRANGE leaderboard 0 9 WITHSCORES # TOP 10(从高到低)
ZREVRANK leaderboard "player:A" # A 的排名(从 0 开始)
ZSCORE leaderboard "player:A" # A 的分数
ZCOUNT leaderboard 1000 2000 # 分数在 1000-2000 之间的玩家数
ZREM leaderboard "player:D" # 移除玩家
# 延迟任务队列:score 存储执行时间戳(毫秒)
ZADD delay_queue 1710000000 "task:001" 1710003600 "task:002"
# 取出当前时间之前的任务
ZRANGEBYSCORE delay_queue 0 1710000000 LIMIT 0 10
# 注意:取和删不是原子操作,需要 Lua 脚本保证
# 正确做法用 Lua:
# local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, 10)
# if #tasks > 0 then redis.call('ZREM', KEYS[1], unpack(tasks)) end
# return tasks
# 带权重的热门内容排序
# score = timestamp + weight(如 timestamp * 10000 + likes)
# 这样既能按时间排序,又能让热门内容排前面
# Sorted Set 底层编码
# 元素数 < zset-max-listpack-entries(默认 128)
# 且所有元素大小 < zset-max-listpack-value(默认 64 字节)
# → 使用 listpack 编码(内存紧凑)
# 否则 → 使用 skiplist + hashtable 编码
# skiplist 提供按 score 排序的 O(log N) 操作
# hashtable 提供按 member 查找的 O(1) 操作
OBJECT ENCODING leaderboardStream(流)
Stream 是 Redis 5.0 引入的数据类型,专为消息队列场景设计。它支持持久化、消费者组、消息确认(ACK)和消息回溯,是 List 的全面升级。
# 创建 Stream 并添加消息
XADD orders_stream * user_id 1001 product_id 5001 amount 299.00
# * 表示自动生成 ID(格式:时间戳-序号)
# 也可以手动指定 ID:XADD orders_stream 1710000000000-0 user_id 1001
# 消费消息(独立消费者)
XREAD COUNT 1 BLOCK 5000 STREAMS orders_stream $
# $ 表示只接收新消息
# 创建消费者组(从最早的消息开始消费)
XGROUP CREATE orders_stream group_order_processing 0 MKSTREAM
# 0 表示从最早的消息开始
# $ 表示只消费新消息
# 消费者组消费消息
XREADGROUP GROUP group_order_processing consumer_1 COUNT 1 BLOCK 5000 STREAMS orders_stream >
# > 表示只接收未消费的新消息
# 确认消息已处理
XACK orders_stream group_order_processing 1710000000000-0
# 查看待处理消息(Pending 列表)
XPENDING orders_stream group_order_processing
# 输出:消息ID、消费者、空闲时间、投递次数
# 查看消费者组信息
XINFO GROUPS orders_stream
XINFO CONSUMERS orders_stream group_order_processing
# 读取 Stream 中的历史消息(回溯)
XRANGE orders_stream - + COUNT 10
# - 表示最小 ID,+ 表示最大 ID
# Stream 与 List 的对比
# 特性 List Stream
# 消费组 不支持 支持
# 消息确认 不支持 支持(XACK)
# 消息回溯 不支持 支持(XRANGE)
# 消息 ID 无 自动生成(时间戳+序号)
# 阻塞消费 BRPOP XREADGROUP BLOCK
# 消息堆积 可能丢失 持久化到 AOF/RDBSet(集合)
Set 是无序的、不重复的字符串集合。它支持集合间的交、并、差运算,常用于标签系统、共同好友、去重等场景。
# Set:标签系统(用户打标签)
SADD user:1001:tags "java" "redis" "mysql"
SADD user:1002:tags "python" "redis" "mongodb"
SMEMBERS user:1001:tags # 获取所有标签
SISMEMBER user:1001:tags "redis" # 判断是否拥有某标签
SCARD user:1001:tags # 标签数量
SREM user:1001:tags "mysql" # 移除标签
# Set:共同好友 / 共同关注
SADD user:1001:following "user:2001" "user:2002" "user:2003"
SADD user:1002:following "user:2002" "user:2003" "user:2004"
SINTER user:1001:following user:1002:following # 共同关注
SUNION user:1001:following user:1002:following # 全部关注(去重)
SDIFF user:1001:following user:1002:following # A 关注了但 B 没关注的
# Set:随机操作(抽奖系统)
SADD lottery:2024 "user:1001" "user:1002" "user:1003" "user:1004"
SRANDMEMBER lottery:2024 2 # 随机取 2 个(不移除,可重复抽奖预览)
SPOP lottery:2024 1 # 随机取 1 个(移除,真正的抽奖)
SCARD lottery:2024 # 剩余参与人数
# Set 底层编码
# 元素全为整数且数量 <= set-max-intset-entries(默认 512)
# → 使用 intset 编码(内存紧凑,约每个元素 4 字节)
# 否则 → 使用 hashtable 编码
OBJECT ENCODING user:1001:tags
# Set 性能注意事项
# SADD / SREM / SISMEMBER:O(1)
# SMEMBERS:O(N),大 Set 慎用,用 SSCAN 替代
# SINTER / SUNION / SDIFF:O(N),N 是最小集合的元素数,大集合交集注意性能
# SRANDMEMBER / SPOP:O(1)Geo(地理位置)
Geo 基于 Sorted Set 实现,提供地理空间数据的存储和查询能力。底层将经纬度编码为 GeoHash 值作为 Sorted Set 的 score。
# Geo:添加地理位置(经度、纬度、名称)
GEOADD stores:beijing 116.397128 39.916527 "store:001"
GEOADD stores:beijing 116.407526 39.904030 "store:002"
GEOADD stores:beijing 116.448732 39.927810 "store:003"
# 查询附近门店(核心功能)
GEORADIUS stores:beijing 116.397128 39.916527 5 km COUNT 10 WITHDIST WITHCOORD ASC
# 中心点经纬度,半径 5km,返回 10 个,附带距离和坐标,按距离升序
# GEORADIUSBYMEMBER:以某个门店为中心查询
GEORADIUSBYMEMBER stores:beijing "store:001" 3 km COUNT 5
# 查询两个位置之间的距离
GEODIST stores:beijing "store:001" "store:002" km
# 获取位置的 GeoHash 值
GEOHASH stores:beijing "store:001"
# 获取位置的经纬度
GEOPOS stores:beijing "store:001" "store:002"
# 删除位置(本质是 ZREM)
ZREM stores:beijing "store:001"
# Geo 底层原理
# 1. 将经纬度通过 GeoHash 算法编码为一维字符串
# 2. 将 GeoHash 字符串转为 52 位双精度浮点数
# 3. 以此浮点数作为 score 存入 Sorted Set
# 4. 查询时通过 ZRANGEBYSCORE 找出范围内的元素
# GeoHash 的精度问题:边界附近可能漏查或误查,实际业务中需要二次校验Bitmap(位图)
Bitmap 基于 String 实现,可以对每个 bit 位进行操作。适合用于签到、在线状态、特征标记等场景。
# 用户签到系统(按天签到)
SETBIT sign:2024:01:user1001 0 1 # 第 1 天签到
SETBIT sign:2024:01:user1001 14 1 # 第 15 天签到
# 统计本月签到天数
BITCOUNT sign:2024:01:user1001
# 查询某天是否签到
GETBIT sign:2024:01:user1001 0 # 返回 1 或 0
# 统计多个用户签到的交集/并集
BITOP AND sign:2024:01:all sign:2024:01:user1001 sign:2024:01:user1002
BITOP OR sign:2024:01:any sign:2024:01:user1001 sign:2024:01:user1002
# Bitmap 内存优势
# 1 亿用户的签到数据:1 亿 bit = 12.5 MB
# 如果用 Set 存储:每个用户 ID 占用约 10 字节,1 亿用户 = 1 GB
# Bitmap 比 Set 节省约 80 倍空间HyperLogLog(基数估算)
HyperLogLog 用于估算集合中不重复元素的数量(基数),空间固定为 12KB,误差率约 0.81%。
# 统计网站独立访客数(UV)
PFADD uv:2024:01:15 "user:1001" "user:1002" "user:1003" "user:1001"
# 重复的 user:1001 不会增加计数
PFCOUNT uv:2024:01:15 # 返回 3(不重复元素数)
# 合并多天的 UV
PFMERGE uv:2024:01:total uv:2024:01:15 uv:2024:01:16 uv:2024:01:17
PFCOUNT uv:2024:01:total
# HyperLogLog 适用场景
# - UV/PV 统计(不需要精确值)
# - 搜索关键词去重统计
# - 邮件发送去重
# 不适用场景:需要精确计数的场景Pipeline 与批量操作
Pipeline 是减少网络往返(RTT)提升 Redis 吞吐量的核心技术。每次 Redis 命令都有一次网络往返开销(约 0.1-1ms),在高并发场景下这个开销不可忽视。
# Pipeline 原理:客户端将多个命令打包,一次发送给服务端,服务端依次执行后一次性返回结果
# 无 Pipeline:N 个命令 = N 次 RTT
# 有 Pipeline:N 个命令 = 1 次 RTT
# Redis-cli Pipeline 示例
echo -en "SET key1 value1\r\nSET key2 value2\r\nSET key3 value3\r\nGET key1\r\n" | redis-cli --pipe
# 编程语言中使用 Pipeline(以 Java Jedis 为例)
# Pipeline pipeline = jedis.pipelined();
# pipeline.set("key1", "value1");
# pipeline.set("key2", "value2");
// pipeline.get("key1");
// List<Object> results = pipeline.syncAndReturnAll();
# Pipeline 注意事项
# 1. Pipeline 中的命令不是原子操作,中间可能被其他客户端的命令插入
# 2. Pipeline 不宜一次打包过多命令,建议每批 500-1000 个
# 过大可能导致:客户端等待超时、服务端输出缓冲区暴涨
# 3. Pipeline 返回的结果需要按顺序处理
# 4. 如果需要原子性,应使用 Lua 脚本或事务(MULTI/EXEC)Lua 脚本与原子操作
Redis 的 Lua 脚本在服务端原子执行,可以保证多个命令的原子性,同时减少网络往返。
# Lua 脚本示例:分布式限流(滑动窗口)
# 每分钟最多允许 100 次请求
EVAL '
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call("INCR", key)
if current == 1 then
redis.call("EXPIRE", key, window)
end
if current > limit then
return 0
else
return current
end
' 1 rate_limit:user:1001 100 60
# Lua 脚本示例:原子性的延迟队列消费
# 取出当前时间之前的任务并删除
EVAL '
local tasks = redis.call("ZRANGEBYSCORE", KEYS[1], 0, ARGV[1], "LIMIT", 0, ARGV[2])
if #tasks > 0 then
redis.call("ZREM", KEYS[1], unpack(tasks))
end
return tasks
' 1 delay_queue 1710000000 10
# Lua 脚本示例:库存扣减(防止超卖)
EVAL '
local stock_key = KEYS[1]
local order_key = KEYS[2]
local quantity = tonumber(ARGV[1])
local user_id = ARGV[2]
local stock = tonumber(redis.call("GET", stock_key))
if stock >= quantity then
redis.call("DECRBY", stock_key, quantity)
redis.call("HINCRBY", order_key, user_id, quantity)
return 1
else
return 0
end
' 2 product:5001:stock user:1001:orders 2 "user:1001"
# Lua 脚本注意事项
# 1. Lua 脚本在 Redis 中是原子执行的,但会阻塞其他命令
# 2. 脚本执行时间不宜过长,建议不超过 5ms
# 3. Redis 7.0 推荐使用 Function 替代 EVAL(更好管理)
# 4. Lua 脚本中不能使用随机数(RANDOMKEY、SRANDMEMBER),因为需要确定性内存优化策略
Key 设计优化
# 1. Key 前缀缩短:每节省 1 字节在百万 Key 场景下节省约 1MB
# 差:my_application:user:profile:1001(35 字节)
# 优:m:u:p:1001(11 字节)
# 2. 使用 Hash 替代多个独立 Key
# 差:user:1001:name、user:1001:age、user:1001:role(3 个 RedisObject)
# 优:user:1001 的 Hash 字段 name、age、role(1 个 RedisObject)
# 3. 序列化优化:使用 MessagePack 代替 JSON
# JSON:{"name":"张三","age":28} = 27 字节
# MessagePack:同样的数据约 18 字节
# Protobuf / CBOR 同理
# 4. 使用位操作压缩数据
# 将多个布尔标记存入一个 Bitmap,而不是多个 Key
# 差:user:1001:flag:email_verified、user:1001:flag:phone_verified 等
# 优:user:1001:flags 的 Bitmap(每个 bit 代表一个标记)
# 5. 编码切换阈值调优
# 针对小数据量场景,调整 listpack 参数使更多 Key 使用紧凑编码
CONFIG SET hash-max-listpack-entries 1024 # 默认 512
CONFIG SET hash-max-listpack-value 128 # 默认 64
CONFIG SET zset-max-listpack-entries 256 # 默认 128
CONFIG SET set-max-intset-entries 1024 # 默认 512过期策略与内存淘汰
# Redis 过期删除策略(双管齐下)
# 1. 惰性删除:访问 Key 时检查是否过期,过期则删除
# 2. 定期删除:每 100ms 随机检查一批 Key,删除过期的
# 默认每次检查 20 个 Key,删除其中过期的,如果过期比例 > 25% 则继续检查
# 内存淘汰策略(maxmemory-policy)
CONFIG SET maxmemory 4gb
CONFIG SET maxmemory-policy allkeys-lru
# 淘汰策略对比
# noeviction — 不淘汰,写入报错(默认)
# allkeys-lru — 从所有 Key 中淘汰最近最少使用的(推荐)
# allkeys-lfu — 从所有 Key 中淘汰使用频率最低的(Redis 4.0+)
# allkeys-random — 从所有 Key 中随机淘汰
# volatile-lru — 从设置了过期的 Key 中淘汰最近最少使用的
# volatile-lfu — 从设置了过期的 Key 中淘汰使用频率最低的
# volatile-ttl — 从设置了过期的 Key 中淘汰 TTL 最短的
# volatile-random — 从设置了过期的 Key 中随机淘汰
# LRU vs LFU 的选择
# LRU:适合热点数据有明显的"最近使用"特征(如缓存)
# LFU:适合热点数据长期被频繁访问(如配置、字典)优点
缺点
Big Key 防范与治理
Big Key 是 Redis 生产环境最常见的性能杀手。一个 Big Key 的操作可能导致其他请求超时。
# Big Key 标准:
# String > 10KB
# Hash/List/Set/Sorted Set > 5000 个元素
# 扫描 Big Key
redis-cli --bigkeys -i 0.1
# -i 0.1 表示每 100ms 扫描一次,降低对生产的影响
# 检查单个 Key 的内存占用
MEMORY USAGE user:1001
# Big Key 治理方案
# 1. 拆分大 Hash:user:1001 → user:1001:field1, user:1001:field2
# 2. 删除大 Key 使用 UNLINK(异步删除,不阻塞)
UNLINK big_key
# 3. 大 Hash 使用 HSCAN 代替 HGETALL
HSCAN user:1001 0 COUNT 100
# 4. 大 List 使用 LTRIM 分批删除
LTRIM big_list 0 -101 # 保留最后 100 个
LTRIM big_list 0 -1 # 删除全部总结
选择合适的 Redis 数据结构是系统设计的基本功。核心原则:String 做缓存和计数、Hash 做对象存储、Sorted Set 做排行榜和延迟队列、Stream 做可靠消息队列、Bitmap 做标记和签到。避免 Big Key,善用 Pipeline 和 Lua 脚本减少网络往返。
关键知识点
- Redis 对小数据量自动优化编码:Hash < 512 字段用 listpack,Sorted Set < 128 元素用 listpack
OBJECT ENCODING key可查看当前编码方式- Big Key 标准:String > 10KB、集合 > 5000 元素,使用
redis-cli --bigkeys扫描 - Redis 7.0 用 listpack 替代 ziplist,解决了级联更新问题
项目落地视角
- 制定数据结构选择规范:什么场景用什么结构,避免全部用 String JSON
- 监控 Big Key:定期扫描
redis-cli --bigkeys,设置maxmemory-policy - 使用 Hash 替代多个 String Key 存储 same-type 对象,节省内存和 Key 数量
- 评估数据过期策略:LRU/LFU 淘汰 + 主动过期双管齐下
常见误区
- 用 String 存储大 JSON 后频繁修改某个字段:应该用 Hash 替代,避免每次全量读写
- 用 List 做可靠消息队列:List 不支持消费组确认,消息丢失风险大,应使用 Stream
- 无限往 Sorted Set 写入数据:ZRANGEBYSCORE 在大数据集上仍然较慢,需要设置 TTL 或分批清理
- 忽略 Key 过期时间设置:导致内存持续增长,最终触发 OOM 或淘汰策略误删热数据
进阶路线
- 深入 Redis 底层数据结构实现:SDS、listpack、quicklist、skiplist、intset
- 学习 Redis 7.0 的 Function 特性,替代 Lua 脚本管理
- 研究 Redis 集群模式下的数据分片:Hash Slot 机制
- 了解 Redis 模块开发:RediSearch、RedisJSON、RedisTimeSeries
适用场景
- 高频读写缓存(String/Hash)
- 实时排行榜、计数器(Sorted Set/INCR)
- 消息队列、最新列表(Stream/List)
- 用户签到、在线状态(Bitmap)
- UV 统计、去重计数(HyperLogLog)
落地建议
- 制定 Key 命名规范:
业务:实体:ID,如order:user:1001 - 所有缓存 Key 必须设置过期时间,防止内存泄漏
- 使用 Pipeline 批量操作,减少网络 RTT
- 定期执行 Big Key 扫描,建立治理流程
排错清单
MEMORY USAGE key检查单个 Key 内存占用redis-cli --bigkeys扫描大 KeySLOWLOG GET 10查看慢操作OBJECT ENCODING key检查编码方式是否符合预期
复盘问题
- 当前最大的 Redis Key 占用多少内存?是否是 Big Key?
- 缓存命中率是多少?是否有大量无效缓存?
- 消息队列场景是否需要消费组确认?Stream 比 List 是否更合适?
- Key 命名规范是否统一?过期时间是否设置?
