Redis 从入门到精通:数据结构Hash 与 List

IT策士 10余年一线大厂经验,专注 IT 思维、架构、职场进阶。我会在各个平台持续发布最新文章,助你少走弯路。

上一篇我们吃透了 Redis 最万能的 String,学会了用它做缓存、计数器和分布式锁。但现实世界的数据远比键值对复杂------用户有多个属性(姓名、年龄、积分),任务有先后顺序,消息需要排队处理。这时候,把一切塞进序列化后的 String 就捉襟见肘了。

今天,我们来解锁两个专为解决这类问题而生的数据结构:Hash(哈希)List(列表)。一个适合存储对象,一个擅长处理顺序数据。掌握它们,你的 Redis 工具箱就丰富了一半。

第一部分:Hash------对象的天然容器

1. 为什么需要 Hash?

想象你要缓存一个用户信息:

bash 复制代码
user = {
    "id": 1001,
    "name": "Alice",
    "age": 30,
    "email": "alice@example.com",
    "score": 950
}

如果用 String,你可能会这么做:

  • 方案 A:全部序列化成 JSON 存为一个 String。每次只想更新 score,都要 GET → 反序列化 → 修改 → 序列化 → SET,浪费带宽和 CPU。

  • 方案 B:每个字段存一个 String,键名变成 user:1001:nameuser:1001:age......键名大量冗余,管理麻烦。

Hash 给出了完美答案:一个键下可以包含多个字段-值对,每个字段独立读写,既节省键空间,又操作灵活。

bash 复制代码
Key: user:1001
+----------------+---------+
| field          | value   |
+----------------+---------+
| name           | Alice   |
| age            | 30      |
| email          | alice@..|
| score          | 950     |
+----------------+---------+

本质上,Hash 就像一个"微型 Redis",每个键都是一个独立的小字典。

2. Hash 核心命令速览

redis-cli 中动手试试:

bash 复制代码
# 设置单个字段
127.0.0.1:6379> HSET user:1001 name "Alice"
(integer) 1

# 设置多个字段
127.0.0.1:6379> HSET user:1001 age 30 email "alice@example.com"
(integer) 2

# 获取单个字段
127.0.0.1:6379> HGET user:1001 name
"Alice"

# 获取所有字段和值
127.0.0.1:6379> HGETALL user:1001
1) "name"
2) "Alice"
3) "age"
4) "30"
5) "email"
6) "alice@example.com"

# 获取多个字段的值
127.0.0.1:6379> HMGET user:1001 name age
1) "Alice"
2) "30"

# 判断字段是否存在
127.0.0.1:6379> HEXISTS user:1001 name
(integer) 1
127.0.0.1:6379> HEXISTS user:1001 phone
(integer) 0

# 删除字段
127.0.0.1:6379> HDEL user:1001 age
(integer) 1

# 获取所有字段名 / 所有值 / 字段数量
127.0.0.1:6379> HKEYS user:1001
1) "name"
2) "email"
127.0.0.1:6379> HVALS user:1001
1) "Alice"
2) "alice@example.com"
127.0.0.1:6379> HLEN user:1001
(integer) 2

3. 数字操作与原子增减

Hash 的字段值如果是数字字符串,同样支持原子增减,不需要整体取出:

bash 复制代码
127.0.0.1:6379> HSET user:1001 score 950
(integer) 1
127.0.0.1:6379> HINCRBY user:1001 score 10
(integer) 960
127.0.0.1:6379> HINCRBYFLOAT user:1001 score 0.5
"960.5"

这对游戏积分、点赞数统计特别方便。如果字段不存在,HINCRBY 默认从 0 开始加。

4. Python 实战 Hash------用户信息管理

先确保环境就绪:

场景:实现一个用户信息存储,支持部分字段更新和积分增减。

bash 复制代码
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# 1. 创建或更新用户信息
def save_user(user_id, **kwargs):
    key = f'user:{user_id}'
    if kwargs:
        r.hset(key, mapping=kwargs)
    return key

# 2. 获取用户完整信息
def get_user(user_id):
    key = f'user:{user_id}'
    return r.hgetall(key)

# 3. 更新指定字段(支持增量操作)
def update_user_field(user_id, field, value, is_increment=False):
    key = f'user:{user_id}'
    if is_increment:
        return r.hincrby(key, field, value)
    else:
        return r.hset(key, field, value)

# 4. 批量获取多个用户的特定字段
def batch_get_user_fields(user_ids, fields):
    pipe = r.pipeline()
    for uid in user_ids:
        pipe.hmget(f'user:{uid}', fields)
    return pipe.execute()

# ---------- 测试 ----------
save_user(1001, name='Alice', age=30, email='alice@example.com', score=950)
print("完整信息:", get_user(1001))

update_user_field(1001, 'score', 50, is_increment=True)
print("加 50 分后:", r.hget('user:1001', 'score'))  # 输出: 1000

update_user_field(1001, 'email', 'alice_new@example.com')
print("更新邮箱后:", r.hget('user:1001', 'email'))  # 输出: alice_new@example.com

