散列(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 | redis> HSET article::10086 title "greeting" |
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 | redis> HSET article::10086 title "Redis Tutorial" |
被更新之后的 article::10086 散列
HSETNX:只在字段不存在的情况下为它设置值
HSETNX 命令的作用和 HSET 命令的作用非常相似, 它们之间的区别在于, HSETNX 命令只会在指定字段不存在的情况下执行设置操作:
1 | HSETNX hash field value |
HSETNX 命令在字段不存在并且成功为它设置值时返回 1 , 在字段已经存在并导致设置操作未能成功执行时返回 0 。
举个例子, 对于图 3-5 所示的 article::10086 散列来说, 执行以下 HSETNX 命令将不会对散列产生任何影响, 因为 HSETNX 命令想要设置的 title 字段已经存在:
1 | redis> HSETNX article::10086 title "Redis Performance Test" |
相反地, 如果我们使用 HSETNX 命令去对尚未存在的 view_count 字段进行设置, 那么这个命令将会顺利执行, 并将 view_count 字段的值设置为 100 :
1 | redis> HSETNX article::10086 view_count 100 |
展示了 HSETNX 命令成功执行之后的 article::10086 散列。
HGET:获取字段的值
HGET 命令可以根据用户给定的字段, 从散列里面获取该字段的值:
1 | HGET hash field |
比如对于图 3-7 所示的两个散列键来说, 执行以下命令可以从 article::10086 散列里面获取 author 字段的值:
1 | redis> HGET article::10086 author |
而执行以下命令则可以从 article::10086 散列里面获取 created_at 字段的值:
1 | redis> HGET article::10086 created_at |
又比如说, 如果我们想要从 account::54321 散列里面获取 email 字段的值, 那么可以执行以下命令:
1 | redis> HGET account::54321 email |
处理不存在的字段或者不存在的散列
如果用户给定的字段并不存在于散列当中, 那么 HGET 命令将返回一个空值。
举个例子, 在以下代码中, 我们尝试从 account::54321 散列里面获取 location 字段的值, 但由于 location 字段并不存在于 account::54321 散列当中, 所以 HGET 命令将返回一个空值:
1 | redis> HGET account::54321 location |
尝试从一个不存在的散列里面获取一个不存在的字段值, 得到的结果也是一样的:
1 | redis> HGET not-exists-hash not-exists-field |
示例:实现短网址生成程序
为了给用户提供更多发言空间, 并记录用户在网站上的链接点击行为, 大部分社交网站都会将用户输入的网址转换为相应的短网址。 比如说, 如果我们在新浪微博发言时输入网址 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 | from base36 import base10_to_base36 |
代码清单 3-2 将 10 进制数字转换成 36 进制数字的程序:/hash/base36.py
1 | def base10_to_base36(number): |
ShortyUrl 类的 shorten() 方法负责为输入的网址生成短网址 ID , 它的工作包括以下四个步骤:
为每个给定的网址创建一个 10 进制数字 ID 。
将 10 进制数字 ID 转换为 36 进制, 并将这个 36 进制数字用作给定网址的短网址 ID , 这种方法在数字 ID 长度较大时可以有效地缩短数字 ID 的长度。 代码清单 3-2 展示了将数字从 10 进制转换成 36 进制的 base10_to_base36 函数的具体实现。
将短网址 ID 和目标网址之间的映射关系储存到散列里面。
向调用者返回刚刚生成的短网址 ID 。
另一方面, restore() 方法要做的事情和 shorten() 方法正好相反: 它会从储存着映射关系的散列里面取出与给定短网址 ID 相对应的目标网址, 然后将其返回给调用者。
以下代码简单地展示了使用 ShortyUrl 程序创建短网址 ID 的方法, 以及根据短网址 ID 获取目标网址的方法:
1 | from redis import Redis |
展示了上面这段代码在数据库中创建的散列结构。
HINCRBY:对字段储存的整数值执行加法或减法操作
跟字符串键的 INCRBY 命令一样, 如果散列的字段里面储存着能够被 Redis 解释为整数的数字, 那么用户就可以使用 HINCRBY 命令为该字段的值加上指定的整数增量:
1 | HINCRBY hash field increment |
HINCRBY 命令在成功执行加法操作之后将返回字段当前的值作为命令的结果。
比如说, 对于图 3-10 所示的 article::10086 散列, 我们可以通过执行以下命令, 为 view_count 字段的值加上 1 :
1 | redis> HINCRBY article::10086 view_count 1 |
也可以通过执行以下命令, 为 view_count 字段的值加上 30 :
1 | redis> HINCRBY article::10086 view_count 30 |
执行减法操作
因为 Redis 只为散列提供了用于执行加法操作的 HINCRBY 命令, 但是却并没有为散列提供相应的用于执行减法操作的命令, 所以如果用户需要对字段储存的整数值执行减法操作的话, 那么他就需要将一个负数增量传给 HINCRBY 命令, 从而达到对值执行减法计算的目的。
以下代码展示了如何使用 HINCRBY 命令去对 view_count 字段储存的整数值执行减法计算:
1 | redis> HGET article::10086 view_count -- 文章现在的浏览次数为 131 次 |
处理异常情况
HINCRBY 命令只能对储存着整数值的字段执行, 并且用户给定的增量也必须为整数, 尝试对非整数值字段执行 HINCRBY 命令, 又或者向 HINCRBY 命令提供非整数增量, 都会导致 HINCRBY 命令拒绝执行并报告错误。
以下是一些导致 HINCRBY 命令报错的例子:
1 | redis> HINCRBY article::10086 view_count "fifty" -- 增量必须能够被解释为整数 |
HINCRBYFLOAT:对字段储存的数字值执行浮点数加法或减法操作
HINCRBYFLOAT 命令的作用和 HINCRBY 命令的作用类似, 它们之间的主要区别在于 HINCRBYFLOAT 命令不仅可以使用整数作为增量, 还可以使用浮点数作为增量:
1 | HINCRBYFLOAT hash field increment |
HINCRBYFLOAT 命令在成功执行加法操作之后, 将返回给定字段的当前值作为结果。
举个例子, 通过执行以下 HINCRBYFLOAT 命令, 我们可以将 geo::peter 散列 longitude 字段的值从原来的 100.0099647 修改为 113.2099647 :
1 | redis> HGET geo::peter longitude |
增量和字段值的类型限制
正如之前所说, HINCRBYFLOAT 命令不仅可以使用浮点数作为增量, 还可以使用整数作为增量:
1 | redis> HGET number float |
此外, 不仅储存浮点数的字段可以执行 HINCRBYFLOAT 命令, 储存整数的字段也一样可以执行 HINCRBYFLOAT 命令:
1 | redis> HGET number int -- 储存整数的字段 |
最后, 如果加法计算的结果能够被表示为整数, 那么 HINCRBYFLOAT 命令将使用整数作为计算结果:
1 | redis> HGET number sum |
执行减法操作
跟 HINCRBY 命令的情况一样, Redis 也没有为 HINCRBYFLOAT 命令提供对应的减法操作命令, 因此如果我们想要对字段储存的数字值执行浮点数减法操作, 那么只能通过向 HINCRBYFLOAT 命令传入负值浮点数来实现:
1 | redis> HGET geo::peter longitude |
示例:使用散列键重新实现计数器
前面的《字符串》一章曾经展示过怎样使用 INCRBY 命令和 DECRBY 命令去构建一个计数器程序, 在学习了 HINCRBY 命令之后, 我们同样可以通过类似的原理来构建一个使用散列实现的计数器程序, 就像代码清单 3-3 展示的那样。
使用散列实现的计数器:/hash/counter.py
1 | class Counter: |
这个计数器实现充分地发挥了散列的特长:
它允许用户将多个相关联的计数器储存到同一个散列键里面实行集中管理, 而不必像字符串计数器那样, 为每个计数器单独设置一个字符串键。
与此同时, 通过对散列中的不同字段执行 HINCRBY 命令, 程序可以对指定的计数器执行加法操作和减法操作, 而不会影响到储存在同一散列中的其他计数器。
作为例子, 以下代码展示了怎样将三个页面的浏览次数计数器储存到同一个散列里面:
1 | >>> from redis import Redis |
因为 user_peter_counter 、 product_256_counter 和 product_512_counter 这三个计数器都是用来记录页面浏览次数的, 所以这些计数器都被放到了 page_view_counters 这个散列里面; 与此类似, 如果我们要创建一些用途完全不一样的计数器, 那么只需要把新的计数器放到其他散列里面就可以了。
比如说, 以下代码就展示了怎样将文件 dragon_rises.mp3 和文件 redisbook.pdf 的下载次数计数器放到 download_counters 散列里面:
1 | >>> dragon_rises_counter = Counter(client, "download_counters", "dragon_rises.mp3") |
展示了 page_view_counters 和 download_counters 这两个散列以及它们包含的各个计数器的样子。
通过使用不同的散列储存不同类型的计数器, 程序能够让代码生成的数据结构变得更容易理解, 并且在针对某种类型的计数器执行批量操作时也会变得更加方便。 比如说, 当我们不再需要下载计数器的时候, 只要把 download_counters 散列删掉就可以移除所有下载计数器了。
HSTRLEN:获取字段值的字节长度
用户可以通过使用 HSTRLEN 命令, 获取给定字段值的字节长度:
1 | HSTRLEN hash field |
比如对于图 3-12 所示的 article::10086 散列来说, 我们可以通过执行以下 HSTRLEN 命令, 取得 title 、 content 、 author 等字段值的字节长度:
1 | redis> HSTRLEN article::10086 title |
如果给定的字段或散列并不存在, 那么 HSTRLEN 命令将返回 0 作为结果:
1 | redis> HSTRLEN article::10086 last_updated_at -- 字段不存在 |
HEXISTS:检查字段是否存在
HEXISTS 命令可以检查用户给定的字段是否存在于散列当中:
1 | HEXISTS hash field |
如果散列包含了给定的字段, 那么命令返回 1 ; 否则的话, 命令返回 0 。
比如说, 以下代码就展示了如何使用 HEXISTS 命令去检查 article::10086 散列是否包含某些字段:
1 | redis> HEXISTS article::10086 author |
从 HEXISTS 命令的执行结果可以看出, article::10086 散列包含了 author 字段和 content 字段, 但是却并没有包含 last_updated_at 字段。
如果用户给定的散列并不存在, 那么 HEXISTS 命令对于这个散列所有字段的检查结果都是不存在:
1 | redis> HEXISTS not-exists-hash not-exists-field |
HDEL:删除字段
HDEL 命令用于删除散列中的指定字段及其相关联的值:
1 | HDEL hash field |
当给定字段存在于散列当中并且被成功删除时, 命令返回 1 ; 如果给定字段并不存在于散列当中, 又或者给定的散列并不存在, 那么命令将返回 0 表示删除失败。
举个例子, 对于图 3-13 所示的 article::10086 散列, 我们可以使用以下命令去删除散列的 author 字段和 created_at 字段, 以及与这些字段相关联的值:
1 | redis> HDEL article::10086 author |
展示了以上两个 HDEL 命令执行之后, article::10086 散列的样子。
article::10086 散列
删除了两个字段之后的 article::10086 散列
HLEN:获取散列包含的字段数量
用户可以通过使用 HLEN 命令获取给定散列包含的字段数量:
1 | HLEN hash |
比如对于图 3-15 中展示的 article::10086 散列和 account::54321 散列来说, 我们可以通过执行以下命令来获取 article::10086 散列包含的字段数量:
1 | redis> HLEN article::10086 |
又或者通过执行以下命令来获取 account::54321 散列包含的字段数量:
1 | redis> HLEN account::54321 |
另一方面, 如果用户给定的散列并不存在, 那么 HLEN 命令将返回 0 作为结果:
1 | redis> HLEN not-exists-hash |
示例:实现用户登录会话
为了方便用户, 网站一般都会为已登录的用户生成一个加密令牌, 然后把这个令牌分别储存在服务器端和客户端, 之后每当用户再次访问该网站的时候, 网站就可以通过验证客户端提交的令牌来确认用户的身份, 从而使得用户不必重复地执行登录操作。
另一方面, 为了防止用户因为长时间不输入密码而导致遗忘密码, 并且为了保证令牌的安全性, 网站一般都会为令牌设置一个过期期限(比如一个月), 当期限到达之后, 用户的会话就会过时, 而网站则会要求用户重新登录。
上面描述的这种使用令牌来避免重复登录的机制一般被称为登录会话(login session), 通过使用 Redis 的散列, 我们可以构建出代码清单 3-4 所示的登录会话程序。
使用散列实现的登录会话程序:/hash/login_session.py
1 | import random |
LoginSession 的 create() 方法首先会计算出随机的会话令牌以及会话的过期时间戳, 然后使用用户 ID 作为字段, 将令牌和过期时间戳分别储存到两个散列里面。
在此之后, 每当客户端向服务器发送请求并提交令牌的时候, 程序就会使用 validate() 方法验证被提交令牌的正确性: validate() 方法会根据用户的 ID , 从两个散列里面分别取出用户的会话令牌以及会话的过期时间戳, 然后通过一系列检查判断令牌是否正确以及会话是否过期。
最后, destroy() 方法可以在用户手动登出(logout)时调用, 它可以删除用户的会话令牌以及会话的过期时间戳, 让用户重新回到未登录状态。
在拥有 LoginSession 程序之后, 我们可以通过执行以下代码, 为用户 peter 创建出相应的会话令牌:
1 | from redis import Redis |
并通过以下代码, 验证给定令牌的正确性:
1 | "wrong_token") session.validate( |
然后在会话使用完毕之后, 通过执行以下代码来销毁会话:
1 | session.destroy() |
展示了使用 LoginSession 程序在数据库里面创建多个会话时的样子。
登录会话程序数据结构示意图
摘抄:Redis使用手册