Django 优化数据库查询的一些经验

ORM 帮我们节省了很多工作,基本上不用写 SQL,就可以完成很多 CRUD 操作,而且外键的关联也会自动被 ORM 处理好,使得开发的效率非常高。我觉得 Django 的 ORM 在 ORM 里面算是非常好用的了,尤其是自带的 Django-admin,可以节省很多工作,甚至比很多公司内部开发的后台界面都要优秀。

但是 ORM 也带来了一些问题,最严重的就是,这些外键关联会去自动 Fetch,导致非常容易写出来 N + 1 查询,加上如果使用 django-rest-framework, Serializer 可以帮助你很方便地去渲染关联的外键,就更容易写出 N + 1 查询了。也是因为这个原因,之前在蚂蚁工作的时候,公司基本上是禁止(有一些小范围的内部项目还是可以使用)使用类似于 Hibernate 这种自动关联外键 ORM 的,都需要手写 SQL map,自己去 Join。但其实,我觉得这是一种非常粗暴的做法,用大量的人力换取降低错误出现的几率。这个问题是非常好解决的,我们只要对接口 Profile,看一下完成一次请求到底进行了哪些 SQL 查询即可,如果发现了 N + 1 就去解决。

这篇文章介绍 Django 中去 debug 和优化数据库查询的一些方法,其实对于其他的语言和框架,也差不多类似,也有同类的工具。

让我们从头开始思考,如何提升网站的性能。首先,最接近于用户的就是前端的代码,我们可以从前端开始 perf,看哪一个页面渲染用的时间最长,是请求 Block 住了,还是前端的组件写的性能差。如果是非常重要的面向用户的页面,可以系统性地使用一些监控工具发现性能问题。如果是对内的,其实只要自己去每一个页面点几下试试就可以发现性能问题了。如果自己没觉得卡顿,基本上就足够了。

对于内部的系统来说,如果前端不是写的出奇的差的话,性能问题一般都是由 API 慢引起的。所以本文就不过多介绍前端性能的优化了。

Slow API

发现耗时长的 API

Django 有一个 Prometheus 的 exporter 库,django-prometheus,可以使用这个库将 metrics 暴露出来,然后在 Grafana 上绘制每一个 view 的 P99/P95 等,发现耗时长的 API,然后针对性地进行优化,去 Debug 到底是慢在了哪里。

Debug

Django 的 django-debug-toolbar 是一个非常好用的工具。安装之后,设置好 debug 用的 IP,使用这个 IP 去访问的时候,它会自动对整个请求做 Profile,包括使用的 Cache,Template,Signal 等等。最有用的就是 SQL Profile 了。

它会把一个请求涉及的所有 SQL 都列出来,包括:

  • 这个 SQL 花了多少时间
  • 这个 SQL 是由哪一行代码触发的,类似于一个 traceback
  • 有多少个类似的 SQL,有多少个重复的 SQL (如果有的话,一般就是有问题了,意味着同样的 SQL 查询了多次)
  • 点击 Expl 可以很方便地看到这个 SQL 的 Explain
  • 点击 Sel 可以看到 SQL 的详情

如下所示:

然后就可以针对慢的 API 进行优化了。

解决

N + 1 Query

N + 1 查询是造成 API 慢的最常见的原因。比如这样一个需求:我们有一个列表页,对于列表中的每一个 Item,都要展示它相关的 Tag。如果使用 djagno-rest-framework 的 Nested relationships 的话,实际的查询会:

  1. 先查询出来当前列表页要展示的 item list
  2. 对于每一个 item,都去查询它的 tag

这就是 N + 1 查询。

解决的方法很简单,就是使用 Django 的 prefetch_related(),它的原理是使用 in 一次性将所有的外键关联的数据查询出来,然后在内存中使用 Python 做 “join”,这样就只会产生两个查询:

  1. 先查询出来列表页要展示的 item list
  2. 一次查询出来所有的 tag,使用 tag_id in (item_id, item2_id…)

参考一个官方文档中的例子:每一个 Pizze 都有不同的 Topping(浇头?)。

下面这个查询就是一个 N + 1,要先查出来所有的 Pizze,然后对于每一个 Pizza 去查询它的 Toppings:

如果使用 prefetch 的话,就会只有 3 次查询(因为是一个 many-to-many 关系,所以要有一次是查询中间表的):

 

注意只能使用 .all()

prefetch 其实是缓存了一个 queryset(), 如果查询条件改变了,Django 就必须重新发起查询。以下这个用法,就不会用到 prefetch 的 cache:

因为 prefetch 已经把所有的数据查询到内存里面了,所以我们应该这么用,就不会触发新的查询了:

 

Prefetch()

prefetch_related() 所接收的参数,除了可以是一个 string 外,也可以是一个 Prefetch() 对象,可以用来更精确地控制 cache 的 queryset. 比如排序:

也可以一次性 prefetch 多个外键(顺序很重要,参考文档),Prefetch()string 可以混用:

 

many-to-many 和嵌套外键

对于嵌套的外键,可以用 __ 将 Model 的属性名字联合起来,比如这样:

这样 pizzastoppings 都会被 prefetch.

 

select_related()

select_related() 也是有类似作用的一个功能,只不过他和 prefetch 的区别是:

  • prefetch_related() 是用 in 然后用代码 join
  • select_related() 是用 SQL 直接 join

显然,select_related() 触发的查询更少,一次查询就可以解决问题。但是它的功能也有限,不能支持嵌套的外键查询。

 

prefetch_related_objects()

以上的两个方法是用于 queryset 的,如果是对 object 的话,可以使用这个函数。

比如,我们要查询最近的一个订单关联的数据的话,可以这么使用:

Cached Property

Django 提供了一很实用的装饰器 @cached_property ,用这个替换 @property 的话,一个对象在读取这个 property 的时候只会计算一次,同一个对象在第一次之后来读取这个 property 都会使用缓存。

有点类似于 Python 中的 @lru_cache

 

减少不必要的展示字段

无论是 Cache 还是 prefetch 的方法,都是有一些复杂的。如果前端用户到一些字段,就没有必要一次性返回。

刚开始写 DRF 中的 Serializer 的时候,倾向于每一个 Model 都有一个 Serializer,然后这些 Serializer 都互相关联。最终,导致查询一个列表页的时候,每一个 item 相关的数据,以及这些数据相关的数据,都被一次性展示出来了。即使优化过后也难以维护。

后来总结出来一个比较好的实践,是每一个 Model 都有两个 Serializer:

  • ListSerialzer:对于所有的外键只展开一层,不展开外键的外键
    • 用于列表页 API 的显示
    • 这样查询的时候,只需要对于每一个外键查询一次 in 就可以了
  • DetailSerializer:按需求展示所有的外键
    • 用于详情页的渲染
    • 对于每一个外键关联的 row,可能都要再进行一次查询,把所有关联的外键都展开,方便展示。但是因为只有一个对象,所以也不会特别慢。但是依然要注意 N + 1,如果嵌套的太深,考虑不一次展示那么多,新提供一个 API 进行查询

这样的好处是我们可以按需进行 prefetch,List 页面的 API 只需要 prefetch 直接关联的外键就可以了,Detail 的 API 可以按需进行级联 prefetch. 总体的原则就是尽量避免多重外键的 prefetch.

值得一提的是在 django-rest-framework 中,是可以在同一个 ModelViewSet 里面,针对不同的 API,使用不同的 Serializer 的:

使用冗余字段

现在存储已经很便宜了,在合适的场景下,可以考虑直接将 fields 多存几份,节省查询。

比如我的一个场景是:一个 group 里面有个并行执行的 Execution,如果所有的 Execution 都执行完了,这个 group 就可以被认为是执行完了。

之前的实现是在 group 上定义一个 is_running 的字段,返回 group.execution_set.filter(is_running=True).exists()。这样每次都需要查询外键。

其实可以在 group 上保存一个 is_running 的字段,然后当 Execution 结束的时候顺便更新 group.is_running. (Signal 其实不太好维护,我比较喜欢显式调用)。

这样的好处是:

  1. 方便查询,业务逻辑变得简单

缺点是:

  1. 另外肯定有某个地方的逻辑变得复杂了,因为要同步更新
  2. 可能又潜在的数据不一致

