大家好,我是腾意。

字符串(string)键是Redis最基本的键值对类型,这种类型的键值对会在数据库中把单独的一个键和单独的一个值关联起来,被关联的键和值既可以是普通的文字数据,也可以是图片、视频、音频、压缩文件等更为复杂的二进制数据。

图2-1展示了数据库视角下的4个字符串键,其中:

·与键"message"相关联的值是"hello world"。

·与键"number"相关联的值是"10086"。

·与键"homepage"相关联的值是"redis.io"。

·与键"redis-logo.jpg"相关联的值是二进制数据"\xff\xd8\xff\xe0\x00\x10JFIF\x00..."。

img

图2-1 数据库中的字符串键示例

Redis为字符串键提供了一系列操作命令,通过使用这些命令,用户可以:

·为字符串键设置值。

·获取字符串键的值。

·在获取旧值的同时为字符串键设置新值。

·同时为多个字符串键设置值,或者同时获取多个字符串键的值。

·获取字符串值的长度。

·获取字符串值指定索引范围内的内容,或者对字符串值指定索引范围内的内容进行修改。

·将一些内容追加到字符串值的末尾。

·对字符串键存储的整数值或者浮点数值执行加法操作或减法操作。

接下来将对以上提到的字符串键命令进行介绍,并演示如何使用这些命令去解决各种实际问题。

2.1 SET:为字符串键设置值

创建字符串键最常用的方法就是使用SET命令,这个命令可以为一个字符串键设置相应的值。在最基本的情况下,用户只需要向SET命令提供一个键和一个值就可以了:

SET key value

与之前提到过的一样,这里的键和值既可以是文字也可以是二进制数据。

SET命令在成功创建字符串键之后将返回OK作为结果。比如通过执行以下命令,我们可以创建出一个字符串键,它的键为"number",值为"10086":

redis> SET number "10086"
OK

再比如,通过执行以下命令,我们可以创建出一个键为"book",值为"The Design and Implementation of Redis"的字符串键:

redis> SET book "The Design and Implementation of Redis"
OK

img

图2-2 执行SET命令之前数据库的状态

图2-2和图2-3分别展示了数据库在以上两条SET命令执行之前以及执行之后的状态。

img

图2-3 执行SET命令之后数据库的状态

数据库键的存放方式

为了方便阅读,本书会将数据库中新出现的键放置到已有键的下方。比如在上面展示的数据库图2-3中,我们就将新添加的"number"键和"book"键放置到了已有键的下方。

在实际中,Redis数据库是以无序的方式存放数据库键的,一个新加入的键可能会出现在数据库的任何位置上,因此我们在使用Redis的过程中不应该对键在数据库中的摆放位置做任何假设,以免造成错误。

2.2.1 改变覆盖规则

在默认情况下,对一个已经设置了值的字符串键执行SET命令将导致键的旧值被新值覆盖。举个例子,如果我们连续执行以下两条SET命令,那么第一条SET命令设置的值将被第二条SET命令设置的值所覆盖:

redis> SET song_title "Get Wild"
OK

redis> SET song_title "Running to Horizon"
OK

在第二条SET命令执行完毕之后,song_title键的值将从原来的"Get Wild"变为"Running to Horizon"。

从Redis 2.6.12版本开始,用户可以通过向SET命令提供可选的NX选项或者XX选项来指示SET命令是否要覆盖一个已经存在的值:

SET key value [NX|XX]

如果用户在执行SET命令时给定了NX选项,那么SET命令只会在键没有值的情况下执行设置操作,并返回OK表示设置成功;如果键已经存在,那么SET命令将放弃执行设置操作,并返回空值nil表示设置失败。

以下代码展示了带有NX选项的SET命令的行为:

redis> SET password "123456" NX
OK       -- 对尚未有值的password键进行设置,成功

redis> SET password "999999" NX
(nil)    -- password键已经有了值,设置失败

因为第二条SET命令没有改变password键的值,所以password键的值仍然是刚开始时设置的"123456"。

如果用户在执行SET命令时给定了XX选项,那么SET命令只会在键已经有值的情况下执行设置操作,并返回OK表示设置成功;如果给定的键并没有值,那么SET命令将放弃执行设置操作,并返回空值表示设置失败。

举个例子,如果我们对一个没有值的键mongodb-homepage执行以下SET命令,那么命令将因为XX选项的作用而放弃执行设置操作:

redis> SET mongodb-homepage "mongodb.com" XX
(nil)

相反,如果我们对一个已经有值的键执行带有XX选项的SET命令,那么命令将使用新值去覆盖已有的旧值:

redis> SET mysql-homepage "mysql.org"
OK    -- 为键mysql-homepage设置一个值

redis> SET mysql-homepage "mysql.com" XX
OK    -- 对键的值进行更新

在第二条SET命令执行之后,mysql-homepage键的值将从原来的"mysql.org"更新为"mysql.com"。

2.2.2 其他信息

复杂度:O(1)。

版本要求:不带任何可选项的SET命令从Redis 2.0.0版本开始可用;带有NX、XX等可选项的SET命令从Redis 2.6.12版本开始可用。

2.2 GET:获取字符串键的值

用户可以使用GET命令从数据库中获取指定字符串键的值:

GET key

GET命令接受一个字符串键作为参数,然后返回与该键相关联的值。

img

图2-4 使用GET命令获取数据库键的值

比如对于图2-4所示的数据库来说,我们可以通过执行以下GET命令来取得各个字符串键相关联的值:

redis> GET message
"hello world"

redis> GET number
"10086"

redis> GET homepage
"redis.io"

另外,如果用户给定的字符串键在数据库中并没有与之相关联的值,那么GET命令将返回一个空值:

redis> GET date
(nil)

上面这个GET命令的执行结果表示数据库中并不存在date键,也没有与之相关联的值。

因为Redis的数据库要求所有键必须拥有与之相关联的值,所以如果一个键有值,那么我们就说这个键存在于数据库;相反,如果一个键没有值,那么我们就说这个键不存在于数据库。比如对于上面展示的几个键来说,date键就不存在于数据库,而message键、number键和homepage键则存在于数据库。

其他信息

复杂度:O(1)。

版本要求:GET命令从Redis 2.0.0开始可用。

2.3 GETSET:获取旧值并设置新值

GETSET命令就像GET命令和SET命令的组合版本,GETSET首先获取字符串键目前已有的值,接着为键设置新值,最后把之前获取到的旧值返回给用户:

GETSET key new_value

以下代码展示了如何使用GETSET命令去获取number键的旧值并为它设置新值:

redis> GET number    -- number键现在的值为"10086"
"10086"

redis> GETSET number "12345"
"10086"              -- 返回旧值

redis> GET number    -- number键的值已被更新为"12345"
"12345"

