简单的时间序列查询

  • 返回所有指标名字为 http_requests_total 的时间序列
1
http_requests_total
  • 返回所有名字为 http_requests_total,并且 jobhandler 标签为以下值的时间序列
1
http_requests_total{job="apiserver", handler="/api/comments"}
  • 返回一个时间范围内的指标数据(返回一个范围向量,range vector)
1
http_requests_total{job="apiserver", handler="/api/comments"}[5m]

返回范围向量的表达式不能直接用图表展示,但是可以通过表格的方式展示。

  • 匹配标签的时候我们也可以使用正则表达式,如:
1
http_requests_total{job=~".*server"}
  • 选择不是 4xx 的状态的数据:
1
http_requests_total{status!~"4.."}

子查询

  • 返回 http_requests_total 过去 30 分钟内每 5 分钟的速率,分辨率(resolution)为 1 分钟
1
rate(http_requests_total[5m])[30m:1m]
  • 嵌套的子查询
1
max_over_time(deriv(rate(distance_covered_total[5s])[30s:5s])[10m:])

使用函数、操作符等

  • 返回 http_requests_total 过去 5 分钟每秒的速率
1
rate(http_requests_total[5m])
  • 根据相同 job 统计
1
2
3
sum by (job) {
rate(http_requests_total[5m])
}
  • 不同指标计算
1
(instance_memory_limit_bytes - instance_memory_usage_bytes) / 1024 / 1024
  • 根据相同标签聚合
1
2
3
sum by (app, proc) {
instance_memory_limit_bytes - instance_memory_usage_bytes
} / 1024 / 1024

过去的一个星期里,一直在做一些重构工作(因为这部分代码年久失修导致出问题的时候排查非常困难),因为关联的业务逻辑比较多,加上业务流程太长等原因,费了好大的劲才把其中一部分逻辑从原来散落在各个 service 里面抽离出来。前两天把这部分代码重构完之后,一开始想着自己测试一遍,没问题就交给测试测,这是相对容易的一种方式。但其实重构后的代码还是有不少的问题,只不过没有原来那么大的问题,所以内心隐隐觉得后面如果这部分代码出 bug 还是会不太好修。

为什么写单元测试?

重构完这一部分代码之后,想着就按 plan A 来测试吧,然后就跑去看了下《聊聊架构》,看到了里面第20章的单元测试之后,对于单元测试多年以来的一些困惑得到了答案。比如里面讲到的“单元测试测什么”,很简单的一句话,就是测软件工程师自己写的逻辑,对于服务代码、存储代码、别人写的代码是不需要测试的(这里的意思是在测自己的逻辑的时候,如果需要调用外部的方法,我们不需要再去验证这个外部调用是否正确,因为这一步验证是应该放在那个外部方法对应的单元测试里面实现,这也是书里一直强调的“权责对等”的问题)。

其实一开始写现在重构的这部分代码的时候,就已经尝试过写“单元测试”,这里的单元测试是带有引号的,因为以前觉得那是的单元测试,但实际上那叫集成测试,那时候写的是对接口的测试,就是通过模拟请求,测试请求的结果准确性。这样做的问题是,先要生成模拟数据,但是这部分代码需要的关联实在是太多了,导致写完生成模拟数据的代码就已经花了很多时间了,而且这样有个问题是,对数据库强依赖,实在是难以处理,所以那时候的测试只写了一个版本,后续就没有维护了。毕竟实在是没法维护,依赖太多,耦合太多,流程太长,要做到响应需求的变化非常的困难。

看完这书之后,加上前段时间看了《重构》,所以想着再尝试一下写写单元测试。毕竟《重构》里面提到“重构的第一块基石是自测试代码”(里面很多地方都提到这一点,而且里面介绍的重构步骤也频繁提到“重构->测试”这种模式),也就是说,如果你要对代码进行重构,你的代码首先要有对应的测试代码,这样才能保证你的每一步重构的操作不会对原有代码造成破坏,这样的重构才是真正的重构。

