dot 快速的未来即将在您的城市举行活动。

加入我们参加 Redis 发布会

在 Redis 中存储计数器

返回词汇表

当我们监控应用程序时,能够随着时间推移收集信息变得越来越重要。代码更改(会影响网站响应速度以及随后提供的页面数量)、新的广告活动或系统中的新用户都可能从根本上改变网站上加载的页面数量。随后,任何数量的其他性能指标都可能发生变化。但是,如果我们没有记录任何指标,那么我们就无法知道它们是如何变化的,也无法知道我们的表现是好还是坏。

为了开始收集指标进行观察和分析,我们将构建一个工具来随时间推移保存命名计数器(带有名称的计数器,如网站访问量、销售额或数据库查询,可能至关重要)。每个计数器将以不同的时间精度(如 1 秒、5 秒、1 分钟等)存储最新的 120 个样本。样本数量和记录精度的选择都可以根据需要进行自定义。保存计数器的第一步实际上是保存计数器本身。

更新计数器

为了更新计数器,我们需要存储实际的计数器信息。对于每个计数器和精度(如网站访问量和 5 秒),我们将保留一个 HASH,该 HASH 存储有关每个 5 秒时间片内发生的网站访问量的信息。哈希中的键将是时间片的开始,而值将是访问量。图 5.1 显示了来自具有 5 秒时间片的访问计数器的数据选择。

当我们开始使用计数器时,我们需要记录哪些计数器已被写入,以便我们可以清除旧数据。为此,我们需要一个有序序列,让我们可以逐个遍历其条目,并且不允许重复。我们可以使用 LISTSET 相结合,但这需要额外的代码和往返 Redis 的次数。相反,我们将使用一个 ZSET,其中成员是已写入的精度和名称的组合,而分数均为 0。通过将 ZSET 中的所有分数设置为 0,Redis 将尝试按分数排序,并且发现它们都相等后,将按成员名称排序。这为给定的一组成员提供了一个固定顺序,这将使我们能够轻松地依次扫描它们。图 5.2 显示了已知计数器的示例 ZSET

图 5.1 一个 HASH,显示了 2012 年 5 月 7 日上午 7:40 左右 5 秒时间片内的网页访问量
图 5.2 一个 ZSET,显示了一些已知计数器

现在我们知道计数器结构是什么样子了,接下来会发生什么呢?对于每个时间片精度,我们将向已知 ZSET 添加对该精度的引用以及计数器的名称,并将相应的时间窗口在正确的 HASH 中递增计数。更新计数器的代码如下所示。清单 5.3 update_counter() 函数PRECISION = [1, 5, 60, 300, 3600, 18000, 86400]

计数器以秒为单位的精度:1 秒、5 秒、1 分钟、5 分钟、1 小时、5 小时、1 天—根据需要调整。def update_counter(conn, name, count=1, now=None): now = now or time.time()

获取当前时间,以便了解要递增的时间片。 pipe = conn.pipeline()

创建一个事务性管道,以便稍后的清理可以正常工作。 for prec in PRECISION

为我们记录的所有精度添加条目。 pnow = int(now / prec) * prec

获取当前时间片的开始。 hash = ‘%s:%s’%(prec, name)

创建将存储此数据的命名哈希。 pipe.zadd(‘known:’, hash, 0)

将对计数器的引用记录到一个具有分数 0 的 ZSET 中,以便我们可以自行清理。 pipe.hincrby(‘count:’ + hash, pnow, count)

更新给定名称和时间精度的计数器。 pipe.execute()

更新计数器信息并不难;对于每个时间片精度,只需要一个 ZADD 和一个 HINCRBY 即可。获取命名计数器和特定精度的信息也很容易。我们使用 HGETALL 获取整个 HASH,将我们的时间片和计数器转换回数字(它们都作为字符串返回),按时间排序,最后返回这些值。下一个清单显示了获取计数器数据的代码。清单 5.4 get_counter() 函数def get_counter(conn, name, precision): hash = ‘%s:%s’%(precision, name)

获取我们将存储计数器数据的键的名称。 data = conn.hgetall(‘count:’ + hash)

从 Redis 中获取计数器数据。 to_return = [] for key, value in data.iteritems(): to_return.append((int(key), int(value)))

将计数器数据转换为更期望的东西。 to_return.sort()

对我们的数据进行排序,以便较旧的样本排在最前面。 return to_return

我们确实按照我们所说的话做了。我们获取了数据,按时间顺序对其进行了排序,并将它们转换回整数。让我们看看我们如何阻止这些计数器保留过多数据。

清除旧计数器

现在我们已将所有计数器写入 Redis,并且可以轻松地获取它们。但是,当我们更新计数器时,如果我们不执行任何清理,最终会耗尽内存。因为我们有先见之明,所以我们在已知的 ZSET 中写入了已知计数器的列表。为了清除计数器,我们需要遍历该列表并清理旧条目。

为什么不使用 EXPIREEXPIRE 命令的一个限制是它只适用于整个键;我们不能让键的一部分过期。因为我们选择对数据进行结构化,以便精度为 Y 的计数器 X 在所有时间段内都在同一个键中,所以我们必须定期清理计数器。如果您有雄心壮志,您可能想尝试重新构建计数器以更改数据布局以使用标准的 Redis 过期而不是手动清理。

