昨天介绍了Redis Pipeline,Pipeline能帮我们组装命令一次发送给Redis,但是Pipeline中的命令不具有原子性,所以如果我们需要自己组装一个原子性的操作,使用Pipeline是无法实现的,庆幸的是Redis提供了事务和支持Lua脚本来实现原子性操作。
今天我们来了解一下Redis事务。
很多关系型数据库都支持事务操作,保证在一个事务内的操作是原子性的,要么都执行,要么都不执行。
Redis也提供了简单的事务功能,这里为什么说Redis提供的事务是简单的事务功能呢?等下面再揭晓。
1、事务的使用
Redis提供了一个 multi 命令开启事务,exec 命令提交事务,在它们之间的命令是在一个事务内的,能保证原子性。
192.168.1.4:0>multi
"OK"
192.168.1.4:0>set tran1 hello
"QUEUED"
192.168.1.4:0>set tran2 world
"QUEUED"
192.168.1.4:0>exec
1) "OK"
2) "OK"
通过上面的命令可以看到使用 multi 命令开启事务之后,执行的Redis命令返回结果 QUEUED,表示命令并没有执行,而是暂时保存在Redis事务中,直到执行 exec 命令后才会执行上面的命令并且返回结果。
一般的关系型数据库比如Mysql中的事务会存在事务的隔离级别,可能会存在脏读、不可重复读以及幻读的情况。
比如说脏读,A事务可以读到B事务未提交的事务,那么预示着B事务在还没提交的时候就已经将命令发送到Mysql了,事务的提交与否只会影响到事务是否回滚,即使B没有提交事务,A也能读取到B事务中对数据的修改。
Redis的事务做的很简单,没有像关系型数据库那样把事务的隔离级别划分的那么细,Redis在事务没提交之前不会执行事务中的命令,会等到事务提交的那一刻再执行事务中的所有命令。所以上面的案例中,如果在 exec 命令未执行之前另一个Redis客户端调用 get tran1 命令返回值会是null,因为事务没有提交之前事务中的命令还没有执行。
下面这个是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.
2、事务对异常的处理机制
Redis执行命令的错误主要分为两种:
- 1.命令错误:执行命令语法错误,比如说将 set 命令写成 sett
- 2.运行时错误:命令语法正确,但是执行错误,比如说对 List 集合执行 sadd 命令
Redis事务中如果发生上面两种错误,处理机制也是不同的。
命令错误处理机制
开启事务之后,往事务中添加的命令如果有命令错误(语法错误),那么整个事务中的命令都不会执行。
192.168.1.4:0>multi
"OK"
192.168.1.4:0>set a1 a
"QUEUED"
192.168.1.4:0>sett a2 b
"ERR unknown command 'sett'"
192.168.1.4:0>exec
"EXECABORT Transaction discarded because of previous errors."
192.168.1.4:0>get a1
null
上面案例中,开启事务后第一条命令添加返回QUEUED,第二条命令语法错误,最后提交事务。
可以看到,事务提交后 get a1 返回值是null,所以第二条命令的语法错误导致整个事务中的命令都不会执行。
运行时错误处理机制
如果语法没有错误,而执行过程中发生了运行时错误,Redis不仅不会回滚事务,还会跳过这个运行时错误,继续向下执行命令
192.168.1.4:0>lpush l1 a
"1"
192.168.1.4:0>lpush l2 b
"1"
192.168.1.4:0>lpush l3 c
"1"
192.168.1.4:0>multi
"OK"
192.168.1.4:0>lpush l1 aa
"QUEUED"
192.168.1.4:0>sadd l2 bb
"QUEUED"
192.168.1.4:0>lpush l3 cc
"QUEUED"
192.168.1.4:0>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中插入元素,最后提交事务。
可以看到最后事务的执行结果是第一条和第三条命令执行成功,第二条命令执行失败,所以第二条命令的执行失败不仅没有回滚事务而且还不会影响后续第三条命令的执行。
3、watch命令
虽然事务能保证事务内的操作是原子性的,但是无法保证在事务开启到事务提交之间事务中的key没有被其他客户端修改。
有点类似关系型数据库中的不可重复读的概念,在Redis的一个事务中是可以读取到其他事务提交的内容的。
@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类型。
4、总结
在介绍Redis事务的时候就提到了,Redis提供的事务是简单的,这个简单主要体现在不像关系型数据库那样将事务隔离级别划分的那么细以及不支持事务的回滚。
当然了,Redis这么做的目的也是为了性能考虑的,体现了它“keep it simple”的特性。
不得不说Redis事务是一个好功能,能帮我们实现一些原子性的操作,但是实际正常开发中很少遇到使用Redis事务的场景,因为Lua脚本同样可以帮我们实现Redis事务相关功能,并且功能要强大很多。
下一篇文章再来介绍Redis使用Lua脚本。
喜欢这篇文章的朋友,欢迎扫描下图关注公众号lebronchen,第一时间收到更新内容。