问答 百科手机端

美团点评CAT跨语言服务监控深度讲解(三)CAT客户端原则

2023-04-16 09:40

cat客户端部分核心类

message目录下面有消息相关的部分接口
internal目录包含主要的CAT客户端内部实现类;
io目录包含建立服务端连接、重连、消息队列监听、上报等io实现类;
spi目录为上报消息工具包,包含消息二进制编解码、转义等实现类。

美团点评CAT跨语言服务监控深度讲解(三)CAT客户端原则

消息的组织 - 消息树

大众点评Cat使用消息树(MessageTree)组织日志,下面为消息树的类定义

美团点评CAT跨语言服务监控深度讲解(三)CAT客户端原则

我们每次操作的实体都是消息树,其中有个domain字段,这是cat中一个非常重要的概念,一个domain可以对应成一个project,每个消息树拥有一个唯一的MessageId, 不同的消息树(比如微服务中A服务调用B服务,A,B都会生成消息树) 通过 parenMessageId、rootMessageId 串联起来,消息树下的所有实体都是Message,一共有5种类型的Message, 分别是Transaction,Event, Trace, Metric和Heartbeat。

Transaction:可以理解为是一个事务,事务之间可以互相嵌套,事务还可以嵌套任意其他消息类型,存放在List<Message> m_children 成员变量中,也只有事务才可以嵌套。一般用来记录跨越系统边界的程序访问行为,比如远程调用,数据库调用,也适合执行时间较长的业务逻辑监控。

Event:代表系统是在某个时间点发生的一次事件,例如新用户注册、登陆,系统异常等,理论上可以记录任何事情,它和transaction相比缺少了时间的统计,开销比transaction要小。还可以用来记录两个事务之间的关系,分支事务通过设置消息树的parentMessageId维护与主事务消息之间的关系。

Trace:用于记录一些trace、debug这类的信息,比如log4j打印日志。以便于快速调试定位问题

Metric:用于记录业务指标、指标可能包含对一个指标记录次数、记录平均值、记录总和

Heartbeat:主要用于记录系统的心跳信息,比如CPU%, MEM%,连接池状态,系统负载等。
美团点评CAT跨语言服务监控深度讲解(三)CAT客户端原则

客户端的初始化

客户端操作对象Cat封装了所有的接口。我们先通过下面源码来了解下Cat的初始化过程。

class Cat{ public static Transaction newTransaction(String type, String name) { return Cat.getProducer().newTransaction(type, name); } public static MessageProducer getProducer() { checkAndInitialize(); return s_instance.m_producer; } private static void checkAndInitialize() { if (!s_init) { synchronized (s_instance) { if (!s_init) { initialize(new File(getCatHome(), 'client.xml')); log('WARN', 'Cat is lazy initialized!'); s_init = true; } } } } public static void initialize(File configFile) { PlexusContainer container = ContainerLoader.getDefaultContainer(); initialize(container, configFile); } public static void initialize(PlexusContainer container, File configFile) { ModuleContext ctx = new DefaultModuleContext(container); Module module = ctx.lookup(Module.class, CatClientModule.ID); if (!module.isInitialized()) { ModuleInitializer initializer = ctx.lookup(ModuleInitializer.class); ctx.setAttribute('cat-client-config-file', configFile); initializer.execute(ctx, module); } } }

从上面代码可以看到, 创建transaction首先会通过getProducer函数获取消息生产者MessageProducer对象,在返回MessageProducer对象之前,函数会对客户端进行初始化,设置 CatHome目录,默认是/data/appdatas/cat ,读取配置文件 client.xml,接下来的初始化流程和服务器类似,也是先通过ContainerLoader.getDefaultContainer(); 初始化plexus容器。客户端需要被装入容器的对象在下图的配置中:
美团点评CAT跨语言服务监控深度讲解(三)CAT客户端原则

我们借助components-cat-client.xml 配置来看部分类的实例化与初始化顺序:

