【Redis】Redis 协议与连接

发布于:2025-07-28 ⋅ 阅读:(17) ⋅ 点赞:(0)

一、Redis 协议

1.1 RESP

RESP 是 Redis 客户端与服务器之间的通信协议,采用文本格式(基于 ASCII 字符),支持多种数据类型的序列化和反序列化

RESP 通过首字符区分数据类型,主要支持 5 种类型:

类型 首字符 格式示例 说明
简单字符串 + +OK\r\n \r\n 结尾,用于返回状态信息(如 OK)
错误信息 - -ERR wrong type\r\n 格式同简单字符串,但表示错误
整数 : :10086\r\n 用于返回计数、自增结果等整数
批量字符串 $ $5\r\nhello\r\n 用于存储二进制安全的字符串(长度 + 内容)
数组(列表) * *2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n 用于表示多个元素的集合(如命令参数)

示例解析

客户端发送命令 SET name redis 时,协议格式为:

`*3\r\n$3\r\nSET\r\n$4\r\nname\r\n$5\r\nredis\r\n`
  • *3 表示数组包含 3 个元素(命令 + 2 个参数)
  • $3\r\nSET 表示第一个元素是长度为 3 的字符串 “SET”
  • $4\r\nname表示第二个元素是长度为4的字符串name
  • $5\r\nredis表示第三个元素是长度为5的字符串redis
  • \r\n是最后的结束分隔符

1.2 Redis Pipeline

Redis Pipeline(管道)是 Redis 客户端提供的一种优化网络通信的机制,允许客户端一次性发送多个命令到服务器,再批量接收所有命令的响应,从而大幅减少网络往返次数,提升通信效率

在这里插入图片描述

  • 传统模式:客户端发送一个命令 → 等待服务器响应 → 再发送下一个命令(每命令 1 次网络往返)。
  • Pipeline 模式:客户端一次性发送多个命令 → 服务器批量执行 → 一次性返回所有结果(N 个命令仅 1 次网络往返)。

Pipeline 特点

  1. 非原子性:Pipeline 不保证事务性,命令按顺序执行,但中间若某命令失败,后续命令仍会继续执行(与 MULTI/EXEC 事务不同)。

  2. 顺序性:服务器按接收顺序执行 Pipeline 中的命令,响应结果也与命令顺序一一对应。

  3. 适用场景

    • 批量读写操作(如批量设置多个键值对)。
    • 非依赖型命令(命令之间无因果关系,不需要前一个命令的结果作为后一个的参数)。

1.3 Redis 事务

Redis 事务是一组命令的集合,通过 MULTIEXEC 等命令将多个操作封装为一个不可分割的工作单元,要么全部执行,要么全部不执行(特殊情况除外,见后文说明)。它主要用于保证一系列操作的原子性,避免中间被其他命令干扰

Redis 事务通过以下命令实现完整流程:

命令 作用
MULTI 开启事务,后续命令进入 “队列” 等待执行,而非立即执行
EXEC 执行事务队列中的所有命令,返回各命令的结果(按入队顺序)
DISCARD 取消事务,清空队列,放弃执行
WATCH 监控一个或多个键,若事务执行前被监控的键发生变动,则事务被打断(乐观锁)

Redis 事务的特点

  1. 原子性限制

    • 若事务中命令存在语法错误(如命令不存在),EXEC 会直接放弃所有命令(全部不执行)。
    • 若命令语法正确但运行时错误(如对字符串执行 LPOP),错误命令会失败,其他命令仍会执行,不回滚 ,这与传统数据库事务的 “完全回滚” 不同,Redis 不支持部分失败后的回滚,需业务层处理。
  2. 顺序性:事务中的命令按入队顺序执行,不会被其他客户端的命令插入。

  3. 乐观锁机制:通过 WATCH 实现,适用于 “读 - 改 - 写” 场景,防止并发修改导致的数据不一致。

基础命令

MULTI + EXEC 执行事务
# 开启事务
127.0.0.1:6379> MULTI
OK

# 命令入队(此时仅排队,不执行)
127.0.0.1:6379(TX)> SET name "redis"
QUEUED
127.0.0.1:6379(TX)> GET name
QUEUED
127.0.0.1:6379(TX)> INCR counter
QUEUED

# 执行事务(返回所有命令结果,按入队顺序)
127.0.0.1:6379(TX)> EXEC
1) OK
2) "redis"
3) (integer) 1

在这里插入图片描述

DISCARD 取消事务
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET a 10
QUEUED
127.0.0.1:6379(TX)> SET b 20
QUEUED

