背景
公司在18年开始,使用pinpoint(1.7.2)做应用监控,考虑到数据上报所带来的网络、存储、应用负载等成本,只配置了部分采样(10%)
profiler.sampling.rate=10
因此,只能拿到10%的全局traceId,导致部分异常请求无法定位到上游的应用.经过相关调研以及成本估算(使用其他APM方案,例如skywalking、cat),还是决定通过改造pinpoint的方式来实现全局采样、部分上报.
数据采样原理熟悉
pinpoint进行全链路监控时,支持对请求的采样,某条请求是否被采样,取决于整个链路开始的机器.该机器使用特定的采样算法,采样的标志会一直在链路中透传.
比如在http里面,会在header里面增加Pinpoint-Sampled字段,使用不同的值表示是否采样.
- s0:此条请求不采样
- s1:此条请求采样
通过搜索profiler.sampling.rate,最终能在代码中定位到SamplingRateSampler.java.
下面分析一下相关代码。
Sampler有三个实现类,我们主要关注SamplingRateSampler(比例采样).结合代码,很容易得出,比例采样使用原子累加计数,当累加之后得到的数为samplingRate的倍数则表示该请求被采样.
package com.navercorp.pinpoint.profiler.sampler;
import com.navercorp.pinpoint.bootstrap.sampler.Sampler;
import com.navercorp.pinpoint.common.util.MathUtils;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @author emeroad
*/
public class SamplingRateSampler implements Sampler {
private final AtomicInteger counter = new AtomicInteger(0);
private final int samplingRate;
public SamplingRateSampler(int samplingRate) {
if (samplingRate <= 0) {
throw new IllegalArgumentException("Invalid samplingRate " + samplingRate);
}
this.samplingRate = samplingRate;
}
@Override
public boolean isSampling() {
int samplingCount = MathUtils.fastAbs(counter.getAndIncrement());
int isSampling = samplingCount % samplingRate;
return isSampling == 0;
}
@Override
public String toString() {
return "SamplingRateSampler{" +
"counter=" + counter +
"samplingRate=" + samplingRate +
'}';
}
}
再看一下这个类的初始化链路
SamplerProvider继承了com.google.inject.Provider,使用Guice来做依赖注入
Guice是Google开发的一个轻量级,基于Java5(主要运用泛型与注释特性)的依赖注入框架(IOC)。Guice非常小而且快。Guice是类型安全的,它能够对构造函数,属性,方法(包含任意个参数的任意方法,而不仅仅是setter方法)进行注入。Guice采用Java加注解的方式进行托管对象的配置,充分利用IDE编译器的类型安全检查功能和自动重构功能,使得配置的更改也是类型安全的。Guice提供模块对应的抽象module,使得架构和设计的模块概念产物与代码中的module类一一对应,更加便利的组织和梳理模块依赖关系,利于整体应用内部的依赖关系维护,而其他IOC框架是没有对应物的。此外,借助privateModule的功能,可以实现模块接口的明确导出和实现封装,使得支持多数据源这类需求实现起来异常简单。
了解Guice相关知识后,直接看ApplicationContextModule类,其中对Sampler.class和SamplerProvider.class进行了绑定,并且为单例实例.
ApplicationContextModule.java
bind(Sampler.class).toProvider(SamplerProvider.class).in(Scopes.SINGLETON);
数据上报原理熟悉
通过源码及相关文档了解到,调用链数据使用TcpDataSender进行上报.
TcpDataSender:调用链数据上报通道,单线程,数据队列(LinkedBlockingQueue)大小5120,重试队列(LinkedBlockingQueue)大小1024,底层通迅使用的Netty
调用链数据上报分为两种:
- api、sql、string常量;上报失败会先放到重试队列,后续进行重试,默认重试3次
- 调用链详情数据;上报失败则不会重试
再看一下添加SpanEvent到缓存区部分的实现
DefaultTrace.java
public void traceBlockEnd(int stackId) {
if (closed) {
if (isWarn) {
stackDump("already closed trace");
}
return;
}
final SpanEvent spanEvent = callStack.pop();
if (spanEvent == null) {
if (isWarn) {
stackDump("call stack is empty.");
}
return;
}
if (spanEvent.getStackId() != stackId) {
// stack dump will make debugging easy.
if (isWarn) {
stackDump("not matched stack id. expected=" + stackId + ", current=" + spanEvent.getStackId());
}
}
if (spanEvent.isTimeRecording()) {
spanEvent.markAfterTime();
}
logSpan(spanEvent);
}
private void logSpan(SpanEvent spanEvent) {
this.storage.store(spanEvent);
}
可以看到,这部分代码对SpanEvent做一些处理后,直接添加到缓存区.
由此产生想法,那如果我们不想上报某个trace,是不是只要将某个trace增加一个标记,此处拿到这个标记之后拒绝添加SpanEvent到缓存区就可以了?
所以接下来的问题就是怎么全局控制上报比例以及如何给Trace增加不上报的标记
改造方案
1⃣先解决第一个问题,怎么全局控制上报比例.
通过上文中采样比例部分的分析,上报可以采用同样的方式,即通过一个原子计数器,如果上报的数目是上报比例的倍数,则该条记录需要上报,反之则不上报.
具体实现:
1)在配置文件中增加上报比例的配置(每多少条上报1条,默认为1表示全部上报,0表示都不上报)
2)参考Sampler进行代码实现,然后使用Guice进行绑定,同样需要为单例模式
2⃣再来解决第二个问题,如何增加不上报的标记.
结合代码,会发现每个Span里都会有一个TraceRoot,并且TraceRoot在整个链路的Span中都是透传的,所以可以考虑在TraceRoot节点进行标记.
具体实现:
1)修改DefaultBaseTraceFactory,提供带Reporter的构造方法
2)在创建TraceRoot的时候,调用计数器来判断是否需要上报
3)添加SpanEvent到缓存区的时候,取出Span的TraceRoot校验标记
整体变动点如下图,代码已上传github 链接
说明
针对此次改造做几点说明:
- 整体基于pinpoint 1.7.2源码修改
- 在代码构建以及环境搭建过程中可能会碰到问题,对于过程中碰到的部分已在下文列出,仅供参考
- 改动对整体性能的影响未做测试
- 方案纯属个人想法,未做深入研究,可能会违反pinpoint设计原则;整个改造过程耗时四天左右
附录
针对整体改造过程中碰到的问题做一下记录,有需要可以参考
1、本地构建可能无法拿到相关jar包或者时间特别长,可以在pom文件增加仓库地址,同时部分仓库链接需从http改成https
<repositories>
<repository>
<id>alimaven</id>
<name>aliyun maven</name>
<url>http://maven.aliyun.com/nexus/content/groups/public/</url>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>cloudera</id>
<url>https://repository.cloudera.com/artifactory/cloudera-repos/</url>
</repository>
<repository>
<id>spring-maven-repository</id>
<name>Spring Framework Maven Release Repository</name>
<url>https://maven.springframework.org/release/</url>
</repository>
<repository>
<id>spring-maven-release-remote</id>
<name>Spring Framework Maven Release Remote Repository</name>
<url>https://repo.spring.io/libs-release-remote/</url>
</repository>
<repository>
<id>bintray</id>
<name>bintray</name>
<url>http://jcenter.bintray.com</url>
</repository>
<repository>
<id>jboss-3rd-party-releases</id>
<name>Jboss Third Party Repository</name>
<url>https://repository.jboss.org/nexus/content/repositories/thirdparty-releases/</url>
</repository>
</repositories>
2、pinpoint对环境要求比较多,首先需要按照官网配置java6、7、8,然后是maven、hbase版本.本次使用的是maven 3.2.1+hbase 1.4.13
3、如果Springboot应用有agent信息,但没有请求数据,可以先排查采样比例是否为1,比例没有问题则可以看启动日志中打印的
ApplicationServerType是否为SPRING_BOOT,不是的话可以手动指定
profiler.applicationservertype=SPRING_BOOT
profiler.tomcat.conditional.transform=false
4、如果需要在日志中打印PtxId,需要先修改配置
profiler.logback.logging.transactioninfo=true
同时需要注意,如果使用Dubbo Filter方式打印日志时,需要确定相关日志框架拦截器的顺序.例如LoggingEventOfLogbackInterceptor,这些拦截器会往MDC中put相关值,如果在拦截器之前打印日志,put操作未执行,则无法显示
针对Dubbo,可以直接从RpcContext中取
private static final String TRACE_ID_KEY = "_DUBBO_TRASACTION_ID";
RpcContext.getContext().getAttachment(TRACE_ID_KEY)
至此,整个改造方案基本完成,结果后续发布验证.