业务系统的稳定性建设
作者: 发布于:

引文

在当下微服务、分布式架构的背景下,一次简单的接口调用背后可能涉及到了多个业务服务单元和服务器。如何才能在当前架构下准确、高效、即时地了解业务系统的运行情况、保障系统稳定性、快速定位线上异常?本文正是针对于该问题解决方案的探讨。并立足于讨论如何通过建立完善的日志分析系统来保障业务系统的稳定性。

PS:本文主要在业务服务的范围内去讨论稳定性建设,并不包含非业务相关的服务器级别(如:网络、CPU等)稳定性内容。

基础架构

当我们要了解系统运行情况,首先我们需要将我们认为能反应系统运行情况的数据进行收集,日志便是系统运行情况数据很好的载体。以一定的方式收集分散在各个服务器上的日志数据,再按需进行消费。这一思路便是最基础的日志分析链路。而一个完整的日志分析体系,最基础需要具备的服务有:

  1. 日志采集服务:负责将分布在不同服务器上的日志数据进行收集,以及对原始日志数据的加工。一般分为部署在应用服务侧的 Agent 和对采集上来的数据进行再加工和转储的后端服务。
  1. 日志存储服务:负责持久化采集上来的日志信息。
  1. 日志消费服务:根据自身的需求进行搭建的日志信息消费服务。

建立一整套完善的日志分析产品有很多要保障的地方:1、采集信息的实时性;2、日志采集服务的采集成功性;3、日志存储服务的弹性伸缩;4、分析服务对于文本查询、复杂分析性查询的支持等等。而现在市面上已经有很多较为成熟的日志分析产品可以直接接入使用,像是:ELK、Prometheus 两个开源届大拿。一些云厂商也推出了各自的日志服务,迭代至今也已相当成熟,且云厂商的日志服务一般还会打通自家的其他服务,以产品化的方式提供更加多样化的服务。不同的产品有着自己不同的侧重点及特色,网上有一些对比文章(Promethenus vs. ELKSLS vs. ELK)可供大家进行产品对比,在此不再进行赘述。

推荐规范

关键采集信息

下面列举一些业务系统比较常见的采集信息内容:

  1. 对本系统的调用信息以及本系统对外部依赖的调用信息
  1. 调用信息中包含:
  1. 该次调用流量唯一标志码(用于串联一次流量的整个调用链)
  1. 请求开始时间、结束时间、耗时
  1. 接口协议、请求方式、接口名
  1. 接口入参和出参(试系统请求量级而定,这两份数据量一般会比较大)
  1. 接口调用人信息及其他有用的上下文信息(如果有)
  1. 一些业务信息(如果有)

采集方式

  1. 像是上面提到的系统接口关键采集信息或者系统异常等都是可以通过切面来统一采集的。统一由切面来管控接口信息的打印策略,使得后续策略调整和升级都能更加简单。同时也避免了重复在接口处进行日志打印的代码书写工作量。
  1. 一些特殊的业务逻辑相关的信息采集可以通过在需要的业务逻辑代码处显式打印。此时除了打印业务信息,最好还要打印流量唯一标志码,用于将该信息和某次具体调用(流量)进行关联。
  1. 为了给统一接口信息附加一些业务标识(比如单据号等),可以将业务标志附加到流量的上下文中,后续切面中打印日志时便可从上下文中获取业务标志。附加业务标志可以在业务代码中手动添加,如果该业务标识具有一定的提取规则,也是可以通过切面等手段进行统一提取附加的。

日志信息获取方式

  1. 日志信息先落本地磁盘,再由日志采集的 agent 去监听日志文件进行数据读取上报。这种情况下,要注意控制日志文件的定时清理策略,避免磁盘打满造成的服务不可用。
  1. 应用服务直接将需要采集的日志信息输出到日志服务。比如:Aliyun Log Log4j Appender 便是该获取思路。

使用场景

测试用例生成

通常我们书写测试用例是为了保障“自身系统”逻辑符合预期,并且鉴于测试用例的“可重复性”要求,我们日常书写的接口级别集成测试用例大致可归纳为如下范式。其中常被忽略的对外部服务调用的MOCK,即是对“可重复性”要求的保障,并确保测试范围仅限于测试“自身系统”逻辑。

describe('some ut', () => {
  it('should be that', async () => {
    // 1. MOCK 对外部服务的调用
    // 2. 准备调用入参和环境
    // 3. 调用测试接口
    // 4. 结果断言
  });
});

虽然我们都认可丰富的测试用例是系统稳定性很好的保障,但是由于写测试用例带来的工作量并不比功能开发小,按照上面的规范为系统的每个接口书写各种场景的测试用例还是有很大的人力成本。好在计算机天然擅长解决有固定范式的问题。既然有了固定范式,那么自然要思考如何借助计算机的手段来为我们自动生成测试代码。首先来看下生成上述测试代码所依赖的信息:

  1. 要 MOCK 对外部服务的调用,依赖:外部服务名以及服务的入参和响应结果
  1. 要准备调用入参和环境,依赖:测试接口的入参、环境参数(用户、来源页面等)
  1. 调用测试接口,依赖:测试接口名
  1. 进行结果断言,依赖:测试接口的出参

