艾体宝干货 | Redis Python 开发系列#2 核心数据结构(上)

前言

继上篇文章,成功连接 Redis 之后,我们直面其核心:数据结构。Redis 的强大,绝非仅是简单的键值存储,而是其精心设计的多种数据结构所能解决的各种业务场景。

本篇读者收益

  • 精通 String 类型的全部核心命令,掌握其在缓存、计数器、分布式锁中的应用。
  • 精通 Hash 类型的全部核心命令,掌握其高效存储对象、进行分组统计的技巧。
  • 深刻理解 String 和 Hash 的底层差异与内存效率,能根据场景做出正确选择。
  • 了解生产环境中使用这两种结构时的常见“坑”与最佳实践。

先修要求

本文假设读者已掌握如何使用 redis-py 建立连接(详见系列第一篇)。

关键要点

  1. String 是万金油,可存文本、数字、序列化数据,INCR 命令是原子操作的典范。
  2. Hash 适合存储对象,能单独操作字段,内存效率更高(使用 ziplist 编码时)。
  3. MSET/MGET 和 HMSET(已弃用,用 HSET 替代)/HMGET 是提升批量操作性能的关键。
  4. 选择 String 还是 Hash 存储对象,是一场 序列化开销 vs 字段管理复杂度 的权衡。

背景与原理简述

Redis 提供了五种核心数据结构,本篇聚焦最基础也最常用的两种:String(字符串)  和 Hash(哈希散列)

  • String: 最简单的类型,一个 Key 对应一个 Value。虽然是字符串,但可以存储任何二进制安全的数据,包括图片、序列化后的对象等。它是实现其他复杂功能的基石。
  • Hash: 一个 Key 对应一个 Field-Value 的映射表。非常适合用来存储对象(如用户信息、商品属性),你可以单独获取、更新对象的某个字段,而无需操作整个对象。

理解它们的底层实现和适用场景,是写出高效 Redis 应用的关键。

环境准备与快速上手

假设阅读本篇时你已安装 redis-py 并能够成功连接 Redis 服务器。本篇所有示例将基于以下连接客户端展开:

# filename: setup.py
import os
import redis
from redis import Redis

# 使用连接池创建客户端(推荐方式,详见第一篇文章)
pool = redis.ConnectionPool(
    host=os.getenv('REDIS_HOST', 'localhost'),
    port=int(os.getenv('REDIS_PORT', 6379)),
    password=os.getenv('REDIS_PASSWORD'), # 若无密码可注释此行
    decode_responses=True, # 自动解码,省去 .decode()
    max_connections=10
)
r = Redis(connection_pool=pool)

# 简单的连接测试
assert r.ping() is True
print("连接成功,开始操作 String 和 Hash!")

核心用法与代码示例

String (字符串) 操作

基本操作与应用场景

# filename: string_operations.py
def string_basic_operations():
    """String 基本操作:缓存、存值、取值"""
    # 1. 简单设置与获取 (SET/GET)
    # 应用场景:简单缓存、存储配置项
    r.set('username', 'alice')
    username = r.get('username')  # 返回 'alice' (因为设置了 decode_responses=True)
    print(f"Username: {username}")

    # 2. 设置过期时间 (SETEX)
    # 应用场景:手机验证码、临时会话、限时优惠券
    r.setex('sms_code:13800138000', 300, '123456')  # 300秒后自动过期
    code = r.get('sms_code:13800138000')
    print(f"SMS Code: {code}")
    ttl = r.ttl('sms_code:13800138000')  # 查看剩余生存时间
    print(f"TTL: {ttl} seconds")

    # 3. 仅当键不存在时设置 (SETNX)
    # 应用场景:分布式锁、首次初始化
    success = r.setnx('initialized', 'true')
    if success:
        print("系统初始化标记设置成功!")
    else:
        print("系统已初始化过。")

    # 4. 批量操作 (MSET/MGET) - 大幅减少网络往返
    # 应用场景:批量初始化配置、批量获取用户状态
    r.mset({"config:theme": "dark", "config:language": "zh-CN", "config:notifications": "on"})
    configs = r.mget(["config:theme", "config:language", "config:notifications"])
    print(f"Batch configs: {configs}")  # ['dark', 'zh-CN', 'on']

# 运行示例
string_basic_operations()

数值操作与应用场景

