Redis 传输协议精讲 / 03 - RESP3 新类型详解
RESP3 新类型详解
3.1 为什么需要 RESP3
RESP2 虽然简洁高效,但存在几个设计缺陷,在大规模应用中逐渐暴露:
RESP2 的痛点
| 问题 | 具体表现 | 影响 |
|---|---|---|
| 类型信息丢失 | HGETALL 返回扁平数组,客户端无法区分 Map 和 List | 客户端需要"硬编码"命令返回类型 |
| NULL 表示不统一 | 只有 $-1\r\n 和 *-1\r\n 两种 NULL | 无法区分"空集合"和"NULL" |
| 缺少布尔类型 | SISMEMBER 返回 0/1,需客户端转换 | 语义不清晰 |
| 缺少浮点数 | ZSCORE 返回字符串 “3.14” | 客户端需要自行解析 |
| 无属性机制 | 服务器无法附带调试/统计信息 | 性能分析困难 |
| 无主动推送 | 服务器无法主动发送消息(除了 Pub/Sub) | 扩展受限 |
RESP3 的设计目标
antirez 在 2019 年提出 RESP3 规范,核心目标:
- 类型自描述:响应本身携带足够的类型信息,客户端无需"猜"
- 语义精确:每种数据类型有独立的表示
- 向后兼容:RESP3 客户端可以与 RESP2 服务器通信(降级)
- 可扩展:通过 Attribute 和 Push 类型支持未来扩展
3.2 版本握手
客户端需要通过 HELLO 命令切换到 RESP3:
→ HELLO 3
← %7
← $6
← server
← $5
← redis
← $7
← version
← $5
← 7.2.4
← $5
← proto
← :3
← $2
← id
← :1
← $4
← mode
← $10
← standalone
← $4
← role
← $6
← master
← $7
← modules
← *0
HELLO 响应是一个 Map(% 前缀),包含服务器信息和协商后的协议版本。
协议降级
如果服务器不支持 RESP3(Redis < 6.0),HELLO 命令会报错,客户端应回退到 RESP2:
def connect_with_fallback(host, port, password=None):
"""连接 Redis,自动协商协议版本"""
sock = socket.create_connection((host, port))
# 先尝试 RESP3
send_command(sock, "HELLO", "3")
resp = read_response(sock)
if isinstance(resp, Exception):
# 服务器不支持 RESP3,回退到 RESP2
send_command(sock, "HELLO", "2")
resp = read_response(sock)
protocol_version = 2
else:
protocol_version = 3
if password:
send_command(sock, "AUTH", password)
read_response(sock)
return sock, protocol_version
3.3 Null(空值)
编码规则
_\r\n
RESP3 用统一的 _ 前缀表示 NULL,解决了 RESP2 中 $-1 和 *-1 的不一致问题。
对比 RESP2
| 场景 | RESP2 | RESP3 |
|---|---|---|
| GET 不存在的 key | $-1\r\n | _\r\n |
| 不存在的 Hash 字段 | $-1\r\n | _\r\n |
| NULL 数组元素 | *-1\r\n | _\r\n |
注意事项
⚠️ 空字符串不是 NULL RESP3 的 NULL(
_\r\n)和空字符串($0\r\n\r\n)语义不同。客户端必须严格区分。
3.4 Boolean(布尔)
编码规则
#t\r\n → true
#f\r\n → false
使用场景
→ SISMEMBER myset element
← #t # RESP3: true(元素存在)
→ EXISTS mykey
← :1 # 注意:EXISTS 仍然返回整数,保持向后兼容
Redis 中返回布尔语义的命令在 RESP3 模式下可能返回 Boolean 类型,但不是所有命令都会自动切换。具体行为取决于 Redis 版本和命令实现。
客户端处理
def handle_boolean(value: str) -> bool:
if value == "t":
return True
elif value == "f":
return False
else:
raise ValueError(f"Invalid boolean: {value}")
3.5 Double(双精度浮点数)
编码规则
,<浮点数>\r\n
使用场景
→ ZSCORE myset member
← ,3.14159
→ INCRBYFLOAT mykey 1.5
← ,2.5
特殊值
,inf\r\n → 正无穷
,-inf\r\n → 负无穷
,nan\r\n → 非数字
对比 RESP2
| 命令 | RESP2 | RESP3 |
|---|---|---|
ZSCORE | $5\r\n3.14159\r\n(字符串) | ,3.14159\r\n(浮点数) |
INCRBYFLOAT | $3\r\n2.5\r\n(字符串) | ,2.5\r\n(浮点数) |
RESP2 中这些值是字符串,客户端需要自行用 atof() 转换。RESP3 直接提供浮点数表示。
3.6 Big Number(大数)
编码规则
(<大整数>\r\n
使用场景
当整数超出 64 位有符号范围时,RESP3 使用 Big Number:
→ INCRBY bigkey 999999999999999999999999999999
← (999999999999999999999999999999
客户端处理
from decimal import Decimal
def handle_big_number(line: bytes) -> Decimal:
"""解析 Big Number,使用 Decimal 避免精度丢失"""
return Decimal(line[1:].decode("utf-8"))
注意:大多数应用中 Big Number 不常见。如果客户端不需要处理超大整数,可以将其作为字符串存储。
3.7 Verbatim String(原样字符串)
编码规则
=<总长度>\r\n<3字符编码>:<内容>\r\n
设计动机
在 RESP2 中,GET key 返回的 Bulk String 不携带编码信息。但某些场景(如 DEBUG OBJECT 或 Lua 脚本返回值)可能需要知道内容的格式。
Verbatim String 在内容前附加 3 个字符的编码提示:
=15\r\ntxt:Hello World\r\n
15:总长度(包含txt:前缀 4 字节 + 内容 11 字节)txt:编码类型(txt表示文本,bin表示二进制)::分隔符Hello World:实际内容
编码类型
| 前缀 | 含义 | 示例 |
|---|---|---|
txt: | 文本内容 | 调试信息、描述 |
bin: | 二进制内容 | 序列化数据 |
客户端处理
def handle_verbatim_string(data: bytes) -> tuple[str, str]:
"""
解析 Verbatim String
返回 (encoding, content)
"""
# data 不包含 = 和长度前缀,如 b"txt:Hello World"
encoding = data[:3].decode()
content = data[4:] # 跳过 "txt:"
return encoding, content
3.8 Map(映射)
编码规则
%<键值对数量>\r\n
<键 1><值 1>
<键 2><值 2>
...
设计动机
RESP2 中 HGETALL 返回扁平数组 ["field1", "value1", "field2", "value2"],客户端必须知道这是 Map 才能正确解析。RESP3 的 Map 类型让响应自描述:
# HGETALL myhash 的 RESP3 响应
%2
$6
field1
$6
value1
$6
field2
$6
value2
与 RESP2 对比
# RESP2: HGETALL myhash
*4
$6
field1
$6
value1
$6
field2
$6
value2
# RESP3: HGETALL myhash
%2
$6
field1
$6
value1
$6
field2
$6
value2
唯一区别是 *4 变成了 %2(元素数量从 4 变为键值对数量 2)。客户端可以直接识别这是 Map,无需依赖命令语义。
嵌套 Map
Map 的键和值可以是任意 RESP3 类型:
%2
$7
user:1
%2
$4
name
$5
Alice
$3
age
$2
30
$7
user:2
%2
$4
name
$3
Bob
$3
age
$2
25
结构化表示:
{
"user:1": {"name": "Alice", "age": 30},
"user:2": {"name": "Bob", "age": 25}
}
3.9 Set(集合)
编码规则
~<元素数量>\r\n
<元素 1>
<元素 2>
...
使用场景
→ SMEMBERS myset
← ~3
← $5
← hello
← $5
← world
← $3
← foo
与 Array 的区别
| 特性 | Array (*) | Set (~) |
|---|---|---|
| 有序性 | 有序 | 无序(语义上) |
| 重复元素 | 允许 | 不允许(语义上) |
| 客户端映射 | 列表/数组 | 集合/哈希集 |
注意:从传输格式看,Set 和 Array 的编码几乎相同,区别主要在语义层面。客户端应根据类型前缀映射到语言中的集合类型(如 Python 的
set)。
3.10 Attribute(属性)
编码规则
|<属性数量>\r\n
<键 1><值 1>
<键 2><值 2>
...
<实际响应>
设计动机
Attribute 允许服务器在返回结果的同时附带元数据,且不影响结果本身。客户端可以选择读取或忽略属性。
使用场景
→ DEBUG SET-ACTIVE-EXPIRE 1
← |1
← $7
← elapsed
← ,0.000123
← +OK
这个响应包含:
- 属性:
elapsed = 0.000123(命令执行耗时) - 实际结果:
+OK
属性消费规则
- 客户端在解析响应时,如果遇到
|前缀,应先读取所有属性键值对 - 属性之后紧跟的才是实际响应
- 如果客户端不支持 Attribute,应忽略整个属性块
def parse_response_with_attributes(reader):
"""解析可能带属性的响应"""
# 预读一个字节判断类型
first_byte = reader.peek_byte()
if first_byte == ord("|"):
# 这是属性,读取并存储
attrs = reader.parse_map()
# 继续读取实际响应
result = reader.parse()
return result, attrs
else:
return reader.parse(), None
3.11 Push(推送)
编码规则
><元素数量>\r\n
<元素 1>
<元素 2>
...
设计动机
RESP2 中,服务器只能在客户端发送命令后才能返回响应。Pub/Sub 消息是唯一的"例外",但实现方式比较 hack——客户端需要在读取响应时特殊处理 Pub/Sub 消息。
Push 类型是 RESP3 中正式引入的"服务器主动推送"机制:
| Push 事件 | 触发条件 |
|---|---|
message | Pub/Sub 收到消息 |
subscribe | 订阅成功确认 |
invalidate | 缓存失效通知(客户端缓存) |
redirection | 集群重定向通知 |
tracking-redir-broken | 追踪重定向失败 |
客户端缓存的 Push 示例
# 客户端启用缓存追踪
→ CLIENT TRACKING ON
← +OK
# 读取数据
→ GET mykey
← $5
← hello
# 另一个客户端修改了 mykey
# 服务器推送失效通知
← |1
← $7
← caching
← #t
← *1
← $5
← mykey
客户端收到 invalidate Push 后,应清除本地缓存中对应的 key。
Push 与 Pub/Sub 的关系
在 RESP3 中,Pub/Sub 消息也通过 Push 类型传输:
→ SUBSCRIBE channel
← >3 ← Push 类型
← $9
← subscribe
← $7
← channel
← :1 ← 当前订阅数
# 收到消息时
← >3
← $7
← message
← $7
← channel
← $5
← hello
3.12 完整类型速查表
| 前缀 | 类型 | RESP2 | RESP3 | 说明 |
|---|---|---|---|---|
+ | Simple String | ✅ | ✅ | 不变 |
- | Error | ✅ | ✅ | 不变 |
: | Integer | ✅ | ✅ | 不变 |
$ | Bulk String | ✅ | ✅ | 不变 |
* | Array | ✅ | ✅ | 不变 |
_ | Null | ❌ | ✅ | 新增 |
# | Boolean | ❌ | ✅ | 新增 |
, | Double | ❌ | ✅ | 新增 |
( | Big Number | ❌ | ✅ | 新增 |
= | Verbatim String | ❌ | ✅ | 新增 |
% | Map | ❌ | ✅ | 新增 |
~ | Set | ❌ | ✅ | 新增 |
| | Attribute | ❌ | ✅ | 新增 |
> | Push | ❌ | ✅ | 新增 |
3.13 RESP3 解析器实现
class RESP3Parser:
"""支持 RESP2 和 RESP3 的混合解析器"""
def __init__(self, sock):
self.sock = sock
self.buffer = b""
def _read_line(self) -> bytes:
while b"\r\n" not in self.buffer:
chunk = self.sock.recv(4096)
if not chunk:
raise ConnectionError("Connection closed")
self.buffer += chunk
pos = self.buffer.index(b"\r\n")
line = self.buffer[:pos]
self.buffer = self.buffer[pos + 2:]
return line
def _read_bytes(self, n: int) -> bytes:
while len(self.buffer) < n:
chunk = self.sock.recv(4096)
if not chunk:
raise ConnectionError("Connection closed")
self.buffer += chunk
result = self.buffer[:n]
self.buffer = self.buffer[n:]
return result
def parse(self):
line = self._read_line()
prefix = chr(line[0])
# RESP2 类型
if prefix == "+":
return line[1:].decode("utf-8")
elif prefix == "-":
raise RedisError(line[1:].decode("utf-8"))
elif prefix == ":":
return int(line[1:])
elif prefix == "$":
length = int(line[1:])
if length == -1:
return None
data = self._read_bytes(length + 2)
return data[:length]
elif prefix == "*":
count = int(line[1:])
if count == -1:
return None
return [self.parse() for _ in range(count)]
# RESP3 新类型
elif prefix == "_":
return None
elif prefix == "#":
return line[1:] == b"t"
elif prefix == ",":
val = line[1:].decode()
if val == "inf": return float("inf")
if val == "-inf": return float("-inf")
if val == "nan": return float("nan")
return float(val)
elif prefix == "(":
return int(line[1:]) # 或使用 Decimal
elif prefix == "=":
length = int(line[1:])
data = self._read_bytes(length + 2)[:length]
encoding = data[:3].decode()
content = data[4:]
return {"encoding": encoding, "content": content}
elif prefix == "%":
count = int(line[1:])
result = {}
for _ in range(count):
key = self.parse()
value = self.parse()
result[key] = value
return result
elif prefix == "~":
count = int(line[1:])
return {self.parse() for _ in range(count)}
elif prefix == "|":
count = int(line[1:])
attrs = {}
for _ in range(count):
key = self.parse()
value = self.parse()
attrs[key] = value
result = self.parse()
return {"_attrs": attrs, "_value": result}
elif prefix == ">":
count = int(line[1:])
return {"_type": "push", "data": [self.parse() for _ in range(count)]}
else:
raise ValueError(f"Unknown RESP type prefix: {prefix}")
3.14 注意事项
⚠️ 并非所有命令都会返回 RESP3 类型 切换到 RESP3 协议后,大多数命令的行为不变。只有部分命令(如
HGETALL、LMPOP等)会利用新类型。具体行为取决于 Redis 版本。
⚠️ 客户端兼容性 并非所有客户端库都支持 RESP3。在选择客户端时,需要确认是否支持
HELLO 3握手和新类型的解析。
⚠️ 代理兼容性 如果 Redis 前面有代理(如 Codis、Twemproxy),代理可能不支持 RESP3 的新类型。升级前需要验证代理的兼容性。
3.15 扩展阅读
| 资源 | 说明 |
|---|---|
| RESP3 规范 | antirez 的原始设计文档 |
| Redis HELLO 命令 | 协议版本切换 |
| 客户端缓存 | Push 类型的核心应用场景 |
| Redis 6.0 Release Notes | RESP3 首次发布 |
上一章:RESP2 格式详解 | 下一章:命令格式与发送