Sermant源码(三)事件

130 阅读14分钟

前言

本章分析Sermant2.0.0事件系统:

  1. 横向对比其他系统中的事件定义;

  2. Agent与Backend的通讯方式;

  3. 心跳服务(事件依赖于心跳);

  4. 事件服务;

一、 事件定义

1、OpenTelemetry

在OTEL的定义中,Event依附于Trace的Span而存在。

如:

{
  "name": "hello",
  "context": {
    "trace_id": "0x5b8aa5a2d2c872e8321cf37308d69df2",
    "span_id": "0x051581bf3cb55c13"
  },
  "parent_id": null,
  "start_time": "2022-04-29T18:52:58.114201Z",
  "end_time": "2022-04-29T18:52:58.114687Z",
  "attributes": {
    "http.route": "some_route1"
  },
  "events": [
    {
      "name": "Guten Tag!",
      "timestamp": "2022-04-29T18:52:58.114561Z",
      "attributes": {
        "event_attributes": 1
      }
    }
  ]
}

OTEL对于Event的定义是:Event是Span上的一个结构化日志消息或注解,是Span生命周期中一个有意义的时间点。

Event包含三个属性:

  1. name:事件名称;

  2. timestamp:事件发生时间;

  3. attributes:0-n个k-v对;

使用java api创建事件如下:

@GetMapping
public String index() {
    Span currentSpan = Span.current();
    Attributes att = Attributes.of(AttributeKey.longKey("event-random-att"), random.nextLong());
    currentSpan.addEvent("访问首页", att);
    return appName;
}

otel仅定义规范,使用otel-agent将trace发送给otel-collector,otel-collector将trace数据导出到Jaeger、Zipkin。

在Jaeger中,event在span的logs中,结构化展示。

在Zipkin中,event在span的annotation中,非结构化展示。

2、SkyWalking

SkyWalking也有对于Event的定义与实现。

Event,用于记录系统的特殊时刻,比如版本升级、配置变更。

Event与Metrics联系,能够解释Metrics的波峰波谷。

Event与Trace和Log联系,能够协助根因分析。

SkyWalking对于Event模型定义如下,与Trace没有直接联系:

message Event {
  // 唯一id
  string uuid = 1;
  // 事件来源(服务、服务实例、端点)
  Source source = 2;
  // 事件名,如服务启动
  string name = 3;
  // 正常or异常
  Type type = 4;
  // 事件消息
  string message = 5;
  // 扩展参数
  map<string, string> parameters = 6;
  // 开始-结束时间
  int64 startTime = 7;
  int64 endTime = 8;
  // sw9,所属层级
  string layer = 9;
}
enum Type {
  Normal = 0;
  Error = 1;
}
message Source {
  string service = 1;
  string serviceInstance = 2;
  string endpoint = 3;
}

截止10.0.1版本,SkyWalking截止目前尚未完全实现在java客户端采集Event。

在javaagent内部只能采集一个应用启动事件,暂时没有实现toolkit让用户自己进行事件埋点。

除了javaagent,SkyWalking提供了命令行工具skywalking-cli,支持发送事件给OAP。

./swctl event report --instance-name 034012b64ee0452b9cce486e27e3af1b@10.251.1.1 --service-name prod::service-a --uuid uuid123 --name BIZ_EVENT01 --message "hahaha" --layer GENERAL --start-time 事件开始时间 --end-time 事件结束时间

3、Sermant

Sermant的事件与Trace无关,通过Sermant Agent上报至Sermant Backend。

使用Sermant事件需要Agent开启两个基础服务:heartbeat和gateway。

agent.service.heartbeat.enable=true
agent.service.gateway.enable=true

应用启动后可在Sermant Backend看到事件。

Sermant事件包含以下属性。

public class Event {
    // instanceId
    private String metaHash;
    // 事件时间
    private long time;
    // 事件区域,framework、springboot-registry-plugin、AbstractGroupConfigSubscriber::subscribe(日志事件)
    private String scope;
    // 等级
    private EventLevel eventLevel;
    // 事件类型
    private EventType eventType;
    // 非LOG类型,事件信息
    private EventInfo eventInfo;
    // LOG类型,事件信息
    private LogInfo logInfo;
}

LOG类型事件,SermantBridgeHandler拦截WARN和ERROR日志,记录LogInfo。

LogInfo通过java.util.logging.LogRecord转换得到。