# 取消事务,队列清空
127.0.0.1:6379(TX)> DISCARD
OK

# 验证命令未执行
127.0.0.1:6379> GET a
(nil)

在这里插入图片描述

WATCH 监控键

两个客户端同时更新同一个键,确保只有先获取到原始值的客户端能成功更新

客户端A:

# 监控键 balance
127.0.0.1:6379> WATCH balance
OK

# 读取当前值
127.0.0.1:6379> GET balance
"100"

# 开启事务,准备更新(此时客户端 B 还未操作)
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET balance 200
QUEUED

在这里插入图片描述

客户端B:

# 修改被监控的键 balance
127.0.0.1:6379> SET balance 150
OK

在这里插入图片描述

客户端A继续执行:

# 由于 balance 被 B 修改,事务被打断(返回 nil)
127.0.0.1:6379(TX)> EXEC
(nil)

# 验证结果(A 的修改未生效)
127.0.0.1:6379> GET balance
"150"

在这里插入图片描述

WATCH 会在 EXEC 前检查监控的键是否被修改,若被修改则事务失败(返回 nil),需业务层重试

应用场景

1. 实现 ZPOP(原子性移除有序集合首个元素)

Redis 没有内置 ZPOP 命令,可用事务实现 “获取首个元素并删除” 的原子操作:

# 监控有序集合 zset,防止被其他客户端修改
127.0.0.1:6379> WATCH zset
OK

# 获取首个元素(分数最低的)
127.0.0.1:6379> ZRANGE zset 0 0
1) "member1"

# 开启事务,删除该元素
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> ZREM zset "member1"
QUEUED

# 执行事务(若 zset 未被修改,返回 1 表示删除成功)
127.0.0.1:6379(TX)> EXEC
1) (integer) 1
2. 实现值的原子加倍操作

对一个键的值进行加倍,确保操作过程中不被其他客户端干扰:

# 监控键 score:10001
127.0.0.1:6379> WATCH score:10001
OK

# 读取当前值
127.0.0.1:6379> GET score:10001
"5"

# 开启事务,设置新值(5*2=10)
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET score:10001 10
QUEUED

# 执行事务(成功返回 OK)
127.0.0.1:6379(TX)> EXEC
1) OK

Lua脚本

Redis 中,Lua 脚本的执行也是原子性的(执行期间不会被其他命令打断),且功能更强大,两者的区别如下:

特性 事务(MULTI/EXEC Lua 脚本(EVAL/EVALSHA
原子性 保证命令按顺序执行,部分错误不回滚 脚本内所有操作作为整体原子执行,支持复杂逻辑
灵活性 仅支持简单命令队列,不支持条件判断 支持分支、循环等逻辑,可实现复杂原子操作
网络开销 需多次交互(MULTI→入队→EXEC 一次请求即可,减少网络往返
适用场景 简单批量操作,依赖乐观锁(WATCH)的场景 复杂原子操作(如带条件的更新、多键联动)
示例:用 Lua 脚本实现原子加倍操作
EVAL "local val = tonumber(redis.call('GET', KEYS[1])); redis.call('SET', KEYS[1], val*2); return val*2" 1 score:10001

事务与Pipeline 对比

特性 Pipeline MULTI/EXEC 事务
网络优化 减少网络往返(核心目的) 无(仍需多次往返)
原子性 无(命令逐个执行) 有(所有命令要么全执行,要么全不执行)
命令依赖 不支持(命令无因果关系) 支持(可基于前序命令结果)
适用场景 批量非依赖型命令 需保证原子性的操作

1.4 Redis ACID

ACID 是数据库事务的四大特性(原子性、一致性、隔离性、持久性),Redis 作为内存数据库,对这些特性的支持与传统关系型数据库有显著差异

1. 原子性(Atomicity)

定义:事务中的操作要么全部成功,要么全部失败,不允许部分执行。

Redis 的支持情况

  • 不完整支持:Redis 事务通过 MULTI/EXEC 将命令入队,EXEC 时批量执行,但不支持回滚。
    • 若事务中存在语法错误(如命令不存在),EXEC 会直接放弃所有命令(全部不执行)。
    • 若命令语法正确但运行时错误(如对字符串执行 LPOP),错误命令会失败,其他命令仍会继续执行(不会回滚)

示例

key1 被成功设置为 “hello”,错误命令不影响其他命令执行,违反原子性。

# 开启事务
127.0.0.1:6379> MULTI
OK

# 正确命令:设置 key1
127.0.0.1:6379(TX)> SET key1 "hello"
QUEUED

# 运行时错误:对字符串执行 LPOP(列表操作)
127.0.0.1:6379(TX)> LPOP key1
QUEUED

# 执行事务
127.0.0.1:6379(TX)> EXEC
1) OK  # 第一个命令成功
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value  # 第二个命令失败

在这里插入图片描述

2. 一致性(Consistency)

定义:事务执行前后,数据需满足预设的约束(如业务规则),保持逻辑一致。

Redis 的支持情况

  • 有限支持:仅保证数据结构层面的一致性(如字符串不会被改造成列表),但不保证业务逻辑一致性。
  • 若事务中部分命令失败,可能导致业务数据不一致(如转账时 “扣钱失败但加钱成功”)。

示例:模拟转账场景(A 向 B 转 100 元)

# 初始状态
127.0.0.1:6379> SET A 500
OK
127.0.0.1:6379> SET B 300
OK

# 开启事务(假设 A 扣钱命令出错)
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> DECRBY A 100  # 正确命令:A 扣 100
QUEUED
127.0.0.1:6379(TX)> INCRBY B 100  # 正确命令:B 加 100
QUEUED
127.0.0.1:6379(TX)> INCRBY A abc  # 运行时错误:参数不是数字
QUEUED

# 执行事务
127.0.0.1:6379(TX)> EXEC
1) (integer) 400  # A 扣钱成功
2) (integer) 400  # B 加钱成功
3) (error) ERR value is not an integer or out of range  # 第三个命令失败