如果被设置的键并不存在于数据库,那么GETSET命令将返回空值作为键的旧值:

redis> GET counter
(nil)    -- 键不存在

redis> GETSET counter 50
(nil)    -- 返回空值作为旧值

redis> GET counter
"50"

其他信息

复杂度:O(1)。

版本要求:GETSET命令从Redis 2.0.0开始可用。

示例:缓存

对数据进行缓存是Redis最常见的用法之一,因为缓存操作是指把数据存储在内存而不是硬盘上,而访问内存远比访问硬盘的速度要快得多,所以用户可以通过把需要快速访问的数据存储在Redis中来提升应用程序的速度。

代码清单2-1展示了一个使用Redis实现的缓存程序代码,这个程序使用SET命令将需要缓存的数据存储到指定的字符串键中,并使用GET命令来从指定的字符串键中获取被缓存的数据。

代码清单2-1 使用字符串键实现的缓存程序:/string/cache.py

class Cache:

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

    def set(self, key, value):
        """
        把需要被缓存的数据存储到键key里面,如果键key已经有值,那么使用新值去覆盖旧值
        """
        self.client.set(key, value)

    def get(self, key):
        """
        获取存储在键key里面的缓存数据,如果数据不存在,那么返回None
        """
        return self.client.get(key)

    def update(self, key, new_value):
        """
        对键key存储的缓存数据进行更新,并返回键key在被更新之前存储的缓存数据。
        如果键key之前并没有存储数据,那么返回None
        """
        return self.client.getset(key, new_value)

除了用于设置缓存的set()方法以及用于获取缓存的get()方法之外,缓存程序还提供了由GETSET命令实现的update()方法,这个方法可以让用户在对缓存进行设置的同时,获得之前被缓存的旧值。用户可以根据自己的需要决定是使用set()方法还是update()方法对缓存进行设置。

以下代码展示了如何使用这个程序来缓存一个HTML页面,并在需要时获取它:

>>> from redis import Redis
>>> from cache import Cache
>>> client = Redis(decode_responses=True)  # 使用文本编码方式打开客户端
>>> cache = Cache(client)
>>> cache.set("greeting-page", "<html><p>hello world</p></html>")
>>> cache.get("greeting-page")
'<html><p>hello world</p></html>'
>>> cache.update("greeting-page", "<html><p>good morning</p></html>")
'<html><p>hello world</p></html>'
>>> cache.get("greeting-page")
'<html><p>good morning</p></html>'

因为Redis的字符串键不仅可以存储文本数据,还可以存储二进制数据,所以这个缓存程序不仅可以用来缓存网页等文本数据,还可以用来缓存图片和视频等二进制数据。比如,如果你正在运营一个图片网站,那么你同样可以使用这个缓存程序来缓存网站上的热门图片,从而提高用户访问这些热门图片的速度。

作为例子,以下代码展示了将Redis的Logo图片缓存到键redis-logo.jpg中的方法:

>>> from redis import Redis
>>> from cache import Cache
>>> client = Redis                        # 使用二进制编码方式打开客户端
>>> cache = Cache(client)
>>> image = open("redis-logo.jpg", "rb")  # 以二进制只读方式打开图片文件
>>> data = image.read()                   # 读取文件内容
>>> image.close()                         # 关闭文件
>>> cache.set("redis-logo.jpg", data)     # 将内存缓存到键redis-logo.jpg中
>>> cache.get("redis-logo.jpg")[:20]      # 读取二进制数据的前20个字节
b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x01\x00H\x00H\x00\x00'

提示 在测试以上两段代码的时候,请务必以正确的编码方式打开客户端(第一段代码采用文本方式,第二段代码采用二进制方式),否则测试代码将会出现编码错误。

示例:锁

锁是一种同步机制,用于保证一项资源在任何时候只能被一个进程使用,如果有其他进程想要使用相同的资源,那么就必须等待,直到正在使用资源的进程放弃使用权为止。

一个锁的实现通常会有获取(acquire)和释放(release)这两种操作:

·获取操作用于取得资源的独占使用权。在任何时候,最多只能有一个进程取得锁,我们把成功取得锁的这个进程称为锁的持有者。在锁已经被持有的情况下,所有尝试再次获取锁的操作都会失败。

·释放操作用于放弃资源的独占使用权,一般由锁的持有者调用。在锁被释放之后,其他进程就可以再次尝试获取这个锁了。

代码清单2-2展示了一个使用字符串键实现的锁程序,这个程序会根据给定的字符串键是否有值来判断锁是否已经被获取,而针对锁的获取操作和释放操作则是分别通过设置字符串键和删除字符串键来完成的。

代码清单2-2 使用字符串键实现的锁程序:/string/lock.py

VALUE_OF_LOCK = "locking"

class Lock:

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

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

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

获取操作acquire()方法是通过执行带有NX选项的SET命令来实现的:

result = self.client.set(self.key, VALUE_OF_LOCK, nx=True)

NX选项的值确保了代表锁的字符串键只会在没有值的情况下被设置:

·如果给定的字符串键没有值,那么说明锁尚未被获取,SET命令将执行设置操作,并将result变量的值设置为True。

·如果给定的字符串键已经有值了,那么说明锁已经被获取,SET命令将放弃执行设置操作,并将result变量的值设置为None。

acquire()方法最后会通过检查result变量的值是否为True来判断自己是否成功取得了锁。

释放操作release()方法使用了之前没有介绍过的DEL命令,这个命令接受一个或多个数据库键作为参数,尝试删除这些键以及与之相关联的值,并返回被成功删除的键的数量作为结果:

DEL key [key ...]

因为Redis的DEL命令和Python的del关键字重名,所以在redis-py客户端中,执行DEL命令实际上是通过调用delete()方法来完成的:

self.client.delete(self.key) == 1

release()方法通过检查delete()方法的返回值是否为1来判断删除操作是否执行成功:如果用户尝试对一个尚未被获取的锁执行release()方法,那么方法将返回false,表示没有锁被释放。

在使用DEL命令删除代表锁的字符串键之后,字符串键将重新回到没有值的状态,这时用户就可以再次调用acquire()方法去获取锁了。

以下代码演示了这个锁的使用方法:

>>> from redis import Redis
>>> from lock import Lock
>>> client = Redis(decode_responses=True)
>>> lock = Lock(client, 'test-lock')
>>> lock.acquire()  # 成功获取锁
True
>>> lock.acquire()  # 锁已被获取,无法再次获取
False
>>> lock.release()  # 释放锁
True
>>> lock.acquire()  # 锁释放之后可以再次被获取
True

虽然代码清单2-2中展示的锁实现了基本的获取和释放功能,但它并不完美:

·因为这个锁的释放操作无法验证进程的身份,所以无论执行释放操作的进程是否为锁的持有者,锁都会被释放。如果锁被持有者以外的其他进程释放,那么系统中可能会同时出现多个锁,导致锁的唯一性被破坏。

·这个锁的获取操作不能设置最大加锁时间,因而无法让锁在超过给定的时限之后自动释放。因此,如果持有锁的进程因为故障或者编程错误而没有在退出之前主动释放锁,那么锁就会一直处于已被获取的状态,导致其他进程永远无法取得锁。

本书后续将继续改进这个锁的实现,使得它可以解决这两个问题。

2.4 MSET:一次为多个字符串键设置值

除了SET命令和GETSET命令之外,Redis还提供了MSET命令用于对字符串键进行设置。与SET命令和GETSET命令只能设置单个字符串键不同,MSET命令可以一次为多个字符串键设置值:

MSET key value [key value ...]

以下代码展示了如何使用一条MSET命令去设置message、number和homepage这3个键:

redis> MSET message "hello world" number "10086" homepage "redis.io"
OK

redis> GET message
"hello world"

redis> GET number
"10086"

redis> GET homepage
"redis.io"

与SET命令一样,MSET命令也会在执行设置操作之后返回OK表示设置成功。此外,如果给定的字符串键已经有相关联的值,那么MSET命令也会直接使用新值去覆盖已有的旧值。

比如以下代码就展示了如何使用MSET命令去覆盖上一个MSET命令为message键和number键设置的值:

redis> MSET message "good morning!" number "12345"
OK

redis> GET message
"good morning!"

redis> GET number
"12345"

MSET命令除了可以让用户更为方便地执行多个设置操作之外,还能有效地提高程序的效率:执行多条SET命令需要客户端和服务器之间进行多次网络通信,并因此耗费大量的时间;而使用一条MSET命令去代替多条SET命令只需要一次网络通信,从而有效地减少程序执行多个设置操作时的时间。

其他信息

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

版本要求:MSET命令从Redis 2.0.1开始可用。

2.5 MGET:一次获取多个字符串键的值

MGET命令就是一个多键版本的GET命令,MGET接受一个或多个字符串键作为参数,并返回这些字符串键的值:

MGET key [key ...]

MGET命令返回一个列表作为结果,这个列表按照用户执行命令时给定键的顺序排列各个键的值。比如,列表的第一个元素就是第一个给定键的值,第二个元素是第二个给定键的值,以此类推。

作为例子,以下代码展示了如何使用一条MGET命令去获取message、number和homepage这3个键的值:

redis> MGET message number homepage
1) "hello world"    -- message键的值
2) "10086"          -- number键的值
3) "redis.io"       -- homepage键的值

与GET命令一样,MGET命令在碰到不存在的键时也会返回空值:

redis> MGET not-exists-key
1) (nil)

与MSET命令类似,MGET命令也可以将执行多个获取操作所需的网络通信次数从原来的N次降低至只需一次,从而有效地提高程序的运行效率。

其他信息

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

版本要求:MGET命令从Redis 2.0.0开始可用。

2.6 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 2.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)

这个文章存储程序比较长,让我们来逐个分析它的各项功能。首先,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展示了上面这段代码创建出的键以及这些键的值。

表2-1 文章数据存储示例

img

键的命名格式

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

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

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

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

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

2.7 STRLEN:获取字符串值的字节长度

通过对字符串键执行STRLEN命令,用户可以取得字符串键存储的值的字节长度:

STRLEN key

以下代码展示了如何使用STRLEN去获取不同字符串值的字节长度:

redis> GET number
"10086"

redis> STRLEN number    -- number键的值长5字节
(integer) 5

redis> GET message
"hello world"

redis> STRLEN message   -- message键的值长11字节
(integer) 11

redis> GET book
"The Design and Implementation of Redis"

redis> STRLEN book      -- book键的值长38字节
(integer) 38

对于不存在的键,STRLEN命令将返回0:

redis> STRLEN not-exists-key
(integer) 0

其他信息

复杂度:O(1)。

版本要求:STRLEN命令从Redis 2.2.0开始可用。

2.8 字符串值的索引

因为每个字符串都是由一系列连续的字节组成的,所以字符串中的每个字节实际上都拥有与之相对应的索引。Redis为字符串键提供了一系列索引操作命令,这些命令允许用户通过正数索引或者负数索引,对字符串值的某个字节或者某个部分进行处理,其中:

·字符串值的正数索引以0为开始,从字符串的开头向结尾不断递增。

·字符串值的负数索引以-1为开始,从字符串的结尾向开头不断递减。

图2-5展示了值为"hello world"的字符串,及其各个字节相对应的正数索引和负数索引。

img

图2-5 字符串的索引示例

接下来将对GETRANGE和SETRANGE这两个字符串键的索引操作命令进行介绍。

2.9 GETRANGE:获取字符串值指定索引范围上的内容

通过使用GETRANGE命令,用户可以获取字符串值从start索引开始,直到end索引为止的所有内容:

GETRANGE key start end

GETRANGE命令接受的是闭区间索引范围,也就是说,位于start索引和end索引上的值也会被包含在命令返回的内容当中。

举个例子,以下代码展示了如何使用GETRANGE命令去获取message键的值的不同部分:

redis> GETRANGE message 0 4     -- 获取字符串值索引0至索引4上的内容
"hello"

redis> GETRANGE message 6 10    -- 获取字符串值索引6至索引10上的内容
"world"

redis> GETRANGE message 3 7     -- 获取字符串值的中间部分
"lo wo"

redis> GETRANGE message -11 -7  -- 使用负数索引获取指定内容
"hello"

图2-6展示了上面4个命令是如何根据索引去获取值的内容的。

img

图2-6 GETRANGE命令执行示例

其他信息

复杂度:O(N),其中N为被返回内容的长度。

版本要求:GETRANGE命令从Redis 2.4.0开始可用。

2.10 SETRANGE:对字符串值的指定索引范围进行设置

通过使用SETRANGE命令,用户可以将字符串键的值从索引index开始的部分替换为指定的新内容,被替换内容的长度取决于新内容的长度:

SETRANGE key index substitute

SETRANGE命令在执行完设置操作之后,会返回字符串值当前的长度作为结果。

例如,我们可以通过执行以下命令,将message键的值从原来的"hello world"修改为"hello Redis":

redis> GET message
"hello world"

redis> SETRANGE message 6 "Redis"
(integer) 11    -- 字符串值当前的长度为11字节

redis> GET message
"hello Redis"

这个例子中的SETRANGE命令会将message键的值从索引6开始的内容替换为"Redis",图2-7展示了这个命令的执行过程。

img

图2-7 SETRANGE命令修改message键的过程