非LOG类型事件包含:OPERATION-运行;GOVERNANCE-治理。

这类事件仅记录事件名称和详细描述,通过各插件手动埋点。

二、 通讯

Sermant-Agent core模块定义了GatewayClient接口向Backend发送数据。

1、Agent建立连接

Sermant-Agent implement模块引入netty依赖实现与Backend通讯。

NettyGatewayClient被SPI加载后,start方法创建单例NettyClient

NettyClient构造时与Backend建立连接。

NettyClient#bind:连接超时时间5s,连接空闲时长60s。

NettyClient#doConnect:

提交连接请求到netty线程,如果连接成功,创建Send任务,如果连接失败,尝试重连。

NettyClient#createSendTask:case1,连接成功。

开启定时任务Sender,每隔10s,消费100容量阻塞队列中的数据,发送至Backend。

NettyClient#reconnect:case2,连接失败。

提交延迟任务到netty线程,延迟5s、10s...180s再次执行doConnect。

2、 Agent发送数据

各插件模块通过ServiceManager获取GatewayClient发送数据,如EventSender发送事件数据。

NettyGatewayClient业务对象使用JSON序列化,支持两种模式发送实时非实时

NettyClient#sendData:

非实时,将数据放入Sender阻塞队列(容量100),由Sender每隔10s批量发送。

NettyClient#sendInstantData:

实时,直接调用channel#writeAndFlush,发送到Backend。

最终通讯层采用gzip压缩+protobuf序列化,业务对象采用json序列化。

注意,Sermant-Agent侧没有保证数据可靠发送至Backend,非实时场景下Sender队列满了会丢弃,实时场景下也只保证数据提交到Netty线程池。

3、 Backend接收数据

Sermant-Backend暴露6888端口,单向接收Agent上报数据。

默认netty boss线程组和worker线程组都是20。

ServerHandler#handlerData:Backend根据数据类型走不同逻辑,目前只有三类数据:

  1. HEARTBEAT_DATA_VALUE,心跳;

  2. EVENT_DATA_VALUE,事件;

  3. VISIBILITY_DATA_VALUE,服务可见性;

三、 心跳

1、Agent侧

HeartbeatServiceImpl#start:Agent心跳服务启动,每heartbeat.interval=30秒发送一次心跳。

HeartbeatServiceImpl#execute:每次心跳会重新组装插件信息(Sermant插件支持agentmain挂载)发送至backend。

HeartbeatMessage包含普通心跳数据和插件信息。

PluginInfo插件信息包括插件名、插件版本和扩展信息。

HeartbeatServiceImpl#addExtInfo:

如果插件提供扩展信息,会在发送心跳时加入PluginInfo。

HeartbeatServiceImpl#setExtInfo:

插件通过实现ExtInfoProvider提供扩展信息。

RegistryImpl#shutdown:

服务注册插件在shutdown后,在心跳信息加入status=stopped标识。

2、Backend侧

ServerHandler#handleHeartBeat:Backend记录心跳数据,包括写外部存储和内存。

心跳持久化

Backend侧仅支持两种存储方式,Memory和Redis,这里采用Redis。

ServerHandler#writeInstanceMeta:

提取心跳包中的ip、instanceId、service,封装为InstanceMeta

RedisClientImpl#addInstanceMeta:redis存储,key=instanceId,value=InstanceMeta。

key的ttl应该是60s,但是截止2.0有bug,ttl是60000s,已经提PR修复。

心跳超时检测

Backend将心跳按照Service+Instance维度放入内存。

NettyServer启动后,每3s执行心跳超时检测。

DeleteTimeoutDataTask#deleteHeartbeatCache:

  1. 超过max.cache.time=600000ms=600s移除心跳,服务实例正式下线;

  2. 超过max.effective.time=60000ms=60s标记instance非健康;

心跳查询

GET /sermant/getPluginsInfo,走内存获取所有心跳信息。

因为走内存,所以backend控制台展示的是连接当前backend实例的agent信息

四、 事件

1、 案例

使用Sermant开发一个SpringSchedule事件上报插件,记录定时任务执行时长和异常情况。

实现 EventCollector

SpringScheduleEventCollector继承EventCollector,通过getInstance暴露单例。

EventCollector单例注册到EventManager统一管理。

