博客 / 詳情

返回

艾體寶乾貨 | 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 字段獨立,是存儲扁平化對象、實現高效部分更新的最佳選擇。

user avatar prepared 頭像 pudongping 頭像 yimin333 頭像 xiaoqian01 頭像 software_arch 頭像 jueqiangqingtongsan 頭像 chuck1sn 頭像 ZYPLJ 頭像 1312mn 頭像 skysailstar 頭像 zhuiyi_5e4ea2134d01e 頭像 kuaidi100api 頭像
17 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.