Redis 4.x系列(九):Redis Transaction(事务)

  Redis 中的事务与传统数据库的事务存在较大的差异,所以理解 Redis 事务,必须跳出传统数据库事务的概念,这是由两者对事务的实现方式不同决定的。

  Redis 官方文档对 Redis 事务在处理所有命令的描述是:要么处理所有命令,要么都不处理,因此 Redis 事务也是原子的。 Redis Transactions 官方文档注:Redis 事务确保原子性的时机不同于传统数据库的事务处理,详见下文。

事务对比

MySQL为例,对 Redis 事务 与 传统数据库的事务特性做个简单的比较。

事务执行流程

Redis 事务的执行流程不同于传统数据库的事务处理流程。

  1. 传统数据库事务处理:

    • BEGIN:开启一个事务
    • COMMIT:提交事务,修改持久化
    • ROLLBACK:事务l回滚,并撤销所有正在进行但未提交的修改

    MySQL 事务是基于UNDO/REDO日志实现的

    • UNDO日志记录修改前状态,ROLLBACK基于UNDO日志实现;
    • REDO日志记录修改后的状态,COMMIT基于REDO日志实现。

    MySQL 开启事务,在 COMMIT 之前的 insert,update,delete 操作会被立即执行并返回结果,但没有持久化写入硬盘,这里涉及到事务隔离级别另说,如果其中某一步骤出问题,则所有操作都会回滚。 执行 COMMIT操作,修改才被写入到硬盘。

  2. Redis 事务处理:

    • MULTI:标记事务开始
    • EXEC:按顺序执行 commands 队列中的命令
    • DISCARD:结束事务,不执行任何命令,并清除队列,连接状态恢复正常。

    Redis 事务是基于 commands 队列实现的,默认未开启事务,命令会被立即执行并返回结果;开启事务,命令并不会立即执行,而是进入队列,只有调用了 EXEC 才会按顺序执行队列中的命令。

    Redis 事务不支持回滚,官方文档里给出的解释是:只有在语法错误时,命令才会执行失败,而语法错误的命令在排队期间无法被检测到;或数据类型错误导致执行失败,意味着失败的命令是编程错误的结果,应在开发过程中就检测出错误,而不会在生产中。Redis 命令失败的错误类型不太可能进入生产中,所以选择了更简单、更快速的方法,即不支持错误回滚。

从上可以看出,两者在事务处理上存在明显的区别:

MySQL 事务中涉及事务操作会被立即执行并返回结果,若执行出现错误,则需要ROLLBACK回滚,SQL语法错误或内部错误(如主键重复)只能在执行时才能被检测到并报错;

Redis 事务中的命令不会被立即执行,而是进入队列,调用了 EXEC 才会被执行,在命令进入队列的操作时就对命令的语法进行了检测,若存在语法错误则直接清除commands对列,并闭事务,可理解为在事务中能被执行的命令都是正确的。

相对于MySQL,可理解为 Redis 事务对命令的语法检测提前到在将命令加入队列时进行,而不是执行命令时,屏蔽了存在语法错误的 commands 队列被执行。当然还可能出现在执行时才能检测到的类型错误,这又回到了 Redis 的官方解释。

事务四大特性

  1. 原子性:所有操作要么全部成功,要么全部失败回滚。
    • MySQL:开启了事务,事务中的操作会被立即执行并返回结果,在事务中的执行出现异常则撤消未提交的修改(回滚)。
    • Redis:开启了事务,操作命令被加入到队列不会被立即执行,在加入队列过程中检测语法,若出现错误则清空队列并关闭事务;EXEC命令触发事务中所有命令的执行,若执行出现错误,所有其它命令仍将被执行并有效,Redis 不支持回滚。以传统关系数据库对事务原子性的定义,则 Redis是非原子的;若以对语法错误的命令处理,可认为 Redis 是原子性的。
  2. 一致性:事务必须使数据库从一个一致性状态变换到另一个一致性状态(事务执行前后都必须处于一致性状态)。
    • MySQL:MySQL的事务处理机制是确保状态一致性的(强一致)。
    • Redis:因 Redis 不支持回滚,也就放弃了对数据一致性的保证。不过对于一个高性能的内存数据库,数据操作一致性更多应该依赖于应用层面,在分布式和集群环境,数据一致性无法依靠事务机制来保证,更多是在应用层处理来是追求最终一致性
  3. 隔离性:并发事务之间相互隔离不被干扰。
    • MySQL:存在并发事务,事务之间存在被干扰的可能,所以 MySQL 定义了 5 个事务隔离级别,根据需要可对事务进行分级控制。
    • Redis:单线程模型,也就不存在并发事务,在一个事务完成之前其它客户端提交的各种操作都不能执行,也就保证了隔离性。
  4. 持久性:事务被提交,数据的改变就是永久性的。
    • MySQL:在事务提交后,数据会被写入到硬盘,数据的改变是永久性的。
    • Redis:内存数据库,持久性是无法保证的。但 Redis 也提供了 2 种数据持久化模式AOPRDB(可看作某一个时间点的快照)

