Redis中的事务

Redis的事务是与SQL数据库不同的。详细了解请参考文档,转述如下:

Redis的事务:先以 MULTI 开始一个事务,然后将多个命令入队到事务中, 最后由 EXEC 命令触发事务。当碰到命令:MULTI (标记一个事务块的开始),在该连接上的命令不会执行:它们会排队(调用方会得到每个队列的回复)。当遇到命令:EXEC(执行所有事务块内的命令),它们被应用到一个单独的单元中(比如:没有其它连接操作之间的那个时间段)。如果是命令 DISCARD(取消事务,放弃执行事务块内的所有命令) 而不是 EXEC,那么所有的操作都会不执行(回滚)。因为命令是在事务里面排队的,所以你不能改变内部事务。

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

  • 事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
  • 事务是一个原子操作:事务中的命令要么全部被执行,要么全部都不执行。

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

  • 开始事务。
  • 命令入队。
  • 执行事务。
Redis 事务命令
  • DISCARD 取消事务,放弃执行事务块内的所有命令。
  • EXEC 执行所有事务块内的命令。
  • MULTI 标记一个事务块的开始。
  • UNWATCH 取消 WATCH 命令对所有 key 的监视。
  • WATCH key [key …] 监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。

例如:在SQL数据库中你可能回做如下操作:

  1. // 仅在它没有唯一ID的时候,分配一个唯一的ID。确保事务中没有线程竞争
  2. var newId = CreateNewUniqueID(); // optimistic
  3. using(var tran = conn.BeginTran())
  4. {
  5. var cust = GetCustomer(conn, custId, tran);
  6. var uniqueId = cust.UniqueID;
  7. if(uniqueId == null)
  8. {
  9. cust.UniqueId = newId;
  10. SaveCustomer(conn, cust, tran);
  11. }
  12. tran.Complete();
  13. }

在Redis中是怎么做的?

在Redis事务中这简直是不可能的是:一旦事务被开启,你不能去获取数据 — 你的操作是排队执行的。幸运的是,有另外两个命令可以帮助我们:WATCH和UNWATCH

WATCH{key} 命令用于监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断且回滚。EXEC 所做的和 DISCARD 一样(调用方一开始就能发现并重试)。那么你能做的是:使用命令:WATCH 某个键,以正常的方式,来检查给定键的数据,然后使用 MULTI/EXEC 命令执行你的改变。当你检查数据的时候,你会发现你实际上不需要事务,你可以用 UNWATCH 命令用于取消 WATCH 命令对所有 key 的监视。注意:在使用 EXECDISCARD 的时候,观察键也是可以重置的(如果执行EXEC 或者DISCARD,则不需要手动执行UNWATCH)。所以在Redis层,这只是概念上的:

  1. WATCH {custKey}
  2. HEXISTS {custKey} "UniqueId"
  3. (check the reply, then either:)
  4. MULTI
  5. HSET {custKey} "UniqueId" {newId}
  6. EXEC
  7. (or, if we find there was already an unique-id:)
  8. UNWATCH

这可能看起来很奇怪:只有跨越单个操作时才可以使用 MULTI/EXEC 命令,但重要的是我们现在也可以使用 {custKey} 从所有其它的连接中来跟踪变更:如果其他人更改这个Key,那么事务会被终止。

在StackExchange.Redis又该怎么做?

更复杂的事实是StackExchange.Redis使用的是多路复用器的方式。

我们不能只让并发调用方发布 WATCH / UNWATCH / MULTI / EXEC / DISCARD:这应该是混合在一起的。所以一个额外的抽象被给出:另外会让使事情更简单准确:约束。约束是预定义测试包括 WATCH 某种类型的测试并对结果进行检查。如果所有的约束都通过了,那么要么是以 MULTI / EXEC 发布(从事务开始,到执行整个事务块);要么是以 UNWATCH 发布(取消 WATCH 命令对所有 key 的监视)。阻止命令于其它调用方被混合在一起;所以例子可以是:

  1. var newId = CreateNewId();
  2. var tran = db.CreateTransaction();
  3. tran.AddCondition(Condition.HashNotExists(custKey, "UniqueID"));
  4. tran.HashSetAsync(custKey, "UniqueID", newId);
  5. bool committed = tran.Execute();
  6. // ^^^ 如果真: 该命令会被执行; 如果假: 那么会回滚。

注意:从 CreateTransaction 返回的对象最后都是调用异步方法来执行命令(Execute方法最终也是调用ExecuteAsync,具体可以看源码):由于不知道每个操作的结果,除非在 ExecuteExecuteAsync 操作完成后。如果操作没有被执行,所有的 Task 将被标记为取消,否则在命令执行后你可以获取每个正常的结果。

通过 When 的内置操作

还应该注意的是,Redis已经为我们预料到了许多常见的场景(特别是:key/hash的存在,就像上面一样),还有单操作(single-operation)原子命令的存在。
通过 When 来访问,所以前面的示例也可以这样来实现:

  1. var newId = CreateNewId();
  2. bool wasSet = db.HashSet(custKey, "UniqueID", newId, When.NotExists);

注意:When.NotExists 会使用命令 HSETNX 而不会使用 HSET

Lua

你应该记住Redis 2.6及以上的版本支持Lua脚本,它可以描述为:一个常用的工具,使多个操作在服务器端的以一个原子单元执行。在使用Lua脚本的时候,由于不需要服务于其它的连接,所以它的行为更像是一个事务处理,但是没有 MULTI / EXEC 那么复杂。这也避免了诸如调用方和服务器端之间带宽和延迟的问题。但是代价是在脚本执行的时候独占了服务器。

在Redis层(假设 HSETNX 不存在)我们可以有如下实现:

  1. EVAL "if redis.call('hexists', KEYS[1], 'UniqueId') then return redis.call('hset', KEYS[1], 'UniqueId', ARGV[1]) else return 0 end" 1 {custKey} {newId}

在 StackExchange.Redis 是这样使用的:

  1. var wasSet = (bool) db.ScriptEvaluate(@"if redis.call('hexists', KEYS[1], 'UniqueId') then return redis.call('hset', KEYS[1], 'UniqueId', ARGV[1]) else return 0 end",
  2. new RedisKey[] { custKey }, new RedisValue[] { newId });

注意:来自 ScriptEvaluateScriptEvaluateAsync 的响应是可变的,这依赖于你所写的脚本。响应结果可以被强制转换,在这个例子中是被转换为 bool 类型。