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 事务和管道的核心关系:

Redis 批量操作核心机制 事务(MULTI/EXEC) 管道(Pipeline) MULTI 开启 命令入队 EXEC 执行 DISCARD 放弃 批量发送 服务端执行 批量读取 减少网络开销 WATCH 乐观锁:监视 key,变化则放弃事务 组合用法:管道 + 事务 = 减少网络开销 + 保证隔离性

五、避坑指南

我曾经在一个项目中,用管道批量插入 10 万条数据,结果 Redis 服务器直接卡死了。为什么?因为管道里的命令太多,Redis 来不及处理,缓冲区爆了。

几个实用建议:

  • 管道不要一次塞太多命令,建议控制在 1000 条以内
  • 事务中的命令不要太多,否则会阻塞其他客户端太久
  • WATCH 的 key 不要太多,否则性能会下降
  • 如果只是批量读取数据,用管道就够了,不需要事务
  • 如果需要真正的原子性,考虑用 Lua 脚本

小技巧:用管道时,记得设置合理的超时时间。如果 Redis 服务器挂了,你的客户端可能一直卡在 redisGetReply 上。我一般会设置 5 秒的超时,超过就重连。

好了,Redis 事务和管道就讲到这里。记住一句话:事务管隔离,管道管性能。两者可以配合使用,但别搞混了它们的职责。


公众号:蓝海资料掘金营,微信 deep3321