Redis 系列(二):数据结构与命令体系——五大数据类型与扩展结构
核心目标:掌握 String/Hash/List/Set/ZSet 五种核心结构的使用场景、命令复杂度与 redis-py 写法;理解 Bitmap/HyperLogLog/GEO/Stream 四种扩展结构;能根据访问模式选择正确的数据结构,并理解"命令复杂度 = 服务器阻塞风险"。
前置知识:完成 Part 1,理解命令执行链路(单线程事件循环)与键空间概念。
验证环境:Redis 8.10.0(cygwin 移植版,127.0.0.1:6379)、redis-py 8.1.0、Python 3.11.6、Windows 11;实验在 db 15 隔离执行;大数据集实验数据规模为 5 万键/元素。最后复核日期:2026-08-07。
0. 本篇问题场景:三个常见的选择困境
- 商品详情该用 String + JSON,还是 Hash? 缓存一个对象,两种结构都行,差别在哪?
- 排行榜怎么存? 每次查完在 Python 里排序?数据多了怎么办?
- “取所有 key” 为什么是事故源头? 单线程服务器上 O(N) 命令意味着什么?
这三个问题的答案都指向同一件事:数据结构不是 API 细节,而是性能与内存的决策。本篇建立五种核心结构的完整心智模型,再做三组真实实验验证"复杂度即风险",最后用 shop-lab 的购物车与排行榜落地。
1. 五种核心数据结构
先看一张总览表,然后逐个展开:
| 结构 | 底层模型 | 典型场景 | 关键命令(复杂度) |
|---|---|---|---|
| String | 字节串 + 数字语义 | 缓存、计数器、ID、Session | SET/GET/INCR(O(1)) |
| Hash | 字段 → 值 | 对象缓存、购物车 | HSET/HGET/HINCRBY(O(1)) |
| List | 双向链表/列表 | 队列、时间线 | LPUSH/RPOP/LRANGE(头尾 O(1)) |
| Set | 无序集合 | 去重、标签、交集并集 | SADD/SISMEMBER/SINTER(O(1)) |
| ZSet | 有序集合 | 排行榜、延迟队列、滑窗 | ZADD/ZSCORE(O(log N)) |
1.1 String:一切的基础
String 是最简单的结构,但"数字语义"让它不止是缓存:
print(r.set("product:1:name", "机械键盘")) # True
print(r.get("product:1:name")) # 机械键盘
print(r.incr("counter:views:1")) # 1
print(r.incr("counter:views:1")) # 2
print(r.setnx("product:1:name", "x")) # False:已存在不覆盖
print(r.setnx("product:2:name", "鼠标")) # True:不存在才写
print(r.mset({"a": "1", "b": "2"})) # True
print(r.mget("a", "b")) # ['1', '2']
本机真实输出:
True
机械键盘
1
2
False
True
True
['1', '2']
要点:
INCR是原子的(单线程服务器上天然如此),计数器不需要"读-加-写"三步;SETNX只在键不存在时写入——Part 9 分布式锁的地基;MSET/MGET一次往返批量读写多个键;APPEND/SETRANGE等修改操作会把短字符串的 embstr 编码升级成 raw(后面实验会看到)。
1.2 Hash:对象的字段级读写
Hash 把"对象"存成一个键,内部再按字段组织。对比 String + JSON 的差异:
print(r.hset("cart:user:1001", mapping={"sku1": "2", "sku2": "1"}))
print(r.hget("cart:user:1001", "sku1")) # 2:只读一个字段
print(r.hgetall("cart:user:1001")) # {'sku1': '2', 'sku2': '1'}
print(r.hincrby("cart:user:1001", "sku1", 3)) # 5:字段级原子累加
print(r.hlen("cart:user:1001")) # 2
print(r.hdel("cart:user:1001", "sku2")) # 1
2
2
{'sku1': '2', 'sku2': '1'}
5
2
1
String + JSON vs Hash 的取舍:
| 维度 | String + JSON | Hash |
|---|---|---|
| 读整个对象 | 一次 GET,快 | HGETALL,快 |
| 改一个字段 | 整读 → 改 → 整写(竞态风险) | HSET/HINCRBY 直接改,原子 |
| 字段数量 | 无上限 | 过多字段有内存/性能开销 |
| 序列化 | 需要 | 不需要(值本身就是字段) |
| 适用 | 整体读写、结构经常变 | 字段级频繁更新(购物车、计数、状态) |
1.3 List:队列与时间线
List 的头尾操作是 O(1),适合队列与"最新 N 条":
print(r.lpush("feed:user:1001", "post3", "post2", "post1")) # 3
print(r.rpush("feed:user:1001", "post4")) # 4
print(r.lrange("feed:user:1001", 0, -1)) # 全部
print(r.lpop("feed:user:1001")) # post1
print(r.llen("feed:user:1001")) # 3
print(r.lindex("feed:user:1001", 0)) # post2
3
4
['post1', 'post2', 'post3', 'post4']
post1
3
post2
要点:
LPUSH+LPOP/RPOP就是栈/队列;阻塞版BLPOP/BRPOP在空队列时挂起等待,是轻量任务队列的基础(Part 7 用 Stream 讲更完整的形态);LRANGE 0 -1取全量是 O(N)——小列表无所谓,大列表是阻塞隐患(§2 实验);- 中间插入
LINSERT是 O(N),不是它的强项。
1.4 Set:去重与集合运算
Set 提供 O(1) 成员判断和高效的集合运算:
print(r.sadd("tag:python", "a", "b", "c")) # 3
print(r.sadd("tag:web", "b", "d")) # 2
print(r.smembers("tag:python")) # {'c', 'b', 'a'}
print(r.sismember("tag:python", "a")) # 1
print(r.sinter("tag:python", "tag:web")) # {'b'}:交集
print(r.sunion("tag:python", "tag:web")) # {'c', 'd', 'b', 'a'}:并集
print(r.scard("tag:python")) # 3
3
2
{'c', 'b', 'a'}
1
{'b'}
{'c', 'd', 'b', 'a'}
3
场景:标签系统(SINTER 找"同时打了 A、B 标签的文章")、好友关系、去重(SADD 重复成员自动忽略)。
1.5 ZSet:带分数的有序集合
ZSet 是 Set + 排序:每个成员带一个 score,Redis 按 score 维护顺序,支持 O(log N) 的更新与 O(log N+M) 的范围查询:
print(r.zadd("rank:product:sales", {"p1": 100, "p2": 300, "p3": 200}))
print(r.zscore("rank:product:sales", "p2")) # 300.0
print(r.zrange("rank:product:sales", 0, -1, withscores=True)) # 分数升序
print(r.zrevrange("rank:product:sales", 0, -1, withscores=True)) # 降序(TopN)
print(r.zrangebyscore("rank:product:sales", 150, 999, withscores=True)) # 按分区间
print(r.zincrby("rank:product:sales", 50, "p1")) # 150.0
print(r.zrank("rank:product:sales", "p1")) # 0
3
300.0
[('p1', 100.0), ('p3', 200.0), ('p2', 300.0)]
[('p2', 300.0), ('p3', 200.0), ('p1', 100.0)]
[('p3', 200.0), ('p2', 300.0)]
150.0
0
ZREVRANGE 0 N-1 就是排行榜的 TopN,分数更新用 ZINCRBY 原子累加——"销量 +1 后排行榜实时变化"一次往返完成。ZSet 的底层是 skiplist(Part 4 详讲),这就是它"既能精确查分、又能范围遍历"的原因。
2. 命令复杂度与键设计:复杂度即阻塞风险
2.1 第一现场:OBJECT ENCODING 看编码
五种结构在写入后,服务器会为它们选择内部编码。用 OBJECT ENCODING 直接观察(Part 3 会解释每种编码的底层实现):
product:1:name type=string encoding=raw ← 被 APPEND 过,从 embstr 升级
counter:views:1 type=string encoding=int ← 纯数字
cart:user:1001 type=hash encoding=listpack ← 小 Hash
feed:user:1001 type=list encoding=listpack ← 小 List
tag:python type=set encoding=listpack ← 小 Set
rank:product:sales type=zset encoding=listpack ← 小 ZSet
小对象用紧凑的 listpack,数据变大后升级为更通用的结构:
| 结构 | 小数据编码 | 大数据编码 | 升级条件(默认) |
|---|---|---|---|
| String | int / embstr | raw | 长度/内容超出阈值 |
| Hash | listpack | hashtable | 字段 > 512(本机默认)或字段值 > 64 字节 |
| List | listpack | quicklist | 超过 listpack 阈值 |
| Set | intset(纯整数)或 listpack | listpack(小)或 hashtable(大) | 超 512 个或含非整数:小规模转 listpack,大规模转 hashtable |
| ZSet | listpack | skiplist | 成员 > 128 |
实测的升级对照(真实输出):
h:small(10 字段×短值)→ listpack
h:big(200 字段×100 字节)→ hashtable
s:int(100 个整数)→ intset
s:str(100 个字符串)→ listpack
z:small(10 成员)→ listpack
z:big(1000 成员)→ skiplist
注意:OBJECT ENCODING 返回什么,取决于当时的数据规模与 Redis 版本配置。它不是一个"该用哪种结构"的决策依据,而是"当前实际用了哪种实现"的观察手段——Part 3/4 会解释每个编码的内存与性能含义。
2.2 为什么 O(N) 命令在线上危险
Redis 是单线程执行命令。一条 O(N) 命令的耗时 = 服务器阻塞时长 = 所有其他命令的排队时长。用 5 万个键/元素做实测(本机 loopback,中位耗时,3 次取中):
| 实验(5 万数据) | 耗时 | 性质 |
|---|---|---|
KEYS * | 45.80 ms | 单次往返,服务器连续遍历 5 万键,期间全部命令排队 |
SCAN 迭代 | 63.82 ms | 多次往返累加,每次只处理一小批,不阻塞其他命令 |
SMEMBERS(大 Set) | 44.00 ms | 单次返回 5 万元素 |
SSCAN 迭代 | 54.24 ms | 分批返回 |
LRANGE 0 -1(大 List) | 39.58 ms | 单次返回全量 |
LRANGE 分页(100/页×500 次) | 98.97 ms | 多次往返,但单次数据量恒定 |
关键结论不是"SCAN 更快"——实测里 SCAN 累计更慢——而是"SCAN 不危险":
KEYS *的 45ms 是一次性的全局阻塞;数据涨到 500 万键就是 4.5 秒,期间线上所有读写全部卡死;SCAN的 63ms 摊在几十次往返里,每次只阻塞亚毫秒级,其他命令照常执行;LRANGE 0 -1同理:单次阻塞 + 单次传输全量。分页虽然累计往返多,但单次数据量恒定,对服务器和网络都友好。
另一个侧面数据:单条命令的往返成本(RTT)不可忽略——ZADD 单条循环 5 万次共耗时 6.0 秒(纯网络往返主导)。这预告了 Part 7 的 pipeline 的价值。
环境注记:以上耗时来自 Windows 11 本机 loopback + cygwin 移植版,绝对数值只作量级参考;结论(O(N) 阻塞全局、SCAN 分批无害、RTT 累加)与环境和版本无关。
2.3 用 SCAN 族替代 KEYS
# 错误:阻塞全局
keys = r.keys("*")
# 正确:分批迭代,不阻塞
for key in r.scan_iter(match="cart:user:*", count=1000):
...
四种结构的迭代命令一一对应:SCAN(键空间)、HSCAN(Hash 字段)、SSCAN(Set 成员)、ZSCAN(ZSet 成员+分数)。生产代码永远用 SCAN 族,不用 KEYS 族。
2.4 key 命名规范
业务域:对象:属性:标识
cart:user:1001 ← 购物车
rank:product:sales ← 销量榜
checkin:user:1001:202608 ← 签到
规则:小写、冒号分隔、从通用到具体、一个语义一个 key。规范的价值在 Part 8/12 会体现:SCAN 模式匹配、缓存清理、排障时按前缀定位都依赖它。
3. 四种扩展结构
3.1 Bitmap:位图
Bitmap 本质是 String 的位操作视图:一个 8 位字节的每一位都是独立"开关"。1 亿个用户占 1 亿 bit = 12.5 MB,是签到/在线状态/布尔标记的最省内存方案:
for day in (1, 2, 3, 4, 5): # 用户 1001 签到 5 天
r.setbit("checkin:user:1001:202608", day, 1)
print(r.getbit("checkin:user:1001:202608", 3)) # 1:第 3 天签了
print(r.getbit("checkin:user:1001:202608", 6)) # 0:第 6 天没签
print(r.bitcount("checkin:user:1001:202608")) # 5:总签到天数
1
0
5
位运算 BITOP 支持 AND/OR/XOR/NOT,可以跨用户算"共同签到日":
r.setbit("checkin:user:1002:202608", 1, 1)
r.setbit("checkin:user:1002:202608", 3, 1)
r.bitop("AND", "checkin:both:202608",
"checkin:user:1001:202608", "checkin:user:1002:202608")
print(r.bitcount("checkin:both:202608")) # 2:两人都在 1、3 天签到
3.2 HyperLogLog:基数估计
HLL 用 ~12 KB 的固定内存估计任意规模的去重基数,误差约 0.81%(标准偏差)。适合 UV 统计这类"不需要精确值"的场景:
# 模拟 120 万次访问,其中 10 万去重用户
pipe = r.pipeline()
for i in range(1_200_000):
pipe.pfadd("uv:page:home", f"uid{i % 100_000}")
pipe.execute()
print("真实去重用户:", 100_000)
print("PFCOUNT 估计:", r.pfcount("uv:page:home"))
真实去重用户: 100000
PFCOUNT 估计: 101031
误差: 1.03%
要点:HLL 不能读回"都有谁"(PFADD 只进不出),只能问"大约多少个";需要精确明细时用 Set 或专门的去重存储。它和"精确统计"是两种能力,选型看业务是否接受误差。
3.3 GEO:地理坐标
GEO 在底层是 ZSet(成员名编码了经纬度),所以天然支持距离与附近查询:
r.geoadd("geo:stores", (116.397128, 39.916527, "store-1")) # 北京天安门
r.geoadd("geo:stores", (116.407526, 39.904030, "store-2")) # 王府井
r.geoadd("geo:stores", (121.473701, 31.230416, "store-3")) # 上海
r.geoadd("geo:stores", (113.264385, 23.129112, "store-4")) # 广州
print(r.geodist("geo:stores", "store-1", "store-2", unit="km"))
print(r.geosearch("geo:stores", longitude=116.397128, latitude=39.916527,
radius=100, unit="km"))
1.65
['store-2', 'store-1']
天安门与王府井相距 1.65 km;以天安门为圆心 100 km 内只有这两家(注意:GEOSEARCH 默认不排序,返回顺序不代表距离,需要按距离排序要显式传 ASC/DESC)。适合"附近门店/附近的人"。
3.4 Stream:消息流
Stream 是追加式消息流,消息带自动生成的 ID(毫秒时间戳-序号),支持消费组:
r.xadd("stream:orders", {"order_id": "1001", "status": "created"})
r.xadd("stream:orders", {"order_id": "1002", "status": "paid"})
r.xadd("stream:orders", {"order_id": "1003", "status": "shipped"})
print(r.xlen("stream:orders")) # 3
for msg_id, fields in r.xrange("stream:orders", min="-", max="+"):
print(f" {msg_id}: {fields}")
3
1786061023933-0: {'order_id': '1001', 'status': 'created'}
1786061023933-1: {'order_id': '1002', 'status': 'paid'}
1786061023933-2: {'order_id': '1003', 'status': 'shipped'}
Stream 是"队列/延迟任务"的首选结构(相比 List):消息 ID 单调、支持消费组与确认、有 PEL(pending entries)机制。完整的消费组语义、确认与重投放在 Part 7,本篇先建立"消息流 + 消费组"的概念。
4. 序列化与存储选型
同一个对象,不同的存法对应不同的读写模式:
| 方案 | 读 | 改 | 适用 |
|---|---|---|---|
| String + JSON | 一次 GET | 必须整读整写 | 整体展示、结构多变 |
| Hash 字段 | HGETALL | 字段级原子改 | 购物车、状态机、计数 |
| String + msgpack | 一次 GET | 整读整写 | 字节更小、结构固定 |
Python 序列化的安全边界:pickle 可以反序列化任意对象,从不可信来源加载是 RCE 风险。缓存数据只应使用 JSON(可读、跨语言)或 msgpack(紧凑);永远不要 pickle.dumps 用户可控的数据再存进 Redis。
5. shop-lab 实战:购物车与排行榜
新增两个服务(shop-lab/src/shop_lab/cart.py 与 rank.py):
# cart.py:Hash 字段级读写
def add_item(user_id: int, sku_id: str, quantity: int = 1) -> int:
return get_redis().hincrby(f"cart:user:{user_id}", sku_id, quantity)
def get_all(user_id: int) -> dict[str, str]:
return get_redis().hgetall(f"cart:user:{user_id}")
# rank.py:ZSet 排行榜
def record_sales(product_id: str, delta: int = 1) -> float:
return get_redis().zincrby("rank:product:sales", delta, product_id)
def top(n: int = 10) -> list[tuple[str, float]]:
return get_redis().zrevrange("rank:product:sales", 0, n - 1, withscores=True)
top() 的语义值得展开:ZREVRANGE 0 n-1 只取排行榜头部 n 条,不遍历全部——这就是"数据量 10 万时排行榜依然毫秒级"的原因。
配套测试(tests/test_cart.py、tests/test_rank.py)覆盖:加购累加、覆盖写、移除、TopN 排序、名次查询、按分数区间筛选,以及结构断言(type 必须是 hash/zset):
$ cd redis/shop-lab && python -m pytest
................................... [100%]
27 passed in 29.87s
(Part 1 的 17 个 + 本篇新增 10 个。)
6. 失败实验与根因
实验一:对 String 执行各结构命令(WRONGTYPE 全家桶)
| 命令 | 错误 |
|---|---|
LPUSH str_key x | WRONGTYPE Operation against a key holding the wrong kind of value |
HSET str_key f v | 同上 |
SADD str_key x | 同上 |
ZADD str_key 1 x | 同上 |
根因(承接 Part 1):键值在写入时类型即固定,服务器端做类型检查,任何客户端都无法绕过。遇到这类错误先找写入该 key 的代码,而不是换客户端重试。
实验二:KEYS * 的阻塞是真实可测的
§2.2 的 45.80 ms 单次阻塞在 5 万键下"看不出来",但机制上:KEYS 执行期间,其他客户端的命令在排队。放大到生产规模(百万级键)就是秒级停顿。所以"本地没感觉"不能作为线上用它理由。
实验三:大数据量的单条往返成本
5 万次单条 ZADD 耗时 6.0 秒(RTT 主导)——不是 ZADD 慢(O(log N)),是往返次数慢。这是"批量操作必须用 pipeline"(Part 7)的实证。
7. 版本与环境差异
| 差异点 | 官方 7.4 | 本机 8.10.0(cygwin 移植版) | 影响 |
|---|---|---|---|
| String embstr 阈值 | 44 字节(object.c 中 OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44) | 实测 38 字节 | 阈值是"实现细节"不是"规范",不要背死数字,用 OBJECT ENCODING 实测 |
| 小集合编码 | listpack(7.2+ 全面替换 ziplist) | listpack | 一致 |
GEOSEARCH | 6.2+ 提供 | 可用 | 老版本用 GEORADIUS |
| redis-py 8.x | — | geoadd 参数为 (lon, lat, member) 元组或 dict | 按官方签名调用 |
embstr 阈值差异是本篇的意外发现:官方 7.4 源码(_refsrc/redis-7.4.2/src/object.c:101)定义 44,而本机 8.10.0 实测 38 字节即转 raw。它不影响任何正确性,但提醒我们:编码细节是版本相关的,教学与排障都应以当前版本实测为准。
8. 测试与验收
- 每个结构至少一个正向用例与一个结构断言(
r.type()/r.object("ENCODING")); - 大数据集实验保留可复现脚本(写入量、测量方式、环境);
- 验收清单见下。
本篇验收清单:
- 能说出五种结构的典型场景与关键命令复杂度(String/Hash/List/Set O(1),ZSet O(log N));
- 能解释"为什么
KEYS危险而SCAN安全",即使 SCAN 累计耗时更长; - 会用
OBJECT ENCODING观察当前编码,并知道 5 种结构的小/大编码对照; - 会用 Bitmap/HLL/GEO/Stream 各完成一个真实场景(签到/UV/附近/消息流);
-
cd redis/shop-lab && python -m pytest27 passed; - 能画出"访问模式 → 数据结构"的选型决策树(读多写多、字段级、有序、去重、计数……)。
9. 常见误区
- “String 存 JSON 万能,Hash 没必要”——字段级频繁更新时 String 的"整读整写"有竞态与带宽问题,Hash 的字段级原子操作才是正解(§1.2)。
- “SCAN 比 KEYS 快”——不一定(实测 SCAN 累计更慢);正确的说法是"SCAN 不阻塞全局,KEYS 会"(§2.2)。
- “排行榜每查一次在应用层排序”——数据量一大就不可行;ZSet 把排序维护在服务器端,查询与更新都是 O(log N)。
- “HLL 能当精确去重用”——它有 ~1% 误差且不能读回成员;精确场景用 Set 或数据库。
- “小对象编码是固定的”——listpack/intset 是"小数据"的优化,数据增长后自动升级(§2.1),依赖小编码的假设必须验证。
- “
OBJECT ENCODING能看出类型”——不能,它只反映当前实现;类型用TYPE。
10. 本篇小结
回到开篇三个问题:
- String+JSON vs Hash:取决于"改"的频率——整体读写用 String,字段级原子更新用 Hash;
- 排行榜:ZSet 是唯一正解——O(log N) 更新、O(log N+M) TopN、可查名次可筛区间;
- "取所有 key"为什么是事故:
KEYS的 O(N) 在单线程服务器上等于全局阻塞,SCAN 分批迭代才是生产做法。
本篇建立的决策模型:先看访问模式(读/写/改的频率与粒度),再选结构(O(1) 还是 O(log N),是否有序),最后用 OBJECT ENCODING 与实测验证。下一篇 Part 3:底层实现(一) 会打开这些编码的黑盒:RedisObject、SDS 与 dict 的内存布局,回答"10 字节的键为什么占了 100 多字节内存"。
11. 官方资料
- Data types 概览:https://redis.io/docs/latest/develop/data-types/
- Redis Commands:https://redis.io/docs/latest/commands/
- HyperLogLog:https://redis.io/docs/latest/develop/data-types/probabilistic/hyperloglogs/
- GEO:https://redis.io/docs/latest/develop/data-types/geospatial/
- Streams:https://redis.io/docs/latest/develop/data-types/streams/
- SCAN:https://redis.io/docs/latest/commands/scan/
更多推荐
所有评论(0)