2.10.1 自动扩展被修改的字符串

当用户给定的新内容比被替换的内容更长时,SETRANGE命令就会自动扩展被修改的字符串值,从而确保新内容可以顺利写入。

例如,以下代码就展示了如何通过SETRANGE命令,将message键的值从原来的11字节长修改为41字节长:

redis> GET message
"hello Redis"

redis> SETRANGE message 5 ", this is a message send from peter."
(integer) 41

redis> GET message
"hello, this is a message send from peter."

图2-8展示了这个SETRANGE命令扩展字符串并进行写入的过程。

img

图2-8 SETRANGE命令的执行过程示例

2.10.2 在值里面填充空字节

SETRANGE命令除了会根据用户给定的新内容自动扩展字符串值之外,还会根据用户给定的index索引扩展字符串。

当用户给定的index索引超出字符串值的长度时,字符串值末尾直到索引index-1之间的部分将使用空字节进行填充,换句话说,这些字节的所有二进制位都会被设置为0。

举个例子,对于字符串键greeting来说:

redis> GET greeting
"hello"

当我们执行以下命令时,SETRANGE命令会先将字符串值扩展为15个字节长,然后将"hello"末尾直到索引9之间的所有字节都填充为空字节,最后再将索引10到索引14的内容设置为"world"。图2-9展示了这个扩展、填充、最后设置的过程。

redis> SETRANGE greeting 10 "world"
(integer) 15

img

图2-9 SETRANGE greeting 10"world"的执行过程

通过执行GET命令,我们可以取得greeting键在执行SETRANGE命令之后的值:

redis> GET greeting
"hello\x00\x00\x00\x00\x00world"

可以看到,greeting键的值现在包含了多个\x00符号,每个\x00符号代表一个空字节。

2.10.3 其他信息

复杂度:O(N),其中N为被修改内容的长度。

版本要求:SETRANGE命令从Redis 2.2.0开始可用。

示例:给文章存储程序加上文章长度计数功能和文章预览功能

在前面的内容中,我们使用MSET、MGET等命令构建了一个存储文章信息的程序,在学习了STRLEN命令和GETRANGE命令之后,我们可以给这个文章存储程序加上两个新功能,其中一个是文章长度计数功能,另一个则是文章预览功能。

·文章长度计数功能用于显示文章内容的长度,读者可以通过这个长度值来了解一篇文章大概有多长,从而决定是否继续阅读。

·文章预览功能则用于显示文章开头的一部分内容,这些内容可以帮助读者快速地了解文章大意,并吸引读者进一步阅读整篇文章。

代码清单2-4展示了这两个功能的具体实现代码,其中文章长度计数功能是通过对文章内容执行STRLEN命令来实现的,文章预览功能是通过对文章内容执行GETRANGE命令来实现的。

代码清单2-4 带有长度计数功能和预览功能的文章存储程序:/string/article.py

from time import time  # time()函数用于获取当前UNIX时间戳

class Article:

    # 省略之前展示过的__init__()、create()、update()等方法

    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)

get_content_len()方法的实现非常简单直接,没有什么需要说明的。与此相比,get_content_preview()方法显得更复杂一些,让我们进行一些分析。

首先,get_content_preview()方法会接受一个preview_len参数,用于记录调用者指定的预览长度。接着程序会根据这个预览长度计算出预览内容的起始索引和结束索引:

start_index = 0
end_index = preview_len-1

因为预览功能要做的就是返回文章内容的前preview_len个字节,所以上面这两条赋值语句要做的就是计算并记录文章前preview_len个字节所在的索引范围,其中start_index的值总是0,而end_index的值则为preview_len-1。举个例子,假如用户输入的预览长度为150,那么start_index将被赋值为0,而end_index将被赋值为149。

最后,程序会调用GETRANGE命令,根据上面计算出的两个索引,从存储着文章内容的字符串键里面取出指定的预览内容:

self.client.getrange(self.content_key, start_index, end_index)

以下代码展示了如何使用文章长度计数功能以及文章预览功能:

>>> from redis import Redis
>>> from article import Article
>>> client = Redis(decode_responses=True)
>>> article = Article(client, 12345)
>>> title = "Improving map data on GitHub"
>>> content = "You've been able to view and diff geospatial data on GitHub for a while, but now, in addition to being able to collaborate on the GeoJSON files you upload to GitHub, you can now more easily contribute to the underlying, shared basemap, that provides your data with context."
>>> author = "benbalter"
>>> article.create(title, content, author)  # 将一篇比较长的文章存储起来
True
>>> article.get_content_len()               # 文章总长273字节
273
>>> article.get_content_preview(100)        # 获取文章前100字节的内容
"You've been able to view and diff geospatial data on GitHub for a while, but now, in addition to bei"

2.11 APPEND:追加新内容到值的末尾

通过调用APPEND命令,用户可以将给定的内容追加到字符串键已有值的末尾:

APPEND key suffix

APPEND命令在执行追加操作之后,会返回字符串值当前的长度作为命令的返回值。

举个例子,对于以下这个名为description的键来说:

redis> GET description
"Redis"

我们可以通过执行以下命令,将字符串"is a database"追加到description键已有值的末尾:

redis> APPEND description " is a database"
(integer) 19    -- 追加操作执行完毕之后,值的长度

以下是description键在执行完追加操作之后的值:

redis> GET description
"Redis is a database"

在此之后,我们可以继续执行以下APPEND命令,将字符串"with many different data structure."追加到description键已有值的末尾:

redis> APPEND description " with many different data structure."
(integer) 55

现在,description键的值又变成了以下形式:

redis> GET description
"Redis is a database with many different data structure."

图2-10展示了description键的值是如何随着APPEND命令的执行而变化的。

img

图2-10 description键的值随着APPEND命令的执行而变化

2.11.1 处理不存在的键

如果用户给定的键并不存在,那么APPEND命令会先将键的值初始化为空字符串"",然后再执行追加操作,最终效果与使用SET命令为键设置值的情况类似:

redis> GET append_msg  -- 键不存在
(nil)

redis> APPEND append_msg "hello"  -- 效果相当于执行SET append_msg "hello"
(integer) 5

redis> GET append_msg
"hello"

当键有了值之后,APPEND又会像平时一样,将用户给定的值追加到已有值的末尾:

redis> APPEND append_msg ", how are you?"
(integer) 19

redis> GET append_msg
"hello, how are you?"

图2-11展示了APPEND命令是如何根据键是否存在来判断应该执行哪种操作的。

img

图2-11 APPEND的判断过程

2.11.2 其他信息

复杂度:O(N),其中N为新追加内容的长度。

版本要求:APPEND命令从Redis 2.0.0开始可用。

示例:存储日志