美团点评CAT跨语言服务监控深度讲解(三)CAT客户端原则

上面流程中非常多的initialize() 方法,有些是主动调用的,有些则是由容器框架调用的,这个在讲plexus容器的时候说过,如果类方法实现了Initializable 接口,创建实例后会执行类的initialize() 方法做一些初始化的工作。

然后创建一个模块上下文 DefaultModuleContext,该对象拥有之前初始化过的plexus容器,以及配置文件信息/data/appdatas/cat/client.xml,然后创建一个模块初始器 DefaultModuleInitializer,随后调用模块初始器的execute(ctx)方法初始化模块,这里需要初始化的模块只有 CatClientModule,他也不依赖其它任何模块,我们调用setup,这是个空方法,也就是说模块的安装不需要干任何事情,接着调用CatClientModule的excute方法,

以下为execute的源码:

class CatClientModule { @Override protected void execute(final ModuleContext ctx) throws Exception { ctx.info('Current working directory is ' + System.getProperty('user.dir')); // initialize milli-second resolution level timer MilliSecondTimer.initialize(); // tracking thread start/stop Threads.addListener(new CatThreadListener(ctx)); // warm up Cat Cat.getInstance().setContainer(((DefaultModuleContext) ctx).getContainer()); // bring up TransportManager ctx.lookup(TransportManager.class); ClientConfigManager clientConfigManager = ctx.lookup(ClientConfigManager.class); if (clientConfigManager.isCatEnabled()) { // start status update task StatusUpdateTask statusUpdateTask = ctx.lookup(StatusUpdateTask.class); Threads.forGroup('cat').start(statusUpdateTask); LockSupport.parkNanos(10 * 1000 * 1000L); // wait 10 ms // MmapConsumerTask mmapReaderTask = ctx.lookup(MmapConsumerTask.class); // Threads.forGroup('cat').start(mmapReaderTask); } } }

StatusUpdateTask statusUpdateTask = ctx.lookup(StatusUpdateTask.class);
Threads.forGroup('cat').start(statusUpdateTask);

CatClientModule从StatusUpdateTask中启动一个线程来每隔一段时间发送一个HeartBeatMessage,其中包括了客户端能拿到的各种信息,包括CPU,Memory,Disk等等,开发者也可以通过实现StatusExtension接口的方式来实现对于HeartBeatMessage发送内容的扩展。

public class StatusUpdateTask implements Task, Initializable { @Override public void run() { ... StatusInfoCollector statusInfoCollector = new StatusInfoCollector(m_statistics, m_jars); status.accept(statusInfoCollector.setDumpLocked(m_manager.isDumpLocked())); ... } }

m_statistics包含的是已经发送过信息的容量,m_jars是通过classLoader加载的jar包名称,StatusInfoCollector通过大量访问者模式的代码实现了将各种指标set到status中的功能,之后将status封装到HeartBeatMessage中,按照一般对于message的处理流程,flush到消息传输层中。

消息生产 -- 入栈

我们获取消息生产者对象 MessageProducer 之后,就可以调用newTransaction(type, name) 来创建 Transaction类消息了,

值得注意的是MessageProducer对业务封装了CAT内部的所有细节,所以业务方只需要一个MessageProducer对象就可以完成消息的所有操作。
美团点评CAT跨语言服务监控深度讲解(三)CAT客户端原则

下面通过 newTransaction 的源码来分析 Transaction消息的创建过程

class DefaultMessageProducer { public Transaction newTransaction(String type, String name) { // this enable CAT client logging cat message without explicit setup if (!m_manager.hasContext()) { m_manager.setup(); } if (m_manager.isMessageEnabled()) { DefaultTransaction transaction = new DefaultTransaction(type, name, m_manager); m_manager.start(transaction, false); return transaction; } else { return NullMessage.TRANSACTION; } } }

他首先通过消息管理者MassageManager判断是否存在消息上下文context,如果不存在则在setup中创建消息上下文。

Context 线程本地变量

消息上下文 Context 采用的是线程本地变量。通过ThreadLocal存取Context数据。

高并发下日志的打印通常会采用这种方式,或者说一次事务的日志一起打印,因为一般默认一次事务都是由同一个线程执行的(如一次http请求),将事务的日志保存在线程局部变量当中,当事务执行完成的时候统一打印。

为什么需要用到线程本地变量?在低并发请求下,一条日志会很快被处理,普通变量即可满足需求,很少出现多个线程同时读写同一个变量,

然在高并发场景下,多个线程同时读写同一个变量会导致不可预知的结果,我们称这为线程非安全,比如线程A要写一大段日志,写到一半,线程B获得CPU执行时间片开始写日志,AB的日志就会交错混乱,有同学会问,为什么不用同步锁?这是一个方案,同步锁是一个相对较复杂的保证线程安全,保证同时只有一个线程可以读写变量,其它线程要读写变量就需要排队,这就必然会带来高延迟,

线程本地变量功用则非常简单,就是为每一个使用该变量的线程都提供一个变量值的副本,是Java中一种较为特殊的线程绑定机制,JVM 为每个运行的线程,绑定了私有的本地实例存取空间,每一个线程都可以独立地改变自己的副本,而不会和其它线程的副本冲突,从而为多线程环境常出现的并发访问问题提供了一种隔离机制,但是会造成数据冗余,是一种用空间换时间的线程安全方案。

ThreadLocal是如何做到为每一个线程维护变量的副本的呢?其实实现的思路很简单,在ThreadLocal类中有一个Map,用于存储每一个线程的变量的副本。大家看下面代码,核心在于ThreadLocal的get 和 set函数,函数首先会获取当前线程,然后从 Map 中获取或者设置该线程的Context

public class ThreadLocal<T> { public T get() { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) { ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) { @SuppressWarnings('unchecked') T result = (T)e.value; return result; } } return setInitialValue(); } } public class DefaultMessageManager extends ContainerHolder implements MessageManager, Initializable, LogEnabled { private ThreadLocal<Context> m_context = new ThreadLocal<Context>(); private Context getContext() { if (Cat.isInitialized()) { Context ctx = m_context.get(); if (ctx != null) { return ctx; } else { if (m_domain != null) { ctx = new Context(m_domain.getId(), m_hostName, m_domain.getIp()); } else { ctx = new Context('Unknown', m_hostName, ''); } m_context.set(ctx); return ctx; } } return null; } }

在Context构造函数里,我们看到了消息树MessageTree和Transaction栈被创建了,由于Context是线程本地变量,由此可以推断,每个线程都拥有各自的消息树和事务栈,这里所说的线程都是业务线程,Context属于MessageManager的内部类。可以认为MessageManager的其中一个功能是作为context的一个代理,MessageManager的start、add、end等方法,核心都是调用当前线程context的start、add、end方法。

Transaction事务的开启

接着MessageProducer就会创建一个Transation对象,然后将Transaction对象交给 MessageManager启动,我们通过下面源码看看的启动流程,最关键的启动步骤是调用ctx.start(transactionm forked) 完成的,下面分析这个方法:

1.如果 m_stack 不为空, 而且 transaction 类型为 ForkedTransaction
1.计算时间 或 长度条件,如果需要发送到Server,就发送到Server里(truncateAndFlush)
2.将当前 transaction 加到 m_stack 栈顶元素的子消息中去。
3.m_length++;

2.如果m_stack为空,就把当前这个Transaction加到MessageTree里面。
3.最后判断 transaction 是否是forked的事务,不是则将transaction加入 m_stack 。

public class DefaultMessageManager extends ContainerHolder implements MessageManager, Initializable, LogEnabled { @Override public void start(Transaction transaction, boolean forked) { Context ctx = getContext(); if (ctx != null) { ctx.start(transaction, forked); ... } class Context { private MessageTree m_tree; private Stack<Transaction> m_stack; public void start(Transaction transaction, boolean forked) { if (!m_stack.isEmpty()) { ... } else { m_tree.setMessage(transaction); } if (!forked) { m_stack.push(transaction); } } } }

其他类型消息组合

@RunWith(JUnit4.class) public class AppSimulator extends CatTestCase { @Test public void simulateHierarchyTransaction() throws Exception { MessageProducer cat = Cat.getProducer(); Transaction t = cat.newTransaction('URL', 'WebPage'); String id1 = cat.createMessageId(); String id2 = cat.createMessageId(); try { // do your business here t.addData('k1', 'v1'); t.addData('k2', 'v2'); t.addData('k3', 'v3'); Thread.sleep(5); cat.logMetric('payCount', 'C', '1'); cat.logMetric('totalfee', 'S', '30.5'); cat.logMetric('avgfee', 'T', '25.6'); cat.logMetric('order', 'S,C', '3,25.6'); Metric event = Cat.getProducer().newMetric('kingsoft', 'praise'); event.setStatus('C'); event.addData('3'); event.complete(); Cat.getManager().setTraceMode(true); cat.logTrace('Trace1', 'debug', SUCCESS, 'user_debug_data'); cat.logEvent('RuntimeException', 'Name1', 'ERROR', 'data1'); cat.logEvent('Error', 'Name2', SUCCESS, 'data2'); cat.logEvent('RemoteCall', 'Service1', SUCCESS, id1); t.setStatus(SUCCESS); } catch (Exception e) { t.setStatus(e); } finally { t.complete(); } } }

可以通过 MessageProducer的 logEvent 记录event类型的消息,方法首先会调用newEvent方法创建Event对象,如果有消息数据,就用addData方法添加数据,然后setStatus设置消息状态,complete完成日志记录。

public class DefaultMessageProducer implements MessageProducer { @Override public void logEvent(String type, String name, String status, String nameValuePairs) { Event event = newEvent(type, name); if (nameValuePairs != null && nameValuePairs.length() > 0) { event.addData(nameValuePairs); } event.setStatus(status); event.complete(); } @Override public Event newEvent(String type, String name) { if (!m_manager.hasContext()) { m_manager.setup(); } if (m_manager.isMessageEnabled()) { DefaultEvent event = new DefaultEvent(type, name, m_manager); return event; } else { return NullMessage.EVENT; } } }

event.complet 做了什么事情? 他会首先设置消息complete状态为true,然后调用 MessageManager 的 add 方法,并传入自身的指针,在Context 线程本地变量章节的时候说过MessageManager是context的代理,MessageManager 的add方法核心是调用的context得add方法。

context的add方法,会首先判断m_stack栈是否为空,如果是空的说明这个消失是一个单独的非事务类型消息, 直接将消息放入MessageTree然后发送到服务器。

如果m_stack 不为空,说明这个event消息处在一个事务下面,我们从m_stack 栈顶获取事务,将event消息嵌套到事务里,等待事务结束的时候一同推送到服务器。上边的案例就是这种情况。

class Context { public void add(Message message) { if (m_stack.isEmpty()) { MessageTree tree = m_tree.copy(); tree.setMessage(message); flush(tree); } else { Transaction parent = m_stack.peek(); addTransactionChild(message, parent); } } }

我们也可以不用logEvent 记录日志,而是自己通过newEvent创建Event消息实例, 然后由自己控制什么时候add数据、setStatus以及complete消息。

Heartbeat, Metric, Trace类别的消息操作流程和Event消息基本一样,其中Trace消息需要MessageManager开启TradeMode追踪模式才可以用,类似我们开发中的Debug模式,调用Cat.getManager().setTraceMode(true) 方法可以开启追踪模式。

消息的完成-出栈

美团点评CAT跨语言服务监控深度讲解(三)CAT客户端原则

美团点评CAT跨语言服务监控深度讲解(三)CAT客户端原则

所有的消息事务, 都会由Transaction 对象的complete 方法完成最终的发送流程。

public class DefaultTransaction extends AbstractMessage implements Transaction { @Override public void complete() { try { if (isCompleted()) { //complete() was called more than once ... } else { m_durationInMicro = (System.nanoTime() - m_durationStart) / 1000L; setCompleted(true); if (m_manager != null) { m_manager.end(this); } } } catch (Exception e) { // ignore } } }

complete 会首先设置complete标志位,然后交由 m_manager 去完成消息发送。核心还是调用context的end方法,该方法会从栈顶弹出事务, 如果弹出的事务不等于end方法传入的事务,则认为弹出的事务不是我们需要结束的事务,而是被嵌套的子事务,我们继续弹出下一个栈顶元素,即父事务,直到弹出我们需要结束的事务为止。在这个过程,会调用validate对事务进行校验。

然后我们判断栈是否为空,如果为空,则认为end传入的事务为根事务,这个时候我们才调用 m_manager.flush 将消息树上报到服务器。

class context { public boolean end(DefaultMessageManager manager, Transaction transaction) { if (!m_stack.isEmpty()) { Transaction current = m_stack.pop(); if (transaction == current) { m_validator.validate(m_stack.isEmpty() ? null : m_stack.peek(), current); } else { while (transaction != current && !m_stack.empty()) { m_validator.validate(m_stack.peek(), current); current = m_stack.pop(); } } if (m_stack.isEmpty()) { MessageTree tree = m_tree.copy(); m_tree.setMessageId(null); m_tree.setMessage(null); if (m_totalDurationInMicros > 0) { adjustForTruncatedTransaction((Transaction) tree.getMessage()); } manager.flush(tree); return true; } } return false; } }

消息的发送-队列化

在上一章我们知道,MessageManager 会通过 flush 将消息树上报到服务器,我们来通过下面源码分析一下flush方法,函数首先判断是否分配MessageID,没有则分配, 然后调用TcpSocketSender的send函数来发送消息。

send函数也不是立即发送, 仅仅只是插入内存队列。读者可以去看看 TcpSocketSender 的initialize() 方法, 有行代码Threads.forGroup('cat').start(this) ,这行代码使得客户端在初始化的时候, 就开启一个上报线程,上报线程一直读取内存队列,获取要发送的消息树,调用sendInternal(MessageTree tree) 方法将消息树发送到服务器。

这样子,客户端就实现了消息的多线程、异步化、队列化,从而保证日志的记录不会因为CAT系统异常而影响主业务线程。

public class DefaultMessageManager extends ContainerHolder implements MessageManager, Initializable, LogEnabled { public void flush(MessageTree tree) { if (tree.getMessageId() == null) { tree.setMessageId(nextMessageId()); } MessageSender sender = m_transportManager.getSender(); if (sender != null && isMessageEnabled()) { sender.send(tree); reset(); } else { ... } } }

消息的序列化

在上一章节我们说到上报线程通过sendInternal(MessageTree tree) 将消息发送到服务器,在sendInternal 方法内, TcpSocketSender 在发送报文之前,会先调用m_codec.encode(tree, buf) 对消息树进行序列化,序列化就是将对象编码为一组字节,使得对象能够通过 tcp/ip 协议发送到服务器端的技术, 服务器再通过反序列化, 将字节解码为对象。

在Java中,只要一个类实现了java.io.Serializable接口,那么它就可以被序列化。但是通过公共接口编码的字节会有很多冗余信息来保证不同对象与字节之间的正确编解码,在CAT中,需要传输的只有MessageTree这么一个对象。通过自定义的序列化方案可以节省许多不必要的字节信息,保证网络传输的高效性。

public class PlainTextMessageCodec implements MessageCodec, LogEnabled { @Override public void encode(MessageTree tree, ByteBuf buf) throws UnsupportedEncodingException { int count = 0; int index = buf.writerIndex(); buf.writeInt(0); // place-holder count += encodeHeader(tree, buf); if (tree.getMessage() != null) { count += encodeMessage(tree.getMessage(), buf); } buf.setInt(index, count); } }

被序列化的字节码包含3个部分:

1、 前4个字节包含整组字节串的长度,首先通过buf.writeInt(0)占位,编码完通过buf.setInt(index, count)将字节码长度写入buf头4个字节。

2、编码消息树的头部,依次将tree的version, domain, hostName, ipAdress, treadGroupName, treadId, threadName, MessageId, parentMessageId, rootMessageId, sessionToken写入头部,字段之间以' '分隔,并以'
'结尾。空用null表示。

3、编码消息体,每个消息都是以一个表示消息类型的字符开头。
a.'A'表示没有嵌套其他类型消息的事务,
b.有嵌套其他消息的事务,以一个 't' 开头,然后递归去遍历并编码子消息, 最后以一个'T'结束,
c.'E'/'L'/'M'/'H'分别表示Event/Trace/Metric/Heartbeat类型消息;
然后依次记录时间、type、name
然后根据条件依次写入status、duration+us、data
字段之间依然以' '分割,以'
'结尾,空用null表示

比如上面其它消息组合章节的案例中,MessageTree通过编码之后:

口PT1 Cat Win7-caoh.kingsoft.cn 192.168.37.41 main 1 main Cat-c0a82529-423686-40028 null null null t2018-05-02 22:59:05.347 URL WebPage H2018-05-02 22:59:05.353 Heartbeat1 hearbeat 0 cpu=90&mem=70 M2018-05-02 22:59:05.353 metric1 0 total_fee L2018-05-02 22:59:05.354 Trace1 debug 0 user_debug_data E2018-05-02 22:59:05.354 Event1 Name1 0 data1 E2018-05-02 22:59:05.354 Event2 Name2 0 data2 E2018-05-02 22:59:05.354 RemoteCall Service1 0 Cat-c0a82529-423686-40026 T2018-05-02 22:59:07.507 URL WebPage 0 2160695us k1=v1&k2=v2&k3=v3

上面一串字符串,是通过字节码转换成string的结果, 最前面的乱码,实际上表示的是4个字节的int类型转为string类型表现形式。字节码转int后是541,是整个字节码的长度。

最终TcpSocketSender 通过ChannelManager 将编码后的字节码发送到服务器。这里采用的是netty客户端,不展开来讲了,网上有许多资料可以查阅。

MessageId的设计

CAT每个消息都有一个唯一的ID,这个ID在客户端生成,后续都通过这个ID在进行消息内容的查找。典型的RPC消息串起来的问题,比如A调用B的时候,在A这端生成一个Message-ID,在A调用B的过程中,将Message-ID作为调用传递到B端,在B执行过程中,B用context传递的Message-ID作为当前监控消息的Message-ID。

CAT消息的Message-ID格式ShopWeb-0a010680-375030-2,CAT消息一共分为四段:

第一段是应用名shop-web。
第二段是当前这台机器的IP的16进制格式,01010680表示10.1.6.108。
第三段的375030,是系统当前时间除以小时得到的整点数。

第四段的2,是表示当前这个客户端在当前小时的顺序递增号。

一定得注意的是,同一台客户端机器产生的Message-ID的第四段,即当前小时的顺序递增号,在当前小时内一定不能重复,因为在服务端,CAT会为每个客户端IP、每个小时的原始消息存储都创建一个索引文件,每条消息的索引记录在索引文件内的偏移位置是由顺序递增号决定的,一旦顺序号重复生成,那么该小时的重复索引数据将会被覆盖,导致我们无法通过索引找到原始消息数据,具体可以看原始消息的存储章节。

热门