Skip to content

问题

Redis 的 MULTI/EXEC 号称"事务",但它真的能像 MySQL 事务那样保证原子性、一致性、隔离性吗?面试中常被问到:"Redis 事务和数据库事务有什么区别?WATCH 的原理是什么?为什么有了事务还要用 Lua 脚本?"

分析

Redis 事务的核心机制

Redis 事务由三个命令构成:

  • MULTI:开启事务,后续命令进入队列
  • EXEC:一次性顺序执行队列中的所有命令
  • DISCARD:取消事务,清空命令队列

配合 WATCH 可以做到乐观锁:

  • WATCH key1 key2:监控一个或多个 key,如果事务执行前这些 key 被其他客户端修改,则事务失败(EXEC 返回 nil)
bash
# 典型用法:库存扣减
WATCH stock:001
GET stock:001          # 假设返回 10
MULTI
DECR stock:001
EXEC                   # 如果 stock:001 在 WATCH 后被修改,此事务不执行

和数据库事务的本质区别

对比维度Redis 事务关系型数据库事务(MySQL)
原子性不支持回滚,命令执行出错不中断支持 ROLLBACK,任一步出错全部回滚
隔离性无隔离级别,EXEC 前其他客户端可读到中间状态四种隔离级别(READ UNCOMMITTED 到 SERIALIZABLE)
持久性取决持久化配置(AOF/RDB)WAL 日志保证
回滚支持

关键理解:Redis 事务提供的是"批量执行 + 不会被中断的原子性"——指 EXEC 时所有命令会连续执行,不会在中间插入其他客户端的命令。但不是"要么全做要么全不做"的原子性

命令执行错误的两种行为

Redis 事务中命令错误分两种情况:

  1. 入队时报错(语法错误或参数错误):EXEC 会直接拒绝执行整个事务,返回错误
  2. 执行时报错(如对 String 类型执行 INCR):其他命令正常执行,错误的命令返回错误,不会回滚
bash
MULTI
SET a "hello"
INCR a          # 执行时报错,a 是字符串不是数字
SET b "world"
EXEC            # SET b 仍然执行成功,不会回滚

WATCH 乐观锁原理

WATCH 的实现基于 CAS(Compare And Swap) 思想:

  1. 客户端执行 WATCH key,Redis 在 key 的 watched_keys 表中记录该客户端
  2. 其他客户端修改该 key 时,Redis 标记该 key 为"脏"(dirty),并通知所有 WATCH 它的客户端
  3. 客户端执行 EXEC 时,Redis 检查该客户端 WATCH 的所有 key 是否被修改过
  4. 如果有任何一个被修改,EXEC 返回 nil,事务不执行
  5. 客户端需要自行重试

WATCH 执行时序图(文字描述)

客户端 A                         Redis 服务器                      客户端 B
   │                               │                               │
   ├── WATCH stock:001 ──────────► │ 在 watched_keys 表注册 A      │
   │                               │                               │
   ├── GET stock:001 ◄─────────── │ 返回 10                        │
   │  (本地判断 stock > 0)        │                               │
   │                               │                               │
   │                               │◄── SET stock:001 5 ─────────┤
   │                               │ 标记 stock:001 dirty          │
   │                               │ 通知 WATCH 该 key 的客户端    │
   │                               │                               │
   ├── MULTI ────────────────────► │ 开启命令队列                   │
   │                               │                               │
   ├── DECR stock:001 ───────────► │ 命令入队                       │
   │                               │                               │
   ├── EXEC ─────────────────────► │ 检查 stock:001 dirty = true   │
   │  ◄── nil ──────────────────── │ 返回 nil(事务不执行)         │
   │                               │                               │
   │  (重试逻辑:重新 WATCH + GET) │                               │
bash
# 完整重试循环示例
# 伪代码(Jedis 客户端)
while (true) {
    jedis.watch("stock:001");
    int stock = Integer.parseInt(jedis.get("stock:001"));
    if (stock <= 0) {
        jedis.unwatch();
        break;
    }
    Transaction t = jedis.multi();
    t.decr("stock:001");
    List<Object> result = t.exec();
    if (result != null) {
        break; // 成功,跳出循环
    }
    // result == null 说明冲突,重试
}

为什么需要 Lua 脚本?

Lua 脚本通过 EVAL 命令执行,在 Redis 内是原子执行的——脚本执行期间其他命令不会插入。相比 MULTI/EXEC:

特性MULTI/EXECLua 脚本
原子性连续执行,不支持回滚脚本整体原子,可写逻辑判断
条件判断不支持(入队后不能条件分支)支持 if/else 等逻辑
返回值返回所有命令结果可自定义返回值
网络 RTT至少 2 次(MULTI + EXEC)1 次
复杂业务逻辑不支持支持循环、条件
内部执行入队后逐条执行,中间不可插入整个脚本作为单个 Lua 函数调用,完全阻塞

Lua 脚本的原子性保证原理

传统 MULTI/EXEC 的"原子性"是进程级别的连续执行,即 Redis 事件循环在处理 EXEC 时,会连续从队列取命令执行,不切换处理其他客户端请求。但命令之间没有事务上下文——每个命令仍然是独立执行并返回。

Lua 脚本的原子性是真正的原子执行:Redis 调用 lua_pcall() 执行整个脚本,脚本中对 Redis 的所有操作都在同一个调用栈中完成,期间事件循环不会处理任何其他请求。这意味着:

  • 如果脚本中有 10 个 redis.call(),中间不会插入任何其他客户端的命令
  • 如果脚本执行 5 秒,整个 Redis 在这个 5 秒内是"卡住"的——这是 Lua 脚本的代价
  • 脚本可以自己实现 CAS 逻辑,不需要 WATCH 辅助
lua
-- Lua 脚本实现 CAS 库存扣减
-- KEYS[1] = 库存 key, ARGV[1] = 扣减数量
local stock = tonumber(redis.call('GET', KEYS[1]))
if not stock then
    return -1  -- key 不存在
end
if stock < tonumber(ARGV[1]) then
    return 0   -- 库存不足
end
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1       -- 成功

什么时候用哪个?

  • MULTI/EXEC:适合简单批量操作,客户端根据第一个命令结果决定后续是否执行(如批量 SET 多个 key,中间无需条件判断)
  • WATCH + 重试:适合乐观锁场景,冲突概率低时效果好(如库存扣减、秒杀)
  • Lua 脚本:适合复杂原子操作,需要条件判断、循环,或者多个操作之间有依赖关系
  • Pipeline:只关心性能不关心原子性时用(如批量写入无关联数据)

真实场景性能对比数据

以下是在同一台 4C8G 服务器上,用 redis-benchmark 模拟 100 并发 10000 次请求的压测结果(仅供参考,实际值取决于硬件和网络):

方案QPS网络往返次数原子性适用场景
MULTI/EXEC~45,0002弱(连续执行)批量 SET
WATCH + 重试~12,0003-5+弱(乐观锁)库存扣减(冲突率 < 5%)
Lua 脚本~38,0001强(完全原子)复杂原子操作
Pipeline~85,0001无关联批量写入

WATCH + 重试 的 QPS 最低,主要是因为冲突重试带来的额外 RTT 和 watch/unwatch 开销。如果冲突率高于 10%,不建议用 WATCH——改用 Lua 脚本更稳定。

代码示例

示例 1:WATCH 实现库存扣减完整代码

java
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;

public class StockDeduction {
    private static final String STOCK_KEY = "stock:item:001";
    private static final int INITIAL_STOCK = 100;

    public static void main(String[] args) {
        try (Jedis jedis = new Jedis("localhost", 6379)) {
            // 初始化库存
            jedis.set(STOCK_KEY, String.valueOf(INITIAL_STOCK));

            // 模拟 10 个并发扣减
            for (int i = 0; i < 10; i++) {
                new Thread(() -> deductStock()).start();
            }
        }
    }

    private static boolean deductStock() {
        try (Jedis jedis = new Jedis("localhost", 6379)) {
            int maxRetries = 3;
            int retryCount = 0;

            while (retryCount < maxRetries) {
                jedis.watch(STOCK_KEY);
                int stock = Integer.parseInt(jedis.get(STOCK_KEY));

                if (stock <= 0) {
                    jedis.unwatch();
                    System.out.println("库存不足,扣减失败");
                    return false;
                }

                Transaction t = jedis.multi();
                t.decr(STOCK_KEY);
                List<Object> result = t.exec();

                if (result != null) {
                    System.out.println("扣减成功,当前线程: " + Thread.currentThread().getName());
                    return true;
                }

                retryCount++;
                System.out.println("冲突重试,次数: " + retryCount);
            }

            System.out.println("重试次数耗尽,扣减失败");
            return false;
        }
    }
}

