Redis 客户端与服务端通信协议 RESP 详解及 predis 扩展实现原理


上篇教程中,学院君通过字符串键值对 Redis 指令执行的生命周期给大家整体介绍了 Redis 的组织架构和基本实现,从今天开始,学院君将花几个篇幅的教程给大家介绍其中的具体实现细节。

我们从前到后依次展开,首先从客户端和服务端的通信协议 RESP 说起:

1010726-20200408144737602-630529345

所谓通信协议指的是通信双方沟通和数据交换的一组约定,在这里,RESP 协议就是 Redis 客户端与服务端沟通和数据交换的约定格式,作为对比,HTTP 协议就是浏览器(Web 客户端)与 Web 服务器进行沟通和数据交换的约定格式,关于网络协议的细节,请参考网络协议部分教程。

RESP 的全称是 REdis Serialization Protocol,即 Redis 序列化协议,这是一个纯文本协议,有人可能质疑文本协议是否浪费流量、牺牲性能,Redis 的作者认为数据库系统的性能瓶颈并不在网络流量,事实也确实如此,所以这里并没有设计为使用二进制协议。

使用纯文本协议的好处官方文档中已经给出:

  • 实现简单
  • 解析快
  • 可读性好

RESP 协议可以序列化多种数据类型,包括整型、字符串、数组,以及错误等。下面我们就来简单介绍下 Redis 客户端和服务端通信过程中如何基于 RESP 协议解析数据。

RESP 协议简介

在 RESP 协议中,数据类型通过它的第一个字节进行判断,然后每部分都是以 \r\n (CRLF) 结尾的。

单行字符串以 + 符号开头:

+hello xueyuanjun\r\n

多行字符串以 $ 符号开头,后跟字符串长度:

$16\r\nhello xueyuanjun\r\n

整数值以 : 符号开头,后跟整数的字符串形式:

:1024\r\n

错误消息以 - 符号开头:

-ERR syntax error\r\n

数组以 * 号开头,后跟数组的长度:

*3\r\n:1\r\n:2\r\n:3\r\n

空字符串通过长度为 0 的多行字符串表示:

$0\r\n\r\n

NULL 则通过长度为 -1 的多行字符串表示:

$-1\r\n

以上就是 RESP 协议的基本约定,接下来,我们就来看看 Redis 请求和响应是如何通过 RESP 协议来表示的。

客户端向服务端发起请求

Redis 客户端向服务端发送的是一组由执行的命令组成的字符串数组,服务端根据不同的命令回复不同类型的数据。

比如一个简单的字符串键值对设置指令 set author xueyuanjun,会被序列化成如下 RESP 协议文本传输给 Redis 服务端:

*3\r\n$3\r\nset\r\n$6\r\nauthor\r\n$10\r\nxueyuanjun\r\n

可以看到,首先最外层套的是数组一个包含 3 个元素的数组,然后指令的每个部分被拆分为对应的多行字符串元素。

为了方便大家直观地感受这个序列化字符串,我们编写一段 PHP Socket 代码模拟 Redis 服务端接收客户端请求数据:

<?php
// 服务端 IP 和端口号
$host = '127.0.0.1';
$port = 63790;
// 模拟 Redis 服务端 Socket
$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
// 绑定服务端 Socket、地址和端口号
socket_bind($socket, $host, $port);
// 监听客户端请求
socket_listen($socket);
while (true) {
    // 接收到请求后开一个新的子进程处理
    $client = socket_accept($socket);
    $pid = pcntl_fork();
    if (0 === $pid) {
        // 从客户端连接读取请求数据并打印
        while ($message = socket_read($client, 1024, PHP_NORMAL_READ)) { 
            echo $message;
        }
        // 将处理成功消息返回给客户端连接
        $response = "+OK\r\n";
        socket_write($client, $response, strlen($response));
        // 关闭客户端连接
        socket_close($client);
    }
}
// 关闭服务端 Socket
socket_close($socket);

如果你觉得阅读这段代码有点吃力,可以回顾下 Socket 编程教程。

其中这个 PHP 脚本。然后在终端窗口通过 Redis 客户端执行如下指令:

redis-cli -h 127.0.0.1 -p 63790 set author xueyuanjun

这里,需要显式指定服务端 IP 和端口号,然后跟上 Redis 指令,在 PHP Socket 脚本所在终端窗口,可以看到如下输出:

-w651

可以看到和我们预期的数据格式是吻合的,\r\n 会被自动转化为换行符,所以这里看不到。你也可以通过这个脚本窥探其他指令序列化后的数据格式。

服务端返回响应给客户端

前面我们提到 Redis 服务端会根据不同的客户端指令返回不同的响应,比如 set author xueyuanjun 指令执行成功后返回的是单行字符串:

+OK\r\n

而如果试图对字符串键值对应的键名进行自增操作,会返回错误信息:

127.0.0.1:6379> set author xueyuanjun
OK
127.0.0.1:6379> incr author
(error) ERR value is not an integer or out of range

对应的序列化格式是:

-ERR value is not an integer or out of range\r\n

而如果是整型格式键值,或者不存在的键值对,则可以返回整型的自增结果:

127.0.0.1:6379> incr score
(integer) 1

对应的序列化格式是:

:1\r\n

如果视图获取字符串格式的键值,返回的是多行字符串:

127.0.0.1:6379> get author
"xueyuanjun"

对应的序列化格式是:

$10\r\nxueyuanjun\r\n

最后,对于列表、集合、字典类型,返回的通常是数组格式数据:

127.0.0.1:6379> hset post author xueyuanjun views 1000
(integer) 2
127.0.0.1:6379> hgetall post
1) "author"
2) "xueyuanjun"
3) "views"
4) "1000"

你可以按照 RESP 协议数组、字符串、数字格式组装出对应的序列化格式,也可以通过编写 PHP Soket 客户端程序去打印序列化格式响应结果:

<?php
// 创建 Socket
$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);

// 与服务端 Redis 建立连接
socket_connect($socket, '127.0.0.1', 6379);

// 通过客户端发送指令
$command = "*2\r\n$7\r\nhgetall\r\n$4\r\npost\r\n";
socket_write($socket, $command, strlen($command));
echo "> hgetall post\r\n";

// 发送成功则打印响应结果
while ($buffer = socket_read($socket, 1024, PHP_NORMAL_READ)) {
    echo $buffer;
}

socket_close($socket);

启动 redis-server,运行上述 Socket 客户端脚本 php socket_client.php,输出结果如下:

-w609

红框部分就是序列化的响应数组结果了,没处换行替换成 \r\n 就可以得到 RESP 协议中的序列化数据了。

小结

有了上面两段模拟 Redis 服务端和客户端的 Socket 代码,其实任何 Redis 客户端指令和服务端响应数据的序列化都难不倒你了,运行对应的脚本就可以输出相应的 RESP 序列化数据,你也可以以此为雏形去实现类似 predis 这种纯 PHP 代码实现的、与 Redis 服务器交互的 Redis 客户端扩展,其实底层就是通过 RESP 协议封装和解析 Redis 指令和响应数据。

另外,RESP 虽然是为 Redis 通信设计的,但也可以广泛应用于其他客户端与服务端通信场景。


Vote Vote Cancel Collect Collect Cancel

<< 上一篇: 通过 Redis 指令执行的生命周期看 Redis 的底层架构和基本实现

>> 下一篇: 通过非阻塞 IO 和多路复用机制确保 Redis 单线程 IO 模型的高性能