前言
本章分析Sermant2.0.0事件系统:
-
横向对比其他系统中的事件定义;
-
Agent与Backend的通讯方式;
-
心跳服务(事件依赖于心跳);
-
事件服务;
一、 事件定义
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包含三个属性:
-
name:事件名称;
-
timestamp:事件发生时间;
-
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根据数据类型走不同逻辑,目前只有三类数据:
-
HEARTBEAT_DATA_VALUE,心跳;
-
EVENT_DATA_VALUE,事件;
-
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:
-
超过max.cache.time=600000ms=600s移除心跳,服务实例正式下线;
-
超过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:
-
before:定时任务开始前,记录开始时间;
-
after:定时任务结束后,通过SpringScheduleEventCollector上报事件,包括事件区域、事件级别、事件类型、事件信息;
-
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事件系统初始化:
-
默认注册Sermant框架和日志EventCollector;
-
30s(event.sendInterval=30000)执行一次collectAll;
EventManager#collectAll:
调用所有EventCollector#collect采集事件,通过EventSender发送至Backend。
EventSender#sendEvent:调用gatewayClient,立即发送所有事件。
EventCollector
每个EventCollector用100容量队列缓存事件数据。
EventCollector#collect:每隔30s会被EventManager采集走。
EventCollector#offerEvent:业务通过offerEvent发送事件
-
checkEventInfoOfferInterval,过滤短期内被采集过的Event;
-
优先放内存队列,等EventManager批量收集后发送;
-
如果队列满了,先将缓存的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:事件处理包含
-
循环持久化Event;
-
查询需要告警的Event(只是合并了Instance信息,忽略),调用webhook发送需要告警的Event(这部分功能不太好用,还有bug,忽略);
RedisClientImpl#addEvent:存储Event到Redis
-
根据instanceId(event.MetaHash)查询Instance信息,而Instance信息由心跳提供,所以事件强依赖心跳;
-
拼接事件field,事件field是事件唯一标识,由instance信息和Event信息拼接而成,包括service、ip、EventType-事件类型、EventLevel-等级、scope-区域等字段,最终field为
{service}_{ip}_{eventType}_{eventLevel}_{scope}_{instanceId}_{cluster}_{env}_{az}_{instanceId}_{timestamp}
。 -
存储事件,事件存储在名为sermant_events_hash的hash中,hash的key即为事件field,value为事件+实例信息;
-
存储时间-事件索引,通过zset数据结构存储,key=sermant_event_keyset,score=事件时间戳,value=事件field;
Event查询
从backend查询事件:
-
支持分页;
-
必选时间段;(这样才能走时间-事件索引)
-
可通过服务、ip、事件级别、事件类型条件查询;
Backend将事件查询分成两部分:首次查询、跳页翻页。
/sermant/event/events,首次查询包含两部分逻辑:
-
事件列表;
-
事件等级对应事件数量;
注意,Backend的事件查询都与当前http会话关联,可以理解为一个用户一份视图。
RedisClientImpl#queryEvent:
-
根据查询条件,拼接事件field正则匹配表达式,目前页面根据服务名查询,则pattern=
服务名_.*_.*_.*_.*_.*
; -
zrange查询时间范围内的所有事件Field;
-
使用pattern过滤这些事件Field;
-
缓存当前客户端会话本次查询条件下的所有Field,key=sessionId,value=过滤后的事件Field,缓存时长60s;
-
查询第一页事件;
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_keyset和sermant_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:
-
实时发送,立即发送数据到Netty io线程;
-
非实时发送,仅将数据发送到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)上报一次心跳。
心跳包含两类数据:
-
基本信息:如service、ip、instanceId;
-
插件信息:插件名,插件版本,插件扩展信息;
每个插件可以实现ExtInfoProvider接口,提供插件扩展信息,注册到HeartbeatServiceImpl。
Backend侧
Backend将完整心跳数据包放入内存,用于心跳超时检测和页面查询:
-
超过max.cache.time=600000ms=600s移除心跳,服务实例正式下线;
-
超过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会有两种情况:
-
如果EventCollector队列满了,会立即将当前collector缓存的event发送至backend;
-
否则,将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存储事件和事件索引。
-
事件Field:事件的唯一标识,一个字符串,包含InstanceMeta和Event中的部分维度信息,格式为
{service}_{ip}_{eventType}_{eventLevel}_{scope}_{instanceId}_{cluster}_{env}_{az}_{instanceId}_{timestamp}
。 -
事件Hash:sermant_events_hash,存储事件Field-事件数据(含InstanceMeta);
-
事件ZSet:sermant_event_keyset,存储事件发生时间-事件Field;
事件数据默认只会存储7天(database.event.expire),过期将被删除:
-
定时扫描sermant_event_keyset 7天前的EventField;
-
批量删除sermant_event_keyset和sermant_events_hash中的相关数据;
Backend UI支持根据时间范围分页查询事件信息。
每个HttpSession对应一个查询视图,缓存时长为60s(session.expire)。
用户首次查询:
-
根据查询条件,生成Field正则匹配表达式,如
服务名_.*_.*_.*_.*_.*
; -
zrange查询sermant_event_keyset时间范围内的Field集合;
-
正则匹配过滤Field集合;
-
将sessionId和事件Field集合关系存储到redis,key=sessionId,value=过滤后的事件Field,缓存时长60s(session.expire);
-
执行第一页查询;
翻页/跳页/第一页查询:
-
根据sessionId查询当前用户EventField视图;
-
循环所有Field,查询sermant_events_hash得到事件数据;
事件等级统计查询:
-
根据sessionId查询当前用户EventField视图;
-
循环所有Field,由于Field中包含事件等级,内存求和即可;