Redis知识梳理(14) [ 事务 ]

版权声明:转载请注明出处 https://blog.csdn.net/cowbin2012/article/details/90040121

为了确保连续多个操作的原子性,一个成熟的数据库通常都会有事务支持, Redis 也不例外。Redis 的事务使用非常简单,不同于关系数据库,我们无须理 解那么多复杂的事务模型,就可以直接使用。不过也正是因为这种简单性,它的 事务模型很不严格,这要求我们不能像使用关系数据库的事务一样来使用 Redis。

Redis 事务的基本使用

每个事务的操作都有 begin、commit 和 rollback,它大致的形式如下

begin 指示事务的开始,

commit 指示事务的提交,

rollback 指示事务的回滚。

​ begin();
try {
command1();
command2();

commit();
} catch(Exception e) {
rollback();
}

Redis 在形式上看起来也差不多,分别是 multi/exec/discard。

multi 指示事务 的开始,exec 指示事务的执行,discard 指示事务的丢弃。

​ > multi
OK
> incr books
QUEUED
> incr books
QUEUED
> exec
(integer) 1
(integer) 2

上面的指令演示了一个完整的事务过程,所有的指令在 exec 之前不执行,而是 缓存在服务器的一个事务队列中,

服务器一旦收到 exec 指令,才开执行整个事 务队列,执行完毕后一次性返回所有指令的运行结果。

因为 Redis 的单线程特性,它不用担心自己在执行队列的时候被其它指令打搅,可以保证他们能得到的 「原子性」执行。

img

上图显示了以上事务过程完整的交互效果。QUEUED 是一个简单字符串,

同 OK 是一个形式,它表示指令已经被服务器缓存到队列里了。

原子性

事务的原子性是指要么事务全部成功,要么全部失败,那么 Redis 事务执行是原 子性的么?

上面的例子是事务执行到中间遇到失败了,因为我们不能对一个字符串进行数学 运算,

事务在遇到指令执行失败后,后面的指令还继续执行,所以 poorman 的 值能继续得到设置。

到这里,你应该明白 Redis 的事务根本不能算「原子性」,

而仅仅是满足了事务 的「隔离性」,隔离性中的串行化——当前执行的事务有着不被其它事务打断的 权利。

discard(丢弃)

Redis 为事务提供了一个 discard 指令,用于丢弃事务缓存队列中的所有指令, 在 exec 执行之前。

我们可以看到 discard 之后,队列中的所有指令都没执行,就好像 multi 和 discard 中间的所有指令从未发生过一样。

优化

上面的 Redis 事务在发送每个指令到事务缓存队列时都要经过一次网络读写,当 一个事务内部的指令较多时,

需要的网络 IO 时间也会线性增长。

所以通常 Redis 的客户端在执行事务时都会结合 pipeline 一起使用,这样可以将多次 IO 操作压缩为单次 IO 操作。

比如我们在使用 Python 的 Redis 客户端时执行事务 时是要强制使用 pipeline 的。

​ pipe = redis.pipeline(transaction=true)
pipe.multi()
pipe.incr(“books”)
pipe.incr(“books”)
values = pipe.execute()

Watch

考虑到一个业务场景,Redis 存储了我们的账户余额数据,它是一个整数。现在 有两个并发的客户端要对账户余额进行修改操作,这个修改不是一个简单的 incrby 指令,而是要对余额乘以一个倍数。

Redis 可没有提供 multiplyby 这样 的指令。我们需要先取出余额然后在内存里乘以倍数,再将结果写回 Redis。

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

Redis 提供了这种 watch 的机制,它就是一种乐观锁。

有了 watch 我们又多了 一种可以用来解决并发修改的方法。 watch 的使用方式如下:

​ while True:
do_watch()
commands()
multi()
send_commands()
try:
exec()
break
except WatchError:
continue

watch 会在事务开始之前盯住 1 个或多个关键变量,当事务执行时,也就是服 务器收到了 exec 指令要顺序执行缓存的事务队列时,Redis 会检查关键变量自 watch 之后,是否被修改了 (包括当前事务所在的客户端)。如果关键变量被人动 过了,exec 指令就会返回 null 回复告知客户端事务执行失败,这个时候客户端 一般会选择重试。

当服务器给 exec 指令返回一个 null 回复时,客户端知道了事务执行是失败的,

通常客户端 (redis-py) 都会抛出一个 WatchError 这种错误,不过也有些语言 (jedis) 不会抛出异常,

而是通过在 exec 方法里返回一个 null,这样客户端需要 检查一下返回结果是否为 null 来确定事务是否执行失败。

注意事项

Redis 禁止在 multi 和 exec 之间执行 watch 指令,

而必须在 multi 之前做好盯 住关键变量,否则会出错。

-*- coding: utf-8

import redis
def key_for(user_id):
return “account_{}”.format(user_id)

def double_account(client, user_id):
key = key_for(user_id)
while True:
client.watch(key)
value = int(client.get(key))
value*=2 #加倍
pipe = client.pipeline(transaction=True)
pipe.multi()
pipe.set(key, value)
try:
pipe.execute()
break # 总算成功了
except redis.WatchError:
continue # 事务被打断了,重试
return int(client.get(key)) # 重新获取余额

client = redis.StrictRedis()
user_id = “abc”
client.setnx(key_for(user_id), 5) # setnx 做初始化
print double_account(client, user_id)

Java 实现:

import java.util.List;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;

public class TransactionDemo {

public static void main(String[] args) {
Jedis jedis = new Jedis();
String userId = “abc”;
String key = keyFor(userId);
jedis.setnx(key, String.valueOf(5)); # setnx 做初始化
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 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 为什么不支持回滚?

Redis 命令只会因为错误的语法而失败(并且这些问题不能在入队时发现),或是命令用在了错误类型的键上面:这也就是说,

从实用性的角度来说,失败的命令是由编程错误造成的,而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中。

因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速。

有种观点认为 Redis 处理事务的做法会产生 bug , 然而需要注意的是, 在通常情况下, 回滚并不能解决编程错误带来的问题

举个例子, 如果你本来想通过 INCR 命令将键的值加上 1 , 却不小心加上了 2 ,

又或者对错误类型的键执行了 INCR , 回滚是没有办法处理这些情况的。

鉴于没有任何机制能避免程序员自己造成的错误, 并且这类错误通常不会在生产环境中出现,

所以 Redis 选择了更简单、更快速的无回滚方式来处理事务。

猜你喜欢

转载自blog.csdn.net/cowbin2012/article/details/90040121