另外一个原因是,用在代码维护的时间往往会比开发时间多很多,如果有了单元测试,可以在后续的维护过程中放心地做一些修改,因为单元测试会告诉你你的修改有没有影响到原有的代码,这也就省下了很多做回归测试的时间。这样可以释放一部分脑容量去思考一些更值得花时间思考的东西,同时我们只需要专注于新的代码,过去的代码不再对我们的思想造成负担。正如《聊聊架构》里面说为什么要做单元测试:“写的代码越多,不安全感累积会越多,最后会发觉自己对自己所写的代码完全没有把握,这是非常影响生活质量的”。

写单元测试过程发现的一个问题

聊完为什么写单元测试之后,再聊聊这几天在单元测试上的一些实践及从中得到的一些启发吧。重构完之后,得到的代码总感觉有点不太完善,但是就是说不出来哪里不对。暂时先不管吧,先去写单元测试代码了。

写单元测试的过程中,重构之后的那一部分代码的问题开始暴露出来了,比如,其中包含了不少对依赖类的属性的使用,大概格式如下面这种:

1
$obj->a->b->c

又或者像下面这种,对依赖对象里面属性下方法的调用:

1
$obj->a->b()

这样有什么问题呢?一眼看上去好像没有啥问题。但是在我写单元测试的时候发现一个问题,对于 $obj->a->b->c 这种代码,想给它们写测试的话,必须先进行一些复杂的 mock 操作,但关键是,a 里面的 b 及 b 里面的 c 属性都不是我当前类关注的内容,现在却要我去 mock 依赖对象的内部实现细节。而且如果我另一个类也需要获取 c 这个属性的时候,给这个新的类写单元测试又要 mock 一堆对象,这明显就会导致产生重复的单元测试操作。如果多个类都需要用到这个 c 属性,那样岂不是会产生一大堆重复的代码。

现在来让我们概括一下,这里的问题就是我需要了解依赖内部才能用好这个依赖的对象。我本来只是想使用依赖的对象,但上面这一种写法却需要我们去了解依赖对象内部的结构,因为对于我来说,$obj 是跟我直接交互的对象,你这个对象里面的细节我并不关心。也就是说, 上面这种写法违背了面向对象封装的特性,主要影响就是,对象的细节实现散落在对象以外的地方,也就不是所谓高内聚的代码了。如果后续这个对象的细节需要改动的时候,就需要去找到所有用到了这个细节的地方去修改,改动的地方可能会非常多。另一方面,如果原来的代码缺乏单元测试,改动越多,改动带来的风险就越大,这些潜在的风险往往会给我们带来一些心智上的负担。可能也会在上线之后不定期给我们带来惊喜。

也许可以用另外一个例子说明这个问题。我们去银行存钱的时候,跟我们交互的是银行的工作人员,具体怎么存我们不需要去干涉(我们也干涉不了),我们只需要告诉他们我要存钱然后将我们的钱和银行卡给工作人员,他们会帮我们完成具体的存钱操作(而不需要我们去告诉工作人员第一步怎么做,第二步怎么做,因为具体怎么操作是工作人员的职责范围内的事,我们不能去干涉)。其实归根到底还是 SOLID 里面的 SRP(单一职责原则)。

那该如果应对这种问题呢?目前我的做法是,将这些对依赖内部细节的实现挪动到对象本身里去,给它们定义对应的方法(它们配得上一个独立的方法)。这也是《聊聊架构》里面多次强调的权责对等,细节的东西本来属于你,现在只是把这一部分职责归还给了你。这样的结果就是,我们想了解的所有细节,都可以在一个地方找到,就是我们依赖的那个对象里面。对于外部来说,能看到的只是那个对象提供的功能(public 方法),但是这就已经足够了。

所以,单元测试一方面可以保证我们代码的可靠程度,同时在写测试的时候你会发现你代码设计得不好的地方。有个有趣的评判标准是,编写单元测试的难度与代码质量成反比。最近真的是深有体会。

单元测试之后

准确来讲,这个操作是在我写单元测试的过程中完成的,多亏我们的运维同学,给 gitlab 加上了 runner,让我得以实现在 ci 里面加上单元测试的操作(同时之前 build 的 docker 镜像又一次可以派上用场了)。这样一来,好像开始有那味了,多年以前的一些想法得以实现了。同时,看着 gitlab ci 的 job 都 passed 蛮有意思的,哈哈。