Slow SQL

随着数据越来越多,即使开发的环境中发现 API 造成的请求都很少,也很快,但是线上环境跑着跑着可能就有问题了。

所以最好对线上的 SQL 也进行观测。方法很简单,只要将查询时间 >1秒(或者其他时间)的 SQL log 出来就可以了。可以通过针对 Django ORM 设置 logging 配置来完成这件事:

添加一个新的 logger,然后 filter 类似于一下设置:

就可以将 SQL 日志过滤出来,然后只 log 请求时间 >50ms 的。

 

最后,以 Django 中的一句话作为结尾:

Always profile for your use case!

 

PromQL 使用多个 label 组合过滤

继《最近的工作感悟》中提到的大部分问题都解决了之后,有一些错误还是无法避免的,就试图想办法从监控系统中忽略掉。尝试了很长时间,发现在 PromQL 中写 “exclude 特定 label 的 metrics” 这样的查询不是很方便,目前没有找到比较合适的方法,这里记录一下一些可行的,但不是特别优雅的方法。

问题可以简化成这样:有一个 metric 叫做 request_count, 有两个 label:

  • client: 客户端的名字,比如有: curl, chrome, safari, firefox, python
  • error_code: 400, 200, 403, 302 等

因为有一些错误无法避免,比如由爬虫(假设 clientpython)引起的 404 问题,在 chrome 上发生的 403 问题,我们想从监控中忽略掉。

首先 request_count{client!="python", error_code="404"} 这样的查询是不行的,因为这样会忽略来自 python 的所有的请求,以及所有的 error_code=404。这样写实际上是一个  and 的关系,metric 的 label 满足所有的条件才会展示,否则不展示。

 

其实通过 Grafana 的 Transform 设置,我们可以取消展示一些单独的 Metric。选择 Transform  tab,然后选择 “Filter by name”, 就可以勾选单独的 metric 取消展示。

这样可以解决展示的问题,但是查询结果实际还是包含这些 metric 的。如果基于这个查询来设置 alerting rules 的话,那么这些 metric 还是无法被忽略。我还是想从查询上来忽略这些 metric,这样无论展示和告警,都可以使用同一个 aggr rule.

 

通过查询来忽略的方法有些 tricky,因为涉及两个 lebel 的 and 条件查询,总体的思路是:

  1. 忽略 label A 的所有 metrics;
  2. 使用 or 添加满足 label A 和 B 两个 label 的 metrics;

以上面的例子,查询的 PromQL 就是:

因为 label 的写法只支持 and,但是我们可以使用 or 组合 metric 来实现查询。

or 也支持连续的写法,以及再需要提一下需要永远先 rate 再 sum,所以回到刚开始的例子,就需要写成:

 

参考:How to filter by two labels in prometheus?

 

《Prometheus Up & Running》阅读

最近读完了这本讲监控的书:Prometheus Up & Running,学到很多东西,在博客上推荐一下。

可以将这本书的读者分成三种角色:

  1. 应用程序的开发者,需要使用 Prometheus 来监控自己的应用;
  2. SRE,需要监控应用以及服务器的运行状态;
  3. 监控系统的维护者,可能也是 SRE,需要维护和部署 Prometheus。

全书分成了 6 个部分:

  1. 介绍配 Prometheus 的一些概念,工作的模式,核心的思想。比如数据不是“完全准确的”,“拉取的模型”,存储,监控面板等等;
  2. 介绍了应用的 Metrics 如何暴露,Metrics 的类型,一些 Prometheus 的概念(更详细)等等;
  3. Prometheus 现在的周边生态已经比较完善了,这一部分介绍如何使用已有的 Exporter 以及如何自己写 Exporter;
  4. 介绍如何使用 PromQL 做查询,PromQL 是一个完整并且强大(图灵完备的)查询语言;
  5. 介绍如何配置告警,一些核心思想,工作原理,需要避免的误区等等;
  6. Prometheus 的部署,如何扩大存储的规模,如何解决性能问题,如何提高查询速度等;