public class SpringScheduleEventCollector extends EventCollector {
    private static volatile SpringScheduleEventCollector instance;
    private SpringScheduleEventCollector() {
    }
    public static SpringScheduleEventCollector getInstance() {
        if (instance == null) {
            synchronized (SpringScheduleEventCollector.class) {
                if (instance == null) {
                    instance = new SpringScheduleEventCollector();
                    EventManager.registerCollector(instance);
                }
            }
        }
        return instance;
    }
}

增强定义

SpringScheduleDeclarer实现PluginDeclarer,拦截Component注解类下的Scheduled注解方法,用SpringScheduleInterceptor增强。

public class SpringScheduleDeclarer extends AbstractPluginDeclarer {

    @Override
    public ClassMatcher getClassMatcher() {
        return ClassMatcher.isAnnotatedWith("org.springframework.stereotype.Component");
    }

    @Override
    public InterceptDeclarer[] getInterceptDeclarers(ClassLoader classLoader) {
        return new InterceptDeclarer[]{InterceptDeclarer.build(
                MethodMatcher.isAnnotatedWith("org.springframework.scheduling.annotation.Scheduled"), 
                new SpringScheduleInterceptor())};
    }
}

在META-INF/services加入SPI定义文件io.sermant.core.plugin.agent.declarer.PluginDeclarer:

io.sermant.mytest.SpringScheduleDeclarer

实现拦截器

SpringScheduleInterceptor:

  1. before:定时任务开始前,记录开始时间;

  2. after:定时任务结束后,通过SpringScheduleEventCollector上报事件,包括事件区域、事件级别、事件类型、事件信息;

  3. onThrow:定时任务发生异常,通过打印warn日志的方式上报日志事件;(为了演示日志上报事件能力)

public class SpringScheduleInterceptor implements Interceptor {

    private static final Logger LOGGER = LoggerFactory.getLogger();

    private static final ThreadLocal<Long> start = new ThreadLocal<>();

    @Override
    public ExecuteContext before(ExecuteContext context) throws Exception {
        start.set(System.currentTimeMillis());
        return context;
    }

    @Override
    public ExecuteContext after(ExecuteContext context) throws Exception {
        Class<?> clazz = context.getObject().getClass();
        String method = context.getMethod().getName();
        boolean suc = context.getThrowable() == null;
        String desc = String.format("%s#%s#%s#%d", clazz.getName(), method, suc, (System.currentTimeMillis() - start.get()));
        start.remove();
        SpringScheduleEventCollector.getInstance().offerEvent(
                new Event(
                        "spring-schedule", // 事件区域
                        EventLevel.NORMAL, // 事件级别
                        EventType.GOVERNANCE, // 事件类型
                        // 事件名+事件描述
                        new EventInfo("SPRING_SCHEDULE_TRIGGER", desc)
                )
        );
        return context;
    }

    @Override
    public ExecuteContext onThrow(ExecuteContext context) throws Exception {
        Class<?> clazz = context.getObject().getClass();
        String method = context.getMethod().getName();
        LOGGER.warning("[SPRING_SCHEDULE_TRIGGER]" + clazz + "#" + method + ":" + recordStackTrace(context.getThrowable()));
        return context;
    }

    public static String recordStackTrace(Throwable throwable) {
        StringBuilder record = new StringBuilder(throwable.toString());
        record.append(System.lineSeparator());
        StackTraceElement[] messages = throwable.getStackTrace();
        Arrays.stream(messages).filter(Objects::nonNull).forEach(stackTraceElement -> {
            record.append(stackTraceElement).append(System.lineSeparator());
        });
        return record.toString();
    }
}

Agent配置

agent.service.heartbeat.enable=true // 心跳服务
agent.service.gateway.enable=true // 通讯服务
event.enable=true // 开启事件模块
event.offerWarnLog=true // warn日志上报事件

Backend UI

如果要展示事件名称,需要将EventInfo.name在Backend UI侧配置前端文案。

效果

2、 Agent侧

EventManager

core模块EventManager管理所有EventCollector。

EventManager事件系统初始化:

  1. 默认注册Sermant框架和日志EventCollector;

  2. 30s(event.sendInterval=30000)执行一次collectAll;

EventManager#collectAll:

调用所有EventCollector#collect采集事件,通过EventSender发送至Backend。

EventSender#sendEvent:调用gatewayClient,立即发送所有事件。

EventCollector

每个EventCollector用100容量队列缓存事件数据。

EventCollector#collect:每隔30s会被EventManager采集走。

