前言
繼上篇文章,成功連接 Redis 之後,我們直面其核心:數據結構。Redis 的強大,絕非僅是簡單的鍵值存儲,而是其精心設計的多種數據結構所能解決的各種業務場景。
本篇讀者收益
- 精通 String 類型的全部核心命令,掌握其在緩存、計數器、分佈式鎖中的應用。
- 精通 Hash 類型的全部核心命令,掌握其高效存儲對象、進行分組統計的技巧。
- 深刻理解 String 和 Hash 的底層差異與內存效率,能根據場景做出正確選擇。
- 瞭解生產環境中使用這兩種結構時的常見“坑”與最佳實踐。
先修要求
本文假設讀者已掌握如何使用 redis-py 建立連接(詳見系列第一篇)。
關鍵要點
- String 是萬金油,可存文本、數字、序列化數據,INCR 命令是原子操作的典範。
- Hash 適合存儲對象,能單獨操作字段,內存效率更高(使用 ziplist 編碼時)。
- MSET/MGET 和 HMSET(已棄用,用 HSET 替代)/HMGET 是提升批量操作性能的關鍵。
- 選擇 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) ,是提升性能的最有效手段之一。
安全與可靠性
- 大 Key 風險: 避免使用一個巨大的 String(通常超過 10KB 被定義為 Big Key)或一個包含成千上萬個字段的 Hash。這類 Key 在持久化、遷移、刪除時可能會阻塞 Redis 服務。對 Hash,定期檢查 HLEN。
-
命令複雜度:
- 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 字段獨立,是存儲扁平化對象、實現高效部分更新的最佳選擇。