第2章 使用Redis构建Web应用

本章涵盖:

  • 登录cookie

  • 购物车cookie

  • 缓存生成的网页

  • 缓存数据库行

  • 分析网页访问记录

前面的第1章对Redis的特性和功能做了简单的介绍,本章将紧接上一章的步伐,通过几个示例,对一些类型的Web应用进行介绍。尽管本章展示的问题比起实际情况要简单得多,但这里给出的网络应用实际上只需要进行少量修改就可以直接应用到真实的程序里面。本章的主要任务是作为一个实用指南,告诉你可以使用Redis来做些什么事情,而之后的第3章将对Redis命令进行更详细的介绍。

从高层次的角度来看,Web应用就是通过HTTP协议对网页浏览器发送的请求进行响应的服务器或者服务(service)。一个Web服务器对请求进行响应的典型步骤如下:

  1. 服务器对客户端发来的请求(request)进行解析。

  2. 请求被转发给一个预定义的处理器(handler)。

  3. 处理器可能会从数据库中取出数据。

  4. 处理器根据取出的数据对模板(template)进行渲染(render)。

  5. 处理器向客户端返回渲染后的内容作为对请求的响应(response)。

以上列举的5个步骤从高层次的角度展示了典型Web服务器的运作方式,这种情况下的Web请求被认为是无状态(stateless)的,也就是说,服务器本身不会记录与过往请求有关的任何信息,这使得失效(fail)的服务器可以很容易地被替换掉。有不少书籍专门介绍了如何优化响应过程的各个步骤,本书要做的事情也和它们类似,不同之处在于,本书讲解的是如何使用更快的Redis查询来代替传统的关系数据库查询,以及如何使用Redis来完成一些使用关系数据库没办法高效完成的任务。

本章的所有内容都是围绕着发现并解决Fake Web Retailer这个虚构的大型网上商店来展开的,这个商店每天会有大约500万名不同用户带来的1亿次点击和10万多件商品的成交。我们之所以将Fake Web Retailer的几个数据量设置得特别大,是考虑到如果可以在大数据量背景下顺利地解决问题,那么解决小数据量和中等数据量引发的问题就更不在话下了。另外,尽管本章展示的解决方案都是为了解决Fake Web Retailer这个大型网店所遇到的问题而给出的,但除了其中一个解决方案之外,其他所有解决方案都可以在一个只有几GB内存的Redis服务器上面使用,并且所有这些解决方案的目标都是提高系统响应实时请求的性能。

本章列举的所有解决方案(以及它们的一些变种)都在生产环境中实际使用过。说得更具体一点,通过将传统数据库的一部分数据处理任务以及存储任务转交给Redis来完成,可以提升网页的载入速度,并降低资源的占用量。

我们要解决的第一个问题就是使用Redis来管理用户登录会话(session)。

2.2 使用Redis实现购物车

网景(Netscape)公司在90年代中期最先在网络中使用了cookie,这些cookie最终变成了我们在上一节讨论的登录会话cookie。cookie最初的意图在于为网络零售商(web retailer)提供一种购物车,让用户可以收集他们想要购买的商品。在cookie之前,有过几种不同的购物车解决方案,但这些方案全都不太好用。

使用cookie实现购物车——也就是将整个购物车都存储到cookie里面的做法非常常见,这种做法的一大优点是无须对数据库进行写入就可以实现购物车功能,而缺点则是程序需要重新解析和验证(validate)cookie,确保cookie的格式正确,并且包含的商品都是真正可购买的商品。cookie购物车还有一个缺点:因为浏览器每次发送请求都会连cookie一起发送,所以如果购物车cookie的体积比较大,那么请求发送和处理的速度可能会有所降低。

因为我们在前面已经使用Redis实现了会话cookie和记录用户最近浏览过的商品这两个特性,所以我们决定将购物车的信息也存储到Redis里面,并且使用与用户会话cookie相同的cookie ID来引用购物车。

购物车的定义非常简单:每个用户的购物车都是一个散列,这个散列存储了商品ID与商品订购数量之间的映射。对商品数量进行验证的工作由Web应用程序负责,我们要做的是在商品的订购数量出现变化时,对购物车进行更新:如果用户订购某件商品的数量大于0,那么程序会将这件商品的ID以及用户订购该商品的数量添加到散列里面,如果用户购买的商品已经存在于散列里面,那么新的订购数量会覆盖已有的订购数量;相反地,如果用户订购某件商品的数量不大于0,那么程序将从散列里面移除该条目。代码清单2-4的add_to_cart()函数展示了程序是如何更新购物车的。


