大家好,我是腾意。

redis 数据类型之字符串(string) 详细介绍文章中,我们介绍过如何使用多个字符串键去存储相关联的一组数据。比如在字符串键实现的文章存储程序中,程序会为每篇文章创建4个字符串键,并把文章的标题、内容、作者和创建时间分别存储到这4个字符串键里面,图3-1就展示了一个使用字符串键存储文章数据的例子。

img

图3-1 使用多个字符串键存储文章

使用多个字符串键存储相关联数据虽然在技术上是可行的,但是在实际应用中并不是最有效的方法,这种存储方法至少存在以下3个问题:

首先,程序每存储一组相关联的数据,就必须在数据库中同时创建多个字符串键,这样的数据越多,数据库包含的键数量也会越多。数量庞大的键会对数据库某些操作的执行速度产生影响,维护这些键也会产生大量的资源消耗。

其次,为了在数据库中标识出相关联的字符串键,程序需要为它们加上相同的前缀。但键名实际上也是一种数据,存储键名也需要耗费内存空间,因此重复出现的键名前缀实际上导致很多内存空间被白白浪费了。此外,带前缀的键名降低了键名的可读性,让人无法一眼看清键的真正用途,比如键名article::10086::author就远不如键名author简洁,键名article::10086::title也不如键名title简洁。

最后,虽然程序在逻辑上会把带有相同前缀的字符串键看作相关联的一组数据,但是在Redis看来,它们只不过是存储在同一个数据库中的不同字符串键而已,因此当程序需要处理一组相关联的数据时,就必须对所有有关的字符串键都执行相同的操作。比如,如果程序想要删除ID为10086的文章,那么它就必须把article::10086::title、article::10086::content等4个字符串键都删掉才行,这给文章的删除操作带来了额外的麻烦,并且还可能会因为漏删或者错删了某个键而出现错误。

为了解决以上问题,我们需要一种能够真正地把相关联的数据打包起来存储的数据结构,而这种数据结构就是本章要介绍的散列(hash)键。

1.1 散列简介

Redis的散列键会将一个键和一个散列在数据库里关联起来,用户可以在散列中为任意多个字段(field)设置值。与字符串键一样,散列的字段和值既可以是文本数据,也可以是二进制数据。

通过使用散列键,用户可以把相关联的多项数据存储到同一个散列里面,以便对这些数据进行管理,或者针对它们执行批量操作。比如图3-2就展示了一个使用散列存储文章数据的例子,在这个例子中,散列的键为article::10086,而这个键对应的散列则包含了4个字段,其中:

"title"字段存储文章的标题"greeting"。

"content"字段存储文章的内容"hello world"。

"author"字段存储文章的作者名字"peter"。

"create_at"字段存储文章的创建时间"1442744762.631885"。

img

图3-2 使用散列存储文章数据

与之前使用字符串键存储文章数据的做法相比,使用散列存储文章数据只需要在数据库里面创建一个键,并且因为散列的字段名不需要添加任何前缀,所以它们可以直接反映字段值存储的是什么数据。

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

  • 为散列的字段设置值,或者只在字段不存在的情况下为它设置值。
  • 从散列里面获取给定字段的值。
  • 对存储着数字值的字段执行加法操作或者减法操作。
  • 检查给定字段是否存在于散列当中。
  • 从散列中删除指定字段。
  • 查看散列包含的字段数量。
  • 一次为散列的多个字段设置值,或者一次从散列中获取多个字段的值。
  • 获取散列包含的所有字段、所有值或者所有字段和值。
  • 本章接下来将对以上提到的散列操作进行介绍,说明如何使用这些操作去构建各种有用的应用程序,并在最后详细地说明散列键与字符串键之间的区别。

1.2 HSET:为字段设置值

用户可以通过执行HSET命令为散列中的指定字段设置值:

HSET hash field value

根据给定的字段是否已经存在于散列中,HSET命令的行为也会有所不同:

如果给定字段并不存在于散列当中,那么这次设置就是一次创建操作,命令将在散列里面关联起给定的字段和值,然后返回1。

如果给定的字段原本已经存在于散列里面,那么这次设置就是一次更新操作,命令将使用用户给定的新值去覆盖字段原有的旧值,然后返回0。

举个例子,通过执行以下HSET命令,我们可以创建出一个包含了4个字段的散列,这4个字段分别存储了文章的标题、内容、作者以及创建日期:

redis> HSET article::10086 title "greeting"
(integer) 1

redis> HSET article::10086 content "hello world"
(integer) 1

redis> HSET article::10086 author "peter"
(integer) 1

redis> HSET article::10086 created_at "1442744762.631885"
(integer) 1

图3-3展示了以上HSET命令对散列article::10086进行设置的整个过程。

img

图3-3 HSET命令对article::10086进行设置的整个过程

img

图3-3 (续)

提示 散列包含的字段就像数据库包含的键一样,在实际中都是以无序方式进行排列的,不过本书为了展示方便,一般都会把新字段添加到散列的末尾,排在所有已有字段的后面。

1.2.1 使用新值覆盖旧值

正如之前所说,如果用户在调用HSET命令时给定的字段已经存在于散列当中,那么HSET命令将使用用户给定的新值去覆盖字段已有的旧值,并返回0表示这是一次更新操作。

比如,以下代码就展示了如何使用HSET命令去更新article::10086散列的title字段以及content字段:

redis> HSET article::10086 title "Redis Tutorial" 
(integer) 0

redis> HSET article::10086 content "Redis is a data structure store, ..."
(integer) 0

图3-4展示了被更新之后的article::10086散列。

img

图3-4 被更新之后的article::10086散列

1.2.2 其他信息

复杂度:O(1)。

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

1.3 HSETNX:只在字段不存在的情况下为它设置值

HSETNX命令的作用和HSET命令的作用非常相似,它们之间的区别在于,HSETNX命令只会在指定字段不存在的情况下执行设置操作:

HSETNX hash field value

HSETNX命令在字段不存在并且成功为它设置值时返回1,在字段已经存在并导致设置操作未能成功执行时返回0。

举个例子,对于图3-5所示的article::10086散列来说,执行以下HSETNX命令将不会对散列产生任何影响,因为HSETNX命令想要设置的title字段已经存在:

redis> HSETNX article::10086 title "Redis Performance Test"
(integer) 0    -- 设置失败

img

图3-5 HSETNX命令执行之前的article::10086散列

相反,如果我们使用HSETNX命令去对尚未存在的view_count字段进行设置,那么这个命令将会顺利执行,并将view_count字段的值设置为100:

redis> HSETNX article::10086 view_count 100
(integer) 1    -- 设置成功

图3-6展示了HSETNX命令成功执行之后的article::10086散列。

