Skip to content

Redis的事务讲一下

约 2727 字大约 9 分钟

Redis美团

2025-4-15

⭐ 题目日期:

美团 - 2025/4/12

📝 题解:

1. 概念讲解

什么是事务?

在数据库领域,事务(Transaction)通常指作为单个逻辑工作单元执行的一系列操作,这些操作要么全部成功执行,要么全部不执行。事务具有四大特性,即 ACID

  • 原子性(Atomicity):事务中的所有操作要么全部完成,要么全部不完成,不会结束在中间某个环节。
  • 一致性(Consistency):事务执行前后,数据库的完整性约束没有被破坏,数据状态从一个一致状态转变到另一个一致状态。
  • 隔离性(Isolation):多个并发事务之间相互隔离,事务提交前,其操作对其他事务是不可见的。数据库通常提供多种隔离级别(读未提交、读已提交、可重复读、串行化)。
  • 持久性(Durability):一旦事务提交,则其结果就是永久性的,即使系统宕机也不会丢失。

Redis 事务的核心概念

Redis 的事务与传统关系型数据库(如 MySQL)的事务有显著区别。Redis 事务的核心是将一组命令打包,然后按顺序、一次性、不可中断地执行。它通过 MULTIEXECDISCARDWATCH 四个命令来实现。

  • MULTI:标记一个事务块的开始。之后输入的命令都会被放入一个队列中,但不会立即执行
  • EXEC:原子地执行队列中的所有命令。如果事务执行期间没有受到 WATCH 监视的键被修改,则执行;否则,事务被打断(不执行任何命令)。
  • DISCARD:取消事务,清空命令队列,并退出事务状态。
  • WATCH key [key ...]:监视一个或多个 key。如果在 EXEC 执行之前,任何被 WATCH 监视的 key 被其他命令修改,那么整个事务将被取消,EXEC 返回 null 回复。这是一种**乐观锁(Optimistic Locking)**机制。

与 RDBMS 事务的关键区别:

  1. 不保证原子性(针对运行时错误):Redis 命令在入队时会检查语法错误。如果命令存在语法错误,整个事务会失败,所有命令都不执行(类似原子性)。但是,如果命令在执行期间发生错误(例如对 String 类型执行 List 操作),Redis 不会回滚已经执行成功的命令,而是继续执行后续命令。这是 Redis 为了追求简单性和高性能所做的设计选择。
  2. 隔离性有限:在 EXEC 执行之前,其他客户端可以修改事务中的 key(除非使用了 WATCH)。在 EXEC 执行期间,由于 Redis 是单线程处理命令的,所以可以保证这批命令执行时不会被其他客户端命令打断,具有一定的隔离性。
  3. 一致性:由开发者自行保证。如果事务中的命令逻辑正确,且执行期间未出错或被 WATCH 中断,可以达到最终一致性。
  4. 持久性:依赖于 Redis 的持久化策略(RDB 或 AOF)。

2. 原理分析

Redis 事务执行流程:

img

核心要点解释:

  1. 命令队列MULTI 之后,所有命令只是进入一个先入先出(FIFO)的队列,并不立即执行。
  2. 单线程执行EXEC 时,Redis 会以单线程模式按顺序执行队列中的所有命令。这保证了执行期间的原子性(不会被其他命令插入)。
  3. WATCH 的乐观锁
    • WATCH 命令必须在 MULTI 之前执行。
    • 服务器会记录被 WATCH 的 key 及其版本(或状态)。
    • 如果在 MULTIEXEC 之间,任何一个被 WATCH 的 key 被其他客户端修改(如 SET, DEL, INCR 等),服务器会标记该事务为“脏”(dirty)。
    • EXEC 执行时,服务器会先检查事务是否为“脏”。如果是,则拒绝执行事务中的所有命令,并返回 nil
    • WATCH 是一次性的,无论 EXEC 成功还是失败(或执行了 DISCARD),监视都会被取消。若想继续监视,需要再次 WATCH
  4. 错误处理
    • 入队时错误(语法错误等):如果一个命令无法入队(例如命令名错误、参数数量不对),服务器会拒绝该命令,并记录一个错误状态。当 EXEC 被调用时,服务器会拒绝执行所有命令,并返回一个错误。这种情况下,事务是“原子”的,不会执行任何操作。
    • 执行时错误(类型错误等):如果命令在语法上正确,但在执行时出错(例如对字符串执行列表操作),服务器会捕获该错误,记录下来,但会继续执行队列中的下一个命令EXEC 会返回一个包含每个命令执行结果(或错误)的数组。这种情况下,Redis 不提供回滚

