《Redis使用手册》笔记 —— 代码均使用Python实现

本书配套代码

本书配套代码

Redis的特色与请求响应

a532a967b722835fd686310f18fb52be.png

962e88d54074e21d9cef29fcb589d24f.png

Python操作redis及相关汇总

Python操作redis
个人总结合集
Mac中redis的安装配置及图形化工具的下载与使用

字符串

字符串命令补充

1、GETSET

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

127.0.0.1:6379> set name1 whw
OK
127.0.0.1:6379> getset name1 www
"whw"
127.0.0.1:6379> get name1
"www"

2、MSETNX

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

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

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

  • 获取操作用于取得资源的独占使用权。在任何时候,最多只能有一个进程取得锁,我们把成功取得锁的这个进程称为锁的持有者。在锁已经被持有的情况下,所有尝试再次获取锁的操作都会失败。
  • 释放操作用于放弃资源的独占使用权,一般由锁的持有者调用。在锁被释放之后,其他进程就可以再次尝试获取这个锁了。

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

from redis import Redis


VALUE_OF_LOCK = "locking"

class Lock(object):

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

    def acquire(self):
        """
        尝试获取锁 成功返回True 失败返回False
        """
        # nx选项的值确保了代表锁的字符串键只会在 没有值 的情况下被设置
        result = self.client.set(self.key,VALUE_OF_LOCK,nx=True)
        ret = result is True
        return ret

    def release(self):
        """
        尝试释放锁 删除成功返回True 删除
        失败返回False
        """
        ret = self.client.delete(self.key)
        res = (ret == 1)
        return res
        
client = Redis(decode_response=True)
lock = Lock(client,"test_lock")

lock.acquire() # 成功获取锁  True
lock.acquire() # 锁已被获取 无法再次获取 False

lock.release() # 释放锁
lock.acquire() # 锁释放后还能被获取 True

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

result = self.client.set(self.key,VALUE_OF_LOCK,nx=True)
  • 如果给定的字符串键没有值,那么说明锁尚未被获取,SET命令将执行设置操作,并将result变量的值设置为True。
  • 如果给定的字符串键已经有值了,那么说明锁已经被获取,SET命令将放弃执行设置操作,并将result变量的值设置为None。

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

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

ret = self.client.delete(self.key)
res = (ret == 1)
return res

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

上述代码存在问题:

  • 因为这个锁的释放操作无法验证进程的身份,所以无论执行释放操作的进程是否为锁的持有者,锁都会被释放。如果锁被持有者以外的其他进程释放,那么系统中可能会同时出现多个锁,导致锁的唯一性被破坏。
  • 这个锁的获取操作不能设置最大加锁时间,因而无法让锁在超过给定的时限之后自动释放。因此,如果持有锁的进程因为故障或者编程错误而没有在退出之前主动释放锁,那么锁就会一直处于已被获取的状态,导致其他进程永远无法取得锁。

缓存文章信息

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

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

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

# -*- coding:utf-8 -*-
from time import time  # time() 函数用于获取当前 Unix 时间戳

class Article:

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

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

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

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

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

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

键的命名格式

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

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

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

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

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

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

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

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

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

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

class Article(object):
    
    # 省略之前的 init create update等方法
    
    # 返回文章内容的字节长度
    def get_content_len(self):
        return self.clieent.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)

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

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

APPEND key sjffix

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

redis> get name1
"whw"
redis> append name1 "666"
(integer) 6

处理不存在的key

如果用户给定的键并不存在,那么APPEND命令会先将键的值初始化为空字符串"",然后再执行追加操作。

fed4e0bc38e53ce73c1d4d0e047c22c3.png

使用字符串键存储数字值

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

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

838c6e58f0d8f38f221ada2dd044e12f.png

增减

INCRBYDECRBY:对整数值执行加法操作和减法操作

key不存在时

e65c210584bf31a7b46c16770dbfb6a3.png

ID生成器

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

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

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

class IdGenerator(object):

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

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

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

*计数器 —— 没有加锁

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

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

# -*- coding:utf-8 -*-
class Counter:

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

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

    def decrease(self, n=1):
        """
        将计数器的值减去 n ,然后返回计数器当前的值。
        如果用户没有显式地指定 n ,那么将计数器的值减去一。
        """
        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)

*限速器

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

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

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

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

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

import redis

# 连接池 实际中可以做成一个基于模块导入的单例
POOL = redis.ConnectionPool(host="127.0.0.1",port=6379,max_connections=10000)
client = redis.Redis(connection_pool=POOL)


#coding:utf-8

class Limiter:

    def __init__(self, client, limiter_name):
        self.client = client
        self.max_execute_times_key = limiter_name + '::max_execute_times'
        self.current_execute_times_key = limiter_name + '::current_execute_times'

    def set_max_execute_times(self, n):
        """
        设置操作的最大可执行次数。
        """
        self.client.set(self.max_execute_times_key, n)
        # 初始化操作的已执行次数为 0
        self.client.set(self.current_execute_times_key, 0)

    def get_max_execute_times(self):
        """
        返回操作的最大可执行次数。
        """
        return int(self.client.get(self.max_execute_times_key))

    def get_current_execute_times(self):
        """
        返回操作的当前已执行次数。
        """
        current_execute_times = int(self.client.get(self.current_execute_times_key))
        max_execute_times = self.get_max_execute_times()

        if current_execute_times > max_execute_times:
            # 当用户尝试执行操作的次数超过最大可执行次数时
            # current_execute_times 的值就会比 max_execute_times 的值更大
            # 为了将已执行次数的值保持在 
            # 0 <= current_execute_times <= max_execute_times 这一区间
            # 如果已执行次数已经超过最大可执行次数
            # 那么程序将返回最大可执行次数作为结果
            return max_execute_times
        else:
            # 否则的话,返回真正的当前已执行次数作为结果
            return current_execute_times

    def still_valid_to_execute(self):
        """
        检查是否可以继续执行被限制的操作,
        是的话返回 True ,不是的话返回 False 。
        """
        updated_current_execute_times = self.client.incr(self.current_execute_times_key)
        max_execute_times = self.get_max_execute_times()
        return (updated_current_execute_times <= max_execute_times)

    def remaining_execute_times(self):
        """
        返回操作的剩余可执行次数。
        """
        current_execute_times = self.get_current_execute_times()
        max_execute_times = self.get_max_execute_times()
        return max_execute_times - current_execute_times

    def reset_current_execute_times(self):
        """
        清零操作的已执行次数。
        """
        self.client.set(self.current_execute_times_key, 0)

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

字符串重点 ***

  • 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类型的整数以及能够被表示为longdouble类型的浮点数当作数字来处理。

散列

散列命令补充

HSETNX

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

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

HSETNX stu:1 name whw

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

# 失败
127.0.0.1:6379> hsetnx stu:1 name whw
(integer) 0
# 失败
127.0.0.1:6379> hsetnx stu:1 age 22
(integer) 0
# 成功
127.0.0.1:6379> hsetnx stu:1 gender male
(integer) 1

实现短网址生成程序

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

创建短网址本质上就是要创建出短网址ID与目标网址之间的映射,并在用户访问短网址时,根据短网址的ID从映射记录中找出与之相对应的目标网址。

b6e7802f8f95b8ccecc8ecca288c654c.png

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

### 添加缓存功能

#coding:utf-8

from cache import Cache
from base36 import base10_to_base36

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

### 将10进制转换为36进制
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]

### 实现短网址的类
class ShortyUrl:

    def __init__(self, client):
        self.client = client
        self.cache = Cache(self.client, URL_CACHE)  # 创建缓存对象

    def shorten(self, target_url):
        """
        为目标网址创建一个短网址 ID 。
        """
        # 尝试在缓存里面寻找目标网址对应的短网址 ID
        cached_short_id = self.cache.get(target_url)
        if cached_short_id is not None:
            return cached_short_id

        ### 如果没有的话就在之前的id的基础上增1 新建一个新的ID!!!
        new_id = self.client.incr(ID_COUNTER)
        short_id = base10_to_base36(new_id)
        self.client.hset(URL_HASH, short_id, target_url)
        # 在缓存里面关联起目标网址和短网址 ID
        # 这样程序就可以在用户下次输入相同的目标网址时
        # 直接重用已有的短网址 ID
        self.cache.set(target_url, short_id)
        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的长度。代码清单展示了将数字从十进制转换成三十六进制的base10_to_base36函数的具体实现。

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

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

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

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

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命令,程序可以对指定的计数器执行加法操作和减法操作,而不会影响到存储在同一散列中的其他计数器。

实现用户登陆会话 ***

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

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

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

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)时调用,它可以删除用户的会话令牌以及会话的过期时间戳,让用户重新回到未登录状态。

57394a28e39372aacbbb3992b9ce1444.png

存储图数据 ***

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

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

87e52af2cbd90f144bcbc6deb10bcb45.png

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, hkey):
        self.client = client
        self.key = hkey

    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命令获取图的所有边以及权重。

使用散列键实现文章存储程序 **

比起用多个字符串键来存储文章的各项数据,更好的做法是把每篇文章的所有数据都存储到同一个散列中

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作为标题的字段,而不必担心出现命名冲突。

散列与字符串 ***

0f05f1e8c11660e14390b3f06941362c.png

对于表中列出的字符串命令和散列命令来说,它们之间的最大区别就是前者处理的是字符串键,而后者处理的则是散列键,除此之外,这些命令要做的事情几乎都是相同的。

散列键的优势

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

6999fbf9db30099fa4c8dcaf08815050.png

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

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

  • 为了对数据库以及数据库键的使用情况进行统计,Redis会为每个数据库键存储一些额外的信息,并因此带来一些额外的内存消耗。对于单个数据库键来说,这些额外的内存消耗几乎可以忽略不计,但是当数据库键的数量达到上百万、上千万甚至更多的时候,这些额外的内存消耗就会变得比较可观。
  • 当散列包含的字段数量比较少的时候,Redis就会使用特殊的内存优化结构去存储散列中的字段和值。与字符串键相比,这种内存优化结构存储相同数据所需要的内存要少得多。使用内存优化结构的散列越多,内存优化结构的效果也就越明显。在一定条件下,对于相同的数据,使用散列键进行存储比使用字符串键存储要节约一半以上的内存,有时候甚至会更多。
  • 除了需要耗费更多内存之外,更多的数据库键也需要占用更多的CPU。每当Redis需要对数据库中的键进行处理时,数据库包含的键越多,进行处理所需的CPU资源就会越多,处理所耗费的时间也会越长。典型的情况包括:
    • 统计数据库和数据库键的使用情况。
    • 对数据库执行持久化操作,或者根据持久化文件还原数据库。
    • 通过模式匹配在数据库中查找某个键,或者执行类似的查找操作。

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

