前言

我们在使用Redis的时候,有时为了处理多个结构,需要向Redis中一次性发送多个命令。这一组命令我们要求不能被打断。Redis事务提供了一种将多个命令打包,然后一次性按顺序执行的能力。Redis事务和标准事务特性有所不同,Redis的事务不是标准事务,下面笔者逐步展开叙述。


一、标准事务

一说到事务,我们自然就会想到关系数据库中的事务,比如MySQL、Oracle、Sql Server等。事务(Transaction)在计算机科学中,特别是在数据库管理系统中,指的是一个逻辑上完整的工作单元,它包含了一系列的操作

1.1 标准事务的特性

标准事务具有以下几个核心特性,通常被称为 ACID 特性:

  1. 原子性(Atomicity):
    事务中的所有操作要么全部成功,要么全部失败。这意味着事务作为一个整体被执行,不能部分执行。
    如果事务的一部分失败,则整个事务都将被回滚到执行前的状态。

  2. 一致性(Consistency):
    事务的执行结果必须使数据库从一个一致性状态转换到另一个一致性状态。
    事务完成后,数据库必须处于有效的状态,满足所有的约束条件。

  3. 隔离性(Isolation):
    多个并发执行的事务之间是相互隔离的,一个事务的执行不应影响其他事务的执行。
    隔离级别可以有不同的设定,以平衡并发性和一致性之间的需求。

  4. 持久性(Durability):
    一旦事务提交,它对数据库所做的更改就是永久的,即使系统发生故障。
    已经提交的事务结果不会因任何系统故障而丢失。

1.2 标准事务的生命周期

标准事务通常由以下步骤构成:

  • 开始:通过 BEGIN TRANSACTION 或类似的命令开始事务。

  • 执行:执行一系列的数据库操作。

  • 提交:通过 COMMIT 命令提交事务,使其更改成为永久性的。

  • 回滚:通过 ROLLBACK 命令撤销事务,使数据库回到事务开始前的状态。

Java中的JDBC使用事务就是封装了上述四个操作,示例代码如下:

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;

public class JDBCTransactionExample {
    public static void main(String[] args) {
        String url = "jdbc:mysql://localhost:3306/testdb";
        String user = "root";
        String password = "password";

        try (Connection conn = DriverManager.getConnection(url, user, password)) {
            // 关闭自动提交
            conn.setAutoCommit(false);

            // 准备 SQL 语句
            String sql1 = "INSERT INTO users (name, email) VALUES (?, ?)";
            String sql2 = "UPDATE accounts SET balance = balance - 100 WHERE user_id = ?";
            
            PreparedStatement pstmt1 = conn.prepareStatement(sql1);
            pstmt1.setString(1, "John Doe");
            pstmt1.setString(2, "john.doe@example.com");
            
            PreparedStatement pstmt2 = conn.prepareStatement(sql2);
            pstmt2.setInt(1, 1);

            // 执行 SQL 语句
            int rowsAffected1 = pstmt1.executeUpdate();
            int rowsAffected2 = pstmt2.executeUpdate();

            // 提交事务
            conn.commit();

            System.out.println("Transaction completed successfully.");

        } catch (SQLException e) {
            // 发生异常时回滚事务
            try {
                if (conn != null) {
                    conn.rollback();
                }
            } catch (SQLException ex) {
                ex.printStackTrace();
            }

            e.printStackTrace();
        }
    }
}

代码中可以看到在成功提交事务之前发生的SQLException异常都会导致事务的回滚撤销,保证数据的一致性。

1.3 事务的作用

事务的作用主要体现在以下几个方面:

  1. 数据一致性
    事务确保数据的一致性,即事务执行前后数据必须处于一致的状态。通过保证事务的原子性、一致性、隔离性和持久性(ACID 特性),事务可以确保数据在并发操作下的正确性和一致性。

  2. 错误处理
    事务提供了错误处理机制。如果事务中的某个操作失败,可以通过回滚事务来撤销所有更改,从而使数据库恢复到事务开始前的状态。这有助于防止数据损坏和不一致的状态。

  3. 并发控制
    事务通过隔离性来控制并发操作,确保多个事务之间不会相互干扰。不同的隔离级别可以平衡并发性和数据一致性之间的需求。

  4. 简化复杂操作
    事务可以将一系列相关操作封装在一起,作为一个整体来执行。这有助于简化应用程序中的复杂业务逻辑,例如转账操作、库存更新等。

  5. 安全性
    事务提供了一种安全的方式来执行数据库操作,确保数据的完整性和准确性。通过事务管理,可以减少因程序错误导致的数据丢失或损坏的风险。

  6. 性能优化
    在某些情况下,事务还可以帮助优化性能,例如通过减少锁定时间或优化查询计划。
    例如,在一些数据库系统中,事务可以减少锁定资源的时间,从而提高并发性能。

  7. 简化编程模型
    事务提供了一种简单的方式来处理复杂的业务逻辑,使得程序员可以更容易地编写和维护代码。