3. 代码示例 (使用 Jedis - Java 客户端)

import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;
import redis.clients.jedis.Response;
import redis.clients.jedis.exceptions.JedisDataException;
import java.util.List;

public class RedisTransactionExample {

    public static void main(String[] args) {
        Jedis jedis = null;
        try {
            jedis = new Jedis("localhost", 6379);
            // 如果设置了密码
            // jedis.auth("yourpassword");

            // === 示例 1: 基本事务 ===
            System.out.println("=== 示例 1: 基本事务 ===");
            jedis.set("balance", "100");
            jedis.set("debt", "0");

            Transaction t = jedis.multi(); // 开始事务
            Response<String> r1 = t.decrBy("balance", 10); // 余额减 10
            Response<String> r2 = t.incrBy("debt", 10);    // 债务加 10
            // 故意加一个执行时会失败的命令 (如果 balance 是 string)
            // Response<Long> r3 = t.llen("balance"); // 这会产生运行时错误

            List<Object> results = t.exec(); // 执行事务

            if (results == null) {
                System.out.println("事务被取消 (可能因为 WATCH 失败).");
            } else {
                System.out.println("事务执行完成.");
                // 检查每个命令的结果
                System.out.println("Balance decrBy result: " + r1.get()); // 注意:需要调用 get() 获取 Response 的值
                System.out.println("Debt incrBy result: " + r2.get());
                // 如果包含错误命令 r3,这里会是 JedisDataException
                // try {
                //     System.out.println("LLEN on balance result: " + r3.get());
                // } catch (JedisDataException e) {
                //     System.out.println("LLEN on balance failed as expected: " + e.getMessage());
                // }
                // 验证最终结果
                System.out.println("Final balance: " + jedis.get("balance")); // 应该是 90
                System.out.println("Final debt: " + jedis.get("debt"));       // 应该是 10
            }
            System.out.println("---------------------\n");


            // === 示例 2: 使用 WATCH 实现乐观锁 ===
            System.out.println("=== 示例 2: 使用 WATCH 实现乐观锁 ===");
            jedis.set("product:stock", "10"); // 商品库存
            String keyToWatch = "product:stock";

            jedis.watch(keyToWatch); // 监视库存

            int currentStock = Integer.parseInt(jedis.get(keyToWatch));
            if (currentStock > 0) {
                Transaction t2 = jedis.multi(); // 开始事务
                t2.decr(keyToWatch); // 库存减 1

                // 模拟在 WATCH 和 EXEC 之间,有其他客户端修改了库存
                 // Jedis otherClient = new Jedis("localhost", 6379);
                 // otherClient.incr("product:stock"); // 模拟并发修改
                 // otherClient.close();
                 // System.out.println("!!! 模拟并发修改发生 !!!");

                List<Object> results2 = t2.exec(); // 尝试执行事务

                if (results2 == null) {
                    System.out.println("事务执行失败:库存已被其他操作修改,需要重试。");
                    // 这里通常需要加入重试逻辑
                } else {
                    System.out.println("库存扣减成功!");
                    System.out.println("Transaction results: " + results2);
                    System.out.println("Stock after decr: " + jedis.get(keyToWatch));
                }
            } else {
                System.out.println("库存不足,无法购买。");
                jedis.unwatch(); // 如果事务没开始就结束了,最好 unwatch
            }

        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (jedis != null) {
                jedis.close(); // 关闭连接
            }
        }
    }
}

