49、Redis事务与管道:MULTI/EXEC/DISCARD、WATCH乐观锁、管道(pipeline)批量操作
Redis 的事务和管道,这两个概念我经常看到有人搞混。说实话,它们虽然都能“批量干活”,但本质完全不同。今天咱们就把它们掰开揉碎了讲清楚。
一、Redis 事务:MULTI / EXEC / DISCARD
Redis 的事务,跟 MySQL 那种 ACID 事务不太一样。它更像是一个“命令队列”。你用 MULTI 开启事务,后续的命令不会立即执行,而是排队。直到你调用 EXEC,这些命令才按顺序一次性执行。
核心特点:
- 事务中的所有命令,要么全部执行,要么全部不执行(DISCARD 时)
- 执行过程中,不会被其他客户端的命令打断
- 但不保证原子性——如果某条命令执行失败,后续命令仍然会执行
基本用法
// 开启事务
redisCommand(ctx, "MULTI");
// 入队命令
redisCommand(ctx, "SET key1 value1");
redisCommand(ctx, "SET key2 value2");
redisCommand(ctx, "INCR counter");
// 执行事务
redisReply *reply = redisCommand(ctx, "EXEC");
if (reply == NULL) {
printf("事务执行失败\n");
} else {
printf("事务执行成功,返回 %d 条结果\n", reply->elements);
freeReplyObject(reply);
}
如果你中途想放弃,用 DISCARD 清空队列:
redisCommand(ctx, "MULTI");
redisCommand(ctx, "SET key1 value1");
// 突然发现不对,放弃
redisCommand(ctx, "DISCARD");
// 此时 key1 不会被设置
注意:Redis 事务不支持回滚。如果命令入队时语法错误,整个事务会失败。但如果运行时出错(比如对字符串执行 INCR),错误命令会失败,其他命令照常执行。这一点跟关系型数据库差别很大,我当年第一次用的时候就踩过这个坑。
二、WATCH 乐观锁
Redis 的 WATCH 命令,说白了就是“乐观锁”。它用来解决事务中的竞态条件问题。
举个例子:假设你要实现一个转账功能,先查余额,再扣减。如果不用锁,两个客户端同时操作,余额可能变成负数。
用 WATCH 可以这样:
// 监视 balance 这个 key
redisCommand(ctx, "WATCH balance");
// 读取当前余额
redisReply *reply = redisCommand(ctx, "GET balance");
int balance = atoi(reply->str);
freeReplyObject(reply);
if (balance < 100) {
// 余额不足,放弃
redisCommand(ctx, "UNWATCH");
return;
}
// 开启事务
redisCommand(ctx, "MULTI");
redisCommand(ctx, "DECRBY balance 100");
redisCommand(ctx, "INCRBY other 100");
// 执行事务
reply = redisCommand(ctx, "EXEC");
if (reply == NULL) {
// 说明在 WATCH 之后,balance 被其他客户端修改了
// 事务被放弃,需要重试
printf("余额已变化,请重试\n");
} else {
printf("转账成功\n");
freeReplyObject(reply);
}
工作原理:
WATCH监视一个或多个 key- 如果这些 key 在
EXEC之前被其他客户端修改了 - 当前事务会被自动放弃,
EXEC返回 NULL - 你需要重新读取数据,再试一次
我的经验:WATCH 适合并发冲突不频繁的场景。如果冲突很频繁,每次都要重试,性能反而下降。我在做秒杀系统时,就用了 WATCH + 重试机制,效果还不错。但如果是高并发扣库存,我建议用 Lua 脚本,后面会讲到。
三、管道(Pipeline)批量操作
管道跟事务完全是两码事。管道解决的是“网络开销”问题。
你想想看,每次发一条命令,都要经过一次网络往返。如果一次要发 1000 条命令,那就是 1000 次网络往返。这太慢了。
管道允许你把多条命令打包,一次性发给 Redis 服务器,然后一次性读取所有回复。这样网络往返次数从 N 次降到了 1 次。
C 语言中的管道实现
// 使用 hiredis 的管道功能
redisAppendCommand(ctx, "SET key1 value1");
redisAppendCommand(ctx, "SET key2 value2");
redisAppendCommand(ctx, "GET key1");
redisAppendCommand(ctx, "GET key2");
// 读取所有回复
redisReply *reply;
for (int i = 0; i < 4; i++) {
if (redisGetReply(ctx, (void**)&reply) == REDIS_OK) {
printf("命令 %d 返回: %s\n", i, reply->str);
freeReplyObject(reply);
}
}
管道 vs 事务:
| 特性 | 事务(MULTI/EXEC) | 管道(Pipeline) |
|---|---|---|
| 主要目的 | 保证命令的隔离执行 | 减少网络往返次数 |
| 原子性 | 不保证(部分失败) | 不保证 |
| 执行时机 | EXEC 时一次性执行 | 发送后立即执行 |
| 是否可被中断 | 执行期间不会被其他命令插入 | 命令之间可能被其他客户端插入 |
| 返回值 | 所有命令的返回值数组 | 每个命令独立返回 |
管道 + 事务的组合用法
你还可以把管道和事务结合起来用:
// 通过管道发送事务命令
redisAppendCommand(ctx, "MULTI");
redisAppendCommand(ctx, "SET key1 value1");
redisAppendCommand(ctx, "SET key2 value2");
redisAppendCommand(ctx, "EXEC");
// 读取回复
redisReply *reply;
for (int i = 0; i < 4; i++) {
redisGetReply(ctx, (void**)&reply);
freeReplyObject(reply);
}
这样既减少了网络往返,又保证了事务的隔离性。我个人习惯在批量写入时用这种组合方式。
四、知识体系图
下面这张图帮你理清 Redis 事务和管道的核心关系:
五、避坑指南
我曾经在一个项目中,用管道批量插入 10 万条数据,结果 Redis 服务器直接卡死了。为什么?因为管道里的命令太多,Redis 来不及处理,缓冲区爆了。
几个实用建议:
- 管道不要一次塞太多命令,建议控制在 1000 条以内
- 事务中的命令不要太多,否则会阻塞其他客户端太久
- WATCH 的 key 不要太多,否则性能会下降
- 如果只是批量读取数据,用管道就够了,不需要事务
- 如果需要真正的原子性,考虑用 Lua 脚本
小技巧:用管道时,记得设置合理的超时时间。如果 Redis 服务器挂了,你的客户端可能一直卡在 redisGetReply 上。我一般会设置 5 秒的超时,超过就重连。
好了,Redis 事务和管道就讲到这里。记住一句话:事务管隔离,管道管性能。两者可以配合使用,但别搞混了它们的职责。
公众号:蓝海资料掘金营,微信 deep3321