Redis 传输协议精讲 / 04 - 命令格式与发送
命令格式与发送
4.1 命令的本质
在 Redis 中,命令就是一个字符串数组。无论命令多复杂,最终都编码为 RESP Array:
*<参数数量>\r\n
$<参数1长度>\r\n<参数1>\r\n
$<参数2长度>\r\n<参数2>\r\n
...
命令解析流程
客户端发送 服务端处理
───────── ──────────
"SET key value"
↓
编码为 RESP Array
↓
*3\r\n$3\r\nSET\r\n... ──→ 读取 Array → 提取参数列表
↓
查找命令处理器
↓
执行命令逻辑
↓
编码响应为 RESP
←── +OK\r\n
4.2 标准命令格式
单键命令
# SET key value
*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n
# GET key
*2\r\n$3\r\nGET\r\n$3\r\nkey\r\n
# DEL key
*2\r\n$3\r\nDEL\r\n$3\r\nkey\r\n
多键命令
# MGET key1 key2 key3
*4\r\n$4\r\nMGET\r\n$4\r\nkey1\r\n$4\r\nkey2\r\n$4\r\nkey3\r\n
# DEL key1 key2 key3
*4\r\n$3\r\nDEL\r\n$4\r\nkey1\r\n$4\r\nkey2\r\n$4\r\nkey3\r\n
带可选参数的命令
# SET key value EX 3600 NX
*6\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n$2\r\nEX\r\n$4\r\n3600\r\n$2\r\nNX\r\n
# KEYS pattern
*2\r\n$4\r\nKEYS\r\n$1\r\n*\r\n
无参数命令
# PING
*1\r\n$4\r\nPING\r\n
# DBSIZE
*1\r\n$6\r\nDBSIZE\r\n
# INFO
*1\r\n$4\r\nINFO\r\n
# INFO server(带子命令)
*2\r\n$4\r\nINFO\r\n$6\r\nserver\r\n
4.3 命令大小写
Redis 命令不区分大小写,但按惯例使用大写:
| 写法 | 是否合法 | 推荐 |
|---|---|---|
SET key value | ✅ | ✅ 推荐 |
set key value | ✅ | ⚠️ 可用但不推荐 |
Set key value | ✅ | ⚠️ 可用但不推荐 |
服务端在查找命令处理器时会将命令名转为大写。但从协议角度,发送什么字节就是什么字节。
4.4 内联命令(Inline Command)
什么是内联命令
除了标准的 RESP 数组格式,Redis 还支持一种简化的"内联"格式。内联命令是用空格分隔的纯文本行:
PING\r\n
SET key value\r\n
GET key\r\n
内联 vs RESP 对比
# 内联格式
SET key value\r\n
# RESP 格式(等价)
*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n
内联命令的检测逻辑
Redis 服务端在接收到数据时,根据第一个字节判断格式:
第一个字节 == '*' → RESP 数组格式
第一个字节 != '*' → 内联命令格式
源码位置(networking.c):
void processInputBuffer(client *c) {
while (c->qb_pos < sdslen(c->querybuf)) {
if (!c->reqtype) {
if (c->querybuf[0] == '*') {
c->reqtype = PROTO_REQ_MULTIBULK;
} else {
c->reqtype = PROTO_REQ_INLINE;
}
}
if (c->reqtype == PROTO_REQ_MULTIBULK) {
if (processMultibulkBuffer(c) != C_OK) break;
} else {
if (processInlineBuffer(c) != C_OK) break;
}
}
}
内联命令的限制
| 特性 | 内联命令 | RESP 命令 |
|---|---|---|
| 参数中的空格 | ❌ 不能包含 | ✅ 可以包含 |
| 二进制数据 | ❌ 不支持 | ✅ 支持 |
| 换行符参数 | ❌ 不支持 | ✅ 支持 |
| 性能 | 略低(需要文本解析) | 更高(长度前缀直接跳转) |
| 适用场景 | 手动调试 | 生产环境 |
telnet 中使用内联命令
$ telnet 127.0.0.1 6379
PING
+PONG
SET mykey hello
+OK
GET mykey
$5
hello
DBSIZE
:1
内联命令在 telnet 调试时非常方便,但生产环境应始终使用 RESP 格式。
4.5 命令别名与子命令
命令别名
某些命令有别名,它们在协议层面上是等价的:
| 别名 | 等价于 | 说明 |
|---|---|---|
DEL | DEL | 删除 key |
UNLINK | DEL(异步) | Redis 4.0+ |
SUBSTR | GETRANGE | 已废弃 |
子命令
某些命令有子命令,在 RESP 中作为独立的参数:
# CLIENT LIST
*2\r\n$6\r\nCLIENT\r\n$4\r\nLIST\r\n
# CLIENT SETNAME myapp
*3\r\n$6\r\nCLIENT\r\n$7\r\nSETNAME\r\n$5\r\nmyapp\r\n
# CONFIG GET maxmemory
*3\r\n$6\r\nCONFIG\r\n$3\r\nGET\r\n$10\r\nmaxmemory\r\n
# ACL WHOAMI
*2\r\n$3\r\nACL\r\n$6\r\nWHOAMI\r\n
子命令在协议层面就是数组中的普通参数,Redis 通过两层分派(主命令 + 子命令)来处理。
4.6 大小写敏感的参数
虽然命令名不区分大小写,但以下参数区分大小写:
| 参数 | 正确 | 错误 |
|---|---|---|
| Key 名称 | myKey | mykey(不同的 key) |
| 字段名 | userName | username(不同的字段) |
| 值 | Hello | hello(不同的值) |
| EX/NX/XX | EX | ex(通常不区分,但建议大写) |
4.7 命令编码实战
Python 实现
def encode_resp_command(*args) -> bytes:
"""
将命令参数编码为 RESP 格式
参数可以是 str 或 bytes:
- str: 自动编码为 UTF-8
- bytes: 直接使用(二进制安全)
"""
parts = []
# 数组头
parts.append(f"*{len(args)}\r\n".encode())
for arg in args:
if isinstance(arg, str):
arg = arg.encode("utf-8")
# 长度前缀 + 内容 + CRLF
parts.append(f"${len(arg)}\r\n".encode())
parts.append(arg)
parts.append(b"\r\n")
return b"".join(parts)
# 测试
assert encode_resp_command("PING") == b"*1\r\n$4\r\nPING\r\n"
assert encode_resp_command("GET", "mykey") == b"*2\r\n$3\r\nGET\r\n$5\r\nmykey\r\n"
assert encode_resp_command("SET", "key", "value") == \
b"*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n"
Go 实现
package resp
import (
"fmt"
"strings"
)
func EncodeCommand(args ...string) string {
var sb strings.Builder
// 数组头
sb.WriteString(fmt.Sprintf("*%d\r\n", len(args)))
for _, arg := range args {
sb.WriteString(fmt.Sprintf("$%d\r\n", len(arg)))
sb.WriteString(arg)
sb.WriteString("\r\n")
}
return sb.String()
}
// 二进制安全版本
func EncodeCommandBytes(args ...[]byte) []byte {
parts := [][]byte{
[]byte(fmt.Sprintf("*%d\r\n", len(args))),
}
for _, arg := range args {
parts = append(parts, []byte(fmt.Sprintf("$%d\r\n", len(arg))))
parts = append(parts, arg)
parts = append(parts, []byte("\r\n"))
}
return bytes.Join(parts, nil)
}
Rust 实现
fn encode_command(args: &[&[u8]]) -> Vec<u8> {
let mut buf = Vec::new();
// 数组头
buf.extend_from_slice(format!("*{}\r\n", args.len()).as_bytes());
for arg in args {
buf.extend_from_slice(format!("${}\r\n", arg.len()).as_bytes());
buf.extend_from_slice(arg);
buf.extend_from_slice(b"\r\n");
}
buf
}
fn main() {
let cmd = encode_command(&[b"SET", b"key", b"value"]);
assert_eq!(cmd, b"*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n");
}
4.8 二进制安全验证
RESP 的 Bulk String 支持任意二进制数据。让我们验证这个特性:
import socket
def send_raw(sock, data):
"""发送原始 RESP 数据"""
sock.sendall(data)
def recv_all(sock):
"""接收所有可用数据"""
chunks = []
while True:
chunk = sock.recv(4096)
if not chunk:
break
chunks.append(chunk)
return b"".join(chunks)
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("127.0.0.1", 6379))
# 设置一个包含特殊字节的值
# 值包含:null 字节(0x00)、换行(0x0A)、回车(0x0D)、高位字节(0xFF)
value = b"\x00\x0A\x0D\xFF\x00hello\r\nworld\x00"
cmd = b"*3\r\n$3\r\nSET\r\n$7\r\nbinkey\r\n"
cmd += f"${len(value)}\r\n".encode()
cmd += value + b"\r\n"
send_raw(s, cmd)
resp = s.recv(1024)
print(f"SET response: {resp}") # +OK
# 读取回来
s.sendall(b"*2\r\n$3\r\nGET\r\n$7\r\nbinkey\r\n")
resp = s.recv(1024)
print(f"GET response: {resp}")
# $17\r\n\x00\n\r\xff\x00hello\r\nworld\x00\r\n
s.close()
验证结果:值中的 \r\n、\x00、\xFF 等特殊字节都能正确存储和读取,证明了 RESP 的二进制安全性。
4.9 命令执行流程(服务端视角)
1. 接收 TCP 数据 → processInputBuffer()
2. 判断格式(RESP / Inline)
3. 解析为参数数组 → argv[], argc
4. 查找命令 → lookupCommand(argv[0])
5. 权限检查 → ACL 检查
6. 参数校验 → 验证参数数量、类型
7. 执行命令 → cmd->proc(c)
8. 传播命令 → propagate()(用于主从复制和 AOF)
9. 返回响应 → addReply*()
命令传播
命令在执行后可能需要传播到:
- AOF:持久化日志
- 从节点:主从复制
- Sentinel:监控通知
传播的格式与原始 RESP 命令相同。这就是为什么 RESP 协议的设计如此重要——它不仅是网络传输格式,也是持久化和复制的格式。
4.10 特殊命令格式
SELECT(切换数据库)
*2\r\n$6\r\nSELECT\r\n$1\r\n0\r\n
AUTH(认证)
# 单参数(Redis < 6.0)
*2\r\n$4\r\nAUTH\r\n$8\r\npassword\r\n
# 双参数(Redis 6.0+,支持 ACL)
*3\r\n$4\r\nAUTH\r\n$8\r\nusername\r\n$8\r\npassword\r\n
MULTI/EXEC(事务)
*1\r\n$5\r\nMULTI\r\n
+QUEUED
*2\r\n$3\r\nSET\r\n$4\r\nkey1\r\n$6\r\nvalue1\r\n
+QUEUED
*2\r\n$3\r\nSET\r\n$4\r\nkey2\r\n$6\r\nvalue2\r\n
+QUEUED
*1\r\n$4\r\nEXEC\r\n
*2
+OK
+OK
EVAL(Lua 脚本)
# EVAL "return 1" 0
*3\r\n$4\r\nEVAL\r\n$9\r\nreturn 1\r\n$1\r\n0\r\n
# EVAL "return redis.call('GET', KEYS[1])" 1 mykey
*4\r\n$4\r\nEVAL\r\n$38\r\nreturn redis.call('GET', KEYS[1])\r\n$1\r\n1\r\n$5\r\nmykey\r\n
4.11 注意事项
⚠️ 命令名是第一个参数 RESP 数组的第一个元素是命令名,其余是参数。
*3\r\n表示有 3 个参数(1 个命令名 + 2 个参数)。
⚠️ 不要手动拼接 RESP 字符串 手动拼接容易出错(如长度计算、CRLF 遗漏)。建议使用经过测试的编码函数。
⚠️ 内联命令不支持二进制数据 如果参数包含空格、换行或二进制字节,必须使用 RESP 格式。
⚠️ 命令长度限制 默认情况下,Redis 限制单个命令的大小为
proto-max-bulk-len(默认 512MB)。超过此限制会返回错误。
4.12 扩展阅读
| 资源 | 说明 |
|---|---|
| Redis 命令参考 | 所有命令的参数和返回值 |
| Redis 源码 server.c | 命令表定义 |
| Redis 源码 networking.c | 协议解析核心 |
上一章:RESP3 新类型 | 下一章:Pipeline 管道机制