代码清单2-4 add_to_cart()函数

def add_to_cart(conn, session, item, count):
    if count <= 0:
        # 从购物车里面移除指定的商品。
        conn.hrem('cart:' + session, item)
    else:
        # 将指定的商品添加到购物车。
        conn.hset('cart:' + session, item, count)

接着,我们需要对之前的会话清理函数进行更新,让它在清理旧会话的同时,将旧会话对应用户的购物车也一并删除,更新后的函数如代码清单2-5所示。


代码清单2-5 clean_full_sessions()函数

def clean_full_sessions(conn):
    while not QUIT:
        size = conn.zcard('recent:')
        if size <= LIMIT:
            time.sleep(1)
            continue

        end_index = min(size - LIMIT, 100)
        sessions = conn.zrange('recent:', 0, end_index-1)

        session_keys = []
        for sess in sessions:
            session_keys.append('viewed:' + sess)
            session_keys.append('cart:' + sess)   # 新增加的这行代码用于删除旧会话对应用户的购物车。

        conn.delete(*session_keys)
        conn.hdel('login:', *sessions)
        conn.zrem('recent:', *sessions)

我们现在将会话和购物车都存储到了Redis里面,这种做法除了可以减少请求的体积之外,还使得我们可以根据用户浏览过的商品、用户放入购物车的商品以及用户最终购买的商品进行统计计算,并构建起很多大型网络零售商都在提供的“在查看过这件商品的用户当中,有X%的用户最终购买了这件商品”、“购买了这件商品的用户也购买了某某其他商品”等功能,这些功能可以帮助用户查找其他相关的商品,并最终提升网站的销售业绩。

通过将会话cookie和购物车cookie存储在Redis里面,我们得到了进行数据分析所需的两个重要的数据来源,接下来的一节将展示如何使用缓存来减少数据库和Web前端(front-end)的负载。

2.3 网页缓存

在动态生成网页的时候,通常会使用模板语言(templating language)来简化网页的生成操作。需要手写每个页面的日子已经一去不复返——现在的Web页面通常由包含首部、尾部、侧栏菜单、工具条、内容域的模板生成,有时候模板还用于生成JavaScript。

尽管Fake Web Retailer也能够动态地生成内容,但这个网站上的多数页面实际上并不会经常发生大的变化:虽然会向分类中添加新商品、移除旧商品、有时有特价促销、有时甚至还有“热卖商品”页面,但是在一般情况下,网站只有账号设置、以往订单、购物车(结账信息)以及其他少数几个页面才包含需要每次载入都要动态生成的内容。

通过对浏览数据进行分析,Fake Web Retailer发现自己所处理的95%的Web页面每天最多只会改变一次,这些页面的内容实际上并不需要动态地生成,而我们的工作就是想办法不再生成这些页面。减少网站在动态生成内容上面所花的时间,可以降低网站处理相同负载所需的服务器数量,并让网站的速度变得更快。(研究表明,减少用户等待页面载入的时间,可以增加用户使用网站的欲望,并改善用户对网站的印象。)

所有标准的Python应用框架都提供了在处理请求之前或者之后添加层(layer)的能力,这些层通常被称为中间件(middleware)或者插件(plugin)。我们将创建一个这样的层来调用Redis缓存函数:对于一个不能被缓存的请求,函数将直接生成并返回页面;而对于可以被缓存的请求,函数首先会尝试从缓存里面取出并返回被缓存的页面,如果缓存页面不存在,那么函数会生成页面并将其缓存在Redis里面5分钟,最后再将页面返回给函数调用者。代码清单2-6展示了这个缓存函数。


代码清单2-6 cache_request()函数

def cache_request(conn, request, callback):
    # 对于不能被缓存的请求,直接调用回调函数。
    if not can_cache(conn, request):
        return callback(request)

    # 将请求转换成一个简单的字符串键,方便之后进行查找。
    page_key = 'cache:' + hash_request(request)
    # 尝试查找被缓存的页面。
    content = conn.get(page_key)

    if not content:
        # 如果页面还没有被缓存,那么生成页面。
        content = callback(request)
        # 将新生成的页面放到缓存里面。
        conn.setex(page_key, content, 300)

    # 返回页面。
    return content