img

图3-6 HSETNX命令执行之后的article::10086散列

其他信息

复杂度:O(1)。

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

1.4 HGET:获取字段的值

HGET命令可以根据用户给定的字段,从散列中获取该字段的值:

HGET hash field

例如,对于图3-7所示的两个散列键来说,执行以下命令可以从article::10086散列中获取author字段的值:

redis> HGET article::10086 author
"peter"

而执行以下命令则可以从article::10086散列中获取created_at字段的值:

redis> HGET article::10086 created_at
"1442744762.631885"

img

图3-7 两个散列

再例如,如果我们想要从account::54321散列中获取email字段的值,那么可以执行以下命令:

redis> HGET account::54321 email
"peter1984@spam_mail.com"

1.4.1 处理不存在的字段或者不存在的散列

如果用户给定的字段并不存在于散列当中,那么HGET命令将返回一个空值。

举个例子,在以下代码中,我们尝试从account::54321散列里面获取location字段的值,但由于location字段并不存在于account::54321散列当中,所以HGET命令将返回一个空值:

redis> HGET account::54321 location
(nil)

尝试从一个不存在的散列里面获取一个不存在的字段值,得到的结果也是一样的:

redis> HGET not-exists-hash not-exists-field
(nil)

1.4.2 其他信息

复杂度:O(1)。

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

示例:实现短网址生成程序

为了给用户提供更多发言空间,并记录用户在网站上的链接点击行为,大部分社交网站都会将用户输入的网址转换为相应的短网址。比如,如果我们在新浪微博中发言时输入网址http://redisdoc.com/geo/index.html,那么微博将把这个网址转换为相应的短网址http://t.cn/RqRRZ8n,当用户访问这个短网址时,微博在后台就会对这次点击进行一些数据统计,然后再引导用户的浏览器跳转到http://redisdoc.com/geo/index.html上面。

创建短网址本质上就是要创建出短网址ID与目标网址之间的映射,并在用户访问短网址时,根据短网址的ID从映射记录中找出与之相对应的目标网址。比如在前面的例子中,微博的短网址程序就将短网址http://t.cn/RqRRZ8n中的ID值RqRRZ8n映射到了http://redisdoc.com/geo/index.html这个网址上面,当用户访问短网址http://t.cn/RqRRZ8n时,程序就会根据这个短网址的ID值RqRRZ8n找出与之对应的目标网址http://redisdoc.com/geo/index.html,并将用户引导至目标网址上面去。

作为示例,图3-8展示了几个微博短网址ID与目标网址之间的映射关系。

img

图3-8 微博短网址ID与目标网址映射关系示例

因为Redis的散列非常适合用来存储短网址ID与目标网址之间的映射,所以我们可以基于Redis的散列实现一个短网址程序,代码清单3-1展示了一个这样的例子。

代码清单3-1 使用散列实现的短网址程序:/hash/shorty_url.py

from base36 import base10_to_base36

ID_COUNTER = "ShortyUrl::id_counter"
URL_HASH = "ShortyUrl::url_hash"

class ShortyUrl:

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

    def shorten(self, target_url):
        """
        为目标网址创建并存储相应的短网址ID
        """
        # 为目标网址创建新的数字ID
        new_id = self.client.incr(ID_COUNTER)
        # 通过将十进制数字转换为三十六进制数字来创建短网址ID,
        # 比如,十进制数字10086将被转换为三十六进制数字7S6
        short_id = base10_to_base36(new_id)
        # 把短网址ID用作字段,目标网址用作值,将它们之间的映射关系存储到散列里面
        self.client.hset(URL_HASH, short_id, target_url)
        return short_id

    def restore(self, short_id):
        """
        根据给定的短网址ID,返回与之对应的目标网址
        """
        return self.client.hget(URL_HASH, short_id)

ShortyUrl类的shorten()方法负责为输入的网址生成短网址ID,它的工作包括以下4个步骤:

1)为每个给定的网址创建一个十进制数字ID。

2)将十进制数字ID转换为三十六进制,并将这个三十六进制数字用作给定网址的短网址ID,这种方法在数字ID长度较大时可以有效地缩短数字ID的长度。代码清单3-2展示了将数字从十进制转换成三十六进制的base10_to_base36函数的具体实现。

3)将短网址ID和目标网址之间的映射关系存储到散列中。

4)向调用者返回刚刚生成的短网址ID。

代码清单3-2 将十进制数字转换成三十六进制数字的程序:/hash/base36.py

def base10_to_base36(number):
    alphabets = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    result = ""

    while number != 0 :
        number, i = divmod(number, 36)
        result = (alphabets[i] + result)
    return result or alphabets[0]

restore()方法要做的事情和shorten()方法正好相反,它会从存储着映射关系的散列里面取出与给定短网址ID相对应的目标网址,然后将其返回给调用者。

以下代码简单地展示了使用ShortyUrl程序创建短网址ID的方法,以及根据短网址ID获取目标网址的方法:

>>> from redis import Redis
>>> from shorty_url import ShortyUrl
>>> client = Redis(decode_responses=True)
>>> shorty_url = ShortyUrl(client)
>>> shorty_url.shorten("RedisGuide.com")       # 创建短网址ID
'1'
>>> shorty_url.shorten("RedisBook.com")
'2'
>>> shorty_url.shorten("RedisDoc.com")
'3'
>>> shorty_url.restore("1")                    # 根据短网址ID查找目标网址
'RedisGuide.com'
>>> shorty_url.restore("2")
'RedisBook.com'

图3-9展示了上面这段代码在数据库中创建的散列结构。

img

图3-9 短网址程序在数据库中创建的散列结构

1.5 HINCRBY:对字段存储的整数值执行加法或减法操作

与字符串键的INCRBY命令一样,如果散列的字段里面存储着能够被Redis解释为整数的数字,那么用户就可以使用HINCRBY命令为该字段的值加上指定的整数增量:

HINCRBY hash field increment

HINCRBY命令在成功执行加法操作之后将返回字段当前的值作为命令的结果。

比如,对于图3-10所示的article::10086散列,我们可以通过执行以下命令为view_count字段的值加上1:

redis> HINCRBY article::10086 view_count 1
(integer) 101

也可以通过执行以下命令,为view_count字段的值加上30:

redis> HINCRBY article::10086 view_count 30
(integer) 131

img

图3-10 存储着文章数据的散列

1.5.1 执行减法操作

因为Redis只为散列提供了用于执行加法操作的HINCRBY命令,但是没有为散列提供相应的用于执行减法操作的命令,所以如果用户需要对字段存储的整数值执行减法操作,就需要将一个负数增量传给HINCRBY命令,从而达到对值执行减法计算的目的。

