【Redis入门系列】:从 RESP 协议到 redis-plus-plus:Redis 客户端编程与 C++ 接口设计

🔥 本文专栏:Redis

🌸作者主页:努力努力再努力wz

💪 今日博客励志语录:最难的不是做出决定的那一瞬间,而是在此后无数个想要放弃的深夜里,依然能够在心里再一次'选择'坚持。

思维导图

text 复制代码
Redis 数据存储服务
        │
        ├── C/S 网络服务模型
        │       │
        │       └── Client ← TCP → Redis Server
        │
        ├── 逻辑数据库
        │       │
        │       ├── db0 / db1 / db2 ...
        │       ├── 每个 DB 拥有独立 Key Space
        │       └── SELECT 切换当前连接所使用的 DB
        │
        ├── RESP 应用层协议
        │       │
        │       ├── Array
        │       ├── Bulk String
        │       ├── Simple String
        │       ├── Error
        │       └── Integer
        │
        └── C++ 客户端编程
                │
                ├── hiredis
                │       └── C Redis Client
                │
                └── redis-plus-plus
                        │
                        ├── Redis 对象
                        ├── SET / GET / EXISTS / KEYS
                        ├── StringView
                        ├── OptionalString
                        ├── initializer_list
                        └── Output Iterator

引入

在此前的学习中,我们已经认识了 Redis 中常见的五种数据类型,并且围绕这些数据类型学习了大量 Redis 命令。

但是,当我们真正把 Redis 放到一个后端程序中使用时,仅仅会在 redis-cli 中输入命令显然是不够的。

这是因为,实际业务中的 Redis 读写通常并不是由人手动完成的,而是整个业务程序中的一个子模块。例如:

text 复制代码
HTTP 请求到达业务服务器
        ↓
业务代码处理请求
        ↓
查询 Redis 缓存
        ↓
命中缓存 / 写入缓存
        ↓
继续完成后续业务逻辑

也就是说,在真正的工程中:

业务进程本身需要承担 Redis Client 的角色。

而一旦从"手动输入 Redis 命令"过渡到"程序自动访问 Redis",我们就会自然遇到几个问题:

text 复制代码
Redis Server 内部的数据到底如何划分?
Client 与 Server 在网络上传输什么?
程序是不是需要自己 socket + connect + send + recv?
C++ 程序应该如何方便地操作 Redis?

接下来,本文就沿着这条链路逐步展开。


一、Redis Server 中的逻辑数据库

1. 从 MySQL Database 过渡到 Redis Database

此前学习 MySQL 时,我们已经接触过 database 这一概念。

对于 MySQL 来说:

text 复制代码
MySQL Server
│
├── database_A
│   ├── table_1
│   └── table_2
│
└── database_B
    ├── table_3
    └── table_4

一个 MySQL Database 可以理解为:

一组逻辑上相关的表所组成的逻辑容器。

而 Redis 同样是一个数据存储服务,因此 Redis 内部也存在"逻辑数据库"的概念。

但是 Redis 与 MySQL 的数据组织方式不同。

Redis 本身采用的是:

text 复制代码
key → value

这样的 KV 存储模型,因此 Redis 的逻辑数据库内部并不存在 MySQL 中的"表"这一层结构,而是直接维护一个 Key Space:

text 复制代码
Redis Server
│
├── db0
│   ├── name → "Tom"
│   ├── age  → "20"
│   └── rank → ZSet Object
│
├── db1
│   ├── name → "Jerry"
│   └── ...
│
└── db2

这里可以建立一个非常重要的认识:

Redis Database 本质上可以理解为 Redis Server 内部划分出的一个逻辑 Key Space。


2. 每个逻辑数据库拥有自己的键空间

Redis 每个逻辑数据库之间的 Key Space 是相互独立的。

例如:

text 复制代码
db0:
name → "Tom"

db1:
name → "Jerry"

虽然两个数据库中都存在 name 这个 key,但是二者并不会冲突。

这是因为客户端真正进行一次 Redis 操作时,完整的定位过程应该理解为:

text 复制代码
当前客户端连接
        ↓
当前连接选择的逻辑数据库
        ↓
该数据库对应的 Key Space
        ↓
根据 key 查找对应 value

所以:

redis 复制代码
GET name

并不是在整个 Redis Server 中全局查找 name,而是在:

text 复制代码
当前连接所选择的数据库

中查找这个 key。

需要注意的是,这里的"数据库隔离"主要指的是:

text 复制代码
不同 DB 拥有独立的 Key Space

它们并不是多个真正完全独立的 Redis Server。

这些逻辑数据库仍然属于同一个 Redis 实例,共享实例级别的资源以及持久化体系。


3. Redis Database 使用编号进行标识