# filename: string_counter.py
def string_counter_operations():
    """String 数值操作:计数器"""
    # 初始化一个计数器
    r.set('page_views', 0)

    # 1. 递增 (INCR/INCRBY)
    # 应用场景:文章阅读量、用户点赞数、秒杀库存
    new_views = r.incr('page_views')  # +1,返回 1
    new_views = r.incr('page_views')  # +1,返回 2
    new_views = r.incrby('page_views', 10)  # +10,返回 12
    print(f"Page views: {new_views}")

    # 2. 递减 (DECR/DECRBY)
    # 应用场景:扣减库存、撤销操作
    stock = r.decrby('product:1001:stock', 5)  # 扣减5个库存
    print(f"Current stock: {stock}")

    # 3. 浮点数操作 (INCRBYFLOAT)
    # 应用场景:金额、分数、权重
    r.set('account:balance', 100.5)
    new_balance = r.incrbyfloat('account:balance', 20.8)  # 增加 20.8
    print(f"New balance: {new_balance}")  # 121.3

# 运行示例
string_counter_operations()

Hash (哈希散列) 操作

基本操作与应用场景

# filename: hash_operations.py
def hash_basic_operations():
    """Hash 基本操作:对象存储"""
    user_id = 1001

    # 1. 设置和获取字段 (HSET/HGET)
    # 应用场景:存储对象属性
    r.hset(f'user:{user_id}', 'name', 'Alice')
    r.hset(f'user:{user_id}', 'email', 'alice@example.com')
    user_name = r.hget(f'user:{user_id}', 'name')
    print(f"User name: {user_name}")

    # 2. 批量设置和获取字段 (HMSET is deprecated, use HSET with mapping)
    # 应用场景:一次性设置或获取对象的所有属性
    user_data = {
        'age': '30', # Note: Hash field values are always strings
        'city': 'Beijing',
        'occupation': 'Engineer'
    }
    r.hset(f'user:{user_id}', mapping=user_data) # 批量设置

    # 批量获取多个字段
    fields = ['name', 'email', 'age', 'city']
    user_info = r.hmget(f'user:{user_id}', fields)
    print(f"User info (list): {user_info}") # ['Alice', 'alice@example.com', '30', 'Beijing']

    # 3. 获取所有字段和值 (HGETALL)
    # 小心使用!如果Hash很大,可能会阻塞服务器或消耗大量网络带宽。
    all_user_data = r.hgetall(f'user:{user_id}')
    print(f"All user data (dict): {all_user_data}") # {'name': 'Alice', 'email': 'alice@example.com', ...}

    # 4. 获取所有字段名或值 (HKEYS/HVALS)
    field_names = r.hkeys(f'user:{user_id}')
    field_values = r.hvals(f'user:{user_id}')
    print(f"Field names: {field_names}")
    print(f"Field values: {field_values}")

    # 5. 判断字段是否存在 (HEXISTS) 和 删除字段 (HDEL)
    if r.hexists(f'user:{user_id}', 'email'):
        print("Email field exists.")
    r.hdel(f'user:{user_id}', 'occupation') # 删除一个字段
    print(f"Fields after deletion: {r.hkeys(f'user:{user_id}')}")

# 运行示例
hash_basic_operations()

数值操作与应用场景

# filename: hash_counter.py
def hash_counter_operations():
    """Hash 字段的数值操作"""
    product_id = 2001
    key = f'product:{product_id}'

    # 初始化
    r.hset(key, 'price', '99.9')
    r.hset(key, 'views', '0')

    # 哈希字段的递增递减 (HINCRBY/HINCRBYFLOAT)
    # 应用场景:商品价格调整、独立计数器(如商品浏览量)
    new_views = r.hincrby(key, 'views', 1) # 整数字段 +1
    new_price = r.hincrbyfloat(key, 'price', -10.5) # 浮点字段 -10.5
    print(f"Product views: {new_views}, New price: {new_price}")

# 运行示例
hash_counter_operations()

性能优化与容量规划

String vs. Hash:如何选择?

存储对象时这是一个常见的设计决策。其实对于 Redis 上的对象存储,更推荐使用 RedisJSON 拓展进行直接存储,当然这不在本篇的讨论范围内,就 String 与 Hash 的选用上,给出参考如下。

使用 String (存储 JSON):

import json
user_data = {'name': 'Alice', 'age': 30, 'city': 'Beijing'}
# 写入
r.set('user:1001', json.dumps(user_data))
# 读取(无法部分更新,必须读取整个对象)
data = json.loads(r.get('user:1001'))
  • 优点: 简单直观,可利用 JSON 的复杂结构。
  • 缺点无法原子性地更新单个字段。每次修改任何属性都需要序列化并写入整个对象,网络和CPU开销大。读取任何属性也需反序列化整个对象。

使用 Hash (存储字段):

# 写入
r.hset('user:1001', mapping={'name': 'Alice', 'age': '30', 'city': 'Beijing'})
# 读取单个字段(高效)
name = r.hget('user:1001', 'name')
# 更新单个字段(原子高效)
r.hset('user:1001', 'age', '31')
  • 优点: 可以原子性地、独立地访问和修改每个字段,非常高效。内存优化更好(使用 ziplist 编码时)。
  • 缺点: 无法直接存储嵌套结构,字段值只能是字符串。