对于Fake Web Retailer网站上面95%的可被缓存并且频繁被载入的内容来说,代码清单2-6展示的缓存函数可以让网站在5分钟之内无需再为它们动态地生成视图页面。取决于网页的内容有多复杂,这一改动可以将包含大量数据的页面的延迟值从20到50毫秒降低至查询一次Redis所需的时间:查询本地Redis的延迟值通常低于1毫秒,而查询位于同一个数据中心的Redis的延迟值通常低于5毫秒。对于那些需要访问数据库的页面来说,这个缓存函数对于减少页面载入时间和降低数据库负载的作用会更加显著。

在这一节,我们学习了如何使用Redis来减少载入不常改变页面所需的时间,那么对于那些经常发生变化的页面,我们是否也能够使用Redis来减少它们的载入时间呢?答案是肯定的,接下来的一节将介绍实现这一目标的具体做法。

2.4 数据行缓存

到目前为止,我们已经将原本由关系数据库和网页浏览器实现的登录和访客会话转移到了Redis上面实现;将原本由关系数据库实现的购物车也放到了Redis上面实现;还将所有页面缓存到了Redis里面。这一系列工作提升了网站的性能,降低了关系数据库的负载并减少了网站成本。

Fake Web Retailer的商品页面通常只会从数据库里面载入一两行数据:包括已登录用户的用户信息(这些信息可以通过AJAX动态地载入,所以不会对页面缓存造成影响)和商品本身的信息。即使是那些无法被整个缓存起来的页面——比如用户账号页面、记录用户以往购买商品的页面等等,程序也可以通过缓存页面载入时所需的数据库行来减少载入页面所需的时间。

为了展示数据行缓存的作用,我们假设Fake Web Retailer为了清空旧库存和吸引客户消费,决定开始新一轮的促销活动:这个活动每天都会推出一些特价商品供用户抢购,所有特价商品的数量都是限定的,卖完即止。在这种情况下,网站是不能对整个促销页面进行缓存的,因为这可能会导致用户看到错误的特价商品剩余数量,但是每次载入页面都从数据库里面取出特价商品的剩余数量的话,又会给数据库带来巨大的压力,并导致我们需要花费额外的成本来扩展数据库。

为了应对促销活动带来的大量负载,我们需要对数据行进行缓存,具体的做法是:编写一个持续运行的守护进程函数,让这个函数将指定的数据行缓存到Redis里面,并不定期地对这些缓存进行更新。缓存函数会将数据行编码(encode)为JSON字典并存储在Redis的字符串里面,其中,数据列(column)的名字会被映射为JSON字典的键,而数据行的值则会被映射为JSON字典的值,图2-1展示了一个被缓存的数据行示例。


../_images/2-1.png

图2-1 一个被缓存的数据行,这个数据行包含了在线售卖商品的信息


程序使用了两个有序集合来记录应该在何时对缓存进行更新:第一个有序集合为调度(schedule)有序集合,它的成员为数据行的行ID,而分值则是一个时间戳,这个时间戳记录了应该在何时将指定的数据行缓存到Redis里面;第二个有序集合为延时(delay)有序集合,它的成员也是数据行的行ID,而分值则记录了指定数据行的缓存需要每隔多少秒更新一次。

Note

使用JSON而不是其他格式

因为JSON简明易懂,并且据我们所知,目前所有拥有Redis客户端的编程语言都带有能够高效地编码和解码JSON格式的函数库,所以这里的缓存函数使用了JSON格式来表示数据行,而没有使用XML、Google的协议缓冲区(protocol buffer)、Thrift、BSON、MessagePack或者其他序列化格式。在实际应用中,读者可以根据自己的需求和喜好来选择编码数据行的格式。

Note

嵌套多个结构

使用过其他非关系数据库的用户可能会期望Redis也拥有嵌套多个结构的能力,比如说,一个刚开始使用Redis的用户可能会期盼着散列能够包含有序集合值或者列表值。尽管嵌套结构这个特性在概念上并无不妥,但这个特性很快就会引起类似以下这样的问题:“对于一个位于嵌套第5层的散列,我们如何才能对它的值执行自增操作呢?”为了保证命令语法的简单性,Redis并不支持嵌套结构特性。如果有需要的话,读者可以通过使用键名来模拟嵌套结构特性:比如使用键user:123表示存储用户信息的散列,并使用键user:123:posts表示存储用户最近发表文章的有序集合;又或者直接将嵌套结构存储到JSON或者其他序列化格式里面(第11章将介绍使用Lua脚本在服务器端直接以JSON格式或者MessagePack格式对数据进行编码的方法)。

