使用Redis构建 Web应用
之登录和 cookie缓存

990 阅读12分钟

兔哥接下来的几篇主要介绍的内容

■ 登录 cookie

■购物车cookie

■ 缓存生成的网页

■ 缓存数据库行

■ 分析网页访问记录

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

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

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

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

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

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

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

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

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

登录和 cookie 缓存

每当我们登录互联网服务(比如银行账户或者电子邮件)的时候,这些服务都会使用 cookie来记录我们的身份。cookie由少量数据组成,网站会要求我们的浏览器存储这些数据,并在每次服务发送请求时将这些数据传回给服务。对于用来登录的 cookie,有两种常见的方法可以将登录信息存储在cookie里面∶一种是签名(signed)cookie,另一种是令牌(token) cookie。

签名cookie通常会存储用户名,可能还有用户ID、用户最后一次成功登录的时间,以及网站觉得有用的其他任何信息。除了用户的相关信息之外,签名 cookie 还包含一个签名,服务器可以使用这个签名来验证浏览器发送的信息是否未经改动(比如将 cookie中的登录用户名改成另一个用户)。

令牌 cookie会在 cookie里面存储一串随机字节作为令牌,服务器可以根据令牌在数据库中查找令牌的拥有者。随着时间的推移,旧令牌会被新令牌取代。下表展示了签名cookie和令牌 cookie 的优点与缺点。

cookie 类型

优点

缺点

签名 cookie

验证 cookie 所需的一切信息都存储在 cookie 里面。 cookie可以包含额外的信息(additional infomation),关且对这些信息进行签名也很容易

正确地处理签名很难。很容易忘记对数据进行签名,或者忘记验证数据的签名,从而造成安全漏洞

令牌 cookie

添加信息非常容易。cookie 的体积非常小,因此移动终端和速度较慢的客户端可以更快地发送请求

需要在服务器中存储更多信息。如果使用的是关系数据库,那么载入和存储 cookie的代价可能会很高

因为兔哥商城 没有实现签名 cookie 的需求,所以我们选择了使用令牌cookie来引用关系数据库表中负责存储用户登录信息的条目(entry)。除了用户登录信息之外,兔哥商城 还可以将用户的访问时长和已浏览商品的数量等信息存储到数据库里面,这样便于将来通过分析这些信息来学习如何更好地向用户推销商品。

一般来说,用户在决定购买某个或某些商品之前,通常都会先浏览多个不同的商品,而记录用户浏览过的所有商品以及用户最后一次访问页面的时间等信息,通常会导致大量的数据库写入。从长远来看,用户的这些浏览数据的确非常有用,但问题在于,即使经过优化,大多数关系数据库在每台数据库服务器上面每秒也只能插入、更新或者删除 200~2000个数据库行。尽管扑量插入、批量更新和批量删除等操作可以以更快的速度执行,但因为客户端每次浏览网页都只更新少数几个行,所以高速的批量插入在这里并不适用。

因为兔哥商城目前一天的负载量相对比较大——平均情况下每秒大约1200次写入,高峰时期每秒接近 6000次写入,所以它必须部署10台关系数据库服务器才能应对高峰时期的负载量。而我们要做的就是使用Redis重新实现登录cookie功能,取代目前由关系数据库实现的登录 cookie 功能。

首先,我们将使用一个散列来存储登录 cookie 令牌与已登录用户之间的映射。要检查一个用户是否已经登录,需要根据给定的令牌来查找与之对应的用户,并在用户已经登录的情况下,返回该用户的 ID。下面的代码展示检查登录 cookie 的方法。

对令牌进行检查并不困难,因为大部分复杂的工作都是在更新令牌时完成的∶用户每次浏览页面的时候,程序都会对用户存储在登录散列里面的信息进行更新,并将用户的令牌和当前时间戳添加到记录最近登录用户的有序集合里面;如果用户正在浏览的是一个商品页面,那么程序还会将这个商品添加到记录这个用户最近浏览过的商品的有序集合里面,并在被记录商品的数量超过25个时,对这个有序集合进行修剪。下面的代码就展示程序更新令牌的方法。

