Redis事务机制

1.概述

Redis 事务可以一次执行多个命令, 并且带有以下两个重要的保证:

  • 批量操作在发送 EXEC 命令前被放入队列缓存。
  • 收到 EXEC 命令后进入事务执行,事务中任意命令执行失败,其余的命令依然被执行。
  • 在事务执行过程,其他客户端提交的命令请求不会插入到事务执行命令序列中。

一个事务从开始到执行会经历以下三个阶段:

  • 开始事务。
  • 命令入队。
  • 执行事务。

2.Redis事务命令

命令

命令及描述

MULTI 

标记一个事务块的开始。其后执行的命令都将被存入命令队列,直到执行EXEC时,这些命令才会被原子执行。

EXEC 

执行所有事务块内的命令,同时将当前连接的状态恢复为正常状态,即非事务状态。如果在事务中执行了WATCH命令,那么只有当WATCH所监控的keys没有被修改的前提下,EXEC命令才能执行事务队列中的所有命令,否则EXEC将放弃当前事务中的所有命令。

DISCARD 

取消事务,放弃执行事务块内的所有命令。同时再将当前连接的状态恢复为正常状态,即非事务状态。如 果WATCH命令被使用,该命令将UNWATCH所有的keys.

WATCH key [key ...] 

监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。

UNWATCH 

取消 WATCH 命令对所有 key 的监视。如果执行了EXEC或DISCARD命令,则无需再手工执行  该命令了,因为在此之后,事务中所有的keys都将自动取消,

 

3.事务的使用

Redis提供了一个 multi 命令开启事务,exec 命令提交事务,在它们之间的命令是在一个事务内的,能保证原子性。

127.0.0.1:6379>multi
"OK"
127.0.0.1:6379>set tran1 hello
"QUEUED"
127.0.0.1:6379>set tran2 world
"QUEUED"
127.0.0.1:6379>exec
 1)  "OK"
 2)  "OK"

 通过上面的命令可以看到使用 multi 命令开启事务之后,执行的Redis命令返回结果 QUEUED,表示命令并没有执行,而是暂时保存在Redis事务中,直到执行 exec 命令后才会执行上面的命令并且返回结果。

此外我们可以使用DISCARD取消事务。当执行 DISCARD 命令时, 事务会被放弃, 事务队列会被清空, 并且客户端会从事务状态中退出。

127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name2 "lisi"
QUEUED
127.0.0.1:6379> set age 22
QUEUED
127.0.0.1:6379> discard
OK
127.0.0.1:6379> exec
(error) ERR EXEC without MULTI
127.0.0.1:6379>

 下面这个是Jedis客户端执行事务的代码:

public static void testTran() {
    // 开启事务
    Transaction transaction = jedis.multi();
    // 执行事务内的Redis命令
    transaction.set("tran1", "hello");
    transaction.set("tran2", "world");
    // 提交事务
    List<Object> exec = transaction.exec();
    // 事务中每条命令的执行结果
    System.out.println(exec);
}

需要注意的是开启事务之后,执行命令的对象不是Jedis对象,而是Transaction对象,否则会抛出下面的异常:

Exception in thread "main" redis.clients.jedis.exceptions.JedisDataException: 
Cannot use Jedis when in Multi. 
Please use Transation or reset jedis state.

4.事务对异常的处理机制

 Redis执行命令的错误主要分为两种: 

- 1.命令错误:执行命令语法错误,比如说将 set 命令写成 sett 
- 2.运行时错误:命令语法正确,但是执行错误,比如说对 List 集合执行 sadd 命令

 Redis事务中如果发生上面两种错误,处理机制也是不同的。

(1)命令错误处理机制

这种情况需要区别Redis版本,Redis2.65之前的版本会忽略错误的命令,执行其他正确的命令,2.65之后的版本会忽略这个事务中的所有命令,都不执行,就比如上面的例子(使用的Redis版本是2.8的);

127.0.0.1:6379>multi
"OK"
127.0.0.1:6379>set a1 a
"QUEUED"
127.0.0.1:6379>sett a2 b
"ERR unknown command 'sett'"
127.0.0.1:6379>exec
"EXECABORT Transaction discarded because of previous errors."
127.0.0.1:6379>get a1
null

 上面案例中,开启事务后第一条命令添加返回QUEUED,第二条命令语法错误,最后提交事务。

可以看到,事务提交后 get a1 返回值是null,所以第二条命令的语法错误导致整个事务中的命令都不会执行。

(2)运行时错误处理机制

运行错误表示命令执行过程中出现错误,就比如用GET命令去获取一个散列表类型的键值。

这种错误在命令执行之前Redis是无法发现的,所以在事务里这样的命令都会被Redis接受并执行.如果事务里有一条命令执行错误,Redis不仅不会回滚事务,还会跳过这个运行时错误,其他命令依旧会执行(包括出错后的命令)。

127.0.0.1:6379>lpush l1 a
"1"
127.0.0.1:6379>lpush l2 b
"1"
127.0.0.1:6379>lpush l3 c
"1"
127.0.0.1:6379>multi 
"OK"
127.0.0.1:6379>lpush l1 aa
"QUEUED"
127.0.0.1:6379>sadd l2 bb
"QUEUED"
127.0.0.1:6379>lpush l3 cc
"QUEUED"
127.0.0.1:6379>exec
1) "2"
2) "WRONGTYPE Operation against a key holding the wrong kind of value"
3) "2"

