您现在的位置是:首页 >技术杂谈 >Redis事务详解网站首页技术杂谈
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个失败了,后面都不会成功;如果在组队阶段成功了,在执行阶段有那个命令失败就这条失败,其他的命令则正常执行,不保证都成功或都失败。