# 批量获取多个用户的 name 和 score
save_user(1002, name='Bob', age=25, email='bob@example.com', score=800)
results = batch_get_user_fields([1001, 1002], ['name', 'score'])
for uid, data in zip([1001, 1002], results):
    print(f"用户 {uid}: {dict(zip(['name', 'score'], data))}")

输出示例:

bash 复制代码
完整信息: {'name': 'Alice', 'age': '30', 'email': 'alice@example.com', 'score': '950'}
加 50 分后: 1000
更新邮箱后: alice_new@example.com
用户 1001: {'name': 'Alice', 'score': '1000'}
用户 1002: {'name': 'Bob', 'score': '800'}

💡 最佳实践hset 支持 mapping 参数,可直接传入字典,省去多次 HSET 调用。批量操作使用 Pipeline 减少网络往返。

5. Hash 内部编码优化

Redis 对 Hash 有两种内部编码,会根据数据量自动切换:

查看编码:

bash 复制代码
127.0.0.1:6379> OBJECT ENCODING user:1001
"hashtable"   # 或 "ziplist"

实际开发中,如果对象字段少且值短,Redis 会自动用 ziplist 节省内存。阈值可通过配置文件调整。

第二部分:List------顺序数据的完美载体

1. 为什么需要 List?

Redis List 是一个双向链表,元素按插入顺序排列,支持从头部和尾部快速插入/删除。这让它天然适合:

  • 栈(Stack) :后进先出,LPUSH + LPOP

  • 队列(Queue) :先进先出,LPUSH + RPOP

  • 阻塞队列(Blocking Queue) :队列为空时等待,BRPOP

  • 最新消息列表:固定长度的时间线

String 虽然能存序列化的数组,但每次修改都要整体读写,无法实现高效的"取最新 10 条"或"弹出队首元素"。

2. List 核心命令速览

bash 复制代码
# 从左侧(头部)插入
127.0.0.1:6379> LPUSH tasks "发送邮件" "生成报表"
(integer) 2

# 从右侧(尾部)插入
127.0.0.1:6379> RPUSH tasks "备份数据库"
(integer) 3

# 查看列表所有元素(LRANGE key start stop)
127.0.0.1:6379> LRANGE tasks 0 -1
1) "生成报表"      # 最先 LPUSH 的在最左侧?不,LPUSH 依次头部插入,"生成报表" 最后插入,因此在最左
2) "发送邮件"
3) "备份数据库"

# 从左侧弹出
127.0.0.1:6379> LPOP tasks
"生成报表"

# 从右侧弹出
127.0.0.1:6379> RPOP tasks
"备份数据库"

# 列表长度
127.0.0.1:6379> LLEN tasks
(integer) 1

# 获取指定位置元素
127.0.0.1:6379> LINDEX tasks 0
"发送邮件"

# 修剪列表(保留指定范围)
127.0.0.1:6379> RPUSH logs "a" "b" "c" "d" "e"
(integer) 5
127.0.0.1:6379> LTRIM logs 0 2   # 只保留前 3 个
OK
127.0.0.1:6379> LRANGE logs 0 -1
1) "a"
2) "b"
3) "c"

LTRIM 配合 LPUSH 可以轻松实现"只保留最新 N 条"的固定长度列表,非常适合时间线或日志。

3. 阻塞队列:BRPOP / BLPOP

当队列为空时,RPOP 返回 nil,消费者需要轮询,浪费 CPU。BRPOP 解决了这个问题:队列为空时阻塞等待,一旦有数据立即返回。

bash 复制代码
# 终端 1(消费者):阻塞等待
127.0.0.1:6379> BRPOP task_queue 30   # 30 秒超时,0 表示永久阻塞
# ... 阻塞中,直到有数据或超时 ...

# 终端 2(生产者):插入数据
127.0.0.1:6379> LPUSH task_queue "新任务"
(integer) 1

# 终端 1 立即输出:
1) "task_queue"
2) "新任务"
(20.15s)   # 等待了 20.15 秒

BRPOP 可同时监听多个队列,返回第一个有数据的队列名和值。

4. Python 实战 List------消息队列与时间线

场景一:简单任务队列(生产者-消费者)

生产者 producer.py

bash 复制代码
import redis
import time
import random

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

tasks = ['发送邮件', '生成报表', '备份数据库', '清理日志', '同步数据']
for task in tasks:
    r.lpush('task_queue', task)
    print(f'[生产者] 发布任务: {task}')
    time.sleep(random.uniform(0.5, 1.5))

消费者 consumer.py

bash 复制代码
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

print('[消费者] 开始等待任务...')
while True:
    # 阻塞等待,超时 5 秒
    result = r.brpop('task_queue', timeout=5)
    if result is None:
        print('[消费者] 超时,队列为空,退出')
        break
    queue_name, task = result
    print(f'[消费者] 处理任务: {task}')
    # 模拟任务处理
    import time
    time.sleep(0.5)

运行结果(先启动消费者,再启动生产者):

bash 复制代码
[消费者] 开始等待任务...
[消费者] 处理任务: 发送邮件
[消费者] 处理任务: 生成报表
[消费者] 处理任务: 备份数据库
[消费者] 处理任务: 清理日志
[消费者] 处理任务: 同步数据
[消费者] 超时,队列为空,退出