字符串键的有点

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

  • 虽然散列键命令和字符串键命令在部分功能上有重合的地方,但是字符串键命令提供的操作比散列键命令更为丰富。比如,字符串能够使用SETRANGE命令和GETRANGE命令设置或者读取字符串值的其中一部分,或者使用APPEND命令将新内容追加到字符串值的末尾,而散列键并不支持这些操作。
  • 第12章中将对Redis的键过期功能进行介绍,这一功能可以在指定时间到达时,自动删除指定的键。因为键过期功能针对的是整个键,用户无法为散列中的不同字段设置不同的过期时间,所以当一个散列键过期的时候,它包含的所有字段和值都将被删除。与此相反,如果用户使用字符串键存储信息项,就不会遇到这样的问题——用户可以为每个字符串键分别设置不同的过期时间,让它们根据实际的需要自动被删除。

字符串键与散列键的选择

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

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

  • 如果程序需要为每个数据项单独设置过期时间,那么使用字符串键。
  • 如果程序需要对数据项执行诸如SETRANGE、GETRANGE或者APPEND等操作,那么优先考虑使用字符串键。当然,用户也可以选择把数据存储在散列中,然后将类似SETRANGE、GETRANGE这样的操作交给客户端执行。
  • 如果程序需要存储的数据项比较多,并且你希望尽可能地减少存储数据所需的内存,就应该优先考虑使用散列键。
  • 如果多个数据项在逻辑上属于同一组或者同一类,那么应该优先考虑使用散列键。

散列键重点 ***

  • 散列键会将一个键和一个散列在数据库中关联起来,用户可以在散列中为任意多个字段设置值。与字符串键一样,散列的字段和值既可以是文本数据,也可以是二进制数据。
  • 用户可以通过散列键把相关联的多项数据存储到同一个散列中,以便对其进行管理,或者针对它们执行批量操作。
  • 因为Redis并没有为散列提供相应的减法操作命令,所以如果用户想对字段存储的数字值执行减法操作,就需要将负数增量传递给HINCRBY命令或HINCRBYFLOAT命令。
  • Redis散列包含的字段在底层是以无序方式存储的,根据字段插入的顺序不同,包含相同字段的散列在执行HKEYS、HVALS和HGETALL等命令时可能会得到不同的结果,因此用户在使用这3个命令时,不应该对命令返回元素的排列顺序作任何假设。
  • 字符串键和散列键虽然在操作方式上非常相似,但是因为它们都拥有各自独有的优点和缺点,所以在一些情况下,这两种数据结构是没有办法完全代替对方的。因此用户在构建应用程序的时候,应该根据实际需要来选择相应的数据结构。

列表

LPUSHX、RPUSHX:只对已存在的列表执行推入操作

# 不存在不会lpush
127.0.0.1:6379> lpushx lst1 whw naruto sasuke
(integer) 0
127.0.0.1:6379> lpush lst1 whw
(integer) 1
# 存在的话才会lpush
127.0.0.1:6379> lpushx lst1  naruto sasuke
(integer) 3
127.0.0.1:6379> lrange lst1 0 -1
1) "sasuke"
2) "naruto"
3) "whw"

先进先出队列

class FIFOqueue:

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

    def enqueue(self, item):
        """
        将给定元素放入队列,然后返回队列当前包含的元素数量作为结果。
        """
        return self.client.rpush(self.key, item)

    def dequeue(self):
        """
        移除并返回队列目前入队时间最长的元素。
        """
        return self.client.lpop(self.key)

分页

对于互联网上每一个具有一定规模的网站来说,分页程序都是必不可少的:新闻站点、博客、论坛、搜索引擎等,都会使用分页程序将数量众多的信息分割为多个页面,使得用户可以以页为单位浏览网站提供的信息,并以此来控制网站每次取出的信息数量。

代码清单展示了一个使用列表实现分页程序的方法,这个程序可以将给定的元素有序地放入一个列表中,然后使用LRANGE命令从列表中取出指定数量的元素,从而实现分页这一概念。

class Paging:

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

    def add(self, item):
        """
        将给定元素添加到分页列表中。
        """
        self.client.lpush(self.key, item)

    def get_page(self, page_number, item_per_page):
        """
        从指定页数中取出指定数量的元素。
        """
        # 根据给定的 page_number (页数)和 item_per_page (每页包含的元素数量)
        # 计算出指定分页元素在列表中所处的索引范围
        # 例子:如果 page_number = 1 , item_per_page = 10
        # 那么程序计算得出的起始索引就是 0 ,而结束索引则是 9
        start_index = (page_number - 1) * item_per_page
        end_index = page_number * item_per_page - 1
        # 根据索引范围从列表中获取分页元素
        return self.client.lrange(self.key, start_index, end_index)

    def size(self):
        """
        返回列表目前包含的分页元素数量。
        """
        return self.client.llen(self.key)

待办事项列表

现在很多人都会使用待办事项软件(也就是通常说的TODO软件)来管理日常工作,这些软件通常会提供一些列表,用户可以将要做的事情记录在待办事项列表中,并将已经完成的事项放入已完成事项列表中。

代码清单展示了一个使用列表实现的待办事项程序,这个程序的核心概念是使用两个列表来分别记录待办事项和已完成事项:

  • 当用户添加一个新的待办事项时,程序就把这个事项放入待办事项列表中。
  • 当用户完成待办事项列表中的某个事项时,程序就把这个事项从待办事项列表中移除,并放入已完成事项列表中。

totolist1

def make_todo_list_key(user_id):
    """
    储存待办事项的列表。
    """
    return user_id + "::todo_list"

def make_done_list_key(user_id):
    """
    储存已完成事项的列表。
    """
    return user_id + "::done_list"


class TodoList:

    def __init__(self, client, user_id):
        self.client = client
        self.user_id = user_id
        self.todo_list = make_todo_list_key(self.user_id)
        self.done_list = make_done_list_key(self.user_id)

    def add(self, event):
        """
        将指定事项添加到待办事项列表中。
        """
        self.client.lpush(self.todo_list, event)

    def remove(self, event):
        """
        从待办事项列表中移除指定的事项。
        """
        self.client.lrem(self.todo_list, 0, event)

    def done(self, event):
        """
        将待办事项列表中的指定事项移动到已完成事项列表,
        以此来表示该事项已完成。
        """
        # 从待办事项列表中移除指定事项
        self.remove(event)
        # 并将它添加到已完成事项列表中
        self.client.lpush(self.done_list, event)

    def show_todo_list(self):
        """
        列出所有待办事项。
        """
        return self.client.lrange(self.todo_list, 0, -1)

    def show_done_list(self):
        """
        列出所有已完成事项。
        """
        return self.client.lrange(self.done_list, 0, -1)

todolist2

#coding:utf-8

def make_event_key(event_id):
    return "TodoList::event::" + str(event_id)

def make_todolist_key(user_id):
    return "TodoList::todo_events::" + str(user_id)

def make_donelist_key(user_id):
    return "TodoList::done_events::" + str(user_id)


class Event:

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

    def set(self, title, content="", category="", due_date=""):
        """
        设置待办事项的各项信息,并在设置成功时返回 True 。
        """
        data = {
            "title": title,
            "content": content,
            "category": category,
            "due_date": due_date
        }
        return self.client.hmset(self.key, data)

    def get(self):
        """
        获取待办事项的各项信息,并以字典方式返回这些信息。
        """
        # 获取信息
        event_data = self.client.hgetall(self.key)
        # 将待办事项的 ID 也添加到被返回的信息中,方便查询
        event_data["id"] = self.id
        return event_data


class TodoList:

    def __init__(self, client, user_id):
        self.client = client
        # 根据用户的 ID ,创建出代办事项列表和已完成事项列表
        self.todolist = make_todolist_key(user_id)
        self.donelist = make_donelist_key(user_id)
        # 待办事项的 ID 生成器
        self.event_id = "TodoList::event_id"

    def add(self, title, content="", category="", due_date=""):
        """
        添加新的待办事项,并返回该事项的 ID 。
        """
        # 为新的待办事项生成 ID
        new_event_id = self.client.incr(self.event_id)
        # 设置待办事项的相关信息
        new_event = Event(self.client, new_event_id)
        new_event.set(title, content, category, due_date)
        # 将待办事项的 ID 添加到待办事项列表中
        self.client.rpush(self.todolist, new_event_id)
        return new_event_id

    def remove(self, event_id):
        """
        移除指定的待办事项,
        移除成功时返回 True ,失败时返回 False 。
        """
        self.client.lrem(self.todolist, event_id, 0)

    def done(self, event_id):
        """
        将指定的待办事项设置为已完成。
        """
        self.client.lrem(self.todolist, event_id, 0)
        self.client.lpush(self.donelist, event_id)

    def show_todo_list(self):
        """
        列出用户的所有待办事项的 ID 。
        """
        return self.client.lrange(self.todolist, 0, -1)

    def show_done_list(self):
        """
        列出用户的所有已完成事项的 ID 。
        """
        return self.client.lrange(self.donelist, 0, -1)

BLPOP:阻塞式左端弹出操作

BLPOP命令是带有阻塞功能的左端弹出操作,它接受任意多个列表以及一个秒级精度的超时时限作为参数:

BLPOP list [list ...] timeout

BLPOP命令会按照从左到右的顺序依次检查用户给定的列表,并对最先遇到的非空列表执行左端元素弹出操作。如果BLPOP命令在检查了用户给定的所有列表之后都没有发现可以执行弹出操作的非空列表,那么它将阻塞执行该命令的客户端并开始等待,直到某个给定列表变为非空,又或者等待时间超出给定时限为止。

当BLPOP命令成功对某个非空列表执行了弹出操作之后,它将向用户返回一个包含两个元素的数组:数组的第一个元素记录了执行弹出操作的列表,即被弹出元素的来源列表,而数组的第二个元素则是被弹出元素本身。

