Skip to content

优化首页 TraceID 全局搜索的预计算延迟 —— 实施方案

基于 README.md 制定。

0x01 调研与约束

a. 关键事实

  • 预计算结果表为租户级共享,单次查询天然覆盖全租户,但写入有分钟级延迟。
  • 原始 Trace 数据按应用粒度落表(Application.trace_result_table_id),数据落库即可查,但单表只覆盖一个应用。
  • apm_web.models.UserVisitRecorduser_visit_record 装饰器写入,覆盖 service_list / service_detail / trace_list 等 view。
  • (bk_biz_id, app_name) 聚合可得到用户的"应用访问次数",created_bycreated_at 均有索引。
  • Application 模型按 (bk_tenant_id, bk_biz_id) 过滤即可拉到候选,配合 exclude(trace_result_table_id="") 排除空表。

b. 关键决策

决策点结论理由
预计算与直查关系并行收集,按 application_id 去重累计预计算覆盖老数据广度,直查覆盖延迟期;里程碑 2 不再“先非空者赢”
候选业务范围当前业务、默认业务、UserVisitRecord 出现的业务三类去重并集兼顾用户主场景,避免拉全量业务造成雪崩
访问数据源UserVisitRecord,废弃 FUNCTION_ACCESS_RECORD.apm_service后者是服务访问记录而非应用访问,与 Trace 检索语义不匹配
应用权限过滤不前置过滤,命中后由前端跳转时处理与现状一致,简化实现,避免无 IAM 的高频损耗
候选应用规模TopN 默认 15,并发查询与现有预计算多 cluster 并发量级一致
直查时间窗口7d与预计算路径对齐,便于结果合并语义统一
Trace 返回上限K=3,Trace 专用常量,不复用 page_size多应用命中只需少量候选;编码成本可忽略
Trace 收集超时绝对 5s deadline(time.monotonic()Searcher 单项 get(timeout=5) 对齐;K 填不满时不扫完全部候选

c. 边界与风险

  • bk_biz_id 入参缺省时跳过"当前业务"来源,仅用默认业务与访问过的业务。
  • 候选业务去重并集后才作为 ORM bk_biz_id__in 输入,避免重复扫描。
  • Application 查询必须带 bk_tenant_id
  • 直查的 trace_id__eq 必须配合 time_field=OtlpKey.END_TIME,否则会与预计算字段语义混淆。
  • UserVisitRecord 访问次数可达数百次,必须 log1p 归一压扁高频段,避免极端用户的常用应用碾压业务意图。

0x02 方案主干

a. 双路径并行收集

mermaid
flowchart TD
    Q["TraceSearchItem.iter_search<br/>(trace_id, bk_biz_id?)"] --> P["Path A<br/>预计算多 cluster 并发查询"]
    Q --> R["Path B<br/>构造候选应用集合"]
    R --> S["TopN 应用并发<br/>直查原始 Trace 表"]
    P --> M["命中队列<br/>按 application_id 去重"]
    S --> M
    M -- "累计 1→2→3" --> O["流式 yield 完整快照"]
    M -- "K=3 / 耗尽 / 5s" --> X["trace_stop<br/>停止提交新查询"]

Path A 与 Path B 并行产出命中,互不依赖;聚合层按 application_id 去重后流式推送累计快照。

b. 候选应用打分

按"访问过 / 未访问"分层赋分,访问过的层在排序上恒定优于未访问层:

text
score = APP_WEIGHT_CURRENT + log1p(visit)                                                       if visit > 0
      = BIZ_WEIGHT_CURRENT * is_current + BIZ_WEIGHT_DEFAULT * is_default + APP_WEIGHT_HAS_SERVICE * has_service  otherwise
常量作用
BIZ_WEIGHT_CURRENT1未访问层:当前业务加分
BIZ_WEIGHT_DEFAULT1未访问层:默认业务加分
APP_WEIGHT_HAS_SERVICE0.5未访问层:有服务应用加分
APP_WEIGHT_CURRENTBIZ_WEIGHT_CURRENT + BIZ_WEIGHT_DEFAULT + APP_WEIGHT_HAS_SERVICE = 2.5访问过层基础分,确保大于未访问层最大值

关键不变量:访问过的最低分 2.5 + log1p(1) ≈ 3.19 > 未访问的最高分 2.5,分层严格保序。

排序规则:访问过按 log1p(visit) 排序,未访问按业务来源与服务数排序,同分按 application_id 升序。

对照(典型场景):

应用visit业务score
任意应用100任意2.5 + 4.62 ≈ 7.12
任意应用1任意2.5 + 0.69 ≈ 3.19
未访问0当前 + 默认 + 有服务2.5
未访问0当前 / 默认1
未访问0其他0

c. 直查协议契约

直查复用 BK_APM 数据源构造,关键差异点:

字段预计算路径直查路径
table_idDataLink.pre_calculate_config.cluster[*].table_nameApplication.trace_result_table_id
time_fieldPreCalculateSpecificField.MIN_START_TIMEOtlpKey.END_TIME
filtertrace_id__eqtrace_id__eq
valuesBIZ_IDAPP_NAMEtrace_id(仅判存在)
limit51
time_range7d7d

直查命中后,bk_biz_idapp_nameapplication_id 由调用侧的 Application 实例直接提供,不依赖查询返回值。

d. 并发与超时

里程碑 1(已落地):直查与双轨使用 ThreadPool + _first_truthy_concurrent,首个非空即结束。

里程碑 2(本方案):

  • Path A / Path B 持续产出命中;活跃并发上限仍为预计算 5、原始表 8
  • Searcher 保持流式汇聚:output_queue.get(timeout=5),语义对齐现状 results.next(timeout=5)queue.Empty 按超时跳过,不引入 item_timeout
  • TraceSearchItem 使用绝对 deadline = time.monotonic() + TRACE_SEARCH_TIMEOUTTRACE_SEARCH_TIMEOUT=5,与外层单项等待对齐。
  • 停止信号只阻止尚未发起的新查询;已发出的 UQ 请求依赖下游硬超时退出,线程池无法强杀。
  • 任一路径异常仅 logger.exception,按 miss 处理,不向上抛错。

e. 不变量

  • 预计算路径查询语义与现状一致,可独立回退。
  • 直查 miss 不影响预计算命中返回。
  • 输出 item 的字段集合与现有 TraceSearchItem 完全相同;流式仅改变推送次数。
  • 候选业务集合在 bk_biz_id 缺省、DEFAULT_BIZ_ID 缺省、UserVisitRecord 无记录时退化为空集,此时 Path B 直接返回空,不抛错。
  • SSE 协议保持 start → data* → endevent: end 不携带结束原因(本期不做)。

f. 流式 TopK 查询架构

职责分层:

职责不感知
Searcher[a] 并行调度并流式 yield 快照
[b] get(timeout=5) 等待下一条队列消息
Trace 的 K、候选耗尽、打分
TraceSearchItem[a] 双路径收集并去重累计 K=3
[b] 内部绝对 5s deadline 收口
Searcher 内部队列实现
mermaid
flowchart LR
    A["Path A<br/>预计算集群"] --> H["命中队列"]
    B["Path B<br/>候选应用"] --> H
    H --> T["TraceSearchItem<br/>K=3 / 5s 收口"]
    T -- "1→2→3 完整快照" --> O["输出队列"]
    O --> S["Searcher<br/>get timeout=5"]
    S --> V["event_stream()<br/>SSE data"]
    T -. "K=3 / 耗尽 / 5s" .-> E["trace_stop.set()"]
    E --> A
    E --> B

TopN 是 Path B 的候选探测上限;K=3 是 Trace 最终返回上限。二者相互独立,且 K 不复用 page_size

TraceSearchItem 结束条件(任一即停):

text
hit_count == 3
or all_paths_done
or monotonic() >= deadline
or stop_event.is_set()
结束原因行为
K=3 已满立即 trace_stop,停止提交新查询;已 yield 的 1→2→3 快照保留
候选提前耗尽保留已有 0~2 条,正常结束
到达 5s保留已有 0~2 条,停止继续查,向 Searcher 发送完成信号
客户端断开 / 外层停止request_stop 传入 Trace;停止提交新查询

0x03 开发方案

a. 文件级落点

packages/monitor_web/overview/views.py

入口职责
SearchSerializer增加 bk_biz_id = IntegerField(required=False, allow_null=True)
SearchViewSet.list透传 bk_biz_idevent_stream 退出时关闭 Searcher.search() 迭代器以触发 request_stop

packages/monitor_web/overview/search.py · 调度层

入口职责
SearchItem.search保持现有一次性返回;普通子类签名同步 stop_event 后可忽略
SearchItem.iter_search默认包装 search():列表拆成逐条 yield;TraceSearchItem 覆盖为真正流式
Searcher.search消费 iter_search()get(timeout=5) 汇聚快照并 yield

packages/monitor_web/overview/search.py · TraceSearchItem

入口职责
iter_search启动双路、按 application_id 去重累计 K=3、按 Trace 5s deadline 收口并流式 yield
_aggregate_user_visits单次 GROUP BY 查询 UserVisitRecord,输出 (bk_biz_id, app_name) → count
_collect_candidate_apps候选业务并集(当前 ∪ 默认 ∪ 访问过) → 全量应用 → 统一打分截 TopN
_query_raw_apps_by_trace_id直查单应用 trace_result_table_idlimit=1 仅判存在
_query_precalc_apps_by_trace_id多 cluster 并发查询预计算表,由 _query_apps_by_trace_id 重命名,逻辑不变

b. 候选应用收集步骤

  1. 聚合最近 30 天访问次数:_aggregate_user_visits(username) → dict[(bk_biz_id, app_name), int]
  2. 候选业务并集:biz_ids = {visit.keys 的业务} ∪ {current?} ∪ {default?}
  3. 单次 Application.objects.filter(bk_tenant_id=..., bk_biz_id__in=biz_ids).exclude(trace_result_table_id="") 拉取候选应用。
  4. 对每个应用计算 score = access_score(visit) + biz_boost(app)(公式见 0x02.b)。
  5. score 降序、application_id 升序,截取前 TopN。

c. 类常量

常量默认值说明
RAW_QUERY_TOP_N15Path B 直查应用上限
TRACE_TOP_K3Trace 最终返回上限,不复用 page_size
TRACE_SEARCH_TIMEOUT5Trace 收集绝对超时,与 Searcher.get(timeout=5) 对齐
BIZ_WEIGHT_CURRENT1未访问层:当前业务加分
BIZ_WEIGHT_DEFAULT1未访问层:默认业务加分
APP_WEIGHT_HAS_SERVICE0.5未访问层:有服务应用加分
APP_WEIGHT_CURRENT2.5访问过层基础分(= BIZ_WEIGHT_CURRENT + BIZ_WEIGHT_DEFAULT + APP_WEIGHT_HAS_SERVICE,派生不可独立调)

d. 流式 TopK 核心流程

本节落实:Searcher 回到 get(timeout=5) 汇聚版;TraceSearchItemdeadline=5 自行收口。

变更点目标
[Add] SearchItem.iter_search()默认把 search() 的列表结果逐条 yield;普通搜索项无需改实现。
[Change] Searcher.search()[a] 消费 iter_search() 并写入输出队列
[b] get(timeout=5) 等待下一条,对齐现状 next(timeout=5)
[Change] SearchItem.search()签名新增 stop_event;普通子类忽略该参数,继续返回列表。
[Change] TraceSearchItem.iter_search()[a] 创建 Trace 专用 deadline=5trace_stop
[b] 去重并 yield 1→2→3 累计快照
[c] 满足结束条件后停止
[Change] TraceSearchItem._path_precalc()持续返回各预计算表命中;提交或发起查询前检查 trace_stopdeadline
[Change] TraceSearchItem._path_raw()持续返回 TopN 候选命中;提交或发起查询前检查 trace_stopdeadline
[Delete] TraceSearchItem._first_truthy_concurrent()删除“首个非空即结束”的公共收敛逻辑。
python
@classmethod
def iter_search(cls, ..., stop_event=None):
    result = cls.search(..., stop_event=stop_event)
    for snapshot in result or []:
        yield snapshot
python
def _consume_item(item):
    try:
        for snapshot in item.iter_search(..., stop_event=request_stop):
            if request_stop.is_set():
                break
            output_queue.put(snapshot)
    finally:
        output_queue.put(None)


with ThreadPool() as pool:
    try:
        for item in search_items:
            pool.apply_async(_consume_item, (item,))

        start_time = time.time()
        while unfinished and time.time() - start_time <= timeout:
            try:
                snapshot = output_queue.get(timeout=5)
            except queue.Empty:
                # 对齐现状 results.next(timeout=5) 的 TimeoutError
                logger.error("Searcher search timeout, query: %s", query)
                continue
            if snapshot is None:
                unfinished -= 1
                continue
            yield snapshot
    finally:
        request_stop.set()

Searcher 不读取 Trace 常量。Trace 在 5s 内自行 ITEM_DONE 后,外层 get(timeout=5) 不会误等。

python
deadline = time.monotonic() + TRACE_SEARCH_TIMEOUT  # 5s
trace_stop = threading.Event()

def _drain_path(path):
    try:
        for app in path:
            if stop_event.is_set() or trace_stop.is_set() or time.monotonic() >= deadline:
                break
            app_queue.put(app)
    finally:
        app_queue.put(None)

paths = [
    cls._path_precalc(..., stop_event=stop_event, trace_stop=trace_stop, deadline=deadline),
    cls._path_raw(..., stop_event=stop_event, trace_stop=trace_stop, deadline=deadline),
]

with ThreadPool(2) as pool:
    try:
        pool.map_async(_drain_path, paths)

        while (
            len(seen) < TRACE_TOP_K
            and unfinished_paths
            and not stop_event.is_set()
            and time.monotonic() < deadline
        ):
            remaining = deadline - time.monotonic()
            try:
                app = app_queue.get(timeout=min(0.2, remaining))
            except queue.Empty:
                continue
            if app is None:
                unfinished_paths -= 1
                continue
            if app.application_id in seen:
                continue

            seen[app.application_id] = app
            yield [{"type": "trace", "name": "Trace", "items": [
                cls._build_item(query, hit) for hit in seen.values()
            ]}]
    finally:
        trace_stop.set()

[Change] 双路径滑动窗口探测

两条路径共用滑动窗口,不使用一次提交全部候选的 imap_unordered

python
def _iter_hits(candidates, probe, max_workers):
    """先提交固定窗口;每完成一个任务再补一个候选;停止后不再提交。"""
    pending = []
    index = 0

    with ThreadPool(max_workers) as pool:
        while index < len(candidates) and len(pending) < max_workers:
            if stop_event.is_set() or trace_stop.is_set() or time.monotonic() >= deadline:
                break
            pending.append(pool.apply_async(probe, (candidates[index],)))
            index += 1

        while pending:
            if stop_event.is_set() or trace_stop.is_set() or time.monotonic() >= deadline:
                break
            done = next((f for f in pending if f.ready()), None)
            if done is None:
                time.sleep(0.05)
                continue
            pending.remove(done)
            hit = done.get()
            if hit:
                yield hit
            if index < len(candidates) and not (
                stop_event.is_set() or trace_stop.is_set() or time.monotonic() >= deadline
            ):
                pending.append(pool.apply_async(probe, (candidates[index],)))
                index += 1
路径candidatesprobemax_workers
Path BTopN 应用列表发起 UQ 存在性探测;异常按 miss8
Path A预计算 table_id 列表查询单 cluster;异常按 miss,不拖垮整条 Path A5

路径侧约束:

  • 提交任务与发起 UQ 前都必须检查 trace_stop / deadline
  • 停止后不再提交新候选;已发出的 UQ 请求依赖下游硬超时退出。
  • event: end 仍由 SearchViewSet.event_stream 在迭代结束后发送;超时与候选耗尽都走同一出口。

0x04 实施进展

时间结论性进展
2026-07-24 10:00[a] 里程碑 2 收口:K=3 + Trace 绝对 5s
[b] Searcher 回到 get(timeout=5) 汇聚版,不引入 item_timeout
[c] Trace 内部 deadline=5 与外层单项等待对齐
2026-05-06 16:00PR #10492 review 收口:预计算恢复 MIN_START_TIME,访问层基础分 + log1p,未访问层保留业务来源与服务数加权
2026-05-03 00:00里程碑 1 落地:双轨竞速 + 原始 Trace 直查 + UserVisitRecord 候选
2026-05-02 00:00PLAN 主干定稿:双轨并行、候选不前置权限过滤、log1p 归一加权

0x05 参考

0x07 版本锚点

状态分支里程碑PR
feat/apm_trace/#1010158081134011153里程碑 1:首页 TraceID 检索支持原始 Trace 低延迟通道TencentBlueKing/bk-monitor #10492
🔄<branch_name>里程碑 2:流式 TopK=3 + Trace 绝对 5s 超时收口待创建