dot Redis 8 发布了——并且是开源的

了解更多

2.1 登录和 cookie 缓存

返回主页

2.1 登录和 cookie 缓存

每当我们登录到互联网上的服务(例如银行帐户或网络邮件)时,这些服务都会使用cookie记住我们的身份。 Cookie 是小型数据片段,网站要求我们的 Web 浏览器存储并在每次向该服务发出请求时重新发送。 对于登录 cookie,有两种常见的在 cookie 中存储登录信息的方法:签名 cookie 或令牌 cookie。

签名 cookie 通常存储用户的姓名,可能是他们的用户 ID,上次登录的时间以及该服务可能认为有用的任何其他信息。 除了此用户特定信息之外,cookie 还包含一个签名,该签名允许服务器验证浏览器发送的信息是否已被更改(例如,将一个用户的登录名替换为另一个用户的登录名)。

令牌 cookie 使用一系列随机字节作为 cookie 中的数据。 在服务器上,令牌用作密钥,通过查询某种数据库来查找拥有该令牌的用户。 随着时间的推移,可以删除旧令牌以为新令牌腾出空间。 表 2.1 显示了签名 cookie 和令牌 cookie 用于引用信息的一些优点和缺点。

表 2.1 签名 cookie 和令牌 cookie 的优缺点

Cookie 类型

优点

缺点

签名 cookie

验证 cookie 所需的一切都在 cookie 中。
可以轻松包含和签名其他信息

正确处理签名很难。
很容易忘记签名和/或验证数据,从而导致安全漏洞

令牌 cookie

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

服务器上需要存储更多信息。
如果使用关系数据库,则 cookie 的加载/存储可能会很昂贵

为了避免实现签名 cookie,Fake Web Retailer 选择使用令牌 cookie 来引用关系数据库表中的条目,该表存储用户登录信息。 通过将此信息存储在数据库中,Fake Web Retailer 还可以存储诸如用户浏览了多长时间,或他们看过多少项目之类的信息,并在以后分析该信息,以尝试了解如何更好地向其用户进行营销。

正如预期的那样,人们通常会在选择购买一件(或几件)商品之前浏览许多不同的商品,并且记录有关所有看到的商品、用户上次访问页面的时间等信息可能会导致大量的数据库写入。 从长远来看,这些数据很有用,但即使经过数据库调整,大多数关系数据库每秒每个数据库服务器也只能插入、更新或删除大约 200–2,000 个单独的行。 虽然可以更快地执行批量插入/更新/删除,但客户只会为每个网页视图更新少量行,因此更高速的批量插入在此处没有帮助。

目前,由于一天中的负载相对较大(平均每秒大约 1,200 次写入,高峰时每秒接近 6,000 次写入),Fake Web Retailer 不得不设置 10 个关系数据库服务器来处理高峰时段的负载。 我们的工作是从登录 cookie 中删除关系数据库,并用 Redis 替换它们。

首先,我们将使用HASH来存储从登录 cookie 令牌到登录用户的映射。 要检查登录名,我们需要根据令牌获取用户并返回它(如果可用)。 以下清单显示了我们如何检查登录 cookie。

清单 2.1 check_token() 函数
def check_token(conn, token):
	return conn.hget('login:', token)

获取并返回给定的用户(如果可用)。


检查令牌并不是很令人兴奋,因为所有有趣的事情都发生在更新令牌本身时。 对于访问,我们将更新用户的登录HASH,并在最近用户的ZSET中记录令牌的当前时间戳。 如果用户正在查看某个项目,我们还会将该项目添加到用户最近查看的ZSET中,并修剪该ZSET(如果它增长到超过 25 个项目)。 下面可以看到执行所有这些操作的函数。

清单 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)

记录用户查看了该项目。

		conn.zremrangebyrank('viewed:' + token, 0, -26)

删除旧项目,保留最近的 25 个。


你知道吗? 就这样。 我们现在已经记录了具有给定会话的用户上次查看某个项目的时间以及该用户最近查看的商品。 在过去几年中制造的服务器上,您每秒至少可以记录 20,000 个项目视图的信息,这比我们需要对数据库执行的操作多三倍以上。 这可以做得更快,我们将在稍后讨论。 但是即使对于此版本,我们在此环境中的性能也比典型的关系数据库提高了 10-100 倍。

