MSETNX:只在键不存在的情况下,一次为多个字符串键设置值

MSETNX 命令与 MSET 命令一样,都可以对多个字符串键进行设置:

MSETNX key value [key value ...]

MSETNX 与 MSET 的主要区别在于,MSETNX 只会在所有给定键都不存在的情况下对键进行设置,而不会像 MSET 那样直接覆盖键已有的值:如果在给定键当中,即使有一个键已经有值了,那么 MSETNX 命令也会放弃对所有给定键的设置操作。MSETNX 命令在成功执行设置操作时返回 1,在放弃执行设置操作时则返回 0。

在以下代码中,因为键 k4 已经存在,所以 MSETNX 将放弃对键 k1、k2、k3 和 k4 进行设置操作:

redis> MGET k1 k2 k3 k4
1) (nil) -- 键k1、 k2和k3都不存在
2) (nil)
3) (nil)
4) "hello world" -- 键k4已存在

redis> MSETNX k1 "one" k2 "two" k3 "three" k4 "four"
(integer) 0 -- 因为键k4已存在,所以MSETNX未能执行设置操作

redis> MGET k1 k2 k3 k4 -- 各个键的值没有变化
1) (nil)
2) (nil)
3) (nil)
4) "hello world"

如果只对不存在的键 k1、k2 和 k3 进行设置,那么 MSETNX 可以正常地完成设置操作:

redis> MSETNX k1 "one" k2 "two" k3 "three"
(integer) 1 -- 所有给定键都不存在,成功执行设置操作

redis> MGET k1 k2 k3 k4
1) "one" -- 刚刚使用MSETNX设置的3个值
2) "two"
3) "three"
4) "hello world" -- 之前已经存在的键k4的值没有改变

其他信息

  • 复杂度:O(N),其中 N 为用户给定的字符串键数量。

  • 版本要求:MSETNX 命令从 Redis 1.0.1 开始可用。

示例:存储文章信息

在构建应用程序的时候,我们经常会需要批量地设置和获取多项信息。以博客程序为例:

  • 当用户想要注册博客时,程序就需要把用户的名字、账号、密码、注册时间等多项信息存储起来,并在用户登录的时候取出这些信息。

  • 当用户想在博客中撰写一篇新文章的时候,程序就需要把文章的标题、内容、作者、发表时间等多项信息存储起来,并在用户阅读文章的时候取出这些信息。

通过使用 MSET 命令、MSETNX 命令以及 MGET 命令,我们可以实现上面提到的这些批量设置操作和批量获取操作。比如代码清单 2-3 就展示了一个文章存储程序,这个程序使用 MSET 命令和 MSETNX 命令将文章的标题、内容、作者、发表时间等多项信息存储到不同的字符串键中,并通过 MGET 命令从这些键里面获取文章的各项信息。

代码清单2-3 文章存储程序:/string/article.py
from time import time  # time() 函数用于获取当前 Unix 时间戳

class Article:

    def __init__(self, client, article_id):
        self.client = client
        self.id = str(article_id)
        self.title_key = "article::" + self.id + "::title"
        self.content_key = "article::" + self.id + "::content"
        self.author_key = "article::" + self.id + "::author"
        self.create_at_key = "article::" + self.id + "::create_at"

    def create(self, title, content, author):
        """
        创建一篇新的文章,创建成功时返回 True ,
        因为文章已存在而导致创建失败时返回 False 。
        """
        article_data = {
            self.title_key: title,
            self.content_key: content,
            self.author_key: author,
            self.create_at_key: time()
        }
        return self.client.msetnx(article_data)

    def get(self):
        """
        返回 ID 对应的文章信息。
        """
        result = self.client.mget(self.title_key,
                                  self.content_key,
                                  self.author_key,
                                  self.create_at_key)
        return {"id": self.id, "title": result[0], "content": result[1],
                "author": result[2], "create_at": result[3]}

    def update(self, title=None, content=None, author=None):
        """
        对文章的各项信息进行更新,
        更新成功时返回 True ,失败时返回 False 。
        """
        article_data = {}
        if title is not None:
            article_data[self.title_key] = title
        if content is not None:
            article_data[self.content_key] = content
        if author is not None:
            article_data[self.author_key] = author
        return self.client.mset(article_data)

    def get_content_len(self):
        """
        返回文章内容的字节长度。
        """
        return self.client.strlen(self.content_key)

    def get_content_preview(self, preview_len):
        """
        返回指定长度的文章预览内容。
        """
        start_index = 0
        end_index = preview_len-1
        return self.client.getrange(self.content_key, start_index, end_index)

这个文章存储程序比较长,让我们来逐个分析它的各项功能。首先,Article 类的初始化方法 __init__() 接受一个 Redis 客户端和一个文章 ID 作为参数,并将文章 ID 从数字转换为字符串:

self.id = str(article_id)

接着程序会使用这个字符串格式的文章 ID,构建出用于存储文章各项信息的字符串键的键名:

self.title_key = "article::" + self.id + "::title"
self.content_key = "article::" + self.id + "::content"
self.author_key = "article::" + self.id + "::author"
self.create_at_key = "article::" + self.id + "::create_at"

在这些键当中,第一个键用于存储文章的标题,第二个键用于存储文章的内容,第三个键用于存储文章的作者,第四个键则用于存储文章的创建时间。

当用户想要根据给定的文章 ID 创建具体的文章时,就需要调用 create() 方法,并传入文章的标题、内容以及作者信息作为参数。create() 方法会把以上信息以及当前的 UNIX 时间戳放入一个 Python 字典里面:

article_data = {
    self.title_key: title,
    self.content_key: content,
    self.author_key: author,
    self.create_at_key: time()
}

