String 类型底层:SDS 动态字符串
Redis 的 String 不是 C 语言的裸字符串,而是自研的 SDS(Simple Dynamic String)。理解 SDS 的设计能帮助你写出内存友好的 Redis 使用方式。
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 切换到 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 操作会失败。
String 的高级应用:位图(Bitmap)
String 类型的每个字节可以被当作位(bit)操作,这使得 Redis 可以用极小的内存实现用户签到、UV 统计等功能。1亿用户的年签到记录只需约 4.4 MB 内存。
# 用户签到:key = sign:{uid}:{year-month},offset = 日期(0-30)
# SETBIT key offset value
SETBIT sign:1001:2024-12 0 1 # 12月1日签到
SETBIT sign:1001:2024-12 1 1 # 12月2日签到
SETBIT sign:1001:2024-12 5 1 # 12月6日签到
# 查询某天是否签到
GETBIT sign:1001:2024-12 0 # 1
GETBIT sign:1001:2024-12 3 # 0(12月4日未签到)
# 统计本月签到天数
BITCOUNT sign:1001:2024-12 # 3
# 获取连续签到 / 第一次未签到的位置
BITPOS sign:1001:2024-12 0 # 第一个未签到的日期(从第0位开始找0)
Hash 的最佳实践:小 Hash 节省内存
当 Hash 的字段数量少于 hash-max-listpack-entries(默认 128)时,Redis 使用 listpack 紧凑编码,内存占用比 hashtable 少 60% 以上。可以利用这一特性设计"分桶 Hash"来存储大量小对象:
# 问题:存储 100 万个用户信息
# 方案1:每个用户独立 String key(100万个key,元数据开销大)
# SET user:1 "{...}" SET user:2 "{...}" ... × 100万
# 方案2:分桶 Hash(将 uid 按 100 分桶,每桶最多 100 个用户)
# key 格式:users:{uid // 100},field:uid % 100
HSET users:0 0 '{"name":"张三"}' # uid=0
HSET users:0 1 '{"name":"李四"}' # uid=1
HSET users:0 99 '{"name":"王五"}' # uid=99
HSET users:1 0 '{"name":"赵六"}' # uid=100
# 每个 Hash 不超过 100 字段,始终保持 listpack 编码
# 内存节省约 50%,但需要在应用层计算分桶 key
String 和 Hash 是 Redis 中使用频率最高的两种数据结构。关键设计原则:
String 适合:缓存单个值、计数器、分布式 ID、位图操作、简单 KV 存储。
Hash 适合:存储对象(用户、商品),需要频繁更新单个字段时比 JSON 字符串高效。
内存意识:始终关注底层编码(listpack vs hashtable/raw),保持对象在 listpack 范围内能节省大量内存。
批量操作:多个 key 的操作优先使用 MSET/MGET 和 HSET 批量设置,减少网络往返次数。