前言
Redis 作为高性能的内存数据库,其客户端与服务端的通信机制是每个后端开发者必须掌握的核心知识。本文将从协议原理、Pipeline 模式、事务机制、Lua 脚本、异步连接等多个维度,全面剖析 Redis 的通信原理,帮助你彻底理解 Redis 的客户端与服务端交互本质。
一、Redis 协议(RESP)
1.1 协议概述
Redis 采用 RESP(REdis Serialization Protocol) 作为通信协议。RESP 设计简洁、易于解析、支持多种数据类型,同时具备很好的可读性。
协议特点:
- 文本协议,便于调试和理解
- 二进制安全,支持任意数据
- 解析效率高,开销小
1.2 五种数据类型标识
| 标识 | 类型 | 说明 |
|---|---|---|
+ |
简单字符串 | 如 +OK\r\n |
- |
错误 | 如 -ERR unknown command\r\n |
: |
整数 | 如 :1000\r\n |
$ |
字符串(Bulk String) | $5\r\nhello\r\n |
* |
数组 | *3\r\n... |
1.3 协议格式详解
简单字符串:
+OK\r\n
错误:
-ERR unknown command 'foobar'\r\n
整数:
:10086\r\n
Bulk String(含空值):
$5\r\n
hello\r\n
$-1\r\n // Null Bulk String
数组:
*3\r\n
$3\r\n
SET\r\n
$3\r\n
key\r\n
$5\r\n
value\r\n
上述协议对应命令:SET key value
1.4 协议解析过程
Redis 服务端接收到数据后,按照以下流程解析:
1. 读取数据到输入缓冲区
2. 检测首字符确定数据类型
3. 根据类型读取对应长度数据
4. 组装成 RedisClient 对象
5. 执行命令并返回响应
二、阻塞 IO 与 Pipeline 模式
2.1 阻塞 IO 模型
传统阻塞 IO 是 Redis 客户端的默认驱动模式:
┌────────┐ ┌────────┐
│ 客户端 │─── 请求1 ─────────▶│ Redis │
│ 线程 │◀── 响应1 ─────────│ │
└────────┘ └────────┘
▼ 阻塞等待
┌────────┐ ┌────────┐
│ 客户端 │─── 请求2 ─────────▶│ Redis │
│ 线程 │◀── 响应2 ─────────│ │
└────────┘ └────────┘
执行流程:
- 客户端发起
GET key请求 - 线程阻塞,等待网络响应
- Redis 处理命令并返回结果
- 客户端线程被唤醒,获取结果
存在的问题:
- 每个命令需要一次完整的网络往返(RTT)
- 线程在等待期间无法执行其他任务
- 高延迟场景下性能较差
2.2 Pipeline 模式详解
Pipeline 是客户端提供的机制,核心思想是:将多条 Redis 命令打包,在一次网络交互中发送,服务端处理完成后一次性返回结果。
┌────────┐
│ 客户端 │─── SET k1 v1 ─┐
│ │─── GET k1 ─────┼──▶ Redis(一次网络交互)
│ │─── DEL k2 ─────┤ │
│ │─── INCR cnt ───┘ │
└────────┘ ▼
┌────────────┐
│ [1] OK │
│ [2] v1 │
│ [3] 1 │
│ [4] 1 │
└────────────┘
关键点:
- 只产生 一次网络往返
- Redis 服务端内部 并行处理 多条命令
- 客户端需要自行解析多条响应
2.3 Pipeline vs 普通模式对比
| 特性 | 普通模式 | Pipeline 模式 |
|---|---|---|
| 网络往返次数 | N 次(N 条命令) | 1 次 |
| 线程状态 | 阻塞等待 | 一次等待 |
| 延迟 | O(N × RTT) | O(1 × RTT) |
| 内存占用 | 低 | 需要缓存所有命令和响应 |
2.4 Pipeline 使用示例(hiredis)
c
#include <hiredis/hiredis.h>
redisContext *c = redisConnect("127.0.0.1", 6379);
redisReply *reply;
// 创建 Pipeline
redisAppendCommand(c, "SET key1 value1");
redisAppendCommand(c, "GET key1");
redisAppendCommand(c, "INCR counter");
redisAppendCommand(c, "DEL key2");
// 批量获取结果
for (int i = 0; i < 4; i++) {
redisGetReply(c, (void**)&reply);
printf("Result %d: %s\n", i, reply->str);
freeReplyObject(reply);
}
三、Redis 事务机制
3.1 为什么需要事务?
考虑这样一个场景:电商系统中,商品库存操作:
客户端 A:GET stock → 10
客户端 A:SET stock 9 → 库存减 1(期望)
客户端 B:SET stock 100 → 中途修改了库存
如果 A 的两步操作之间,B 修改了库存,就会导致数据不一致。这种多条命令存在依赖关系的场景,就需要事务来保证原子性。
3.2 事务命令
bash
MULTI # 开启事务,后续命令进入事务队列
SET key1 v1
SET key2 v2
GET key1
EXEC # 执行事务队列中的所有命令
执行过程:
MULTI
→ +OK
SET key1 v1
→ +QUEUED
SET key2 v2
→ +QUEUED
EXEC
→ *2 // 返回 2 个结果
→ +OK
→ +OK
3.3 WATCH 乐观锁(CAS 机制)
问题场景: 事务执行期间,其他客户端修改了被监视的 key,导致事务执行失败。
bash
WATCH user:100:balance # 监视 key
MULTI
DECRBY user:100:balance 100 # 扣款
INCRBY user:200:balance 100 # 收款
EXEC # 如果 user:100:balance 在此期间被修改,事务取消
执行结果:
- 如果 key 未被修改:
EXEC返回事务执行结果 - 如果 key 被修改:
EXEC返回nil(空),事务自动取消
乐观锁 vs 悲观锁:
| 特性 | 乐观锁(WATCH) | 悲观锁(SELECT FOR UPDATE) |
|---|---|---|
| 实现 | 检测变动再决定是否执行 | 直接加锁,阻塞其他连接 |
| 适用场景 | 读多写少 | 写并发高 |
| 数据库 | Redis | MySQL |
| 实现机制 | CAS | 锁机制 |
3.4 FLUSHALL 清空数据库
bash
FLUSHALL # 清空所有数据库
FLUSHDB # 清空当前数据库
⚠️ 注意:此操作不可逆,生产环境中务必谨慎使用。
四、Lua 脚本
4.1 为什么需要 Lua 脚本?
Redis 事务(MULTI/EXEC)存在一个关键问题:事务队列中的命令在 EXEC 时逐条执行,如果某条命令出错,已执行的命令不会回滚。
事务的原子性缺陷示例:
MULTI
SET account:a 100
DECRBY account:a 50 -- 执行成功
INCRBY account:a 200 -- 执行出错(类型错误)
EXEC
-- 结果:account:a = 150,而不是原来的 100
因此,Redis 提供了 Lua 虚拟机 来实现真正的原子操作。
4.2 Lua 脚本工作原理
┌─────────────────────────────────────────────────────────┐
│ Redis Server │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Lua 虚拟机 │ │
│ │ │ │
│ │ redis.call('SET', KEYS[1], ARGV[1]) │ │
│ │ local val = redis.call('GET', KEYS[1]) │ │
│ │ redis.call('INCRBY', KEYS[1], val) │ │
│ │ │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
执行特点:
- Lua 脚本在 Redis 内原子执行
- 脚本执行期间,不允许其他命令插入
- 脚本执行完毕,一次性返回所有结果
4.3 Lua 脚本基础语法
lua
-- 简单的 SET/GET 操作
redis.call('SET', KEYS[1], ARGV[1])
return redis.call('GET', KEYS[1])
lua
-- 实现 INCRBY 原子加倍
local current = redis.call('GET', KEYS[1])
if current == false then
current = 0
else
current = tonumber(current)
end
current = current * 2
redis.call('SET', KEYS[1], current)
return current
EVAL 命令:
bash
EVAL "redis.call('SET', KEYS[1], ARGV[1]) return redis.call('GET', KEYS[1])" 1 "mykey" "hello"
1表示 key 的数量mykey是 KEYS[1]hello是 ARGV[1]
4.4 脚本缓存机制
频繁发送相同的 Lua 脚本会造成网络浪费,Redis 提供了脚本缓存机制:
bash
-- 先加载脚本,获取 SHA1 哈希值
SCRIPT LOAD "redis.call('SET', KEYS[1], ARGV[1])"
# 返回:abcdef1234567890...
-- 之后通过哈希值执行脚本(避免重复传输)
EVALSHA "abcdef1234567890..." 1 "mykey" "hello"
SHA1 哈希值的应用:
- Git 的 SHA1 Commit ID 同样使用 SHA1 算法
- SRP6(安全远程密码协议)也使用 SHA1 哈希
4.5 脚本管理命令
bash
SCRIPT EXISTS <sha1> [<sha1> ...] # 检查脚本是否已缓存
SCRIPT FLUSH # 清除所有脚本缓存
SCRIPT KILL # 杀死正在运行的脚本
4.6 服务端预编译策略
在大型项目中,通常在服务启动时预编译所有 Lua 脚本:
1. 启动时加载所有 Lua 脚本文件
2. 调用 SCRIPT LOAD 获取哈希值
3. 存入 unordered_map<string hash, string script>
4. 运行时通过哈希值直接调用
五、ACID 特性深度分析
5.1 原子性(Atomicity)
定义: 事务是一个不可分割的工作单位,要么全部成功,要么全部失败。
Redis 的实现:
- Redis 不支持回滚(ROLLBACK)
- 即使事务队列中的某条命令执行出错,整个事务仍会继续执行完毕
- 已执行的命令不会撤销
示例:
MULTI
SET a 1
INCR a -- 将字符串自增,会出错
SET b 2
EXEC
-- 结果:a = 1, b = 2,INCR a 的错误被忽略
5.2 一致性(Consistency)
定义: 事务执行前后,数据保持一致状态,不能违反数据的完整性约束。
Redis 的实现:
| 一致性类型 | 说明 | Redis 支持 |
|---|---|---|
| 数据完整性约束 | 不能违反数据类型、范围等约束 | ✅ 支持 |
| 业务逻辑一致性 | 转账场景中扣款和收款必须同时成功或失败 | ❌ 不支持 |
逻辑一致性被破坏的示例:
场景:转账 A → B,金额 100 元
正常情况:
1. A 账户扣 100 → A: 900
2. B 账户加 100 → B: 1100
异常情况:
1. A 账户扣 100 → A: 900 ✓
2. B 账户加 100 → B: 1100 ✓
3. 事务提交完成
结果:A 少了 100,B 没加 100 → **系统凭空少了 100 元**
5.3 隔离性(Isolation)
定义: 各事务之间互不干扰,并发执行的结果与串行执行一致。
Redis 的实现:
- Redis 采用单线程执行模型
- 天然具备完全隔离性
- 事务执行期间,不会被其他命令插入
5.4 持久性(Durability)
定义: 事务提交后,数据必须永久保存。
Redis 的持久化策略:
| 持久化策略 | 配置 | 持久性 |
|---|---|---|
| RDB | 定时快照 | ❌ 丢失最后一次快照后的数据 |
| AOF (appendfsync = no) | 异步写入 | ❌ 可能丢失部分数据 |
| AOF (appendfsync = everysec) | 每秒同步 | ⚠️ 最多丢失 1 秒数据 |
| AOF (appendfsync = always) | 每次操作同步 | ✅ 完全持久化 |
⚠️ 实际项目中,几乎不会使用 appendfsync = always,因为性能损失太大。
5.5 ACID 特性总结
| 特性 | 支持情况 | 说明 |
|---|---|---|
| 原子性 | ⚠️ 部分支持 | 不支持回滚,错误命令不会撤销 |
| 一致性 | ❌ 不满足 | 不支持业务逻辑一致性 |
| 隔离性 | ✅ 完全支持 | 单线程天然隔离 |
| 持久性 | ⚠️ 可配置 | 仅 AOF + always 时满足 |
5.6 面试标准回答
Redis + Lua 脚本:满足原子性和隔离性,但一致性和持久性不是全部满足。
六、分布式锁
6.1 分布式锁的应用场景
在分布式系统中,多个进程可能同时访问共享资源,需要互斥机制保证数据一致性。
典型场景:
- 秒杀系统:防止超卖
- 任务调度:防止重复执行
- 库存扣减:防止超卖
6.2 错误的锁释放方式
bash
GET dislock -- 检查锁持有者
→ "uuid-12345"
DEL dislock -- 删除锁(问题:可能删除了他人的锁)
问题分析:
时间线:
T1: 进程 A 获取锁(uuid-A),持有锁
T2: 锁过期自动释放
T3: 进程 B 获取锁(uuid-B)
T4: 进程 A 执行完毕,检查锁是自己的 uuid-A
T5: 进程 A 删除锁 → 进程 B 的锁被误删!
T6: 进程 C 获取锁 → 与进程 B 并发访问 → 数据不一致
6.3 Lua 脚本实现原子释放
lua
local uuid = redis.call("GET", KEYS[1])
if uuid == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
完整分布式锁实现:
lua
-- 加锁(SET NX EX)
SET lock_key unique_id NX EX 30
-- 释放锁(Lua 脚本)
local uuid = redis.call("GET", KEYS[1])
if uuid == ARGV[1] then
redis.call("DEL", KEYS[1])
return 1
else
return 0
end
七、异步连接
7.1 同步连接 vs 异步连接
| 特性 | 同步连接 | 异步连接 |
|---|---|---|
| 连接方式 | 阻塞等待 | 事件驱动 |
| 线程模型 | 每连接一线程 | 单线程/少线程 + 事件循环 |
| 等待方式 | 线程阻塞 | 事件回调 |
| 并发能力 | 低(受线程数限制) | 高(受 IO 能力限制) |
| 适用场景 | 低并发、简单逻辑 | 高并发、IO 密集型 |
7.2 Reactor 模型处理连接流程
1. socket() → 创建 TCP socket
2. fcntl() → 设置为非阻塞模式
3. connect() → 发起连接(非阻塞)
4. 注册写事件 → 等待连接建立完成
5. 写事件触发 → 连接建立成功
6. 注销写事件 → 注册读事件
7. 读事件触发 → 接收数据、处理业务
7.3 hiredis 异步连接详解
hiredis 是 Redis 官方提供的高性能 C 客户端库,提供了异步 IO 支持。
7.3.1 事件适配器
使用 hiredis 异步连接,需要适配事件对象:
c
// 事件对象结构
struct event_s {
int fd; // 文件描述符
void (*callback)(int fd); // 回调函数
};
// 事件操作函数
event_t *new_event(int fd, void (*callback)(int));
void add_event(event_t *e); // 注册事件
void del_event(event_t *e); // 删除事件
void enable_event(event_t *e); // 启用事件
7.3.2 异步连接代码示例
c
#include <hiredis/async.h>
// 自定义事件适配
typedef struct {
int fd;
void (*read_cb)(int fd);
void (*write_cb)(int fd);
} my_event_t;
void onConnect(const redisAsyncContext *c) {
printf("Connected!\n");
}
void onDisconnect(const redisAsyncContext *c) {
printf("Disconnected!\n");
}
int main() {
redisAsyncContext *c = redisAsyncConnect("127.0.0.1", 6379);
// 适配事件到 libev/libevent
redisLibevAttach(EV_DEFAULT_ &c->ev);
// 设置回调
redisAsyncSetConnectCallback(c, onConnect);
redisAsyncSetDisconnectCallback(c, onDisconnect);
// 发起异步命令
redisAsyncCommand(c, callback, NULL, "SET %s %s", "key", "value");
redisAsyncCommand(c, callback, NULL, "GET %s", "key");
event_loop(EV_DEFAULT);
return 0;
}
7.4 hiredis 异步连接职责划分
| 模块 | 实现方 | 说明 |
|---|---|---|
| 协议解析 | hiredis | RESP 协议编解码 |
| 读写事件 | hiredis + 用户适配 | 注册/触发读写的 IO 事件 |
| 缓冲区设计 | hiredis | 读写数据缓冲区管理 |
| 缓冲区操作 | hiredis | 数据的读写、扩容、收缩 |
| 协议加密 | 用户实现 | SSL/TLS 加密层 |
| 事件对象 | 用户实现 | new_event / add_event / del_event |
关键理解:
- hiredis 负责协议层面的解析和缓冲
- 用户需要提供事件机制(libev/libevent/libuv 等)
- 两者通过适配器模式结合
八、实战:hiredis 完整异步示例
c
#include <hiredis/hiredis.h>
#include <hiredis/async.h>
#include <ev.h>
// 全局事件循环
struct ev_loop *loop;
// 异步命令回调
void callback(redisAsyncContext *c, void *r, void *priv) {
redisReply *reply = r;
if (reply == NULL) {
printf("Error: %s\n", c->errstr);
return;
}
printf("Reply: %s\n", reply->str);
}
// 连接建立回调
void connectCallback(const redisAsyncContext *c) {
printf("Connected to Redis\n");
// 发起异步命令
redisAsyncCommand(c, callback, NULL, "SET %s %s", "async_key", "async_value");
redisAsyncCommand(c, callback, NULL, "GET %s", "async_key");
}
// 连接断开回调
void disconnectCallback(const redisAsyncContext *c) {
printf("Disconnected from Redis\n");
ev_break(loop, EVBREAK_ALL);
}
int main() {
loop = EV_DEFAULT;
// 创建异步上下文
redisAsyncContext *c = redisAsyncConnect("127.0.0.1", 6379);
if (c->err) {
printf("Connection error: %s\n", c->errstr);
return 1;
}
// 适配 libev 事件循环
redisLibevAttach(loop, c);
// 设置回调
redisAsyncSetConnectCallback(c, connectCallback);
redisAsyncSetDisconnectCallback(c, disconnectCallback);
// 启动事件循环
ev_loop(loop, 0);
return 0;
}
编译:
bash
gcc -o async_redis async_redis.c -lhiredis -lev
九、总结
9.1 知识点汇总
| 模块 | 核心概念 | 应用场景 |
|---|---|---|
| RESP 协议 | 文本协议、5 种数据类型 | 理解 Redis 通信基础 |
| Pipeline | 客户端批量发送、一次 RTT | 批量操作、优化性能 |
| 事务(MULTI/EXEC) | 命令队列、批量执行 | 简单的多命令原子操作 |
| WATCH | 乐观锁、CAS | 冲突检测、并发控制 |
| Lua 脚本 | 原子执行、预编译缓存 | 复杂业务逻辑、分布式锁 |
| ACID | 原子性、隔离性 | 事务特性分析 |
| 异步连接 | 事件驱动、非阻塞 IO | 高并发网络编程 |
根据零声教育教学写作https://github.com/0voice