article_data 字典的键存储了代表文章各项信息的字符串键的键名,而与这些键相关联的则是这些字符串键将要被设置的值。接下来,程序会调用 MSETNX 命令,对字典中给定的字符串键进行设置:

self.client.msetnx(article_data)

因为 create() 方法的设置操作是通过 MSETNX 命令来进行的,所以这一操作只会在所有给定字符串键都不存在的情况下进行:

  • 如果给定的字符串键已经有值了,那么说明与给定 ID 相对应的文章已经存在。在这种情况下,MSETNX 命令将放弃执行设置操作,并且 create() 方法也会向调用者返回 False 表示文章创建失败。

  • 如果给定的字符串键尚未有值,那么 create() 方法将根据用户给定的信息创建文章,并在成功之后返回 True。

在成功创建文章之后,用户就可以使用 get() 方法获取文章的各项信息。get() 方法会调用 MGET 命令,从各个字符串键中取出文章的标题、内容、作者等信息,并把这些信息存储到 result 列表中:

result = self.client.mget(self.title_key,
        self.content_key,
        self.author_key,
        self.create_at_key)

为了让用户可以更方便地访问文章的各项信息,get() 方法会将存储在 result 列表中的文章信息放入一个字典里面,然后再返回给用户:

return {"id": self.id, "title": result[0], "content": result[1], "author": result[2], "create_at": result[3]}

这样做的好处有两点:

  • 隐藏了 get() 方法由 MGET 命令实现这一底层细节。如果程序直接向用户返回 result 列表,那么用户就必须知道列表中的各个元素代表文章的哪一项信息,然后通过列表索引来访问文章的各项信息。这种做法非常不方便,而且也非常容易出错。

  • 返回一个字典可以让用户以 dict[key] 这样的方式去访问文章的各个属性,比如使用 article["title"] 去访问文章的标题,使用 article["content"] 去访问文章的内容,诸如此类,这使得针对文章数据的各项操作可以更方便地进行。

另外要注意的一点是,虽然用户可以通过访问 Article 类的 id 属性来获得文章的 ID,但是为了方便起见,get() 方法在返回文章信息的时候也会将文章的 ID 包含在字典里面一并返回。

对文章信息进行更新的 update() 方法是整个程序最复杂的部分。首先,为了让用户可以自由选择需要更新的信息项,这个函数在定义时使用了 Python 的具名参数特性:

def update(self, title=None, content=None, author=None):

通过具名参数,用户可以根据自己想要更新的文章信息项来决定传入哪个参数,不需要更新的信息项则会被赋予默认值 None,例如:

  • 如果用户只想更新文章的标题,那么只需要调用 update(title=new_title)即可。

  • 如果用户想同时更新文章的内容和作者,那么只需要调用 update(content=new_content,author=new_author)即可。

在定义了具名参数之后,update()方法会检查各个参数的值,并将那些不为 None 的参数以及与之相对应的字符串键键名放入 article_data 字典里面:

article_data = {}
if title is not None:
    article_data[self.title_key] = title
if content is not None:
    article_data[self.content_key] = content
if author is not None:
    article_data[self.author_key] = author

article_data 字典中的键就是需要更新的字符串键的键名,而与之相关联的则是这些字符串键的新值。

一切准备就绪之后,update() 方法会根据 article_data 字典中设置好的键值对调用 MSET 命令对文章进行更新:

self.client.mset(article_data)

以下代码展示了这个文章存储程序的使用方法:

>>> from redis import Redis
>>> from article import Article
>>> client = Redis(decode_responses=True)
>>> article = Article(client, 10086) # 指定文章ID
>>> article.create('message', 'hello world', 'peter') # 创建文章
True
>>> article.get() # 获取文章
{'id': '10086', 'title': 'message', 'content': 'hello world',
'author': 'peter', 'create_at': '1551199163.4296808'}
>>> article.update(author="john") # 更新文章的作者
True
>>> article.get() # 再次获取文章
{'id': '10086', 'title': 'message', 'content': 'hello world',
'author': 'john', 'create_at': '1551199163.4296808'}

表2-1展示了上面这段代码创建出的键以及这些键的值。

image 2025 01 02 15 17 25 499
Figure 1. 表2-1 文章数据存储示例

键的命名格式

Article 程序使用了多个字符串键去存储文章信息,并且每个字符串键的名字都是以 article::<id>::<attribute> 格式命名的,这是一种 Redis 使用惯例:

Redis 用户通常会为逻辑上相关联的键设置相同的前缀,并通过分隔符来区分键名的各个部分,以此来构建一种键的命名格式。

比如对于 article::10086::title、article::10086::author 这些键来说,article 前缀表明这些键都存储着与文章信息相关的数据,而分隔符 “::” 则区分开了键名里面的前缀、ID以及具体的属性。除了 “::” 符号之外,常用的键名分隔符还包括 “.” 符号,比如 article.10086.title;或者 “->” 符号,比如 article->10086->title;以及 “|” 符号,比如 article|10086|title 等。

分隔符的选择通常只取决于个人喜好,而键名的具体格式也可以根据需要进行构造,比如,如果不喜欢 article::<id>::<attribute> 格式,那么也可以考虑使用 article::<attribute>::<id> 格式,诸如此类。唯一需要注意的是,一个程序应该只使用一种键名分隔符,并且持续地使用同一种键名格式,以免造成混乱。

通过使用相同的格式去命名逻辑上相关联的键,我们可以让程序产生的数据结构变得更容易被理解,并且在需要的时候,还可以根据特定的键名格式在数据库里面以模式匹配的方式查找指定的键。