Redis 事务机制

MULTI, EXEC, DISCARD, WATCH是 Redis 事务的基础。Redis 事务允许一个步骤中执行一组命令,并且带有以下两个保证:

  1. Redis 事务中的所有命令都被序列化并按顺序的执行。在执行 Redis 事务的过程中,不会处理另外一个客户端的请求,这可以确保这些命令作为一个单独隔离操作来执行。
  2. 所有命令要么都处理,要么都不处理,因此 Redis 事务也是原子的。EXEC命令触发事务中所有命令的执行,因此如果客户端在调用标记开始事务的MULTI命令之前丢失了与服务器的连接,则不执行任何操作;相反,如果调用到了EXEC命令,则执行所有操作。

从版本2.2开始,Redis 允许对上述两条提供额外的保证,采用乐观锁的实现方式。

Redis 事务用法

  1. 使用MULTI标记开始一个事务,该命令的返回的始终是 OK。
  2. 此时,用户可以发出多个命令。Redis 不会执行这些命令,而是将它们排队。当开启了 Redis 事务,所有命令都将使用字符串QUEUED回复。排队命令在调用EXEC时被调度执行。
  3. 调用EXEC,所有命令都会被执行,返回一个数组,其中每个元素都是事务中单个命令的回复,其顺序与发出命令的顺序相同。
  4. 或者调用DISCARD命令清空事务队列并退出事务。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name jack
QUEUED
127.0.0.1:6379> set age 23
QUEUED
127.0.0.1:6379> set address Shenzhen
QUEUED
127.0.0.1:6379> exec
1) OK
2) OK
3) OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set key1 value1
QUEUED
127.0.0.1:6379> set key2 value2
QUEUED
127.0.0.1:6379> discard
OK
127.0.0.1:6379> exec
(error) ERR EXEC without MULTI

Redis 事务错误处理

在事务期间,可能会遇到两种命令错误:

  1. 命令可能无法排队,在调用EXEC之前可能出错,队列会被清空并退出事务。例如,命令可能语法错误(错误的参数数量,错误的命令名称等),或者一些关键条件不满足,如内存不足(如果配置了 maxmemory 限制)。
  2. 调用EXEC后,队列中的的某一个命令执行可能失败,但 Redis 不会停止执行命令, 队列中的所有其它正确的命令仍会被执行成功。 例如,对错误值的键进行操作(比如对字符串型的值执行INCR操作),这类错误通常是类型错误

客户端通过检查排队命令的返回值来感知在EXEC调用之前发生的第一类错误:如果命令回复QUEUED,则它正确排队,否则 Redis 会返回错误,如果在排队时命令出错,大多数客户端中止该事务。若是在调用EXEC之后发生的错误,即使某些命令在事务期间失败,所有其它命令也将被执行。

还有一种更复杂的错误场景,在管道中使用了事务,从 Redis 2.6.5 开始,服务器会记住命令在打包期间的错误,并且拒绝执行事务(事务期间的命令不被执行),在EXEC期间返回错误,并自动丢弃事务。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
-- 命令语法错误,无法排队
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set key1 value1
QUEUED
127.0.0.1:6379> set key2 value2
QUEUED
127.0.0.1:6379> set key3 value3
QUEUED
127.0.0.1:6379> sett key4 value4
(error) ERR unknown command `sett`, with args beginning with: `key4`, `value4`,
127.0.0.1:6379> exec
(error) EXECABORT Transaction discarded because of previous errors.

-- 执行命令,类型错误
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name jack
QUEUED
127.0.0.1:6379> incr name
QUEUED
127.0.0.1:6379> set age 22
QUEUED
127.0.0.1:6379> set email jack@email.com
QUEUED
127.0.0.1:6379> exec
1) OK
2) (error) ERR value is not an integer or out of range
3) OK
4) OK
127.0.0.1:6379>