Redis 并不像 MySQL 那样通过:

sql 复制代码
CREATE DATABASE ...
DROP DATABASE ...

在运行过程中动态创建或者删除逻辑数据库。

对于传统 Redis 单机实例来说,逻辑数据库会按照编号进行标识:

text 复制代码
db0
db1
db2
...

编号从 0 开始。

传统默认配置通常为:

text 复制代码
databases 16

也就是:

text 复制代码
db0 ~ db15

一共 16 个逻辑数据库。

因此,Redis 对一个逻辑数据库最直接的标识方式就是:

text 复制代码
数据库编号

4. SELECT:切换当前连接使用的数据库

Redis 提供:

redis 复制代码
SELECT index

来切换当前客户端连接所选择的逻辑数据库。

例如:

redis 复制代码
SELECT 3

表示:

text 复制代码
当前连接
    ↓
切换到 db3

之后该连接继续执行:

redis 复制代码
SET name Tom
GET name

这些操作就都是针对 db3

这里有一个非常重要的点:

SELECT 改变的是"当前连接"的数据库状态,而不是整个 Redis Server 当前使用的数据库。

也就是说:

text 复制代码
Connection A → db0
Connection B → db3
Connection C → db5

可以同时存在。

新建立的 Redis 连接默认使用:

text 复制代码
db0

如果某个连接执行:

redis 复制代码
SELECT 3

那么这个状态只属于当前连接。

连接断开之后,再重新创建一个新连接:

text 复制代码
新的 Connection
        ↓
重新默认使用 db0

并不会自动记住上一次连接曾经选择过 db3

因此这里可以形成完整的心智模型:

text 复制代码
Redis Server
        ↓
多个逻辑 Database
        ↓
Database 通过编号标识
        ↓
每个 Database 拥有独立 Key Space
        ↓
Client Connection 默认选择 db0
        ↓
SELECT n 修改当前连接选择的数据库

二、Redis 本质上仍然是一个网络服务程序

认识了 Redis Database 之后,我们继续回到 Redis 最根本的模型。

Redis 本质上是一个:

text 复制代码
Client / Server

架构的网络服务程序。

在当前的学习模型下,可以先理解为:

text 复制代码
多个 Client
        ↓
与 Redis Server 建立 TCP 连接
        ↓
Client 发送请求
        ↓
Redis Server 执行命令
        ↓
生成响应
        ↓
通过网络返回 Client

对于 Redis 来说,SETGETEXISTS 这些命令最终都不是某种"本地函数调用"。

它们真正发生的过程仍然是:

text 复制代码
Redis Client
        ↓
构造网络请求报文
        ↓
TCP
        ↓
Redis Server
        ↓
解析命令
        ↓
访问内存中的 KV 数据
        ↓
构造响应报文
        ↓
TCP 返回 Client

既然涉及网络通信,就一定会涉及一个问题:

Client 和 Server 怎么知道网络中这一段字节流究竟表示什么?

这就需要应用层协议。


三、RESP:Redis Client 与 Server 的应用层协议

1. 为什么 Redis 需要自己的应用层协议

对于 TCP/IP 网络模型来说,IP、TCP 等协议已经由操作系统内核实现。

上层程序通常不需要自己重新实现:

text 复制代码
IP 数据报如何路由
TCP 如何确认
TCP 如何重传
TCP 如何维护连接

这些事情。

但是 TCP 提供给应用程序的本质仍然只是:

text 复制代码
一段连续的字节流

那么现在 Redis Client 想表达:

redis 复制代码
SET name wangzhe

Redis Server 收到的并不是一个天然存在的:

text 复制代码
Command 对象
key 对象
value 对象

而只是一段字节。

所以双方必须事先约定:

text 复制代码
一条命令有几个参数?
每个参数多长?
某段数据是什么类型?
数据从哪里开始?
到哪里结束?

Redis 使用的这套序列化协议就是:

text 复制代码
RESP
Redis Serialization Protocol

所以可以把整个过程理解为:

text 复制代码
Redis Command
        ↓
RESP 编码
        ↓
网络字节流
        ↓
TCP
        ↓
Redis Server
        ↓
RESP 解析
        ↓
恢复出命令以及参数

2. RESP 并不是围绕 KV 设计的

由于 Redis 是一个 KV 存储服务,很容易认为 RESP 协议本身应该围绕:

text 复制代码
key
value

来组织报文。

其实不是。

RESP 所看到的只是:

text 复制代码
命令 + 参数

例如:

redis 复制代码
SET name wangzhe

从 Redis 命令语义来看:

text 复制代码
SET
key   = name
value = wangzhe

但是 RESP 并不知道第二个字段叫 key,也不知道第三个字段叫 value。

