Redis事务详解

一、前言

事务是指一个完整的动作,要么全部执行,要么什么也没有做。Redis 事务不是严格意义上的事务,只是用于帮助用户在一个步骤中执行多个命令。单个 Redis 命令的执行是原子性的,但 Redis 没有在事务上增加任何维持原子性的机制,所以 Redis 事务的执行并不是原子性的。

Redis 事务可以理解为一个打包的批量执行脚本,但批量指令并非原子化的操作,中间某条指令的失败不会导致前面已做指令的回滚,也不会造成后续的指令不做。

什么是原子性?

举个经典的简单例子,银行转账,A 像 B 转账 100 元。转账这个操作其实包含两个离散的步骤:

  • 步骤 1:A 账户减去 100
  • 步骤 2:B 账户增加 100

我们要求转账这个操作是原子性的,也就是说步骤 1 和步骤 2 是顺续执行且不可被打断的,要么全部执行成功、要么执行失败。

Redis 的事务不是原子性,但是Redi执行每一个命令都是原子性的

举例:INCR在redis中是自增,即使多个客户端对同一个密钥发出INCR,也永远不会进入竞争状态。例如,客户机1读取“10”,客户机2同时读取“10”,两者都增加到11,并将新值设置为11,这样的情况永远不会发生。最终的值将始终是12。

这个案例是官网提出来的:https://redis.io/docs/data-types/tutorial/

之所以Redi执行每一个命令都是原子性,因为Redis是单线程执行的。这里我们一直在强调的单线程,只是在处理我们的网络请求的时候只有一个线程来处理,一个正式的Redis Server运行的时候肯定是不止一个线程的,这里需要大家明确的注意一下。例如Redis进行持久化的时候会以子进程或者子线程的方式执行。

Mysql当中针对于并发事务会存在脏读、不可重复读、幻读等情况,那么Redis会有这种情况吗?

对于Redis而言根本不需要考虑这个。因为Redis是单线程的,根本不具备并发事务,并且Redis的事务虽然给人的感觉是将所有Redis命令放到了一个事务,本质上执行事务,就是把这个事务当成了一行命令来处理,然后对事务内的命令也是一行一行执行。

事务一般都是为原子性而生,既然Redis事务没有原子性,那他存在的意义是什么?

redis事务的主要作用就是串联多个命令防止 别的命令插队。

官网介绍:https://redis.com.cn/redis-transaction.html

二、Redis事务 - 基本使用

每个事务的操作都有 begin、commit 和 rollback:

  • begin 指示事务的开始
  • commit 指示事务的提交
  • rollback 指示事务的回滚

它大致的形式如下:

begin();
try {
	// 执行业务相关代码
	command1();
	command2();
	....
	commit();
} catch(Exception e) {
	rollback();
}

Redis 在形式上看起来也差不多,MULTI、EXEC、DISCARD这三个指令构成了 redis 事务处理的基础:

  • MULTI:用来组装一个事务,从输入Multi命令开始,输入的命令都会依次进入命令队列中,但不会执行,直到输入Exec后,redis会将之前的命令依次执行。
  • EXEC:用来执行一个事务
  • DISCARD:用来取消一个事务

redis事务分2个阶段:组队阶段、执行阶段

  • 组队阶段:只是将所有命令加入命令队列
  • 执行阶段:依次执行队列中的命令,在执行这些命令的过程中,不会被其他客户端发送的请求命令插队或者打断。

案例一:事务被成功执行

127.0.0.1:6379> set user_id 1 # 定义了一个user_id的key,value为1
OK
127.0.0.1:6379> get user_id 
"1"
127.0.0.1:6379> MULTI # 标记事务开始
OK
127.0.0.1:6379> incr user_id # 多条命令按顺序入队,返回值为QUEUED,表示这个命令加入队列了,还没有被执行。
QUEUED
127.0.0.1:6379> incr user_id # incr是自增的命令
QUEUED
127.0.0.1:6379> incr user_id
QUEUED
127.0.0.1:6379> exec # 执行事务过后返回的是事务块内所有命令的返回值,按命令执行的先后顺序排列。
1) (integer) 2
2) (integer) 3
3) (integer) 4
127.0.0.1:6379> get user_id
"4"