通过使用事务,可以避免编写复杂的错误处理和数据同步代码。比如在一次操作中修改了很多个表的数据,这时发生了异常,那么我们需要对修改了数据的表进行恢复还原,如果没有事务,我们需要手动编写大量的补偿代码,把数据还原掉。


二、Redis事务

Redis事务是一种机制,它允许客户端将一组命令作为一个整体发送到Redis服务器。这些命令会被服务器接收并按顺序执行。Redis事务的主要目的是确保这一组命令能够作为一个逻辑单元被执行,而不是单独的命令。

2.1 Redis事务的特性

Redis事务主要有如下特性:

  1. 命令排队:在事务开始后(通过MULTI命令),所有的命令都会被放入一个事务队列中等待执行。

  2. 原子性:尽管Redis事务保证了命令的执行顺序,但是它并不保证原子性。这意味着如果其中一个命令失败,其他的命令仍然会被执行(注意Redis的单个命令是原子性的)。

  3. 事务回滚:Redis事务不支持回滚,一旦事务开始执行,即使某些命令失败也不会取消整个事务。

  4. 监视器和条件执行:Redis提供了WATCH命令来监视一个或多个键,如果在执行EXEC之前这些键被其他客户端修改,则整个事务不会被执行。

  5. 响应延迟:事务中的所有命令都是在一个网络往返内发送的,这可能会减少网络延迟。

2.2 Redis事务与普通事务的区别

  1. 原子性:传统的关系型数据库事务具有ACID特性,即原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。而 Redis事务不提供原子性 ,除了WATCH机制提供的有限形式的一致性保障外,事务中的命令要么全部执行,要么部分执行。

  2. 回滚机制:关系型数据库通常支持回滚,即如果事务的一部分失败,可以通过回滚撤销所有已执行的操作。Redis事务没有回滚机制

  3. 隔离级别:关系型数据库事务支持不同的隔离级别,如读未提交、读已提交、可重复读、串行化等,以防止脏读、不可重复读等问题。Redis事务不提供这样的隔离级别。

  4. 持久性:关系型数据库事务提交后,数据会被持久化到磁盘上。Redis事务提交后,数据也会被持久化,但这取决于Redis的持久化配置。

总的来说,Redis事务更侧重于命令的有序执行,而不是提供强一致性和事务回滚功能。这使得 Redis事务在性能上通常优于传统的关系型数据库事务,但也意味着它在某些场景下可能不适合用于需要严格事务一致性的应用


三、Redis事务常用命令

Redis 的事务机制提供了一种方式来确保一组命令作为一个整体被执行。虽然 Redis 本身是一个单线程模型,并且每个命令都是原子性的,但是事务可以让你更好地控制命令的执行顺序和一些并发场景下的数据一致性问题。
以下是 Redis 事务相关的几个重要命令:

  • MULTI
    开始一个新的事务块。
    之后的所有命令都会被放入队列中,直到调用 EXEC 命令。

  • EXEC
    提交事务,执行所有在 MULTI 命令后排队的命令。
    如果事务中有任何命令执行失败,整个事务仍然会被执行,但失败的命令会返回错误。

  • DISCARD
    取消事务,放弃执行事务块中的所有命令。
    这个命令可以用来取消从 MULTI 开始以来的所有排队命令。

  • WATCH
    监视一个或多个键。
    如果在发出 WATCH 命令之后,但 EXEC 命令之前,有其他客户端改变了任何一个被监视的键,那么整个事务将被取消。

  • UNWATCH
    取消 WATCH 命令对所有键的监视。
    这个命令可以在事务之外使用,以取消之前的 WATCH 命令的效果。

我们日常使用Redis命令很少使用原生命令,一般都是通过封装的依赖库,去发送命令执行,这里笔者使用Jedis去演示这些Redis事务相关命令。

package com.hl.redisdemo;

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

import java.util.List;