通过 update_token()函数,我们可以记录用户最后一次浏览商品的时间以及用户最近浏览了哪些商品。在一台最近几年生产的服务器上面,使用update_token()函数每秒至少可以记录20000 件商品,这比兔哥商城高峰时期所需的6000次写入要高3倍有余。不仅如此,通过后面介绍的一些方法,我们还可以进一步优化update_token()函数的运行速度。但即使是现在这个版本的update_token()函数,比起原来的关系数据库,性能也已经提升了10~100倍。

因为存储会话数据所需的内存会随着时间的推移而不断增加,所以我们需要定期清理旧的会话数据。为了限制会话数据的数量,我们决定只保存最新的1000万个会话。"清理旧会话的程序由一个循环构成,这个循环每次执行的时候,都会检查存储最近登录令牌的有序集合的大小,如果有序集合的大小超过了限制,那么程序就会从有序集合里面移除最多100个最旧的令牌,并从记录用户登录信息的散列里面,移除被删除令牌对应的用户的信息,并对存储了这些用户最近浏览商品记录的有序集合进行清理。与此相反,如果令牌的数量未超过限制,那么程序会先休眠1秒,之后再重新进行检查。下面代码展示清理旧会话程序的具体代码。

                                                    代码清单 :清理旧会话

让我们通过计算来了解一下,这段简单的代码为什么能够妥善地处理每天 500 万人次的访问∶假设网站每天有 500 万用户访问,并且每天的用户都和之前的不一样,那么只需要两天,令牌的数量就会达到1000万个的上限,并将网站的内存空间消耗殆尽。因为一天有24×3600=86 400秒,而网站平均每秒产生500000/86400<58个新会话,如果清理函数和我们之前在代码里面定义的一样,以每秒一次的频率运行的话,那么它每秒需要清理将近60个令牌,才能防止令牌数量过多的问题发生。但是实际上,我们定义的令牌清理函数在通过网络来运行时,每秒能够清理 100多个令牌,在本地运行时,每秒能够清理60000多个令牌,这比所需的清理速度快了150~1000 倍,所以因为旧令牌过多而导致网站空间耗尽的问题不会出现。

在哪里执行清理函数? 兔哥的文章会包含一些清理函数,它们可能会以守护进程的方式来运行,也可能会作为定期作业(cronjob)每隔一段时间运行一次,甚至在每次执行某个操作时运行一次(例如,在一个获取锁操作里面包含了一个清理操作)一般来说,包含while not QUIT∶代码的函数都应该作为守护进程来执行,不过如果有需要的话,也可以把它们改成周期性地运行。

Redis 的过期数据处理 随着对 Redis 的了解逐渐加深,大家会慢慢发现兔哥展示的一些解决方案有时候并不是问题的唯一解决办法。比如对于这个登录cokie例子来说,我们可以直接将登录用户和令牌的信息存储到字符串键值对里面,然后使用Redis的EXPIRE命令,为这个字符串和记录用户商品浏览记录的有序集合设置过期时间,让Redis在一段时间之后自动删除它们,这样就不需要再使用有序集合来记录最近出现的令牌了。但是这样一来,我们就没有办法将会话的数量限制在 1000 万之内了,并且在将来有需要的时候,我们也没办法在会话过期之后对被废弃的购物车进行分析了。

熟悉多线程编程或者并发编程的读者可能会发现 “代码清单 :清理旧会话 ” 展示的清理函数实际上包含一个竞争条件(race condition);如果清理函数正在删除某个用户的信息,而这个用户又在同一时间访问网站的话,那么竞争条件就会导致用户的信息被错误地删除。目前来看,这个竞争条件除了会使得用户需要重新登录一次之外,并不会对程序记录的数据产生明显的影响,所以我们暂时先搁置这个问题,之后的文章会说明怎样防止类似的竞争条件发生,并进一步加快清理函数的执行速度。

通过使用Redis来记录用户信息,我们成功地将每天要对数据库执行的行写入操作减少了数百万次。虽然这非常的了不起,但这只是我们使用Redis构建 Web 应用程序的第一步,接下来的一篇文章兔哥将大家展示如何使用 Redis 来处理另一种类型的 cookie。