很多程序在运行的时候都会生成一些日志,这些日志记录了程序的运行状态以及执行过的重要操作。

例如,以下展示的就是Redis服务器运行时输出的一些日志,这些日志记录了Redis开始运行的时间,载入数据库所耗费的时长,接收客户端连接所使用的端口号,以及进行数据持久化操作的时间点等信息:

6066:M 06 Jul 17:40:49.611 # Server started, Redis version 3.2.999
6066:M 06 Jul 17:40:49.627 * DB loaded from disk: 0.016 seconds
6066:M 06 Jul 17:40:49.627 * The server is now ready to accept connections on port 6379
6066:M 06 Jul 18:29:20.009 * DB saved on disk

为了记录程序运行的状态,或者为了对日志进行分析,我们有时需要把程序生成的日志存储起来。

例如,我们可以使用SET命令将日志的生成时间作为键,日志的内容作为值,把上面展示的日志存储到多个字符串键里面:

redis> SET "06 Jul 17:40:49.611" "# Server started, Redis version 3.2.999"
OK

redis> SET "06 Jul 17:40:49.627" "* DB loaded from disk: 0.016 seconds"
OK

redis> SET "06 Jul 17:40:49.627" "* The server is now ready to accept connections on port 6379"
OK

redis> SET "06 Jul 18:29:20.009" "* DB saved on disk"
OK

遗憾的是,这种日志存储方式并不理想,主要问题有两个:

·使用这种方法需要在数据库中创建很多键。因为Redis每创建一个键就需要消耗一定的额外资源(overhead)来对键进行维护,所以键的数量越多,消耗的额外资源就会越多。

·这种方法将全部日志分散地存储在不同的键里面,当程序想要对特定的日志进行分析的时候,就需要花费额外的时间和资源去查找指定的日志,这给分析操作带来了麻烦和额外的资源消耗。

代码清单2-5展示了另一种更为方便和高效的日志存储方式,这个程序会把同一天之内产生的所有日志都存储在同一个字符串键里面,从而使用户可以非常高效地取得指定日期内产生的所有日志。

代码清单2-5 使用字符串键实现高效的日志存储程序:/string/log.py

LOG_SEPARATOR = "\n"

class Log:

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

    def add(self, new_log):
        """
        将给定的日志存储起来
        """
        new_log += LOG_SEPARATOR
        self.client.append(self.key, new_log)

    def get_all(self):
        """
        以列表形式返回所有日志
        """
        all_logs = self.client.get(self.key)
        if all_logs is not None:
            log_list = all_logs.split(LOG_SEPARATOR)
            log_list.remove("")
            return log_list
        else:
            return []

日志存储程序的add()方法负责将新日志存储起来。这个方法首先会将分隔符追加到新日志的末尾:

new_log += LOG_SEPARATOR

然后调用APPEND命令,将新日志追加到已有日志的末尾:

self.client.append(self.key, new_log)

举个例子,如果用户输入的日志是:

"this is log1"

那么add()方法首先会把分隔符"\n"追加到这行日志的末尾,使之变成:

"this is log1\n"

然后调用以下命令,将新日志追到已有日志的末尾:

APPEND key "this is log1\n"

负责获取所有日志的get_all()方法比较复杂,因为它不仅需要从字符串键里面取出包含了所有日志的字符串值,还需要从这个字符串值里面分割出每一条日志。首先,这个方法使用GET命令从字符串键里面取出包含了所有日志的字符串值:

all_logs = self.client.get(self.key)

接着,程序会检查all_logs这个值是否为空。如果为空则表示没有日志被存储,程序直接返回空列表“[]”作为get_all()方法的执行结果;如果值不为空,那么程序将调用Python的split()方法对字符串值进行分割,并将分割结果存储到log_list列表里面:

log_list = all_logs.split(LOG_SEPARATOR)

因为split()方法会在结果中包含一个空字符串,而我们并不需要这个空字符串,所以程序还会调用remove()方法,将空字符串从分割结果中移除,使得log_list列表中只保留被分割的日志:

log_list.remove("")

在此之后,程序只需要将包含了多条日志的log_list列表返回给调用者就可以了:

return log_list

举个例子,假设我们使用add()方法,在一个字符串键里面存储了"this is log1"、"this is log2"、"this is log3"这3条日志,那么get_all()方法在使用GET命令获取字符串键的值时,将得到以下结果:

"this is log1\nthis is log2\nthis is log3"

在使用split(LOG_SEPARATOR)方法对这个结果进行分割之后,程序将得到一个包含4个元素的列表,其中列表最后的元素为空字符串:

["this is log1", "this is log2", "this is log3", ""]

在调用remove("")方法移除列表中的空字符串之后,列表里面就只会包含被存储的日志:

["this is log1", "this is log2", "this is log3"]

这时get_all()方法只需要把这个列表返回给调用者就可以了。

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

>>> from redis import Redis
>>> from log import Log
>>> client = Redis(decode_responses=True)
>>> # 按日期归类日志
>>> log = Log(client, "06 Jul")
>>> # 存储日志
>>> log.add("17:40:49.611 # Server started, Redis version 3.2.999")
>>> log.add("17:40:49.627 * DB loaded from disk: 0.016 seconds")
>>> log.add("17:40:49.627 * The server is now ready to accept connections on port 6379")
>>> log.add("18:29:20.009 * DB saved on disk")
>>> # 以列表形式返回所有日志
>>> log.get_all()    
['17:40:49.611 # Server started, Redis version 3.2.999', '17:40:49.627 * DB loaded from disk: 0.016 seconds', '17:40:49.627 * The server is now ready to accept connections on port 6379', '18:29:20.009 * DB saved on disk']
>>> # 单独打印每条日志
>>> for i in log.get_all():
...   print(i)
... 
17:40:49.611 # Server started, Redis version 3.2.999
17:40:49.627 * DB loaded from disk: 0.016 seconds
17:40:49.627 * The server is now ready to accept connections on port 6379
18:29:20.009 * DB saved on disk

2.12 使用字符串键存储数字值

每当用户将一个值存储到字符串键里面的时候,Redis都会对这个值进行检测,如果这个值能够被解释为以下两种类型的其中一种,那么Redis就会把这个值当作数字来处理:

·第一种类型是能够使用C语言的long long int类型存储的整数,在大多数系统中,这种类型存储的都是64位长度的有符号整数,取值范围介于-9223372036854775808和9223372036854775807之间。

·第二种类型是能够使用C语言的long double类型存储的浮点数,在大多数系统中,这种类型存储的都是128位长度的有符号浮点数,取值范围介于3.36210314311209350626e-4932和2.18973149535723176502e+4932L之间。

表2-2中列举了一些不同类型的值,并说明了Redis对它们的解释方式。