当我们处理和清理旧计数器时,有一些重要的事项需要关注。以下列表显示了在清理旧计数器时需要注意的一些比较重要的项目

考虑到所有这些因素,我们将构建一个守护进程函数,其操作类似于我们在第 2 章中编写的守护进程函数。与之前一样,我们将重复循环,直到系统被告知退出。为了帮助最小化清理期间的负载,我们将尝试大约每分钟清理一次旧计数器,并且还将按照它们创建新条目的时间表清理旧计数器,但对于每分钟有多个新条目的计数器除外。如果计数器的 time slice 为 5 分钟,我们将尝试每 5 分钟清理一次该计数器中的旧条目。对于更频繁地出现新条目的计数器(在我们的示例中为 1 秒和 5 秒),我们将每分钟清理一次。

为了遍历计数器,我们将使用 ZRANGE 逐个获取已知计数器。为了清理计数器,我们将获取给定计数器的所有开始时间,计算哪些条目早于计算出的截止时间(120 个样本之前),并将其删除。如果给定计数器没有更多数据,我们将从已知 ZSET 中删除该计数器引用。解释发生了什么很简单,但代码的细节显示了一些极端情况。查看此清单以了解清理代码的完整细节。清单 5.5 clean_counters() 函数def clean_counters(conn): pipe = conn.pipeline(True) passes = 0

记录通过次数,以便我们可以平衡每秒计数器和每天计数器的清理。 while not QUIT

不断清理计数器,直到我们被告知停止。 start = time.time()

获取通过的开始时间以计算总持续时间。 index = 0 while index < conn.zcard(‘known:’)

递增地遍历所有已知计数器。 hash = conn.zrange(‘known:’, index, index)

获取下一个要检查的计数器。 index += 1 if not hash: break hash = hash[0] prec = int(hash.partition(‘:’)[0])

获取计数器的精度。 bprec = int(prec // 60) or 1

我们将大约每 60 秒通过一次,因此我们将尝试大致按照计数器写入的速率清理计数器。 if passes % bprec

尝试下一个计数器,如果我们不应该在本轮检查此计数器(例如,我们已通过了三次,但计数器的精度为 5 分钟)。 continue hkey = ‘count:’ + hash cutoff = time.time() – SAMPLE_COUNT * prec

找到我们应该保留的最早样本的截止时间,考虑到精度和要保留的样本数量。 samples = map(int, conn.hkeys(hkey))

获取样本的时间,并将字符串转换为整数。 samples.sort() remove = bisect.bisect_right(samples, cutoff)

确定应该删除的样本数量。 if remove: conn.hdel(hkey, *samples[:remove])

根据需要删除样本。 if remove == len(samples)

数据 HASH 可能为空。 try: pipe.watch(hkey) if not pipe.hlen(hkey): pipe.multi() pipe.zrem(‘known:’, hash)

观察计数器哈希以进行更改。

验证计数器哈希是否为空,如果为空,则将其从已知计数器中删除。 pipe.execute() index -= 1

如果我们删除了一个计数器,那么我们可以在下一轮使用相同的索引。 else: pipe.unwatch()

哈希不为空;将其保留在已知计数器的列表中。 except redis.exceptions.WatchError: pass

其他人通过添加计数器来更改了计数器哈希,这意味着它有数据,因此我们将保留该计数器在已知计数器的列表中。 passes += 1 duration = min(int(time.time() – start) + 1, 60)

passes += 1 duration = min(int(time.time() – start) + 1, 60) time.sleep(max(60 – duration, 1))

休眠剩余的 60 秒,或至少 1 秒,只是为了稍微休息一下。

如前所述,我们逐个迭代计数器 ZSET ,查找要清理的项目。我们只清理此轮次中应该清理的计数器,因此我们尽早执行该检查。然后,我们获取计数器数据并确定应清理什么(如果有)。在必要时清理完旧数据后,如果我们认为不应该有任何剩余数据,我们将验证计数器没有更多数据并将其从我们的 ZSET 计数器中删除。最后,在遍历所有计数器后,我们计算执行一次遍历所需的时间,并休眠大约剩余的一分钟,以便我们在下一轮遍历之前执行完整的清理。

现在我们有了计数器数据,正在清理它,并且可以获取它,只需要构建一个用于使用数据的接口即可。不幸的是,这部分超出了本书的范围,但有一些可用的 JavaScript 绘图库可以帮助您处理 Web 方面的任务(我在个人和专业用途方面对 jqplot [http://www.jqplot.com/]、Highcharts [http://www.highcharts.com/]、dygraphs [http://dygraphs.com/] 和 D3 [https://d3js.cn/] 有过良好的体验)。

在处理真实网站的复杂程度时,了解一个页面每天被访问数千次可以帮助我们决定是否应该缓存该页面。但是,如果该页面渲染需要 2 毫秒,而另一个页面流量只有十分之一,但渲染需要 2 秒,那么我们可以将注意力转向优化速度较慢的页面。在下一节中,我们将改变我们的方法,从维护精确的计数器以提供随时间推移的数据,转变为维护聚合统计数据以帮助我们做出更细致入微的决策,以确定优化哪些内容。