Redis WATCH-乐观锁

  • WATCH:Redis 的WATCH命令为 Redis 事务提供了CAS(check-and-set)特性; WATCH命令是基于乐观锁的实现。
    在事务开启前使用WATCH命令对目标键加乐观锁(监视),如果监视的任意一个 Key的值在调用EXEC之前被修改,则整个事务中止,并且EXEC返回**(nil)**通知事务失败。
  • UNWATCH:对已加锁的目标键解锁。可以使用UNWATCH命令,不带任何参数来清除所有键的乐观锁(WATCH)。

WATCH使EXEC成为条件命令,只有监视的键没有任何修改,才会执行事务。单个WATCH可以对多个加乐观锁。WATCH的生命周期从事务开始是开始到调用EXEC时结束。当调用EXEC时,无论事务是否中止,所有键都是UNWATCH,当客户端连接关闭时,所有都是UNWATCH。也可以在事务中使用UNWATCH解锁,让后续操作不被WATCH,可使连接用于新事务(这句转译的有些别扭,使用方式感觉没啥用)。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
127.0.0.1:6379> watch age
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incr age
QUEUED
127.0.0.1:6379> exec
1) (integer) 14
127.0.0.1:6379> watch age
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incr age
QUEUED
//其它客户端修改了 age 的值,再执行 exec
127.0.0.1:6379> exec
(nil)

127.0.0.1:6379> watch age
OK
127.0.0.1:6379> unwatch
OK

使用WATCH创建新的原子操作

示例:从有序集合中自动弹出分数较低的元素(实际开发可通过代码或脚本实现)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
127.0.0.1:6379> zadd zset 0 apple
(integer) 1
127.0.0.1:6379> zadd zset 10 oranage
(integer) 1
127.0.0.1:6379> zadd zset 7 banana
(integer) 1
127.0.0.1:6379> zadd zset 4 pear
(integer) 1
127.0.0.1:6379> watch zset
OK
127.0.0.1:6379> zrange zset 0 0
1) "apple"
127.0.0.1:6379> multi
OK
127.0.0.1:6379> zrem zset apple
QUEUED
127.0.0.1:6379> exec
1) (integer) 1

Redis 脚本和事务

根据定义,Redis 脚本是事务性的,所以在 Redis 事务中可以执行的所有操作也可使用脚本操作,通常脚本将更简单、更快速。
这种重复是因为在Redis 2.6中才引入了脚本,而事务早就存在,不可能在短时间内删除对事务的支持,因为即使不使用 Redis 脚本,它仍然可以避免竞争条件(Race Conditions),同时因为 Redis 事务的实现复杂性很小。
若未来出现用户只使用脚本的情况,Redis 事务可能会被弃用并最终删除。

Redis事务和乐观锁

基于 Spring Boot 提供的自动配置来执行事务,在注册 RedisTemplate Bean 时开启事务支持:template.setEnableTransactionSupport(true);或获取连接来执行事务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* @name: RedisTransaction
* @desc: Redis事务
**/
@RunWith(SpringRunner.class)
@SpringBootTest
public class RedisTransaction {

private static final Logger logger = LogManager.getLogger(RedisTransaction.class);

@Autowired
private RedisTemplate redisTemplate;

@Autowired
private RedisConnectionFactory redisConnectionFactory;

@Test
public void redisTransactionTest(){

/*
//开启事务
redisTemplate.multi();
redisTemplate.opsForValue().set("name","Tom Tom");//执行成功
redisTemplate.opsForValue().increment("name",1);//执行失败
redisTemplate.opsForValue().set("filmName","Tom And Jerry");//执行成功
List<Object> execList = redisTemplate.exec();
*/

RedisConnection connection = redisConnectionFactory.getConnection();
//乐观锁
connection.watch("name".getBytes());
//开启事务
connection.multi();
connection.set("age".getBytes(),String.valueOf(34).getBytes());
connection.set("filmName".getBytes(),"Tom Love Jerry".getBytes());
//乐观锁:在exec()处打断点,使用其它客户端修改watch(key)的值,此处再方放行,事务执行失败返回null
List<Object> execList = connection.exec();
logger.info("execList:{}", JSON.toJSONString(execList));
}
}

Redis 4.x系列(九):Redis Transaction(事务)

http://blog.gxitsky.com/2018/10/14/Redis-9-transaction/

作者

光星

发布于

2018-10-14

更新于

2022-08-14

许可协议

评论