map-reduce 操作(我在第 1 章和第 6 章中提到过)的一个常见用途是计算网站的唯一身份访问者。我们可以不用等到一天结束时才执行该计算,而是保持对唯一身份访问者的实时更新计数。在 Redis 中计算唯一身份访问者的一种方法是使用 SET,但单个 SET 存储许多唯一身份访问者会非常大。在本节中,我们将对 SET 进行分片,以此来构建一种计算网站唯一身份访问者的方法。
首先,我们将假设每个访问者都有一个唯一的标识符,类似于我们在第 2 章中为登录会话 cookie 生成的 UUID。虽然我们可以直接在 SET 中使用这些 UUID 作为成员,并使用第 9.2.1 节中的分片函数对键进行分片,但我们会失去 intset 编码的优势。假设我们随机生成 UUID(就像我们在之前的章节中所做的那样),我们可以使用 UUID 的前 15 个十六进制数字作为完整的键。这应该会引出两个问题:首先,我们为什么要这样做?其次,这足够吗?
对于第一个问题(我们为什么要这样做),UUID 基本上是 128 位数字,以易于阅读的方式格式化。如果我们要存储它们,每个唯一身份访问者将存储大约 16 字节(如果我们将它们按原样存储,则为 36 字节)。但是,仅存储转换为数字的前 15 个十六进制数字3,每个唯一身份访问者仅存储 8 字节。因此,我们预先节省了空间,稍后我们可以将其用于其他问题。这也让我们使用 intset 优化来降低内存使用。
对于第二个问题(为什么这足够),这归结为所谓的生日碰撞。简单地说:两个 128 位随机标识符在前 56 位匹配的几率是多少?从数学上讲,我们可以精确地计算出概率,只要我们在给定的时间段内(在我们的示例中为一天)的唯一身份访问者少于 2.5 亿,那么最多只有 1% 的概率会发生单个匹配(因此,如果我们每天有 2.5 亿访问者,那么大约每 100 天就会有大约 1 个人未被统计)。如果我们的唯一身份访问者少于 2500 万,那么不计算用户的几率会降到我们需要运行该网站大约 2739 年才会遗漏单个用户。
现在我们已经决定使用 UUID 的前 56 位,我们将构建一个分片 SADD 函数,我们将使用它作为更大一段代码的一部分来实际计算唯一身份访问者。清单 9.10 中的这个分片 SADD 函数将使用我们在第 9.2.1 节中使用的相同的分片键计算,经过修改,用非数字字符作为数字 ID 的前缀来计算分片 ID,因为我们的 56 位 ID 不是密集打包的(就像数字 ID 的假设一样)。
def shard_sadd(conn, base, member, total_elements, shard_size): shard = shard_key(base,
'x'+str(member), total_elements, shard_size)
将成员分片到分片的 SETs 之一;记住将其转换为字符串,因为它不是一个连续的 ID。
return conn.sadd(shard, member)
实际上将成员添加到分片。
使用分片 SADD 函数,我们现在可以保持唯一身份访问者计数。当我们想要计算一个访问者时,我们将首先根据他们的会话 UUID 的前 56 位计算他们的较短 ID。然后我们将确定今天的日期,并将该 ID 添加到今天的分片唯一身份访问者 SET。如果该 ID 尚未在 SET 中,我们将增加今天的唯一身份访问者计数。用于跟踪唯一身份访问者计数的代码可以在以下清单中看到。
SHARD_SIZE = 512
我们坚持使用 SETs 的 intset 编码的典型分片大小。
def count_visit(conn, session_id):
today = date.today() key = 'unique:%s'%today.isoformat()
获取今天的日期并生成唯一计数的键。
expected = get_expected(conn, key, today)
获取或计算今天预期的唯一身份访问量。
id = int(session_id.replace('-', '')[:15], 16)
计算此 s128 位 UUID 的 s56 位 ID。
if shard_sadd(conn, key, id, expected, SHARD_SIZE):
将 ID 添加到分片 SET。
conn.incr(key)
如果该 ID 不在分片 SET 中,那么我们增加我们的唯一身份访问量。
该函数的工作方式完全如上所述,但你会注意到我们调用了 get_expected() 来确定预期的每日访问者数量。我们这样做是因为网页访问量往往会随着时间的推移而变化,并且每天保持相同数量的分片不会随着我们的增长而增长(或者如果我们每天的唯一身份访问者数量明显少于一百万,则会缩小)。
为了解决每日预期访问者的变化,我们将编写一个函数,该函数根据昨天的计数,计算每天新的预期唯一身份访问者数量。我们将为任何给定的一天计算一次,估计今天将比昨天至少增加 50% 的访问者,四舍五入到下一个 2 的幂。用于计算此值的代码可以在下面看到。
DAILY_EXPECTED = 1000000
我们从一个初始的每日访问次数预期值开始,这个值可能有点高。
EXPECTED = {}
保留任何计算出的预期计数的本地副本。
def get_expected(conn, key, today):
if key in EXPECTED: return EXPECTED[key]
如果我们已经计算或看到了今天的预期访问量,则使用该数字。
exkey = key + ':expected'
expected = conn.get(exkey)
如果其他人已经计算了今天的预期访问量,则使用该数字。
if not expected:
yesterday = (today - timedelta(days=1)).isoformat() expected = conn.get('unique:%s'%yesterday) expected = int(expected or DAILY_EXPECTED)
获取昨天的唯一计数,如果不可用,则使用我们的默认值 100 万。
expected = 2**int(math.ceil(math.log(expected*1.5, 2)))
在昨天的计数上增加 50%,并四舍五入到下一个偶数 2 的幂,假设今天的访问量应该至少比昨天好 50%。
if not conn.setnx(exkey, expected):
如果可能,将我们计算出的预期访问量保存回 Redis,以便其他调用使用。
expected = conn.get(exkey)
如果其他人比我们更早地存储了今天的预期计数,则使用他们的计数代替。
EXPECTED[key] = int(expected) return EXPECTED[key]
保留今天预期点击量的本地副本,并将其返回给调用者。
该函数的大部分都在以某种方式读取和处理数据,但总体结果是,我们通过获取昨天的访问量,将其增加 50%,并四舍五入到下一个 2 的幂,来计算今天预期的唯一身份访问量。如果已经计算出今天预期的访问量,我们将使用它。
采用这段完全相同的代码并添加 100 万唯一身份访问者,Redis 将使用大约 9.5 兆字节来存储唯一身份访问者计数。如果没有分片,Redis 将使用 56 兆字节来存储相同的数据(单个 SET 中的 56 位整数 ID)。通过分片,存储量减少了 83%,这将使我们能够在相同的硬件上存储 5.75 倍的数据。
对于此示例,我们只需要一个 SET 命令来确定给定日期的唯一身份访问者计数。您可以添加分片 SREM 和 SISMEMBER 调用吗? 奖励积分:假设您有两个分片 SET,它们的预期总项目数相同,并且分片大小相同,您将拥有相同数量的分片,并且相同的 ID 将位于相同的分片 ID 中。您可以添加 SINTERSTORE、SUNIONSTORE 和 SDIFFSTORE 的分片版本吗?
计算唯一身份访问者计数的其他方法如果您有数字访问者 ID(而不是 UUID),并且访问者 ID 的最大值相对较低,与其将访问者信息存储为分片 SET,不如使用类似于我们在下一节中描述的技术将它们存储为位图。可以在 https://github.com/Doist/bitmapist 找到一个用于基于位图计算唯一身份访问者计数和其他有趣分析的 Python 库。
在分片大型整数 SET 以减少存储后,现在是学习如何将位和字节打包到 STRING 中的时候了。
3 另一个好问题是为什么是 56 位而不是 64 位?这是因为 Redis 仅将 intset 用于最大 64 位带符号整数,并且在大多数情况下,将 64 位无符号整数转换为带符号整数的额外工作是不值得的。如果您需要额外的精度,请查看 Python struct 模块并查看 Q 和 q 格式代码。