带有乐观锁的事务
本书在前面的《字符串》一章实现了具有基本获取和释放功能的锁程序,并在《自动过期》一章为该程序加上了自动释放功能,但是这两个锁程序都有一个问题,那就是,它们的释放操作都是不安全的:
无论某个客户端是否是锁的持有者,只要它调用
release()
方法,锁就会被释放。在锁被占用期间,如果某个不是持有者的客户端错误地调用了
release()
方法,那么锁将在持有者不知情的情况下释放,并导致系统中同时存在多个锁。
为了解决这个问题,我们需要修改锁实现,给它加上身份验证功能:
客户端在尝试获取锁的时候,除了需要输入锁的最大使用时限之外,还需要输入一个代表身份的标识符,当客户端成功取得锁时,程序将把这个标识符储存在代表锁的字符串键里面。
当客户端调用
release()
方法时,它需要将自己的标识符传给release()
方法,而release()
方法则需要验证客户端传入的标识符与锁键储存的标识符是否相同,以此来判断调用release()
方法的客户端是否就是锁的持有者,从而决定是否释放锁。
根据以上描述,我们可能会写出代码清单 13-5 所示的代码。
代码清单 13-5 不安全的锁实现:/pipeline-and-transaction/unsafe_identity_lock.py
- class IdentityLock:
- def __init__(self, client, key):
- self.client = client
- self.key = key
- def acquire(self, identity, timeout):
- """
- 尝试获取一个带有身份标识符和最大使用时限的锁,
- 成功时返回 True ,失败时返回 False 。
- """
- result = self.client.set(self.key, identity, ex=timeout, nx=True)
- return result is not None
- def release(self, input_identity):
- """
- 根据给定的标识符,尝试释放锁。
- 返回 True 表示释放成功;
- 返回 False 则表示给定的标识符与锁持有者的标识符并不相同,释放请求被拒绝。
- """
- # 获取锁键储存的标识符
- lock_identity = self.client.get(self.key)
- if lock_identity is None:
- # 如果锁键的标识符为空,那么说明锁已经被释放
- return True
- elif input_identity == lock_identity:
- # 如果给定的标识符与锁键的标识符相同,那么释放这个锁
- self.client.delete(self.key)
- return True
- else:
- # 如果给定的标识符与锁键的标识符并不相同
- # 那么说明当前客户端不是锁的持有者
- # 拒绝本次释放请求
- 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
命令,要求服务器对一个或多个数据库键实施监视,如果在客户端尝试执行事务之前,这些键的值发生了变化,那么服务器将拒绝执行客户端发送的事务,并向它返回一个空值:
- WATCH key [key ...]
与此相反,如果所有被监视的键都没有发生任何变化,那么服务器将会如常地执行客户端发送的事务。
通过同时使用 WATCH
命令和 Redis 事务,我们可以构建出一种针对被监视键的乐观锁机制,确保事务只会在被监视键没有发生任何变化的情况下执行,从而保证事务对被监视键的所有修改都是安全、正确和有效的。
以下代码展示了一个因为乐观锁机制而导致事务执行失败的例子:
- redis> WATCH user_id_counter
- OK
- redis> GET user_id_counter -- 获取当前最新的用户 ID
- "256"
- redis> MULTI
- OK
- redis> SET user::256::email "peter@spamer.com" -- 尝试使用这个 ID 来储存用户信息
- QUEUED
- redis> SET user::256::password "topsecret"
- QUEUED
- redis> INCR user_id_counter -- 创建新的用户 ID
- QUEUED
- redis> EXEC
- (nil) -- user_id_counter 键已被修改,事务被拒绝执行
表 13-2 展示了这个事务执行失败的具体原因:因为客户端 A 监视了 user_id_counter
键,而客户端 B 却在客户端 A 执行事务之前对该键进行了修改,所以服务器最终拒绝了客户端 A 的事务执行请求。
表 13-2 事务被拒绝执行的完整过程
时间 | 客户端 A | 客户端 B |
---|---|---|
0000 | WATCH user_id_counter | |
0001 | GET user_id_counter | |
0002 | MULTI | |
0003 | SET user::256::email "peter@spamer.com" | |
0004 | SET user::256::password "topsecret" | |
0005 | SET user_id_counter 10000 | |
0006 | INCR user_id_counter | |
0007 | EXEC |
其他信息
属性 | 值 |
---|---|
时间复杂度 | O(N),其中 N 为被监视键的数量。 |
版本要求 | WATCH 命令从 Redis 2.2.0 版本开始可用。 |
UNWATCH:取消对键的监视
客户端可以通过执行 UNWATCH
命令,取消对所有键的监视:
- UNWATCH
服务器在接收到客户端发送的 UNWATCH
命令之后,将不会再对之前 WATCH
命令指定的键实施监视,这些键也不会再对客户端发送的事务造成任何影响。
以下代码展示了一个 UNWATCH
命令的执行示例:
- redis> WATCH "lock_key" "user_id_counter" "msg"
- OK
- redis> UNWATCH -- 取消对以上三个键的监视
- OK
除了显式地执行 UNWATCH
命令之外,使用 EXEC
命令执行事务和使用 DISCARD
取消事务,同样会导致客户端撤销对所有键的监视,这是因为这两个命令在执行之后都会隐式地调用 UNWATCH
命令。
其他信息
属性 | 值 |
---|---|
复杂度 | O(N),其中 N 为被取消监视的键数量。 |
版本要求 | UNWATCH 命令从 Redis 2.2.0 版本开始可用。 |