为了让缓存函数定期地缓存数据行,程序首先需要将行ID和给定的延迟值添加到延迟有序集合里面,然后再将行ID和当前时间的时间戳添加到调度有序集合里面。实际执行缓存操作的函数需要用到数据行的延迟值,如果某个数据行的延迟值不存在,那么这个调度商品将会被移除。如果想移除数据行已有的缓存,并且缓存函数也不再缓存这个数据行,那么可以把这个数据行的延迟值设置为值小于或等于0。代码清单2-7展示了负责调度缓存和终止缓存的函数。


代码清单2-7 schedule_row_cache()函数

def schedule_row_cache(conn, row_id, delay):
    # 先设置数据行的延迟值。
    conn.zadd('delay:', row_id, delay)
    # 立即缓存数据行。
    conn.zadd('schedule:', row_id, time.time())

现在我们已经完成了调度部分,那么接下来如何缓存行呢?负责缓存数据行的函数会尝试读取调度有序集合的第一个元素以及该元素的分值,如果调度有序集合没有包含任何元素,或者分值存储的时间戳所指定的时间尚未来临,那么函数会先休眠50毫秒,然后再重新进行检查。当缓存函数发现一个需要立即进行更新的数据行时,缓存函数会检查这个数据行的延迟值:如果数据行的延迟值小于或者等于0,那么缓存函数会从延迟有序集合和调度有序集合里面移除这个数据行的ID,并从缓存里面删除这个数据行已有的缓存,然后再重新进行检查;对于延迟值大于0的数据行来说,缓存函数会从数据库里面取出这些行,将它们编码为JSON格式并存储到Redis里面,然后更新这些行的调度时间。执行以上工作的缓存函数如代码清单2-8所示。


代码清单2-8 守护进程函数cache_rows()

def cache_rows(conn):
    while not QUIT:
        # 尝试获取下一个需要被缓存的数据行以及该行的调度时间戳,
        # 命令会返回一个包含零个或一个元组(tuple)的列表。
        next = conn.zrange('schedule:', 0, 0, withscores=True)
        now = time.time()
        if not next or next[0][1] > now:
            # 暂时没有行需要被缓存,休眠50毫秒后重试。
            time.sleep(.05)
            continue

        row_id = next[0][0]
        # 获取下一次调度前的延迟时间。
        delay = conn.zscore('delay:', row_id)
        if delay <= 0:
            # 不必再缓存这个行,将它从缓存中移除。
            conn.zrem('delay:', row_id)
            conn.zrem('schedule:', row_id)
            conn.delete('inv:' + row_id)
            continue

        # 读取数据行。
        row = Inventory.get(row_id)
        # 更新调度时间并设置缓存值。
        conn.zadd('schedule:', row_id, now + delay)
        conn.set('inv:' + row_id, json.dumps(row.to_dict()))

通过组合使用调度函数和持续运行缓存函数,我们实现了一种重复进行调度的自动缓存机制,并且可以随心所欲地控制数据行缓存的更新频率:如果数据行记录的是特价促销商品的剩余数量,并且参与促销活动的用户非常多的话,那么我们最好每隔几秒钟更新一次数据行缓存;另一方面,如果数据并不经常改变,或者商品缺货是可以接受的,那么我们可以每分钟更新一次缓存。

在这一节,我们学习了如何将数据行缓存到Redis里面,在接下来的一节,我们将通过只缓存一部分页面来减少实现页面缓存所需的内存数量。

2.5 网页分析

网站可以从用户的访问、交互和购买行为中收集到有价值的信息。例如,如果我们只想关注那些浏览量最高的页面,那么我们可以尝试修改页面的格局、配色甚至是页面上展示的其他链接。每一个修改尝试都能改变用户对一个页面或者后续页面的体验,或好或坏,甚至还能影响用户的购买行为。

前面的2.1节和2.2节介绍了如何记录用户浏览过的商品或者用户添加到购物车中的商品,2.3节则介绍了如何通过缓存Web页面来减少页面载入时间并提升页面的响应速度。不过遗憾的是,我们对Fake Web Retailer采取的缓存措施做得过了火:Fake Web Retailer总共包含100 000件商品,而冒然地缓存所有商品页面将耗尽整个网站的全部内存!经过一番调研之后,我们决定只对其中10 000件商品的页面进行缓存。

前面的 2.1 节曾经介绍过, 每个用户都有一个相应的记录用户浏览商品历史的有序集合, 尽管使用这些有序集合可以计算出用户最经常浏览的商品, 但进行这种计算却需要耗费大量的时间。 为了解决这个问题,我们决定在update_token()函数里面添加一行代码,如代码清单2-2所示。