场景二:最新动态时间线(固定长度)

社交媒体中,只展示用户最近 50 条动态,用 LTRIM 实现:

bash 复制代码
def add_post(r, user_id, post_content, max_len=50):
    key = f'timeline:{user_id}'
    pipe = r.pipeline()
    pipe.lpush(key, post_content)
    pipe.ltrim(key, 0, max_len - 1)
    pipe.execute()

def get_recent_posts(r, user_id, count=10):
    key = f'timeline:{user_id}'
    return r.lrange(key, 0, count - 1)

# 测试
r = redis.Redis(host='localhost', port=6379, decode_responses=True)

for i in range(60):
    add_post(r, 'user:1001', f'这是第 {i+1} 条动态')

recent = get_recent_posts(r, 'user:1001', 5)
print('最新 5 条动态:')
for i, post in enumerate(recent, 1):
    print(f'  {i}. {post}')

print(f'时间线总长度: {r.llen("timeline:user:1001")}')  # 输出: 50

输出示例:

bash 复制代码
最新 5 条动态:
  1. 这是第 60 条动态
  2. 这是第 59 条动态
  3. 这是第 58 条动态
  4. 这是第 57 条动态
  5. 这是第 56 条动态
时间线总长度: 50

Pipeline 将 LPUSHLTRIM 打包发送,效率更高。

5. List 内部编码:quicklist

Redis 3.2 之前,List 使用 ziplist 或 linkedlist。自 3.2 起统一使用 quicklist------一个由多个 ziplist 节点组成的双向链表。

bash 复制代码
quicklist 结构:
[ziplist 1] <-> [ziplist 2] <-> [ziplist 3]

每个 ziplist 节点存储若干元素,既保留了链表的灵活插入,又利用 ziplist 的紧凑内存,减少指针开销。配置参数 list-max-ziplist-size 控制每个 ziplist 节点的大小。

bash 复制代码
127.0.0.1:6379> OBJECT ENCODING tasks
"quicklist"

日常开发几乎不需要手动调整,了解原理即可。

6. Hash vs String:何时用哪个?

一个常见疑问:存储对象,用 String 存 JSON 还是用 Hash?

选择建议

  • 频繁更新部分字段 → 用 Hash

  • 只整体读写、数据较大 → 序列化 String

  • 需要按字段设置 TTL → 拆分为独立 String 键

  • 简单缓存、字段少 → Hash,利用 ziplist 节省内存

7. 动手试试

  1. Hash 挑战 :创建 product:2001 哈希,字段包括 namepricestock,模拟一个购物场景:用户下单后 HINCRBY stock -1,验证库存变化。

  2. List 挑战 :用 LPUSH + LTRIM 实现一个只保留最近 10 条的错误日志列表,模拟插入 20 条日志后,确认列表长度始终为 10。

  3. 进阶 :用 Python 的 threading 模块同时运行两个消费者监听同一个队列,生产者发布 10 个任务,观察两个消费者如何瓜分任务。

预期效果:

  1. 库存正确递减,且不会减到负数(需配合 Lua 判断,后续章节实现)。

  2. 列表长度恒为 10,最旧的日志被自动淘汰。

  3. 两个消费者均匀分配任务,因为 Redis 单线程保证每次只有一个客户端拿到一个元素。

8. 总结

本篇我们深入了 Redis 的两大核心数据结构:

  • Hash:对象的天然容器,支持字段级别的独立读写和原子操作,内部编码 ziplist/hashtable 兼顾内存与性能。适合用户信息、商品详情、配置项等场景。

  • List :基于 quicklist 的双向链表,支持栈、队列、阻塞队列和固定长度时间线。BRPOP 让消息消费者优雅等待,LTRIM 让时间线自动截断。

这两个数据结构与 String 互补,共同构建了 Redis 处理复杂业务的基础。下一篇,我们将解锁 Set 和 Sorted Set------集合运算和排行榜的终极利器,敬请期待。

想了解更多还可以去各个平台搜索「IT策士」,一起升级 IT 思维 !

相关推荐
百珏1 小时前
流量没暴涨,网关却挂了:Spring Cloud Gateway 从 500 QPS 优化到 4200 QPS
后端·spring cloud·架构
ICT系统集成阿祥1 小时前
什么是AI ECN?
后端
Cache技术分享1 小时前
432. Java 日期时间 API - 时间工具 TemporalQuery 详解
前端·后端
XovH1 小时前
Redis 从入门到精通:初识 Redis
后端
uhakadotcom2 小时前
在 Python 开发中 transitions 的使用
后端·面试·github
Rust研习社2 小时前
通过手写一个迷你 grep 来学习 Rust 的所有权与借用
后端
用户531397318172 小时前
「踩坑实录」原来的SQL索引自动优化失败了,线上数据库差点被打挂
java·后端
go不是csgo2 小时前
从0到1理解Go熔断器:sony/gobreaker 源码剖析 + 仿TikTok Feed 项目实战
开发语言·后端·golang
SimonKing2 小时前
线程池面试被问到怕?看完这篇让他当场沉默
java·后端·程序员