上面的指令演示了一个完整的事务过程,所有的指令在 exec 之前不执行,而是缓存在服务器的一个事务队列中,服务器一旦收到 exec 指令,才开执行整个事务队列,执行完毕后一次性返回所有指令的运行结果。因为 Redis 的单线程特性,不用担心自己在执行队列的时候被其它指令打搅,可以保证他们能得到的有顺序的执行

案例二:取消事务,放弃执行事务块内的所有命令。

127.0.0.1:6379> keys *
(empty list or set)
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET greeting "hello"
QUEUED
127.0.0.1:6379> set kaka aaa
QUEUED
127.0.0.1:6379> DISCARD
OK
127.0.0.1:6379> keys *
(empty list or set)

三、Redis事务 - 错误处理

情况1:组队中某个命令出现了错误报告,执行时整个队列中所有的命令都会被取消。

127.0.0.1:6379> flushdb
OK
127.0.0.1:6379> multi # 开启一个事务块
OK
127.0.0.1:6379> set name ready
QUEUED
127.0.0.1:6379> set age 30
QUEUED
127.0.0.1:6379> set1 age 60 # 命令有问题,导致加入队列失败
(error) ERR unknown command `set1`, with args beginning with: `age`, `60`,
127.0.0.1:6379> exec # 执行exec的时候,事务中所有命令都被取消
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379> get name  # 事务当中的命令也全都执行失败了
(nil)
127.0.0.1:6379> keys *
(empty list or set)

情况2:命令组队的过程中没有问题,执行中出现了错误会导致部分成功部分失败。

127.0.0.1:6379> multi
OK
127.0.0.1:6379> set books iamastring
QUEUED
127.0.0.1:6379> set poorman iamdesperate
QUEUED
127.0.0.1:6379> incr books  
QUEUED
127.0.0.1:6379> exec
1) OK
2) OK
3) (error) ERR value is not an integer or out of range # incr是对数字类型的进行自增,而books存的是字母
127.0.0.1:6379> get books # 只有incr books 执行失败了,其他都执行成功了。
"iamastring"
127.0.0.1:6379> get poorman
"iamdesperate"

四、Redis事务 - 事务冲突

1、事务所产生的问题

想象一个场景:你的账户中只有10000,有多个人使用你的账户,同时去参加双十一抢购

  • 一个请求想给金额减8000
  • 一个请求想给金额减5000
  • 一个请求想给金额减1000

3个请求同时来带①,看到的余额都是10000,大于操作金额,都去执行修改余额的操作,最后导致金额变成了-4000,这显然是有问题的。

2、悲观锁&乐观锁

悲观锁:

悲观锁(Pessimistic Lock),顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人拿到这个数据就会block直到它拿到锁。传统的关系型数据库里面就用到了很多这种锁机制,比如行锁、表锁、读锁、写锁等,都是在做操作之前先上锁。

乐观锁:

乐观锁(Optimistic Lock),顾名思义,就是很乐观,每次去那数据的时候都认为别人不会修改,所以不会上锁,但是在修改的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量。redis就是使用这种check-and-set机制实现事务的

3、watch监听

  • WATCH:在执行multi之前,先执行watch key1 [key2 …],可以监视一个或者多个key,若在事务的exec命令之前这些key对应的值被其他命令所改动了,那么事务中所有命令都将被打断,即事务所有操作将被取消执行。
  • unwatch:取消 WATCH 命令对所有 key 的监视。如果在执行 WATCH 命令之后, EXEC 命令或 DISCARD 命令先被执行了的话,那么就不需要再执行UNWATCH 了。

因为 EXEC 命令会执行事务,因此 WATCH 命令的效果已经产生了;而 DISCARD 命令在取消事务的同时
也会取消所有对 key 的监视,因此这两个命令执行之后,就没有必要执行 UNWATCH 了。

注意:Redis 禁止在 multi 和 exec 之间执行 watch 指令,而必须在 multi 之前做好盯住关键变量,否则会出错。

案例一:监视 key,且事务成功执行