那么回到上面的三种角色,对于工作于监控领域的 SRE 来说,推荐阅读 1-6 章,每一章都会有所启发。对于普通的 SRE 来说,推荐阅读 1-6 章,因为监控可以说是 SRE 工作的中心,如果不会用监控就和瞎子一样,如果精通监控可以让很多事情事半功倍。对于应用开发者来说,推荐阅读 1-6 章。开发者需要熟悉 Prometheus 里面的一些概念,才能正确的 Expose metrics,所以 1-3 章是开发者必读的;同时开发者又是最了解自己的系统的人,所以监控面板的主要编辑者应该是开发者,写 PromQL 就变得重要了,所以第 4-5 章也是必读的。第 6 章就有一些微妙了,Prometheus 其实本质上是一个很简单的架构,但是要在大规模下运行,就需要其他的一些方案。比如我们公司用的是 Thanos,其实它的背后是一个个独立的 Prometheus,Grafana 的查询直接去查 Thanos,Thanos 就表现的是一个无限大的 Prometheus 一样。我刚接触这个架构经常犯的一个错误是在 alerting rule 里面写聚合的查询,导致很多 alerts 没发出来。因为 alerts 在本质上还是在每一个 Prometheus 上做聚合(evaluation)的,那个时候每个 Prometheus 计算自己本身的数据,认为都没有达到 firing 的状态,但是实际将每一个 Prometheus 聚合起来已经达到了。所以,要正确地使用这一套系统,其实还是要了解背后的部署状态比较好。

要是说读完这本书我学到最重要的一点的话,就是:SRE 的效率和正确性同等重要。

去监控现象而不是监控原因,花很多努力让 labels 变得有意义,在工作被 page 的次数和问题发现时间做平衡而不是一味追求快速发现问题,等等。表面上看这些都是在提高 SRE 的幸福感,但是本质上也是提高软件质量和用户体验的正确道路。

要说这本书的缺点的话,就是感觉很多例子都举得不是很好,好的例子应该从现实中找,但是书中的有些例子太刻意了。大部分的例子也有点难运行(和监控这个话题也有关,这本书没有处处将例子的完整配置写出来,可能也太占篇幅了,就导致不是所有的例子都可以复现的),以至于有些话题有些抽象,不太好理解。但是以后遇到问题可以找到相关的章节,再找一下灵感。

现在的监控系统感觉正确性已经做得很好了,需要提高的是体验上的问题,比如部署上的扩展性,UI 上的配置,如何可维护等等。虽然从 Grafana 到 Prometheus 到 Alert 都可以用过 yaml 来配置,做到 gitops,但是在一个很大的团队下,配合起来太痛苦了。最后都会走向 UI 的配置吧,UI 如果没有专业的人来设计,又会做的很难用。难用的话就不会做到 Easy to change, 最后又会做的难以维护。听起来很像是一个悖论呢……

最后推荐一下 My Philosophy on Alerting 这篇文章,Google 的 SRE 写的。

 

PromQL 简明教程

这篇文章介绍如何使用 PromQL 查询 Prometheus 里面的数据。包括如何使用函数,理解这些函数,Metrics 的逻辑等等,因为看了很多教程试图学习 PromQL,发现这些教程都直说有哪些函数、语法是什么,看完之后还是很难理解。比如 [1m] 是什么意思?为什么有的函数需要有的函数不需要?它对 Grafana 上面展示的数据有什么影响?rateirate 的区别是什么?sumrate 要先用哪个后用哪个?经过照葫芦画瓢地写了很多 PromQL 来设置监控和告警规则,我渐渐对 PromQL 的逻辑有了一些理解。这篇文章从头开始,通过介绍 PromQL 里面的逻辑,来理解这些函数的作用。本文不会一一回答上面这些问题,但是我的这些问题都是由于之前对 PromQL 里面的逻辑和概念不了解,相信读完本文之后,这些问题的答案就显得不言而喻了。

本文不会深入讲解 Prometheus 的数据存储原理,Prometheus 对 metrics 的抓取原理等问题;也不会深入介绍 PromQL 中每一个 API 的实现。只会着重于介绍如何写 PromQL 的原理,和它的设计逻辑。但是相信如果理解了本文这些概念,可以更透彻地理解和阅读 Prometheus 官方的文档。