EventCollector#offerEvent:业务通过offerEvent发送事件

  1. checkEventInfoOfferInterval,过滤短期内被采集过的Event;

  2. 优先放内存队列,等EventManager批量收集后发送;

  3. 如果队列满了,先将缓存的event立即发送至backend,再放内存队列;

EventCollector#checkEventInfoOfferInterval:

每个EventInfo缓存了上次offer时间戳,在event.offerInterval=300000ms=300s内,同一个EventInfo短时间内无法投递两次,算是一种降噪。

EventInfo通过name和description两个属性判断相同。

EventCollector#cleanOfferTimeCacheMap:

最后,EventCollector每30s被EventManager采集后,会清理缓存的eventInfoOfferTimeCache(event-发送时间)。

LogEventCollector

Sermant的core模块通过重写jul 到slf4j的SLF4JBridgeHandler桥接处理器接入了事件系统。

对于error和warn日志,调用LogEventCollector收集日志事件。

LogEventCollector继承了EventCollector,采集的事件信息是LogInfo。

LogEventCollector#offerError:采集日志事件同样需要经过LogInfo去重降噪。

LogInfo#hashCode:LogInfo去重包括日志等级、class、方法、行号、线程id、异常类。

3、 Backend侧

Event持久化

ServerHandler#handleEvent:Backend处理Agent发来的Event。

EventPushHandler#pushEvent:事件处理包含

  1. 循环持久化Event;

  2. 查询需要告警的Event(只是合并了Instance信息,忽略),调用webhook发送需要告警的Event(这部分功能不太好用,还有bug,忽略);

RedisClientImpl#addEvent:存储Event到Redis

  1. 根据instanceId(event.MetaHash)查询Instance信息,而Instance信息由心跳提供,所以事件强依赖心跳

  2. 拼接事件field事件field是事件唯一标识由instance信息和Event信息拼接而成,包括service、ip、EventType-事件类型、EventLevel-等级、scope-区域等字段,最终field为 {service}_{ip}_{eventType}_{eventLevel}_{scope}_{instanceId}_{cluster}_{env}_{az}_{instanceId}_{timestamp}

  3. 存储事件,事件存储在名为sermant_events_hashhash中,hash的key即为事件field,value为事件+实例信息;

  4. 存储时间-事件索引,通过zset数据结构存储,key=sermant_event_keyset,score=事件时间戳,value=事件field;

Event查询

从backend查询事件:

  1. 支持分页;

  2. 必选时间段;(这样才能走时间-事件索引)

  3. 可通过服务、ip、事件级别、事件类型条件查询;

Backend将事件查询分成两部分:首次查询、跳页翻页。

/sermant/event/events,首次查询包含两部分逻辑:

  1. 事件列表;

  2. 事件等级对应事件数量;

注意,Backend的事件查询都与当前http会话关联,可以理解为一个用户一份视图

RedisClientImpl#queryEvent:

  1. 根据查询条件,拼接事件field正则匹配表达式,目前页面根据服务名查询,则pattern=服务名_.*_.*_.*_.*_.*

  2. zrange查询时间范围内的所有事件Field

  3. 使用pattern过滤这些事件Field;

  4. 缓存当前客户端会话本次查询条件下的所有Field,key=sessionId,value=过滤后的事件Field,缓存时长60s;

  5. 查询第一页事件;

RedisClientImpl#queryEventPage:查询某页(跳页、翻页、首次查询)。

根据sessionId查询当前客户端本次查询条件下的所有事件Field,循环所有事件Field,查询sermant_events_hash得到事件。

RedisClientImpl#getQueryCacheSize:查询事件等级对应事件数量

同样根据session拿到当前会话所有事件Field。

DbUtils#getQueryCacheSize:因为Field格式如{service}_{ip}_{eventType}_{eventLevel}_{scope}...,解析可得到EventLevel事件等级。

Event清理

RedisClientImpl#cleanOverDueEventTimerTask:

每10000s执行一次Event清理任务。

通过zrange扫描sermant_event_keyset中7天(database.event.expire)前的事件Field,将事件从sermant_event_keysetsermant_event_hash中移除。

总结

通讯

Agent侧

Agent的core模块定义了GatewayClient接口向Backend发送数据。

Agent的implement模块实现NettyGatewayClient,使用Netty与Backend通讯,被SPI加载后触发start方法,尝试与Backend建立长连接。