以下代码展示了如何使用HINCRBY命令去对view_count字段存储的整数值执行减法计算:

redis> HGET article::10086 view_count           -- 文章现在的浏览次数为131次
"131"

redis> HINCRBY article::10086 view_count -10    -- 将文章的浏览次数减少10次
"121"

redis> HINCRBY article::10086 view_count -21    -- 将文章的浏览次数减少21次
"100"

redis> HGET article::10086 view_count           -- 文章现在的浏览次数只有100次
"100"

1.5.2 处理异常情况

只能对存储着整数值的字段执行HINCRBY命令,并且用户给定的增量也必须为整数,尝试对非整数值字段执行HINCRBY命令,或者向HINCRBY命令提供非整数增量,都会导致HINCRBY命令拒绝执行并报告错误。

以下是一些导致HINCRBY命令报错的例子:

redis> HINCRBY article::10086 view_count "fifty"    -- 增量必须能够被解释为整数
(error) ERR value is not an integer or out of range

redis> HINCRBY article::10086 view_count 1.14       -- 增量不能是浮点数
(error) ERR value is not an integer or out of range

redis> HINCRBY article::10086 content 100           -- 尝试向存储字符串值的字段执行
 HINCRBY
(error) ERR hash value is not an integer

1.5.3 其他信息

复杂度:O(1)。

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

1.6 HINCRBYFLOAT:对字段存储的数字值执行浮点数加法或减法操作

HINCRBYFLOAT命令的作用和HINCRBY命令的作用类似,它们之间的主要区别在于HINCRBYFLOAT命令不仅可以使用整数作为增量,还可以使用浮点数作为增量:

HINCRBYFLOAT hash field increment

HINCRBYFLOAT命令在成功执行加法操作之后,将返回给定字段的当前值作为结果。

举个例子,通过执行以下HINCRBYFLOAT命令,我们可以将geo::peter散列longitude字段的值从原来的100.0099647修改为111.2099647:

redis> HGET geo::peter longitude
"100.0099647"

redis> HINCRBYFLOAT geo::peter longitude 11.2  -- 将字段的值加上11.2
"111.2099647"

1.6.1 增量和字段值的类型限制

正如之前所说,HINCRBYFLOAT命令不仅可以使用浮点数作为增量,还可以使用整数作为增量:

redis> HGET number float
"1.14"

redis> HINCRBYFLOAT number float 10086  -- 整数增量
"10089.13999999999999968"

此外,不仅存储浮点数的字段可以执行HINCRBYFLOAT命令,存储整数的字段也一样可以执行HINCRBYFLOAT命令:

redis> HGET number int                  -- 存储整数的字段
"100"

redis> HINCRBYFLOAT number int 2.56
"102.56"

最后,如果加法计算的结果能够被表示为整数,那么HINCRBYFLOAT命令将使用整数作为计算结果:

redis> HGET number sum
"1.5"

redis> HINCRBYFLOAT number sum 1.5
"5"  -- 结果表示为整数5

1.6.2 执行减法操作

与HINCRBY命令一样,Redis也没有为HINCRBYFLOAT命令提供对应的减法操作命令,因此如果我们想要对字段存储的数字值执行浮点数减法操作,那么只能通过向HINCRBYFLOAT命令传入负值浮点数来实现:

redis> HGET geo::peter longitude
"111.2099647"

redis> HINCRBYFLOAT geo::peter longitude -50  -- 将字段的值减去50
"61.2099647"

1.6.3 其他信息

复杂度:O(1)。

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

示例:使用散列键重新实现计数器

第2章曾经展示过如何使用INCRBY命令和DECRBY命令去构建一个计数器程序,在学习了HINCRBY命令之后,我们同样可以通过类似的原理来构建一个使用散列实现的计数器程序,就像代码清单3-3展示的那样。

代码清单3-3 使用散列实现的计数器:/hash/counter.py

class Counter:

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

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

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

        return self.client.hincrby(self.hash_key, self.counter_name, -n)

    def get(self):
        """
        返回计数器的当前值
        """
        value = self.client.hget(self.hash_key, self.counter_name)
        # 如果计数器并不存在,那么返回0作为默认值
        if value is None:
            return 0
        else:
            return int(value)

    def reset(self):
        """
        将计数器的值重置为0
        """
        self.client.hset(self.hash_key, self.counter_name, 0)

这个计数器实现充分地发挥了散列的优势:

它允许用户将多个相关联的计数器存储到同一个散列键中实行集中管理,而不必像字符串计数器那样,为每个计数器单独设置一个字符串键。

与此同时,通过对散列中的不同字段执行HINCRBY命令,程序可以对指定的计数器执行加法操作和减法操作,而不会影响到存储在同一散列中的其他计数器。

作为例子,以下代码展示了如何将3个页面的浏览次数计数器存储到同一个散列中:

>>> from redis import Redis
>>> from counter import Counter
>>> client = Redis(decode_responses=True)
>>> # 创建一个计数器,用于记录页面/user/peter被访问的次数
>>> user_peter_counter = Counter(client, "page_view_counters", "/user/peter")
>>> user_peter_counter.increase()
1L
>>> user_peter_counter.increase()
2L
>>> # 创建一个计数器,用于记录页面/product/256被访问的次数
>>> product_256_counter = Counter(client, "page_view_counters", "/product/256")
>>> product_256_counter.increase(100)
100L
>>> # 创建一个计数器,用于记录页面/product/512被访问的次数
>>> product_512_counter = Counter(client, "page_view_counters", "/product/512")
>>> product_512_counter.increase(300)
300L

因为user_peter_counter、product_256_counter和product_512_counter这3个计数器都是用来记录页面浏览次数的,所以这些计数器都被放到了page_view_counters这个散列中。与此类似,如果我们要创建一些用途完全不一样的计数器,那么只需要把新的计数器放到其他散列里面就可以了。

比如,以下代码就展示了如何将文件dragon_rises.mp3和文件redisbook.pdf的下载次数计数器放到download_counters散列中:

>>> dragon_rises_counter = Counter(client, "download_counters", "dragon_rise.mp3")
>>> dragon_rises_counter.increase(10086)
10086L
>>> redisbook_counter = Counter(client, "download_counters", "redisbook.pdf")
>>> redisbook_counter.increase(65535)
65535L

图3-11展示了page_view_counters和download_counters这两个散列以及它们包含的各个计数器。

img

图3-11 散列计数器数据结构示意图

通过使用不同的散列存储不同类型的计数器,程序能够让代码生成的数据结构变得更容易理解,并且在针对某种类型的计数器执行批量操作时也会变得更加方便。比如,当我们不再需要下载计数器的时候,只要把download_counters散列删除就可以移除所有下载计数器了。