# 返回的第一个值是弹出元素的列表lst1。第二个元素sasuke源于lst1
127.0.0.1:6379> BLPOP lst1 lst2 5
1) "lst1"
2) "sasuke"

解除阻塞状态

正如前面所说,当BLPOP命令发现用户给定的所有列表都为空时,就会让执行命令的客户端进入阻塞状态。如果在客户端被阻塞的过程中,有另一个客户端向导致阻塞的列表推入了新的元素,那么该列表就会变为非空,而被阻塞的客户端也会随着BLPOP命令成功弹出列表元素而重新回到非阻塞状态。

48858159a97dd0dcc8d460792c1e1160.png

如果在同一时间,有多个客户端因为同一个列表而被阻塞,那么当导致阻塞的列表变为非空时,服务器将按照“先阻塞先服务”的规则,依次为被阻塞的各个客户端弹出列表元素。

962395f09833cd508e8d6883c4d73b02.png

最后,如果被推入列表的元素数量少于被阻塞的客户端数量,那么先被阻塞的客户端将会先解除阻塞,而未能解除阻塞的客户端则需要继续等待下次推入操作。

比如,如果有5个客户端因为列表为空而被阻塞,但是推入列表的元素只有3个,那么最先被阻塞的3个客户端将会解除阻塞状态,而剩下的2个客户端则会继续阻塞。

处理空列表

如果用户向BLPOP命令传入的所有列表都是空列表,并且这些列表在给定的时限之内一直没有变成非空列表,那么BLPOP命令将在给定时限到达之后向客户端返回一个空值,表示没有任何元素被弹出:

127.0.0.1:6379> BLPOP lst3 lst4 3
(nil)
(3.02s)

带有阻塞功能的消息队列

在构建应用程序的时候,有时会遇到一些非常耗时的操作,比如发送邮件,将一条新微博同步给上百万个用户,对硬盘进行大量读写,执行庞大的计算等。因为这些操作非常耗时,所以如果我们直接在响应用户请求的过程中执行它们,那么用户就需要等待非常长时间。

例如,为了验证用户身份的有效性,有些网站在注册新用户的时候,会向用户给定的邮件地址发送一封激活邮件,用户只有在点击了验证邮件里面的激活链接之后,新注册的账号才能够正常使用。

代码清单展示了一个使用Redis实现的消息队列,它使用RPUSH命令将消息推入队列,并使用BLPOP命令从队列中取出待处理的消息。

class MessageQueue:

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

    def add_message(self, message):
        """
        将一条消息放入到队列里面。
        """
        self.client.rpush(self.queue_name, message)

    def get_message(self, timeout=0):
        """
        从队列里面获取一条消息,
        如果暂时没有消息可用,那么就在 timeout 参数指定的时限内阻塞并等待可用消息出现。

        timeout 参数的默认值为 0 ,表示一直等待直到消息出现为止。
        """
        # blpop 的结果可以是 None ,也可以是一个包含两个元素的元组
        # 元组的第一个元素是弹出元素的来源队列,而第二个元素则是被弹出的元素!!!
        result = self.client.blpop(self.queue_name, timeout)
        if result is not None:
            source_queue, poped_item = result
            return poped_item

    def len(self):
        """
        返回队列目前包含的消息数量。
        """
        return self.client.llen(self.queue_name)

使用消息队列实现实时提醒

消息队列除了可以在应用程序的内部使用,还可以用于实现面向用户的实时提醒系统。

比如,如果我们在构建一个社交网站,那么可以使用JavaScript脚本,让客户端以异步的方式调用MessageQueue类的get_message()方法,然后程序就可以在用户被关注的时候、收到了新回复的时候或者收到新私信的时候,通过调用add_message()方法来向用户发送提醒信息。

列表重点 ***

  • Redis的列表是一种线性的有序结构,可以按照元素推入列表中的顺序来存储元素,并且列表中的元素可以重复出现。
  • 用户可以使用LPUSH、RPUSH、RPOP、LPOP等多个命令,从列表的两端推入或者弹出元素,也可以通过LINSERT命令将新元素插入列表已有元素的前面或后面。
  • 用户可以使用LREM命令从列表中移除指定的元素,或者直接使用LTRIM命令对列表进行修剪。
  • 当用户传给LRANGE命令的索引范围超出了列表的有效索引范围时,LRANGE命令将对传入的索引范围进行修正,并根据修正后的索引范围来获取列表元素。
  • BLPOP、BRPOP和BRPOPLPUSH是阻塞版本的弹出和推入命令,如果用户给定的所有列表都为空,那么执行命令的客户端将被阻塞,直到给定的阻塞时限到达或者某个给定列表非空为止。

集合

集合实现唯一计数器 *

在前面对字符串键以及散列键进行介绍的时候,曾经展示过如何使用这两种键去实现计数器程序。我们当时实现的计数器都非常简单:每当某个动作被执行时,程序就可以调用计数器的加法操作或者减法操作,对动作的执行次数进行记录。

以上这种简单的计数行为在大部分情况下都是有用的,但是在某些情况下,我们需要一种要求更为严格的计数器,这种计数器只会对特定的动作或者对象进行一次计数而不是多次计数。

举个例子,一个网站的受欢迎程度通常可以用浏览量和用户数量这两个指标进行描述:

  • 浏览量记录的是网站页面被用户访问的总次数,网站的每个用户都可以重复地对同一个页面进行多次访问,而这些访问会被浏览量计数器一个不漏地记下来。
  • 用户数量记录的是访问网站的IP地址数量,即使同一个IP地址多次访问相同的页面,用户数量计数器也只会对这个IP地址进行一次计数。

对于网站的浏览量,我们可以继续使用字符串键或者散列键实现的计数器进行计数,但如果我们想要记录网站的用户数量,就需要构建一个新的计数器,这个计数器对于每个特定的IP地址只会进行一次计数,我们把这种对每个对象只进行一次计数的计数器称为唯一计数器(unique counter)。

代码清单展示了一个使用集合实现的唯一计数器,这个计数器通过把被计数的对象添加到集合来保证每个对象只会被计数一次,然后通过获取集合的大小来判断计数器目前总共对多少个对象进行了计数。

class UniqueCounter:

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

    def count_in(self, item):
        """
        尝试将给定元素计入到计数器当中:
        如果给定元素之前没有被计数过,那么方法返回 True 表示此次计数有效;
        如果给定元素之前已经被计数过,那么方法返回 False 表示此次计数无效。
        """
        return self.client.sadd(self.key, item) == 1

    def get_result(self):
        """
        返回计数器的值。
        """
        return self.client.scard(self.key)

集合示例:打标签 *

为了对网站上的内容进行分类标识,很多网站都提供了打标签(tagging)功能。比如论坛可能会允许用户为帖子添加标签,这些标签既可以对帖子进行归类,又可以让其他用户快速地了解到帖子要讲述的内容。再比如,一个图书分类网站可能会允许用户为自己收藏的每一本书添加标签,使得用户可以快速地找到被添加了某个标签的所有图书,并且网站还可以根据用户的这些标签进行数据分析,从而帮助用户找到他们可能感兴趣的图书,除此之外,购物网站也可以为自己的商品加上标签,比如“新上架”“热销中”“原装进口”等,方便顾客了解每件商品的不同特点和属性。类似的例子还有很多。

代码清单展示了一个使用集合实现的打标签程序,通过这个程序,我们可以为不同的对象添加任意多个标签:同一个对象的所有标签都会被放到同一个集合里面,集合里的每一个元素就是一个标签。

def make_tag_key(item):
    return item + "::tags"

class Tagging:

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

    def add(self, *tags):
        """
        为对象添加一个或多个标签。
        """
        self.client.sadd(self.key, *tags)

    def remove(self, *tags):
        """
        移除对象的一个或多个标签。
        """
        self.client.srem(self.key, *tags)

    def is_included(self, tag):
        """
        检查对象是否带有给定的标签,
        是的话返回 True ,不是的话返回 False 。
        """
        return self.client.sismember(self.key, tag)

    def get_all_tags(self):
        """
        返回对象带有的所有标签。
        """
        return self.client.smembers(self.key)

    def count(self):
        """
        返回对象带有的标签数量。
        """
        return self.client.scard(self.key)

集合实现点赞功能 *

除了点赞之外,很多网站还有诸如“+1”“顶”“喜欢”等功能,这些功能的名字虽然各有不同,但它们在本质上和点赞功能是一样的。

代码清单展示了一个使用集合实现的点赞程序,这个程序使用集合来存储对内容进行了点赞的用户,从而确保每个用户只能对同一内容点赞一次,并通过使用不同的集合命令来实现查看点赞数量、查看所有点赞用户以及取消点赞等功能。

class Like:

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

    def cast(self, user):
        """
        用户尝试进行点赞。
        如果此次点赞执行成功,那么返回 True ;
        如果用户之前已经点过赞,那么返回 False 表示此次点赞无效。
        """
        return self.client.sadd(self.key, user) == 1

    def undo(self, user):
        """
        取消用户的点赞。
        """
        self.client.srem(self.key, user)

    def is_liked(self, user):
        """
        检查用户是否已经点过赞。
        是的话返回 True ,否则的话返回 False 。
        """
        return self.client.sismember(self.key, user)

    def get_all_liked_users(self):
        """
        返回所有已经点过赞的用户。
        """
        return self.client.smembers(self.key)

    def count(self):
        """
        返回已点赞用户的人数。
        """
        return self.client.scard(self.key)

集合实现投票示例 *

问答网站、文章推荐网站、论坛这类注重内容质量的网站上通常都会提供投票功能,用户可以通过投票来支持一项内容或者反对一项内容:

  • 一项内容获得的支持票数越多,就会被网站安排到越明显的位置,使得网站的用户可以更快速地浏览到高质量的内容。
  • 与此相反,一项内容获得的反对票数越多,它就会被网站安排到越不明显的位置,甚至被当作广告或者无用内容隐藏起来,使得用户可以忽略这些低质量的内容。

根据网站性质的不同,不同的网站可能会为投票功能设置不同的名称,比如有些网站可能会把“支持”和“反对”叫作“推荐”和“不推荐”,而有些网站可能会使用“喜欢”和“不喜欢”来表示“支持”和“反对”,诸如此类,但这些网站的投票功能在本质上都是一样的。

