APM 服务数据状态下沉到后台服务发现 —— 实施方案
0x01 调研与约束
a. 现有数据状态判定
周期任务 refresh_application 每 10 min 重算一次数据状态,判断最近 no_data_period 分钟(默认 10 min)内是否有数据 [1]:
| 类型 | 应用(Application.set_data_status) | 服务(ServiceHandler.get_service_data_status_mapping) |
|---|---|---|
| 调用链 | [a] UnifyQuery:TraceBackendHandler.get_data_count[b] bk_apm_count 求和 | [a] UnifyQuery:ServiceFlowCount[b] Flow 指标含该服务 |
| 指标 | [a] BKBase:MetricBackendHandler.get_data_count[b] 链路消息数 [2] [3] | [a] UnifyQuery:RequestCountInstance[b] bk_apm_count 按服务分组 |
| 日志 | [a] UnifyQuery:LogBackendHandler.get_data_count[b] 索引集 _index 计数 | [a] ES:ServiceLogHandler.get_log_count_mapping[b] 日志表聚合命中或关联索引无 NO_DATA 标签 |
| 性能分析 | [a] BKBase:ProfilingBackendHandler.get_data_count[b] 链路消息数 [2] | [a] DB:query_profile_services_detail[b] last_check_time 晚于窗口起点前 10 min |
- [1] 页面异步拉取服务状态同样使用该窗口,忽略前端传入的时间范围。
- [2] V4 取 DataId 消息数,V3 取存储
output_count且默认按1d聚合,窗口可能扩大到当天(待确认)。 - [3] 含 Trace 生成的
bk_apm_*指标,只上报 Trace 也判为有数据。
服务日志状态里的 NO_DATA 标签不由 APM 计算,而是日志平台巡检索引集后打上的,规则如下:
| 项 | 规则 |
|---|---|
| 入口 | apps.log_search.tasks.no_data.no_data_check,每 15 min |
| 窗口 | 近 1 天 |
| 判据 | 整个索引集无数据即打标(tag_id=4),查询异常也按无数据处理 |
| 范围 | 近 1 天有人检索过的空间下的有效索引集 [1] |
- [1] 范围外的索引集不会被检查,缺少该标签不代表已确认有数据。
b. 慢 Trace 耗时构成
| 子调用(按发生顺序) | 耗时 | 用途 |
|---|---|---|
QueryTopoNodeResource | 401 ms | 服务全集:拓扑节点 |
QueryProfileServicesDetail | 404 ms | 服务全集:性能分析独有服务 |
GraphUnifyQueryResource × 4 | 1211 ms | 指标状态:bk_apm_count 四路分组 |
DetailApplicationResource | 162 ms | 读日志数据源配置 |
ESQueryDslResource × 2 | 1607 ms | 日志状态:两个服务名字段的 terms 聚合 |
unify_query | 314 ms | 调用链状态:apm_service_to_apm_service_flow_count |
QueryProfileServicesDetail | 962 ms | 性能分析状态:带 last_check_time__gt 过滤 |
- [1] 样本:Trace
aa9a057ba090042a43eeb6012be6e024,data_status缓存未命中;入口耗时5.235 s,资源内部5.149 s,19个直接子 Span 连续执行。 - [2] ES 两次聚合并行,表中
1607 ms为墙钟耗时,非执行时间之和;ES 执行耗时分别为1279 ms、1317 ms。 - [3] 服务全集重复枚举
2次,性能分析接口分别用于服务枚举和状态判定。
c. 后台发现现状
调度入口均位于 apm.task.tasks,发现器由 apm.apps.ApmApiConfig.ready 注册。
| 数据源 | 发现器 | 调度入口与周期 | 写入对象 | 时间语义 |
|---|---|---|---|---|
| 调用链 | apm.core.discover.node.NodeDiscover 等 7 个 | [a] topo_discover_cron[b] 每分钟分片,单应用每 10 min 一轮 [1] | TopoNode:合并 source=["trace"],刷新 updated_at | 节点更新时间,未记录数据时间 |
| 指标 | apm.core.discover.metric.service.ServiceDiscover | [a] datasource_discover_cron[b] 每分钟分片,单应用每 10 min 一轮 [2] | TopoNode:合并 source=["metric"],刷新 updated_at | 节点更新时间,未记录数据时间 |
| 性能分析 | apm.core.discover.profile.service.ServiceDiscover | [a] profile_discover_cron[b] 每 10 min 一轮,应用间串行执行 | ProfileService:刷新 last_check_time | 任务开始时刻,未记录数据时间 |
| 日志 | 无 | 无 | 无 | 无 |
- [1] 新建应用每
2 min一轮,eBPF 应用不参与调用链发现。 - [2] 指标发现包含 eBPF 应用,按
200 s切分子窗口;窗口起点误用当前时间,查询范围指向未来。 - [3]
TopoNode.updated_at由多个发现器共用,无法表达各数据类型的更新时间。 - [4]
apm.core.discover.cached_mixin.CachedDiscoverMixin的 Redis 心跳只用于对象回收,不区分数据类型,也不覆盖TopoNode、ProfileService,不能用于服务状态判定。
0x02 架构设计
a. 架构命题
把「服务是否有数据」从请求时重新证明改为发现时记录事实:每个 telemetry 发现器在写服务拓扑的同时落盘本数据源的服务级数据心跳,服务拓扑查询接口一次返回服务全集与四类心跳,SaaS 只保留心跳到三态的纯映射。
b. 复杂度来源与收敛点
复杂度来自发现链路与状态链路对同一事实做了两次独立计算:
- 发现器从窗口数据里已经知道哪些服务有数据,却只留下一个被
auto_now覆盖、且被两个发现器共享的updated_at。 - 状态链路只能用另一套查询(四路
bk_apm_count、Flow 指标、ES 聚合、ProfileService表)重新证明同一件事。 - 服务全集来自拓扑与性能分析两条链路,状态映射的键不能代表服务全集,接口只能先等全集再算状态。
收敛点是服务数据心跳:按「服务 × 数据类型」记录数据时间的后台事实,由四个发现器各写一类、由拓扑查询接口统一读出。
局部补丁解决不了这个问题。把日志聚合改成缓存判断只消除一项开销,指标四路、Flow 指标和性能分析仍留在请求线程,服务全集仍要枚举两次。
c. 核心对象模型
flowchart LR
subgraph BG["后台发现(每应用 10 min 一轮)"]
D1["NodeDiscover<br/>调用链"]
D2["MetricServiceDiscover<br/>指标"]
D3["LogServiceDiscover<br/>日志"]
D4["ProfileServiceDiscover<br/>性能分析"]
end
D1 -->|"topo_key + source"| TN[("TopoNode")]
D2 --> TN
D3 --> TN
D4 --> TN
D1 -->|"heartbeat.trace"| HB["heartbeat 列<br/>四类数据源的数据时间"]
D2 -->|"heartbeat.metric"| HB
D3 -->|"heartbeat.log"| HB
D4 -->|"heartbeat.profiling"| HB
HB -.- TN
TN --> QN["QueryTopoNodeResource<br/>节点 + heartbeat"]
QN --> LS["ServiceHandler.list_services"]
LS --> SL["ServiceListResource"]
LS --> SA["ServiceListAsyncResource"]TopoNode 同时承载服务全集与数据心跳,两件事由不同字段表达:
| 字段 | 回答的问题 | 写入方 |
|---|---|---|
topo_key + source | 这个应用有哪些服务,各自在哪些 telemetry 链路被发现或观测到数据 | 四个发现器共同维护 |
heartbeat | 每个服务的每类数据最后何时出现 | 每个发现器只改自己数据类型对应的子键 |
- [1] 节点超过
EXPIRED_DAYS(180天)未更新即删除,心跳随节点回收。 - [2] 性能分析服务须先并入
TopoNode,四类心跳才能共用节点;服务全集由拓扑节点与 SaaS 手动自定义服务组成,QueryProfileServicesDetail退出服务列表链路。 - [3] 无心跳的服务仍保留在列表中,不能用心跳的键集合代替服务全集。
- [4]
source是追加式事实:节点发现写入来源;心跳观测到有效数据时间时补齐对应类型。仅完成检查不补来源。
d. 心跳协议
TopoNode.heartbeat 按数据类型记录服务最后一条数据的时间,以及最近一次观测到该服务的检查完成时间:
{
"trace": {"last_data_at": 1789530180, "checked_at": 1789530200},
"metric": {"last_data_at": 1789530120, "checked_at": 1789530200},
"log": {"last_data_at": 1789529600, "checked_at": 1789529700}
}| 字段 | 类型 | 说明 |
|---|---|---|
<data_type> | object | 每个发现器只更新自己负责的类型,保留其他类型的值;子键缺失表示该类数据从未被观测到 |
<data_type>.last_data_at | int | 最后一条数据的时间(秒),新旧时间取较大值 |
<data_type>.checked_at | int | 最近一次观测到该服务的检查完成时间(秒),新旧时间取较大值 |
心跳只记录本轮观测到的服务。服务是稀疏上报的,一轮窗口查询无法证明其他服务没有数据,服务停止上报体现为 last_data_at 不再前进。查询失败或只拿到部分结果时,本轮视为未观测,不写入心跳。
touch_heartbeat() 在 last_data_at 非空时确保 source 包含当前数据类型。该补偿只更新已有节点,不创建节点;last_data_at=None 只推进 checked_at,不补来源。
调用链与指标、日志按 app_id % 10 == minute % 10 在同一分钟派发,可能同时写同一行。写入方在事务内按 id 顺序锁行后重读合并,并发写入串行执行,不会丢失更新。行锁超时或死锁时,写入方回滚本次写入并保留旧值,由下一轮发现补齐。
- [1] 写入更新
heartbeat与必要的source补偿,不改updated_at,节点仍按拓扑更新时间回收。 - [2] 服务状态使用独立的观测窗口,默认
3600 s,吸收发现任务排队及缓存滞后;超出窗口仍可能误判,本期不增加更新延迟状态。 - [3] 心跳只由后台发现器更新,页面查询不更新心跳时间。
e. 状态映射协议
服务级状态由 SaaS 读取心跳与后台关联日志状态快照映射,页面请求不查询日志平台索引集列表:
service_status(service, data_type):
if not application.is_enabled_{data_type}: -> DISABLED
if data_type == log:
if heartbeat_fresh(service, log): -> NORMAL
if related_index_available(service): -> NORMAL
-> NO_DATA
if heartbeat_fresh(service, data_type): -> NORMAL
-> NO_DATA
heartbeat_fresh(service, data_type):
last_data_at >= now - APM_SERVICE_DATA_STATUS_LOOKBACK_SECONDS日志状态合并上报心跳与关联索引可用性,任一满足即为 NORMAL。关联范围包括服务级手动关联、应用级全局关联和 K8s 自动关联,关联关系与心跳分别维护,索引集标签不写入 heartbeat.log.last_data_at。
服务观测窗口由 APM_SERVICE_DATA_STATUS_LOOKBACK_SECONDS 配置,默认 3600 s,表达「最近一小时发现过数据」,独立于页面时间范围和应用 no_data_period。真正停止上报后的无数据判定也会相应延后,后台延迟超过一小时仍可能误判。
应用级状态不由心跳推导,沿用 Application.set_data_status 的现有链路与自身无数据周期。
0x03 开发方案 —— 后台心跳生产
a. 模型与统一写入口
apm.models.topo.TopoNode 新增:
heartbeat:models.JSONField,默认{},字段含义见「心跳协议」。touch_heartbeat(...):四个发现器共用的心跳写入方法;观测到有效数据时间时补齐对应source。upsert_telemetry_nodes(...):日志与性能分析共用的节点写入方法,已有节点追加来源,缺失节点新建,不覆盖已有拓扑分类 [1]。
flowchart LR
A["discover()"] -->|"observed only"| B["topo_key → last_data_at"]
B --> E
subgraph T["touch_heartbeat() · transaction"]
direction TB
E["select_for_update()<br/>order_by('id')"] --> F["merge<br/>heartbeat[data_type] + source"]
F --> G["bulk_update<br/>fields=['heartbeat', 'source']"]
end
E -.->|"lock timeout /<br/>deadlock"| R["rollback<br/>return False"]- [1]
upsert_telemetry_nodes不加锁:并发覆盖最多丢掉其他来源标签,下一轮有效心跳或节点发现补回。 - [2] 各发现器在
discover()末尾从本轮窗口数据生成「服务键 → 最后数据时间」映射,不改动现有节点写入流程,取值方式见各数据源小节。
touch_heartbeat 核心逻辑:
@classmethod
def touch_heartbeat(
cls,
bk_biz_id: int,
app_name: str,
data_type: str,
last_data_at_mapping: dict[str, int | None], # topo_key -> 本轮观测到的最后数据时间(秒)
checked_at: int, # 本次检查完成时间(秒)
) -> bool:
try:
with transaction.atomic():
# reload:按 id 顺序锁行后重读,并发写入串行合并 [3]
nodes: list[TopoNode] = list(
cls.objects.select_for_update()
.filter(bk_biz_id=bk_biz_id, app_name=app_name, topo_key__in=last_data_at_mapping)
.only("id", "topo_key", "heartbeat", "source")
.order_by("id")
)
# merge:只改本类型心跳;有实际数据时补齐本类型来源
for node in nodes:
sub: dict[str, int] = node.heartbeat.get(data_type) or {}
new_last_data_at: int | None = last_data_at_mapping[node.topo_key]
if new_last_data_at is not None:
sub["last_data_at"] = max(sub.get("last_data_at") or 0, new_last_data_at)
source: list[str] = list(node.source or [])
if data_type not in source:
source.append(data_type)
node.source = source
sub["checked_at"] = max(sub.get("checked_at") or 0, checked_at)
node.heartbeat[data_type] = sub
# bulk_update:不声明 updated_at,不触发 auto_now
cls.objects.bulk_update(nodes, fields=["heartbeat", "source"], batch_size=200)
except OperationalError: # 仅锁超时、死锁返回 False,其他异常上抛
return False
return True- [3] 新服务的节点已在
discover()内建出,heartbeat默认为{},reload 能直接读到。 - [4]
source补偿只作用于last_data_at_mapping命中的已有节点;缺失节点不创建,空数据时间不补来源。
b. 调用链心跳
TopoHandler.discover() 在常规轮次后做一次按服务折叠的补扫,按 kind DESC, end_time DESC 选择 Span。INTERNAL=1 排在其余有效类型之后,同一 kind 内取最新记录;trace 心跳直接使用所选 Span 的数据时间。
| 变更点 | 修改内容 |
|---|---|
[Add] bkmonitor.data_source.utils.query.BaseQuery._query_group_list | 签名同 _query_list,增加 group_field,每组返回排序后的第一条完整记录 [1] |
[Change] apm.core.handlers.query.span_query.SpanQuery.query_group_list | 支持可选 sort,默认按 end_time DESC 排序,保留完整 Span 与应用隔离条件 |
[Add] apm.core.discover.base.DiscoverBase.DISCOVERY_REQUIRES_TRACE_CONTEXT | 标记依赖完整 Trace 上下文的发现器,默认 False,RootEndpointDiscover 置为 True,兜底轮跳过这类发现器 [4] |
[Change] apm.core.discover.base.TopoHandler.discover | 常规轮与兜底轮共用 _discover_spans,汇总发现器返回结果 |
[Add] apm.constants.ApmCacheType.TRACE_DISCOVERY_CHECK、ApmCacheConfig | 统一配置检查位置的 key 模板和 1 h 过期时间,模板固定包含 _TRACE 后缀 |
[Keep] apm.core.handlers.apm_cache_handler.ApmCacheHandler.get_cache_key | 复用原有签名与格式化逻辑,不增加参数,实际 Redis key 格式保持兼容 |
[Change] apm.task.tasks.topo_discover_cron、handler | 先筛选到期应用再申请锁,以消息头传递投递时间,用 task_id 串联投递、开始和结束日志 |
# TopoHandler.discover(),位于 discover finished 日志之前
for round_index, trace_ids in enumerate(self.list_trace_ids(index_name)):
...
self._discover_spans(all_spans, topo_params_template)
# 兜底轮
end_time = query_started_at
start_time = max(last_successful_query_end, end_time - 3600)
last_spans: list[dict[str, Any]] = span_query.query_group_list(
start_time, end_time, group_field=OtlpKey.get_resource_key(ResourceAttributes.SERVICE_NAME),
offset=0, limit=SpanQuery.QUERY_MAX_LIMIT, sort=["kind desc", "end_time desc"],
)
discovered = self._discover_spans(last_spans, topo_params_template, is_fallback=True)
heartbeat_written = TopoNode.touch_heartbeat(
self.bk_biz_id, self.app_name, TelemetryDataType.TRACE.value,
last_data_at_mapping={get_service_name(s): s[OtlpKey.END_TIME] // 1_000_000 for s in last_spans},
checked_at=int(time.time()),
)
if discovered and heartbeat_written:
save_successful_query_end(end_time)- [1] 与
apm_web.llm.query.LLMQuery.query_group_list同一机制,后者额外调用.values(group_field)只取分组值,这里返回整条 Span。 - [2] 常规轮保持最近
10 min;兜底从上次成功查询右边界继续,最长回看1 h,冷启动、缓存过期或时钟回拨时回看1 h。保存查询右边界而非任务完成时间,避免跳过执行期间到达的数据。 - [3] 兜底 Span 走同一套拓扑发现任务,稀疏服务的节点在写心跳前建出。常规轮已经写入,各发现器重读已有对象后再按存在性决定创建或更新,不会重复创建。
- [4] 兜底 Span 是按服务折叠后的独立 Span,没有父子关系,入口接口发现处理它会把下游接口误判为入口。
- [5] 接受不同非 INTERNAL 类型之间的优先级差异,所选 Span 不保证是服务内时间最新的记录,不追加查询或聚合。查询异常、发现器明确返回失败、心跳写入失败时保留检查位置;
NodeDiscover沿用既有内部批次异常处理,折叠保留10,000条上限,不做截断检测。 - [6] 应用级补扫位置与服务级
heartbeat.trace.checked_at分别表达成功查询边界和观测完成时间,不能互相替代。日志自带时间表示任务投递、开始和结束时刻,字段保留dispatch_delay_s、queue_delay_s、duration_s、窗口、数量及兜底耗时,状态只在结束日志记录;逐发现器成功日志与批次调参日志删除,不保留 DEBUG。共享查询层只删除QueryHelper重复查询体日志,query.py的UNIFY_QUERY日志原样保留;缓存无增删改时静默,有变化时合并摘要,清理前记录过期/超量原因及最多十个 ID,超量使用 WARNING,Redis 刷新失败保留 key、数量、TTL 与异常堆栈。
c. 指标心跳
| 变更点 | 修改内容 |
|---|---|
[Change] apm.core.discover.metric.service.ServiceDiscover.query_dimensions | 新增 with_last_data_at,开启时为每个序列返回 last_data_at |
[Change] apm.core.discover.metric.service.ServiceDiscover.discover | 末尾执行心跳查询,按下表展开服务键后调用 touch_heartbeat [1] |
def query_dimensions(
self, promql: str, start_time: int, end_time: int, with_last_data_at: bool = False
) -> list[dict[str, Any]]:
# ...
for item in response.get("series", []):
# ...
dimension: dict[str, Any] = dict(zip(item["group_keys"], item["group_values"]))
if with_last_data_at:
value_index: int = item["columns"].index("_value")
points: list[float] = [p[value_index] for p in item["values"] if p[value_index] is not None]
dimension["last_data_at"] = int(max(points)) if points else None # [2]
dimensions.append(dimension)
return dimensionsmax by (service_name, db_system, messaging_system, peer_service) (
timestamp({__name__="custom:<result_table_id>:bk_apm_count"})
)| 序列维度 | 服务键 [3] | 写入心跳 |
|---|---|---|
service_name | {service_name} | metric |
service_name + db_system | {service_name}-{db_system} | metric、trace [4] |
service_name + messaging_system | {service_name}-{messaging_system} | metric、trace [4] |
peer_service | http:{peer_service} | metric、trace [4] |
- [1] 选用
bk_apm_count:它由 collector 从 Trace 生成并经指标链路写入,最能反映指标数据链路是否畅通。 - [2]
timestamp()的值是原始样本时间(秒),不受步长影响,已在 bkop 实测;每个求值点只回看5 min,step须不超过5 min,当前子窗口200 s已满足。 - [3] 同一个服务可能同时出现在多条序列里,例如普通调用与 DB 调用各一条,取其中最晚的
last_data_at。 - [4] 组件与远程服务没有独立的 Span 心跳来源,而
bk_apm_count本就由 Span 生成,因此同时写入metric与trace子键。
d. 日志服务发现
日志与调用链兜底轮一致,按服务名折叠查询窗口内每个服务的最后一条日志,同一次查询既发现服务,也生成 log 心跳。服务名来自 resource.service.name 与 resource.server 两个字段,每个字段各折叠一次,按服务名取最晚时间合并。
| 变更点 | 修改内容 |
|---|---|
[Add] apm.core.discover.log.base.Discover | 持有 LogDataSource,构造参数与指标发现器一致 |
[Add] apm.core.discover.log.service.ServiceDiscover | 折叠查询每个服务的最后一条日志,经 upsert_telemetry_nodes 写入节点(合并 source=["log"])后调用 touch_heartbeat |
[Change] bkmonitor.data_source.data_source.LogSearchLogDataSource.__init__ | 设置 distinct 时不补默认的 COUNT(_index),折叠查询返回原始日志记录 |
[Change] apm.apps.ApmApiConfig.ready | 注册 DiscoverContainer.register(TelemetryDataType.LOG.value, LogServiceDiscover) |
[Change] apm.task.tasks.datasource_discover_cron | 遍历 MetricDataSource 与 LogDataSource,按数据类型派发;窗口起点改为当前时间前一个调度周期,修正指标窗口指向未来 |
[Change] apm.task.tasks.datasource_discover_handler | 新增 data_type 参数,默认 metric,兼容发布前已入队的旧任务;由发现器决定窗口切分方式 |
for field in ("resource.service.name", "resource.server"):
q: QueryConfigBuilder = (
QueryConfigBuilder((DataTypeLabel.LOG, DataSourceLabel.BK_LOG_SEARCH))
.table(str(self.datasource.index_set_id))
.index_set_id(self.datasource.index_set_id)
.time_field("time")
.distinct(field) # [1]
.order_by("time desc")
)
last_logs: list[dict[str, Any]] = list(
UnifyQuerySet().start_time(start_time * 1000).end_time(end_time * 1000).add_query(q).limit(QUERY_MAX_LIMIT)
)- [1]
distinct由BaseBkMonitorLogDataSource编译为 EScollapse,与调用链兜底轮同一机制,每个服务返回时间最晚的一条日志,其time即该服务的last_data_at。 - [2] 仅查询 APM 自身的
LogDataSource,index_set_id为空时跳过,不查询服务关联索引。
e. 性能分析心跳
| 变更点 | 修改内容 |
|---|---|
[Change] apm.core.discover.profile.service.ServiceDiscover.discover | [a] 经 upsert_telemetry_nodes 写入节点,合并 source=["profiling"],按普通服务归入「其他」分类 [1][b] 本轮发现到的服务以 check_time 作为 last_data_at 写入 profiling 心跳 [2] |
[Change] apm.task.tasks.profile_discover_cron | 过滤未开启性能分析的应用,应用间保持串行同步执行 [3] |
- [1]
extra_data.kind=profiling没有消费方,原先只在list_services中为纯性能分析服务临时拼出。按普通服务落库后,不需要为「未归类节点」做额外补偿。 - [2] 发现窗口为
check_time前10 min,用检查时间近似数据时间的误差不超过10 min;页面状态统一使用独立的服务观测窗口。 - [3] 新增写入只有几次数据库读写,相比 Doris 查询可以忽略;改为
delay()派发会在 Doris 和celery_cron队列上形成并发尖峰。
0x04 开发方案 —— 读取协议
a. 服务粒度读取
[Change] apm.resources.QueryTopoNodeResource:在节点响应中补充 source 与 heartbeat。SaaS 的 api.apm_api.default.QueryTopoNodeResource API 缓存缩短为 5 min,ServiceHandler.list_nodes 本地缓存保留 60 s,其他 APM API 的缓存不变。
{
"topo_key": "activity-microservices.msgcenter",
"extra_data": {"category": "http", "kind": "service"},
"source": ["trace", "metric"],
"heartbeat": {
"trace": {"last_data_at": 1789530180, "checked_at": 1789530200},
"metric": {"last_data_at": 1789530120, "checked_at": 1789530200}
}
}- [1] 心跳随节点一同读取,不增加查询;子键缺失的处理见「心跳协议」。
- [2] 接口不按性能分析开关过滤节点。关闭性能分析后,性能分析发现不再派发,已有节点与其他数据源一样按
EXPIRED_DAYS回收。
b. 服务全集
[Change] apm_web.handlers.service_handler.ServiceHandler.list_services:
- 返回拓扑节点与手动自定义服务;
include_data_status=True时附带映射后的data_status,默认只枚举服务。 - 应用关闭性能分析时,过滤
source == ["profiling"]的节点。 - 移除
query_profile_services_detail调用,合并函数列入「旧对象清理」。
0x05 开发方案 —— SaaS 状态映射与旧链路下线
a. 服务状态
flowchart LR
Q["QueryTopoNodeResource"] -->|"nodes + heartbeat"| L["ServiceHandler<br/>.list_services()"]
L -->|"include_data_status=True"| B["ServiceHandler<br/>.build_service_data_status()"]
B -->|"log"| R["ServiceLogHandler<br/>.list_available_related_services()"]
R --> LSR["LogServiceRelation"]
R --> K8S["APP_SERVICE_K8S_RELATED_LOG_INDEXES_KEY"]
R --> SNAP["APM_RELATED_LOG_INDEX_STATUS_KEY"]
L -->|"nodes"| SA["ServiceListAsyncResource<br/>._get_data_status_mapping()"]
SA -->|"data_type / filter_keys"| B
L -->|"services + data_status"| SL["ServiceListResource"]| 变更点 | 修改内容 |
|---|---|
[Add] apm_web.handlers.service_handler.ServiceHandler.build_service_data_status(app, nodes, data_type=None) | 按独立服务观测窗口生成三态,支持按 data_type 计算单列,日志状态按需读取后台关联索引状态快照 |
[Add] apm_web.handlers.log_handler.ServiceLogHandler.list_available_related_services(app, service_names) | 按应用批量读取服务级与全局 LogServiceRelation、K8S 关联日志缓存,比对后台索引集状态快照,返回存在可用关联索引的服务集合 [1] |
[Change] apm_web.metric.resources.ServiceListAsyncResource._get_data_status_mapping | [a] 从 list_services 读取节点后复用统一心跳映射[b] 无筛选计数时只计算请求服务,单列只计算对应类型 [c] 需要筛选计数时计算当前服务全集的全部类型 |
[Keep] apm_web.metric.resources.ServiceListResource | 同步状态列及 StatisticsApplyModule、StatisticsHaveData 的筛选规则不变,输入换为心跳映射结果 |
def build_service_data_status(
app: Application, nodes: list[dict[str, Any]], *, data_type: str | None = None
) -> dict[str, dict[str, str]]:
# topo_key -> {telemetry_data_type: DataStatus},与旧 get_service_data_status_mapping 结构一致,例如:
# {"order": {"trace": "normal", "metric": "normal", "log": "no_data", "profiling": "disabled"}}
...
def list_available_related_services(app: Application, service_names: list[str]) -> set[str]:
# 返回存在可用关联索引的服务;全局关联可用时,service_names 全部计入
...| 日志来源 | 处理 |
|---|---|
服务级手动关联(is_global=False) | 按标签判定 |
应用级全局关联(is_global=True) | 按标签判定,对应用下全部服务生效 |
K8S 关联日志缓存(ApmCacheKey.APP_SERVICE_K8S_RELATED_LOG_INDEXES_KEY) | 按标签判定 [2] |
| APM 自身日志结果表 | ES 聚合改为后台心跳 |
- [1] 索引集
tags含下方「无数据」内置标签即判为不可用;逐个检查该服务的全部关联索引集,tags为空的索引集不判为可用。 - [2] 缓存结构为
service_name -> [{"index_set_id", "bk_biz_id", "updated_at"}],由cache_application_k8s_related_indexes周期写入,状态读取见里程碑 3。
[
{
"space_uid": "",
"name": "无数据",
"value": "",
"color": "red",
"tag_id": 4,
"tag_type": "inner"
}
]b. 应用状态与服务数量
| 变更点 | 修改内容 |
|---|---|
[Keep] apm_web.models.application.Application.set_data_status | 应用四态仍由 get_data_count 判定,不接入心跳 |
[Change] apm_web.models.application.Application.set_service_count_and_data_status | 仅写服务数量,移除服务状态缓存写入 |
[Keep] apm_web.models.application.Application.{trace,profiling,metric,log}_data_status | 字段名和三态取值不变 |
[Keep] apm_web.metric.resources.ServiceListResource.perform_request | 请求侧继续回写 service_count |
c. 请求侧状态检查
| 入口 | 修改内容 |
|---|---|
apm_web.meta.resources.ApplicationInfoByAppNameResource | 删除 have_data() 覆盖,直接返回库内 Application.trace_data_status [1] |
apm_web.metric.resources.ApdexQueryResource、AlertQueryResource | 删除 have_data() 前置检查,由主查询决定所选时间窗内的数据与空值 [3] |
apm_web.service.resources.ServiceInfoResource.get_profiling_info | 复用已获取的拓扑节点,按 Profiling 开关与 heartbeat.profiling.last_data_at 的统一映射返回当前状态,移除 Doris count(*) |
apm_web.profile.resources.ListApplicationServicesResource | normal / no_data 分类改读 Application.profiling_data_status,两组均保留历史服务及采样类型,服务明细与 eBPF 查询保留 |
- [1] 此接口原先按传入时间窗覆盖状态,改造后返回应用当前状态;慢 Trace
8bfa95a9cf8553981b0105f1e89f2064(pvpesport.battle)在原路径耗时约12.8 s,最终报 VM 并发超限。 - [2]
apm_web.metric_handler.RequestCountInstance继续用于请求量列和图表,不随have_data删除。 - [3] Apdex 无数据时保留空值柱点;告警无数据时保留零值柱点,历史告警不因请求量为空而短路。
d. 旧对象清理
| 删除对象 | 原用途 |
|---|---|
apm_web.handlers.service_handler.ServiceHandler.get_service_data_status_mapping | 重算服务级状态 |
apm_web.handlers.application_handler.ApplicationHandler.have_data | 按传入时间窗重查应用 Trace 是否有数据,调用方移除后无引用 |
apm_web.handlers.log_handler.ServiceLogHandler.get_log_count_mapping | 供上述方法计算日志状态 |
apm_web.handlers.log_handler.ServiceLogHandler.get_label_values_in_table | 供上述日志方法聚合服务名 |
apm_web.constants.ApmCacheKey.APP_SERVICE_STATUS_KEY | 缓存服务状态 [1] |
apm_web.handlers.service_handler.ServiceHandler._combine_profile_trace_service | 合并拓扑与性能分析服务 |
apm_web.models.application.Application.doc_count | 按 es_retention 查询请求量,无调用方 |
apm_web.metrics 的 SERVICE_DATA_STATUS、REMOTE_SERVICE_DATA_STATUS、COMPONENT_DATA_STATUS | 以请求量判断状态的遗留模板,无调用方 |
- [1] 周期任务写入该缓存时未传
timeout,模块启停和无数据周期变更也不触发失效。
e. 保留的查询
| 入口 | 用途 |
|---|---|
apm_web.meta.resources.DataStatusResource | 按用户指定时间窗检查数据量 |
apm_web.meta.resources.StorageInfoResource、StorageStatusResource | 展示存储配置与健康度 |
apm_web.profile.doris.querier.QueryTemplate.get_sample_info | 返回查询窗口内真实的 last_report_time |
apm_web.handlers.service_handler.ServiceHandler.get_service_metric_range_mapping | 查询调用次数、错误率、平均耗时 |
apm_web.metric_handler.ServiceFlowCount | 查询服务拓扑与调用关系 |
apm_web.handlers.backend_data_handler.telemetry_handler_registry(...).get_data_count | 应用级四态、数据接入检查与存储状态展示 |
api.apm_api.query_profile_services_detail | 性能分析页面的服务明细 |
f. 兼容与迁移
| 阶段或场景 | 处理 |
|---|---|
| 首次观测到服务前 | 对应数据类型的心跳子键缺失,返回 NO_DATA 并记录缺失计数日志 |
| 同步与异步请求间服务被删除或因关闭性能分析而不再展示 | 异步响应为请求中的缺失服务补齐默认三态,筛选计数只统计重新读取的当前服务全集 |
| 新旧对照 | 同一批应用按新旧窗口分别对照,区分独立一小时窗口、日志双来源及缓存滞后造成的状态差异 |
| 读取切换 | 先切换读取,再单独删除旧对象,切换时保留旧函数 |
| eBPF 应用 | 不补齐调用链发现,trace 状态回退为无数据,指标与日志发现不受影响 |
g. 关联日志状态后台刷新
每 5 min 刷新非 AI 应用所关联业务的全部日志索引状态,页面只读缓存。
| 变更点 | 职责 |
|---|---|
[Add] apm_web/tasks.py | 新增 refresh_related_log_index_status,按下方伪代码过滤应用、汇总关联并刷新缓存 |
[Add] config/celery/config.py | 注册任务,每 5 min 执行 |
[Add] apm_web/constants.py | 在 ApmCacheKey 定义 APM_RELATED_LOG_INDEX_STATUS_KEY |
[Change] apm_web/handlers/log_handler.py | list_available_related_services 按当前关联关系读取缓存,供日志状态和筛选计数共用 |
前置过滤与刷新
apps = Application.objects.filter(is_enabled=True).exclude(app_name__startswith="bkapp_ai0us0")
targets = {(app.bk_tenant_id, biz_id) for app in apps for biz_id in related_biz_ids(app)}
for tenant_id, biz_id in targets:
with tenant_context(tenant_id):
try:
indexes = get_biz_index_sets_with_cache(bk_biz_id=biz_id)
except Exception:
continue # 保留旧缓存,不续期
statuses = [{"index_set_id": i["index_set_id"], "available": index_available(i)} for i in indexes]
write_cache(tenant_id, biz_id, {"checked_at": now(), "indexes": statuses}, ttl=cache_ttl)- [1]
related_biz_ids(app)汇总服务级、应用级手动关联与 K8s 关联的业务 ID,无关联返回空集合。 - [2]
get_biz_index_sets_with_cache返回普通索引集列表,转换并缓存该业务全部索引的状态,不按关联索引 ID 过滤。
缓存结构
{
"checked_at": 1800000000,
"indexes": [
{
"index_set_id": 101,
"available": true
},
{
"index_set_id": 102,
"available": false
}
]
}- [1]
ApmCacheKey.APM_RELATED_LOG_INDEX_STATUS_KEY=apm:related_log_index_status:{bk_tenant_id}:{bk_biz_id}。 - [2] KEY 按租户和关联日志所属业务隔离,
checked_at记录本轮缓存刷新完成的 Unix 秒时间戳。 - [3] 缓存有效期默认
15 min(可配置),缺失或过期时仅按上报心跳判定日志状态。
0x06 验收与验证
a. 测试门禁
uv run pytest apm/tests packages/apm_web/testsb. 补充测试用例
后台心跳生产基础能力已随里程碑 1 合入 apm/tests。来源补偿与 SaaS 状态映射补充以下测试:
| 用例 | 断言重点 |
|---|---|
test_heartbeat_observation_repairs_source | [a] last_data_at 非空时追加来源[b] 空值只推进 checked_at[c] 不创建节点、不刷新 updated_at |
test_heartbeat_fresh_uses_service_lookback | 一小时边界返回 NORMAL、超过一秒返回 NO_DATA,不受应用无数据周期影响 |
test_heartbeat_fresh_uses_configured_lookback | 服务窗口可配置,按配置值验证精确边界 |
test_service_status_with_discovery_and_cache_delay | 发现排队叠加 API 五分钟及本地六十秒缓存时,状态与缓存刷新时序符合预期,其他 API 缓存不变 |
test_build_service_data_status_disabled_first | 数据类型开关关闭时返回 DISABLED,不受心跳影响 |
test_build_service_data_status_log_dual_source | 心跳为空但关联索引无 NO_DATA 标签时返回 NORMAL |
test_related_index_available_empty_tags | 索引集标签列表为空时不判为可用 |
test_list_services_excludes_profiling_only_when_disabled | 关闭性能分析后过滤仅由性能分析发现的节点 |
test_service_list_async_no_remote_query | 状态列不再发起 UnifyQuery、es_query_dsl 与性能分析接口调用 |
test_related_log_status_refresh | [a] 排除 AI、停用和无关联应用,缓存目标业务全部索引状态 [b] 三类关联业务取并集,同租户业务每轮调用一次缓存查询函数,跨租户隔离 [c] 失败保留旧缓存且不续期,正常空结果清除旧状态 |
test_related_log_status_snapshot_only | 冷缓存与过期快照均不回源,关联删除立即生效,心跳可独立判正常,日志列与全量筛选计数一致 |
c. 回归口径
| 维度 | 口径 |
|---|---|
| 状态一致性 | 同一应用逐服务对照新旧状态,差异需归因到独立服务窗口、日志来源或其他已声明的语义调整 |
| 状态时效 | 以 last_data_at 和服务观测窗口判定状态,验证一小时边界、发现排队与两级缓存组合,说明更长延迟仍可能误判 |
| 服务全集 | 服务数量、分类、语言、排序与分页结果与切换前一致 |
| 筛选项 | have_data 与 apply_module 的计数基于全量展示服务,不随分页变化 |
| 性能 | 记录 service_list 与 data_status 的端到端耗时与下游调用数,状态计算不再调用 UnifyQuery、ES 聚合和性能分析明细接口,关联日志冷缓存也不调用日志平台,拓扑读取仍可能产生远程调用 |
| 后台开销 | 记录四个发现器的单轮耗时与查询数,确认新增查询不突破现有 10 min 调度窗口 |
0x07 实施进展
| 时间 | 结论性进展 |
|---|---|
2026-10-27 16:00 | [a] Trace 按应用和数据源记录检查边界,最长补扫一小时,单次折叠按 kind DESC, end_time DESC 排序,接受有损选择[b] 检查位置使用固定 Trace 缓存模板,缓存生成入口无改动,撤回 NodeDiscover 批次异常扩展并移除截断分支 [c] 调度先筛选再加锁,日志保留生命周期、三段延迟与有界摘要,删除重复时间戳、批次调参和过滤计数,不保留 DEBUG [d] 只删 QueryHelper 重复查询体,保留 UNIFY_QUERY,缓存无变化静默,保留增删改摘要、清理原因与十个 ID 样本及写入失败上下文 [e] PR #12958 的相关回归 144 项通过(含缓存诊断与共享查询层回归),排除的 7 项已在未修改基线复现失败或数据库环境问题[f] 未验证真实存储集成和生产负载,本机 PreCI 不可用 |
2026-10-26 11:00 | [a] 旧状态查询与缓存清理 PR #12920 已合入,合并提交为 969ef23c0a549a43be827aa288c3bb68ffde10fe[b] 性能分析状态读取 PR #12893 已于 2026-10-22 合入,合并提交为 9a2b831afc758a05d809c252347c10d2a4ffe395[c] 本期后台心跳生产、服务全集与状态读取、应用详情与图表探测移除、性能分析状态改造及旧对象清理均已合入;真实存储集成与端到端性能未在本次 review 中独立验证 |
2026-10-26 11:00 | [a] 旧状态查询与缓存清理 PR(#12920,547f8afc7fca7d9e8134b45d2d78f334d4ae252d)完成 review,未发现阻塞问题,删除入口、状态模板与缓存键均无残余引用[b] 应用级数据量判定、真实指标、图表及 Profiling 明细查询保留,测试桩改为拦截底层查询 [c] 相关 118 项回归测试在 SQLite 内存表与 LocMemCache 隔离环境通过,Ruff 与 diff 检查通过;未独立复跑 paas3、MySQL 或真实存储集成验证 |
2026-10-24 00:00 | 新增里程碑 3,方案待开发:关联发现与状态刷新独立调度,页面移除索引集冷缓存回源,保留上报心跳和三类关联来源 |
2026-10-22 16:00 | [a] 性能分析状态读取 PR(#12893,130f6cf50625dba738364f90df8f26dd0c8cf8c7)完成 review,未发现阻塞问题,已 Approve[b] 服务详情复用拓扑节点的统一心跳映射,应用列表按持久化状态分组并保留历史服务及采样类型,真实采样与图表查询保持 [c] 新增 19 项回归测试在 SQLite 内存表与 LocMemCache 隔离环境通过,Ruff 与 diff 检查通过;已核对 PR 验证截图,未独立复跑 paas3、MySQL 集成或压测 |
2026-10-21 10:00 | [a] 应用详情与图表请求侧探测移除 PR(#12851)已合入,合并提交为 6032fcbbb3a7a54f857ac6328f128c17b823b5d6[b] 应用详情读取持久化状态,Apdex 与告警按所选时间窗直接查询,空值柱点与历史告警展示符合方案 [c] 新增 12 项回归测试在 SQLite 内存表与 LocMemCache 隔离环境通过,Ruff 检查通过;已核对 PR 的 paas3 截图,未独立复跑环境或压测 |
2026-10-20 15:00 | [a] 服务列表心跳读取 PR(#12830,42d9d93401f0e743ed405a3083b2b66e6fd6e971)复查通过并 Approve,上轮两条线程已 resolve[b] 服务窗口独立配置,默认一小时;拓扑 API 缓存五分钟、本地缓存六十秒,无筛选计数时只计算请求服务 [c] 相关 87 项测试在 SQLite 内存表与 LocMemCache 隔离环境通过,GitHub 标签及 CLA 检查通过,未独立复跑 paas3 或真实存储探测 |
2026-10-19 17:00 | [a] 复查 #12830,版本 eb847954,已发布 review,包含 1 个 P1、1 个 P2 行内评论[b] 用户确认服务状态改为独立最近 60 min 观测窗口,拓扑 API 缓存缩短至 5 min,本地 60 s LRU 保留,应用级无数据周期及关联日志判据保持各自口径,PR 尚未实现[c] 实际缓存包装器与状态函数模拟复现持续上报误判无数据,列为 P1;无筛选计数的异步请求仍计算全量服务,列为 P2 [d] 上一轮 74 项测试在 SQLite 内存表与 LocMemCache 隔离环境通过,本轮补充函数语义与缓存时序模拟,尚未验证调整后代码及真实环境性能 [e] 历史慢请求中移除的状态探测耗时约 4.26 s,不能声明所有请求同幅加速或所有远程调用归零 |
2026-10-15 18:00 | [a] 服务列表 review(#12764)确认全集改为拓扑节点加尚未发现的手动服务,关闭性能分析时去掉 source == ["profiling"] 的节点[b] 手动服务写入独立结果列表,不再改到拓扑缓存 [c] 服务状态仍沿用原有重算和缓存,心跳映射留在后续慢接口改造 |
2026-10-14 16:00 | [a] last_data_at 非空时为已有节点追加对应 source[b] 仅推进 checked_at 时不补来源[c] 写入不创建节点、不刷新 updated_at |
2026-10-12 15:00 | [a] 里程碑 1 PR 完成 review,实现与方案对齐 [b] 心跳只记录本轮观测到的服务,写入改为行锁合并,撤回「读取紧贴写入」吸收丢更新的约束 [c] 调用链兜底轮跳过入口接口发现,避免下游接口被误判为入口 [d] 性能分析服务按普通服务并入拓扑,发现任务保持串行,不改 delay() 派发 |
2026-09-29 11:00 | ApplicationHandler.have_data 三个调用方全部移除后列入删除:应用详情改读库内 trace_data_status,Apdex 与告警图去掉前置短路,由主查询返回空 |
2026-09-17 14:00 | [a] 确立收敛判据:「查一次数据存储只为取一个 bool」的调用统一归心跳,返回真实数据量或用户自选时间窗的查询保留实时[b] 完成 apm_web 全量探测点盘查,补入四处此前遗漏的请求侧探测:ApplicationInfoByAppNameResource 覆盖 trace_data_status、ApdexQueryResource / AlertQueryResource 查图前置短路、ServiceInfoResource.get_profiling_info 的 Doris count(*)、ListApplicationServicesResource 的应用有无数据分类[c] 慢 Trace 8bfa95a9cf8553981b0105f1e89f2064 归因为 have_data() → RequestCountInstance → VM 整窗 sum_over_time(bk_apm_count[7d]),约 12.8 s 并以并发超限报错,与服务列表 data_status 同源同病[d] have_data 定为换源而非删除;Apdex 与告警图的前置短路直接删除,让主查询自然返回空[e] 清理两处以请求量冒充数据状态的遗留: Application.doc_count、metrics.py 三个 *_DATA_STATUS 模板,均全仓无调用方 |
2026-09-16 15:00 | [a] 心跳载体定为 TopoNode.heartbeat JSON 列,按数据类型分子键,撤回独立表方案。写入沿用发现器现有的读改写加 bulk_update,不引入 JSON_SET[b] 确认同一应用的调用链与指标、日志发现由同一分片公式派发到同一分钟,读改写必然重叠,用「读取紧贴写入」和「新鲜度阈值放宽一个发现周期」两条约束吸收单次丢更新,代价是无数据判定延后一个周期 [c] 应用级状态确认改为「任一服务有数据」,切换前记录运营基线 [d] 日志状态保留关联索引来源。查清日志平台没有索引集数据状态字段, green 只存在于 ES 索引健康度,判据落到 NO_DATA 内置标签[e] eBPF 应用的调用链心跳本期不补齐,转为已知回退 |
2026-09-16 12:00 | [a] 基于慢 Trace aa9a057ba090042a43eeb6012be6e024 完成耗时归因:服务全集枚举两次、四类状态串行重算,资源内部耗时 5.149 s[b] 确认性能分析已有数据库心跳( ProfileService.last_check_time),调用链与指标发现不记录数据时间,日志无发现器。现有 CachedDiscoverMixin 保活心跳语义不同且不覆盖 TopoNode,不可复用[c] 确定收敛点为服务数据心跳,四发现器单写、拓扑接口单读。性能分析服务并入拓扑后服务全集收敛到单一事实源 [d] 明确日志状态双来源边界:心跳覆盖 APM 日志结果表,关联索引只含服务级手动关联 [e] 完成应用级状态字段消费方梳理,确认字段契约不变、口径变更会传导到每日快照与运营统计 [f] 定位三处需一并修复的缺陷:指标发现窗口指向未来、日志 terms 聚合缺少 size、关联索引判据只取首个且空标签判为可用 |
0x08 参考
- <源码> apm/core/discover/base.py DiscoverContainer / TopoHandler
- <源码> apm/core/discover/metric/service.py ServiceDiscover
- <源码> apm/core/discover/profile/service.py ServiceDiscover
- <源码> apm/task/tasks.py 发现任务调度
- <源码> apm/models/topo.py TopoNode
- <源码> packages/apm_web/handlers/service_handler.py ServiceHandler
- <源码> packages/apm_web/handlers/log_handler.py ServiceLogHandler
- <源码> packages/apm_web/metric/resources.py ServiceListAsyncResource
- 关联 issue:APM 关联容器日志采集项慢接口优化
0x09 版本锚点
| 状态 | 分支 | 里程碑 | PR |
|---|---|---|---|
| ✅ | feat/apm_application_detail_loading_opt/#1010158081137600836 | 里程碑 1:APM 服务心跳统一维护 | TencentBlueKing/bk-monitor #12629 |
| ✅ | feat/apm_service_heartbeat_backfill_source_field/#1010158081139040371 | 里程碑 1 补充:心跳观测回补 source | TencentBlueKing/bk-monitor #12790 |
| ✅ | feat/apm_service_list_query_topo_node/#1010158081138934112 | 服务列表统一读取拓扑节点 | TencentBlueKing/bk-monitor #12764 |
| ✅ | feat/apm_service_list_status_from_heartbeat/#1010158081139048589 | 服务列表状态读取后台心跳 | TencentBlueKing/bk-monitor #12830 |
| ✅ | feat/apm_app_detail_charts_remove_data_probe/#1010158081139171253 | 应用详情与图表移除请求侧数据探测 | TencentBlueKing/bk-monitor #12851 |
| ✅ | feat/apm_profiling_service_heartbeat_app_status_group/#1010158081139232058 | 性能分析服务心跳读取与应用状态分组 | TencentBlueKing/bk-monitor #12893 |
| ✅ | feat/apm_service_status_remove_legacy_query_cache/#1010158081139342361 | 旧服务状态查询、缓存与模板清理 | TencentBlueKing/bk-monitor #12920 |
| 🔄 | <branch_name> | 里程碑 3:关联日志状态后台刷新 | 待创建 |
| 🔄 | fix/codex-trace-discovery-catchup/#1010158081165814054 | Trace 服务发现延迟补扫与任务日志 | TencentBlueKing/bk-monitor #12958 |