从 RESP 角度,它只是:

text 复制代码
[
    "SET",
    "name",
    "wangzhe"
]

也就是:

一个由多个参数组成的数组。

至于每个参数在某条 Redis 命令中究竟代表什么,是 Redis 命令自身的语义,而不是 RESP 负责的事情。


3. RESP 中常见的数据类型标识

以 RESP2 为例,我们当前最值得认识的几种类型有:

text 复制代码
*    Array
$    Bulk String
+    Simple String
-    Error
:    Integer

这些符号本质上可以理解为:

类型标记。

Redis Server 在解析一段 RESP 字节流时,会根据第一个字节判断接下来应该按照什么规则解释数据。


4. Redis 请求:Array + Bulk String

对于客户端向 Redis Server 发送命令来说,最常见的结构就是:

text 复制代码
Array
+
Bulk String

例如:

redis 复制代码
SET name wangzhe

逻辑上首先表示为:

text 复制代码
["SET", "name", "wangzhe"]

整个请求有 3 个元素,所以 RESP 首先编码:

text 复制代码
*3\r\n

其中:

text 复制代码
*
→ 当前是一个 Array

3
→ Array 中有 3 个元素

\r\n
→ RESP 中的 CRLF 分隔符

然后第一个参数:

text 复制代码
$3\r\nSET\r\n

可以拆成:

text 复制代码
$3\r\n
→ 接下来是一个 Bulk String
→ 数据长度为 3 字节

SET
→ 真正的数据内容

第二个参数:

text 复制代码
$4\r\nname\r\n

第三个参数:

text 复制代码
$7\r\nwangzhe\r\n

最终完整报文就是:

text 复制代码
*3\r\n
$3\r\n
SET\r\n
$4\r\n
name\r\n
$7\r\n
wangzhe\r\n

如果不换行展示,真正字节流的逻辑结构就是:

text 复制代码
*3\r\n$3\r\nSET\r\n$4\r\nname\r\n$7\r\nwangzhe\r\n

因此,这里需要建立一个非常重要的认识:

一个字符串参数经过 RESP 编码之后,其网络表示并不仅仅只有字符串的数据内容,还包含用于描述这段数据的协议元信息。

例如:

text 复制代码
$3\r\nSET\r\n

这里:

text 复制代码
$3\r\n
→ RESP 描述信息

SET
→ 实际数据内容

但是注意:

text 复制代码
"$3"

并不是字符串 "SET" 本身的一部分。

它只是 Redis 为了让接收端能够正确解析 "SET" 而加入的协议控制信息。


5. Bulk String 与 Simple String

Bulk String

Bulk String 的基本格式是:

text 复制代码
$<length>\r\n<data>\r\n

例如:

text 复制代码
$5\r\nhello\r\n

这里:

text 复制代码
5
→ 明确告诉接收端后面的实际数据有 5 个字节

这种方式有一个很重要的优势:

数据边界由长度决定,而不是简单依赖某个字符判断结束。

因此 Bulk String 可以承载更加通用的数据,甚至可以包含二进制内容。

Redis Client 发送命令参数时,大量使用的就是这种形式。


Simple String

Simple String 的格式更加简单:

text 复制代码
+内容\r\n

例如:

text 复制代码
+OK\r\n

它没有单独的长度字段,而是直接以 CRLF 作为结束。

所以 Simple String 更适合:

text 复制代码
短小、简单、不包含 CR/LF 的文本响应

例如 SET 成功时常见的:

text 复制代码
OK

6. Error 与 Integer

Redis RESP 中:

text 复制代码
-

表示 Error。

例如:

text 复制代码
-ERR unknown command\r\n

这类内容主要出现在 Redis Server 返回给 Client 的响应中,用来表示:

text 复制代码
命令不存在
类型错误
语法错误
...

而:

text 复制代码
:

则表示 Integer。

很多 Redis 命令的响应本身就是一个整数,因此会通过 RESP Integer 表达。

例如:

text 复制代码
:1\r\n

就表示整数 1


7. GET 不存在的 key 与 Null

这里还能顺便把后面 Optional 的设计连接起来。

假设执行:

redis 复制代码
GET not_exist

如果 key 不存在,那么 Redis 并不能简单返回:

text 复制代码
""

因为:

text 复制代码
key 存在,value = ""

本身也是一种合法的数据状态。

所以 RESP 必须能够区分:

text 复制代码
有一个空字符串

和:

text 复制代码
根本没有值

在 RESP2 中,不存在的 Bulk String 可以表示为:

text 复制代码
$-1\r\n

也就是 Null Bulk String。

这个"有值 / 没值"的语义,后面就会直接映射到 C++ 客户端库中的 OptionalString


四、为什么实际开发不自己手写 RESP 与 Socket