代码清单 2-9 修改后的update_token()函数

def update_token(conn, token, user, item=None):
    timestamp = time.time()
    conn.hset('login:', token, user)
    conn.zadd('recent:', token, timestamp)
    if item:
        conn.zadd('viewed:' + token, item, timestamp)
        conn.zremrangebyrank('viewed:' + token, 0, -26)
        conn.zincrby('viewed:', item, -1)                   # 这行代码是新添加的。

新添加的代码记录了所有商品的浏览次数,并根据浏览次数对商品进行了排序,被浏览得最多的商品将被放到有序集合的索引0位置上,并且具有整个有序集合最少的分值。随着时间的流逝,商品的浏览次数会呈现两极分化的状态,一些商品的浏览次数会越来越多,而另一些商品的浏览次数则会越来越少。除了缓存最常被浏览的商品之外,程序还需要发现那些变得越来越流行的新商品,并在合适的时候缓存它们。

为了让商品浏览次数排行榜能够保持最新,我们需要定期修剪有序集合的长度并调整已有元素的分值,从而使得新流行的商品也可以在排行榜里面占据一席之地。之前的2.1节已经介绍过从有序集合里面移除元素的方法,而调整元素分值的动作则可以通过ZINTERSTORE命令来完成。ZINTERSTORE命令可以组合起一个或多个有序集合,并将有序集合包含的每个分值都乘以一个给定的数值(用户可以为每个有序集合分别指定不同的相乘数值)。每隔5分钟,代码清单2-10展示的函数就会删除所有排名在20 000名之后的商品,并将删除之后剩余的所有商品的浏览次数减半。


代码清单2-10 守护进程函数rescale_viewed()

def rescale_viewed(conn):
    while not QUIT:
        # 删除所有排名在20 000名之后的商品。
        conn.zremrangebyrank('viewed:', 0, -20001)
        # 将浏览次数降低为原来的一半
        conn.zinterstore('viewed:', {'viewed:': .5})
        # 5分钟之后再执行这个操作。
        time.sleep(300)

通过记录商品的浏览次数,并定期对记录浏览次数的有序集合进行修剪和分值调整,我们为Fake Web Retailer建立起了一个持续更新的最常浏览商品排行榜。接下来要做的就是修改之前介绍过的can_cache()函数,让它使用新的方法来判断页面是否需要被缓存,如代码清单2-11所示。


代码清单2-11 can_cache()函数

def can_cache(conn, request):
    # 尝试从页面里面取出商品ID。
    item_id = extract_item_id(request)
    # 检查这个页面能否被缓存以及这个页面是否为商品页面。
    if not item_id or is_dynamic(request):
        return False
    # 取得商品的浏览次数排名。
    rank = conn.zrank('viewed:', item_id)
    # 根据商品的浏览次数排名来判断是否需要缓存这个页面。
    return rank is not None and rank < 10000

通过使用前面介绍的几个函数,Fake Web Retailer现在可以统计商品被浏览的次数,并以此来缓存用户最经常浏览的10 000个商品页面。如果我们想以最少的代价来存储更多页面,那么可以考虑先对页面进行压缩,然后再缓存到Redis里面;或者使用Edge Side Includes技术移除页面中的部分内容;又或者对模板进行提前优化(pre-optimize),移除所有非必要的空格字符。这些技术能够减少内存消耗并增加Redis能够缓存的页面数量,为访问量不断增长的网站带来额外的性能提升。

2.6 小结

本章介绍了几种用于降低Fake Web Retailer的数据库负载和Web服务器负载的方法,这些例子里面介绍的思路和方法都是当今真实的Web应用程序正在使用的。

本章希望向读者传达这样一个概念:在为应用程序创建新构件时,不要害怕回过头去重构已有的构件,因为就像本章展示的购物车cookie的例子和基于登录会话cookie实现网页分析的例子一样,已有的构件有时候需要进行一些细微的修改才能真正满足你的需求。本书之后的章节也会继续引入新的主题,并且偶尔会回过头去审视之前介绍过的主题,对它们的功能或者性能进行改进,又或者重用之前已经介绍过的思路。

本章向读者介绍了怎样使用Redis来构建真实的应用程序组件,下一章将向读者介绍Redis提供的各种命令:通过更深入地了解Redis提供的各种结构以及这些结构的作用,读者将掌握到构建更复杂也更有用的组件所需的知识。不要犹豫,赶快阅读下一章吧!