Redis中的事务
Redis的事务是与SQL数据库不同的。详细了解请参考文档,转述如下:
Redis的事务:先以 MULTI 开始一个事务,然后将多个命令入队到事务中, 最后由 EXEC 命令触发事务。当碰到命令:MULTI (标记一个事务块的开始),在该连接上的命令不会执行:它们会排队(调用方会得到每个队列的回复)。当遇到命令:EXEC(执行所有事务块内的命令),它们被应用到一个单独的单元中(比如:没有其它连接操作之间的那个时间段)。如果是命令 DISCARD(取消事务,放弃执行事务块内的所有命令) 而不是 EXEC,那么所有的操作都会不执行(回滚)。因为命令是在事务里面排队的,所以你不能改变内部事务。
注意:Redis 事务可以一次执行多个命令, 并且带有以下两个重要的保证:
- 事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
- 事务是一个原子操作:事务中的命令要么全部被执行,要么全部都不执行。
一个事务从开始到执行会经历以下三个阶段:
- 开始事务。
- 命令入队。
- 执行事务。
Redis 事务命令
- DISCARD 取消事务,放弃执行事务块内的所有命令。
- EXEC 执行所有事务块内的命令。
- MULTI 标记一个事务块的开始。
- UNWATCH 取消 WATCH 命令对所有 key 的监视。
- WATCH key [key …] 监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。
例如:在SQL数据库中你可能回做如下操作:
// 仅在它没有唯一ID的时候,分配一个唯一的ID。确保事务中没有线程竞争
var newId = CreateNewUniqueID(); // optimistic
using(var tran = conn.BeginTran())
{
var cust = GetCustomer(conn, custId, tran);
var uniqueId = cust.UniqueID;
if(uniqueId == null)
{
cust.UniqueId = newId;
SaveCustomer(conn, cust, tran);
}
tran.Complete();
}
在Redis中是怎么做的?
在Redis事务中这简直是不可能的是:一旦事务被开启,你不能去获取数据 — 你的操作是排队执行的。幸运的是,有另外两个命令可以帮助我们:WATCH和UNWATCH。
WATCH{key} 命令用于监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断且回滚。EXEC 所做的和 DISCARD 一样(调用方一开始就能发现并重试)。那么你能做的是:使用命令:WATCH 某个键,以正常的方式,来检查给定键的数据,然后使用 MULTI/EXEC 命令执行你的改变。当你检查数据的时候,你会发现你实际上不需要事务,你可以用 UNWATCH 命令用于取消 WATCH 命令对所有 key 的监视。注意:在使用 EXEC 和 DISCARD 的时候,观察键也是可以重置的(如果执行EXEC 或者DISCARD,则不需要手动执行UNWATCH)。所以在Redis层,这只是概念上的:
WATCH {custKey}
HEXISTS {custKey} "UniqueId"
(check the reply, then either:)
MULTI
HSET {custKey} "UniqueId" {newId}
EXEC
(or, if we find there was already an unique-id:)
UNWATCH
这可能看起来很奇怪:只有跨越单个操作时才可以使用 MULTI/EXEC 命令,但重要的是我们现在也可以使用 {custKey} 从所有其它的连接中来跟踪变更:如果其他人更改这个Key,那么事务会被终止。
在StackExchange.Redis又该怎么做?
更复杂的事实是StackExchange.Redis使用的是多路复用器的方式。
我们不能只让并发调用方发布 WATCH / UNWATCH / MULTI / EXEC / DISCARD:这应该是混合在一起的。所以一个额外的抽象被给出:另外会让使事情更简单准确:约束。约束是预定义测试包括 WATCH 某种类型的测试并对结果进行检查。如果所有的约束都通过了,那么要么是以 MULTI / EXEC 发布(从事务开始,到执行整个事务块);要么是以 UNWATCH 发布(取消 WATCH 命令对所有 key 的监视)。阻止命令于其它调用方被混合在一起;所以例子可以是:
var newId = CreateNewId();
var tran = db.CreateTransaction();
tran.AddCondition(Condition.HashNotExists(custKey, "UniqueID"));
tran.HashSetAsync(custKey, "UniqueID", newId);
bool committed = tran.Execute();
// ^^^ 如果真: 该命令会被执行; 如果假: 那么会回滚。
注意:从 CreateTransaction 返回的对象最后都是调用异步方法来执行命令(Execute方法最终也是调用ExecuteAsync,具体可以看源码):由于不知道每个操作的结果,除非在 Execute 或 ExecuteAsync 操作完成后。如果操作没有被执行,所有的 Task 将被标记为取消,否则在命令执行后你可以获取每个正常的结果。
通过 When 的内置操作
还应该注意的是,Redis已经为我们预料到了许多常见的场景(特别是:key/hash的存在,就像上面一样),还有单操作(single-operation)原子命令的存在。
通过 When 来访问,所以前面的示例也可以这样来实现:
var newId = CreateNewId();
bool wasSet = db.HashSet(custKey, "UniqueID", newId, When.NotExists);
注意:When.NotExists 会使用命令 HSETNX 而不会使用 HSET
Lua
你应该记住Redis 2.6及以上的版本支持Lua脚本,它可以描述为:一个常用的工具,使多个操作在服务器端的以一个原子单元执行。在使用Lua脚本的时候,由于不需要服务于其它的连接,所以它的行为更像是一个事务处理,但是没有 MULTI / EXEC 那么复杂。这也避免了诸如调用方和服务器端之间带宽和延迟的问题。但是代价是在脚本执行的时候独占了服务器。
在Redis层(假设 HSETNX 不存在)我们可以有如下实现:
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 是这样使用的:
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",
new RedisKey[] { custKey }, new RedisValue[] { newId });
注意:来自 ScriptEvaluate 和 ScriptEvaluateAsync 的响应是可变的,这依赖于你所写的脚本。响应结果可以被强制转换,在这个例子中是被转换为 bool 类型。