127.0.0.1:6379> set lock aa   # 新增了一个key/value
OK
127.0.0.1:6379> keys *        # 数据库目前就只有lock一个key  
1) "lock"
127.0.0.1:6379> watch lock lock_times # 开始监视key为lock或者lock_times的值。lock_times在数据库不存在也是可以监视的
OK
127.0.0.1:6379> multi        # 开启事务
OK
127.0.0.1:6379> SET lock "huangz"
QUEUED
127.0.0.1:6379> INCR lock_times # INCR是对一个key值进行自增,假如key值没有在数据库当中会进行创建并赋值为1
QUEUED
127.0.0.1:6379> EXEC		# 开始执行事务
1) OK
2) (integer) 1
127.0.0.1:6379> get lock
"huangz"
127.0.0.1:6379> get lock_times
"1"

案例二:监视 key,且事务被打断,这里需要准备两个客户端进行测试

案例三:watch监听key后只对当前客户端第一个事务有效,并不影响其他命令执行

127.0.0.1:6379> watch lock
OK
127.0.0.1:6379> set lock 'cccc'
OK
127.0.0.1:6379> get lock
"cccc"
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set lock bbb
QUEUED
127.0.0.1:6379> exec #exec 指令返回一个 null 回复时,事务执行是失败的
(nil)

案例四:取消监听

127.0.0.1:6379>  WATCH lock lock_times
OK
127.0.0.1:6379> UNWATCH
OK

4、watch的应用场景

考虑到一个业务场景,Redis 存储了我们的账户余额数据,它是一个整数。现在有两个并发的客户端要对账户余额进行修改操作,这个修改不是一个简单的 incrby 指令,而是要对余额乘以一个倍数。Redis 可没有提供 multiplyby 这样的指令。我们需要先取出余额然后在内存里乘以倍数,再将结果写回 Redis。

这就会出现并发问题,因为有多个客户端会并发进行操作。我们可以通过 Redis 的分布式锁来避免冲突,这是一个很好的解决方案。分布式锁是一种悲观锁,那是不是可以使用乐观锁的方式来解决冲突呢?

当服务器给 exec 指令返回一个 null 回复时,客户端知道了事务执行是失败的,通常客户端 (redis-py) 都会抛出一个 WatchError 这种错误,不过也有些语言 (jedis) 不会抛出异常,而是通过在 exec 方法里返回一个 null,这样客户端需要检查一下返回结果是否为 null 来确定事务是否执行失败。

使用Java代码来实现这个需求,这里用的客户端是Jedis:

<dependency>
	<groupId>redis.clients</groupId>
	<artifactId>jedis</artifactId>
	<version>4.4.1</version>
</dependency>
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;

import java.util.List;

public class TransactionDemo {
    public static void main(String[] args) {
        Jedis jedis = new Jedis();
        String userId = "abc";
        // 自定义一个key
        String key = keyFor(userId);
        // setnx 做初始化:如果key不存在,创建key,并且返回1,如果存在不覆盖原来的值,返回0
        jedis.setnx(key, String.valueOf(5));
        System.out.println(doubleAccount(jedis, userId));
        jedis.close();
    }

    public static int doubleAccount(Jedis jedis, String userId) {
        String key = keyFor(userId);
        while (true) {
            jedis.watch(key);
            int value = Integer.parseInt(jedis.get(key));
            // 加倍
            value *= 2;
            // 开启事务
            Transaction tx = jedis.multi();
            tx.set(key, String.valueOf(value));
            List<Object> res = tx.exec();
            // 直到事务提交成功退出循环
            if (res != null) {
                // 成功了
                break;
            }
        }
        // 重新获取余额
        return Integer.parseInt(jedis.get(key));
    }

    public static String keyFor(String userId) {
        return String.format("account_{}", userId);
    }
}

五、Redis 事务特性

  • 单独的隔离操作: 事务中的所有命令都会序列化、按顺序地执行,事务在执行过程中,不会被其他客户端发送来的命令请求所打断。
  • 没有隔离级别的概念: 队列中的命令没有提交(exec)之前,都不会实际被执行,因为事务提交前任何指令都不会被实际执行。
  • 不能保证原子性: 事务中如果有一条命令执行失败,后续的命令仍然会被执行,没有回滚。如果在组队阶段,有1个失败了,后面都不会成功;如果在组队阶段成功了,在执行阶段有那个命令失败就这条失败,其他的命令则正常执行,不保证都成功或都失败。
  • 12
    点赞
  • 41
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

怪 咖@

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值