随着我们对应用进行监控,能够随时间收集信息变得越来越重要。代码变更(这会影响我们网站的响应速度,进而影响我们提供的页面数量)、新的广告活动或新用户都可能根本性地改变网站加载的页面数量。随后,任何数量的其他性能指标也可能发生变化。但如果我们没有记录任何指标,那么就无法知道它们是如何变化的,或者我们是做得更好还是更差。
为了开始收集指标进行观察和分析,我们将构建一个工具来随时间维护命名计数器(诸如网站点击、销售或数据库查询等名称的计数器至关重要)。这些计数器中的每一个都将以各种时间精度(例如 1 秒、5 秒、1 分钟等)存储最近 120 个样本。样本数量和记录精度的选择都可以根据需要进行自定义。维护计数器的第一步实际上是存储计数器本身。
为了更新计数器,我们需要存储实际的计数器信息。对于每个计数器和精度,例如网站点击和 5 秒,我们将维护一个 HASH 来存储在每个 5 秒时间片内发生的网站点击次数信息。哈希中的键将是时间片的开始,值将是点击次数。图 5.1 显示了具有 5 秒时间片的点击计数器的一些数据示例。
随着我们开始使用计数器,我们需要记录哪些计数器已被写入,以便我们可以清除旧数据。为此,我们需要一个有序序列,允许我们逐个迭代其条目,并且不允许重复。我们可以使用 LIST 结合 SET,但这将需要额外的代码和与 Redis 的往返通信。相反,我们将使用一个 ZSET,其中成员是已被写入的精度和名称的组合,分数都为 0。通过将 ZSET 中的所有分数设置为 0,Redis 将尝试按分数排序,当所有分数相等时,再按成员名称排序。这为给定成员集合提供了固定的顺序,从而方便我们按顺序扫描它们。图 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 中。要清理计数器,我们需要遍历该列表并清理旧条目。
为什么不使用 EXPIRE?EXPIRE 命令的一个限制是它只应用于整个键;我们不能使键的部分过期。而且由于我们选择将数据结构化,使得精度 Y 的计数器 X 在一个单独的键中存储所有时间数据,我们必须定期清理计数器。如果你有雄心,你可能想尝试重构计数器以改变数据布局,转而使用标准的 Redis 过期功能。
在我们处理和清理旧计数器时,有几件事需要特别注意。以下列表显示了我们在清理旧计数器时需要注意的几个更重要的事项
考虑到所有这些因素,我们将构建一个守护函数,其操作类似于我们在第二章中编写的守护函数。和以前一样,我们将重复循环,直到系统被告知退出。为了帮助最小化清理期间的负载,我们将尝试大约每分钟清理一次旧计数器,并且还将按照它们创建新条目的大致计划清理旧计数器,但那些每分钟新增条目次数多于一次的计数器除外。如果一个计数器的时间片是 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 秒来渲染,我们则可以将注意力转移到优化速度较慢的页面。在下一节中,我们将改变我们的方法,从维护提供随时间变化数据的精确计数器,转变为维护聚合统计数据,以帮助我们做出更细致的优化决策。