核心目标:掌握 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. 本篇问题场景:三个常见的选择困境

  1. 商品详情该用 String + JSON,还是 Hash? 缓存一个对象,两种结构都行,差别在哪?
  2. 排行榜怎么存? 每次查完在 Python 里排序?数据多了怎么办?
  3. “取所有 key” 为什么是事故源头? 单线程服务器上 O(N) 命令意味着什么?

这三个问题的答案都指向同一件事:数据结构不是 API 细节,而是性能与内存的决策。本篇建立五种核心结构的完整心智模型,再做三组真实实验验证"复杂度即风险",最后用 shop-lab 的购物车与排行榜落地。


1. 五种核心数据结构

先看一张总览表,然后逐个展开:

结构底层模型典型场景关键命令(复杂度)
String字节串 + 数字语义缓存、计数器、ID、SessionSET/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 + JSONHash
读整个对象一次 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,数据变大后升级为更通用的结构:

结构小数据编码大数据编码升级条件(默认)
Stringint / embstrraw长度/内容超出阈值
Hashlistpackhashtable字段 > 512(本机默认)或字段值 > 64 字节
Listlistpackquicklist超过 listpack 阈值
Setintset(纯整数)或 listpacklistpack(小)或 hashtable(大)超 512 个或含非整数:小规模转 listpack,大规模转 hashtable
ZSetlistpackskiplist成员 > 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.pyrank.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.pytests/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 xWRONGTYPE 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.cOBJ_ENCODING_EMBSTR_SIZE_LIMIT 44实测 38 字节阈值是"实现细节"不是"规范",不要背死数字,用 OBJECT ENCODING 实测
小集合编码listpack(7.2+ 全面替换 ziplist)listpack一致
GEOSEARCH6.2+ 提供可用老版本用 GEORADIUS
redis-py 8.xgeoadd 参数为 (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 pytest 27 passed;
  • 能画出"访问模式 → 数据结构"的选型决策树(读多写多、字段级、有序、去重、计数……)。

9. 常见误区

  1. “String 存 JSON 万能,Hash 没必要”——字段级频繁更新时 String 的"整读整写"有竞态与带宽问题,Hash 的字段级原子操作才是正解(§1.2)。
  2. “SCAN 比 KEYS 快”——不一定(实测 SCAN 累计更慢);正确的说法是"SCAN 不阻塞全局,KEYS 会"(§2.2)。
  3. “排行榜每查一次在应用层排序”——数据量一大就不可行;ZSet 把排序维护在服务器端,查询与更新都是 O(log N)。
  4. “HLL 能当精确去重用”——它有 ~1% 误差且不能读回成员;精确场景用 Set 或数据库。
  5. “小对象编码是固定的”——listpack/intset 是"小数据"的优化,数据增长后自动升级(§2.1),依赖小编码的假设必须验证。
  6. OBJECT ENCODING 能看出类型”——不能,它只反映当前实现;类型用 TYPE

10. 本篇小结

回到开篇三个问题:

  1. String+JSON vs Hash:取决于"改"的频率——整体读写用 String,字段级原子更新用 Hash;
  2. 排行榜:ZSet 是唯一正解——O(log N) 更新、O(log N+M) TopN、可查名次可筛区间;
  3. "取所有 key"为什么是事故KEYS 的 O(N) 在单线程服务器上等于全局阻塞,SCAN 分批迭代才是生产做法。

本篇建立的决策模型:先看访问模式(读/写/改的频率与粒度),再选结构(O(1) 还是 O(log N),是否有序),最后用 OBJECT ENCODING 与实测验证。下一篇 Part 3:底层实现(一) 会打开这些编码的黑盒:RedisObject、SDS 与 dict 的内存布局,回答"10 字节的键为什么占了 100 多字节内存"。


11. 官方资料

更多推荐