带有乐观锁的事务

本书在前面的《字符串》一章实现了具有基本获取和释放功能的锁程序,并在《自动过期》一章为该程序加上了自动释放功能,但是这两个锁程序都有一个问题,那就是,它们的释放操作都是不安全的:

  • 无论某个客户端是否是锁的持有者,只要它调用 release() 方法,锁就会被释放。

  • 在锁被占用期间,如果某个不是持有者的客户端错误地调用了 release() 方法,那么锁将在持有者不知情的情况下释放,并导致系统中同时存在多个锁。

为了解决这个问题,我们需要修改锁实现,给它加上身份验证功能:

  • 客户端在尝试获取锁的时候,除了需要输入锁的最大使用时限之外,还需要输入一个代表身份的标识符,当客户端成功取得锁时,程序将把这个标识符储存在代表锁的字符串键里面。

  • 当客户端调用 release() 方法时,它需要将自己的标识符传给 release() 方法,而 release() 方法则需要验证客户端传入的标识符与锁键储存的标识符是否相同,以此来判断调用 release() 方法的客户端是否就是锁的持有者,从而决定是否释放锁。

根据以上描述,我们可能会写出代码清单 13-5 所示的代码。


代码清单 13-5 不安全的锁实现:/pipeline-and-transaction/unsafe_identity_lock.py

  1. class IdentityLock:
  2.  
  3. def __init__(self, client, key):
  4. self.client = client
  5. self.key = key
  6.  
  7. def acquire(self, identity, timeout):
  8. """
  9. 尝试获取一个带有身份标识符和最大使用时限的锁,
  10. 成功时返回 True ,失败时返回 False 。
  11. """
  12. result = self.client.set(self.key, identity, ex=timeout, nx=True)
  13. return result is not None
  14.  
  15. def release(self, input_identity):
  16. """
  17. 根据给定的标识符,尝试释放锁。
  18. 返回 True 表示释放成功;
  19. 返回 False 则表示给定的标识符与锁持有者的标识符并不相同,释放请求被拒绝。
  20. """
  21. # 获取锁键储存的标识符
  22. lock_identity = self.client.get(self.key)
  23. if lock_identity is None:
  24. # 如果锁键的标识符为空,那么说明锁已经被释放
  25. return True
  26. elif input_identity == lock_identity:
  27. # 如果给定的标识符与锁键的标识符相同,那么释放这个锁
  28. self.client.delete(self.key)
  29. return True
  30. else:
  31. # 如果给定的标识符与锁键的标识符并不相同
  32. # 那么说明当前客户端不是锁的持有者
  33. # 拒绝本次释放请求
  34. return False

这个锁实现在绝大部分情况下都能够正常运行,但它的 release() 方法包含了一个非常隐蔽的错误:在程序使用 GET 命令获取锁键的值以后,直到程序调用 DEL 命令删除锁键的这段时间里面,锁键的值有可能已经发生了变化,因此程序执行的 DEL 命令有可能会导致当前持有者的锁被错误地释放。

举个例子,表 13-1 就展示了一个锁被错误释放的例子:客户端 A 是锁原来的持有者,它调用 release() 方法尝试释放自己的锁,但是当客户端 A 执行完 GET 命令并确认自己就是锁的持有者之后,锁键却因为过期而自动被移除了,紧接着客户端 B 又通过执行 acquire() 方法成功取得了锁,然而客户端 A 并未察觉这一变化,它以为自己还是锁的持有者,并调用 DEL 命令把属于客户端 B 的锁给释放了。


表 13-1 一个错误地释放锁的例子

时间客户端 A客户端 B服务器
0000调用 release() 方法
0001执行 GET 命令,获取锁键的值
0002检查锁键的值,确认自己就是持有者
0003移除过期的锁键
0004执行 acquire() 方法并取得锁
0005执行 DEL 命令,删除锁键(在不知情的状况下失去了锁)

为了正确地实现 release() 方法,我们需要一种机制,它可以保证如果锁键的值在 GET 命令执行之后发生了变化,那么 DEL 命令将不会被执行。在 Redis 里面,这种机制被称为乐观锁。

本节接下来的内容将对 Redis 的乐观锁机制进行介绍,并在之后给出一个使用乐观锁实现的、正确的、具有身份验证功能的锁。

WATCH:对键进行监视

客户端可以通过执行 WATCH 命令,要求服务器对一个或多个数据库键实施监视,如果在客户端尝试执行事务之前,这些键的值发生了变化,那么服务器将拒绝执行客户端发送的事务,并向它返回一个空值:

  1. WATCH key [key ...]

与此相反,如果所有被监视的键都没有发生任何变化,那么服务器将会如常地执行客户端发送的事务。

通过同时使用 WATCH 命令和 Redis 事务,我们可以构建出一种针对被监视键的乐观锁机制,确保事务只会在被监视键没有发生任何变化的情况下执行,从而保证事务对被监视键的所有修改都是安全、正确和有效的。

以下代码展示了一个因为乐观锁机制而导致事务执行失败的例子:

  1. redis> WATCH user_id_counter
  2. OK
  3.  
  4. redis> GET user_id_counter -- 获取当前最新的用户 ID
  5. "256"
  6.  
  7. redis> MULTI
  8. OK
  9.  
  10. redis> SET user::256::email "peter@spamer.com" -- 尝试使用这个 ID 来储存用户信息
  11. QUEUED
  12.  
  13. redis> SET user::256::password "topsecret"
  14. QUEUED
  15.  
  16. redis> INCR user_id_counter -- 创建新的用户 ID
  17. QUEUED
  18.  
  19. redis> EXEC
  20. (nil) -- user_id_counter 键已被修改,事务被拒绝执行

表 13-2 展示了这个事务执行失败的具体原因:因为客户端 A 监视了 user_id_counter 键,而客户端 B 却在客户端 A 执行事务之前对该键进行了修改,所以服务器最终拒绝了客户端 A 的事务执行请求。


表 13-2 事务被拒绝执行的完整过程

时间客户端 A客户端 B
0000WATCH user_id_counter
0001GET user_id_counter
0002MULTI
0003SET user::256::email "peter@spamer.com"
0004SET user::256::password "topsecret"
0005SET user_id_counter 10000
0006INCR user_id_counter
0007EXEC

其他信息

属性
时间复杂度O(N),其中 N 为被监视键的数量。
版本要求WATCH 命令从 Redis 2.2.0 版本开始可用。

UNWATCH:取消对键的监视

客户端可以通过执行 UNWATCH 命令,取消对所有键的监视:

  1. UNWATCH

服务器在接收到客户端发送的 UNWATCH 命令之后,将不会再对之前 WATCH 命令指定的键实施监视,这些键也不会再对客户端发送的事务造成任何影响。

以下代码展示了一个 UNWATCH 命令的执行示例:

  1. redis> WATCH "lock_key" "user_id_counter" "msg"
  2. OK
  3.  
  4. redis> UNWATCH -- 取消对以上三个键的监视
  5. OK

除了显式地执行 UNWATCH 命令之外,使用 EXEC 命令执行事务和使用 DISCARD 取消事务,同样会导致客户端撤销对所有键的监视,这是因为这两个命令在执行之后都会隐式地调用 UNWATCH 命令。

其他信息

属性
复杂度O(N),其中 N 为被取消监视的键数量。
版本要求UNWATCH 命令从 Redis 2.2.0 版本开始可用。