代码清单展示了一个使用集合实现的投票程序:对于每一项需要投票的内容,这个程序都会使用两个集合来分别存储投支持票的用户以及投反对票的用户,然后通过对这两个集合执行命令来实现投票、取消投票、统计投票数量、获取已投票用户名单等功能。

def vote_up_key(vote_target):
    return vote_target + "::vote_up"

def vote_down_key(vote_target):
    return vote_target + "::vote_down"

class Vote:

    def __init__(self, client, vote_target):
        self.client = client
        self.vote_up_set = vote_up_key(vote_target)
        self.vote_down_set = vote_down_key(vote_target)

    def is_voted(self, user):
        """
        检查用户是否已经投过票(可以是赞成票也可以是反对票),
        是的话返回 True ,否则返回 False 。
        """
        return self.client.sismember(self.vote_up_set, user) or 
               self.client.sismember(self.vote_down_set, user)

    def vote_up(self, user):
        """
        让用户投赞成票,并在投票成功时返回 True ;
        如果用户已经投过票,那么返回 False 表示此次投票无效。
        """
        if self.is_voted(user): 
            return False

        self.client.sadd(self.vote_up_set, user)
        return True

    def vote_down(self, user):
        """
        让用户投反对票,并在投票成功时返回 True ;
        如果用户已经投过票,那么返回 False 表示此次投票无效。
        """
        if self.is_voted(user): 
            return False

        self.client.sadd(self.vote_down_set, user)
        return True

    def undo(self, user):
        """
        取消用户的投票。
        """
        self.client.srem(self.vote_up_set, user)
        self.client.srem(self.vote_down_set, user)

    def vote_up_count(self):
        """
        返回投支持票的用户数量。
        """
        return self.client.scard(self.vote_up_set)

    def get_all_vote_up_users(self):
        """
        返回所有投支持票的用户。
        """
        return self.client.smembers(self.vote_up_set)

    def vote_down_count(self):
        """
        返回投反对票的用户数量。
        """
        return self.client.scard(self.vote_down_set)

    def get_all_vote_down_users(self):
        """
        返回所有投反对票的用户。
        """
        return self.client.smembers(self.vote_down_set)

集合示例:社交关系

微博、Twitter以及类似的社交网站都允许用户通过加关注或者加好友的方式,构建一种社交关系。这些网站上的每个用户都可以关注其他用户,也可以被其他用户关注。通过正在关注名单(following list),用户可以查看自己正在关注的用户及其人数;通过关注者名单(follower list),用户可以查看有哪些人正在关注自己,以及有多少人正在关注自己。

代码清单展示了一个使用集合来记录社交关系的方法:

  • 程序为每个用户维护两个集合,一个集合存储用户的正在关注名单,而另一个集合则存储用户的关注者名单。
  • 当一个用户(关注者)关注另一个用户(被关注者)的时候,程序会将被关注者添加到关注者的正在关注名单中,并将关注者添加到被关注者的关注者名单里面。
  • 当关注者取消对被关注者的关注时,程序会将被关注者从关注者的正在关注名单中移除,并将关注者从被关注者的关注者名单中移除。
def following_key(user):
    return user + "::following"

def follower_key(user):
    return user + "::follower"

class Relationship:

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

    def follow(self, target):
        """
        关注目标用户。
        """
        # 把 target 添加到当前用户的正在关注集合里面
        user_following_set = following_key(self.user)
        self.client.sadd(user_following_set, target)
        # 把当前用户添加到 target 的关注者集合里面
        target_follower_set = follower_key(target)
        self.client.sadd(target_follower_set, self.user)

    def unfollow(self, target):
        """
        取消对目标用户的关注。
        """
        # 从当前用户的正在关注集合中移除 target
        user_following_set = following_key(self.user)
        self.client.srem(user_following_set, target)
        # 从 target 的关注者集合中移除当前用户
        target_follower_set = follower_key(target)
        self.client.srem(target_follower_set, self.user)

    def is_following(self, target):
        """
        检查当前用户是否正在关注目标用户,
        是的话返回 True ,否则返回 False 。
        """
        # 如果 target 存在于当前用户的正在关注集合中
        # 那么说明当前用户正在关注 target
        user_following_set = following_key(self.user)
        return self.client.sismember(user_following_set, target)

    def get_all_following(self):
        """
        返回当前用户正在关注的所有人。
        """
        user_following_set = following_key(self.user)
        return self.client.smembers(user_following_set)

    def get_all_follower(self):
        """
        返回当前用户的所有关注者。
        """
        user_follower_set = follower_key(self.user)
        return self.client.smembers(user_follower_set)

    def count_following(self):
        """
        返回当前用户正在关注的人数。
        """
        user_following_set = following_key(self.user)
        return self.client.scard(user_following_set)

    def count_follower(self):
        """
        返回当前用户的关注者人数。
        """
        user_follower_set = follower_key(self.user)
        return self.client.scard(user_follower_set)

集合实现抽奖

为了推销商品并回馈消费者,商家经常会举办一些抽奖活动,每个符合条件的消费者都可以参加这种抽奖,而商家则需要从所有参加抽奖的消费者中选出指定数量的获奖者,并向他们赠送物品、金钱或者其他购物优惠。

代码清单展示了一个使用集合实现的抽奖程序,这个程序会把所有参与抽奖活动的玩家都添加到一个集合中,然后通过SRANDMEMBER命令随机地选出获奖者。

#coding:utf-8

class Lottery:

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

    def add_player(self, user):
        """
        将用户添加到抽奖活动当中。
        """
        self.client.sadd(self.key, user)

    def get_all_players(self):
        """
        返回参加抽奖活动的所有用户。
        """
        return self.client.smembers(self.key)

    def player_count(self):
        """
        返回参加抽奖活动的用户人数。
        """
        return self.client.scard(self.key)

    def draw(self, number):
        """
        抽取指定数量的获奖者。
        """
        # 因为 redis-py 目前还不支持 SPOP 命令的 count 参数
        # 所以我们在这里只能通过调用多次 SPOP  命令来获得多个随机元素
        winners = list()
        for i in range(number):
            winners.append(self.client.spop(self.key))
        return winners

集合示例:共同关注与推荐关注

在前面我们学习了如何使用集合存储社交网站的好友关系,但是除了基本的关注和被关注之外,社交网站通常还会提供一些额外的功能,帮助用户去发现一些自己可能会感兴趣的人。

例如,当我们在微博上访问某个用户的个人页面时,页面上就会展示出我们和这个用户都在关注的人

共同关注

def following_key(user):
    return user + "::following"

class CommonFollowing:

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

    def calculate(self, user, target):
        """
        计算并返回当前用户和目标用户共同关注的人。
        """
        user_following_set = following_key(user)
        target_following_set = following_key(target)
        return self.client.sinter(user_following_set, target_following_set)

    def calculate_and_store(self, user, target, store_key):
        """
        计算出当前用户和目标用户共同关注的人,
        并把结果储存到 store_key 指定的键里面,
        最后返回共同关注的人数作为返回值。
        """
        user_following_set = following_key(user)
        target_following_set = following_key(target)
        return self.client.sinterstore(store_key, user_following_set, target_following_set)

推荐关注

代码清单展示了一个推荐关注程序的实现代码,这个程序会从用户的正在关注集合中随机选出指定数量的用户作为种子用户,然后对这些种子用户的正在关注集合执行并集计算,最后从这个并集中随机地选出一些用户作为推荐关注的对象。

def following_key(user):
    return user + "::following"

def recommend_follow_key(user):
    return user + "::recommend_follow"

class RecommendFollow:

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

    def calculate(self, seed_size):
        """
        计算并储存用户的推荐关注数据。
        """
        # 1)从用户关注的人中随机选一些人作为种子用户
        user_following_set = following_key(self.user)
        following_targets = self.client.srandmember(user_following_set, seed_size)
        # 2)收集种子用户的正在关注集合键名
        target_sets = set()
        for target in following_targets:
            target_sets.add(following_key(target))
        # 3)对所有种子用户的正在关注集合执行并集计算,并储存结果
        return self.client.sunionstore(recommend_follow_key(self.user), *target_sets)

    def fetch_result(self, number):
        """
        从已有的推荐关注数据中随机地获取指定数量的推荐关注用户。
        """
        return self.client.srandmember(recommend_follow_key(self.user), number)

    def delete_result(self):
        """
        删除已计算出的推荐关注数据。
        """
        self.client.delete(recommend_follow_key(self.user))

在执行这段代码之前,用户peter关注了tom、david、jack、mary和sam这5个用户,而这5个用户又分别关注了如图5-14所示的一些用户,从结果来看,推荐程序随机选中了david、sam和mary作为种子用户,然后又从这3个用户的正在关注集合的并集中随机选出了10个人作为peter的推荐关注对象。

5898e6292dd8557c4194f826c4bfc208.png

需要注意的是,这里使用的是非常简单的推荐算法,假设用户会对自己正在关注的人的关注对象感兴趣,但实际的情况可能并非如此。为了获得更为精准的推荐效果,实际的社交网站通常会使用更为复杂的推荐算法,有兴趣的读者可以自行查找这方面的资料。

集合实现:使用反向索引构建商品筛选器

在访问网店或者购物网站的时候,我们经常会看到类似图5-15中显示的商品筛选器,对于不同的筛选条件,这些筛选器会给出不同的选项,用户可以通过选择不同的选项来快速找到自己想要的商品。

13238c2d703548b4631f9264a6c082a3.png

实现商品筛选器的方法之一是使用反向索引,这种数据结构可以为每个物品添加多个关键字,然后根据关键字去反向获取相应的物品。举个例子,对于"X1Carbon"这台笔记本电脑来说,我们可以为它添加"ThinkPad"、"14inch"、"Windows"等关键字,然后通过这些关键字来反向获取"X1 Carbon"这台笔记本电脑。

实现反向索引的关键是要在物品和关键字之间构建起双向的映射关系,比如对于刚刚提到的"X1 Carbon"笔记本电脑来说,反向索引程序需要构建出图5-16所示的两种映射关系:

985db66abef12c82ed5c213ba08a3cc0.png

  • 第一种映射关系将"X1 Carbon"映射至它带有的各个关键字。
  • 第二种映射关系将"ThinkPad"、"14inch"、"Windows"等多个关键字映射至"X1 Carbon"。

代码清单展示了一个使用集合实现的反向索引程序,对于用户给定的每一件物品,这个程序都会使用一个集合去存储物品带有的多个关键字,与此同时,对于这件物品的每一个关键字,程序都会使用一个集合去存储关键字与物品之间的映射。因为构建反向索引所需的这两种映射都是一对多映射,所以使用集合来存储这两种映射关系的做法是可行的。

def make_item_key(item):
    return "InvertedIndex::" + item + "::keywords"

def make_keyword_key(keyword):
    return "InvertedIndex::" + keyword + "::items"

class InvertedIndex:

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

    def add_index(self, item, *keywords):
        """
        为物品添加关键字。
        """
        # 将给定关键字添加到物品集合中
        item_key = make_item_key(item)
        result = self.client.sadd(item_key, *keywords)
        # 遍历每个关键字集合,把给定物品添加到这些集合当中
        for keyword in keywords:
            keyword_key = make_keyword_key(keyword)
            self.client.sadd(keyword_key, item)
        # 返回新添加关键字的数量作为结果
        return result

    def remove_index(self, item, *keywords):
        """
        移除物品的关键字。
        """
        # 将给定关键字从物品集合中移除
        item_key = make_item_key(item)
        result = self.client.srem(item_key, *keywords)
        # 遍历每个关键字集合,把给定物品从这些集合中移除
        for keyword in keywords:
            keyword_key = make_keyword_key(keyword)
            self.client.srem(keyword_key, item)
        # 返回被移除关键字的数量作为结果
        return result

    def get_keywords(self, item):
        """
        获取物品的所有关键字。
        """
        return self.client.smembers(make_item_key(item))

    def get_items(self, *keywords):
        """
        根据给定的关键字获取物品。
        """
        # 根据给定的关键字,计算出与之对应的集合键名
        keyword_key_list = map(make_keyword_key, keywords)
        # 然后对这些储存着各式物品的关键字集合执行并集计算
        # 从而查找出带有给定关键字的物品
        return self.client.sinter(*keyword_key_list)

332c8b2b717e2bb3df59189de2481be7.png

99809c5766443e979a949f11065b0c4f.png

集合重点

  • 集合允许用户存储任意多个各不相同的元素。
  • 所有针对单个元素的集合操作,复杂度都为O(1)。
  • 在使用SADD命令向集合中添加元素时,已存在于集合中的元素会自动被忽略。
  • 因为集合以无序的方式存储元素,所以两个包含相同元素的集合在使用SMEMBERS命令时可能会得到不同的结果。
  • SRANDMEMBER命令不会移除被随机选中的元素,而SPOP命令的做法正相反。
  • 因为集合计算需要使用大量的计算资源,所以我们应该尽量存储并重用集合计算的结果,在有需要的情况下,还可以把集合计算放到从服务器中进行。

有序集合

有序集合:排行榜

我们在网上常常会看到各式各样的排行榜,比如,在音乐网站上可能会看到试听排行榜、下载排行榜、华语歌曲排行榜和英语歌曲排行榜等,而在视频网站上可能会看到观看排行榜、购买排行榜、收藏排行榜等,甚至连项目托管网站GitHub都提供了各种不同的排行榜,以此来帮助用户找到近期最受人瞩目的新项目。

代码清单展示了一个使用有序集合实现的排行榜程序:

  • 这个程序使用ZADD命令向排行榜中添加被排序的元素及其分数,并使用ZREVRANK命令去获取元素在排行榜中的排名,以及使用ZSCORE命令去获取元素的分数。
  • 当用户不再需要对某个元素进行排序的时候,可以调用由ZREM命令实现的remove()方法,从排行榜中移除该元素。
  • 如果用户想要修改某个被排序元素的分数,那么只需要调用由ZINCRBY命令实现的increase_score()方法或者decrease_score()方法即可。
  • 当用户想要获取排行榜前N位的元素及其分数时,只需要调用由ZREVRANGE命令实现的top()方法即可。
class RankingList:

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

    def set_score(self, item, score):
        """
        为排行榜中的指定元素设置分数,不存在的元素会被添加到排行榜里面。
        """
        self.client.zadd(self.key, {item:score})

    def get_score(self, item):
        """
        获取排行榜中指定元素的分数。
        """
        return self.client.zscore(self.key, item)

    def remove(self, item):
        """
        从排行榜中移除指定的元素。
        """
        self.client.zrem(self.key, item)

    def increase_score(self, item, increment):
        """
        将给定元素的分数增加 increment 分。
        """
        self.client.zincrby(self.key, increment, item)

    def decrease_score(self, item, decrement):
        """
        将给定元素的分数减少 decrement 分。
        """
        # 因为 Redis 没有直接提供能够减少元素分值的命令
        # 所以这里通过传入一个负数减量来达到减少分值的目的
        self.client.zincrby(self.key, 0-decrement, item)

    def get_rank(self, item):
        """
        获取给定元素在排行榜中的排名。
        """
        rank = self.client.zrevrank(self.key, item)
        # 因为 Redis 元素的排名是以 0 为开始的,
        # 而现实世界中的排名通常以 1 为开始,
        # 所以这里在返回排名之前会执行加一操作。
        if rank is not None: 
            return rank+1

    def top(self, n, with_score=False):
        """
        获取排行榜中得分最高的 n 个元素,
        如果可选的 with_score 参数的值为 True ,那么将元素的分数(分值)也一并返回。
        """
        return self.client.zrevrange(self.key, 0, n-1, withscores=with_score)

有序集合:时间线

在互联网上,有很多网站都会根据内容的发布时间来对内容进行排序,比如:

  • 博客系统会按照文章发布时间的先后,把最近发布的文章放在前面,而发布时间较早的文章则放在后面,这样访客在浏览博客的时候,就可以先阅读最新的文章,然后再阅读较早的文章。
  • 新闻网站会按照新闻的发布时间,把最近发生的新闻放在网站的前面,而早前发生的新闻则放在网站的后面,这样当用户访问该网站的时候,就可以第一时间查看到最新的新闻报道。
  • 诸如微博和Twitter这样的微博客都会把用户最新发布的消息放在页面的前面,而稍早之前发布的消息则放在页面的后面,这样用户就可以通过向后滚动网页,查看最近一段时间自己关注的人都发表了哪些动态。

类似的情形还有很多。通过对这类行为进行抽象,我们可以创建出代码清单所示的时间线程序:

  • 这个程序会把被添加到时间线里面的元素用作成员,与元素相关联的时间戳用作分值,将元素和它的时间戳添加到有序集合中。
  • 因为时间线中的每个元素都有一个与之相关联的时间戳,所以时间线中的元素将按照时间戳的大小进行排序。
  • 通过对时间线中的元素执行ZREVRANGE命令或者ZREVRANGEBYSCORE命令,用户可以以分页的方式按顺序取出时间线中的元素,或者从时间线中取出指定时间区间内的元素。
class Timeline:

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

    def add(self, item, time):
        """
        将元素添加到时间线里面。
        """
        self.client.zadd(self.key, {item:time})

    def remove(self, item):
        """
        从时间线里面移除指定元素。
        """
        self.client.zrem(self.key, item)

    def count(self):
        """
        返回时间线包含的元素数量。
        """
        return self.client.zcard(self.key)

    def pagging(self, number, count, with_time=False):
        """
        按照每页 count 个元素计算,取出时间线第 number 页上的所有元素,
        这些元素将根据时间戳逆序排列。
        如果可选参数 with_time 的值为 True ,那么元素对应的时间戳也会一并被返回。
        注意:number 参数的起始值是 1 而不是 0 。
        """
        start_index = (number - 1)*count
        end_index = number*count-1
        return self.client.zrevrange(self.key, start_index, end_index, withscores=with_time) 

    def fetch_by_time_range(self, min_time, max_time, number, count, with_time=False):
        """
        按照每页 count 个元素计算,获取指定时间段第 number 页上的所有元素,
        这些元素将根据时间戳逆序排列。
        如果可选参数 with_time 的值为 True ,那么元素对应的时间戳也会一并被返回。
        注意:number 参数的起始值是 1 而不是 0 。
        """
        start_index = (number-1)*count
        return self.client.zrevrangebyscore(self.key, max_time, min_time, start_index, count, withscores=with_time)

有序集合:商品推荐 *

在浏览网上商城的时候,我们常常会看到类似“购买此商品的顾客也同时购买”这样的商品推荐功能

从抽象的角度来讲,这些推荐功能实际上都是通过记录用户的访问路径来实现的:如果用户在对一个目标执行了类似浏览或者购买这样的操作之后,也对另一个目标执行了相同的操作,那么程序就会对这次操作的访问路径进行记录和计数,然后程序就可以通过计数结果来知道用户在对指定目标执行了某个操作之后,还会对哪些目标执行相同的操作。

代码清单展示了一个使用以上原理实现的路径统计程序:

  • 每当用户从起点origin对终点destination进行一次访问,程序都会使用ZINCRBY命令对存储着起点origin访问记录的有序集合的destination成员执行一次分值加1操作。
  • 在此之后,程序只需要对存储着origin访问记录的有序集合执行ZREVRANGE命令,就可以知道用户在访问了起点origin之后,最经常访问的目的地有哪些。
def make_record_key(origin):
    return "forward_to_record::{0}".format(origin)

class Path:

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

    def forward_to(self, origin, destination):
        """
        记录一次从起点 origin 到目的地 destination 的访问。
        """
        key = make_record_key(origin)
        self.client.zincrby(key, 1, destination)

    def pagging_record(self, origin, number, count, with_time=False):
        """
        按照每页 count 个目的地计算,
        从起点 origin 的访问记录中取出位于第 number 页的访问记录,
        其中所有访问记录均按照访问次数从多到小进行排列。
        如果可选的 with_time 参数的值为 True ,那么将具体的访问次数也一并返回。
        """
        key = make_record_key(origin)
        start_index = (number-1)*count
        end_index = number*count-1
        return self.client.zrevrange(key, start_index, end_index, withscores=with_time, score_cast_func=int) # score_cast_func = int 用于将成员的分值从浮点数转换为整数

