文章目录
从底层实现到生产落地,带你彻底搞懂 String、Hash、List、Set、ZSet
前言
很多同学用 Redis 用了两三年,日常操作就是 GET/SET/EXPIRE 三件套,一旦遇到稍微复杂点的场景就抓瞎——排行榜怎么做?点赞去重怎么做?延时队列怎么做?
其实答案都藏在 Redis 的五种基础数据结构里。Redis 之所以快,不只是因为它基于内存 + 单线程 + IO 多路复用,更关键的是它每一种数据结构都针对特定场景做了极致的底层优化。
这篇文章我会带你从底层实现 → 常用命令 → 生产实战场景 → 避坑指南 层层递进,看完你会发现:Redis 的威力远比你想象的大。
一、先建立全局认知:Redis 的 KV 模型
Redis 的存储模型非常简单:
key (String) → value (五种数据结构之一)
- key 永远是 String 类型(二进制安全,最大 512MB,但生产一般不超过 100 字节)
- value 才是五种数据结构:String、Hash、List、Set、ZSet
初学者最容易踩的一个坑就是:把 Redis 当 MySQL 用,key 设计得又长又乱,比如 user:1001:profile:name:xxx。实际上合理的 key 设计应该是:
业务名:对象类型:ID[:字段]
例如:user:profile:1001、order:detail:20240101:9527。
好,进入正题。
二、String —— 最基础,也最容易被低估
2.1 底层实现:SDS
Redis 没有直接用 C 语言的字符串,而是自己实现了一个 SDS(Simple Dynamic String)。
为什么?因为 C 字符串有两个致命问题:
- 获取长度需要 O(n) 遍历(
strlen) - 非二进制安全(
\0会被当成结束符)
SDS 结构大致如下:
struct sdshdr {
int len; // 已使用长度
int free; // 剩余空间
char buf[]; // 实际字符数组
};
所以 SDS 能做到 O(1) 获取长度,并且二进制安全。
编码转换(重点):
| 编码 | 触发条件 | 说明 |
|---|---|---|
int | 值为整数且长度 ≤ 20 | 直接把数字存进指针里 |
embstr | 字符串长度 ≤ 44 字节 | 一次内存分配,只读 |
raw | 字符串长度 > 44 字节 | 每次修改重新分配 |
生产意义:如果你存的是
123456789这种纯数字,Redis 会用int编码,内存开销极小。所以能用数字 ID 的地方就不要拼字符串。
2.2 常用命令
SET user:name "张三" EX 3600 # 存值 + 过期时间(推荐写法,原子)
GET user:name
SETNX lock:order:1001 "1" # 不存在才设置(分布式锁用)
MSET k1 v1 k2 v2 # 批量设置
MGET k1 k2 # 批量获取
INCR article:read:1001 # 原子自增
INCRBY article:read:1001 10
DECR stock:1001
APPEND log "text" # 追加
STRLEN user:name
SETBIT sign:1001:202401 15 1 # 位操作
GETBIT sign:1001:202401 15
BITCOUNT sign:1001:202401
2.3 生产实战场景
场景 1:缓存对象(最常见的滥用点)
import json
import redis
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
def get_user(user_id):
cache_key = f"user:profile:{user_id}"
cached = r.get(cache_key)
if cached:
return json.loads(cached)
user = db_query(user_id) # 伪代码:查数据库
r.set(cache_key, json.dumps(user), ex=1800)
return user
⚠️ 注意:缓存整个对象虽然方便,但如果只需要更新一个字段,就必须反序列化整个 JSON,存在并发覆盖问题。字段较多且更新频繁时,优先考虑 Hash。
场景 2:计数器
# 文章阅读量
def incr_read(article_id):
return r.incr(f"article:read:{article_id}")
# 接口限流(固定窗口:1 分钟最多 100 次)
def is_allowed(user_id):
key = f"limit:{user_id}:{int(time.time() // 60)}"
cnt = r.incr(key)
if cnt == 1:
r.expire(key, 60)
return cnt <= 100
INCR 是原子的,单线程模型下不用加锁,这是 Redis 做计数器的核心优势。
场景 3:分布式锁(面试必问)
import uuid
def acquire_lock(lock_key, expire=10):
token = str(uuid.uuid4())
# 必须用 SET NX EX,不能拆成 SETNX + EXPIRE(非原子)
ok = r.set(lock_key, token, nx=True, ex=expire)
return token if ok else None
# 释放锁必须用 Lua 保证「判断 + 删除」原子性
UNLOCK_LUA = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
"""
def release_lock(lock_key, token):
r.eval(UNLOCK_LUA, 1, lock_key, token)
场景 4:位图统计(用户签到 / 日活 UV)
一个用户一年 365 天,用 String 的位操作只需要 365/8 ≈ 46 字节。
# 用户 sign:1001:202401 的第 15 位记录为已签到
r.setbit("sign:1001:202401", 15, 1)
# 统计本月签到天数
count = r.bitcount("sign:1001:202401")
# 统计连续签到(BITCOUNT 不适合,需要用 BITFIELD 或客户端计算)
千万级日活统计(HyperLogLog 是更好的选择,但这是另一个话题)。
三、Hash —— 存储对象的最佳选择
3.1 底层实现
Hash 的底层有两种编码,会自动转换:
| 编码 | 触发条件 | 适用场景 |
|---|---|---|
listpack(早期是 ziplist) | 字段数 ≤ 128 且所有 value ≤ 64 字节 | 小对象,内存极省 |
hashtable | 超过阈值 | 大对象,查找 O(1) |
阈值可以配置:
hash-max-listpack-entries 128
hash-max-listpack-value 64
生产意义:超出阈值会触发一次性全量 rehash,如果此时 Hash 有几十万字段,会造成主线程阻塞。所以大 Hash 一定要提前规划好,千万别让它长到几十万字段。
3.2 常用命令
HSET user:1001 name "张三" age 25 city "北京"
HGET user:1001 name
HMGET user:1001 name age
HGETALL user:1001 # ⚠️ 大 Hash 慎用!会阻塞
HDEL user:1001 city
HINCRBY user:1001 age 1
HKEYS user:1001
HLEN user:1001
HEXISTS user:1001 name
3.3 生产实战场景
场景 1:对象存储(优于 String + JSON)
# 存用户信息
r.hset("user:1001", mapping={
"name": "张三",
"age": 25,
"vip_level": 3
})
# 只更新一个字段,不动其他字段
r.hincrby("user:1001", "vip_level", 1)
# 只取需要的字段
name, age = r.hmget("user:1001", "name", "age")
为什么比 String 好?
- 支持字段级读写,避免全量反序列化
- 内存更省(小对象用 listpack)
- 并发更新不同字段不会互相覆盖
场景 2:购物车
# 用户 1001 的购物车:商品 ID → 数量
r.hset("cart:1001", "sku:2001", 2)
r.hincrby("cart:1001", "sku:2002", 1)
r.hdel("cart:1001", "sku:2001")
cart = r.hgetall("cart:1001") # 获取整个购物车
场景 3:短链映射
短码 → Hash{ 原始URL, 创建时间, 访问次数 }
四、List —— 消息队列与时间线
4.1 底层实现
早期是 ziplist + linkedlist,Redis 3.2 之后统一成 quicklist(本质是「双向链表 + listpack」的组合)。
quicklist
├── node1 → listpack [a, b, c]
├── node2 → listpack [d, e, f]
└── node3 → listpack [g, h]
这样兼顾了:
- 链表的插入删除 O(1)
- listpack的连续内存、缓存友好
重要命令的时间复杂度:
| 命令 | 复杂度 |
|---|---|
LPUSH / RPUSH | O(1) |
LPOP / RPOP | O(1) |
LINDEX | O(n) ⚠️ |
LRANGE | O(S+N) |
4.2 常用命令
LPUSH queue:task "task1" # 左边入队
RPUSH queue:task "task2" # 右边入队
RPOP queue:task # 右边出队(FIFO)
LPOP queue:task # 左边出队(LIFO)
BRPOP queue:task 5 # 阻塞式出队,超时 5s
LRANGE list 0 -1 # 全量(⚠️ 慢)
LTRIM list 0 99 # 保留前 100 个
LLEN queue:task
4.3 生产实战场景
场景 1:简单消息队列(LPUSH + BRPOP)
# 生产者
def produce(task):
r.lpush("queue:task", json.dumps(task))
# 消费者(阻塞等待)
def consume():
while True:
# 超时 5 秒,避免永久阻塞
_, task = r.brpop("queue:task", timeout=5)
if task:
handle(json.loads(task))
⚠️ 局限性:
- 消息无 ACK 机制,消费失败就丢了
- 不支持多消费者组
- 生产环境推荐用
Stream(Redis 5.0+),或者用 Kafka / RabbitMQ
场景 2:最新动态 / 时间线
# 用户发一条动态,推送给粉丝
def post_feed(user_id, feed_id):
key = f"feed:{user_id}"
r.lpush(key, feed_id)
r.ltrim(key, 0, 999) # 只保留最近 1000 条
# 拉取首页动态
feeds = r.lrange("feed:1001", 0, 19) # 取最近 20 条
场景 3:栈与队列
LPUSH + LPOP = 栈(LIFO)
LPUSH + RPOP = 队列(FIFO)
五、Set —— 去重与集合运算
5.1 底层实现
| 编码 | 触发条件 |
|---|---|
intset | 所有元素都是整数且数量 ≤ 512 |
listpack | 非整数但元素少 |
hashtable | 超出阈值 |
set-max-intset-entries 512
set-max-listpack-entries 128
set-max-listpack-value 64
5.2 常用命令
SADD article:like:1001 user1 user2 user3
SREM article:like:1001 user1
SISMEMBER article:like:1001 user2 # 判断是否存在 O(1)
SCARD article:like:1001 # 基数
SMEMBERS article:like:1001 # ⚠️ 大集合慎用
SRANDMEMBER article:like:1001 3 # 随机取 3 个(不删除)
SPOP article:like:1001 3 # 随机弹出 3 个(删除)
# 集合运算
SINTER set1 set2 # 交集(共同好友)
SUNION set1 set2 # 并集
SDIFF set1 set2 # 差集(可能认识的人)
SINTERSTORE dest set1 set2 # 交集存储
5.3 生产实战场景
场景 1:点赞去重
def like(article_id, user_id):
key = f"article:like:{article_id}"
# SADD 返回 1 表示新增成功,返回 0 表示已点赞
return r.sadd(key, user_id) == 1
def unlike(article_id, user_id):
return r.srem(f"article:like:{article_id}", user_id)
def like_count(article_id):
return r.scard(f"article:like:{article_id}")
场景 2:抽奖
# 参与抽奖的用户全部 SADD
r.sadd("lottery:act001", *user_ids)
# 抽 3 个中奖者,不重复
winners = r.spop("lottery:act001", 3)
场景 3:共同好友
# 用户 A 的好友集合、用户 B 的好友集合
r.sadd("friend:1001", 2001, 2002, 2003)
r.sadd("friend:1002", 2002, 2003, 2004)
# 共同好友
common = r.sinter("friend:1001", "friend:1002") # {2002, 2003}
# 可能认识的人(A 的好友,B 不认识)
maybe = r.sdiff("friend:1001", "friend:1002")
场景 4:标签系统 / IP 黑名单
# 判断是否在黑名单
if r.sismember("blacklist:ip", "1.2.3.4"):
return forbidden()
六、ZSet —— 排行榜与延时队列的王者
6.1 底层实现
ZSet 是 Redis 最精妙的数据结构,它同时用了两个结构:
dict(哈希表):存member → score映射,保证 O(1) 查分数skiplist(跳表):按 score 排序,保证 O(logN) 范围查询
ZSet
├── dict: { "A": 90, "B": 85, "C": 95 }
└── skiplist: C(95) → A(90) → B(85)
为什么用跳表不用红黑树?
- 跳表实现简单,容易维护
- 范围查询(
ZRANGE)跳表更高效 - 并发场景下跳表更容易做无锁操作
编码转换:
| 编码 | 触发条件 |
|---|---|
listpack | 元素数 ≤ 128 且所有 member ≤ 64 字节 |
skiplist | 超出阈值 |
6.2 常用命令
ZADD rank:game 100 user1 200 user2 150 user3
ZSCORE rank:game user1
ZINCRBY rank:game 50 user1
ZRANK rank:game user1 # 正序排名(从 0 开始)
ZREVRANK rank:game user1 # 倒序排名
ZRANGE rank:game 0 9 # 正序前 10 名
ZREVRANGE rank:game 0 9 WITHSCORES # 倒序前 10 名(带分数)
# 范围查询(核心功能)
ZRANGEBYSCORE rank:game 100 200 # 分数在 [100, 200]
ZRANGEBYSCORE rank:game (100 +inf # 分数 > 100
# 延时队列关键命令
ZRANGEBYSCORE delay:queue 0 now LIMIT 0 10
ZREM delay:queue task_id
6.3 生产实战场景
场景 1:排行榜(最经典的用法)
# 更新玩家分数
def update_score(player_id, score):
r.zadd("rank:game:202401", {player_id: score})
# 增加分数
def add_score(player_id, delta):
r.zincrby("rank:game:202401", delta, player_id)
# Top 10 榜单
def top10():
return r.zrevrange("rank:game:202401", 0, 9, withscores=True)
# 查看自己排名
def my_rank(player_id):
rank = r.zrevrank("rank:game:202401", player_id)
return rank + 1 if rank is not None else None
# 查询分数区间 [1000, 2000] 的玩家
r.zrangebyscore("rank:game:202401", 1000, 2000)
场景 2:延时队列(订单超时关闭)
原理:score 存执行时间戳,消费者不断扫描 score <= now 的任务。
import time
import uuid
# 生产者:10 秒后执行
def add_delay_task(payload, delay_seconds):
task_id = str(uuid.uuid4())
execute_at = time.time() + delay_seconds
r.zadd("delay:queue", {task_id: execute_at})
r.hset(f"delay:task:{task_id}", mapping=payload)
return task_id
# 消费者
def consume_delay_task():
now = time.time()
while True:
# 原子地取出 + 删除(用 ZPOPMIN 或 Lua)
tasks = r.zrangebyscore("delay:queue", 0, now, start=0, num=10)
for task_id in tasks:
# Lua 保证原子性:score 没变才删
lua = """
if redis.call('zscore', KEYS[1], ARGV[1]) == ARGV[2] then
redis.call('zrem', KEYS[1], ARGV[1])
return 1
end
return 0
"""
score = r.zscore("delay:queue", task_id)
if r.eval(lua, 1, "delay:queue", task_id, score):
payload = r.hgetall(f"delay:task:{task_id}")
handle(payload)
r.delete(f"delay:task:{task_id}")
time.sleep(1)
生产建议:延时队列场景更推荐用 Redis 5.0 的 Stream 或专门的 Redisson DelayedQueue,避免自己实现时踩坑。
场景 3:热搜榜
# 每次搜索给关键词加分
def search(keyword):
r.zincrby("hot:search:20240101", 1, keyword)
# 每小时聚合一次,Top 50
def get_hot():
return r.zrevrange("hot:search:20240101", 0, 49, withscores=True)
场景 4:滑动窗口限流
def sliding_window_limit(user_id, limit=100, window=60):
key = f"limit:sliding:{user_id}"
now = time.time()
pipe = r.pipeline()
# 移除窗口外的请求
pipe.zremrangebyscore(key, 0, now - window)
# 当前窗口请求数
pipe.zcard(key)
# 加入本次请求
pipe.zadd(key, {str(now): now})
pipe.expire(key, window)
_, count, _, _ = pipe.execute()
return count < limit
七、生产环境避坑指南(重点!)
7.1 大 Key 问题
定义:
- String > 10KB
- Hash / List / Set / ZSet 元素数 > 5000
危害:
- 删除时阻塞主线程(
DEL是 O(n)) - 网络传输慢
- 主从复制延迟
- 集群下数据倾斜
排查:
redis-cli --bigkeys # 快速扫描
redis-cli --memkeys # 按内存排序
MEMORY USAGE key # 单 key 内存占用
解决:
- 大 Hash 拆分成多个小 Hash(按 ID 取模)
- 删除用
UNLINK(异步删除)代替DEL - 用
HSCAN/SSCAN/ZSCAN代替HGETALL/SMEMBERS/ZRANGE(0,-1)
7.2 多 Key 操作在集群下的坑
SINTER、MSET、MGET 涉及多个 key 时,Cluster 模式要求这些 key 落在同一个 slot。
解决方案:用 Hash Tag 强制同 slot
# {user1001} 会作为 hash tag 参与 slot 计算
r.sadd("{user1001}:friends", ...)
r.sadd("{user1001}:follows", ...)
# 这样两个 key 一定在同一节点
7.3 时间复杂度陷阱
| 命令 | 复杂度 | 危险点 |
|---|---|---|
KEYS * | O(N) | 生产禁用,用 SCAN |
HGETALL | O(N) | 大 Hash 阻塞,用 HSCAN |
SMEMBERS | O(N) | 大 Set 阻塞,用 SSCAN |
DEL bigkey | O(N) | 用 UNLINK |
ZRANGE 0 -1 | O(N) | 大 ZSet 阻塞 |
7.4 编码转换阈值
一旦超过阈值,listpack 会转为 hashtable/skiplist,内存直接翻几倍。所以:
# 如果业务明确是小对象,可以适当调大阈值
hash-max-listpack-entries 256
hash-max-listpack-value 128
但不要无脑调大,会让 rehash 更慢。
7.5 热点 Key
某个 key 的 QPS 特别高,会导致单个节点被打爆。
解决方案:
- 本地缓存 + 短过期(Caffeine / Go-cache)
- key 加随机后缀,分散到多个节点:
hot:key:{0~9} - 读写分离,从节点分担读压力
八、五种数据结构速查对比
| 结构 | 底层 | 有序性 | 去重 | 查询复杂度 | 典型场景 |
|---|---|---|---|---|---|
| String | SDS | - | - | O(1) | 缓存、计数器、分布式锁、位图 |
| Hash | listpack / hashtable | 无序 | 字段唯一 | O(1) | 对象存储、购物车、配置 |
| List | quicklist | 顺序 | 不去重 | 两端 O(1) | 消息队列、时间线、栈/队列 |
| Set | intset / listpack / hashtable | 无序 | 去重 | O(1) | 点赞、抽奖、共同好友、标签 |
| ZSet | listpack / skiplist | 按 score 有序 | 去重 | O(logN) | 排行榜、延时队列、限流 |
记忆口诀:
存对象用 Hash,计数用 String,
队列用 List,去重用 Set,
只要排名用 ZSet。
九、总结
回到开头的问题——Redis 为什么能覆盖这么多场景?
因为它不像 MySQL 只有一种表结构,而是提供了五种定位明确的数据结构,每种都针对特定需求做了底层优化:
- String 用 SDS 做到二进制安全 + O(1) 长度
- Hash 用 listpack 把小对象压缩到极致
- List 用 quicklist 兼顾内存与性能
- Set 用 intset 把整数集合做到极致省内存
- ZSet 用 skiplist 实现 O(logN) 的范围查询
掌握它们的关键,不是背命令,而是理解「什么场景用什么结构」以及「阈值与复杂度带来的性能陷阱」。
下一篇文章我会深入讲 Redis 持久化(RDB + AOF)与主从复制原理,以及分布式锁的 Redlock 争议,感兴趣的同学可以关注一下。

395

被折叠的 条评论
为什么被折叠?



