😺

Redis プロトコル実装調査 - RESPの課題と現状について

2024/08/23に公開

Redisは、インメモリのキーバリューストアとして2009年に登場し、現在でも活発に機能拡張が継続されているプロダクトです。特に、2020年に登場したRedis 6系では、Rediの通信プロトコルであるRESP (REdis Serialization Protocol)[1]がRESP 3[2]として刷新され、大幅な機能拡張が実現されました。

本家のRedis自信も進化を続けていますが、最近ではRedisと互換性のあるRESPプロトコルに対応した大規模なRedis互換プロダクトが相次いで登場しています[4][5]。今回は、Redis互換プロダクトの互換性担保の基本ともなる、RESP (REdis Serialization Protocol)[1]の通信プロトコルを中心に、実装上の利点などや課題を踏まえて解説してみます。

RESP (REdis Serialization Protocol)の概要

RESPは「実装が簡単」「解析が高速」「ヒューマンリーダブル」を設計思想[1]とする通信プロトコル仕様です。通信パケットの起点となる共通ヘッダは、メッセージ種別を示す1バイトのみで、CRLFをメッセージの終端とする、非常に単純な仕様です。

項目 データ型 補足
メッセージ識別子 byte<1> '+', '-', ':' など
メッセージ部 - メッセージ種別毎に規定
メッセージ終端 byte<2> CR LF (省略化)

例えば、Redisコマンドが正常に実行されたことを示す応答は、文字列のメッセージ種別を示す「+」から開始され、成功を示すメッセージ文字列である「OK」がCRLFで終端されたものが送信されます。

+OK\r\n

RESPのデータ型は、文字列表現が基本です。RESPでは、数値においても文字列として表現されているため、「ヒューマンリーダブル」の設計思想[1]通りに、簡単に目視できるデバッグしやすい通信プロトコルです。

RESPの課題

RESPは、本家のRedisや近年の相次いで登場している大規模なRedis互換プロダクトの基盤となる通信プロトコルです。今回は、RESP仕様[1][3]の調査や実装[6]してみて気がついた課題を、後のRESP 3設計の動機となった課題[3]と合わせて説明します。

RESP 2によるデータ型の限定性

RESP 2仕様[1]では、以下の5種類のデータ型の規定しかありません。そのため、任意のプリミティブ型やコレクション型を表現するには、実装上、暗黙的な変換や規定が必要となります。

識別子 データ型 書式 備考
+ String 文字列 + CRLF
- Error 文字列 + CRLF
: Integer 数文字列 + CRLF 10進数
$ Bulk String バルク文字長 + CRLF + バルク文字 + CRLF
+ Array 配列数 + CRLF + 配列データ 全データ種別指定可 (配列入れ子含む)

例えば、浮動小数点を表現するには文字列型との、連想配列(マップ)型を表現するには配列型との、暗黙的は変換の規定が必要となります。Redisコマンドの実例としては、複数のキーバリューを設定する、以下のMSETコマンドは、

MSET key1 "Hello" key2 "World"

以下のRESPメッセージに変換されたものが、クライアントからサーバーへ送信されています。

*5\r\n$4\r\mset\r\n$4\r\nkey1\r\n$7\r\n"HELLO"\r\n$4\r\nkey2\r\n$7\r\n"World"\r\n

MSETコマンドは、連想配列(マップ)型をキー値、バリュー値が交互に現れる配列として変換され、クライアントからサーバーへ送信されています。

RESP 3による規定

上記に示した、RESP 2仕様[1]の課題を踏まえて、RESP 3仕様[2]では、以下に示す新しいプリミティブ型やコレクション型が仕様として規定されています。

識別子 データ型 書式 備考
- Null CRLF
, Double 浮動小数表現文字列 + CRLF 10進数表現、infなど
# Boolean (t|f) + CRLF
! Blob Error エラー文字長 + CRLF + エラー文字 + CRLF
= Verbatim String バルク文字長 + CRLF + バルク文字 + CRLF
% Map マップ数 + CRLF + (キーデータ + 値データ)*
~ Set セット数 + CRLF + セットデータ

Redis 6からのRESP 3[3]のみに対応する設計的な方針もありましたが、最終的には、RESP 3はRESP 2の上位互換性を意識した設計となっています。

例えば、RESPで導入されたMap(連想配列)型は、上記のRESP 2のMSETの事例でで暗黙的に配列データ型に変換されたものと同一の表現です。識別子こそ、新たなMap型識別子(%)を与えられましたが、従来のRESP 2で用いられていた配列型によるMap型の書式が踏襲されています。

プロトコルの非効率性

RESP 3仕様[2]は、RESP 2仕様[1]の暗黙的な仕様を明確化した側面があります。ただし、上位互換性を重視した設計のため、従来のRESP 2仕様[1]の欠点も引き継がれています。

RESP 2仕様[1]には、RESP仕様の実装の簡単さと、高速性を示す事例として、以下のC言語によるパース処理の実装例が掲載されています。

#include <stdio.h>

int main(void) {
    unsigned char *p = "$123\r\n";
    int len = 0;

    p++;
    while(*p != '\r') {
        len = (len*10)+(*p - '0');
        p++;
    }

    /* Now p points at '\r', and the len is in bulk_len. */
    printf("%d\n", len);
    return 0;
}

上記事例は、文字列のメッセージ種別を示す「$」から開始された10進数文字列メッセージを、整数に変換する処理を示しています。ただし、パース開始時にはメッセージ部のバイト数が不明なため、1バイトずつ取得して解釈せざる負えず、実行効率や速度面では効率的とは言えません。

実際に、RESPをベースとするRedisサーバーのコマンドは、パース開始時にメッセージ部のバイト数や個数が認識できる、バルク文字列($)や配列(*)を主体として実装されています。例えば、以下のRedisのSETコマンドは、

SET key value

以下のRESPメッセージに変換されたものが、クライアントからサーバーへ送信されています。

*3\r\n$3\r\nset\r\n$3\r\nkey\r\n$5\r\nvalue\r\n

上記の、C言語での実装例にあった文字列ライクなCRLRを終端とする通常文字列($)と異なり、バルク文字列($)はPascal文字列ライクに最初に文字列バイト数が把握できるため一括しての読み込みが可能です。

ただし、バルク文字列($)や配列(*)メッセージ共に、最初の個数を示す数値はCRLFを終端する不定型な文字列表現です。RESP 2仕様[1]には「バイナリプロトコルと同等の性能」との主張もありますが、数値を例えば4バイトで表現するような、純粋なバイナリプロトコルと比較すると、非効率な面は残ります。

最後に

近年、Redisと互換性のあるRESPプロトコルに対応した大規模なRedis互換プロダクトが相次いで登場しています[4][5]。今回の記事も、その登場の背景を調査しながら、Redis互換サーバーをgo-redis[6]として試験的に実装して気がついた点を、いったん整理してまとめて見ました。また、調査や実装を進める中で気がついた点があれば、あらためて整理してみようと思います。

Discussion