zrangbylex/zrevrangebylex:返回指定字典范围内的成员

正如本章开头所说,对于拥有不同分值的有序集合成员来说,成员的大小将由分值决定,至于分值相同的成员,它们的大小则由该成员在字典序中的大小决定

这种排列规则的一个特例是,当有序集合的所有成员都拥有相同的分值时,有序集合的成员将不再根据分值进行排序,而是根据字典序进行排序。在这种情况下,本章前面介绍的根据分值对成员进行操作的命令,比如ZRANGEBYSCORE、ZCOUNT和ZREMRANGEBYSCORE等,都将不再适用

为了让用户可以对字典序排列的有序集合执行类似ZRANGEBYSCORE这样的操作,Redis提供了相应的ZRANGEBYLEX、ZREVRANGEBYLEX、ZLEXCOUNT和ZREMRANGEBYLEX命令,这些命令可以分别对字典序排列的有序集合执行升序排列的范围获取操作、降序排列的范围获取操作、统计位于字典序指定范围内的成员数量以及移除位于字典序指定范围内的成员,本章接下来将分别对这些命令进行介绍。

(略)

ZPOPMAX、ZPOPMIN:弹出分值最高和最低的成员

ZPOPMAX和ZPOPMIN是Redis 5.0版本新添加的两个命令,分别用于移除并返回有序集合中分值最大和最小的N个元素:

ZPOPMAX sorted_set [count]
ZPOPMIN sorted_set [count]

其中被移除元素的数量可以通过可选的count参数来指定。如果用户没有显式地给定count参数,那么命令默认只会移除一个元素。

BZPOPMAX、BZPOPMIN:阻塞式最大/最小元素弹出操作

BZPOPMAX命令和BZPOPMIN命令分别是ZPOPMAX命令以及ZPOPMIN命令的阻塞版本,这两个阻塞命令都接受任意多个有序集合和一个秒级精度的超时时限作为参数:


BZPOPMAX sorted_set [sorted_set ...] timeout

接收到参数的BZPOPMAX命令和BZPOPMIN命令会依次检查用户给定的有序集合,并从它遇到的第一个非空有序集合中弹出指定的元素。如果命令在检查了所有给定有序集合之后都没有发现可弹出的元素,那么它将阻塞执行命令的客户端,并在给定的时限之内等待可弹出的元素出现,直到等待时间超过给定时限为止。用户可以通过将超时时限设置为0来让命令一直阻塞,直到可弹出的元素出现为止。

BZPOPMAX命令和BZPOPMIN命令在成功弹出元素时将返回一个包含3个项的列表,这3个项分别为被弹出元素所在的有序集合、被弹出元素的成员以及被弹出元素的分值。与此相反,如果这两个命令因为等待超时而未能弹出任何元素,那么它们将返回一个空值作为结果。

有序集合重点

  • 有序集合同时拥有“有序”和“集合”两种性质,集合性质保证有序集合只会包含各不相同的成员,而有序性质则保证了有序集合中的所有成员都会按照特定的顺序进行排列。
  • 在一般情况下,有序集合成员的大小由分值决定,而分值相同的成员的大小则由成员在字典序中的大小决定。
  • 成员的分值除了可以是数字之外,还可以是表示无穷大的"+inf"或者表示无穷小的"-inf"。
  • ZADD命令从Redis 3.0.2版本开始,可以通过给定可选项来决定执行添加操作或是执行更新操作。
  • 因为Redis只提供了对成员分值执行加法计算的ZINCRBY命令,而没有提供相应的减法计算命令,所以我们只能通过向ZINCRBY命令传入负数增量来对成员分值执行减法计算。
  • ZINTERSTORE命令和ZUNIONSTORE命令除了可以使用有序集合作为输入之外,还可以使用集合作为输入。在默认情况下,这两个命令会把集合的成员看作分值为1的有序集合成员来计算。
  • 当有序集合的所有成员都拥有相同的分值时,用户可以通过ZRANGEBYLEX、ZLEXCOUNT、ZREMRANGEBYLEX等命令,按照字典序对有序集合中的成员进行操作。

HyperLogLog

HyperLogLog简介 @@

位图(bitmap)

位图(bitmap)@@

地理坐标

地理坐标 @@

流(stream)@@

Redis附加功能

Redis数据库的操作 ***

Redis为数据库提供了非常丰富的操作命令,通过这些命令,用户可以:

  • ●指定自己想要使用的数据库。
  • ●一次性获取数据库包含的所有键,迭代地获取数据库包含的所有键,或者随机地获取数据库中的某个键。
  • ●根据给定键的值进行排序。
  • ●检查给定的一个或多个键,看它们是否存在于数据库当中。
  • ●查看给定键的类型。
  • ●对给定键进行重命名。
  • ●移除指定的键,或者将它从一个数据库移动到另一个数据库。
  • ●清空数据库包含的所有键。
  • ●交换给定的两个数据库。

SELECT:切换至指定的数据库

一个Redis服务器可以包含多个数据库。在默认情况下,Redis服务器在启动时将会创建16个数据库:这些数据库都使用号码进行标识,其中第一个数据库为0号数据库,第二个数据库为1号数据库,而第三个数据库则为2号数据库,以此类推。

Redis虽然不允许在同一个数据库中使用两个同名的键,但是由于不同数据库拥有不同的命名空间,因此在不同数据库中使用同名的键是完全没有问题的,而用户也可以通过使用不同数据库来存储不同的数据,以此来达到重用键名并且减少键冲突的目的。

比如,如果我们将用户的个人信息和会话信息都存放在同一个数据库中,那么为了区分这两种信息,程序就需要使用user::::profile格式的键来存储用户信息,并使用user::::session格式的键来存储用户会话;但如果将这两种信息分别存储在0号数据库和1号数据库中,那么程序就可以在0号数据库中使用user::格式的键来存储用户信息,并在1号数据库中继续使用user::格式的键来存储用户会话。

redis> SELECT 3
OK
redis[3]> set name whw

KEYS:获取所有与给定匹配符相匹配的键

KEYS命令接受一个全局匹配符作为参数,然后返回数据库中所有与这个匹配符相匹配的键作为结果:

keys pattern

如果我们想要获取所有以user::为前缀的键,那么可以执行以下命令:

keys user::*

全局匹配符

46864b16f3f78be3343ca0e923f6e69d.png

TYPE:查看键的类型 *

TYPE命令允许我们查看给定键的类型:

127.0.0.1:6379> keys *
1) "msg"
2) "pv_counter::123,100"
3) "fruits"
127.0.0.1:6379> type msg
string
127.0.0.1:6379> type fruits
set
127.0.0.1:6379>

3c52e3170e522414e33e09f922439e87.png

流水线与事物 *****

在执行这些命令的时候,我们总是单独地执行每个命令,也就是说,先将一个命令发送到服务器,等服务器执行完这个命令并将结果返回给客户端之后,再执行下一个命令,以此类推,直到所有命令都执行完毕为止。

这种执行命令的方式虽然可行,但在性能方面却不是最优的,并且在执行时可能还会出现一些非常隐蔽的错误。为了解决这些问题,本章将会介绍Redis的流水线特性以及事务特性,前者可以有效地提升Redis程序的性能,而后者则可以避免单独执行命令时可能会出现的一些错误。

Redis的流水线特性 *

在一般情况下,用户每执行一个Redis命令,Redis客户端和Redis服务器就需要执行以下步骤:

1)客户端向服务器发送命令请求。

2)服务器接收命令请求,并执行用户指定的命令调用,然后产生相应的命令执行结果。

3)服务器向客户端返回命令的执行结果。

4)客户端接收命令的执行结果,并向用户进行展示。

与大多数网络程序一样,执行Redis命令所消耗的大部分时间都用在了发送命令请求和接收命令结果上面:Redis服务器处理一个命令请求通常只需要很短的时间,但客户端将命令请求发送给服务器以及服务器向客户端返回命令结果的过程却需要花费不少时间。通常情况下,程序需要执行的Redis命令越多,它需要进行的网络通信操作也会越多,程序的执行速度也会因此而变慢。

为了解决这个问题,我们可以使用Redis提供的流水线特性:这个特性允许客户端把任意多条Redis命令请求打包在一起,然后一次性地将它们全部发送给服务器,而服务器则会在流水线包含的所有命令请求都处理完毕之后,一次性地将它们的执行结果全部返回给客户端。

通过使用流水线特性,我们可以将执行多个命令所需的网络通信次数从原来的N次降低为1次,这可以大幅度地减少程序在网络通信方面耗费的时间,使得程序的执行效率得到显著的提升。

作为例子,图13-1展示了在没有使用流水线的情况下,执行3个Redis命令产生的网络通信示意图,而图13-2则展示了在使用流水线的情况下,执行相同Redis命令产生的网络通信示意图。可以看到,在使用了流水线之后,程序进行网络通信的次数从原来的3次降低到了1次。

44a734e85a8e737ab4fe851bd1aa9751.png

虽然Redis服务器提供了流水线特性,但这个特性还需要客户端支持才能使用。幸运的是,包括redis-py在内的绝大部分Redis客户端都提供了对流水线特性的支持,因此Redis用户在绝大部分情况下都能够享受到流水线特性带来的好处。

为了在redis-py客户端中使用流水线特性,我们需要用到pipeline()方法,调用这个方法会返回一个流水线对象,用户只需要像平时执行Redis命令那样,使用流水线对象调用相应的命令方法,就可以把想要执行的Redis命令放入流水线中

作为例子,以下代码展示了如何以流水线方式执行SET、INCRBY和SADD命令:

import redis

POOL = redis.ConnectionPool(host="127.0.0.1",port=6379,max_connections=10000)
conn = redis.Redis(connection_pool=POOL,max_connections=1000)

pipe = conn.pipeline(transaction=False)
pipe.set("msg","hello_world")
pipe.incrby("pv_counter::123,100")
pipe.sadd("fruits","apple","banana","cherry")
# 调用流水线对象的execute()方法,将队列中的3个命令调用打包发送给服务器
pipe.execute()