4. 应用场景

  1. 需要原子执行多个命令:当一组操作需要作为一个整体执行,不希望被其他命令插入时。例如,同时更新用户的积分和等级。
  2. 简单并发控制(乐观锁):通过 WATCH 机制,可以在不阻塞其他客户端的情况下,实现对共享资源的“先检查后设置”(Check-And-Set, CAS)操作。适用于并发冲突不频繁的场景,如秒杀系统库存扣减(但高并发下 WATCH 失败率可能很高,Lua 脚本通常更优)。
  3. 实现 RDBMS 中某些简单的事务逻辑:虽然功能有限,但对于一些不需要复杂回滚的场景,可以提供基本的打包执行能力。

局限性与替代方案:

  • 不支持运行时回滚:对于需要严格保证数据一致性、失败时必须回滚所有操作的复杂业务,Redis 事务力不从心。
  • 高并发下 WATCH 效率问题:如果被 WATCH 的 key 频繁被修改,事务会经常失败并需要重试,导致性能下降和逻辑复杂化。
  • 替代方案 - Lua 脚本:对于需要原子性执行的复杂逻辑,或者需要避免 WATCH 竞争的场景,使用 Redis Lua 脚本是更强大和推荐的方式。Lua 脚本在 Redis 服务器端原子执行,执行期间不会被其他命令中断,并且可以实现更复杂的逻辑,甚至可以模拟“回滚”(通过在脚本内部判断并执行反向操作,但这并非真正的事务回滚)。

5. 面试答题技巧

  • 面试官意图

    • 考察你是否理解 Redis 事务的基本用法和命令 (MULTI, EXEC, DISCARD, WATCH)。
    • 考察你是否清楚 Redis 事务与传统 RDBMS 事务的关键区别,特别是原子性和回滚机制的差异。
    • 考察你是否理解 WATCH 的乐观锁原理及其适用场景和局限性。
    • 可能想了解你是否知道 Lua 脚本这一更优的原子性保证方案。
  • 可能的追问

    • "Redis 事务能保证 ACID 吗?为什么?" (重点说清原子性不完全保证,无回滚)
    • "如果事务队列中的某条命令执行错了,会发生什么?" (强调错误命令失败,后续命令继续执行)
    • "WATCH 命令有什么用?它能解决什么问题?有什么缺点?" (乐观锁,CAS,并发冲突下的重试问题)
    • "如果我想原子地执行一个比较复杂的操作,比如先检查库存再扣减,并且还要记录日志,用 Redis 事务好还是用 Lua 脚本好?为什么?" (Lua 脚本更好,原子性强,避免 WATCH 竞争,逻辑封装性好)
    • "Redis 为什么不支持事务回滚?" (设计哲学:追求简单、高性能。认为应用层应负责处理数据一致性,或者错误不常见到需要牺牲性能来支持回滚)
  • 回答要点与加分项

    • 清晰定义:首先说清楚 Redis 事务是打包命令,顺序、原子(执行期间)执行。
    • 点明差异主动清晰地指出与 RDBMS ACID 事务的核心区别,尤其是不支持回滚。
    • 解释 WATCH:准确解释 WATCH 的乐观锁机制和工作原理(检查修改)。
    • 举例说明:结合代码示例或简单场景(如转账简化版)说明用法。
    • 提及局限与替代方案主动提到 Redis 事务的局限性(无回滚、WATCH 性能问题),并引出 Lua 脚本作为更强大、更常用的原子操作解决方案,这是重要的加分项
    • 结合设计理念:如果能提到 Redis 不支持回滚是出于简单和性能的考虑,会显得你理解更深入。