一文吃透 Redis 五大基础数据结构与生产实战场景

从底层实现到生产落地,带你彻底搞懂 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:1001order:detail:20240101:9527

好,进入正题。


二、String —— 最基础,也最容易被低估

2.1 底层实现:SDS

Redis 没有直接用 C 语言的字符串,而是自己实现了一个 SDS(Simple Dynamic String)

为什么?因为 C 字符串有两个致命问题:

  1. 获取长度需要 O(n) 遍历strlen
  2. 非二进制安全\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 / RPUSHO(1)
LPOP / RPOPO(1)
LINDEXO(n) ⚠️
LRANGEO(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)

为什么用跳表不用红黑树?

  1. 跳表实现简单,容易维护
  2. 范围查询(ZRANGE)跳表更高效
  3. 并发场景下跳表更容易做无锁操作

编码转换:

编码触发条件
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 操作在集群下的坑

SINTERMSETMGET 涉及多个 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
HGETALLO(N)大 Hash 阻塞,用 HSCAN
SMEMBERSO(N)大 Set 阻塞,用 SSCAN
DEL bigkeyO(N)UNLINK
ZRANGE 0 -1O(N)大 ZSet 阻塞

7.4 编码转换阈值

一旦超过阈值,listpack 会转为 hashtable/skiplist,内存直接翻几倍。所以:

# 如果业务明确是小对象,可以适当调大阈值
hash-max-listpack-entries 256
hash-max-listpack-value 128

不要无脑调大,会让 rehash 更慢。

7.5 热点 Key

某个 key 的 QPS 特别高,会导致单个节点被打爆。

解决方案

  1. 本地缓存 + 短过期(Caffeine / Go-cache)
  2. key 加随机后缀,分散到多个节点:hot:key:{0~9}
  3. 读写分离,从节点分担读压力

八、五种数据结构速查对比

结构底层有序性去重查询复杂度典型场景
StringSDS--O(1)缓存、计数器、分布式锁、位图
Hashlistpack / hashtable无序字段唯一O(1)对象存储、购物车、配置
Listquicklist顺序不去重两端 O(1)消息队列、时间线、栈/队列
Setintset / listpack / hashtable无序去重O(1)点赞、抽奖、共同好友、标签
ZSetlistpack / 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 争议,感兴趣的同学可以关注一下。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Aerkui

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值