1.7 HSTRLEN:获取字段值的字节长度

用户可以使用HSTRLEN命令获取给定字段值的字节长度:

HSTRLEN hash field

比如对于图3-12所示的article::10086散列来说,我们可以通过执行以下HSTRLEN命令取得title、content、author等字段值的字节长度:

redis> HSTRLEN article::10086 title
(integer) 8    -- title字段的值"greeting"长8个字节

redis> HSTRLEN article::10086 content
(integer) 11   -- content字段的值"hello world"长11个字节

redis> HSTRLEN article::10086 author
(integer) 5    -- author字段的值"peter"长6个字节

img

图3-12 使用散列存储文章数据

如果给定的字段或散列并不存在,那么HSTRLEN命令将返回0作为结果:

redis> HSTRLEN article::10086 last_updated_at  -- 字段不存在
(integer) 0

redis> HSTRLEN not-exists-hash not-exists-key  -- 散列不存在
(integer) 0

其他信息

复杂度:O(1)。

版本要求:HSTRLEN命令从Redis 1.2.0版本开始可用。

1.8 HEXISTS:检查字段是否存在

HEXISTS命令可用于检查用户给定的字段是否存在于散列当中:

HEXISTS hash field

如果散列包含了给定的字段,那么命令返回1,否则命令返回0。

例如,以下代码就展示了如何使用HEXISTS命令检查article::10086散列是否包含某些字段:

redis> HEXISTS article::10086 author
(integer) 1    -- 包含该字段

redis> HEXISTS article::10086 content
(integer) 1

redis> HEXISTS article::10086 last_updated_at
(integer) 0    -- 不包含该字段

从HEXISTS命令的执行结果可以看出,article::10086散列包含了author字段和content字段,但却没有包含last_updated_at字段。

如果用户给定的散列并不存在,那么HEXISTS命令对于这个散列所有字段的检查结果都是不存在:

redis> HEXISTS not-exists-hash not-exists-field
(integer) 0

redis> HEXISTS not-exists-hash another-not-exists-field
(integer) 0

其他信息

复杂度:O(1)。

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

1.9 HDEL:删除字段

HDEL命令用于删除散列中的指定字段及其相关联的值:

HDEL hash field

当给定字段存在于散列当中并且被成功删除时,命令返回1;如果给定字段并不存在于散列当中,或者给定的散列并不存在,那么命令将返回0表示删除失败。

举个例子,对于图3-13所示的article::10086散列,我们可以使用以下命令删除散列的author字段和created_at字段,以及与这些字段相关联的值:

redis> HDEL article::10086 author
(integer) 1

redis> HDEL article::10086 created_at
(integer) 1

img

图3-13 article::10086散列

图3-14展示了删除了两个字段后的article::10086散列。

img

图3-14 删除了两个字段之后的article::10086散列

其他信息

复杂度:O(1)。

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

1.10 HLEN:获取散列包含的字段数量

用户可以通过使用HLEN命令获取给定散列包含的字段数量:

HLEN hash

例如,对于图3-15中展示的article::10086散列和account::54321散列来说,我们可以通过执行以下命令来获取article::10086散列包含的字段数量:

redis> HLEN article::10086
(integer) 4    -- 这个散列包含4个字段

img

图3-15 两个散列键

或者,通过执行以下命令来获取account::54321散列包含的字段数量:

redis> HLEN account::54321
(integer) 2    -- 这个散列包含2个字段

如果用户给定的散列并不存在,那么HLEN命令将返回0作为结果:

redis> HLEN not-exists-hash
(integer) 0

其他信息

复杂度:O(1)。

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

示例:实现用户登录会话

为了方便用户,网站一般都会为已登录的用户生成一个加密令牌,然后把这个令牌分别存储在服务器端和客户端,之后每当用户再次访问该网站的时候,网站就可以通过验证客户端提交的令牌来确认用户的身份,从而使得用户不必重复地执行登录操作。

另外,为了防止用户因为长时间不输入密码而遗忘密码,以及为了保证令牌的安全性,网站一般都会为令牌设置一个过期期限(比如一个月),当期限到达之后,用户的会话就会过时,而网站则会要求用户重新登录。

上面描述的这种使用令牌来避免重复登录的机制一般称为登录会话(login session),通过使用Redis的散列,我们可以构建出代码清单3-4所示的登录会话程序。

代码清单3-4 使用散列实现的登录会话程序:/hash/login_session.py

import random
from time import time # 获取浮点数格式的UNIX时间戳
from hashlib import sha256

# 会话的默认过期时间
DEFAULT_TIMEOUT = 3600*24*30 # 一个月

# 存储会话令牌以及会话过期时间戳的散列
SESSION_TOKEN_HASH = "session::token"
SESSION_EXPIRE_TS_HASH = "session::expire_timestamp"

# 会话状态
SESSION_NOT_LOGIN = "SESSION_NOT_LOGIN"
SESSION_EXPIRED = "SESSION_EXPIRED"
SESSION_TOKEN_CORRECT = "SESSION_TOKEN_CORRECT"
SESSION_TOKEN_INCORRECT = "SESSION_TOKEN_INCORRECT"

def generate_token():
    """
    生成一个随机的会话令牌
    """
    random_string = str(random.getrandbits(256)).encode('utf-8')
    return sha256(random_string).hexdigest()

class LoginSession:

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

    def create(self, timeout=DEFAULT_TIMEOUT):
        """
        创建新的登录会话并返回会话令牌,可选的timeout参数用于指定会话的过期时间(以秒为单位)
        """
        # 生成会话令牌
        user_token = generate_token()
        # 计算会话到期时间戳
        expire_timestamp = time()+timeout
        # 以用户ID为字段,将令牌和到期时间戳分别存储到两个散列里面
        self.client.hset(SESSION_TOKEN_HASH, self.user_id, user_token)
        self.client.hset(SESSION_EXPIRE_TS_HASH, self.user_id, expire_timestamp)
        # 将会话令牌返回给用户
        return user_token