通过分析上述范式,我们会发现其依赖的所有信息均已涵盖在上面提到的日志采集信息中。而每个测试用例可以等价于一个流量的回归验证,所以生成测试代码的自动化步骤大致会有:

  1. 选取流量,根据该流量唯一标志码去日志信息存储服务中获取该次调用的自身接口出入参、环境参数和对外部服务调用的出入参信息。
  1. 根据获取的信息按照前面的测试用例范式生成如下测试代码。

describe('auto ut', () => {
  it('should <%= url %> <%= method %> OK', async () => {
    // mock 环境
    const ctx = app.mockContext({
      // 环境参数(用户等)
    });
    
    // mock 对依赖服务的调用
    mm.classMethod(<%= service %>, <%= method %>, (...args) => {
      if (/**args匹配入参1**/) {
          // 返回入参1对应的响应1
      }
      // 一次接口调用可能存在对外部同一服务的多次不同入参的调用
  		if (/**args匹配入参2**/) {
          // 返回入参2对应的响应2
      }
      // ...
    });
    
    // http 接口测试
    await app
      .httpRequest()
      .<%= method %>('<%= url %>')            // .post('http://xxxx')
      .query(<%= query %>)                  
      .set('referer', '<%= referrer %>')      
      .send(<%= input %>)
      .expect(<%= statusCode %>)
      .expect((res: any) => {
      		// 结果断言
    	});
    
    // 服务测试
    const service: any = await ctx.requestContext.getAsync(
      '<%= serviceName %>'
    );
    const rst = await service.<%= method %>(<%= input %>);
    // 结果断言
  });
});

该自动化程序的输入仅仅是调用流量的唯一标志码,进而人工书写测试代码的工作变成了简单的提供回归流量唯一标志码。

线上问题定位

定位问题常常被看成是一名开发人员非常关键的能力,在并不了解系统细节逻辑的情况下,仍然能通过自己的问题定位方法论定位出问题原因是一个开发人员需要具备的能力。在定位问题方面,为什么我们觉得 debug 是一个简单有效的工具?大概是因为它重现了“案发现场”的每一幕(也就是执行时候的变量值)。那么一般的业务系统现场是什么样子的呢?我们可以将一次业务服务执行总结为如下流程:

  1. 组装入参
  1. 内部系统计算
  1. 对外部服务发起调用
  1. 根据调用返回值进行加工或者计算
  1. ...类似上述步骤
  1. 返回组装好的出参

当该流程中并无内部系统计算或者仅有少量简单的计算时,那么上面推荐的日志信息内容其实已经能涵盖该次执行的足够的现场信息了。通过流量唯一标志码将该次执行链路的所有信息串联起来,我们拿到了该次服务调用的出入参、该服务中对第三方服务调用的出入参以及一些环境信息(如果你有在日志中记录的话)。拿到了这些信息,通过判断是那个环节的入参或者返回结果不符合我们预期,我们就能明确问题发生环节,进而推断出问题原因。

系统异常监控

如何能在系统发生异常的时候即时地发现?基于上述推荐的日志采集信息,我们只需要监控采集的系统接口返回值即可。当然这也依赖于系统需要有一个规范的返回值:至少包含“是否失败”、“错误码”这两个信息。通过监控日志存储服务中失败请求日志信息的产生,来及时感知到系统异常的发生。一些产品化的日志分析产品如今也能够让用户定义通知方式,当监控内容达到阈值时进行消息通知,从而更早地发现问题。
虽然依赖于上面的方案已经能够较为即时地感知到系统异常的产生。但由于监测数据源为接口响应内容,这需要你的响应内容需要遵守一定的异常可识别规范,否则将造成一定的异常误报。通过规范系统的异常码,来为失败的请求分层,仅对关注的错误类型进行订阅。过来的人经验告诉我们,持续的无效异常告警将降低告警接受人员对告警的敏感性,现代版狼来了的故事。

其他

通过分析采集的日志信息我们还可以了解系统接口的耗时情况,从而筛选出耗时接口进行优化。通过分析一次请求的全链路日志数据,我们能了解到该接口耗时组成情况(该接口总耗时=自身计算耗时+对外接口调用耗时)以及产生的外部接口调用次数和对同一个接口的调用次数。这些信息能很好的帮助我们进行接口优化。比如:通过耗时组成发现该接口主要耗时是因为对某个外部接口的调用,那么耗时优化重点应该放在该外部接口优化上;如果一次服务请求产生了多次对一个接口的相同入参的查询调用,代表服务中存在浪费调用;如果是不同的入参,那么可以通过将独立的请求合并为批量请求(如果有批量接口的情况下)也能大大减少请求网络耗时。
此外,带有用户数据的日志信息还能用于我们分析用户行为,在产品优化方向上的选择起到了一定的引导作用。