# 最终状态:A=400,B=400(总金额 800,初始总金额 800,数据结构一致)
# 但业务上:A 扣了 100,B 加了 100,看似正确?若第一个命令是错误(如 DECRBY A abc):
# 则 A 不变,B 加 100,总金额增加 100,业务逻辑不一致。

在这里插入图片描述

3. 隔离性(Isolation)

定义:多个事务并发执行时,彼此的操作互不干扰,结果等同于串行执行。

Redis 的支持情况

  • 完全支持:Redis 是单线程模型,所有命令(包括事务)按顺序执行,不存在并发冲突,天然满足隔离性。

示例:两个客户端并发执行事务

  • 客户端 1 执行事务:SET x 10; INCR x

  • 客户端 2 执行事务:SET x 20; INCR x

  • 结果:无论执行顺序如何,最终 x 要么是 11(客户端 1 先执行),要么是 21(客户端 2 先执行),不会出现中间状态。

4. 持久性(Durability)

定义:事务一旦提交,结果需永久保存(即使服务器崩溃)。

Redis 的支持情况

  • 条件支持:依赖持久化配置,默认不保证持久性。
    • 若使用 AOF 持久化 且配置 appendfsync=always,事务执行后会立即写入磁盘,保证持久性(但性能极差)。
    • 若使用 RDB 或默认 AOF 配置(everysecno),事务结果可能因崩溃丢失。

实际场景:生产环境极少使用 appendfsync=always,因此 Redis 事务通常不满足持久性。

总结:Redis 事务与 ACID

特性 支持情况
原子性 不支持(无回滚,部分命令失败不影响其他命令)
一致性 仅保证数据结构一致,不保证业务逻辑一致
隔离性 完全支持(单线程执行)
持久性 仅在特定 AOF 配置下支持,实际场景中几乎不满足

补充:Lua 脚本的 ACID 表现

  • Lua 脚本执行是原子性的(全程无中断),且满足隔离性(单线程),但一致性和持久性仍与上述相同。

1.5 Redis 发布订阅

Redis 发布订阅是一种消息通信模式,支持 “一对多” 消息分发(多播),适用于简单的消息通知场景。其核心是 “频道(Channel)”:发布者向频道发送消息,订阅者从频道接收消息。

基础命令

命令 作用 示例
SUBSCRIBE 订阅一个或多个频道 SUBSCRIBE news sport
PSUBSCRIBE 订阅符合模式的频道(支持 * 通配符) PSUBSCRIBE news.*(匹配 news.tech 等)
PUBLISH 向频道发布消息 PUBLISH news "Redis 发布订阅示例"
UNSUBSCRIBE 取消订阅频道 UNSUBSCRIBE news
PUNSUBSCRIBE 取消订阅模式频道 PUNSUBSCRIBE news.*

应用场景

发布订阅

场景:客户端 A 订阅 news 频道,客户端 B 向 news 发布消息。

客户端 A(订阅者)