Metric 类型

Prometheus 里面其实只有两种数据类型。Gauge 和 Counter。

Gauge

Gauge 是比较符合直觉的。它就是表示一个当前的“状态”,比如内存当前是多少,CPU 当前的使用率是多少。

Counter

Counter 有一些不符合直觉。我想了很久才理解(可能我有点钻牛角尖了)。Counter 是一个永远只递增的 Metric 类型。

使用 Counter 计算得到的,每秒收到的 packet 数量

典型的 Counter 举例:服务器服务的请求数,服务器收到了多少包(上图)。这个数字是只增不减的,用 Counter 最合适了。因为每一个时间点的总请求数都会包含之前时间点的请求数,所以可以理解成它是一个“有状态的”(非官方说法,我这么说只是为了方便读者理解)。使用 Counter 记录每一个时间点的“总数”,然后除以时间,就可以得到 QPS,packets/s 等数据。

为什么需要 Counter 呢?先来回顾一下 Gauge,你可以将 Gauge 理解为“无状态的”,即类型是 Gauge 的 metric 不需要关心历史的值,只需要记录当前的值是多少就可以了。比如当前的内存值,当前的 CPU 使用率。当然,如果你想要查询历史的值,依然是可以查到的。只不过对于每一个时间点的“内存使用量”这个 Gauge,不包含历史的数据。那么可否用 Gauge 来代替 Counter 呢?

Prometheus 是一个抓取的模型:服务器暴露一个 HTTP 服务,Prometheus 来访问这个 HTTP 接口来获取 metrics 的数据。如果使用 Gauge 来表示上面的 pk/s 数据的话,只能使用这种方案:使用这个 Metric 记录自从上次抓取过后收到的 Packet 总数(或者直接记录 Packet/s ,原理是一样的)。每次 Prometheus 来抓取数据之后,就将这个值重置为 0. 这样的实现就类似 Gauge 了。

Prometheus 的抓取模型,去访问服务的 HTTP 来抓取 metrics

这种实现的缺点有:

  1. 抓取数据本质是 GET 操作,但是这个 GET 操作却会修改数据(将 metric 重置为0),所以会带来很多隐患,比如一个服务每次只能由一个 Prometheus 来抓取,不能扩展;不能 cURL 这个 /metrics 来进行 debug,因为会影响真实的数据,等等。
  2. 如果服务器发生了重启,数据将会清零,会丢失数据(虽然 Counter 也没有从本质上解决这个问题)。

Counter 因为是一个只递增的值,所以它可以判断数字下降的问题,比如现在请求的 Count 数是 1000,然后下次 Prometheus 来抓取发现变成了 20,那么 Prometheus 就知道,真实的数据不可能是 20,因为请求数是不可能下降的。所以它会将这个点认为是 1020。

然后用 Counter 也可以解决多次读的问题,服务器上的 /metrics,可以使用 cURL 和 grep 等工具实时查看,不会改变数据。Counter 有关的细节可以参考下 How does a Prometheus Counter work?

其实 Prometheus 里面还有两种数据类型,一种是 Histogram,另一种是 Summary.

但是这两种类型本质上都是 Counter。比如,如果你要统计一个服务处理请求的平均耗时,就可以用 Summary。在代码中只用一种 Summary 类型,就可以暴露出收到的总请求数,处理这些请求花费的总时间数,两个 Counter 类型的 metric。算是一个“语法糖”。

Histogram 是由多个 Counter 组成的一组(bucket)metrics,比如你要统计 P99 的信息,使用 Histogram 可以暴露出 10 个 bucket 分别存放不同耗时区间的请求数,使用 histogram_quantile 函数就可以方便地计算出 P99(《P99是如何计算的?》). 本质上也是一个“糖”。假如 Prometheus 没有 Histogram 和 Summary 这两种 Metric 类型,也是完全可以的,只不过我们在使用上就需要多做很多事情,麻烦一些。

讲了这么说,希望读者已经明白 Counter 和 Gauge 了。因为我们接下来的查询会一直跟这两种 Metric 类型打交道。

Selectors

下面这张图简单地表示了 Metric 在 Prometheus 中的样子,以给读者一个概念。

