分布式架构的观测
在一个分布式应用中,如果出现了某个异常,那我们必然不可能只依靠 awk、grep 等命令来查看日志分析问题,往往分布式架构的一个异常都贯通多个节点,我们需要将多个节点联系起来排查问题。这就引出了分布式架构的可观测性,可观测性越高,排查问题越轻松
学术界一般会将可观测性分解为三个更具体方向进行研究,分别是:事件日志、链路追踪和聚合度量,这三个方向各有侧重,又不是完全独立
# 日志
日志的职责是记录离散事件,通过这些记录事后分析出程序的行为,譬如曾经调用过什么方法,曾经操作过哪些数据
如何打出优秀的日志是程序员的基本功,如果日志量太大会造成 OOM,如果日志经常打 error 会导致监控报警特别大
只要稍微复杂点的系统,尤其是复杂的分布式系统,往往还要有专门的全局查询和可视化功能。此时,从打印日志到分析查询之间,还隔着收集、缓冲、聚合、加工、索引、存储等若干个步骤。我们简单的梳理一下日志存储与打印的流程,下面所说的流程,就是 ELK 技术栈
# 日志的输出
日志输入就是我们在代码中使用 log 提供的方法输出,当然 print 等方法也可以,不过不推荐。这块主要是开发的工作,打出优秀的日志可以便于排查问题,我们应该尽可能的避免如下几点:
- 日志不能太多,也不能太少,不必把上下文的所有消息都打进去,否则会造成 IO 问题。有些不必要的 info 日志,在测试的时候可以打出来用于排查问题,但是项目上线后就需要删掉
- 避免打印敏感信息。不用专门去提醒,任何程序员肯定都知道不该将密码,银行账号,身份证件这些敏感信息打到日志里
- 避免引用慢操作。日志中打印的信息应该是上下文中可以直接取到的,如果当前上下文中根本没有这项数据,需要专门调用远程服务或者从数据库获取,又或者通过大量计算才能取到的话,那应该先考虑这项信息放到日志中是不是必要且恰当的
我们应当尽可能做到如下几点:
- 处理请求时的 TraceID
- 系统运行过程中的关键事件。日志的职责就是记录事件,进行了哪些操作、发生了与预期不符的情况、运行期间出现未能处理的异常或警告、定期自动执行的任务,等等,都应该在日志中完整记录下来
- 启动时输出配置信息
# 收集与缓冲
写日志是在服务节点中进行的,但我们不可能在每个节点都单独建设日志查询功能。这不是资源或工作量的问题,而是分布式系统处理一个请求要跨越多个服务节点,为了能看到跨节点的全部日志,就要有能覆盖整个链路的全局日志系统。这个需求决定了每个节点输出日志到文件后,必须将日志文件统一收集起来集中存储、建立索引,由此便催生了专门的日志收集器
为了处理多个节点的协调,除了今天流行的日志收集器,轻量高效的 Filebeat 以外,还需要有专门用于其他功能的组件,比如用于审计数据的 Auditbeat、用于无服务计算架构的 Functionbeat、用于心跳检测的 Heartbeat 等等
至于缓冲,是指流量大的时候,会有过多的日志打进 DB,导致数据库压力过大。一种最常用的缓解压力的做法是将日志接收者从 Logstash 和 Elasticsearch 转移至抗压能力更强的队列缓存,譬如在 Logstash 之前架设一个 Kafka 或者 Redis 作为缓冲层
# 加工与聚合
将日志集中收集之后,存入 Elasticsearch 之前,一般还要对它们进行加工转换和聚合处理。这是因为日志是非结构化数据,一行日志中通常会包含多项信息,如果不做处理,那在 Elasticsearch 就只能以全文检索的原始方式去使用日志,既不利于统计对比,也不利于条件过滤
为了加快查询速度,我们必须要建立索引,但是对一串巨大的字符串直接建立索引肯定不是优秀的解法,我们可以根据输出日志的特征、形式,来对字符串做拆分,拆出来的数据就可以建立不同的索引了
这就引出了我们处理加工聚会的工具 Logstash。Logstash 的基本职能是把日志行中的非结构化数据,通过 Grok 表达式语法转换为上面表格那样的结构化数据,然后以 JSON 格式输出到 Elasticsearch 中(这是最普遍的输出形式,Logstash 输出也有很多插件可以具体定制不同的格式)
经过 Logstash 转换,已经结构化的日志,Elasticsearch 便可针对不同的数据项来建立索引,进行条件查询、统计、聚合等操作的了
# 存储与查询
在经过一系列的数据处理后,我们终于可以把数据放进 DB 了,也就是 ES Elasticsearch
Elasticsearch 只提供了 API 层面的查询能力,它通常搭配同样出自 Elastic.co 公司的 Kibana 一起使用,可以将 Kibana 视为 Elastic Stack 的 GUI 部分
Kibana 尽管只负责图形界面和展示,但它提供的能力远不止让你能在界面上执行 Elasticsearch 的查询那么简单。Kibana 宣传的核心能力是“探索数据并可视化”,即把存储在 Elasticsearch 中的数据被检索、聚合、统计后,定制形成各种图形、表格、指标、统计,以此观察系统的运行状态,找出日志事件中潜藏的规律和隐患
# Kibana 标准查询语法
一般来说,直接加上双引号查询肯定没错,比如:
"hello"
标准的查询需要按照 key:value 形式。key 用于指定查询的字段类型
local_ip:111.111.111.111
traceid:EMqATv-DAooV
2
数字范围查询和字段查询通过 AND、OR 等结合使用,注意连接符需要大写
"value1" AND "value2"
# 追踪
微服务时代,追踪就不只局限于调用栈了,一个外部请求需要内部若干服务的联动响应,这时候完整的调用轨迹将跨越多个服务,同时包括服务间的网络传输信息与各个服务内部的调用堆栈信息。追踪的主要目的是排查故障,如分析调用链的哪一部分、哪个方法出现错误或阻塞,接口的输入输出是否符合预期
# 数据收集
追踪数据一般可以使用以下三种方式来实现
- 基于日志的追踪:我们将 TrackID 打进日志里,然后随着所有节点的日志归集过程汇聚到一起,再从全局日志信息中反推出完整的调用链拓扑关系。打入日志这个操作完全可以由插件实现,做到对开发透明,但是缺点是由于日志归集不及时或者精度丢失,导致日志出现延迟或缺失记录的话,会进而产生追踪失真
- 基于服务的追踪:这是目前最主流的追踪方式,服务追踪的实现思路是通过某些手段给目标应用注入追踪探针(Probe),探针在结构上可视为一个寄生在目标服务身上的小型微服务系统,把从目标系统中监控得到的服务调用信息,通过另一次独立的 HTTP 或者 RPC 请求发送给追踪系统
- 基于边车代理的追踪:我们知道边车模式是对服务加一层代理,通过代理来对外层进行交互。边车代理是服务网格的专属方案,也是最理想的分布式追踪模型。它对应用完全透明,无论是日志还是服务本身都不会有任何变化;它与程序语言无关,无论应用采用什么编程语言实现,只要它还是通过网络来访问服务就可以被追踪到
# 业务追踪
Dapper 提出了追踪与跨度两个概念。从客户端发起请求抵达系统的边界开始,记录请求流经的每一个服务,直到到向客户端返回响应为止,这整个过程就称为一次追踪(Trace)。由于每次 Trace 都可能会调用数量不定、坐标不定的多个服务,为了能够记录具体调用了哪些服务,以及调用的顺序、开始时点、执行时长等信息,每次开始调用服务前都要先埋入一个调用记录,这个记录称为一个跨度
让我举个例子来说明上面文字的重要性:
现在有一个很长的业务场景:用户下单,其中必定经过很多流程,比如拿产品信息,校验用户信息,生单,回调,通知其他系统,调支付接口,等等等等,如果用户生单失败了,他们很有可能过来问我们发生了什么问题,这时候如果我们的日志打的很杂很乱,不记录关键步骤,这时候查这个问题就会非常麻烦
而如果我们将这些关键节点记录下来(比如拿完商品信息记录一下,生单完毕记录一下),记录到一个专门的业务日志中,比如 core.log,这时候我们查问题就会非常简单了,只需要拿一个可以贯穿整个流程的 ID(比如用户 ID)去这个日志中搜一下,就可以拿到数据了。这个日志甚至可以放权给运营或者用户自己使用
# 度量(监控和预警)
度量是指对系统中某一类信息的统计聚合。譬如,证券市场的每一只股票都会定期公布财务报表,通过财报上的营收、净利、毛利、资产、负债等等一系列数据来体现过去一个财务周期中公司的经营状况,这便是一种信息聚合。像是任务管理器,就是度量的一种
度量(Metrics)的目的是揭示系统的总体运行状态,因此可以拆分为监控(Monitoring)和预警(Alert),如某些度量指标达到风险阈值时触发事件,以便自动处理或者提醒管理员介入
打完了日志之后,别忘了监控报警。在我们做好核心日志追踪后,可以接着搭建业务追踪告警。这样在项目发布时,如果变更比较多的情况下,可以通过观察告警指标来观察我们的系统是否健康。而告警指标可以通过项目的核心链路来搭建
# 指标
指标收集部分要解决两个问题:如何定义指标以及如何将这些指标告诉服务端, 如何定义指标这个问题听起来应该是与目标系统密切相关的,必须根据实际情况才能讨论,其实并不绝对,无论目标是何种系统,都是具备一些共性特征。确定目标系统前我们无法决定要收集什么指标,但指标的数据类型(Metrics Types)是可数的,所有通用的度量系统都是面向指标的数据类型来设计的:
- 计数度量器(Counter):这是最好理解也是最常用的指标形式,计数器就是对有相同量纲、可加减数值的合计量,譬如业务指标像销售额、货物库存量、职工人数等等;技术指标像服务调用次数、网站访问人数等都属于计数器指标;预警指标是监控系统健康性最重要的数据,包含发生异常次数以及时间
- 吞吐率度量器(Meter):吞吐率度量器顾名思义是用于统计单位时间的吞吐量,即单位时间内某个事件的发生次数。譬如交易系统中常以 TPS 衡量事务吞吐率,即每秒发生了多少笔事务交易
- 时间度量器(Timer):用来统计一个方法或者一个接口的执行时间,然后我们收集 P98 或者 P95 的信息来进行预警,对接口优化
我们现在定义了如何收集这些数据,但是还没有定义如何使用这些数据。优秀的使用方式同样重要,如果错误的使用指标(比如只要每个计数度量器一分钟记了一次就报警一次),监控会瞎报警,从而让程序员忽视掉真正有用的报警。这个问题被称作报警噪音
# 数据采集方式
而如何将这些指标告诉服务端这个问题,通常有两种解决方案:拉取式采集和推送式采集,所谓 Pull 是指度量系统主动从目标系统中拉取指标,相对地,Push 就是由目标系统主动向度量系统推送指标。不管是拉取还是推送,在进行操作之前,指标数据会记在内存中
存放指标一般不会选择 MySQL 或者 PG 等关系型数据库,而是时序数据库。时序数据库用于存储跟随时间而变化的数据,并且以时间(时间点或者时间区间)来建立索引的数据库
时间序列数据是历史烙印,具有不变性、唯一性、有序性。时序数据库同时具有数据结构简单,数据量大的特点
时序数据通常只是追加,很少删改或者根本不允许删改。针对数据热点只集中在近期数据、多写少读、几乎不删改、数据只顺序追加这些特点,时序数据库被允许做出很激进的存储、访问和保留策略:
- 以日志结构的合并树代替传统关系型数据库中的 B+Tree 作为存储结构,LSM 适合的应用场景就是写多读少,且几乎不删改的数据
- 设置激进的数据保留策略,譬如根据过期时间自动删除相关数据以节省存储空间,同时提高查询性能。对于普通数据库来说,数据会存储一段时间后就会被自动删除这种事情是不可想象的
- 对数据进行再采样以节省空间,譬如最近几天的数据可能需要精确到秒,而查询一个月前的数据时,只需要精确到天,查询一年前的数据时,只要精确到周就够了,这样将数据重新采样汇总就可以极大节省存储空间
# 异常打点最佳实践
异常打点是计数度量器中一种比较特殊的打点,他可以让我们观测到系统中的问题,他可能会打在接口出现异常、系统抛出异常、用户被异常拦截时,如何规整他们成了一个问题
为了更好的度量我们的系统,以下有一些类似最佳实践的经验,先说一下在什么地方打点,最最简单的度量就是帮助我们快速召回系统中的问题,即让系统中的每个 bug 都由我们程序员提前发现,这是非常有用的,用户没有发现的 bug 就不算 bug。因此,系统中每次抛出异常的时候,我们可以在以下位置打点:
- 1,定时任务 try catch 单独打点,如果可以抛出异常,可以使用切面打点
- 2,rpc 接口如果不能直接把异常抛出,并且返回值没有统一的格式,也是单独打点。如果可以抛出异常,则使用切面打点
- 3,MQ 的处理和上面一样
- 4,如果是 http 接口并且抛出异常的话,可以使用全局异常处理器打点
- 5,如果 http 接口是正常的返回,但是返回的业务码不是正常的,可以切面打点(需要收集系统中存量的所有返回结构数据,比如什么 CommonQueryResult、CommonResult 等,一个系统中往往有多个统一结果返回,但是每个返回都有 code,我们可以根据 code 来区分是异常还是正常返回)
- 6,不要在 service 层拦截,会打两次点的
- 7,请注意一些拦截或者异常信息,我们需要做三件事情:详细的打点、日志、返回给用户合理的提示
知道了在什么地方打点,我们还需要知道针对什么异常打点,如果每次出现已经预知的异常或者业务异常都给我们发告警,未免太影响休息了,因此我们可以对异常分级,分级的意思是将异常分为 info、error、warn 级别,比如:
- 用户传入参数没有通过校验、用户无权限、输入的字符串过长等业务异常问题,可以将异常记为 info 级别。这些异常不需要重点关注,但是如果一个小时内出现了十万条这样的点,那说明要么是代码写错了,要么是有人在刷我们接口,无论如何,此时都需要通知我们处理
- 下游接口调用失败、RPC 调用失败等问题,可以将异常标记为 warn,因为这不是我们系统导致的,确保我们做好了异常降级,然后打点即可。我们可以在看到这些异常后疯狂艾特下游,但是不用过多关注这些 warn
- 空指针等系统异常都需要标记为 error,但凡是系统异常影响用户体验的问题,都必须是 error,一旦出现就需要报警,快速处理,不让用户感知
举个例子:
public enum ExceptionLevelEnum {
// 系统异常、重要的业务异常,打一次就会报警
ERROR,
// 降级的异常
WARN,
// 正常的返回
INFO,
}
......
SERVICE_ERROR(0, "系统错误", ExceptionLevelEnum.ERROR),
BIZ_ERROR(101, "业务错误", ExceptionLevelEnum.WARN),
LOCK_ALREADY_EXISTS(102, "操作太频繁,请稍后再重试", ExceptionLevelEnum.INFO),
2
3
4
5
6
7
8
9
10
11
12
13
我们拿到枚举值中的 LevelEnum 后,就知道需要打什么级别的日志了
我们在打点的时候所关注的核心数据是什么呢?我们在打点需要打出哪些数据呢:哪里的方法抛出的异常(方法名),异常内容,异常分级级别,要是分不了也没关系,只打印最核心的系统异常(因为代码问题导致流程中断的异常)即可
这里推荐将一些通用的问题规整到同一个打点中,这样做后续新增异常指标时就不用新增报警了。一些有业务语意的才需要做特殊的打点
我们还可以将异常返回和统一返回进行收口,这么做的好处是状态码统一了,异常返回具备以下信息:错误码、错误信息、异常类型;统一返回具备以下信息:业务状态码(可统一为错误码)、message(对应错误信息)。因此两个返回可以收口到同一个错误码中
// 系统错误
public class SystemException extends Exception {
private long code;
private String message;
private ExceptionLevelEnum exceptionLevelEnum;
public SystemException(long code, String message, ExceptionLevelEnum exceptionLevelEnum) {
this.message = message;
this.code = code;
this.exceptionLevelEnum = exceptionLevelEnum;
}
public SystemException(SystemExceptionEnum exceptionEnum) {
this.message = exceptionEnum.getMessage();
this.code = exceptionEnum.getCode();
this.exceptionLevelEnum = exceptionEnum.getLevel();
}
}
// 系统异常枚举
@Getter
public enum SystemExceptionEnum {
// 基础错误码
NO_ERROR(0, "成功", ExceptionLevelEnum.INFO),
BUSINESS_EXCEPTION(1, "业务异常", ExceptionLevelEnum.ERROR),
OTHERS(2, "其他", ExceptionLevelEnum.ERROR);
private final long code;
private final String message;
private final ExceptionLevelEnum level;
SystemExceptionEnum(long code, String message, ExceptionLevelEnum level) {
this.code = code;
this.message = message;
this.level = level;
}
public static SystemExceptionEnum getByCode(long code) {
for (SystemExceptionEnum value : SystemExceptionEnum.values()) {
if (value.getCode() == code) {
return value;
}
}
return OTHERS;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
当你打点的时候,可以使用系统异常和返回码打点,下面是一个例子:
public static final String CODE = "code";
public static final String MESSAGE = "message";
public static final String URL = "url";
public static final String LEVEL = "level";
// 普通异常打点,用于监控系统中出现的业务异常和系统异常
public static void dot(SystemExceptionEnum exceptionEnum, String url) {
MetricUtil.dot(ERROR_CNT_EXCEPTION,
CODE, String.valueOf(exceptionEnum.getCode()),
MESSAGE, exceptionEnum.getMessage(),
URL, url,
LEVEL, exceptionEnum.getLevel().name());
}
public static void dot(SystemException exception, String url) {
MetricUtil.dot(ERROR_CNT_EXCEPTION,
CODE, String.valueOf(exceptionEnum.getCode()),
MESSAGE, exception.getMessage(),
URL, url,
LEVEL, exception.getLevel().name());
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21