这段代码先使用pipeline()方法创建了一个流水线对象,并将这个对象存储到了pipe变量中(pipeline()方法中的transaction=False参数表示不在流水线中使用事务,这个参数的具体意义将在本章后续内容中说明)。在此之后,程序通过流水线对象分别调用了set()方法、incrby()方法和sadd()方法,将这3个方法对应的命令调用放入了流水线队列中。最后,程序调用流水线对象的execute()方法,将队列中的3个命令调用打包发送给服务器,而服务器会在执行完这些命令之后,把各个命令的执行结果依次放入一个列表中,然后将这个列表返回给客户端。

看看效果:

# 一开始没有key
127.0.0.1:6379> keys *
(empty list or set)
127.0.0.1:6379>
# 运行完程序后有key了
127.0.0.1:6379> keys *
1) "msg"
2) "pv_counter::123,100"
3) "fruits"
127.0.0.1:6379>

流水线使用注意事项 *

虽然Redis服务器并不会限制客户端在流水线中包含的命令数量,但是却会为客户端的输入缓冲区设置默认值为1GB的体积上限:当客户端发送的数据量超过这一限制时,Redis服务器将强制关闭该客户端。因此用户在使用流水线特性时,最好不要一下把大量命令或者一些体积非常庞大的命令放到同一个流水线中执行,以免触碰到Redis的这一限制。

除此之外,很多客户端本身也带有隐含的缓冲区大小限制,如果你在使用流水线特性的过程中,发现某些流水线命令没有被执行,或者流水线返回的结果不完整,那么很可能就是你的程序触碰到了客户端内置的缓冲区大小限制。在遇到这种情况时,请缩减流水线命令的数量及其体积,然后再进行尝试。

使用流水线优化随机键创建程序 *

每创建一个键,redis-py客户端就需要与Redis服务器进行一次网络通信:考虑到这个程序执行的都是一些非常简单的命令,每次网络通信只执行一个命令的做法无疑是非常低效的。为了解决这个问题,我们可以使用流水线把程序生成的所有命令都包裹起来,这样的话,创建多个随机键所需要的网络通信次数就会从原来的N次降低为1次。代码清单展示了修改之后的流水线版本随机键创建程序。

import random

def create_random_type_keys(client, number):
    """
    在数据库中创建指定数量的类型随机键。
    """
    # 创建流水线对象 —— transaction为True表示事务 False表示流水线
    pipe = client.pipeline(transaction=False)
    for i in range(number):
        # 构建键名
        key = "key:{0}".format(i)
        # 从六个键创建函数中随机选择一个
        create_key_func = random.choice([
            create_string, 
            create_hash, 
            create_list, 
            create_set, 
            create_zset, 
            create_stream
        ])
        # 把待执行的 Redis 命令放入流水线队列中
        create_key_func(pipe, key)
    # 执行流水线包裹的所有命令
    pipe.execute()

def create_string(client, key):
    client.set(key, "")

def create_hash(client, key):
    client.hset(key, "", "")

def create_list(client, key):
    client.rpush(key, "")

def create_set(client, key):
    client.sadd(key, "")

def create_zset(client, key):
    client.zadd(key, {"":0})

def create_stream(client, key):
    client.xadd(key, {"":""})

即使只在本地网络中进行测试,新版的随机键创建程序也有5倍的性能提升。当客户端与服务器处于不同的网络之中,特别是它们之间的连接速度较慢时,流水线版本的性能提升还会更多。

Redis事务 ***

虽然Redis的LPUSH命令和RPUSH命令允许用户一次向列表推入多个元素,但是列表的弹出命令LPOP和RPOP每次却只能弹出一个元素!

因为Redis并没有提供能够一次弹出多个列表元素的命令,所以为了方便地执行这一任务,用户可能会写出代码清单所示的代码。

def mlpop(client, list_key, number):
    # 用于储存被弹出元素的结果列表
    items = []  
    for i in range(number):
        # 执行 LPOP 命令,弹出一个元素
        poped_item = client.lpop(list_key)
        # 将被弹出的元素追加到结果列表末尾
        items.append(poped_item)
    # 返回结果列表
    return items

mlpop()函数通过将多条LPOP命令发送至服务器来达到弹出多个元素的目的。遗憾的是,这个函数并不能保证它发送的所有LPOP命令都会被服务器执行:如果服务器在执行多个LPOP命令的过程中下线了,那么mlpop()发送的这些LPOP命令将只有一部分会被执行。

举个例子,如果我们调用mlpop(client, "lst", 3),尝试从"lst"列表中弹出3个元素,那么mlpop()将向服务器连续发送3个LPOP命令,但如果服务器在顺利执行前两个LPOP命令之后因为故障下线了,那么"lst"列表将只有2个元素会被弹出。

需要注意的是,即使我们使用流水线特性,把多条LPOP命令打包在一起发送,也不能保证所有命令都会被服务器执行:这是因为流水线只能保证多条命令会一起被发送至服务器但它并不保证这些命令都会被服务器执行。

为了实现一个正确且安全的mlpop()函数,我们需要一种能够让服务器将多个命令打包起来一并执行的技术,而这正是本节将要介绍的事务特性:

  • 事务可以将多个命令打包成一个命令来执行,当事务成功执行时,事务中包含的所有命令都会被执行。
  • 相反,如果事务没有成功执行,那么它包含的所有命令都不会被执行。

通过使用事务,用户可以保证自己想要执行的多个命令要么全部被执行,要么一个都不执行。以mlpop()函数为例,通过使用事务,我们可以保证被调用的多个LPOP命令要么全部执行,要么一个也不执行,从而杜绝只有其中一部分LPOP命令被执行的情况出现。

事务的安全性

在对数据库的事务特性进行介绍时,人们一般都会根据数据库对ACID性质的支持程度去判断数据库的事务是否安全。

具体来说,Redis的事务总是具有ACID性质中的A、C、I性质:

  • 原子性(Atomic):如果事务成功执行,那么事务中包含的所有命令都会被执行;相反,如果事务执行失败,那么事务中包含的所有命令都不会被执行。
  • 一致性(Consistent):Redis服务器会对事务及其包含的命令进行检查,确保无论事务是否执行成功,事务本身都不会对数据库造成破坏。
  • 隔离性(Isolate):每个Redis客户端都拥有自己独立的事务队列,并且每个Redis事务都是独立执行的,不同事务之间不会互相干扰。

除此之外,当Redis服务器运行在特定的持久化模式之下时,Redis的事务也具有ACID性质中的D性质:

  • 持久性(Durable):当事务执行完毕时,它的结果将被存储在硬盘中,即使服务器在此之后停机,事务对数据库所做的修改也不会丢失。

事务对服务器的影响

因为事务在执行时会独占服务器,所以用户应该避免在事务中执行过多命令,更不要将一些需要进行大量计算的命令放入事务中,以免造成服务器阻塞。

流水线与事物

正如前面所言,流水线与事务虽然在概念上有些相似,但是在作用上却并不相同:流水线的作用是将多个命令打包,然后一并发送至服务器,而事务的作用则是将多个命令打包,然后让服务器一并执行它们。

因为Redis的事务在EXEC命令执行之前并不会产生实际效果,所以很多Redis客户端都会使用流水线去包裹事务命令,并将入队的命令缓存在本地,等到用户输入EXEC命令之后,再将所有事务命令通过流水线一并发送至服务器,这样客户端在执行事务时就可以达到“打包发送,打包执行”的最优效果。

本书使用的redis-py客户端就是这样处理事务命令的客户端之一,当我们使用pipeline()方法开启一个事务时,redis-py默认将使用流水线包裹事务队列中的所有命令。

import redis


POOL = redis.ConnectionPool(host="127.0.0.1",port=6379,max_connections=10000)

conn = redis.Redis(connection_pool=POOL,max_connections=1000)
# 开启事务
# transaction默认为True,表示执行事务!!!
transaction = conn.pipeline(transaction=True)
# 将命令放入事务队列
transaction.set("title","Hand in Hand")
transaction.sadd("fruits","apple","banana")
transaction.rpush("numbers",123,222,666)
# 执行事务
transaction.execute()

在执行transaction.execute()调用时,redis-py将通过流水线向服务器发送以下命令:

MULTI
SET title "Hand in Hadn"
SADD fruits "apple" "banana"
RPUSH numbers 123 222 666
EXEC

这样,无论事务包含了多少个命令,redis-py也只需要与服务器进行一次网络通信。

事务示例:实现mlpop()函数 ***

在了解了事务的使用方法之后,现在是时候用它来重新实现一个安全且正确的mlpop()函数了,为此,我们需要使用事务包裹被执行的所有LPOP命令,就像代码所示的那样。

def mlpop(client, list_key, number):
    # 开启事务
    transaction = client.pipeline()
    # 将多个 LPOP 命令放入事务队列
    for i in range(number):
        transaction.lpop(list_key)
    # 执行事务
    return transaction.execute()

新版的mlpop()函数通过事务确保自己发送的多个LPOP命令要么全部执行,要么全部不执行,以此来避免只有一部分LPOP命令被执行的情况出现。

带有乐观锁的事务 ***

本书在第2章实现了具有基本获取和释放功能的锁程序,并在第12章为该程序加上了自动释放功能,但是这两个锁程序都有一个问题,那就是它们的释放操作都是不安全的:

  • 无论某个客户端是否是锁的持有者,只要它调用release()方法,锁就会被释放。
  • 在锁被占用期间,如果某个不是持有者的客户端错误地调用了release()方法,那么锁将在持有者不知情的情况下释放,并导致系统中同时存在多个锁。

为了解决这个问题,我们需要修改锁实现,给它加上身份验证功能:

  • 客户端在尝试获取锁的时候,除了需要输入锁的最大使用时限之外,还需要输入一个代表身份的标识符,当客户端成功取得锁时,程序将把这个标识符存储在代表锁的字符串键中。
  • 当客户端调用release()方法时,它需要将自己的标识符传给release()方法,而release()方法则需要验证客户端传入的标识符与锁键存储的标识符是否相同,以此来判断调用release()方法的客户端是否就是锁的持有者,从而决定是否释放锁。

不安全的带身份标识的锁的实现