随着时间的推移,内存使用量会增加,我们将需要清理旧数据。 作为限制数据的一种方式,我们将仅保留最近的 1000 万个会话。1 对于我们的清理工作,我们将在循环中获取ZSET的大小。 如果ZSET太大,我们将一次最多获取 100 个最旧的项目(因为我们使用的是时间戳,这只是ZSET中的前 100 个项目),将它们从最近的ZSET中删除,从登录HASH中删除登录令牌,并删除相关的已查看ZSET。 如果ZSET不太大,我们将休眠一秒钟,稍后再试。 下面显示了用于清理旧会话的代码。

清单 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
	end_index = min(size - LIMIT, 100)
	tokens = conn.zrange('recent:', 0, end_index-1)

获取应删除的令牌 ID。

	session_keys = []
	for token in tokens:
		session_keys.append('viewed:' + token)

准备要删除的令牌的键名。

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

删除最旧的令牌。


如此简单的东西如何扩展到每天处理五百万用户? 让我们检查一下数字。 如果我们预计每天有五百万个唯一用户,那么在两天内(如果我们每天总是获得新用户),我们将耗尽空间,并且需要开始删除令牌。 一天有 24 x 3600 = 86,400 秒,因此平均每秒有 500 万/86,400 < 58 个新会话。 如果我们每秒运行一次清理函数(如我们的代码所实现的那样),我们将每秒清理不到 60 个令牌。 但是此代码实际上可以通过网络每秒清理超过 10,000 个令牌,并且在本地每秒清理超过 60,000 个令牌,这比我们需要的速度快 150-1,000 倍。

在哪里运行清理函数本书中的此示例和其他示例有时会包含清理函数,例如清单 2.3。 根据清理函数,可以将其编写为作为守护程序进程运行(如清单 2.3),定期通过 cron 作业运行,甚至在每次执行期间运行(第 6.3 节实际上将清理操作作为“获取”操作的一部分包含)。 通常,如果该函数包含while not QUIT:行,则应该将其作为守护程序运行,尽管根据其目的,可以对其进行修改以定期运行。

用于传递和接收可变数量的参数的 PYTHON 语法在清单 2.3 中,您会注意到我们使用类似于conn.delete(*vtokens)的语法调用了三个函数。 基本上,我们将一系列参数传递给底层函数,而无需事先解包参数。 有关此工作方式的语义的更多详细信息,您可以通过访问此短网址访问 Python 语言教程网站:https://mng.bz/8I7W

在 Redis 中过期数据当您了解有关 Redis 的更多信息时,您可能会发现我们提出的某些解决方案并非解决问题的唯一方法。 在这种情况下,我们可以省略最近的ZSET,将登录令牌存储为纯键值对,并使用 Redis EXPIRE设置将来的日期或时间来清理会话和我们最近查看的ZSET。 但是使用EXPIRE阻止我们明确将我们的会话信息限制为 1000 万用户,并阻止我们在会话过期期间执行废弃的购物车分析(如果将来需要)。

那些熟悉线程或并发编程的人可能已经看到,前面的清理函数存在竞争条件,在这种条件下,从技术上讲,用户有可能在我们删除其信息的那一秒钟内设法访问该站点。 我们不打算在此处担心这一点,因为它不太可能发生,并且因为它不会对我们记录的数据造成重大更改(除了要求用户再次登录)。 我们将在第 3 章和第 4 章中讨论如何防止竞争条件以及我们甚至如何加快删除操作。

我们已经减少了每天将写入数据库的行数,减少了数百万行。 这很棒,但这仅仅是在我们的 Web 应用程序中使用 Redis 的第一步。 在下一节中,我们将使用 Redis 处理另一种 cookie。

1 请记住,这些类型的限制仅作为您可以在大型生产环境中使用的示例。 请随意将这些数字减少到更小的数字,以便在您的测试和开发中查看它们是否有效。