public class RedisTransactionCommandsDemo {
    public static void main(String[] args) {
        // 创建 Jedis 实例

        try (Jedis jedis = new Jedis("127.0.0.1", 6379)) {
            // 清除之前的键值
            jedis.del("key1", "key2");

            // 使用 WATCH 命令监视 key1
            jedis.watch("key1");

            // 检查 key1 的当前值
            String currentValue = jedis.get("key1");

            if (currentValue != null) {
                System.out.println("当前 key1 的值: " + currentValue);
            } else {
                System.out.println("key1 不存在");
            }

            // 开始事务
            Transaction transaction = jedis.multi();

            // 添加命令到事务队列
            transaction.set("key1", "新值");
            transaction.get("key1");
            transaction.set("key2", "值2");

            // 在这里可以模拟其他客户端修改 key1
            // 注意:这需要另一个 Redis 客户端来修改 key1
            //jedis.set("key1", "被其他客户端修改"); // 仅用于演示,实际运行时应注释掉

            // 执行事务
            List<Object> result = transaction.exec();

            if (result != null) {
                System.out.println("事务执行成功。");
                System.out.println("设置 key1 的结果: " + result.get(0));
                System.out.println("获取 key1 的结果: " + result.get(1));
                System.out.println("设置 key2 的结果: " + result.get(2));
            } else {
                System.out.println("由于 WATCH 失败,事务未执行。");
            }

            // 取消监视
            jedis.unwatch();

            // 开始新的事务并取消
            transaction = jedis.multi();
            transaction.set("key1", "已取消的值");
            transaction.get("key1");
            transaction.discard();
            System.out.println("事务已取消。");

            // 开始新的事务并执行
            transaction = jedis.multi();
            transaction.set("key1", "最终值");
            transaction.get("key1");
            result = transaction.exec();
            if (result != null) {
                System.out.println("最终事务执行成功。");
                System.out.println("设置 key1 的结果: " + result.get(0));
                System.out.println("获取 key1 的结果: " + result.get(1));
            } else {
                System.out.println("最终事务失败。");
            }
        } catch (Exception e) {
            System.err.println("执行事务时发生错误: " + e.getMessage());
        }
    }
}

以上代码执行结果如下:

【精通Redis】Redis事务-LMLPHP

注意代码中有一行:

jedis.set("key1", "被其他客户端修改"); 

这行代码是被注释的,如果放开,就表示Redis事务在执行过程中,键被修改,会发生如下报错:
【精通Redis】Redis事务-LMLPHP

watch和unwatch这两个关键字一般是配合MULTI和EXEC命令用的,在事务开启之前使用watch监听键,在事务结束之后使用unwatch释放监听。下面写了两个例子:

package com.hl.redisdemo;

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

import java.util.List;

public class RedisTransactionCommandsDemo {
    public static void main(String[] args) {
        // 创建 Jedis 实例

        try (Jedis jedis = new Jedis("127.0.0.1", 6379)) {
            // 清除之前的键值
            jedis.del("key1", "key2");

            // 使用 WATCH 命令监视 key1
            jedis.watch("key1");

            // 检查 key1 的当前值
            String currentValue = jedis.get("key1");

            if (currentValue != null) {
                System.out.println("当前 key1 的值: " + currentValue);
            } else {
                System.out.println("key1 不存在");
            }

            // 开始事务
            Transaction transaction = jedis.multi();
            Thread.sleep(10000); // 等待其他客户端修改key1
            // 添加命令到事务队列
            transaction.set("key1", "新值");
            transaction.get("key1");
            transaction.set("key2", "值2");

            // 执行事务
            List<Object> result = transaction.exec();

            if (result != null) {
                System.out.println("事务执行成功。");
                System.out.println("设置 key1 的结果: " + result.get(0));
                System.out.println("获取 key1 的结果: " + result.get(1));
                System.out.println("设置 key2 的结果: " + result.get(2));
            } else {
                System.out.println("由于 WATCH 失败,事务未执行。");
            }
            // 取消监视
            jedis.unwatch();
        } catch (Exception e) {
            System.err.println("执行事务时发生错误: " + e.getMessage());
        }
    }
}

这段代码在Redis事务开启后线程睡10秒,等待其他Redis客户端修改键key1,另一个客户端代码如下:

package com.hl.redisdemo;

import redis.clients.jedis.Jedis;

public class OtherClientDemo {
    public static void main(String[] args) throws InterruptedException {
        // 创建 Jedis 实例
        try (Jedis jedis = new Jedis("127.0.0.1", 6379)) {
            // 模拟其他客户端修改 key1
            jedis.set("key1", "被其他客户端修改");
            System.out.println("其他客户端修改了 key1 的值为: " + jedis.get("key1"));
        } catch (Exception e) {
            System.err.println("模拟客户端发生错误: " + e.getMessage());
        }
    }
}

先执行RedisTransactionCommandsDemo 中的main方法开启Redis事务,再立刻执行OtherClientDemo中的main方法,观察到 RedisTransactionCommandsDemo的main方法控制台打印结果如下:

【精通Redis】Redis事务-LMLPHP
说明一个Redis客户端监听一个键后,开启事务,如果有其他Redis客户端在该事务开启后结束前去尝试修改这个键对应的值,那么会导致该事务取消。注意unwatch必须在事务EXEC之后使用,且必须使用,否则会造成资源占用,键会一直被监听,影响其他客户端操作。


总结

本篇文章以标准事务为引导,叙述了其特性,接着讨论了Redis事务和普通关系数据库事务的区别。介绍了Redis事务相关的五个命令,并使用Java代码展示了它们的用法,通过本篇文章的学习,我们应该能够理解掌握Redis事务相关命令的使用场景和注意事项。

08-04 17:49