# 订阅 news 频道
127.0.0.1:6379> SUBSCRIBE news
Reading messages... (press Ctrl-C to quit)
1) "subscribe"  # 订阅成功的反馈
2) "news"
3) (integer) 1

# 收到客户端 B 发布的消息
1) "message"    # 消息类型
2) "news"       # 频道
3) "Redis 发布订阅示例"  # 消息内容

在这里插入图片描述

客户端 B(发布者)

# 向 news 频道发布消息
127.0.0.1:6379> PUBLISH news "Redis 发布订阅示例"
(integer) 1  # 表示有 1 个订阅者接收成功s

在这里插入图片描述

模式订阅

场景:客户端 C 订阅 news.* 模式(匹配 news.technews.sport 等频道)。

客户端 A(订阅者)

127.0.0.1:6379> PSUBSCRIBE news.*
Reading messages... (press Ctrl-C to quit)
1) "psubscribe"
2) "news.*"
3) (integer) 1

# 收到向 news.tech 发布的消息
1) "pmessage"   # 模式消息类型
2) "news.*"     # 订阅的模式
3) "news.tech"  # 实际频道
4) "AI 技术新突破"  # 消息内容

在这里插入图片描述

客户端 B(发布者)

127.0.0.1:6379> PUBLISH news.tech "AI 技术新突破"
(integer) 1  # 客户端 A 收到

在这里插入图片描述

总结

缺点与局限性
  • 无消息持久化:Redis 不会存储发布的消息,若订阅者离线,期间的消息会永久丢失。
  • 无确认机制:发布者无法知道消息是否被订阅者接收。
  • 服务器重启丢失:Redis 重启后,所有订阅关系和未传递的消息会被清空。
  • 单独连接:订阅操作会阻塞连接(等待消息推送),需与普通命令连接分离(单独开连接处理订阅)。
适用场景

适用于实时通知、日志广播等对消息可靠性要求不高的场景(如聊天室、实时监控告警)。若需保证消息可达性,建议使用 Redis Stream 或专业消息队列(如 Kafka、RabbitMQ)。

1.6 Redis IO多线程

Redis 在 6.0 版本中引入了 IO 多线程 特性,主要用于优化网络 IO 操作的性能,解决传统单线程模型在高并发场景下的网络瓶颈。但需要注意的是,Redis 的核心命令执行仍然是单线程的,IO 多线程仅负责 网络数据的读写(接收客户端请求和发送响应结果)。

可以修改配置文件开启IO多线程

# 开启 IO 多线程(默认 no)
io-threads-do-reads yes

# 设置 IO 线程数量(建议为 CPU 核心数的 1/2 或 1/4,避免线程切换开销)
# 注意:总线程数 = 配置数 + 1(主线程),如配置 4 则共 5 个线程
io-threads 4

为什么要使用IO多线程

Redis IO 多线程的核心设计约束是:仅将 “网络数据读写” 和 “协议解析” 拆分到多线程,而命令的执行、内存操作等核心逻辑仍由主线程单线程处理。这一原则确保了:

  • 避免多线程竞争数据(无需复杂锁机制),保留 Redis 单线程的简单性和安全性;
  • 仅优化最耗时的网络 IO 环节(在高并发场景下,网络读写可能占总耗时的 60% 以上)。

IO多线程流程概述

Redis 的 IO 多线程采用 “主线程 + 多 IO 线程” 的混合模型,核心流程如下:

  1. 接收请求阶段

    • 主线程监听客户端连接,当有新请求到达时,将连接分配给 IO 线程。
    • 多个 IO 线程并行读取客户端发送的命令数据(解析成 Redis 协议格式),并暂存到队列中。
  2. 命令执行阶段

    • 主线程从队列中取出所有解析好的命令,按顺序执行(保持单线程特性,保证命令的原子性和隔离性)。
  3. 发送响应阶段

    • 主线程将命令执行结果分发给 IO 线程。
    • 多个 IO 线程并行将结果发送回客户端。

在这里插入图片描述

IO多线程的实现

Redis 通过以下关键结构实现 IO 多线程的管理和协作:

1. IO 线程结构体(io_thread_data

每个 IO 线程对应一个结构体,存储线程状态、任务队列等信息

typedef struct {
    pthread_t thread;          // 线程 ID
    int fd;                    // 用于线程间通知的管道(pipe)写端
    redisAtomic size_t pending; // 待处理的任务数(原子变量,避免锁)
    list *clients;             // 分配给该线程的客户端连接列表
    redisAtomic int state;     // 线程状态:IO_THREAD_STATE_IDLE(空闲)/ RUNNING
} io_thread_data;
  • fd:主线程通过管道向 IO 线程发送 “有任务待处理” 的通知;
  • clients:该线程负责处理的客户端连接队列;
  • state:标记线程是否在工作,用于主线程判断是否可以分配新任务。
2. 全局 IO 线程管理器

Redis 用全局变量管理所有 IO 线程:

// 全局 IO 线程数组
static io_thread_data *io_threads;
// IO 线程数量(配置文件中的 io-threads 值)
static int io_threads_num;
// 是否开启 IO 多线程读(配置 io-threads-do-reads yes)
static int io_threads_do_reads = 0;

IO多线程详细过程

IO 多线程的工作流程可分为初始化、接收请求、命令执行、发送响应四个阶段,主线程与 IO 线程通过 “任务分配 - 通知 - 处理 - 同步” 的方式协作。

1. 初始化阶段(服务器启动时)
  • 步骤 1:读取配置文件的 io-threadsio-threads-do-reads 参数,确定是否开启 IO 多线程及线程数量(io_threads_num)。

  • 步骤 2:创建 io_threads_num 个 IO 线程,初始化每个线程的管道(用于主线程通知)和状态(IO_THREAD_STATE_IDLE)。

  • 步骤 3:为每个 IO 线程启动工作函数(IOThreadMain),线程进入循环等待状态(通过管道监听主线程的任务通知)。

IO 线程的主循环逻辑(IOThreadMain):

void *IOThreadMain(void *myid) {
    int id = *(int*)myid;
    while(1) {
        // 等待主线程通过管道发送通知(阻塞)
        if (aeWait(io_threads[id].fd, AE_READABLE, -1) <= 0)
            continue;
        
        // 读取管道数据(仅用于唤醒,数据无实际意义)
        char buf[1];
        read(io_threads[id].fd, buf, 1);
        
        // 处理分配给自己的客户端任务(读/写数据)
        if (io_threads_do_reads) {
            processPendingReads(id);  // 处理读任务(解析请求)
        } else {
            processPendingWrites(id); // 处理写任务(发送响应)
        }
        
        // 标记线程为空闲状态
        io_threads[id].state = IO_THREAD_STATE_IDLE;
    }
}
2. 接收请求阶段(客户端发送命令)

当客户端发送命令时,主线程与 IO 线程协作完成 “读取数据 + 解析协议”:

  • 步骤 1:主线程通过事件循环(aeMain)检测到客户端套接字可读,收集所有待读取的客户端连接。
  • 步骤 2:主线程将客户端连接平均分配给各个 IO 线程(避免某一线程负载过高),并将连接添加到对应线程的 clients 列表。
  • 步骤 3:主线程通过管道向每个 IO 线程发送一个字节的通知(唤醒线程),并标记线程状态为 “运行中”。
  • 步骤 4:IO 线程被唤醒后,执行 processPendingReads 函数:
    • 循环读取 clients 列表中每个客户端的网络数据;
    • 解析数据为 Redis 协议格式(如将 *3\r\n$3\r\nSET... 解析为命令和参数);
    • 解析完成后,将客户端标记为 “待执行” 状态,等待主线程处理。
  • 步骤 5:主线程等待所有 IO 线程完成读任务(通过轮询线程状态,直到所有线程回到 IDLE),然后进入命令执行阶段。
3. 命令执行阶段(主线程单线程处理)

IO 线程完成请求解析后,主线程接管后续流程:

  • 主线程遍历所有 “待执行” 的客户端,按顺序执行解析后的命令(如 GETSET 等);
  • 命令执行过程中,主线程独占数据访问权(无多线程竞争),保证原子性和隔离性;
  • 执行结果暂存在客户端的响应缓冲区中,等待发送。
4. 发送响应阶段(IO 线程并行发送)

命令执行完成后,主线程与 IO 线程协作将结果返回给客户端:

  • 步骤 1:主线程收集所有待发送响应的客户端连接,再次平均分配给各个 IO 线程。
  • 步骤 2:主线程通过管道通知 IO 线程处理写任务,标记线程状态为 “运行中”。
  • 步骤 3:IO 线程被唤醒后,执行 processPendingWrites 函数:
    • 循环将客户端响应缓冲区中的数据写入套接字(发送给客户端);
    • 若数据发送完毕,清理客户端状态;若未发送完毕(如数据量大),则下次继续发送。
  • 步骤 4:主线程等待所有 IO 线程完成写任务,然后进入下一轮事件循环。处理)

更多资料:https://github.com/0voice


网站公告

今日签到

点亮在社区的每一天
去签到