如果完全按照底层流程来操作 Redis,那么 C++ 程序理论上完全可以自己实现:

text 复制代码
socket()
        ↓
connect()
        ↓
按照 RESP 规则编码 Redis 命令
        ↓
send()
        ↓
recv()
        ↓
解析 RESP 响应
        ↓
close()

但是这种做法显然没有必要。

这就和此前使用 MySQL Client Library 是一样的。

真正业务开发中:

text 复制代码
业务程序
        ↓
MySQL Client Library
        ↓
MySQL Server

对于 Redis,同样可以抽象为:

text 复制代码
业务程序
        ↓
Redis Client Library
        ↓
Redis Server

Client Library 已经替我们完成:

text 复制代码
连接管理
RESP 编码
网络发送
响应接收
RESP 解析
异常处理

所以上层业务代码只需要关心:

text 复制代码
我要执行什么 Redis 操作?
参数是什么?
返回结果是什么?

五、hiredis 与 redis-plus-plus

1. hiredis:C 语言 Redis Client

hiredis 是一个 C 语言实现的 Redis 客户端库。

可以先把它理解为:

text 复制代码
C 业务程序
        ↓
hiredis C API
        ↓
Redis Server

它负责完成 Redis Client 的基本行为,例如:

text 复制代码
连接 Redis
发送命令
接收响应
解析 RESP

2. redis-plus-plus:对 hiredis 的 C++ 封装

对于 C++ 开发来说,我们还可以进一步使用:

text 复制代码
redis-plus-plus

它本身就是一个 C++ Redis Client,并且底层基于 hiredis。

整个关系可以理解成:

text 复制代码
C++ 业务代码
        ↓
redis-plus-plus
        ↓
hiredis
        ↓
RESP + TCP
        ↓
Redis Server

这和我们对 Linux Socket API 做 C++ 封装非常类似。

例如 Linux 原生提供:

cpp 复制代码
socket();
connect();
recv();
send();
close();

如果希望更加符合 C++ 编程习惯,我们可能会自己封装:

cpp 复制代码
class Socket {
    // 封装 socket fd
    // 检查系统调用返回值
    // 析构时自动释放资源
};

于是:

text 复制代码
Linux Socket C API
        ↓
C++ Socket Class
        ↓
业务代码

Redis 这里也是类似:

text 复制代码
hiredis C API
        ↓
redis-plus-plus C++ 封装
        ↓
C++ 业务代码

redis-plus-plus 还会利用:

text 复制代码
RAII
STL
异常
模板
连接池
C++ 类型系统

提供更加符合 C++ 编程习惯的接口。

因此,对于 C++ 后端程序来说,通常可以直接把学习重点放在:

text 复制代码
redis-plus-plus

这一层。

至于 hiredis、redis-plus-plus 如何从源码编译安装,并不是这里最重要的内容,只需要先把开发环境配置好即可。


六、redis-plus-plus 的基本使用流程

首先引入:

cpp 复制代码
#include <sw/redis++/redis++.h>

这个头文件向上层暴露 redis-plus-plus 的 C++ API。

最简单的代码可以写成:

cpp 复制代码
#include <sw/redis++/redis++.h>
#include <iostream>

int main() {
    sw::redis::Redis redis("tcp://127.0.0.1:6379");

    redis.set("name", "wangzhe");

    auto value = redis.get("name");

    if (value) {
        std::cout << *value << std::endl;
    }

    return 0;
}

这里整体可以先拆成:

text 复制代码
创建 Redis 对象
        ↓
提供 Redis Server 连接信息
        ↓
调用成员函数执行 Redis 命令
        ↓
redis-plus-plus 内部管理连接
        ↓
hiredis + RESP + TCP
        ↓
Redis Server
        ↓
响应返回
        ↓
redis-plus-plus 转换为 C++ 类型

1. Redis 对象与连接 URI

首先:

cpp 复制代码
sw::redis::Redis redis("tcp://127.0.0.1:6379");

这里字符串:

text 复制代码
tcp://127.0.0.1:6379

包含:

text 复制代码
tcp
→ 使用 TCP

127.0.0.1
→ Redis Server 地址

6379
→ Redis Server 端口

所以构造 Redis 对象时,相当于告诉客户端:

后续应该连接到哪一个 Redis Server。

不过这里需要注意:

构造 Redis 对象,并不代表底层一定立刻创建 TCP 连接。

redis-plus-plus 的连接可以采用 Lazy Connection,也就是延迟建立:

text 复制代码
构造 Redis 对象
        ↓
保存连接配置
        ↓
暂时不一定 connect
        ↓
真正发送 Redis 命令
        ↓
按需创建 / 获取底层连接