def validate(self, input_token):
    """
    根据给定的令牌验证用户身份。
    这个方法有4个可能的返回值,分别对应4种不同情况:
SESSION_NOT_LOGIN —— 用户尚未登录
SESSION_EXPIRED —— 会话已过期SESSION_TOKEN_CORRECT —— 用户已登录,并且给定令牌与用户令牌相匹配SESSION_TOKEN_INCORRECT —— 用户已登录,但给定令牌与用户令牌不匹配    """
    # 尝试从两个散列里面取出用户的会话令牌以及会话的过期时间戳
    user_token = self.client.hget(SESSION_TOKEN_HASH, self.user_id)
    expire_timestamp = self.client.hget(SESSION_EXPIRE_TS_HASH, self.user_id)

    # 如果会话令牌或者过期时间戳不存在,那么说明用户尚未登录
    if (user_token is None) or (expire_timestamp is None):
        return SESSION_NOT_LOGIN

    # 将当前时间戳与会话的过期时间戳进行对比,检查会话是否已过期,因为HGET命令返回的过期时间
    # 戳是字符串格式的,所以在进行对比之前要先将它转换成原来的浮点数格式
    if time() > float(expire_timestamp):
        return SESSION_EXPIRED

    # 用户令牌存在并且未过期,那么检查它与给定令牌是否一致
    if input_token == user_token:
        return SESSION_TOKEN_CORRECT
    else:
        return SESSION_TOKEN_INCORRECT

def destroy(self):
    """
    销毁会话
    """
    # 从两个散列里面分别删除用户的会话令牌以及会话的过期时间戳
    self.client.hdel(SESSION_TOKEN_HASH, self.user_id)
    self.client.hdel(SESSION_EXPIRE_TS_HASH, self.user_id)

LoginSession的create()方法首先会计算出随机的会话令牌以及会话的过期时间戳,然后使用用户ID作为字段,将令牌和过期时间戳分别存储到两个散列里面。

在此之后,每当客户端向服务器发送请求并提交令牌的时候,程序就会使用validate()方法验证被提交令牌的正确性:validate()方法会根据用户的ID,从两个散列里面分别取出用户的会话令牌以及会话的过期时间戳,然后通过一系列检查判断令牌是否正确以及会话是否过期。

最后,destroy()方法可以在用户手动退出(logout)时调用,它可以删除用户的会话令牌以及会话的过期时间戳,让用户重新回到未登录状态。

在拥有LoginSession程序之后,我们可以通过执行以下代码为用户peter创建相应的会话令牌:

>>> from redis import Redis
>>> from login_session import LoginSession
>>>
>>> client = Redis(decode_responses=True)
>>> session = LoginSession(client, "peter")
>>>
>>> token = session.create()
>>> token
'3b000071e59fcdcaa46b900bb5c484f653de67055fde622f34c255a65bd9a561'

通过以下代码验证给定令牌的正确性:

>>> session.validate("wrong_token")
'SESSION_TOKEN_INCORRECT'
>>>
>>> session.validate(token)
'SESSION_TOKEN_CORRECT'

在使用完会话之后,执行以下代码销毁会话:

>>> session.destroy()
>>>
>>> session.validate(token)
'SESSION_NOT_LOGIN'

图3-16展示了使用LoginSession程序在数据库中创建多个会话的示意图。

img

图3-16 登录会话程序数据结构示意图

1.11 HMSET:一次为多个字段设置值

用户可以使用HMSET命令一次为散列中的多个字段设置值:

HMSET hash field value [field value ...]

HMSET命令在设置成功时返回OK。

比如,为了构建图3-17所示的散列,我们可能会执行以下4个HSET命令:

redis> HSET article::10086 title "greeting"
(integer) 1

redis> HSET article::10086 content "hello world"
(integer) 1

redis> HSET article::10086 author "peter"
(integer) 1

redis> HSET article::10086 created_at "1442744762.631885"
(integer) 1

img

图3-17 存储文章数据的散列

但是接下来的这一条HMSET命令可以更方便地完成相同的工作:

redis> HMSET article::10086 title "greeting" content "hello world" author "peter" created_at "1442744762.631885" 
OK

此外,因为客户端在执行这条HMSET命令时只需要与Redis服务器进行一次通信,而上面的4条HSET命令则需要客户端与Redis服务器进行4次通信,所以前者的执行速度要比后者快得多。

1.11.1 使用新值覆盖旧值

如果用户给定的字段已经存在于散列当中,那么HMSET命令将使用用户给定的新值去覆盖字段已有的旧值。

比如对于title和content这两个已经存在于article::10086散列的字段来说:

redis> HGET article::10086 title
"greeting"

redis> HGET article::10086 content
"hello world"

如果我们执行以下命令:

redis> HMSET article::10086 title "Redis Tutorial" content "Redis is a data structure store, ..."
OK

那么title字段和content字段已有的旧值将被新值覆盖:

redis> HGET article::10086 title
"Redis Tutorial"

redis> HGET article::10086 content
"Redis is a data structure store, ..."

1.11.2 其他信息

复杂度:O(N),其中N为被设置的字段数量。

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

1.12 HMGET:一次获取多个字段的值

通过使用HMGET命令,用户可以一次从散列中获取多个字段的值:

HMGET hash field [field ...]

HMGET命令将按照用户给定字段的顺序依次返回与之对应的值。

比如对于图3-18所示的article::10086散列来说,我们可以使用以下命令来获取它的author字段和created_at字段的值:

redis> HMGET article::10086 author created_at
1) "peter"                -- author字段的值
2) "1442744762.631885"    -- created_at字段的值

或者使用以下命令来获取它的title字段和content字段的值:

redis> HMGET article::10086 title content
1) "greeting"       -- title字段的值
2) "hello world"    -- content字段的值

img

图3-18 存储文章数据的散列

与HGET命令一样,如果用户向HMGET命令提供的字段或者散列不存在,那么HMGET命令将返回空值作为结果:

redis> HMGET article::10086 title content last_updated_at
1) "greeting"
2) "hello world"
3) (nil)    -- last_updated_at字段不存在于article::10086散列

redis> HMGET not-exists-hash field1 field2 field3  -- 散列不存在
1) (nil)
2) (nil)
3) (nil)

其他信息

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

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

1.13 HKEYS、HVALS、HGETALL:获取所有字段、所有值、所有字段和值

Redis为散列提供了HKEYS、HVALS和HGETALL这3个命令,可以分别用于获取散列包含的所有字段、所有值以及所有字段和值:

HKEYS hash

HVALS hash

HGETALL hash

举个例子,对于图3-19所示的article::10086散列来说,我们可以使用HKEYS命令去获取它包含的所有字段:

redis> HKEYS article::10086
1) "title"
2) "content"
3) "author"
4) "created_at"

也可以使用HVALS命令去获取它包含的所有值:

redis> HVALS article::10086
1) "greeting"
2) "hello world"
3) "peter"
4) "1442744762.631885"

还可以使用HGETALL命令去获取它包含的所有字段和值:

redis> HGETALL article::10086
1) "title"      -- 字段
2) "greeting"   -- 字段的值
3) "content"
4) "hello world"
5) "author"
6) "peter"
7) "created_at"
8) "1442744762.631885"

img

图3-19 存储文章数据的散列