表2-2 一些能够被Redis解释为数字的例子

img

为了能够更方便地处理那些使用字符串键存储的数字值,Redis提供了一系列加法操作命令以及减法操作命令,用户可以通过这些命令直接对字符串键存储的数字值执行加法操作或减法操作,接下来,将对这些命令进行介绍。

2.13 INCRBY、DECRBY:对整数值执行加法操作和减法操作

当字符串键存储的值能够被Redis解释为整数时,用户就可以通过INCRBY命令和DECRBY命令对被存储的整数值执行加法或减法操作。

INCRBY命令用于为整数值加上指定的整数增量,并返回键在执行加法操作之后的值:

INCRBY key increment

以下代码展示了如何使用INCRBY命令去增加一个字符串键的值:

redis> SET number 100
OK

redis> GET number
"100"

redis> INCRBY number 300     -- 将键的值加上300
(integer) 400

redis> INCRBY number 256     -- 将键的值加上256
(integer) 656

redis> INCRBY number 1000    -- 将键的值加上1000
(integer) 1656

redis> GET number
"1656"

与INCRBY命令的作用正好相反,DECRBY命令用于为整数值减去指定的整数减量,并返回键在执行减法操作之后的值:

DECRBY key increment

以下代码展示了如何使用DECRBY命令去减少一个字符串键的值:

redis> SET number 10086
OK

redis> GET number
"10086"

redis> DECRBY number 300     -- 将键的值减去300
(integer) 9786

redis> DECRBY number 786     -- 将键的值减去786
(integer) 9000

redis> DECRBY number 5500    -- 将键的值减去5500
(integer) 3500

redis> GET number
"3500"

2.13.1 类型限制

当字符串键的值不能被Redis解释为整数时,对键执行INCRBY命令或是DECRBY命令将返回一个错误:

redis> SET pi 3.14
OK

redis> INCRBY pi 100            -- 不能对浮点数值执行
(error) ERR value is not an integer or out of range

redis> SET message "hello world"
OK

redis> INCRBY message           -- 不能对字符串值执行
(error) ERR wrong number of arguments for 'incrby' command

redis> SET big-number 123456789123456789123456789
OK

redis> INCRBY big-number 100    -- 不能对超过64位长度的整数执行
(error) ERR value is not an integer or out of range

另外需要注意的一点是,INCRBY和DECRBY的增量和减量也必须能够被Redis解释为整数,使用其他类型的值作为增量或减量将返回一个错误:

redis> INCRBY number 3.14             -- 不能使用浮点数作为增量
(error) ERR value is not an integer or out of range

redis> INCRBY number "hello world"    -- 不能使用字符串值作为增量
(error) ERR value is not an integer or out of range

2.13.2 处理不存在的键

当INCRBY命令或DECRBY命令遇到不存在的键时,命令会先将键的值初始化为0,然后再执行相应的加法操作或减法操作。

以下代码展示了INCRBY命令是如何处理不存在的键x的:

redis> GET x           -- 键x不存在
(nil)

redis> INCRBY x 123    -- 先将键x的值初始化为0,然后再执行加上123的操作
(integer) 123

redis> GET x
"123"

以下代码展示了DECRBY命令是如何处理不存在的键y的:

redis> GET y           -- 键y不存在
(nil)

redis> DECRBY y 256    -- 先将键y的值初始化为0,再执行减去256的操作
(integer) -256

redis> GET y
"-256"

2.13.3 其他信息

复杂度:O(1)。

版本要求:INCRBY命令和DECRBY命令从Redis 2.0.0开始可用。

2.14 INCR、DECR:对整数值执行加1操作和减1操作

因为对整数值执行加1操作或减1操作的场景经常会出现,所以为了能够更方便地执行这两个操作,Redis分别提供了用于执行加1操作的INCR命令以及用于执行减1操作的DECR命令。

INCR命令的作用就是将字符串键存储的整数值加上1,效果相当于执行INCRBY key 1:

INCR key

DECR命令的作用就是将字符串键存储的整数值减去1,效果相当于执行DECRBY key 1:

DECR key

以下代码展示了INCR命令和DECR命令的作用:

redis> SET counter 100
OK

redis> INCR counter    -- 对整数值执行加1操作
(integer) 101

redis> INCR counter
(integer) 102

redis> INCR counter
(integer) 103

redis> DECR counter    -- 对整数值执行减1操作
(integer) 102

redis> DECR counter
(integer) 101

redis> DECR counter
(integer) 100

除了增量和减量被固定为1之外,INCR命令和DECR命令的其他方面与INCRBY命令以及DECRBY命令完全相同。

其他信息

复杂度:O(1)。

版本要求:INCR命令和DECR命令从Redis 2.0.0开始可用。

2.15 INCRBYFLOAT:对数字值执行浮点数加法操作

除了用于执行整数加法操作的INCR命令以及INCRBY命令之外,Redis还提供了用于执行浮点数加法操作的INCRBYFLOAT命令:

INCRBYFLOAT key increment

INCRBYFLOAT命令可以把一个浮点数增量加到字符串键存储的数字值上面,并返回键在执行加法操作之后的数字值作为命令的返回值。

以下代码展示了如何使用INCRBYFLOAT命令去增加一个浮点数的值:

redis> SET decimal 3.14            -- 一个存储着浮点数值的键
OK

redis> GET decimal
"3.14"

redis> INCRBYFLOAT decimal 2.55    -- 将键decimal的值加上2.55
"5.69"

redis> GET decimal
"5.69"

2.15.1 处理不存在的键

INCRBYFLOAT命令在遇到不存在的键时,会先将键的值初始化为0,然后再执行相应的加法操作。

在以下代码中,INCRBYFLOAT命令就是先把x-point键的值初始化为0,然后再执行加法操作的:

redis> GET x-point    -- 不存在的键
(nil)

redis> INCRBYFLOAT x-point 12.7829
"12.7829"

redis> GET x-point
"12.7829"

2.15.2 使用INCRBYFLOAT执行浮点数减法操作

Redis为INCR命令提供了相应的减法版本DECR命令,也为INCRBY命令提供了相应的减法版本DECRBY命令,但是并没有为INCRBYFLOAT命令提供相应的减法版本,因此用户只能通过给INCRBYFLOAT命令传入负数增量来执行浮点数减法操作。

以下代码展示了如何使用INCRBYFLOAT命令执行浮点数减法计算:

redis> SET pi 3.14
OK

redis> GET pi
"3.14"

redis> INCRBYFLOAT pi -2.1    -- 值减去2.1
"2.04"

redis> INCRBYFLOAT pi -0.7    -- 值减去0.7
"2.34"

redis> INCRBYFLOAT pi -2.3    -- 值减去2.3
"0.04"