如果连接失败,由Netty线程延迟5s、10s...180s后尝试重连。

如果连接成功,创建Sender任务,每隔10s(gateway.sendInternalTime)发送缓存队列(100容量不可配置)中的业务数据。

序列化方面,业务数据采用JSON序列化并gzip压缩,整体采用protobuf序列化。

GatewayClient,提供两种api:

  1. 实时发送,立即发送数据到Netty io线程;

  2. 非实时发送,仅将数据发送到Sender的内存阻塞队列,如果队列满了将被丢弃;

由于Agent到Backend是单向oneway通讯,所以也无法确保数据投递成功。

Backend侧

Backend暴露6888端口,单向接收Agent上报数据。

默认netty boss线程组和worker线程组都是20(netty.thread.num)。

目前Backend仅接收Agent三类数据:心跳事件、服务可见性插件数据。

心跳

事件上报强依赖心跳

Agent侧

HeartbeatService由core模块定义的心跳接口。

HeartbeatServiceImpl由implement模块实现。

HeartbeatServiceImpl在SPI加载后start方法被调用,启动心跳任务,每30秒heartbeat.interval)上报一次心跳。

心跳包含两类数据:

  1. 基本信息:如service、ip、instanceId;

  2. 插件信息:插件名,插件版本,插件扩展信息;

每个插件可以实现ExtInfoProvider接口,提供插件扩展信息,注册到HeartbeatServiceImpl。

Backend侧

Backend将完整心跳数据包放入内存,用于心跳超时检测和页面查询:

  1. 超过max.cache.time=600000ms=600s移除心跳,服务实例正式下线

  2. 超过max.effective.time=60000ms=60s标记instance非健康

此外,Backend将心跳中的instance基本信息封装为InstanceMeta(service、ip、instanceId)放入Redis持久化。

key=instanceId,value=json(InstanceMeta),缓存时长目前是60000s,已提PR修复为60s。

事件

Agent侧

EventManager管理所有EventCollector,每个EventCollector有100容量的阻塞队列,用于存放Event。

发送事件到EventCollector会有两种情况:

  1. 如果EventCollector队列满了,会立即将当前collector缓存的event发送至backend;

  2. 否则,将Event缓存到队列中,由EventManager每隔30s(event.sendInterval)统一发送至backend;

Sermant的core模块通过重写jul 到slf4j的SLF4JBridgeHandler桥接处理器接入了事件系统。对于error和warn日志,支持通过LogEventCollector收集日志事件。

无论是日志还是其他事件,在事件发送给Collector时,collector会忽略300s(event.offerInterval)内相同内容(EventInfo的equals和hash)的事件,算是一种降噪。

Backend侧

Backend接收Agent事件数据后,使用Redis的Hash和ZSet存储事件事件索引

  1. 事件Field:事件的唯一标识,一个字符串,包含InstanceMeta和Event中的部分维度信息,格式为 {service}_{ip}_{eventType}_{eventLevel}_{scope}_{instanceId}_{cluster}_{env}_{az}_{instanceId}_{timestamp}

  2. 事件Hash:sermant_events_hash,存储事件Field-事件数据(含InstanceMeta);

  3. 事件ZSet:sermant_event_keyset,存储事件发生时间-事件Field;

事件数据默认只会存储7天(database.event.expire),过期将被删除:

  1. 定时扫描sermant_event_keyset 7天前的EventField;

  2. 批量删除sermant_event_keysetsermant_events_hash中的相关数据;

Backend UI支持根据时间范围分页查询事件信息。

每个HttpSession对应一个查询视图,缓存时长为60s(session.expire)。

用户首次查询:

  1. 根据查询条件,生成Field正则匹配表达式,如服务名_.*_.*_.*_.*_.*

  2. zrange查询sermant_event_keyset时间范围内的Field集合;

  3. 正则匹配过滤Field集合;

  4. sessionId和事件Field集合关系存储到redis,key=sessionId,value=过滤后的事件Field,缓存时长60s(session.expire);

  5. 执行第一页查询;

翻页/跳页/第一页查询:

  1. 根据sessionId查询当前用户EventField视图

  2. 循环所有Field,查询sermant_events_hash得到事件数据;

事件等级统计查询:

  1. 根据sessionId查询当前用户EventField视图

  2. 循环所有Field,由于Field中包含事件等级,内存求和即可;