因此,上层只需要使用 Redis 对象即可,不需要自己关心某一次底层连接究竟在什么时刻创建。


七、SET:从 Redis 命令映射到 C++ 成员函数

此前使用 redis-cli 时,我们可能执行:

redis 复制代码
SET name wangzhe

到了 redis-plus-plus 中,它就变成:

cpp 复制代码
redis.set("name", "wangzhe");

也就是说:

Redis 原生命令被进一步封装成了 Redis 对象的成员函数。

set() 一个常见接口可以理解为:

cpp 复制代码
bool set(const StringView &key,
         const StringView &val,
         const std::chrono::milliseconds &ttl = std::chrono::milliseconds(0),
         UpdateType type = UpdateType::ALWAYS);

其参数与 Redis SET 命令的语义基本对应:

text 复制代码
key
→ 键

val
→ 值

ttl
→ 过期时间

type
→ 更新条件

1. key 与 value:StringView

这里前两个参数使用:

cpp 复制代码
StringView

为什么不直接规定必须传递:

cpp 复制代码
std::string

呢?

首先需要重新认识一下 string_view

一个 string_view 可以建立这样的心智模型:

text 复制代码
string_view
├── 字符串数据起始位置
└── 字符串长度

例如:

cpp 复制代码
std::string str = "hello";

std::string_view sv = str;

可以概念化理解为:

text 复制代码
sv.data() → str 中 'h' 的位置
sv.size() → 5

这里 sv 并没有重新拷贝:

text 复制代码
"hello"

它只是获得了原字符串的一段只读视图。

因此:

text 复制代码
std::string
→ 拥有字符串数据

std::string_view
→ 不拥有字符串数据
→ 只是观察一段字符区间

这可以减少不必要的字符串复制。


2. StringView 与 C++ 标准兼容

std::string_view 是 C++17 标准库正式提供的。

但是 redis-plus-plus 需要兼容不同 C++ 标准。

因此可以统一理解为:

text 复制代码
C++17+
→ StringView 使用 std::string_view

较早标准
→ redis-plus-plus 提供兼容实现

这样 redis-plus-plus 的上层接口始终可以统一写:

cpp 复制代码
StringView

而不用让业务代码关心底层究竟是哪一种实现。


3. 可以直接传 std::string

这里还有一个容易误解的点。

接口参数虽然是:

cpp 复制代码
const StringView &key

但这并不意味着调用者必须先手动创建:

cpp 复制代码
StringView

例如完全可以:

cpp 复制代码
std::string key = "name";
std::string value = "wangzhe";

redis.set(key, value);

也可以:

cpp 复制代码
redis.set("name", "wangzhe");

其本质可以理解为:

text 复制代码
std::string / C String
        ↓
转换成 StringView
        ↓
获得字符串数据位置 + 长度
        ↓
传递给 set()

因此 StringView 的存在不是为了增加调用复杂度,而是:

让接口可以只读地观察各种字符串来源,并减少不必要的数据拷贝。


4. ttl:设置过期时间

第三个参数:

cpp 复制代码
ttl

表示 key 的生存时间。

例如:

cpp 复制代码
redis.set("name",
          "wangzhe",
          std::chrono::seconds(10));

可以理解为:

text 复制代码
SET name wangzhe
+
设置 10 秒过期时间

如果 TTL 为 0ms,则表示不额外设置超时时间。


5. UpdateType:映射 NX / XX

第四个参数:

cpp 复制代码
UpdateType

用于描述 SET 的条件更新行为。

可以对应:

text 复制代码
UpdateType::ALWAYS
→ 无论 key 是否存在都 SET

UpdateType::NOT_EXIST
→ 对应 NX
→ key 不存在才设置

UpdateType::EXIST
→ 对应 XX
→ key 已经存在才设置

因此:

cpp 复制代码
redis.set("name",
          "wangzhe",
          std::chrono::milliseconds(0),
          sw::redis::UpdateType::NOT_EXIST);

可以理解为:

redis 复制代码
SET name wangzhe NX

这里可以进一步总结:

text 复制代码
NX
→ 更偏向"只插入"

XX
→ 更偏向"只更新"

ALWAYS
→ 存在就更新,不存在就插入

6. SET 的返回值

redis-plus-plus 的这一版 set() 返回:

cpp 复制代码
bool

它表示:

text 复制代码
本次 SET 是否真正成功完成

例如使用:

text 复制代码
NX / XX

时,如果条件不满足,那么 set() 可以返回:

text 复制代码
false

所以:

cpp 复制代码
bool ok = redis.set(...);

并不是简单地表示"网络有没有报错",而是和当前 SET 条件是否成功执行有关。


八、GET:为什么返回 OptionalString

Redis 原生命令:

redis 复制代码
GET key

对应 redis-plus-plus:

cpp 复制代码
auto value = redis.get(key);

这里参数仍然是一个字符串形式的 key。

但是 GET 的返回值不能简单设计成:

cpp 复制代码
std::string

原因在于必须区分:

text 复制代码
情况一:
key 不存在

情况二:
key 存在,但是 value 本身就是空字符串 ""

如果两种情况都返回:

cpp 复制代码
std::string("")

就无法区分。

因此 redis-plus-plus 使用:

cpp 复制代码
OptionalString

来表示:

text 复制代码
这个字符串值可能存在
也可能不存在

可以把它建立成类似:

cpp 复制代码
std::optional<std::string>

的心智模型。


1. Optional 的核心语义

Optional 最核心解决的问题不是:

text 复制代码
字符串是不是空

而是:

text 复制代码
这个值到底存不存在

所以:

text 复制代码
Optional 中存在 std::string("")
→ 仍然表示"有值"

Optional 本身为空
→ 才表示"没有值"

例如:

cpp 复制代码
auto value = redis.get("name");

if (value) {
    std::cout << *value << std::endl;
}

这里:

text 复制代码
if (value)
→ 判断 Optional 中有没有值

*value
→ 取出内部保存的 std::string

2. 为什么 if(value) 可以直接判断

Optional 类型会提供类似:

cpp 复制代码
operator bool()

的能力。

所以:

cpp 复制代码
if (value)

可以理解为:

text 复制代码
value
        ↓
operator bool()
        ↓
内部存在值?
        ↓
true / false

需要再次强调:

text 复制代码
判断的是"有没有对象"
而不是"字符串是不是空串"

3. operator*:取得内部值

如果 Optional 当前有值:

cpp 复制代码
*value

就可以获得其内部保存的字符串。

可以理解为:

text 复制代码
OptionalString
        ↓
operator*()
        ↓
std::string

因此:

cpp 复制代码
if (value) {
    std::cout << *value;
}

就是最直观的一组操作。


4. Optional 常见成员函数

除了:

cpp 复制代码
if (value)
*value

之外,还可以认识几个常见成员函数。

has_value()

cpp 复制代码
value.has_value()

表示:

text 复制代码
Optional 当前是否存在值

所以:

cpp 复制代码
if (value.has_value()) {
}

与:

cpp 复制代码
if (value) {
}

语义接近。


value()

cpp 复制代码
value.value()

用于取出内部保存的值。

但是如果 Optional 当前没有值,直接调用 value() 会失败并抛出对应异常。

因此通常应该先判断。


value_or()

cpp 复制代码
value.value_or("default")

表示:

text 复制代码
有值
→ 返回真实 value

没值
→ 返回给定默认值

例如:

cpp 复制代码
std::string name = value.value_or("unknown");

九、EXISTS:单 key 判断与批量 key 判断

Redis:

redis 复制代码
EXISTS key

用于判断 key 是否存在。

redis-plus-plus 中:

cpp 复制代码
redis.exists("name");

对于单个 key:

text 复制代码
存在
→ 返回 1

不存在
→ 返回 0

因此,在单 key 场景下可以从布尔语义理解它。

但是它的真实返回值并不是 bool,而是:

存在的 key 数量。

所以 Redis 还支持:

redis 复制代码
EXISTS key1 key2 key3

如果三个 key 中有两个存在,则返回:

text 复制代码
2

redis-plus-plus 也支持:

cpp 复制代码
redis.exists({"key1", "key2", "key3"});

十、initializer_list:为什么可以直接传一组 key

当看到:

cpp 复制代码
redis.exists({"key1", "key2", "key3"});

这里就会涉及 C++ 的:

cpp 复制代码
std::initializer_list

可以先建立这样的心智模型:

text 复制代码
{"key1", "key2", "key3"}
        ↓
编译器准备一组连续的初始化元素
        ↓
initializer_list
        ↓
提供对这一组元素的只读访问

这里 initializer_list 本身并不会再拥有一份完整的数据副本。

可以把它类比成前面学习的 string_view

text 复制代码
string_view
→ 对一段字符数据的只读视图

initializer_list
→ 对一组初始化元素的只读视图

从实现思维来看,可以概念化理解为:

text 复制代码
initializer_list
├── 起始位置
└── 元素数量 / 结束位置

不过标准并不要求其内部必须真的按照"两个指针"实现。

所以最稳妥的理解仍然是:

initializer_list 是对编译器生成的一组连续初始化元素提供的轻量只读视图。

redis-plus-plus 内部可以进一步把:

cpp 复制代码
redis.exists({"k1", "k2", "k3"});

转成类似:

text 复制代码
begin()
+
end()

所描述的一段范围,然后依次处理这些 key。