class IdentityLock:

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

    def acquire(self, identity, timeout):
        """
        尝试获取一个带有身份标识符和最大使用时限的锁,
        成功时返回 True ,失败时返回 False 。
        """
        result = self.client.set(self.key, identity, ex=timeout, nx=True)
        return result is not None

    def release(self, input_identity):
        """
        根据给定的标识符,尝试释放锁。
        返回 True 表示释放成功;
        返回 False 则表示给定的标识符与锁持有者的标识符并不相同,释放请求被拒绝。
        """
        # 获取锁键储存的标识符
        lock_identity = self.client.get(self.key)
        if lock_identity is None:
            # 如果锁键的标识符为空,那么说明锁已经被释放
            return True
        elif input_identity == lock_identity:
            # 如果给定的标识符与锁键的标识符相同,那么释放这个锁
            self.client.delete(self.key)
            return True
        else:
            # 如果给定的标识符与锁键的标识符并不相同
            # 那么说明当前客户端不是锁的持有者
            # 拒绝本次释放请求
            return False

这个锁实现在绝大部分情况下都能够正常运行,但它的release()方法包含了一个非常隐蔽的错误:在程序使用GET命令获取锁键的值以后,直到程序调用DEL命令删除锁键的这段时间里面,锁键的值有可能已经发生了变化,因此程序执行的DEL命令有可能会导致当前持有者的锁被错误地释放。

举个例子,表就展示了一个锁被错误释放的例子:客户端A是锁原来的持有者,它调用release()方法尝试释放自己的锁,但是当客户端A执行完GET命令并确认自己就是锁的持有者之后,锁键却因为过期而自动被移除了,紧接着客户端B又通过执行acquire()方法成功取得了锁,然而客户端A并未察觉这一变化,它以为自己还是锁的持有者,并调用DEL命令把属于客户端B的锁给释放了。

8418d277c0e02745d4d5a03c6f8dbbe6.png

为了正确地实现release()方法,我们需要一种机制,它可以保证如果锁键的值在GET命令执行之后发生了变化,那么DEL命令将不会被执行。在Redis中,这种机制被称为乐观锁。

WATCH:对键进行监视

详见这里

乐观锁实现:带有身份验证功能的锁 ***

之前展示的锁实现的问题在于,在GET命令执行之后,直到DEL命令执行之前的这段时间里,锁键的值有可能会发生变化,并出现误删锁键的情况。为了解决这个问题,我们需要使用乐观锁去保证DEL命令只会在锁键的值没有发生任何变化的情况下执行,代码清单展示了修改之后的锁实现。

from redis import WatchError

class IdentityLock:

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

    def acquire(self, identity, timeout):
        """
        尝试获取一个带有身份标识符和最大使用时限的锁,
        成功时返回 True ,失败时返回 False 。
        """
        result = self.client.set(self.key, identity, ex=timeout, nx=True)
        return result is not None

    def release(self, input_identity):
        """
        根据给定的标识符,尝试释放锁。
        返回 True 表示释放成功;
        返回 False 则表示给定的标识符与锁持有者的标识符并不相同,释放请求被拒绝。
        """
        # 开启流水线
        pipe = self.client.pipeline()
        try:
            # 监视锁键~~~!!!
            pipe.watch(self.key)
            # 获取锁键储存的标识符
            lock_identity = pipe.get(self.key)
            if lock_identity is None:
                # 如果锁键的标识符为空,那么说明锁已经被释放
                return True
            elif input_identity == lock_identity:
                # 如果给定的标识符与锁键储存的标识符相同,那么释放这个锁
                # 为了确保 DEL 命令在执行时的安全性,我们需要使用事务去包裹它!!!
                pipe.multi()
                pipe.delete(self.key)
                pipe.execute()
                return True
            else:
                # 如果给定的标识符与锁键储存的标识符并不相同
                # 那么说明当前客户端不是锁的持有者
                # 拒绝本次释放请求
                return False
        ### WatchError
        except WatchError:
            # 抛出异常说明在 DEL 命令执行之前,已经有其他客户端修改了锁键
            return False
        finally:
            # 取消对键的监视~~~!!!
            pipe.unwatch()
            # 因为 redis-py 在执行 WATCH 命令期间,会将流水线与单个连接进行绑定
            # 所以在执行完 WATCH 命令之后,必须调用 reset() 方法将连接归还给连接池
            pipe.reset()

带有身份验证功能的计数信号量(允许多个客户端同时使用资源) ***

本书前面介绍了如何使用锁去获得一项资源的独占使用权,并给出了几个不同的锁实现,但是除了独占一项资源之外,有时候我们也会想让多个用户共享一项资源,只要共享者的数量不超过我们限制的数量即可。

举个例子,假设我们的系统有一项需要进行大量计算的操作,如果很多用户同时执行这项操作,那么系统的计算资源将会被耗尽。为了保证系统的正常运作,我们可以使用计数信号量来限制在同一时间内能够执行该操作的最大用户数量。

计数信号量(counter semaphore)与锁非常相似,它们都可以限制资源的使用权,但是与锁只允许单个客户端使用资源的做法不同,计数信号量允许多个客户端同时使用资源,只要这些客户端的数量不超过指定的限制即可。

代码清单展示了一个带有身份验证功能的计数信号量实现:

  • 这个程序会把所有成功取得信号量的客户端的标识符存储在格式为semaphore::::holders的集合键中,至于信号量的最大可获取数量则存储在格式为semaphore::::max_size的字符串键中。
  • 在使用计数信号量之前,用户需要先通过set_max_size()方法设置计数信号量的最大可获取数量。
  • get_max_size()方法和get_current_size()方法可以分别获取计数信号量的最大可获取数量以及当前已获取数量。
  • 获取信号量的acquire()方法是程序的核心:在获取信号量之前,程序会先使用两个GET命令分别获取信号量的当前已获取数量以及最大可获取数量,如果信号量的当前已获取数量并未超过最大可获取数量,那么程序将执行SADD命令,将客户端给定的标识符添加到holders集合中。
  • 由于GET命令执行之后直到SADD命令执行之前的这段时间里,可能会有其他客户端抢先取得了信号量,并导致可用信号量数量发生变化,因此程序需要使用WATCH命令监视holders键,并使用事务包裹SADD命令,以此通过乐观锁机制确保信号量获取操作的安全性。
  • 因为max_size键的值也会影响信号量获取操作的执行结果,并且这个键的值在SADD命令执行之前也可能会被其他客户端修改,所以程序在监视holders键的同时,也需要监视max_size键。
  • 当客户端想要释放自己持有的信号量时,只需要把自己的标识符传给release()方法即可,release()方法将调用SREM命令,从holders集合中查找并移除客户端给定的标识符。
from redis import WatchError

class Semaphore:

    def __init__(self, client, name):
        self.client = client
        self.name = name
        # 用于储存信号量持有者标识符的集合
        self.holder_key = "semaphore::{0}::holders".format(name)
        # 用于记录信号量最大可获取数量的字符串
        self.size_key = "semaphore::{0}::max_size".format(name)

    def set_max_size(self, size):
        """
        设置信号量的最大可获取数量。
        """
        self.client.set(self.size_key, size)

    def get_max_size(self):
        """
        返回信号量的最大可获取数量。
        """
        result = self.client.get(self.size_key)
        if result is None:
            return 0
        else:
            return int(result)

    def get_current_size(self):
        """
        返回目前已被获取的信号量数量。
        """
        return self.client.scard(self.holder_key)

    def acquire(self, identity):
        """
        尝试获取一个信号量,成功时返回 True ,失败时返回 False 。
        传入的 identity 参数将被用于标识客户端的身份。

        如果调用该方法时信号量的最大可获取数量尚未被设置,那么引发一个 TypeError 。
        """
        # 开启流水线
        pipe = self.client.pipeline()
        try:
            # 监视与信号量有关的两个键
            pipe.watch(self.size_key, self.holder_key)

            # 取得当前已被获取的信号量数量,以及最大可获取的信号量数量
            current_size = pipe.scard(self.holder_key)
            max_size_in_str = pipe.get(self.size_key)
            if max_size_in_str is None:
                raise TypeError("Semaphore max size not set")
            else:
                max_size = int(max_size_in_str)

            if current_size < max_size:
                # 如果还有剩余的信号量可用
                # 那么将给定的标识符放入到持有者集合中
                pipe.multi()
                pipe.sadd(self.holder_key, identity)
                pipe.execute()
                return True
            else:
                # 没有信号量可用,获取失败
                return False
        except WatchError:
            # 获取过程中有其他客户端修改了 size_key 或者 holder_key ,获取失败
            return False
        finally:
            # 取消监视
            pipe.unwatch()
            # 将连接归还给连接池
            pipe.reset()

    def release(self, identity):
        """
        根据给定的标识符,尝试释放当前客户端持有的信号量。
        返回 True 表示释放成功,返回 False 表示由于标识符不匹配而导致释放失败。
        """
        # 尝试从持有者集合中移除给定的标识符
        result = self.client.srem(self.holder_key, identity)
        # 移除成功则说明信号量释放成功
        return result == 1

Redis流水线与事务重点 ***

  • 在通常情况下,程序需要执行的Redis命令越多,需要进行的网络通信次数也会越多,程序的执行速度也会变得越慢。通过使用Redis的流水线特性,程序可以一次把多个命令发送给Redis服务器,这可以将执行多个命令所需的网络通信次数从原来的N次降低为1次,从而使得程序的执行效率得到显著提升。
  • 通过使用Redis的事务特性,用户可能将多个命令打包成一个命令执行:当事务成功执行时,事务中包含的所有命令都会被执行;相反,如果事务执行失败,那么它包含的所有命令都不会被执行。
  • Redis事务总是具有ACID性质中的原子性、一致性和隔离性,至于是否具有耐久性则取决于Redis使用的持久化模式。
  • Redis事务总是具有ACID性质中的原子性、一致性和隔离性,至于是否具有耐久性则取决于Redis使用的持久化模式。
  • 流水线与事务虽然在概念上有相似之处,但它们并不相等:流水线的作用是打包发送多条命令,而事务的作用则是打包执行多条命令。
  • 为了优化事务的执行效率,很多Redis客户端都会把待执行的事务命令缓存在本地,然后在用户执行EXEC命令时,通过流水线一次把所有事务命令发送至Redis服务器。
  • 通过同时使用WATCH命令和事务,用户可以构建一种乐观锁机制,这种机制可以确保事务只会在指定键没有发生任何变化的情况下执行。
原文地址:https://www.cnblogs.com/paulwhw/p/13435471.html