经历了重构、写单元测试、再重构的过程之后,感觉单元测试这件事也许没那么难,如果觉得单元测试写不出来,很可能是因为很多代码出现在了它们不该出现的地方。用专业一点的话来说就是,设计上有缺陷,对于这个目前也还不是太了解。不过有一条很基本的原则,可以做到的话,可能会给我们带来极大的好处,上面也有提到,就是 SRP。个人感觉这可能是所有软件设计的最基本的原则吧。

对个人而言,关于单元测试的很多困惑、认识上的误区已经解决了,接下来其实问题没那么多了,该写单元测试的时候可以写一写了,可以更好地保障一下代码质量了(但是对于生活,依然有很多困惑,诶)。

关于《聊聊架构》这本书

这本书之前听都没听过,在同事吹爆了之后,还是去看了,感觉还是有其独特的价值(可能对于很多不同角色的人都能在书里得到一些启发)。里面一些观点的角度都挺独特的,如果没有很深刻的经验、经历等想不到那些点,又或者可能是因为我读的书少吧。总之,可以一翻。

最后,分享一下最近看的其他书

《代码整洁之道》,这本书其实应该早点看的,操作性不强,但是看完之后会让你看到自己写的代码里面很多不太好的地方,也就是所谓的坏味道(code smells)。比如,之前总是强迫性地给每个方法、属性加上注释,读完之后发现很多注释是没有必要的,一方面可以通过好的命名解决,另一方面代码本身足够简单到不需要注释。

《重构》,Martin Fowler 的经典之作,看的是第二版,实操性很强,在我们需要重构的时候可以当做工具类的书籍来翻阅(不过目前他们有提供在线网站,上面也列出了所有的重构方法,可以直接到他们网站上看)。如果说《代码整洁之道》只是列出 code smells 的话,那这本《重构》就是教你怎么去除这些 code smells。同样可以在本书里发现现有代码写法上的缺陷,因为里面每一种重构的方法都给出了重构的动机,也就是为什么要用那种重构方法。

《编程的逻辑》,没有前面两本经典,但是里面讲到的从需求到最终实现的一些实践方法值得尝试一下。跟软件设计有点关系,但关注的是整一个流程,所以每一个点都不会太深入。

总结

总结一下,聊了些什么东西。 1、代码维护的时间远远大于开发的时间,单元测试的存在可以让我们放心地对代码进行一些修改。 2、单元测试一方面可以保证我们代码的可靠程度,同时在写测试的时候你会发现你代码设计得不好的地方。

其他,关于单元测试的一些认识误区,可以去《聊聊架构》里面看看单元测试这一章。

后记

其实一个多星期之前就想写一下看完《重构》之后的一些想法,但是想着虽然看完了,但是还没有实践过,似乎不是太好。现在好歹也真正地实践过了,可以写一写了,但是之前关于重构的一些想法好像没有了。没有就没有了吧,也许这就是生活吧,有些东西失去了就再也找不回来了。

另外,关于写代码这件事,真的需要很多考量的地方,需要抛却很多主观上的想法,然后对自己写的代码作出客观的评价,而这可能不是一件容易的事。不过是一件值得尝试的事。

在 linux 下执行 shell 脚本的时候,有可能会报错 line 2: $'\r': command not found

出现这种错误的原因是,shell 识别不了 windows 的换行符 \r\n

Ubuntu 解决方法

script
1
2
sudo apt-get install tofrodos
fromdos 文件名

Centos 解决方法

script
1
2
yum -y install dos2unix
dos2unix 文件名

Prometheus 的客户端库中提供了四种核心的指标类型:Counter(计数器)、Guage(仪表盘)、Histogram(直方图)、Summary(摘要)。但这些类型只是在客户端库中存在,实际在 Prometheus 上并不对指标类型进行区分,而是简单地把这些指标类型统一视为无类型的时间序列。

Counter(计数器)

Counter 类型代表一种样本数据单调递增的指标,即只增不减,除非监控系统发生了重置。例如,你可以使用 counter 类型的指标来表示服务的请求数、已完成的任务数、错误发生的次数等。counter 主要有两个方法:

1
2
3
4
// 将 counter 值加1
Inc()
// 将指定的值加到 counter 值上,如果指定值 < 0 会 panic
Add(float64)

Counter 类型数据可以让用户方便的了解事件发生的速率的变化,在 PromQL 内置的相关操作函数可以提供相应的分析,比如以 HTTP 应用请求量来进行说明:

1
2
3
4
// 通过 rate() 函数获取 HTTP 请求量的增长率
rate(http_requests_total[5m])
// 查询当前系统中,访问量前 10 的 HTTP 地址
topk(10, http_requests_total)

不要将 counter 类型应用于样本数据非单调递增的指标,例如:当前运行的进程数量(应该用 Guage 类型)。

Guage(仪表盘)

Guage 类型代表一种样本数据可以任意变化的指标,即可增可减。guage 通常用于像温度或者内存使用率这种指标数据,也可以表示能随时增加或减少的 "总数",例如:当前并发请求的数量。

对于 Guage 类型的监控指标,通过 PromQL 内置函数 delta() 可以获取样本在一段时间内的变化情况,例如,计算 CPU 温度在两小时内的差异:

1
delta(cpu_temp_celsius{host="zeus"}[2h])

你还可以通过 PromQL 内置函数 predict_linear() 基于简单线性回归的方式,对样本数据的变化趋势做出预测。例如,基于 2 小时的样本数据,来预测主机可用磁盘空间在 4 个小时之后的剩余情况:

1
predict_linear(node_filesystem_free{job="node"}[2h], 4 * 3600) < 0

Histogram(直方图)

在大多数情况下人们都倾向于使用某些量化指标的平均值,例如 CPU 的平均使用率、页面的平均响应时间。这种方式的问题很明显,以系统 API 调用的平均响应时间为例:如果大多数 API 请求都维持在 100ms 的响应时间范围内, 而个别请求的响应时间需要 5s,那么就会导致某些 web 页面的响应时间落到中位数的情况,而这种现象被称为长尾问题。

为了区分是平均的慢还是长尾的慢,最简单的方式就是按照请求延迟的范围进行分组。例如,统计延迟在 0~10ms 之间的请求数有多少而 10~20ms 之间的请求数又有多少,通过这种方式可以快速分析系统慢的原因。 Histogram 和 Summary 都是为了能够解决这样问题的存在,通过 Histogram 和 Summary 类型的监控指标,我们可以快速了解监控样本的分布情况。

Histogram 在一段时间范围内对数据进行采样(通常是请求持续时间或响应大小等),并将其计入可配置的存储桶(bucket)中,后续可通过指定区间筛选样本,也可以统计样本总数,最后一般将数据展示为直方图。

Histogram 类型的样本会提供三种指标(假设指标名称为 ):

  • 样本的值分布在 bucket 中的数量,命名为 _bucket{le="<上边界>"}。解释得更通俗易懂一点,这个值表示指标值小于等于上边界的所有样本数量。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 在总共2次请求当中。http 请求响应时间 <=0.005 秒 的请求次数为0
io_namespace_http_requests_latency_seconds_histogram_bucket{path="/",method="GET",code="200",le="0.005",} 0.0
// 在总共2次请求当中。http 请求响应时间 <=0.01 秒 的请求次数为0
io_namespace_http_requests_latency_seconds_histogram_bucket{path="/",method="GET",code="200",le="0.01",} 0.0
// 在总共2次请求当中。http 请求响应时间 <=0.025 秒 的请求次数为0
io_namespace_http_requests_latency_seconds_histogram_bucket{path="/",method="GET",code="200",le="0.025",} 0.0
io_namespace_http_requests_latency_seconds_histogram_bucket{path="/",method="GET",code="200",le="0.05",} 0.0
io_namespace_http_requests_latency_seconds_histogram_bucket{path="/",method="GET",code="200",le="0.075",} 0.0
io_namespace_http_requests_latency_seconds_histogram_bucket{path="/",method="GET",code="200",le="0.1",} 0.0
io_namespace_http_requests_latency_seconds_histogram_bucket{path="/",method="GET",code="200",le="0.25",} 0.0
io_namespace_http_requests_latency_seconds_histogram_bucket{path="/",method="GET",code="200",le="0.5",} 0.0
io_namespace_http_requests_latency_seconds_histogram_bucket{path="/",method="GET",code="200",le="0.75",} 0.0
io_namespace_http_requests_latency_seconds_histogram_bucket{path="/",method="GET",code="200",le="1.0",} 0.0
io_namespace_http_requests_latency_seconds_histogram_bucket{path="/",method="GET",code="200",le="2.5",} 0.0
io_namespace_http_requests_latency_seconds_histogram_bucket{path="/",method="GET",code="200",le="5.0",} 0.0
io_namespace_http_requests_latency_seconds_histogram_bucket{path="/",method="GET",code="200",le="7.5",} 2.0
// 在总共2次请求当中。http 请求响应时间 <=10 秒 的请求次数为 2
io_namespace_http_requests_latency_seconds_histogram_bucket{path="/",method="GET",code="200",le="10.0",} 2.0
io_namespace_http_requests_latency_seconds_histogram_bucket{path="/",method="GET",code="200",le="+Inf",} 2.0
  • 所有样本值的大小总和,命名为 _sum
