散列(Hash)

在前面的《字符串》一章中, 我们曾经看到过如何使用多个字符串键去储存相关联的一组数据。 比如在字符串键实现的文章储存程序中, 程序就会为每篇文章创建四个字符串键, 并把文章的标题、内容、作者和创建时间分别储存到这四个字符串键里面, 图 3-1 就展示了一个使用字符串键储存文章数据的例子。

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

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

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

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

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

散列简介

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

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

  • “title” 字段储存着文章的标题 “greeting” ;

  • “content” 字段储存着文章的内容 “hello world” ;

  • “author” 字段储存着文章的作者名字 “peter” ;

  • “create_at” 字段储存着文章的创建时间 “1442744762.631885” 。

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

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

  • 为散列的字段设置值, 又或者只在字段不存在的情况下为它设置值。

  • 从散列里面获取给定字段的值。

  • 对储存着数字值的字段执行加法操作或者减法操作。

  • 检查给定字段是否存在于散列当中。

  • 从散列里面删除指定字段。

  • 查看散列包含的字段数量。

  • 一次为散列的多个字段设置值, 又或者一次从散列里面获取多个字段的值。

  • 获取散列包含的所有字段、所有值又或者所有字段和值。

本章接下来将对以上提到的散列操作进行介绍, 说明如何使用这些操作去构建各种有用的应用程序, 并在最后详细地说明散列键与字符串键之间的区别。

HSET:为字段设置值

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

1
HSET hash field value

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
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

HSET 命令执行之前的数据库, article::10086 散列并不存在

执行 HSET article::10086 title “greeting” 命令之后

执行 HSET article::10086 content “hello world” 命令之后

执行 HSET article::10086 author “peter” 命令之后

执行 HSET article::10086 created_at “1442744762.631885” 命令之后

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

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

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

1
2
3
4
5
redis> HSET article::10086 title "Redis Tutorial"
(integer) 0

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

被更新之后的 article::10086 散列

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

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

1
HSETNX hash field value

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

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

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

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

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

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

HGET:获取字段的值

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

1
HGET hash field

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

1
2
redis> HGET article::10086 author
"peter"

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

1
2
redis> HGET article::10086 created_at
"1442744762.631885"

又比如说, 如果我们想要从 account::54321 散列里面获取 email 字段的值, 那么可以执行以下命令:

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

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

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

1
2
redis> HGET account::54321 location
(nil)

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

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

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

为了给用户提供更多发言空间, 并记录用户在网站上的链接点击行为, 大部分社交网站都会将用户输入的网址转换为相应的短网址。 比如说, 如果我们在新浪微博发言时输入网址 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 与目标网址之间的映射关系。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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)
# 通过将 10 进制数字转换为 36 进制数字来创建短网址 ID
# 比如说,10 进制数字 10086 将被转换为 36 进制数字 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)

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

1
2
3
4
5
6
7
8
9
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]

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> 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'

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

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

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

1
HINCRBY hash field increment

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

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
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"

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

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

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

redis> HINCRBY article::10086 view_count 3.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

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

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

1
HINCRBYFLOAT hash field increment

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

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

1
2
3
4
5
redis> HGET geo::peter longitude
"100.0099647"

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

增量和字段值的类型限制

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

1
2
3
4
5
redis> HGET number float
"3.14"

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

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

1
2
3
4
5
redis> HGET number int  -- 储存整数的字段
"100"

redis> HINCRBYFLOAT number int 2.56
"102.56"

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

1
2
3
4
5
redis> HGET number sum
"1.5"

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

执行减法操作

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

1
2
3
4
5
redis> HGET geo::peter longitude
"113.2099647"

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

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

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

使用散列实现的计数器:/hash/counter.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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 ,那么将计数器的值加上一。
"""
return self.client.hincrby(self.hash_key, self.counter_name, n)

def decrease(self, n=1):
"""
将计数器的值减去 n ,然后返回计数器当前的值。
如果用户没有显式地指定 n ,那么将计数器的值减去一。
"""
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 命令, 程序可以对指定的计数器执行加法操作和减法操作, 而不会影响到储存在同一散列中的其他计数器。

作为例子, 以下代码展示了怎样将三个页面的浏览次数计数器储存到同一个散列里面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
>>> 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 这三个计数器都是用来记录页面浏览次数的, 所以这些计数器都被放到了 page_view_counters 这个散列里面; 与此类似, 如果我们要创建一些用途完全不一样的计数器, 那么只需要把新的计数器放到其他散列里面就可以了。

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

1
2
3
4
5
6
>>> dragon_rises_counter = Counter(client, "download_counters", "dragon_rises.mp3")
>>> dragon_rises_counter.increase(10086)
10086L
>>> redisbook_counter = Counter(client, "download_counters", "redisbook.pdf")
>>> redisbook_counter.increase(65535)
65535L

展示了 page_view_counters 和 download_counters 这两个散列以及它们包含的各个计数器的样子。

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

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

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

1
HSTRLEN hash field

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

1
2
3
4
5
6
7
8
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 个字节

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

1
2
3
4
5
redis> HSTRLEN article::10086 last_updated_at  -- 字段不存在
(integer) 0

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

HEXISTS:检查字段是否存在

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

1
HEXISTS hash field

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

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

1
2
3
4
5
6
7
8
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 命令对于这个散列所有字段的检查结果都是不存在:

1
2
3
4
5
redis> HEXISTS not-exists-hash not-exists-field
(integer) 0

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

HDEL:删除字段

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

1
HDEL hash field

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

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

1
2
3
4
5
redis> HDEL article::10086 author
(integer) 1

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

展示了以上两个 HDEL 命令执行之后, article::10086 散列的样子。

article::10086 散列

删除了两个字段之后的 article::10086 散列

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

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

1
HLEN hash

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

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

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

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

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

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

示例:实现用户登录会话

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

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

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

使用散列实现的登录会话程序:/hash/login_session.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
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):
"""
根据给定的令牌验证用户身份。
这个方法有四个可能的返回值,分别对应四种不同情况:
1. SESSION_NOT_LOGIN —— 用户尚未登录
2. SESSION_EXPIRED —— 会话已过期
3. SESSION_TOKEN_CORRECT —— 用户已登录,并且给定令牌与用户令牌相匹配
4. 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 创建出相应的会话令牌:

1
2
3
4
5
6
7
8
9
>>> from redis import Redis
>>> from login_session import LoginSession
>>>
>>> client = Redis(decode_responses=True)
>>> session = LoginSession(client, "peter")
>>>
>>> token = session.create()
>>> token
'3b000071e59fcdcaa46b900bb5c484f653de67055fde622f34c255a65bd9a561'

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

1
2
3
4
5
>>> session.validate("wrong_token")
'SESSION_TOKEN_INCORRECT'
>>>
>>> session.validate(token)
'SESSION_TOKEN_CORRECT'

然后在会话使用完毕之后, 通过执行以下代码来销毁会话:

1
2
3
4
>>> session.destroy()
>>>
>>> session.validate(token)
'SESSION_NOT_LOGIN'

展示了使用 LoginSession 程序在数据库里面创建多个会话时的样子。

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

摘抄:Redis使用手册