上面这个案例中,先创建了三个List类型 l1、l2、l3,然后开启事务,第一条命令往l1中插入元素,第二条命令使用 sadd 命令往List类型的l2中添加元素,第三天命令往l2中插入元素,最后提交事务。

可以看到最后事务的执行结果是第一条和第三条命令执行成功,第二条命令执行失败,所以第二条命令的执行失败不仅没有回滚事务而且还不会影响后续第三条命令的执行。

 5.Watch命令(乐观锁的实现)

 WATCH 对key值进行锁操作。 在 WATCH 执行之后, EXEC 执行之前, 有其他客户端修改了 key 的值, 那么当前客户端的事务就会失败。如下:

 Client1开启watch name并在事务中修改name,但是没有执行exec

127.0.0.1:6379> get name

"huangliu"

127.0.0.1:6379> watch name

OK

127.0.0.1:6379> multi

OK

127.0.0.1:6379> set name lisi

QUEUED

Client2 修改name

127.0.0.1:6379> get name

"huangliu"

127.0.0.1:6379> set name "wangwu"

OK

127.0.0.1:6379> get name

"wangwu"

127.0.0.1:6379>

Client1执行exec

127.0.0.1:6379> exec

(nil)

127.0.0.1:6379>

 可见,由于被watch的name已经被Client2 修改,所以Client1的事务执行失败,程序需要做的, 就是不断重试这个操作, 直到没有发生碰撞(Crash)为止。对key进行加锁监视的机制类似Java多线程中的锁(synchronized中的监视器对象),被称作乐观锁。乐观是一种非常强大的锁机制,后面我们会进一步学习redis的分布式锁。下面我们来看一下watch在java的操作:

@Test
public void testWatch() {
    JedisPool jedisPool = new JedisPool("192.168.1.4");
    // 设定 nowatch 的初始值为 hello
    Jedis jedis = jedisPool.getResource();
    jedis.set("watchtest", "hello");
    // 开启事务
    Transaction multi = jedis.multi();
    // 另一个jedis客户端对 watchtest进行append操作
    jedisPool.getResource().append("watchtest", " xxx");
    // 事务内部对watchtest进行append操作
    multi.append("watchtest", " world");
    // 提交事务
    multi.exec();
    // 打印watchtest对应的value
    System.out.println(jedis.get("watchtest"));
}

 上面这个案例,watchtest的初始值是”hello”,开启了一个事务,并且往watchtest中append ” world”,我们预期的结果是”hello world”,但是在事务执行过程中有另一个jedis客户端往watchtest中append ” xxx”,所以上面这段代码会在控制台打印:

hello xxx world

 

我们往往希望当前事务的执行不会受到其他事务的影响,所以这个结果明显不是我们所预期的。

Redis提供了一个 watch 命令来帮我们解决上面描述的这个问题,在 multi 命令之前我们可以使用 watch 命令来”观察”一个或多个key,在事务提交之前Redis会确保被”观察”的key有没有被修改过,没有被修改过才会执行事务中的命令,如果存在key被修改过,那么整个事务中的命令都不会执行,有点类似于乐观锁的机制。

还是上面的案例,如果在开启事务那一行上面添加 watch 命令:

// 使用 watch 命令watch "watchtest"
jedis.watch("watchtest");
// 开启事务
Transaction multi = jedis.multi();

 最终控制台打印结果会变成:

hello xxx

 可以看出,使用 watch 命令之后,由于watchtest被其他客户端修改过,所以事务中append " world" 的命令就不会执行,所以最终会打印 "hello xxx"。

一般乐观锁都需要配合重试机制来实现,所以这里 watch 命令也可以配合重试机制来实现:

public void incr(String key) {
    jedis.watch(key);
    Integer num = Integer.valueOf(jedis.get(key));
    Transaction multi = jedis.multi();
    multi.set(key, String.valueOf(num + 1));
    List<Object> exec = multi.exec();
    // exec为空表示事务没有执行,在这里添加重试机制
    if (exec.isEmpty()) {
        incr(key);
    }
}

 上面这段代码是使用 watch 命令实现了Redis中的incr命令,这里为了演示 watch 命令配合重试的机制,就不去校验key对应的数据结构是否是int类型。

综上所述,在这里引出乐观锁,针对乐观锁和悲观锁做一解释:

乐观锁和共享锁
乐观锁(Optimistic Lock)又叫做共享锁,每次别人拿数据的时候都认为别人不会修改数据,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读得应用类型,这样会提高吞吐量。

悲观锁(Pessimistic Lock)又叫做排它锁(x锁),每次拿刀数据的时候都认为别人会修改数据,所以每次在拿到数据的时候都会上锁,这样别人想拿到这个数据就会block直到
它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁,都是在操作之前先上锁。

6.参考资料

http://www.runoob.com/redis/redis-transactions.html

http://www.sohu.com/a/282419876_179850

https://blog.csdn.net/Hqs_1020417504/article/details/79908264

https://www.cnblogs.com/laojiao/p/9580653.html

https://blog.csdn.net/Leon_cx/article/details/82345054

https://www.cnblogs.com/hjwublog/p/5660578.html

 

posted on 2019-05-02 23:28  Eugene_Jin  阅读(299)  评论(0编辑  收藏  举报