1
2
// 实际含义:发生的2次 http 请求总的响应时间为 13.107670803000001 秒
io_namespace_http_requests_latency_seconds_histogram_sum{path="/",method="GET",code="200",} 13.107670803000001
  • 样本总数,命名为 _count。值和 _bucket{le="+inf"} 相同。
1
2
// 实际含义:当前一共发生了 2 次 http 请求
io_namespace_http_requests_latency_seconds_histogram_count{path="/",method="GET",code="200",} 2.0

可以通过 histogram_quantile() 函数来计算 Histogram 类型样本的分位数。分位数可能不太好理解,我举个例子,假设你要计算样本的 9 分位数(quantile=0.9), 即 90% 的样本的值。Histogram 还可以用来计算应用性能指标值(Apdex score)。

Summary(摘要)

与 Histogram 类型类似,用于表示一段时间内的数据采样结果(通常是请求持续时间或响应大小等),但它直接存储了分位数(通过客户端计算,然后展示出来),而不是通过区间来计算。

Summary 类型的样本也会提供三种指标(假设指标名称为):

  • 样本值的分位数分布情况,命名为 {quantile="<φ>"}
1
2
3
4
// 含义:这 12 次 http 请求中有 50% 的请求响应时间是 3.052404983s
io_namespace_http_requests_latency_seconds_summary{path="/",method="GET",code="200",quantile="0.5",} 3.052404983
// 含义:这 12 次 http 请求中有 90% 的请求响应时间是 8.003261666s
io_namespace_http_requests_latency_seconds_summary{path="/",method="GET",code="200",quantile="0.9",} 8.003261666
  • 所有样本值的大小总和,命名为 _sum
1
2
// 含义:这12次 http 请求的总响应时间为 51.029495508s
io_namespace_http_requests_latency_seconds_summary_sum{path="/",method="GET",code="200",} 51.029495508
  • 样本总数,命名为 _count
1
2
// 含义:当前一共发生了 12 次 http 请求
io_namespace_http_requests_latency_seconds_summary_count{path="/",method="GET",code="200",} 12.0

现在可以总结一下 Histogram 与 Summary 的异同:

  • 它们都包含了 _sum 和 _count 指标。

  • Histogram 需要通过 _bucket 来计算分位数,而 Summary 则直接存储了分位数的值。

在 web 应用中,我们有很大几率会遇到一个请求无响应的情况,这种情况往往是某些系统调用阻塞导致的(比如 MySQL、MongoDB 查询一直没有返回),我们该怎么来知道是什么原因导致的呢?

在 linux 下,我们可以使用 ltrace 来查看进程当前的状态。

使用方法:

script
1
ltrace -p 1234

这里的 1234 是进程 id。

但是有个需要注意的是,对于已经阻塞的进程,通过 ltrace 无法知道当前阻塞在什么系统调用上。不过如果可以在进程处理请求之前拿到进程 id 的话,就可以在请求到来之前先调用 ltrace,然后就可以看到请求过程中的系统调用。

另外有个类似的命令 strace。

0%