因此:

text 复制代码
{"k1", "k2", "k3"}
        ↓
initializer_list
        ↓
exists() 遍历
        ↓
构造 EXISTS k1 k2 k3
        ↓
Redis 返回实际存在数量

十一、KEYS:模式匹配与输出迭代器

此前我们已经学习过 Redis:

redis 复制代码
KEYS pattern

它会在当前逻辑数据库中查找所有满足 pattern 的 key。

例如:

redis 复制代码
KEYS user:*

可能返回:

text 复制代码
user:1
user:2
user:3

KEYS 的时间复杂度是:

text 复制代码
O(N)

因为需要遍历当前数据库中的整个 Key Space。

因此它并不适合在大规模生产数据上作为频繁扫描手段,这也是后面 SCAN 命令存在的重要原因。


1. redis-plus-plus 中的 keys()

redis-plus-plus 的接口可以理解为:

cpp 复制代码
template <typename Output>
void keys(const StringView &pattern, Output output);

第一个参数:

cpp 复制代码
pattern

已经比较熟悉:

text 复制代码
表示 Redis KEYS 命令中的匹配模式

第二个参数:

cpp 复制代码
output

则是一个输出迭代器。

例如:

cpp 复制代码
std::vector<std::string> result;

redis.keys("user:*", std::back_inserter(result));

整体过程可以理解为:

text 复制代码
KEYS user:*
        ↓
Redis Server 返回多个 key
        ↓
redis-plus-plus 解析响应
        ↓
通过 Output Iterator
        ↓
依次写入 result

这里 keys() 本身不需要返回:

cpp 复制代码
std::vector<std::string>

而是由调用者自己决定结果最终放进什么容器。


十二、重新认识 C++ Iterator

学习到这里,就自然遇到了:

cpp 复制代码
std::back_inserter(result)

也就是所谓的插入迭代器。

在此之前,我们对于 Iterator 可以先保持一个很朴素的模型:

Iterator 是一种模拟指针行为的抽象对象。

很多迭代器都会提供类似:

cpp 复制代码
*it
++it
it != end

的行为。

因此它们看起来和普通指针非常相似。

但是需要注意:

Iterator 并不一定只是"把一个普通指针封装进类里"。

例如:

text 复制代码
vector
→ 数据连续
→ iterator 可以非常接近普通指针

list
→ 节点并不连续
→ iterator 必须知道如何从当前节点移动到下一个链表节点

所以更准确地说:

text 复制代码
Iterator
→ 是满足特定迭代行为要求的一种对象
→ 通过运算符重载模拟指针式访问

1. 不同 Iterator 本质上是在描述不同能力

经典 Iterator 通常会按照能力进行分类,例如:

text 复制代码
Input Iterator
Output Iterator
Forward Iterator
Bidirectional Iterator
Random Access Iterator

现在没有必要单独死记这些概念。

只需要先理解:

所谓不同迭代器类别,本质上是在描述这个 Iterator 支持哪些操作。

例如:

text 复制代码
Input Iterator
→ 主要读取

Output Iterator
→ 主要写入

Bidirectional Iterator
→ 可以 ++
→ 可以 --

Random Access Iterator
→ 可以 +n / -n
→ 可以下标访问

十三、插入迭代器:把"赋值"转换为"插入"

std::back_inserter(container) 会生成一种特殊的:

text 复制代码
Output Iterator

它的任务不是读取某一个已经存在的元素,而是:

text 复制代码
向容器写入新的元素

例如:

cpp 复制代码
std::vector<std::string> result;

auto it = std::back_inserter(result);

随后:

cpp 复制代码
*it = "hello";

表面上看起来是:

text 复制代码
给 *it 指向的位置赋值

back_insert_iterator 会重载相关操作,使这一行为最终转换成类似:

cpp 复制代码
result.push_back("hello");

所以这里可以建立一条非常重要的链:

text 复制代码
std::back_inserter(result)
        ↓
创建 back_insert_iterator
        ↓
这是一个 Output Iterator
        ↓
对 Iterator"赋值"
        ↓
转换成 result.push_back(...)

因此在:

cpp 复制代码
redis.keys("*", std::back_inserter(result));

中,redis-plus-plus 可以不断把 Redis 返回的 key:

text 复制代码
key1
key2
key3
...

写给这个输出迭代器。

而这个迭代器再负责:

text 复制代码
result.push_back(key1)
result.push_back(key2)
result.push_back(key3)
...

最终结果自然就进入:

cpp 复制代码
std::vector<std::string> result;

中。


十四、将整个 Redis Client 编程链路串起来

学习到这里,可以把此前零散的知识重新组织成一条完整链路。

假设业务程序执行:

cpp 复制代码
redis.set("name", "wangzhe");

从最上层一路往下,其过程可以理解为:

text 复制代码
C++ 业务代码
        ↓
Redis::set()
        ↓
redis-plus-plus
        ↓
调用/利用 hiredis
        ↓
将命令组织为 RESP
        ↓
["SET", "name", "wangzhe"]
        ↓
RESP 字节流
        ↓
TCP
        ↓
Redis Server
        ↓
当前 Connection 所选择的 Database
        ↓
对应 Database 的 Key Space
        ↓
查找 / 修改 key
        ↓
生成 RESP Response
        ↓
TCP 返回
        ↓
hiredis 解析
        ↓
redis-plus-plus 转换为 C++ 类型
        ↓
业务代码拿到返回结果

这条链路非常重要。

因为这样一来,我们就不会把:

cpp 复制代码
redis.set(...)

看成一个孤立的 C++ API。

它背后依然是此前学习过的:

text 复制代码
C/S 网络模型
+
TCP
+
应用层协议
+
数据库 Key Space
+
客户端库封装

只不过 redis-plus-plus 将这些底层过程隐藏了起来。


十五、本阶段知识总结

本文并没有继续学习某一种新的 Redis 数据类型,而是进一步认识了:

Redis 在真正的 C++ 后端程序中究竟是如何被使用的。

首先,Redis Server 内部存在多个逻辑数据库:

text 复制代码
Redis Server
        ↓
db0 / db1 / db2 ...
        ↓
每个 DB 拥有自己的 Key Space

数据库通过编号标识,并通过:

redis 复制代码
SELECT n

切换当前连接所操作的逻辑数据库。

随后,从 Redis 的 C/S 网络模型出发,我们认识到:

text 复制代码
Client 与 Server
必须通过一套应用层协议
约定网络中字节流的组织方式

Redis 使用的就是:

text 复制代码
RESP

对于最常见的客户端请求:

text 复制代码
Redis Command
        ↓
Array
        +
Bulk String
        ↓
RESP
        ↓
TCP 字节流

但实际业务开发中,我们并不需要自己:

text 复制代码
socket
connect
RESP encode
send
recv
RESP decode

而是直接通过 Redis Client Library 完成。

对于 C++:

text 复制代码
C++ 业务程序
        ↓
redis-plus-plus
        ↓
hiredis
        ↓
RESP + TCP
        ↓
Redis Server

最后,在学习 redis-plus-plus 的基础 API 时,又自然串联起了一系列 C++ 本身的语言特性:

text 复制代码
SET
→ StringView
→ 只读字符串视图

GET
→ OptionalString
→ 表达"有值 / 无值"

EXISTS
→ initializer_list
→ 批量传入多个 key

KEYS
→ Output Iterator
→ back_inserter
→ 将 Redis 返回结果写入 STL 容器

所以这部分学习真正重要的并不是单纯记忆:

cpp 复制代码
redis.set(...)
redis.get(...)
redis.exists(...)
redis.keys(...)

而是理解这些 API 为什么会设计成现在这种形式,以及它们在整个 Redis Client / Server 网络通信链路中分别处于什么位置。

这样后续再学习 Redis 的连接池、Pipeline、事务以及更加复杂的客户端操作时,就不会只是停留在"会调用接口"的层面,而是能够继续沿着:

text 复制代码
上层 API
→ 客户端库
→ RESP
→ TCP
→ Redis Server
→ 数据结构

这条链路向下理解。


相关推荐
志栋智能1 小时前
凌晨3点的告警,如何用AI在5分钟内完成定界?
运维·服务器·数据库·人工智能·自动化
G.E.M.小白1 小时前
【SQLite3 数据库】从安装、SQL 操作到 C/C++ API 实战
数据库·oracle·sqlite3
知兀1 小时前
Tailwindcss报错:没有与此调用匹配的重载
前端·c++·tailwindcss
神明不懂浪漫1 小时前
【第一章】MySQL简介
数据库·经验分享·笔记·mysql·oracle
Dawson Zhu1 小时前
Kafka 在 AI Agent 系统中的应用:任务队列与事件总线的角色辨析及工程实践
人工智能·语言模型·架构·aigc·agi
山岚的运维笔记1 小时前
mysql 专业笔记 -- 第 14 章:GROUP BY
运维·数据库·笔记·后端·学习·mysql·dba
不爱运动的跑者1 小时前
架构可视化不是“画图“:让 AI 产出可追溯架构图的四层工程
人工智能·架构
青禾8371 小时前
Git 与 SVN 完全指南:从集中式到分布式的版本控制
分布式·git·svn
后台模板学习1 小时前
Rust vs Python 在用户认证模块中的实现:底层
开发语言·python·rust