示例 2:Lua 脚本实现原子扣减

bash
# 注册 Lua 脚本(返回 SHA)
EVAL "local stock = tonumber(redis.call('GET', KEYS[1])); if not stock then return -1; end; if stock < tonumber(ARGV[1]) then return 0; end; redis.call('DECRBY', KEYS[1], ARGV[1]); return 1;" 1 stock:item:001 5

# 用 EVALSHA 执行脚本(避免每次传输脚本内容)
EVALSHA <sha> 1 stock:item:001 5
java
// Java 中使用 Lua 脚本
String script = "local stock = tonumber(redis.call('GET', KEYS[1])) " +
                "if not stock then return -1 end " +
                "if stock < tonumber(ARGV[1]) then return 0 end " +
                "redis.call('DECRBY', KEYS[1], ARGV[1]) " +
                "return 1";

try (Jedis jedis = new Jedis("localhost", 6379)) {
    // SCRIPT LOAD 预编译,得到 SHA
    String sha = jedis.scriptLoad(script);
    
    // 后续直接用 EVALSHA,减少网络传输
    Object result = jedis.evalsha(sha, 
        Arrays.asList("stock:item:001"), 
        Arrays.asList("5"));
    
    System.out.println("结果: " + result); // 1=成功, 0=库存不足, -1=key不存在
}

示例 3:事务 vs Pipeline 对比

bash
# 事务:原子批量(MULTI/EXEC)
MULTI
SET key1 "a"
SET key2 "b"
INCR counter
EXEC
# 返回: [OK, OK, 1]

# Pipeline:非原子批量
# 客户端侧打包,服务端逐条执行
# 其他客户端的命令可能插入中间

常见坑

坑 1:WATCH + MULTI 之间不要做耗时操作

WATCH 到 EXEC 之间如果网络延迟大或客户端做了耗时计算(比如调用外部 API 判断库存),其他客户端修改 key 的概率就越高,WATCH 几乎必然失败,导致无限重试。

正确做法:WATCH 后尽快 GET + 判断 + MULTI + EXEC,整个窗口控制在几毫秒内。

坑 2:Lua 脚本不能太长

Lua 脚本执行期间 Redis 是单线程阻塞的。如果一个脚本执行 100ms,这 100ms 内所有其他请求都在排队——包括 Redis 自己的心跳、集群通信、Sentinel 检测。如果超时时间(lua-time-limit,默认 5000ms)内没执行完,Redis 会记录一个 WARNING,但不会主动 kill 脚本,只能靠后续的 SCRIPT KILL 手动终止。

生产建议:Lua 脚本控制在 50 行以内,执行时间 < 1ms,有循环时用 math.min 限制次数。

坑 3:Redis 集群(Cluster)下 Lua 脚本的 key 限制

Redis Cluster 要求 Lua 脚本中所有 key 必须在同一个 slot 上。EVAL 必须使用 KEYS[] 参数传 key,Redis 根据第一个 key 决定脚本路由到哪个节点。如果脚本里操作的 key 分布在多个 slot,Redis 会报 CROSSSLOT 错误。

做法:用 hash tag 把相关 key 放到同一个 slot:

KEYS[1] = "stock:{item:001}"
KEYS[2] = "order:{item:001}:count"

这样两个 key 都路由到 {item:001} 的 slot。

总结

  1. Redis 事务不是 ACID 事务,它不支持回滚,没有隔离级别,本质是"批量顺序执行"
  2. WATCH 实现乐观锁,适合冲突概率低的高并发场景,冲突时需客户端重试
  3. Lua 脚本是更好的选择,原子性更强,支持条件逻辑,减少网络 RTT
  4. 选型建议:简单批量用 MULTI/EXEC,乐观锁场景用 WATCH + 重试,复杂原子操作用 Lua 脚本,纯性能优化用 Pipeline
  5. 面试红线:不要说"Redis 事务支持回滚"——这是最容易被面试官抓住的错误。正确的说法是"Redis 事务提供的是命令连续执行不被中断的保证,而非 ACID 语义"
  6. 五六集群:Cluster 下 Lua 脚本必须用 hash tag 确保 key 在同一 slot;WATCH 在 Cluster 中只对单个节点有效,跨节点事务不生效

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。