Skip to content

事务

详细用法请参考示例https://gitee.com/dexterleslie/demonstration/tree/master/demo-redis/redis-jedis/demo-jedis-transaction

介绍

Redis 事务是一组命令的有序队列,这些命令在 Redis 中作为一个原子操作进行执行。以下是关于 Redis 事务的详细解释:

一、Redis 事务的基本概念

  1. 定义:Redis 事务提供了一种将多个命令请求打包的功能,然后按顺序执行打包的所有命令,并且不会被中途打断。
  2. 特性
    • 原子性:事务是最小的执行单位,事务内的命令要么全部执行成功,要么全部不执行。但需要注意的是,Redis 事务并不支持回滚操作,即如果事务中的某个命令执行失败,其他命令仍然会被执行。
    • 隔离性:在事务执行过程中,其他客户端发送的命令不会被插入到事务执行过程中,保证了事务的隔离性。但 Redis 是单线程处理请求的,因此不需要像关系型数据库那样设置隔离级别。
    • 一致性:事务执行前后,数据的状态应该是一致的。然而,由于 Redis 事务不支持回滚,如果事务中的某个命令执行失败,可能会导致数据不一致的情况。
    • 持久性:Redis 支持持久化,但事务的持久性并不能得到完全保证。因为 Redis 的持久化是异步进行的,即使事务提交后,数据也可能还没有被持久化到磁盘上。

二、Redis 事务的实现方式

Redis 通过 MULTIEXECDISCARDWATCH 等命令来实现事务功能。

  1. MULTI:用于开启一个事务。当客户端发送 MULTI 命令后,服务器会返回 OK,表示事务的开始。之后,客户端发送的所有命令都会被服务器放入一个队列中,而不是立即执行。
  2. EXEC:用于执行事务队列中的所有命令。当客户端发送 EXEC 命令后,Redis 服务器会按照命令在队列中的顺序,原子性地执行它们,并返回每个命令的执行结果。
  3. DISCARD:用于取消事务,放弃执行事务队列中的所有命令。当客户端发送 DISCARD 命令后,Redis 服务器会丢弃事务队列中的所有命令,并返回 OK
  4. WATCH:用于在事务执行前监视一个或多个键。如果在事务执行之前这些键的值被其他客户端修改了,那么事务将被中断,不会执行 EXEC 命令。这有助于防止并发修改导致的数据不一致问题。需要注意的是,WATCH 命令只能监视键的值变化,而不能监视键是否存在等其他属性变化。

三、Redis 事务的注意事项

  1. 事务中的命令顺序:事务中的命令是按照它们被添加到事务队列中的顺序执行的。因此,在添加命令到事务队列时,需要注意命令的顺序。
  2. 事务的错误处理:由于 Redis 事务不支持回滚操作,如果事务中的某个命令执行失败,其他命令仍然会被执行。因此,在编写事务时,需要考虑到错误处理的情况,并采取相应的措施来避免数据不一致的问题。
  3. 事务的性能考量:大规模的事务执行可能会影响 Redis 的性能。因此,在使用事务时,需要慎重考虑事务的使用场景和规模,以避免对 Redis 的性能造成过大的影响。
  4. 持久化与事务的关系:虽然 Redis 支持持久化功能,但事务的持久性并不能得到完全保证。因为 Redis 的持久化是异步进行的,即使事务提交后,数据也可能还没有被持久化到磁盘上。因此,在需要保证数据持久性的场景中,需要采取额外的措施来确保数据的可靠性。

综上所述,Redis 事务提供了一种将多个命令请求打包并按顺序执行的功能,但需要注意其不支持回滚操作的特性以及在使用时需要考虑的错误处理、性能影响和持久化等问题。

Watch 机制

介绍

Redis的WATCH命令是实现乐观锁的重要机制,它允许用户在事务执行之前监视一个或多个键,如果在事务执行之前这些键被其他客户端修改,则事务将被中断。以下是关于Redis WATCH命令的详细解释:

基本用法

  1. 监视键:在执行事务之前,使用WATCH命令监视一个或多个键。例如,WATCH key1 key2会监视key1key2这两个键。
  2. 开启事务:在监视键之后,使用MULTI命令开启一个事务。
  3. 添加命令到事务:在事务中添加要执行的Redis命令。
  4. 执行事务:使用EXEC命令执行事务。如果在执行事务之前,被监视的键被其他客户端修改,则事务将被中断,并抛出一个异常(如Jedis中的JedisException或Python redis-py库中的WatchError)。

工作原理

  1. 记录版本号:当执行WATCH命令时,Redis会记录被监视键的当前值的版本号。
  2. 检查版本号:在事务执行时(即调用EXEC命令时),Redis会检查这些键的版本号是否发生了变化。如果版本号与监视时记录的不一致,说明这些键在事务执行期间被其他客户端修改过,因此事务将被中断。
  3. 乐观锁机制:WATCH命令实现的乐观锁机制允许用户在假设没有并发修改的情况下执行事务。如果确实存在并发修改,则事务失败,用户可以根据需要进行重试或其他处理。

使用场景

  1. 多客户端并发操作共享数据时:确保在没有其他操作干扰的情况下执行特定的操作。例如,在在线购物系统中处理库存扣减时,可以使用WATCH命令监视库存键,以确保在扣减库存之前没有其他客户端修改了库存数量。
  2. 数据一致性要求高的场景:如银行账户转账、库存管理等场景,需要确保数据的一致性和完整性。使用WATCH命令可以防止在事务执行期间数据被其他客户端修改,从而保持数据的一致性。

注意事项

  1. 事务范围:使用WATCH命令时需要注意事务的范围,过多的监视可能会导致性能问题。因此,应该只监视与事务直接相关的键。
  2. 连接断开:如果连接断开,监视将被自动解除。因此,在长时间运行的事务中可能需要重新监视键。
  3. 并发控制:虽然WATCH命令可以确保事务的原子性,但不一定能解决所有并发问题。在某些情况下,可能需要结合其他并发控制机制(如分布式锁)来确保数据的一致性和隔离性。
  4. 异常处理:在事务执行过程中,应该捕获并处理可能抛出的异常(如WatchError),以便在事务失败时进行适当的处理(如重试、回滚等)。

综上所述,Redis的WATCH命令是实现乐观锁的重要工具,在高并发、需要保证数据一致性的场景中具有广泛的应用。正确使用WATCH命令可以有效提升系统的稳定性和可靠性。

实验

借助 redis-cli 实验

bash
# session1 watch 多个 key
watch k1 k2
# session1 开启事务
multi
# session1
set k3 v3
set k4 v4
set k1 v1

# session2
set k1 v11

# session1 所有 set 命令都执行失败,因为 session2 修改了 k1
exec

# session1 取消 watch
unwatch

借助 Jedis 实验

详细用法请参考示例https://gitee.com/dexterleslie/demonstration/tree/master/demo-redis/redis-jedis/demo-jedis-transaction