2.15.3 INCRBYFLOAT与整数值

INCRBYFLOAT命令对于类型限制的要求比INCRBY命令和INCR命令要宽松得多:

·INCRBYFLOAT命令既可用于浮点数值,也可以用于整数值。

·INCRBYFLOAT命令的增量既可以是浮点数,也可以是整数。

·当INCRBYFLOAT命令的执行结果可以表示为整数时,命令的执行结果将以整数形式存储。

以下代码展示了如何使用INCRBYFLOAT去处理一个存储着整数值的键:

redis> SET pi 1               -- 创建一个整数值
OK

redis> GET pi
"1"

redis> INCRBYFLOAT pi 2.14
"3.14"

以下代码展示了如何使用整数值作为INCRBYFLOAT命令的增量:

redis> SET pi 3.14
OK

redis> GET pi
"3.14"

redis> INCRBYFLOAT pi 20      -- 增量为整数值
"23.14"

以下代码展示了INCRBYFLOAT命令是如何把计算结果存储为整数的:

redis> SET pi 3.14
OK

redis> GET pi
"3.14"

redis> INCRBYFLOAT pi 0.86    -- 计算结果被存储为整数
"4"

2.15.4 小数位长度限制

虽然Redis并不限制字符串键存储的浮点数的小数位长度,但是在使用INCRBYFLOAT命令处理浮点数的时候,命令最多只会保留计算结果小数点后的17位数字,超过这个范围的小数将被截断:

redis> GET i
"0.01234567890123456789"    -- 这个数字的小数部分有20位长

redis> INCRBYFLOAT i 0
"0.01234567890123457"       -- 执行加法操作之后,小数部分只保留了17位

2.15.5 其他信息

复杂度:O(1)。

版本要求:INCRBYFLOAT命令从Redis 2.6.0开始可用。

示例:ID生成器

在构建应用程序的时候,我们经常会用到各式各样的ID(identifier,标识符)。比如,存储用户信息的程序在每次出现一个新用户的时候就需要创建一个新的用户ID,而博客程序在作者每次发表一篇新文章的时候也需要创建一个新的文章ID。

ID通常会以数字形式出现,并且通过递增的方式来创建出新的ID。比如,如果当前最新的ID值为10086,那么下一个ID就应该是10087,再下一个ID则是10088,以此类推。

代码清单2-6展示了一个使用字符串键实现的ID生成器,这个生成器通过执行INCR命令来产生新的ID,并且可以通过执行SET命令来保留指定数字之前的ID,从而避免用户为了得到某个指定的ID而生成大量无效ID。

代码清单2-6 使用字符串键实现的ID生成器:/string/id_generator.py

class IdGenerator:

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

    def produce(self):
        """
        生成并返回下一个ID。
        """
        return self.client.incr(self.key)

    def reserve(self, n):
        """
        保留前n个ID,使得之后执行的produce()方法产生的ID都大于n。为了避免produce()
        方法产生重复ID,这个方法只能在produce()方法和reserve()方法都没有执行过的情况下使
        用。这个方法在ID被成功保留时返回True,在produce()方法或reserve()方法已经执行
        过而导致保留失败时返回False
        """
        result = self.client.set(self.key, n, nx=True)
        return result is True

在这个ID生成器程序中,produce()方法要做的就是调用INCR命令,对字符串键存储的整数值执行加1操作,并将执行加法操作之后得到的新值用作ID。

用于保留指定ID的reserve()方法是通过执行SET命令为键设置值来实现的:当用户把一个字符串键的值设置为N之后,对这个键执行INCR命令总是会返回比N更大的值,因此在效果上相当于把所有小于等于N的ID都保留下来了。

需要注意的是,这种保留ID的方法只能在字符串键还没有值的情况下使用,如果用户已经使用过produce()方法来生成ID,或者已经执行过reserve()方法来保留ID,那么再使用SET命令去设置ID值可能会导致produce()方法产生出一些已经用过的ID,并因此引发ID冲突。

为此,reserve()方法在设置字符串键时使用了带有NX选项的SET命令,从而确保了对键的设置操作只会在键不存在的情况下执行:

self.client.set(self.key, n, nx=True)

以下代码展示了这个ID生成器的使用方法:

>>> from redis import Redis
>>> from id_generator import IdGenerator
>>> client = Redis(decode_responses=True)
>>> id_generator = IdGenerator(client, "user::id")
>>> id_generator.reserve(1000000)  # 保留前100万个ID
True
>>> id_generator.produce()         # 生成ID,这些ID的值都大于100万
1000001
>>> id_generator.produce()
1000002
>>> id_generator.produce()
1000003
>>> id_generator.reserve(1000)     # 键已经有值,无法再次执行reserve()方法
False

示例:计数器

除了ID生成器之外,计数器也是构建应用程序时必不可少的组件之一,如对于网站的访客数量、用户执行某个操作的次数、某首歌或者某个视频的播放量、论坛帖子的回复数量等,记录这些信息都需要用到计数器。实际上,计数器在互联网中几乎无处不在,因此如何简单、高效地实现计数器一直都是构建应用程序时经常会遇到的一个问题。

代码清单2-7展示了一个计数器实现,这个程序把计数器的值存储在一个字符串键里面,并通过INCRBY命令和DECRBY命令对计数器的值执行加法操作和减法操作,在需要时,用户还可以通过调用GETSET方法来清零计数器并取得清零之前的旧值。

代码清单2-7 使用字符串键实现的计数器:/string/counter.py

class Counter:

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

    def increase(self, n=1):
        """
        将计数器的值加上n,然后返回计数器当前的值。
        如果用户没有显式地指定n,那么将计数器的值加上1
        """
        return self.client.incr(self.key, n)

    def decrease(self, n=1):
        """
        将计数器的值减去n,然后返回计数器当前的值。
        如果用户没有显式地指定n,那么将计数器的值减去1
        """
        return self.client.decr(self.key, n)

    def get(self):
        """
        返回计数器当前的值
        """
        # 尝试获取计数器当前的值
        value = self.client.get(self.key)
        # 如果计数器并不存在,那么返回0作为计数器的默认值
        if value is None:
            return 0
        else:
            # 因为redis-py的get()方法返回的是字符串值,所以这里需要使用int()函数将字
            # 符串格式的数字转换为真正的数字类型,比如将"10"转换为10
            return int(value)

    def reset(self):
        """
        清零计数器,并返回计数器在被清零之前的值
        """
        old_value = self.client.getset(self.key, 0)
        # 如果计数器之前并不存在,那么返回0作为它的旧值
        if old_value is None:
            return 0
        else:
            # 与redis-py的get()方法一样,getset()方法返回的也是字符串值,所以程序在
            # 将计数器的旧值返回给调用者之前,需要先将它转换成真正的数字
            return int(old_value)

