Chapter 02

String 与 Hash:基础数据结构

SDS 动态字符串的底层设计,Hash 的双编码切换机制,用户信息存储实践

String 类型底层:SDS 动态字符串

Redis 的 String 不是 C 语言的裸字符串,而是自研的 SDS(Simple Dynamic String)。理解 SDS 的设计能帮助你写出内存友好的 Redis 使用方式。

C 字符串 vs SDS 结构对比 C 字符串(char*): ┌───┬───┬───┬───┬───┬───┐ │ R │ e │ d │ i │ s │\0 │ ← 遇\0才知道结尾,不安全 └───┴───┴───┴───┴───┴───┘ SDS(sdshdr8 示例): ┌──────┬──────┬──────┬──────────────────────┐ │ len │ alloc│ flags│ buf[] │ │ 5 │ 10 │ 0x01 │ R e d i s \0 ...... │ └──────┴──────┴──────┴──────────────────────┘ ↑已用 ↑总容量 ↑类型 ↑实际字节,末尾\0兼容C

SDS 的核心优势:

String 的三种编码

编码条件内存结构
int值为整数且在 long 范围内直接存储整数值,无 SDS
embstr字符串长度 ≤ 44 字节RedisObject + SDS 连续内存,一次分配
raw字符串长度 > 44 字节RedisObject 和 SDS 分开分配
# 验证编码类型
127.0.0.1:6379> SET age 25
OK
127.0.0.1:6379> OBJECT ENCODING age
"int"

127.0.0.1:6379> SET name "Redis"
OK
127.0.0.1:6379> OBJECT ENCODING name
"embstr"

127.0.0.1:6379> SET longstr "这是一个超过44字节的长字符串,用于测试raw编码"
OK
127.0.0.1:6379> OBJECT ENCODING longstr
"raw"

String 命令速查表

命令语法说明时间复杂度
SETSET key value [EX s] [PX ms] [NX|XX] [GET]设置值,支持过期时间和条件写O(1)
GETGET key获取值,不存在返回 nilO(1)
MSETMSET k1 v1 k2 v2 ...批量设置O(N)
MGETMGET k1 k2 ...批量获取O(N)
INCRINCR key原子自增 1O(1)
INCRBYINCRBY key delta原子增加指定值O(1)
INCRBYFLOATINCRBYFLOAT key delta浮点数自增O(1)
SETNXSETNX key value仅当 key 不存在时设置(分布式锁基础)O(1)
SETEXSETEX key seconds value设置值同时设置过期秒数O(1)
GETSETGETSET key value设置新值,返回旧值(已废弃,用 SET...GET)O(1)
STRLENSTRLEN key返回字符串字节长度O(1)
APPENDAPPEND key value追加字符串O(1)
EXPIREEXPIRE key seconds设置 key 过期时间(秒)O(1)
TTLTTL key查看剩余过期时间,-1永不过期 -2不存在O(1)

Redis 7.x SET 命令增强SET key value GET 可以在设置新值的同时返回旧值,替代原来的 GETSET。SET key value EXAT timestamp 支持设置绝对过期时间戳。

Hash 类型:存储对象的利器

Hash 类型相当于 Java 的 HashMap 或 Python 的 dict,非常适合存储对象(如用户信息、商品属性)。与将整个对象序列化为 String 相比,Hash 的优势在于可以单独读写某个字段,无需反序列化整个对象。

Hash 底层编码:listpack vs hashtable

listpack 编码(小 Hash,内存紧凑) ┌─────────┬─────┬─────────┬───────┬─────────┬───────┬─────┐ │ total │ ... │ field1 │ val1 │ field2 │ val2 │ end │ └─────────┴─────┴─────────┴───────┴─────────┴───────┴─────┘ 连续内存,无指针,CPU 缓存友好,但查找 O(N) hashtable 编码(大 Hash,查找 O(1)) dictht[0]: ┌───────────────────────────────┐ │ slot 0 │ slot 1 │ ... │ slot N │ └──┬─────┴───┬────┴─────┴────────┘ ↓ ↓ [field:val] [field:val] → [field:val] ← 链地址法处理冲突

触发从 listpack 切换到 hashtable 的条件(可通过配置调整):

Hash 命令速查表

命令语法说明
HSETHSET key field value [field value ...]设置一个或多个字段(7.x 统一替代 HMSET)
HGETHGET key field获取单个字段值
HMGETHMGET key f1 f2 ...批量获取多个字段
HGETALLHGETALL key获取所有字段和值(大 Hash 慎用)
HDELHDEL key field [field ...]删除字段
HEXISTSHEXISTS key field字段是否存在
HLENHLEN key字段数量
HKEYSHKEYS key所有字段名
HVALSHVALS key所有字段值
HINCRBYHINCRBY key field delta字段整数自增
HSCANHSCAN key cursor [MATCH p] [COUNT n]游标遍历,替代 HGETALL

实战:用户信息存储

以电商平台的用户信息为例,比较两种存储方案的优劣:

方案一:String(JSON 序列化)

# 存储
SET user:1001 '{"name":"张三","age":25,"email":"zs@example.com"}'
# 读取整个对象才能修改 age
# 每次更新需序列化整个对象

方案二:Hash(字段分离)

# 存储
HSET user:1001 name "张三" age 25 email "zs@example.com"
# 直接更新单个字段
HINCRBY user:1001 age 1
HGET user:1001 name
import redis
from dataclasses import dataclass, asdict
from typing import Optional

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

@dataclass
class User:
    uid: int
    name: str
    age: int
    email: str
    score: float = 0.0

def save_user(user: User) -> None:
    key = f"user:{user.uid}"
    data = {k: str(v) for k, v in asdict(user).items()}
    r.hset(key, mapping=data)
    r.expire(key, 86400)  # 24小时过期

def get_user(uid: int) -> Optional[User]:
    key = f"user:{uid}"
    data = r.hgetall(key)
    if not data:
        return None
    return User(
        uid=int(data['uid']),
        name=data['name'],
        age=int(data['age']),
        email=data['email'],
        score=float(data['score'])
    )

def increment_score(uid: int, delta: float) -> float:
    # 直接操作单个字段,无需读取整个对象
    return float(r.hincrbyfloat(f"user:{uid}", 'score', delta))

# 使用示例
save_user(User(uid=1001, name="张三", age=25, email="zs@example.com"))
user = get_user(1001)
print(user.name)  # 张三
new_score = increment_score(1001, 10.5)
print(new_score)  # 10.5

HGETALL 大 Hash 陷阱:当 Hash 有数万个字段时,HGETALL 会一次性返回所有数据,可能阻塞 Redis 并打爆网络带宽。应使用 HSCAN 分批遍历,或限制每个 Hash 的字段数量,将大对象拆分为多个小 Hash。

计数器模式:INCR 的原子性

INCR 命令是原子操作,在高并发场景(如文章阅读数、点赞数)中,无需担心竞态条件:

# 文章阅读计数(即使万人并发,结果也绝对准确)
INCR article:1001:views
# 用户登录失败计数(防暴力破解)
INCR login:fail:user1001
EXPIRE login:fail:user1001 300  # 5分钟后重置

# 分布式 ID 生成器
INCR global:order:id   # 每次自增生成唯一订单ID

INCR 溢出问题INCR 操作的最大值是 64 位有符号整数(9223372036854775807)。理论上不会溢出,但如果 key 没有设置过期时间且长期累积,需要定期归档。另外,INCRBYFLOAT 的结果存储为 embstr 编码,后续的 INCR 操作会失败。