第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.1 登录和cookie缓存 --------------------------------- 每当我们登录互联网服务(比如银行账户或者电子邮件)的时候,这些服务都会使用\ *cookie*\ 来记录我们的身份。cookie由少量数据组成,网站会要求我们的浏览器存储这些数据,并且在每次服务发出请求时再将这些数据传回给服务。对于用来登录的cookie,有两种常见的方法可以将登录信息存储在cookie里面:一种是签名(signed)cookie,另一种是令牌(token)cookie。 *签名cookie*\ 通常会存储用户名,可能还有他们的用户ID、用户最后一次成功登录的时间,以及网站觉得有用的其他任何信息。除了用户的相关信息之外,签名cookie还包含一个签名,服务器可以使用这个签名来验证浏览器发送的信息是否未经改动(比如将cookie中的登录用户名改成另一个用户)。 *令牌cookie*\ 会在cookie里面存储一串随机字节作为令牌,服务器可以根据令牌在数据库中查找令牌的拥有者。随着时间的推移,旧令牌会被新令牌取代。表2-1展示了签名cookie和令牌cookie的优点与缺点。 ---- 表2-1 签名cookie和令牌cookie的优点与缺点 +---------------+---------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------+ | cookie类型 | 优点 | 缺点 | +===============+===================================================================================================+===========================================================================+ | 签名cookie | 验证cookie所需的一切信息都存储在cookie里面。cookie可以包含额外的信息(additional infomation), | | | | 并且对这些信息进行签名也很容易 | 正确地处理签名很难。很容易忘记对数据进行签名,或者忘记验证数据的签名, | | | | 从而造成安全漏洞 | +---------------+---------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------+ | 令牌cookie | 添加信息非常容易。cookie的体积非常小,因此移动终端和速度较慢的客户端可以更快地发送请求 | 需要在服务器中存储更多信息。如果使用的是关系数据库, | | | | 那么载入和存储cookie的代价可能会很高 | +---------------+---------------------------------------------------------------------------------------------------+---------------------------------------------------------------------------+ ---- 因为Fake Web Retailer没有实现签名cookie的需求,所以我们选择了使用令牌cookie来引用关系数据库表中负责存储用户登录信息的条目(entry)。除了用户登录信息之外,Fake Web Retailer还可以将用户的访问时长和已浏览的商品数量等信息存储到数据库里面,这样便于将来通过分析这些信息来学习如何更好地向用户推销商品。 一般来说,用户在决定购买某个或某些商品之前,通常都会先浏览多个不同的商品,而记录用户浏览过的所有商品以及用户最后一次访问页面的时间等信息,通常会导致大量的数据库写入。从长远来看,用户的这些浏览数据的确非常有用,但问题在于,即使经过优化,大多数关系数据库在每台数据库服务器上面每秒钟也只能插入、更新或者删除大约200到2 000个数据库行。尽管批量(bulk)插入、批量更新和批量删除等操作可以以更快的速度执行,但因为客户端每次浏览网页都只更新少数几个行,所以高速的批量插入在这里并不适用。 因为Fake Web Retailer目前一天的负载量相对比较大——平均情况下每秒钟大约1 200次写入,高峰时期每秒钟接近6 000次写入,所以它必须部署10台关系数据库服务器才能应对高峰时期的负载量。而我们要做的就是使用Redis重新实现登录cookie功能,取代目前由关系数据库实现的登录cookie功能。 首先,我们将使用一个散列来存储登录cookie令牌与已登录用户之间的映射。要检查一个用户是否已经登录,需要根据给定的令牌来查找与之对应的用户,并在用户已经登录的时候,返回该用户的ID。代码清单2-1展示了检查登录cookie的方法。 ---- 代码清单2-1 ``check_token()``\ 函数 :: def check_token(conn, token): return conn.hget('login:', token) # 尝试获取并返回令牌对应的用户。 ---- 对令牌进行检查并不困难,因为大部分复杂的工作都是在更新令牌时完成的:用户每次浏览页面的时候,程序都会对用户存储在登录散列里面的信息进行更新,并将用户的令牌和当前时间戳添加到记录最近登录用户的有序集合里面;如果用户正在浏览的是一个商品页面,那么程序还会将这个商品添加到记录这个用户最近浏览过的商品的有序集合里面,并在被记录商品的数量超过25个时,对这个有序集合进行修剪。代码清单2-2展示了程序更新令牌的方法。 ---- 代码清单2-2 ``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) # 移除旧的记录,只保留用户最近浏览过的25个商品。 conn.zremrangebyrank('viewed:' + token, 0, -26) ---- 通过\ ``update_token()``\ 函数,我们可以记录用户最后一次浏览商品的时间以及用户最近浏览了哪些商品。在一台最近几年生产的服务器上面,使用\ ``update_token()``\ 函数每秒钟至少可以记录两20 000件商品,这比Fake Web Retailer高峰时期所需的6 000次写入要高3倍有余。不仅如此,通过后面介绍的一些方法,我们还可以进一步优化\ ``update_token()``\ 函数的运行速度。但即使是现在这个版本的\ ``update_token()``\ 函数,比起原来的关系数据库,性能也已经提升了10到100倍。 因为存储会话数据所需的内存会随着时间的推移而不断增加,所以我们需要定期清理旧的会话数据。为了限制会话数据的数量,我们决定只保存最新的1 000万个会话。\ [#f1]_\ 清理旧会话的程序由一个循环构成,这个循环每次执行的时候,都会检查存储最近登录令牌的有序集合的大小,如果有序集合的大小超过了限制,那么程序就会从有序集合里面移除最多100个最旧的令牌,并从记录用户登录信息的散列里面,移除被删除令牌对应的用户的信息,并对存储了这些用户最近浏览商品记录的有序集合进行清理。与此相反,如果令牌的数量未超过限制,那么程序会先休眠1秒钟,之后再重新进行检查。代码清单2-3展示了清理旧会话程序的具体代码。 ---- 代码清单2-3 ``clean_sessions()``\ 函数 :: QUIT = False LIMIT = 10000000 def clean_sessions(conn): while not QUIT: # 找出目前已有令牌的数量。 size = conn.zcard('recent:') # 令牌数量未超过限制,休眠并在之后重新检查。 if size <= LIMIT: time.sleep(1) continue # 获取需要移除的令牌ID。 end_index = min(size - LIMIT, 100) tokens = conn.zrange('recent:', 0, end_index-1) # 为那些将要被删除的令牌构建键名。 session_keys = [] for token in tokens: session_keys.append('viewed:' + token) # 移除最旧的那些令牌。 conn.delete(*session_keys) conn.hdel('login:', *tokens) conn.zrem('recent:', *tokens) ---- 让我们通过计算来了解一下,这段简单的代码为什么能够妥善地处理每天五百万人次的访问:假设网站每天有五百万用户访问,并且每天的用户都和之前的不一样,那么只需要两天,令牌的数量就会达到一千万个的上限,并将网站的内存空间消耗殆尽。因为一天有24×3 600=86 400秒,而网站平均每秒钟产生5 000 000/86 400<58个新会话,如果清理函数和我们之前在代码里面定义的一样,以每秒钟一次的频率运行的话,那么它每秒钟需要清理将近60个令牌,才能防止令牌数量过多的问题发生。但是实际上,我们定义的令牌清理函数在通过网络来运行时,每秒钟能够清理10 000多个令牌,在本地运行时,每秒钟能够清理60 000多个令牌,这比所需的清理速度快了150到1 000倍,所以因为旧令牌过多而导致网站空间耗尽的问题不会出现。 .. note:: 在哪里执行清理函数? 本书会包含一些类似代码清单2-3的清理函数,它们可能会像代码清单2-3那样,以守护进程的方式来运行,也可能会作为定期任务(cron job)每隔一段时间运行一次,甚至在每次执行某个操作时运行一次(例如6.3节就在一个获取锁操作里面包含了一个清理操作)。一般来说,本书中包含\ ``while not QUIT:``\ 代码的函数都应该作为守护进程来执行,不过如果有需要的话,也可以把它们改成周期性地运行。 .. note:: Python传递和接收可变数量参数的语法 代码清单2-3用到了三次类似\ ``conn.delete(*vtokens)``\ 这样的语法。简单来说,这种语法可以直接将一连串的多个参数传入到函数里面,而不必先对这些参数进行解包(unpack)。要了解关于这一语法的更多信息,请通过以下短链接访问Python入门指南的相关章节:\ http://mng.bz/8I7W。 .. note:: Redis的过期数据处理 随着对Redis的了解逐渐加深,读者应该会慢慢发现本书展示的一些解决办法有时候并不是问题的唯一解决办法。比如对于这个登录cookie例子来说,我们可以直接将登录用户和令牌的信息存储到字符串键值对里面,然后使用Redis的\ ``EXPIRE``\ 命令,为这个字符串和记录用户商品浏览记录的有序集合设置过期时间,让Redis在一段时间之后自动删除它们,这样就不需要再使用有序集合来记录最近出现的令牌了。但是这样一来,我们就没有办法将会话的数量限制在1 000万之内了,并且在将来有需要的时候,我们也没办法在会话过期之后对被废弃的购物车进行分析了。 熟悉多线程编程或者并发编程的读者可能会发现代码清单2-3展示的清理函数实际上包含一个竞争条件(race condition):如果清理函数正在删除某个用户的信息,而这个用户又在同一时间访问网站的话,那么竞争条件就会导致用户的信息被错误地删除。目前来看,这个竞争条件除了会使得用户需要重新登录一次之外,并不会对程序记录的数据产生明显的影响,所以我们暂时先搁置这个问题,之后的第3章和第4章会说明怎样防止类似的竞争条件发生,并进一步加快清理函数的执行速度。 通过使用Redis来记录用户信息,我们成功地将每天要对数据库执行的行写入操作减少了数百万次。虽然这非常的了不起,但这只是我们使用Redis构建Web应用程序的第一步,接下来的一节将向读者们展示如何使用Redis来处理另一种类型的cookie。 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展示了一个被缓存的数据行示例。 ---- .. image:: image/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提供的各种结构以及这些结构的作用,读者将掌握到构建更复杂也更有用的组件所需的知识。不要犹豫,赶快阅读下一章吧! ---- .. [#f1] 因为Fake Web Retailer这个示例假设的是生产环境,所以保存会话的数量会设置得比较高,在测试或者开发这个程序的时候,读者可以按照自己的需要调低这个值。