对于需要频繁部分读写、字段较多的扁平化对象(如用户配置、商品属性),Hash 是更优选择。对于读写不频繁或结构复杂嵌套的对象,String + JSON 也是一种可选方案。

内存优化:ziplist 编码

Redis 在存储小的 Hash 时,会使用一种叫 ziplist(压缩列表) 的紧凑编码,这比使用标准的哈希表更节省内存。当以下两个配置阈值被突破时,编码会转换为 hashtable:

  • hash-max-ziplist-entries: Hash 中字段数量的阈值(默认 512)。
  • hash-max-ziplist-value: 每个字段值的最大长度阈值(默认 64 字节)。

最佳实践:根据你的业务数据特点,在 redis.conf 中适当调整这两个参数,可以在内存和性能之间取得更好的平衡。

批量操作

无论是 String 的 MSET/MGET 还是 Hash 的 HMSET(已弃用)/HMGET,批量操作都能极大减少网络往返次数(RTT) ,是提升性能的最有效手段之一。

安全与可靠性

  1. 大 Key 风险: 避免使用一个巨大的 String(通常超过 10KB 被定义为 Big Key)或一个包含成千上万个字段的 Hash。这类 Key 在持久化、迁移、删除时可能会阻塞 Redis 服务。对 Hash,定期检查 HLEN。

  2. 命令复杂度:

    • HGETALL、HKEYS、HVALS 这些 O(n) 复杂度的命令,在 Hash 很大时会非常慢,在生产环境中应谨慎使用。优先使用 HGET 或 HMGET 获取你真正需要的字段。
    • KEYS * 是 O(n) 且会阻塞服务,绝对禁止在生产环境使用。使用 SCAN 命令族进行增量迭代(后续文章会详述)。

常见问题与排错

  • redis.exceptions.DataError: 尝试对非数字值的 String 或 Hash 字段执行 INCR 等操作。确保操作前值是数字或键不存在。

  • 字段值类型错误: Hash 的字段值总是字符串。存储数字后,取回来也是字符串形式(如 '30'),需要客户端自己转换(int()float())。

  • HGETALL 返回类型**: 在 redis-py 中,HGETALL 返回的是一个 Python dict,但在其他一些客户端中可能返回列表。

  • 内存增长过快:

    • 检查是否滥用 String 存储了大对象。
    • 检查 Hash 的字段数量是否过多,考虑是否可用多个 Hash 进行分片。

实战案例/最佳实践

案例:用户会话(Session)存储

# filename: session_manager.py
import uuid
import time

class SessionManager:
    def __init__(self, redis_client):
        self.r = redis_client

    def create_session(self, user_id, user_agent, **extra_data):
        """创建一个新的用户会话(使用Hash存储)"""
        session_id = str(uuid.uuid4())
        session_key = f'session:{session_id}'
        session_data = {
            'user_id': str(user_id),
            'user_agent': user_agent,
            'created_at': str(time.time()),
            'last_activity': str(time.time()),
            **extra_data
        }
        # 使用Hash存储会话数据,并设置30分钟过期
        self.r.hset(session_key, mapping=session_data)
        self.r.expire(session_key, 30 * 60) # 30分钟TTL
        return session_id

    def get_session(self, session_id):
        """获取会话信息(只获取需要的字段,避免使用HGETALL)"""
        session_key = f'session:{session_id}'
        # 高效地获取特定字段,而不是全部
        user_id = self.r.hget(session_key, 'user_id')
        if not user_id:
            return None # Session不存在或已过期

        # 更新最后活动时间
        self.r.hset(session_key, 'last_activity', str(time.time()))
        self.r.expire(session_key, 30 * 60) # 刷新过期时间

        # 按需获取其他字段
        user_agent = self.r.hget(session_key, 'user_agent')
        # ... 获取其他需要的字段
        return {'user_id': user_id, 'user_agent': user_agent}

    def update_session_field(self, session_id, field, value):
        """更新会话的单个字段(Hash的优势)"""
        session_key = f'session:{session_id}'
        self.r.hset(session_key, field, value)
        self.r.expire(session_key, 30 * 60) # 刷新过期时间

# 使用示例
session_mgr = SessionManager(r)
sid = session_mgr.create_session(1001, 'Mozilla/5.0', theme='dark')
session_data = session_mgr.get_session(sid)
print(session_data)

小结

String 和 Hash 是 Redis 最基础、最常用的两种数据结构。String 灵活万能,是缓存和计数器的首选;Hash 字段独立,是存储扁平化对象、实现高效部分更新的最佳选择。

posted @ 2025-11-11 15:27  艾体宝  阅读(0)  评论(0)    收藏  举报