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 的核心优势:
- O(1) 获取长度:直接读
len字段,无需遍历 - 空间预分配:扩容时分配额外空间(<1MB 则翻倍,>1MB 则多分配 1MB),减少重分配次数
- 惰性空间释放:缩短字符串时不立即释放内存,下次扩容时复用
- 二进制安全:
len记录长度,中间可有\0,可存储任意二进制数据(图片、序列化对象)
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 命令速查表
| 命令 | 语法 | 说明 | 时间复杂度 |
|---|---|---|---|
SET | SET key value [EX s] [PX ms] [NX|XX] [GET] | 设置值,支持过期时间和条件写 | O(1) |
GET | GET key | 获取值,不存在返回 nil | O(1) |
MSET | MSET k1 v1 k2 v2 ... | 批量设置 | O(N) |
MGET | MGET k1 k2 ... | 批量获取 | O(N) |
INCR | INCR key | 原子自增 1 | O(1) |
INCRBY | INCRBY key delta | 原子增加指定值 | O(1) |
INCRBYFLOAT | INCRBYFLOAT key delta | 浮点数自增 | O(1) |
SETNX | SETNX key value | 仅当 key 不存在时设置(分布式锁基础) | O(1) |
SETEX | SETEX key seconds value | 设置值同时设置过期秒数 | O(1) |
GETSET | GETSET key value | 设置新值,返回旧值(已废弃,用 SET...GET) | O(1) |
STRLEN | STRLEN key | 返回字符串字节长度 | O(1) |
APPEND | APPEND key value | 追加字符串 | O(1) |
EXPIRE | EXPIRE key seconds | 设置 key 过期时间(秒) | O(1) |
TTL | TTL 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 中的字段数量超过
hash-max-listpack-entries(默认 128) - 任意字段名或字段值的长度超过
hash-max-listpack-value(默认 64 字节)
Hash 命令速查表
| 命令 | 语法 | 说明 |
|---|---|---|
HSET | HSET key field value [field value ...] | 设置一个或多个字段(7.x 统一替代 HMSET) |
HGET | HGET key field | 获取单个字段值 |
HMGET | HMGET key f1 f2 ... | 批量获取多个字段 |
HGETALL | HGETALL key | 获取所有字段和值(大 Hash 慎用) |
HDEL | HDEL key field [field ...] | 删除字段 |
HEXISTS | HEXISTS key field | 字段是否存在 |
HLEN | HLEN key | 字段数量 |
HKEYS | HKEYS key | 所有字段名 |
HVALS | HVALS key | 所有字段值 |
HINCRBY | HINCRBY key field delta | 字段整数自增 |
HSCAN | HSCAN 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 操作会失败。