在HGETALL命令返回的结果列表当中,每两个连续的元素就代表了散列中的一对字段和值,其中奇数位置上的元素为字段,偶数位置上的元素则为字段的值。

如果用户给定的散列并不存在,那么HKEYS、HVALS和HGETALL都将返回一个空列表:

redis> HKEYS not-exists-hash
(empty list or set)

redis> HVALS not-exists-hash
(empty list or set)

redis> HGETALL not-exists-hash
(empty list or set)

1.13.1 字段在散列中的排列顺序

Redis散列包含的字段在底层是以无序方式存储的,根据字段插入的顺序不同,包含相同字段的散列在执行HKEYS命令、HVALS命令和HGETALL命令时可能会得到不同的结果,因此用户在使用这3个命令的时候,不应该对它们返回的元素的排列顺序做任何假设。如果需要,用户可以对这些命令返回的元素进行排序,使它们从无序变为有序。

举个例子,如果我们以不同的设置顺序创建两个完全相同的散列hash1和hash2:

redis> HMSET hash1 field1 value1 field2 value2 field3 value3
OK

redis> HMSET hash2 field3 value3 field2 value2 field1 value1
OK

那么HKEYS命令将以不同的顺序返回这两个散列的字段:

redis> HKEYS hash1
1) "field1"
2) "field2"
3) "field3"

redis> HKEYS hash2
1) "field3"
2) "field2"
3) "field1"

而HVALS命令则会以不同的顺序返回这两个散列的字段值:

redis> HVALS hash1
1) "value1"
2) "value2"
3) "value3"

redis> HVALS hash2
1) "value3"
2) "value2"
3) "value1"

HGETALL命令则会以不同的顺序返回这两个散列的字段和值:

redis> HGETALL hash1
1) "field1"
2) "value1"
3) "field2"
4) "value2"
5) "field3"
6) "value3"

redis> HGETALL hash2
1) "field3"
2) "value3"
3) "field2"
4) "value2"
5) "field1"
6) "value1"

1.13.2 其他信息

复杂度:HKEYS命令、HVALS命令和HGETALL命令的复杂度都为O(N),其中N为散列包含的字段数量。

版本要求:HKEYS命令、HVALS命令和HGETALL命令都从Redis 2.0.0版本开始可用。

示例:存储图数据

在构建地图应用、设计电路图、进行任务调度、分析网络流量等多种任务中,都需要对图(graph)数据结构实施建模,并存储相关的图数据。对于不少数据库来说,想要高效、直观地存储图数据并不是一件容易的事情,但是Redis却能够以多种不同的方式表示图数据结构,其中一种方式就是使用散列。

例如,假设我们想要存储图3-20所示的带权重有向图,那么可以创建一个图3-21所示的散列键,这个散列键会以start_vertex->end_vertex的形式将各个顶点之间的边存储到散列的字段中,并将字段的值设置成边的权重。通过这种方法,我们可以将图的相关数据全部存储到散列中,代码清单3-5展示了使用这种方法实现的图数据存储程序。

img

图3-20 简单的带权重有向图

img

图3-21 图对应的散列键

代码清单3-5 使用散列实现的图数据存储程序:/hash/graph.py

def make_edge_name_from_vertexs(start, end):
    """
    使用边的起点和终点组建边的名字。
    例子:对于start为"a"、 end为"b"的输入,这个函数将返回"a->b"
    """
    return str(start) + "->" + str(end)

def decompose_vertexs_from_edge_name(name):
    """
    从边的名字中分解出边的起点和终点。例子:对于输入"a->b",这个函数将返回结果["a", "b"] 
    """
    return name.split("->")


class Graph:

    def __init__(self, client, key):
        self.client = client
        self.key = key
    def add_edge(self, start, end, weight):
    """
    添加一条从顶点start连接至顶点end的边,并将边的权重设置为weight 
    """
    edge = make_edge_name_from_vertexs(start, end)
    self.client.hset(self.key, edge, weight)

    def remove_edge(self, start, end):
    """
    移除从顶点start连接至顶点end的一条边。
    这个方法在成功删除边时返回True,因为边不存在而导致删除失败时返回False 
    """
    edge = make_edge_name_from_vertexs(start, end)
    return self.client.hdel(self.key, edge)

    def get_edge_weight(self, start, end):
    """
    获取从顶点start连接至顶点end的边的权重,如果给定的边不存在,那么返回None 
    """
    edge = make_edge_name_from_vertexs(start, end)
    return self.client.hget(self.key, edge)

def has_edge(self, start, end):
    """
    检查顶点start和顶点end之间是否有边,有则返回True,否则返回False 
    """
    edge = make_edge_name_from_vertexs(start, end)
    return self.client.hexists(self.key, edge)

def add_multi_edges(self, *tuples):
    """
    一次向图中添加多条边。这个方法接受任意多个格式为(start, end, weight)的三元组作为参数
    """
    # redis-py客户端的hmset()方法接受一个字典作为参数,格式为{field1: value1, 
    # field2: value2, ...}。为了一次对图中的多条边进行设置,我们要将待设置的各条边以及它们
    # 的权重存储在以下字典中
    nodes_and_weights = {}

    # 遍历输入的每个三元组,从中取出边的起点、终点和权重
    for start, end, weight in tuples:
        # 根据边的起点和终点,创建出边的名字
        edge = make_edge_name_from_vertexs(start, end)
        # 使用边的名字作为字段,边的权重作为值,把边及其权重存储到字典中
        nodes_and_weights[edge] = weight

    # 根据字典中存储的字段和值,对散列进行设置
    self.client.hmset(self.key, nodes_and_weights)

def get_multi_edge_weights(self, *tuples):
    """
    一次获取多条边的权重。这个方法接受任意多个格式为(start, end)的二元组作为参数,然后返回
    一个列表作为结果,列表中依次存储着每条输入边的权重
    """
    # hmget()方法接受一个格式为[field1, field2, ...]的列表作为参数。为了一次获取图中多
    # 条边的权重,我们需要把所有想要获取权重的边的名字依次放入以下列表中
    edge_list = []

    # 遍历输入的每个二元组,从中获取边的起点和终点
    for start, end in tuples:
        # 根据边的起点和终点,创建出边的名字
        edge = make_edge_name_from_vertexs(start, end)
        # 把边的名字放入列表中
        edge_list.append(edge)

    # 根据列表中存储的每条边的名字,从散列中获取它们的权重
    return self.client.hmget(self.key, edge_list)