在这个程序中,increase()方法和decrease()方法在定义时都使用了Python的参数默认值特性:

def increase(self, n=1):
def decrease(self, n=1):

以上定义表明,如果用户直接以无参数的方式调用increase()或者decrease(),那么参数n的值将会被设置为1。

在设置了参数n之后,increase()方法和decrease()方法会分别调用INCRBY命令和DECRBY命令,根据参数n的值,对给定的键执行加法或减法操作:

# increase()方法
return self.client.incr(self.key, n)
# decrease()方法
return self.client.decr(self.key, n)

注意,increase()方法在内部调用的是incr()方法而不是incrby()方法,并且decrease()方法在内部调用的也是decr()方法而不是decrby()方法,这是因为在redis-py客户端中,INCR命令和INCRBY命令都是由incr()方法负责执行的:

·如果用户在调用incr()方法时没有给定增量,那么incr()方法就默认用户指定的增量为1,并执行INCR命令。

·如果用户在调用incr()方法时给定了增量,那么incr()方法就会执行INCRBY命令,并根据给定的增量执行加法操作。

decr()方法的情况也与此类似,只是被调用的命令变成了DECR命令和DECRBY命令。

以下代码展示了这个计数器的使用方法:

>>> from redis import Redis
>>> from counter import Counter
>>> client = Redis(decode_responses=True)
>>> counter = Counter(client, "counter::page_view")
>>> counter.increase()    # 将计数器的值加上1
1
>>> counter.increase()    # 将计数器的值加上1
2
>>> counter.increase(10)  # 将计数器的值加上10
12
>>> counter.decrease()    # 将计数器的值减去1
11
>>> counter.decrease(5)   # 将计数器的值减去5
6
>>> counter.reset()       # 重置计数器,并返回旧值
6
>>> counter.get()         # 返回计数器当前的值
0

示例:限速器

为了保障系统的安全性和性能,并保证系统的重要资源不被滥用,应用程序常常会对用户的某些行为进行限制,比如:

·为了防止网站内容被网络爬虫抓取,网站管理者通常会限制每个IP地址在固定时间段内能够访问的页面数量,比如1min之内最多只能访问30个页面,超过这一限制的用户将被要求进行身份验证,确认本人并非网络爬虫,或者等到限制解除之后再进行访问。

·为了防止用户的账号遭到暴力破解,网上银行通常会对访客的密码试错次数进行限制,如果一个访客在尝试登录某个账号的过程中,连续好几次输入了错误的密码,那么这个账号将被冻结,只能等到第二天再尝试登录,有的银行还会向账号持有者的手机发送通知来汇报这一情况。

实现这些限制机制的其中一种方法是使用限速器,它可以限制用户在指定时间段之内能够执行某项操作的次数。

代码清单2-8展示了一个使用字符串键实现的限速器,这个限速器程序会把操作的最大可执行次数存储在一个字符串键里面,然后在用户每次尝试执行被限制的操作之前,使用DECR命令将操作的可执行次数减1,最后通过检查可执行次数的值来判断是否执行该操作。

代码清单2-8 倒计时式的限速器:/string/limiter.py

class Limiter:

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

    def set_max_execute_times(self, max_execute_times):
        """
        设置操作的最大可执行次数
        """
        self.client.set(self.key, max_execute_times)

    def still_valid_to_execute(self):
        """
        检查是否可以继续执行被限制的操作,是则返回True,否则返回False
        """
        num = self.client.decr(self.key)
        return (num >= 0)

    def remaining_execute_times(self):
        """
        返回操作的剩余可执行次数
        """
        num = int(self.client.get(self.key))
        if num < 0:
            return 0
        else:
            return num

这个限速器的关键在于set_max_execute_times()方法和still_valid_to_execute()方法:前者用于将最大可执行次数存储在一个字符串键里面,后者则会在每次被调用时对可执行次数执行减1操作,并检查目前剩余的可执行次数是否已经变为负数,如果为负数,则表示可执行次数已经耗尽,不为负数则表示操作可以继续执行。

以下代码展示了这个限制器的使用方法:

>>> from redis import Redis
>>> from limiter import Limiter
>>> client = Redis(decode_responses=True)
>>> limiter = Limiter(client, 'wrong_password_limiter') # 密码错误限制器
>>> limiter.set_max_execute_times(3)                    # 最多只能输入3次错误密码
>>> limiter.still_valid_to_execute()  # 前3次操作能够顺利执行
True
>>> limiter.still_valid_to_execute()
True
>>> limiter.still_valid_to_execute()
True
>>> limiter.still_valid_to_execute()  # 从第4次开始,操作将被拒绝执行
False
>>> limiter.still_valid_to_execute()
False

以下伪代码则展示了如何使用这个限速器去限制密码的错误次数:

# 试错次数未超过限制
while limiter.still_valid_to_execute():
    # 获取访客输入的账号和密码
    account, password = get_user_input_account_and_password()
    # 验证账号和密码是否匹配
    if password_match(account, password):
        ui_print("密码验证成功")
    else:
        ui_print("密码验证失败,请重新输入")
# 试错次数已超过限制
else:
    # 锁定账号
    lock_account(account)
    ui_print("连续尝试登录失败,账号已被锁定,请明天再来尝试登录。")

2.16 重点回顾

·Redis的字符串键可以把单独的一个键和单独的一个值在数据库中关联起来,并且这个键和值既可以存储文字数据,又可以存储二进制数据。

SET命令在默认情况下会直接覆盖字符串键已有的值,如果我们只想在键不存在的情况下为它设置值,那么可以使用带有NX选项的SET命令;相反,如果我们只想在键已经存在的情况下为它设置新值,那么可以使用带有XX选项的SET命令。

使用MSET、MSETNX以及MGET命令可以有效地减少程序的网络通信次数,从而提升程序的执行效率。

Redis用户可以通过制定命名格式来提升Redis数据的可读性并避免键名冲突。

字符串值的正数索引以0为开始,从字符串的开头向结尾不断递增;字符串值的负数索引以-1为开始,从字符串的结尾向开头不断递减。

GETRANGE key start end命令接受的是闭区间索引范围,位于start索引和end索引上的值也会被包含在命令返回的内容当中。

SETRANGE命令在需要时会自动对字符串值进行扩展,并使用空字节填充新扩展空间中没有内容的部分。

APPEND命令在键不存在时执行设置操作,在键存在时执行追加操作。

Redis会把能够被表示为long long int类型的整数以及能够被表示为long double类型的浮点数当作数字来处理。

版权声明:如无特殊说明,文章均为本站原创,版权所有,转载需注明本文链接

本文链接:http://www.bianchengvip.com/article/redis-string/