java
// jedis事务使用watch乐观锁机制
@Test
public void testWatch() throws InterruptedException {
    // 清空 db
    try (Jedis jedis = JedisUtil.getInstance().getJedis()) {
        jedis.flushDB();
    }

    AtomicInteger successIndicator = new AtomicInteger();
    AtomicInteger failIndicator = new AtomicInteger();
    ExecutorService executorService = Executors.newCachedThreadPool();
    for (int i = 0; i < 2; i++) {
        executorService.submit(() -> {
            Jedis jedis = null;
            try {
                jedis = JedisUtil.getInstance().getJedis();

                // 对 k1 加乐观锁
                jedis.watch("k1");

                Transaction transaction = jedis.multi();

                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    //
                }

                String uuidStr = UUID.randomUUID().toString();
                transaction.sadd("k2", uuidStr);
                transaction.incr("k3");
                transaction.set("k1", uuidStr);
                List<Object> objectList = transaction.exec();
                if (objectList == null || objectList.size() <= 0) {
                    failIndicator.incrementAndGet();
                } else {
                    successIndicator.incrementAndGet();
                }
            } finally {
                if (jedis != null) {
                    jedis.unwatch();
                }

                if (jedis != null) {
                    jedis.close();
                }
            }
        });
    }
    executorService.shutdown();
    while (!executorService.awaitTermination(1, TimeUnit.SECONDS)) ;

    // 只能有一个线程执行成功
    Assert.assertEquals(1, failIndicator.get());
    Assert.assertEquals(1, successIndicator.get());
    try (Jedis jedis = JedisUtil.getInstance().getJedis()) {
        Assert.assertEquals(Long.valueOf(1), jedis.scard("k2"));
        Assert.assertEquals("1", jedis.get("k3"));
    }
}

事务的使用场景

注意:事务 + Watch 机制做到事务期间被 Watch 的数据加乐观锁。(如果业务场景需要使用 Redis 事务解决,一般使用 Lua 脚本方案解决)

https://stackoverflow.com/questions/58984931/how-to-make-a-100-sure-conditional-update-of-a-redis-key-without-lua

Redis事务在实际应用中具有广泛的用途,以下是一个具体的例子来说明Redis事务的用法:

应用场景

在一个在线书店系统中,我们需要确保对书籍库存的更新操作是原子性的,即在一次事务中完成库存的查询和扣减,以防止在高并发情况下出现超卖的情况。

实现步骤

  1. 开启事务:使用MULTI命令开启一个Redis事务。
  2. 添加命令到事务队列:将要执行的Redis命令添加到事务队列中。在这个例子中,我们可能需要先查询书籍的库存,然后扣减库存。但需要注意的是,由于Redis事务不支持事务内的条件判断(如IF语句),我们不能直接在事务中检查库存是否足够。因此,一种常见的做法是乐观锁的方式,即先假设库存足够,执行扣减操作,然后在应用层面检查是否扣减成功(通常通过检查影响的行数或返回的结果来判断)。如果扣减失败,则进行回滚或采取其他补偿措施。不过,这个例子为了简化说明,只展示扣减库存的操作。
  3. 执行事务:使用EXEC命令执行事务队列中的所有命令。如果事务执行过程中发生错误(如语法错误、命令不存在等),则整个事务会被回滚(即所有命令都不会被执行)。但如果是运行时错误(如尝试对一个不存在的键进行操作),则只有出错的命令会被忽略,其他命令仍然会被执行。

示例代码

假设我们有一个Redis键book_inventory:<book_id>用于存储书籍的库存信息,其中<book_id>是书籍的唯一标识符。以下是一个使用Redis事务来扣减库存的示例代码:

redis
# 开启事务
MULTI
 
# 扣减库存(假设要扣减的库存数量为1)
DECRBY book_inventory:<book_id> 1
 
# 执行事务
EXEC

在实际的应用中,这段代码可能会由应用程序的某个方法或函数来执行,并且会包含更多的错误处理和日志记录逻辑。

注意事项

  1. Redis事务的局限性:Redis事务并不支持回滚指定命令,只能回滚整个事务。此外,Redis事务的性能可能会受到一些限制,例如如果在事务执行期间有过多的写入操作,则可能会导致事务执行缓慢。
  2. 乐观锁的使用:在上述例子中,我们使用了乐观锁的方式来处理库存的扣减。在实际应用中,可能还需要结合其他机制(如分布式锁)来确保数据的一致性。
  3. 错误处理:在应用层面需要添加适当的错误处理逻辑,以处理可能出现的各种异常情况(如库存不足、Redis连接失败等)。

通过以上例子,我们可以看到Redis事务在高并发、需要保证数据一致性的场景中具有重要的作用。