def get_all_edges(self):
    """
    以集合形式返回整个图包含的所有边,集合包含的每个元素都是一个(start, end)格式的二元组
    """
    # hkeys()方法将返回一个列表,列表中包含多条边的名字。例如["a->b", "b->c", "c->d"]
    edges = self.client.hkeys(self.key)

    # 创建一个集合,用于存储二元组格式的边
    result = set()
    # 遍历每条边的名字
    for edge in edges:
        # 根据边的名字,分解出边的起点和终点
        start, end = decompose_vertexs_from_edge_name(edge)
        # 使用起点和终点组成一个二元组,然后把它放入结果集合中
        result.add((start, end))

    return result

def get_all_edges_with_weight(self):
    """
    以集合形式返回整个图包含的所有边,以及这些边的权重。集合包含的每个元素都是一个(start, 
    end, weight)格式的三元组
    """
    # hgetall()方法将返回一个包含边和权重的字典作为结果,格式为{edge1: weight1, edge2: 
    # weight2, ...}
    edges_and_weights = self.client.hgetall(self.key)

    # 创建一个集合,用于存储三元组格式的边和权重
    result = set()
    # 遍历字典中的每个元素,获取边以及它的权重
    for edge, weight in edges_and_weights.items():
        # 根据边的名字,分解出边的起点和终点
        start, end = decompose_vertexs_from_edge_name(edge)
        # 使用起点、终点和权重构建一个三元组,然后把它添加到结果集合中
        result.add((start, end, weight))
    return result

这个图数据存储程序的核心概念就是把边(edge)的起点和终点组合成一个字段名,并把边的权重(weight)用作字段的值,然后使用HSET命令或者HMSET命令把它们存储到散列中。比如,如果用户输入的边起点为"a",终点为"b",权重为"30",那么程序将执行命令HSET hash"a->b"30,把"a"至"b"的这条边及其权重30存储到散列中。

在此之后,程序就可以使用HDEL命令删除图的某条边,使用HGET命令或者HMGET命令获取边的权重,使用HEXISTS命令检查边是否存在,使用HKEYS命令和HGETALL命令获取图的所有边以及权重。

例如,我们可以通过执行以下代码,构建出前面展示过的带权重有向图3-20:

>>> from redis import Redis
>>> from graph import Graph
>>>
>>> client = Redis(decode_responses=True)
>>> graph = Graph(client, "test-graph")
>>>
>>> graph.add_edge("a", "b", 30)  # 添加边
>>> graph.add_edge("c", "b", 25)
>>> graph.add_multi_edges(("b", "d", 70), ("d", "e", 19))  # 添加多条边

然后通过执行程序提供的方法获取边的权重,或者检查给定的边是否存在:

>>> graph.get_edge_weight("a", "b")  # 获取边a->b的权重
'30'
>>> graph.has_edge("a", "b")         # 边a->b存在
True
>>> graph.has_edge("b", "a")         # 边b->a不存在
False

最后,我们还可以获取图的所有边以及它们的权重:

>>> graph.get_all_edges()  # 获取所有边
{('b', 'd'), ('d', 'e'), ('a', 'b'), ('c', 'b')}
>>>
>>> graph.get_all_edges_with_weight()  # 获取所有边以及它们的权重
{('c', 'b', '25'), ('a', 'b', '30'), ('d', 'e', '19'), ('b', 'd', '70')}

这里展示的图数据存储程序提供了针对边和权重的功能,因为它能够非常方便地向图中添加边和移除边,并且可以快速地检查某条边是否存在,所以适合用来存储节点较多但边较少的稀疏图(sparse graph)。在后续的章节中,我们还会继续看到更多使用Redis存储图数据的例子。

示例:使用散列键重新实现文章存储程序

之前我们用散列重写了第2章介绍过的计数器程序,但是除了计数器程序之外,还有另一个程序也非常适合使用散列来重写,那就是文章数据存储程序:比起用多个字符串键来存储文章的各项数据,更好的做法是把每篇文章的所有数据都存储到同一个散列中,代码清单3-6展示了这一想法的具体实现。

代码清单3-6 使用散列实现的文章数据存储程序:/hash/article.py

from time import time

class Article:

    def __init__(self, client, article_id):
        self.client = client
        self.article_id = str(article_id)
        self.article_hash = "article::" + self.article_id

    def is_exists(self):
        """
        检查给定ID对应的文章是否存在
        """
        # 如果文章散列里面已经设置了标题,那么我们认为这篇文章存在
        return self.client.hexists(self.article_hash, "title")

    def create(self, title, content, author):
        """
        创建一篇新文章,创建成功时返回True,因为文章已经存在而导致创建失败时返回False 
        """
        # 文章已存在,放弃执行创建操作
        if self.is_exists():
            return False

        # 把所有文章数据都放到字典中
        article_data = {
            "title": title,
            "content": content,
            "author": author,
            "create_at": time()
        }
        # redis-py的hmset()方法接受一个字典作为参数,
        # 并根据字典内的键和值对散列的字段和值进行设置
        return self.client.hmset(self.article_hash, article_data)

    def get(self):
        """
        返回文章的各项信息
        """
        # hgetall()方法会返回一个包含标题、内容、作者和创建日期的字典
        article_data = self.client.hgetall(self.article_hash)
        # 把文章ID也放到字典里面,以便用户操作
        article_data["id"] = self.article_id
        return article_data

    def update(self, title=None, content=None, author=None):
        """
        对文章的各项信息进行更新,更新成功时返回True,失败时返回False 
        """
        # 如果文章并不存在,则放弃执行更新操作
        if not self.is_exists():
            return False

        article_data = {}
        if title is not None:
            article_data["title"] = title
        if content is not None:
            article_data["content"] = content
        if author is not None:
            article_data["author"] = author
        return self.client.hmset(self.article_hash, article_data)

新的文章存储程序除了会用到散列之外,还有两点需要注意:

虽然Redis为字符串提供了MSET命令和MSETNX命令,但是并没有为散列提供HMSET命令对应的HMSETNX命令,所以这个程序在创建一篇新文章之前,需要先通过is_exists()方法检查文章是否存在,然后再考虑是否使用HMSET命令进行设置。

在使用字符串键存储文章数据的时候,为了避免数据库中出现键名冲突,程序必须为每篇文章的每个属性都设置一个独一无二的键,比如使用article::10086::title键存储ID为10086的文章的标题,使用article::12345::title键存储ID为12345的文章的标题,诸如此类。相反,因为新的文章存储程序可以直接将一篇文章的所有相关信息都存储到同一个散列中,所以它可以直接在散列里面使用title作为标题的字段,而不必担心出现命名冲突。

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

