一、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 特点
非原子性:Pipeline 不保证事务性,命令按顺序执行,但中间若某命令失败,后续命令仍会继续执行(与
MULTI/EXEC
事务不同)。顺序性:服务器按接收顺序执行 Pipeline 中的命令,响应结果也与命令顺序一一对应。
适用场景:
- 批量读写操作(如批量设置多个键值对)。
- 非依赖型命令(命令之间无因果关系,不需要前一个命令的结果作为后一个的参数)。
1.3 Redis 事务
Redis 事务是一组命令的集合,通过 MULTI
、EXEC
等命令将多个操作封装为一个不可分割的工作单元,要么全部执行,要么全部不执行(特殊情况除外,见后文说明)。它主要用于保证一系列操作的原子性,避免中间被其他命令干扰
Redis 事务通过以下命令实现完整流程:
命令 | 作用 |
---|---|
MULTI |
开启事务,后续命令进入 “队列” 等待执行,而非立即执行 |
EXEC |
执行事务队列中的所有命令,返回各命令的结果(按入队顺序) |
DISCARD |
取消事务,清空队列,放弃执行 |
WATCH |
监控一个或多个键,若事务执行前被监控的键发生变动,则事务被打断(乐观锁) |
Redis 事务的特点
原子性限制:
- 若事务中命令存在语法错误(如命令不存在),
EXEC
会直接放弃所有命令(全部不执行)。 - 若命令语法正确但运行时错误(如对字符串执行
LPOP
),错误命令会失败,其他命令仍会执行,不回滚 ,这与传统数据库事务的 “完全回滚” 不同,Redis 不支持部分失败后的回滚,需业务层处理。
- 若事务中命令存在语法错误(如命令不存在),
顺序性:事务中的命令按入队顺序执行,不会被其他客户端的命令插入。
乐观锁机制:通过
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 配置(
everysec
或no
),事务结果可能因崩溃丢失。
- 若使用 AOF 持久化 且配置
实际场景:生产环境极少使用 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.tech
、news.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 线程” 的混合模型,核心流程如下:
接收请求阶段:
- 主线程监听客户端连接,当有新请求到达时,将连接分配给 IO 线程。
- 多个 IO 线程并行读取客户端发送的命令数据(解析成 Redis 协议格式),并暂存到队列中。
命令执行阶段:
- 主线程从队列中取出所有解析好的命令,按顺序执行(保持单线程特性,保证命令的原子性和隔离性)。
发送响应阶段:
- 主线程将命令执行结果分发给 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-threads
和io-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 线程完成请求解析后,主线程接管后续流程:
- 主线程遍历所有 “待执行” 的客户端,按顺序执行解析后的命令(如
GET
、SET
等); - 命令执行过程中,主线程独占数据访问权(无多线程竞争),保证原子性和隔离性;
- 执行结果暂存在客户端的响应缓冲区中,等待发送。
4. 发送响应阶段(IO 线程并行发送)
命令执行完成后,主线程与 IO 线程协作将结果返回给客户端:
- 步骤 1:主线程收集所有待发送响应的客户端连接,再次平均分配给各个 IO 线程。
- 步骤 2:主线程通过管道通知 IO 线程处理写任务,标记线程状态为 “运行中”。
- 步骤 3:IO 线程被唤醒后,执行
processPendingWrites
函数:- 循环将客户端响应缓冲区中的数据写入套接字(发送给客户端);
- 若数据发送完毕,清理客户端状态;若未发送完毕(如数据量大),则下次继续发送。
- 步骤 4:主线程等待所有 IO 线程完成写任务,然后进入下一轮事件循环。处理)