如果我们直接在 Grafana 中使用 node_network_receive_packets_total 来画图的话,就会得到 5 条线。

Counter 的值很大,并且此图基本上看不到变化趋势。因为它们只增加,可以认为是这个服务器自存在以来收到的所有的包的数量。

Metric 可以通过 label 来进行选择,比如 node_network_receive_packets_total{device=”bond0″} 就会只查询到 bond0 的数据,绘制 bond0 这个 device 的曲线。也支持正则表达式,可以通过 node_network_receive_packets_total{device=~”en.*”} 绘制 en0 和 en2 的曲线。

其实,metric name 也是一个 “label”, 所以 node_network_receive_packets_total{device="bond0"} 本质上是 {__name__="node_network_receive_packets_total", device="bond0"} 。但是因为 metric name 基本上是必用的 label,所以我们一般用第一种写法, 这样看起来更易懂。

PromQL 支持很复杂的 Selector,详细的用法可以参考文档。 值得一提的是,Prometheus 是图灵完备 (Turing Complete)的(Surprise!)。

实际上,如果你使用下面的查询语句,将会仅仅得到一个数字,而不是整个 metric 的历史数据(node_network_receive_packets_total{device=~"en.*"} 得到的是下图中黄色的部分。

这个就是 Instant Vector:只查询到 metric 的在某个时间点(默认是当前时间)的值。

PromQL 语言的数据类型

为了避免读者混淆,这里说明一下 Metric Type 和 PromQL 查询语言中的数据类型的区别。很简单,在写 PromQL 的时候,无论是 Counter 还是 Gauge,对于函数来说都是一串数字,他们数据结构上没有区别。我们说的 Instant Vector 还是 Range Vector, 指的是 PromQL 函数的入参和返回值的类型。

Instant Vector

Instant 是立即的意思,Instant Vector 顾名思义,就是当前的值。假如查询的时间点是 t,那么查询会返回距离 t 时间点最近的一个值。

常用的另一种数据类型是 Range Vector。

Range Vector

Range Vector 顾名思义,返回的是一个 range 的数据。

Range 的表示方法是 [1m],表示 1 分钟的数据。也可以使用 [1h] 表示 1 小时,[1d] 表示 1 天。支持的所有的 duration 表示方法可以参考文档

假如我们对 Prometheus 的采集配置是每 10s 采集一次,那么 1 分钟内就会有采集 6 次,就会有 6 个数据点。我们使用node_network_receive_packets_total{device=~“.*”}[1m] 查询的话,就可以得到以下的数据:两个 metric,最后的 6 个数据点。

Prometheus 大部分的函数要么接受的是 Instant Vector,要么接受的是 Range Vector。所以要看懂这些函数的文档,就要理解这两种类型。

在详细解释之前,请读者思考一个问题:在 Grafana 中画出来一个 Metric 的图标,需要查询结果是一个 Instant Vector,还是 Range Vector 呢?

答案是 Instant Vector (Surprise!)。

为什么呢?要画出一段时间的 Chart,不应该需要一个 Range 的数据吗?为什么是 Instant Vector?

答案是:Range Vector 基本上只是为了给函数用的,Grafana 绘图只能接受 Instant Vector。Prometheus 的查询 API 是以 HTTP 的形式提供的,Grafana 在渲染一个图标的时候会向 Prometheus 去查询数据。而这个查询 API 主要有两种:

第一种是 /query:查询一个时间点的数据,返回一个数据值,通过 ?time=1627111334 可以查询指定时间的数据。

假如要绘制 1 个小时内的 Chart 的话,Grafana 首先需要你在创建 Chart 的时候传入一个 step 值,表示多久查一个数据,这里假设 step=1min 的话,我们对每分钟需要查询一次数据。那么 Grafana 会向 Prometheus 发送 60 次请求,查询 60 个数据点,即 60 个 Instant Vector,然后绘制出来一张图表。

Grafana 的 step 设置

当然,60 次请求太多了。所以就有了第二种 API query_range,接收的参数有 ?start=<start timestamp>&end=<end timestamp>&step=60。但是这个 API 本质上,是一个语法糖,在 Prometheus 内部还是对 60 个点进行了分别计算,然后返回。当然了,会有一些优化。

然后就有了下一个问题:为什么 Grafana 偏偏要绘制 Instant Vector,而不是 Range Vector 呢?

Grafana 只接受 Instant Vector, 如果查询的结果是 Range Vector, 会报错

因为这里的 Range Vector 并不是一个“绘制的时间”,而是函数计算所需要的时间区间。看下面的例子就容易理解了。

来解释一下这个查询:

rate(node_network_receive_packets_total{device=~”en.*”}[1m])

查询每秒收到的 packet 数量

node_network_receive_packets_total 是一个 Counter,为了计算每秒的 packet 数量,我们要计算每秒的数量,就要用到 rate 函数。

先来看一个时间点的计算,假如我们计算 t 时间点的每秒 packet 数量,rate 函数可以帮我们用这段时间([1m])的总 packet 数量,除以时间 [1m] ,就得到了一个“平均值”,以此作为曲线来绘制。

以这种方法就得到了一个点的数据。

然后我们对之前的每一个点,都以此法进行计算,就得到了一个 pk/s 的曲线(最长的那条是原始的数据,黄色的表示 rate 对于每一个点的计算过程,蓝色的框为最终的绘制的点)。

所以这个 PromQL 查询最终得到的数据点是:… 2.2, 1.96, 2.31, 2, 1.71 (即蓝色的点)。

这里有两个选中的 metric,分别是 en0en2,所以 rate 会分别计算两条曲线,就得到了上面的 Chart,有两条线。

rate, irate 和 increase

很多人都会纠结 iraterate 有什么区别。看到这里,其实就很好解释了。

以下来自官方的文档:

irate()
irate(v range-vector) calculates the per-second instant rate of increase of the time series in the range vector. This is based on the last two data points.

即,irate 是计算的最后两个点之间的差值。可以用下图来表示:

irate 的计算方式

自然,因为只用最后两个点的差值来计算,会比 rate 平均值的方法得到的结果,变化更加剧烈,更能反映当时的情况。那既然是使用最后两个点计算,这里又为什么需要 [1m] 呢?这个 [1m] 不是用来计算的,是用来限制找 t-2 个点的时间的,比如,如果中间丢了很多数据,那么显然这个点的计算会很不准确,irate 在计算的时候会最多向前在 [1m] 找点,如果超过 [1m] 没有找到数据点,这个点的计算就放弃了。

在现实中的例子,可以将上面查询的 rate 改成 irate

irate(node_network_receive_packets_total{device=~”en.*”}[1m])

对比与之前的图,可以看到变化更加剧烈了。

那么,是不是我们总是使用 irate 比较好呢?也不是,比如 requests/s 这种,如果变化太剧烈,从面板上你只能看到一条剧烈抖动导致看不清数值的曲线,而具体值我们是不太关心的,我们可能更关心一天中的 QPS 变化情况;但是像是 CPU,network 这种资源的变化,使用 irate 更加有意义一些。

还有一个函数叫做 increase,它的计算方式是 end - start,没有除。计算的是每分钟的增量。比较好理解,这里就不画图了。

这三个函数接受的都是 Range Vector,返回的是 Instant Vector,比较常用。

另外需要注意的是,increaserate 的 range 内必须要有至少 4 个数据点。详细的解释可以见这里:What range should I use with rate()?

介绍了这两种类型,那么其他的 Prometheus 函数应该都可以看文档理解了。Prometheus 的文档中会将函数这样标注:

changes()
For each input time series, changes(v range-vector) returns the number of times its value has changed within the provided time range as an instant vector.

我们就知道,changes() 这个函数接受的是一个 range-vector, 所以要带上类似于 [1m] 。不能传入不带类似 [1m] 的 metrics,类似于这样的使用是不合法的:change(requests_count{server="server_a"},这样就相当于传入了一个 Instant Vector。

看到这里,你应该已经成为一只在 Prometheus 里面自由翱翔的鸟儿了。接下来可以抱着文档去写查询了,但是在这之前,让我再介绍一点非常重要的误区。

使用函数的顺序问题

在计算 P99 的时候,我们会使用下面的查询:

首先,Histogram 是一个 Counter,所以我们要使用 rate 先处理,然后根据 le 将 labels 使用 sum 合起来,最后使用 histogram_quantile 来计算。这三个函数的顺序是不能调换的,必须是先 ratesum,最后 histogram_quantile

为什么呢?这个问题可以分成两步来看:

rate 必须在 sum 之前。前面提到过 Prometheus 支持在 Counter 的数据有下降之后自动处理的,比如服务器重启了,metric 重新从 0 开始。这个其实不是在存储的时候做的,比如应用暴露的 metric 就是从 2033 变成 0 了,那么 Prometheus 就会忠实地存储 0. 但是在计算 rate 的时候,就会识别出来这个下降。但是 sum 不会,所以如果先 sumrate,曲线就会出现非常大的波动。详细见这里

histogram_quantile 必须在最后。在《P99是如何计算的?》这篇文章中介绍了 P99 的原理。也就是说 histogram_quantile 计算的结果是近似值,去聚合(无论是 sum 还是 max 还是 avg)这个值都是没有意义的。

 

以上都是个人理解,可能存在错误,请在评论批评指正。

最后推荐一个入门的视频,讲得非常好:https://www.youtube.com/watch?v=hTjHuoWxsks

 

Build 一个最小的 Redis Docker Image

好吧,我承认这么说可能违反了广告法,但是……它确实挺小的。

可以对比一组数据:

  • 官方的 Redis 镜像:105MB
  • 官方的基于 alpine 的 Redis 镜像:32.3MB
  • 在 ubuntu 下面用默认配置 Build 出来的 redis-server binary:13M
  • 一个什么都没有的 alpine:latest docker 镜像:5.6MB
  • 上图中的 Redis 镜像:1.69MB

所以……可以说,确实挺小了。

当然生产环境肯定不差 100M 这点空间,还是带上一些常用的工具在生产环境跑比较好。本文只是在玩 Nix 时候的自娱自乐,没有什么实际意义。

这个镜像是用 Nix build 的,实际上,就是玩了一下 Nix 网站上的 Cover Demo。用到的手段有:

  1. 使用 Nix 来 build 这个 image;
  2. 编译的时候关闭了 systemd 的支持,Docker 里面不需要这种东西;
  3. 使用 musl 静态链接编译;
  4. 把除了 redis-server 之外的东西删除掉;
  5. 编译完成使用 strip -s 对最终的 binary 再次做删减。

具体的编译方法

首先在 Nix 创建一个文件,来编译 Redis,这里实际上使用的 Nix 打包好的 Redis,我只是对其通过 preBuildpostInstall 做了一些操作,替换 musl 和 strip 之类的。

然后再需要一个文件描述如何 build docker image:

非常简单,以至于不需要解释。

然后运行下面这个命令,build 就可以了。因为我已经 build 好了,所以 Nix 不会再出现 build 的日志。

Docker (容器) 的原理 曾经解释过,Docker image 本质上就是一个 tar 包。我们使用 docker load -i ./result 可以 load 这个 image。然后就可以运行了:

可能你发现了这个 image 有一些奇怪:Created 51 years ago. 其实这是对的。因为 Nix 号称是完全 reproducible 的,但是 image 如果有一个创建时间的话,那么每次 build 出来的产物都会因为这个创建时间,而导致每次的产物 hash 都不一样。所以 Nix 将 Docker image 产物的 Created 时间设置成 0 了。即 timestamp = 0.

看看这个镜像里面都有什么?

读者可以在 Docker hub 上下载这个镜像,然后使用 docker save 将它保存成 tar 再解压,看看里面都有什么。我这里直接去解压 Nix build 好的 image,每一层 layer 下面的 layer.tar 也都解压到对应的 layer 下面了。

可以看到,里面一层只有一个 redis-server 的 binary,上面一层是一个 bianry 的符号链接。符号链接是 Nix 的逻辑,符号链接很小,就懒得去删除了。

体验这个镜像

我把这个镜像放到了 Docker hub 上。可以直接运行 docker run laixintao/redis-minimal:v1 redis-server 来体验一下。

 

Docker 在 NixOS 里面的安装可以参考这里