>>> from redis import Redis
>>> from article import Article
>>>
>>> client = Redis(decode_responses=True)
>>> article = Article(client, 10086)
>>>
>>> # 创建文章
>>> article.create("greeting", "hello world", "peter")
>>>
>>> # 获取文章内容
>>> article.get()
{'content': 'hello world', 'id': '10086', 'created_at': '1442744762.631885', 'title': 'greeting', 'author': 'peter'}
>>>
>>> # 检查文章是否存在
>>> article.is_exists()
True
>>> # 更新文章内容
>>> article.update(content="good morning!")
>>> article.get()
{'content': 'good morning!', 'id': '10086',  'created_at': '1442744762.631885', 'title': 'greeting', 'author': 'peter'}

图3-22展示了这段代码创建的散列键。

img

图3-22 存储在散列中的文章数据

1.14 散列与字符串

至此,本章中陆续介绍了HSET、HSETNX、HGET、HINCRBY和HINCRBYFLOAT等多个散列命令,如果你对第2章介绍过的字符串命令还有印象,应该记得字符串也有类似的SET、SETNX、GET、INCRBY和INCRBYFLOAT命令。这种相似并不是巧合,正如表3-1所示,散列的确拥有很多与字符串命令功能相似的命令。

表3-1 字符串命令与类似的散列命令

img

img

Redis选择同时提供字符串键和散列键这两种数据结构,是因为它们虽然在操作上非常相似,但是各自却又拥有不同的优点,这使得它们在某些场合无法被对方替代,下面将分别介绍这两种数据结构各自的优点。

1.14.1 散列键的优点

散列的最大优势,就是它只需要在数据库里面创建一个键,就可以把任意多的字段和值存储到散列里面。相反,因为每个字符串键只能存储一个键值对,所以如果用户要使用字符串键去存储多个数据项,就只能在数据库中创建多个字符串键。

图3-23展示了使用字符串键和散列键存储相同数量的数据项时,数据库中创建的字符串键和散列键。

img

图3-23 使用字符串键和散列键存储相同数量的数据项

从图3-23中可以看到,为了存储4个数据项,程序需要用到4个字符串键或者一个散列键。按此计算,如果我们需要存储100万篇文章,那么在使用散列键的情况下,程序只需要在数据库里面创建100万个散列键就可以了;但是如果使用字符串键,那么程序就需要在数据库里面创建400万个字符串键。

数据库键数量增多带来的问题主要和资源有关:

为了对数据库以及数据库键的使用情况进行统计,Redis会为每个数据库键存储一些额外的信息,并因此带来一些额外的内存消耗。对于单个数据库键来说,这些额外的内存消耗几乎可以忽略不计,但是当数据库键的数量达到上百万、上千万甚至更多的时候,这些额外的内存消耗就会变得比较可观。

当散列包含的字段数量比较少的时候,Redis就会使用特殊的内存优化结构去存储散列中的字段和值。与字符串键相比,这种内存优化结构存储相同数据所需要的内存要少得多。使用内存优化结构的散列越多,内存优化结构的效果也就越明显。在一定条件下,对于相同的数据,使用散列键进行存储比使用字符串键存储要节约一半以上的内存,有时候甚至会更多。

除了需要耗费更多内存之外,更多的数据库键也需要占用更多的CPU。每当Redis需要对数据库中的键进行处理时,数据库包含的键越多,进行处理所需的CPU资源就会越多,处理所耗费的时间也会越长,典型的情况包括:

统计数据库和数据库键的使用情况。

对数据库执行持久化操作,或者根据持久化文件还原数据库。

通过模式匹配在数据库中查找某个键,或者执行类似的查找操作。

这些操作的执行时间都会受到数据库键数量的影响。

最后,除了资源方面的优势之外,散列键还可以有效地组织起相关的多项数据,让程序产生更容易理解的数据,使得针对数据的批量操作变得更方便。比如在上面展示的图3-23中,使用散列键存储文章数据就比使用字符串键存储文章数据更为清晰、易懂。

1.14.2 字符串键的优点

虽然使用散列键可以有效地节约资源并更好地组织数据,但是字符串键也有自己的优点:

虽然散列键命令和字符串键命令在部分功能上有重合的地方,但是字符串键命令提供的操作比散列键命令更为丰富。比如,字符串能够使用SETRANGE命令和GETRANGE命令设置或者读取字符串值的其中一部分,或者使用APPEND命令将新内容追加到字符串值的末尾,而散列键并不支持这些操作。

第12章中将对Redis的键过期功能进行介绍,这一功能可以在指定时间到达时,自动删除指定的键。因为键过期功能针对的是整个键,用户无法为散列中的不同字段设置不同的过期时间,所以当一个散列键过期的时候,它包含的所有字段和值都将被删除。与此相反,如果用户使用字符串键存储信息项,就不会遇到这样的问题——用户可以为每个字符串键分别设置不同的过期时间,让它们根据实际的需要自动被删除。

1.14.3 字符串键和散列键的选择

表3-2从资源占用、支持的操作以及过期时间3个方面对比了字符串键和散列键的优缺点。

表3-2 对比字符串键和散列键

img

既然字符串键和散列键各有优点,那么我们在构建应用程序的时候,什么时候应该使用字符串键,什么时候又该使用散列键呢?对于这个问题,以下总结了一些选择的条件和方法:

如果程序需要为每个数据项单独设置过期时间,那么使用字符串键。

如果程序需要对数据项执行诸如SETRANGE、GETRANGE或者APPEND等操作,那么优先考虑使用字符串键。当然,用户也可以选择把数据存储在散列中,然后将类似SETRANGE、GETRANGE这样的操作交给客户端执行。

如果程序需要存储的数据项比较多,并且你希望尽可能地减少存储数据所需的内存,就应该优先考虑使用散列键。

如果多个数据项在逻辑上属于同一组或者同一类,那么应该优先考虑使用散列键。

1.15 重点回顾

散列键会将一个键和一个散列在数据库中关联起来,用户可以在散列中为任意多个字段设置值。与字符串键一样,散列的字段和值既可以是文本数据,也可以是二进制数据。

用户可以通过散列键把相关联的多项数据存储到同一个散列中,以便对其进行管理,或者针对它们执行批量操作。

因为Redis并没有为散列提供相应的减法操作命令,所以如果用户想对字段存储的数字值执行减法操作,就需要将负数增量传递给HINCRBY命令或HINCRBYFLOAT命令。

Redis散列包含的字段在底层是以无序方式存储的,根据字段插入的顺序不同,包含相同字段的散列在执行HKEYS、HVALS和HGETALL等命令时可能会得到不同的结果,因此用户在使用这3个命令时,不应该对命令返回元素的排列顺序作任何假设。

字符串键和散列键虽然在操作方式上非常相似,但是因为它们都拥有各自独有的优点和缺点,所以在一些情况下,这两种数据结构是没有办法完全代替对方的。因此用户在构建应用程序的时候,应该根据实际需要来选择相应的数据结构。

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

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