锁是计算机系统中经常会用到的一种重要的机制,它可以用来保证特定资源在任何时候最多只能有一个使用者。

Redis可以通过多种方法实现锁,其中包括带有基本功能的锁、带有自动释放功能的锁以及带有密码保护功能的锁等等。 本章接下来的两节将先介绍前两种锁,而带有密码保护功能的锁则会在下一章进行介绍。

需求描述

你想要在Redis中构建锁,并使用它来保护特定的资源。

解决方案:使用字符串键实现基本的锁功能

每个锁程序至少需要实现两个方法:

  • 上锁——尝试获得锁的独占权,在任何时候只能有最多一个客户端成功上锁,而除此以外的其他客户端则会失败。

  • 解锁——成功上锁的客户端可以通过解锁释放对锁的独占权,使得包括它自身在内的所有客户端能够重新获得上锁的机会。

在Redis中实现上述两个操作最基本的方法就是使用字符串数据结构,其中上锁操作可以通过SET命令及其NX选项来实现:

SET key value NX

NX选项的效果保证了给定键只会在没有值(也即是键不存在)的情况下被设置。通过将一个键指定为锁键,并使用客户端尝试对它执行带NX选项的SET命令,程序就可以根据命令返回的结果判断上锁是否成功:

  • 如果命令成功设置了指定的锁键,那么代表当前客户端成功上锁。

  • 反之,如果命令未能成功设置锁键,那么说明锁已被其他客户端占用。

因为带NX选项的SET命令是原子命令,所以即使有多个客户端同时对同一个锁键执行相同的命令,也只会有一个客户端能够成功执行设置操作,因此上述的上锁操作实现是安全的。

另一方面,当客户端需要释放锁的时候,程序只需要使用DEL命令将锁键删除即可:

DEL key

在锁键被删除之后,它所代表的锁也会重新回到解锁状态。

代码清单 CODE_BASIC_LOCK 展示了根据上述原理实现的锁程序。


代码清单 CODE_BASIC_LOCK 基本锁实现 lock.py

VALUE_OF_LOCK = ""

class Lock:

    def __init__(self, client, key):
        self.client = client
        self.key = key

    def acquire(self):
        """
        尝试获取锁,成功时返回True,失败时则返回False。
        """
        return self.client.set(self.key, VALUE_OF_LOCK, nx=True) is True

    def release(self):
        """
        尝试释放锁,成功时返回True,失败时则返回False。
        """
        return self.client.delete(self.key) == 1

acquire()方法中,程序通过检查SET命令的返回值是否为True来判断设置是否成功执行;而在release()方法中,程序则通过检查DEL命令返回的成功删除键数量是否为1来判断锁键是否已被成功删除。

这个锁程序的具体使用方法如下:

>>> from redis import Redis
>>> from lock import Lock
>>> client = Redis(decode_responses=True)
>>> locker1 = Lock(client, "Lock:10086")
>>> locker1.acquire()  # 上锁
True
>>> locker1.release()  # 解锁
True

locker1持有锁期间,如果有其他客户端尝试获取锁,那么acquire()方法将返回False表示上锁失败:

>>> locker1.acquire()  # locker1尝试上锁并成功
True
>>> locker2 = Lock(client, "Lock:10086")  # 模拟另一客户端
>>> locker2.acquire()  # locker2也尝试上锁,但失败
False

扩展方案:带自动释放功能的锁

上一节展示的基本锁实现有一个问题,就是它的解锁操作必须由持有锁的客户端手动执行:如果持有锁的客户端在完成任务之后忘记释放锁,又或者客户端在执行过程中非正常退出,那么锁可能永远也不会被释放,而其他等待的客户端也永远无法获得锁。

为了解决这个问题,可以给锁实现加上自动释放功能,使得客户端即使没有手动释放锁,Redis也可以在超过指定时长之后自动删除锁键并释放锁。

这一功能可以通过Redis的键自动过期特性来实现,为了做到这一点,程序需要在执行带NX选项的SET命令时,通过EX选项或PX选项为锁键设置最大生存时间:

SET key value NX EX sec
SET key value NX PX ms

在此之后,即使锁键没有被解锁操作手动删除,带有生存时间的锁键也会在指定的时限到达之后自动被Redis移除,从而导致锁被自动释放。

代码清单 CODE_AUTORELEASE_LOCK 展示了基于上述原理实现的锁程序。


代码清单 CODE_AUTORELEASE_LOCK 带自动释放功能的锁实现 auto_release_lock.py

VALUE_OF_LOCK = ""

class AutoReleaseLock:

    def __init__(self, client, key):
        self.client = client
        self.key = key

    def acquire(self, timeout, unit="sec"):
        """
        尝试获取一个能够在指定时长之后自动释放的锁。
        timeout参数用于设置锁的最大加锁时长。
        可选的unit参数则用于设置时长的单位,
        它的值可以是代表秒的'sec'或是代表毫秒的'ms',默认为'sec'。
        """
        if unit == "sec":
            return self.client.set(self.key, VALUE_OF_LOCK, nx=True, ex=timeout) is True
        elif unit == "ms":
            return self.client.set(self.key, VALUE_OF_LOCK, nx=True, px=timeout) is True
        else:
            raise ValueError("Unit must be 'sec' or 'ms'!")

    def release(self):
        """
        尝试释放锁,成功时返回True,失败时则返回False。
        """
        return self.client.delete(self.key) == 1

这个锁实现的acquire()方法接受timeoutunit两个参数,分别用于设置锁的最大上锁时长及其单位,而程序之后会根据unit参数的值决定是使用SET命令的EX选项还是PX选项来设置键的生存时间。 除此之外,这个锁实现的解锁方法没有发生任何变化,跟之前一样,它只需要执行DEL命令将锁键删除即可。

这个锁实现的使用方法如下:

>>> from redis import Redis
>>> from auto_release_lock import AutoReleaseLock
>>> client = Redis(decode_responses=True)
>>> lock = AutoReleaseLock(client, "Lock:10086")
>>> lock.acquire(5)  # 最多上锁五秒钟
True
>>> # 等待五秒钟,让锁自动释放……
>>> lock.acquire(5)  # 再次上锁成功
True
>>> lock.release()  # 在5秒钟之内手动释放锁
True

注意

在使用带有自动释放功能的锁实现时,锁的最大上锁时长必须超过程序在正常情况下完成任务操作所需的最大时长。

举个例子,如果客户端在成功上锁之后需要消耗1秒钟来完成指定的任务操作,那么你应该将最大上锁时长设置为30秒甚至更长,以便让Redis在上锁客户端出现真正的意外时自动释放锁。

但如果你只是把最大上锁时长设置为1秒钟或者2秒钟,那么当程序运行出现延误的时候,可能就会出现“客户端持有的锁已经被自动释放,但它仍然在使用锁所保护的资源”这类情况,从而导致锁的安全性在实质上被破坏。

换句话说,锁的自动释放功能就跟程序的异常一样,应该被用作保护措施而不是一般特性。成功上锁的客户端在程序正常运行的情况下还是应该手动释放锁,而不是依靠自动释放。

重点回顾

  • 锁是计算机系统中经常会用到的一种重要的机制,它可以用来保证特定资源在任何时候最多只能有一个使用者。

  • 每个锁程序至少会包含上锁和解锁两种操作,在Redis中,实现上锁操作最基本的方法就是使用带有NX选项的SET命令,该命令的性质保证了即使有多个客户端同时执行相同的命令,最多也只会有一个客户端成功进行设置,而其他客户端的设置则会失败,这保证了锁实现的安全性。

  • 释放锁可以通过删除锁对应的锁键来实现,而通过使用Redis的键自动过期特性,锁程序还可以让锁在指定的时长之后自动被释放。

  • 锁的自动释放功能就跟程序的异常一样,应该被用作保护措施而不是一般特性。成功上锁的客户端在程序正常运行的情况下还是应该手动释放锁,而不是依靠自动释放。