mini-redis项目之Resp协议

目录

[一、string_view(C++17 核心特性)](#一、string_view(C++17 核心特性))

[二、optional(C++17 核心特性)](#二、optional(C++17 核心特性))

[三、enum class](#三、enum class)

四、form_chars

[五、find vs memchr](#五、find vs memchr)

六、逗号表达式

七、switch语句

八、append函数

九、协议中出现非法字符处理


【高阶篇】3.1 Redis协议(RESP )详解_redis resp-CSDN博客

这篇文章是别的作者写的详细resp协议的解析

枚举类型 前缀 格式示例 字段含义 + 解析规则
kSimpleString + +OK\r\n 简单字符串 :1. 前缀 + 表示类型;2. 后面跟「文本内容 + \r\n」;3. 用于 Redis 简单响应(如 OKPONG);4. 解析:截取 + 后、\r\n 前的内容(示例中是 OK)。
kError - -ERR wrong password\r\n 错误信息 :1. 前缀 - 表示类型;2. 格式和简单字符串一致,内容是错误描述;3. 解析:截取 - 后、\r\n 前的内容(示例中是 ERR wrong password)。
kInteger : :10086\r\n 整数 :1. 前缀 : 表示类型;2. 后面跟「十进制整数 + \r\n」;3. 用于 Redis 数值响应(如 LLEN 返回列表长度、INCR 返回结果);4. 解析:截取 : 后、\r\n 前的字符串,转成 int64_t(示例中是 10086)。
kBulkString $ $5\r\nhello\r\n 批量字符串(二进制安全) :1. 前缀 $ 表示类型;2. $ 后是「字符串长度 + \r\n」;3. 再跟「字符串内容 + \r\n」;4. 二进制安全:可存储任意字节(包括 \0、换行符);5. 空批量字符串:$0\r\n\r\n;6. 不存在的批量字符串:$-1\r\n(对应你的 kNull);7. 解析:先读长度(示例中 5),再读对应长度的内容(示例中 hello)。
kArray * *2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n 数组 :1. 前缀 * 表示类型;2. * 后是「数组元素个数 + \r\n」;3. 后续依次跟每个元素的完整 RESP 格式;4. 空数组:*0\r\n;5. 不存在的数组:*-1\r\n(对应你的 kNull);6. 解析:先读元素个数(示例中 2),再循环解析 2 个元素($3\r\nfoo\r\n$3\r\nbar\r\n)。
kNull - $-1\r\n / *-1\r\n 空值 :1. 无专属前缀,是批量字符串 / 数组的 "特殊值";2. $-1\r\n = 空批量字符串(Redis 中表示 nil);3. *-1\r\n = 空数组;4. 解析:遇到 $-1\r\n*-1\r\n 时,将 RespValuetype 设为 kNull

一、string_view(C++17 核心特性)

std::string_view 是 C++17 引入的轻量级字符串视图 ,核心作用是「只读、零拷贝访问字符串数据」,是 Mini-Redis 协议解析 / 序列化中提升性能的关键。

为什么是采用view这个单词,这个单词的本质是视图,也就是它是某个字符串的视图,也就是这个关键字定义的变量仅仅只能读

string_view 的 "零拷贝" 体现在函数入参传递阶段,而非 "最终存储阶段"------ 传递时不拷贝,只有当需要把数据存到自己的缓冲区时,才做必要的拷贝。

也就是你可以使用这个关键字去定义这个参数,比如string_view data;

如果你想要里面的原字符串的内容,你可以复制出来的

  1. 核心本质

可以把 string_view 理解为 "字符串的只读指针 + 长度"------ 它不持有字符串的内存,只是指向已有字符串(std::string/char 数组 / 字面量)的一段区间,因此:

  • ❌ 不分配内存,不拷贝数据;

  • ✅ 只读,无法修改原字符串;

  • ✅ 开销极低(仅占 16 字节:64 位系统下,指针 8 字节 + 长度 8 字节)。

  1. 为什么 Mini-Redis 中必须用?

在你的 RespParser::appendRespSerializer::SimpleString 等函数中,string_view 解决了频繁字符串拷贝的性能问题:

  • 场景 1:网络读取的字节流传递给 append 时,用 string_view 直接引用缓冲区,无需拷贝到 std::string

  • 场景 2:序列化时接收业务层传入的字符串(如 "OK"),用 string_view 避免不必要的拷贝;

  • 场景 3:解析协议时截取子串(如从 $5\r\nhello\r\n 中截取 "hello"),用 string_view 仅记录起始位置和长度,零拷贝。

  1. 关键用法对比(string vs string_view)

表格

操作 std::string std::string_view
内存 持有数据,拷贝时复制整个字符串 不持有数据,仅引用,拷贝成本极低
修改 可修改内容(如 s[0] = 'a' 只读,仅能访问内容
构造 std::string s = "hello"(分配内存) std::string_view sv = "hello"(仅记录指针 + 长度)
转换 可直接转 string_viewstring_view sv = s string 需显式:std::string s = sv(此时才拷贝)
  1. Mini-Redis 中的典型用法
cpp 复制代码
// 1. append 接收网络字节流:零拷贝引用,避免拷贝
void RespParser::append(std::string_view data) {
  buffer_.append(data.data(), data.size());  // 仅把数据追加到缓冲区,data 本身不拷贝
}

// 2. 序列化函数入参:接收任意字符串类型,零拷贝
static std::string SimpleString(std::string_view s) {
  return RespSerializer{}.serialize(RespValue{RespType::kSimpleString, std::string(s)});
  // 仅在构造 RespValue 时才拷贝,入参阶段无开销
}

string_view vs string& 核心差异

表格

维度 std::string&/const std::string& std::string_view
支持的数据源 std::string(const 版可隐式接收 const char*,但触发拷贝) 所有字符串类型(string/const char*/char []/ 子串),无拷贝
入参传递开销 拷贝 string 对象(或隐式构造临时 string 触发拷贝) 拷贝 16 字节(指针 + 长度),零开销
语义 可修改(非 const)/ 只读(const),但绑定 std::string 天生只读,仅 "视图",不绑定任何字符串类型
安全性 非 const 版可能误修改原数据;const 版有隐式拷贝风险 只读,无修改风险;无隐式拷贝,更安全

由于你的网络数据是多样的,有时候避免不了隐式类型转换,string_view天然可以接收任意的类型,那会减少隐式类型转换带来的拷贝问题,提高性能


二、optional(C++17 核心特性)

std::optional 是 C++17 引入的可选值容器 ,核心作用是「优雅、安全地表示「一个值可能存在,也可能不存在」的场景,替代传统的 "魔法值""空指针" 等不规范、易出错的写法。」,是 Mini-Redis 流式解析的核心设计。

  1. 核心本质

optional<T> 可以看作 "T 类型的值 + 一个布尔标记":

  • 标记为 true:持有一个有效的 T 类型值;

  • 标记为 false:无值(用 std::nullopt 表示);

它解决了传统 C++ 中 "用特殊值表示无结果" 的弊端(比如用 -1 表示整数不存在、用空指针表示字符串不存在),语义更清晰,避免歧义。

  1. 为什么 Mini-Redis 中必须用?

RespParser::tryParseOne 中,optional 完美适配「流式解析」的场景:

  • 场景 1:解析成功 → 返回 optional<RespValue> 持有解析后的 RespValue

  • 场景 2:数据不完整(如只收到 $5\r\nhe)→ 返回 std::nullopt,表示 "暂时解析不出完整值,需等待更多数据";

  • 场景 3:解析出错 → 返回 RespValue{type=kError, bulk="错误信息"}(仍持有值,只是类型为错误);

  1. 关键用法
操作 示例 说明
创建有值对象 std::optional<int> opt = 10; 持有值 10
创建无值对象 std::optional<int> opt = std::nullopt; 无值
判断是否有值 if (opt.has_value()) { ... }if (opt) { ... } 布尔判断
获取值 int val = opt.value();int val = *opt; 无值时 value() 抛异常,*opt 未定义行为
安全获取值 int val = opt.value_or(0); 无值时返回默认值 0
  1. Mini-Redis 中的典型用法
cpp 复制代码
// 核心:尝试解析一个完整的 RESP 值,值可能不存在(数据不完整)
std::optional<RespValue> RespParser::tryParseOne() {
  size_t pos = 0;
  RespValue value;

  // 1. 解析简单字符串(示例)
  if (buffer_.empty()) {
    return std::nullopt;  // 无数据,返回空
  }

  char first = buffer_[0];
  if (first == '+') {
    if (parseSimple(pos, RespType::kSimpleString, value)) {
      // 解析成功,返回持有值的 optional
      buffer_.erase(0, pos);  // 移除已解析的字节
      return value;
    } else {
      return std::nullopt;  // 数据不完整,返回空
    }
  }

  // 2. 解析出错,返回错误值(仍持有值,类型为 kError)
  value.type = RespType::kError;
  value.bulk = "unknown resp type";
  return value;
}

// 调用方使用:语义清晰
auto opt_val = parser.tryParseOne();
if (opt_val) {  // 有值(解析成功/解析出错)
  RespValue val = *opt_val;
  if (val.type == RespType::kError) {
    // 处理错误
  } else {
    // 处理正常值
  }
} else {
  // 数据不完整,等待更多字节
}
  1. 对比传统写法(为什么 optional 更优)
cpp 复制代码
// 传统写法:用 nullptr 表示无值,语义模糊(空指针也可能是解析出错)
RespValue* tryParseOne() {
  if (数据不完整) return nullptr;
  if (解析出错) return new RespValue{kError, "错误"};
  return new RespValue{...};
}

// optional 写法:语义明确,无内存泄漏风险
std::optional<RespValue> tryParseOne() {
  if (数据不完整) return std::nullopt;
  if (解析出错) return RespValue{kError, "错误"};
  return RespValue{...};
}
问题类型 传统写法(魔法值 / 指针 / 输出参数) std::optional 解决方案
语义模糊 用特殊值(如 kNull)表示 "无值",和合法值冲突 nullopt 明确表示 "无值",和 "有值(包括错误值)" 严格区分
内存安全 动态分配指针易泄漏,空指针解引用崩溃 值存储在栈上,无需手动释放,判空后访问安全
代码简洁性 需额外参数 / 返回值,代码冗余 单一返回值封装 "存在性 + 数据",代码直观
可维护性 新人易误解 "魔法值" 含义,易漏写内存释放 语义自解释,nullopt/has_value() 一看就懂

三、enum class

enum class 是 C++11 引入的强类型枚举 (也叫 "枚举类"),相比传统 enum(弱类型枚举),加 class 的核心目的是:解决传统枚举的 "命名污染" 和 "类型不安全" 问题,让代码更规范、更易维护(尤其适合 Mini-Redis 这类工程化项目)。

  • 枚举类型的常量值 (如 RespType::kError):存储在只读数据段(.rodata)(全局只读,程序启动时加载,退出时释放);
  • 枚举类型的变量 (如 RespType type = RespType::kError):存储位置由变量的作用域 / 生命周期决定(栈、堆、全局 / 静态数据段都可能)。
    先看传统 enum 的坑(为什么需要 class

如果去掉 class 写成普通枚举:

cpp 复制代码
// 传统弱类型枚举
enum RespType {
  kSimpleString,
  kError,
  kInteger,
  kBulkString,
  kArray,
  kNull
};

// 坑1:命名污染(枚举值暴露在全局作用域)
// 假设你还有另一个枚举:
enum LogType {
  kError, // 编译报错!和 RespType::kError 重名,全局作用域冲突
  kInfo
};

// 坑2:类型不安全(枚举值可隐式转换为整数)
RespType type = kSimpleString;
if (type == 0) { // 编译通过!但语义模糊,0 是 kSimpleString 的底层值,易出错
  // ...
}

int num = kError; // 编译通过!枚举值隐式转 int,失去类型校验

enum class 的核心优势(加 class 的原因)

  1. 作用域隔离(解决命名污染)

enum class 的枚举值不会暴露到全局作用域 ,必须通过 枚举类名::枚举值 访问,避免命名冲突:

cpp 复制代码
enum class RespType {
  kSimpleString,
  kError,
  kInteger,
  kBulkString,
  kArray,
  kNull
};

enum class LogType {
  kError, // 编译通过!RespType::kError 和 LogType::kError 属于不同作用域
  kInfo
};

// 正确使用方式(必须加作用域)
RespType resp_type = RespType::kSimpleString;
LogType log_type = LogType::kError;
  1. 强类型校验(解决类型不安全)

enum class 的枚举值不能隐式转换为整数,也不能和整数 / 其他枚举类型比较,强制类型安全:

cpp 复制代码
RespType type = RespType::kSimpleString;

// 错误1:枚举值不能隐式转 int
int num = RespType::kError; // 编译报错!必须显式转换:static_cast<int>(RespType::kError)

// 错误2:枚举值不能和整数比较
if (type == 0) { // 编译报错!语义更严谨,避免误判
  // ...
}

// 错误3:不同枚举类不能比较
if (RespType::kError == LogType::kError) { // 编译报错!避免跨枚举类的无效比较
  // ...
}
  1. 底层类型可显式指定(可选,更灵活)

enum class 可以指定底层存储类型(比如 int/uint8_t),而传统 enum 的底层类型由编译器决定,可能不一致:

cpp 复制代码
// 指定 RespType 底层用 uint8_t 存储(节省内存)
enum class RespType : uint8_t {
  kSimpleString,
  kError,
  kInteger,
  kBulkString,
  kArray,
  kNull
};

四、form_chars

std::from_chars 是 C++17 标准引入的高性能、轻量级数值转换函数 ,专为「字符序列 ↔ 数值类型」的直接转换设计,解决了传统 stoll/strtoll 异常开销大、校验不严格的问题,是网络协议解析(如你的 Mini-Redis)、高性能计算等场景的最优选择。

  1. 函数原型(最常用)
cpp 复制代码
#include <charconv> // 必须包含此头文件!

// 整数转换:char序列 → int64_t/uint64_t/int等
std::from_chars_result from_chars(
    const char* first,    // 待解析字符起始地址(左闭)
    const char* last,     // 待解析字符结束地址(右开)
    T& value,             // 输出:转换后的数值
    int base = 10         // 进制(2-36,默认10)
);

// 返回值结构体(结构化绑定常用)
struct from_chars_result {
    const char* ptr;  // 第一个未解析的字符指针
    std::errc ec;     // 错误码(std::errc() 表示无错误)
};
  1. 基础示例
cpp 复制代码
#include <charconv>
#include <cstdint>
#include <string>
#include <system_error>

bool parseInteger(const std::string& num_str, int64_t& out_val) {
    const char* first = num_str.data();
    const char* last = num_str.data() + num_str.size();
    int64_t val = 0;

    // 核心调用:解析十进制整数
    auto [ptr, ec] = std::from_chars(first, last, val);

    // 错误判断 + 严格校验(无多余字符)
    if (ec != std::errc() || ptr != last) {
        return false;
    }
    out_val = val;
    return true;
}

// 调用示例
int main() {
    int64_t val;
    // 合法场景:返回true,val=10086
    if (parseInteger("10086", val)) { /* 处理成功 */ }
    
    // 非法场景1:含多余字符 → 返回false
    parseInteger("10086a", val);
    // 非法场景2:非数字 → 返回false
    parseInteger("abc", val);
    // 非法场景3:超出范围 → 返回false
    parseInteger("9999999999999999999", val);
    return 0;
}
  1. 错误码(std::errc)说明
错误码 含义 触发场景
std::errc() 无错误 解析成功
std::errc::invalid_argument 无效参数 非数字字符、进制非法(如 1)
std::errc::result_out_of_range 数值超出类型范围 如 "9999999999999999999" 转 int64_t

五、find vs memchr

处理字符类型的数据的时候,能够调用c函数就调用c函数,c++很多都会做封装,性能会有点差异

  • 底层性能关键模块(协议解析、内存操作):用 C 风格实现(裸指针 + 系统库函数),追求极致性能;
  • 上层业务逻辑(命令处理、数据结构封装):用 C++ 特性(容器、RAII、optional),提升开发效率;
  • 核心原则:Redis 也是 "底层纯 C 保性能,上层按需用 C++ 提效率"
差异维度 你的 std::string::find 版本 Redis 指针 +memchr 版本 性能影响(量化)
1. 函数封装层级 调用 std::string::find(C++ 标准库封装) 直接调用 memchr(系统级 C 库函数) string::findmemchr 多 1-2 层封装,单次调用耗时高~20%
2. 内存访问方式 隐式转换:string 内部维护 char*find 需先获取指针 + 校验长度 直接操作原始 char*,无中间转换 减少 1-2 次内存地址跳转,缓存命中率提升~15%
3. 查找逻辑复杂度 string::find("\r\n") 是 "多字符匹配"(需同时找 \r+\n memchr 是 "单字符查找"(只找 \r,再校验 \n 单字符查找是硬件级优化,耗时仅为多字符的 1/3
4. 边界校验冗余 string::find 内部会重复校验 pos < size()(和你的代码重复) 仅一次边界校验,无冗余 减少~5% 的指令执行数

六、逗号表达式

逗号表达式是 C/C++ 中最基础的语法之一,核心是用逗号 , 分隔多个表达式,按「从左到右」顺序执行,最终整个表达式的结果 = 最后一个表达式的值

  1. 最简语法格式
cpp 复制代码
表达式1, 表达式2, 表达式3, ..., 表达式N;
  • 执行顺序:先算 表达式1 → 再算 表达式2 → ... → 最后算 表达式N
  • 最终值:整个逗号表达式的结果 = 表达式N 的值;
  • 注意:逗号在「表达式场景」才是逗号表达式,在「参数分隔 / 变量声明」中只是普通分隔符(比如 func(a,b) 里的逗号不是逗号表达式)。

七、switch语句

cpp 复制代码
switch (表达式) {  // 表达式必须是「整型/字符型/枚举型」)
    case 常量1:    // 常量必须和表达式类型匹配(如 '+'/':'/'$' 都是 char 常量)
        // 分支1逻辑
        break;     // 可选:跳出 switch,不执行后续分支
    case 常量2:
        // 分支2逻辑
        break;
    default:       // 可选:所有 case 都不匹配时执行
        // 默认逻辑
        break;
}
  • 表达式类型限制 :只能是「整数类型」(int/long)、「字符类型」(char)、「枚举类型」(enum)------ 你的场景中 prefixchar,完全符合;
  • case 必须是常量 :不能是变量(比如 case var: 会报错),你的场景中 '+'/':' 都是字符常量,合法;
  • break 的作用:没有 break 会触发「贯穿效应」(执行完当前 case 后,继续执行下一个 case);
  • default 可选 :所有 case 都不匹配时执行(比如场景中遇到非法前缀 #/@)。

八、append函数

std::string::append 是 C++ 字符串拼接中性能天花板级别的接口 ,核心优势是:批量写入 + 零临时对象 + 原生支持 string_view

方法 核心功能 临时对象 性能 适用场景
append 批量追加字符 / 字符串 极高 所有批量拼接场景(推荐)
operator+= 追加单个字符 / 字符串 可能有 简单拼接(少量字符)
push_back 追加单个字符 单字符追加
std::string 构造 拼接多个字符串(如 a+b+c 有(多个) 简单、非性能敏感场景
std::ostringstream 流式拼接(支持格式化) 需要格式化的复杂拼接
std::format(C++20) 格式化拼接 需要格式化的场景

九、协议中出现非法字符处理

当解析器遇到无法识别的字符(如非法的类型前缀)时,Redis 的处理逻辑是立即终止对该连接当前请求的解析,并向客户端返回协议错误,然后关闭连接

  • 返回错误响应 :服务器会向客户端发送一个以 - 开头的错误回复(Error Reply)。例如,常见的协议错误格式为 -ERR Protocol error: ...\r\n 。如果错误与特定命令相关,也可能是 -ERR syntax error\r\n

  • 关闭连接 :对于协议错误,Redis 通常采取严格模式 。因为协议错误意味着客户端发送的数据格式已经完全混乱,服务器无法安全地从数据流中恢复并找到下一个命令的起始位置。为了防止影响后续可能正确的命令,最安全的做法就是直接断开这个出错的客户端连接。你提到的"清空缓冲区"在这里不适用,因为数据流已经不可信了。

关于缓冲区与连接的处置

  • 输入缓冲区 :每个客户端连接都有一个输入缓冲区,用于暂存从 socket 读取的、尚未解析的原始数据 。当发生协议错误时,这个缓冲区中的数据(包括那个非法的 #)通常会被丢弃,因为连接即将关闭。

  • 连接状态 :虽然 Redis 不会因为一次协议错误就杀掉整个服务器进程,但它会杀掉那个出错的客户端连接 。在 redis-cli 中,你会看到连接断开,需要重新连接。

当redis出现解析协议错误时,不仅仅时上面的输入非法字符,还有可能在批量字符串中长度不匹配等问题,都会选择断开连接

为什么选择断开连接?

  1. TCP 是字节流协议,没有天然的消息边界

    一旦协议解析器因为非法字符、长度不匹配等错误而失去同步,就无法确定下一个命令的开始位置。例如,如果批量字符串声明的长度是 10 字节,但实际只发送了 5 字节,服务器会一直等待剩余的 5 字节,导致后续所有命令都卡在缓冲区中无法解析。

  2. 防止"命令混淆"或"数据污染"

    如果尝试"跳过"错误数据继续解析,很可能将部分错误数据误认为是下一个命令的一部分,导致执行了非预期的命令或参数,严重时可能引发数据错误或安全漏洞。

  3. 保持协议实现的简洁与可靠

    Redis 的设计哲学是"快速且简单"。在协议层面采取严格模式,一旦发现错误立即断开,避免了复杂的错误恢复逻辑,也保证了服务器核心状态的稳定。

断开连接的具体流程

  1. 检测错误 :解析器发现协议格式不正确(如非法前缀、长度与实际不符、缺少 \r\n 等)。

  2. 发送错误响应 :向客户端发送 -ERR Protocol error: <description>\r\n 之类的错误信息,让客户端知道发生了什么。

  3. 关闭连接 :调用 close() 或类似机制关闭该客户端对应的 socket。客户端的连接会断开,需要重新连接才能继续通信。

客户端收到错误后的行为

  • 对于 redis-cli,会直接报错并退出。

  • 对于各种语言的客户端库(如 redis-pyjedis 等),通常会抛出异常,程序可以捕获并处理(例如重连或重试)。

  • 在集群或哨兵模式下,客户端可能会自动重连到其他节点。

注意:以上不会导致服务端挂掉,只是因为你客户端输入的非法数据导致客户端和服务端之间的连接挂掉

相关推荐
lhbian2 小时前
redis分页查询
数据库·redis·缓存
顶点多余2 小时前
Mysql 基本查询详解
数据库·mysql
X-⃢_⃢-X2 小时前
八、Redis之BigKey
数据库·redis·缓存
~莫子2 小时前
Redis
数据库·redis·缓存
历程里程碑2 小时前
36 Linux线程池实战:日志与策略模式解析
开发语言·数据结构·数据库·c++·算法·leetcode·哈希算法
颜颜yan_2 小时前
从千毫秒到亚毫秒:连接条件下推如何让复杂 SQL 飞起来
数据库·sql
ChaITSimpleLove2 小时前
如何查看系统中 PostgreSQL 数据库的进程(postgres)运行状态?
数据库·postgresql·查看pgsql运行状态·pgsql进程运行状态·postgres 进程·tree 树形结构
ChaITSimpleLove2 小时前
PostgreSQL 部署与运维常用命令详解
运维·数据库·postgresql·部署·命令解析
ChaITSimpleLove2 小时前
PostgreSQL 的 SQL 执行过程详解
数据库·sql·postgresql·词法分析·语法分析·执行过程