Skip to content

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 耗时构成 ​

子调用(按发生顺序)耗时用途
QueryTopoNodeResource401 ms服务全集:拓扑节点
QueryProfileServicesDetail404 ms服务全集:性能分析独有服务
GraphUnifyQueryResource × 41211 ms指标状态:bk_apm_count 四路分组
DetailApplicationResource162 ms读日志数据源配置
ESQueryDslResource × 21607 ms日志状态:两个服务名字段的 terms 聚合
unify_query314 ms调用链状态:apm_service_to_apm_service_flow_count
QueryProfileServicesDetail962 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. 核心对象模型 ​

mermaid
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 按数据类型记录服务最后一条数据的时间,以及最近一次观测到该服务的检查完成时间:

json
{
  "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_atint最后一条数据的时间(秒),新旧时间取较大值
<data_type>.checked_atint最近一次观测到该服务的检查完成时间(秒),新旧时间取较大值

心跳只记录本轮观测到的服务。服务是稀疏上报的,一轮窗口查询无法证明其他服务没有数据,服务停止上报体现为 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 读取心跳与后台关联日志状态快照映射,页面请求不查询日志平台索引集列表:

text
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]。
mermaid
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 核心逻辑:

python
@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 串联投递、开始和结束日志
python
# 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]
python
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 dimensions
promql
max 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_servicehttp:{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,兼容发布前已入队的旧任务;由发现器决定窗口切分方式
python
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 编译为 ES collapse,与调用链兜底轮同一机制,每个服务返回时间最晚的一条日志,其 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 的缓存不变。

json
{
  "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. 服务状态 ​

mermaid
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 的筛选规则不变,输入换为心跳映射结果
python
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。
json
[
  {
    "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.ListApplicationServicesResourcenormal / 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.pylist_available_related_services 按当前关联关系读取缓存,供日志状态和筛选计数共用

前置过滤与刷新

python
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 过滤。

缓存结构

json
{
  "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. 测试门禁 ​

bash
uv run pytest apm/tests packages/apm_web/tests

b. 补充测试用例 ​

后台心跳生产基础能力已随里程碑 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:00ApplicationHandler.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 参考 ​

0x09 版本锚点 ​

状态分支里程碑PR
✅feat/apm_application_detail_loading_opt/#1010158081137600836里程碑 1:APM 服务心跳统一维护TencentBlueKing/bk-monitor #12629
✅feat/apm_service_heartbeat_backfill_source_field/#1010158081139040371里程碑 1 补充:心跳观测回补 sourceTencentBlueKing/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/#1010158081165814054Trace 服务发现延迟补扫与任务日志TencentBlueKing/bk-monitor #12958