Block Events数据覆盖:一个静默Bug的排查过程

Block Events数据覆盖:一个静默Bug的排查过程

一个不会报错的Bug

SmartInspector 有两条卡顿检测的数据通道:

  1. Perfetto SQL :BlockMonitor 通过 Trace.beginSection("SI$block#MsgClass#250ms") 写入 atrace 切片,Python 端用 SQL 查出来,有精确的纳秒级时间戳
  2. WebSocket:App 端 BlockMonitor 缓存了结构化的 BlockEvent(msgClass、durationMs、stackTrace),CLI 通过 WS 实时拉取

两条通道各有优势。SQL 通道有精确的 ts_ns 时间戳,但 atrace 有 127 字符截断限制,长类名会被截断。WS 通道有完整的调用栈,但没有 Perfetto 的时间戳。

理想情况下,应该把两者的数据合并------用 SQL 的精确时间戳,补上 WS 的完整调用栈。

但实际发生的是:WS 的数据直接覆盖了 SQL 的结果。5 个 SQL 事件 + 3 个 WS 事件,最终只得到 3 个事件,时间戳全是 0。

这就是一个静默 Bug:不报错、不崩溃、不抛异常,只是数据悄悄丢了。

数据是怎么丢的

看修复前的代码:

python 复制代码
# collector.py 修复前
if ws_events:
    merged = []
    for ev in ws_events:
        raw_name = f"SI$block#{ev.get('msgClass', 'Unknown')}#{ev.get('durationMs', 0)}ms"
        merged.append({
            "raw_name": raw_name,
            "ts_ns": 0,
            "dur_ms": ev.get("durationMs", 0),
            "stack_trace": ev.get("stackTrace", []),
        })
    summary.block_events = merged  # 直接覆盖!

问题在最后一行 summary.block_events = merged

summary.block_events 此前已经通过 collect_block_events() 从 Perfetto SQL 查出了数据,每条都有精确的 ts_ns 时间戳。但这段代码完全忽略了 SQL 数据,直接用 WS 数据替换。

丢掉了什么:

  • Perfetto SQL 查出的 5 个卡顿事件,每个都有纳秒级时间戳
  • 可以和帧渲染数据做时间轴对齐的能力
  • 事件数量可能不一致(SQL 和 WS 采集窗口不完全对齐)

为什么没被发现:

  • WS 数据有 stack_trace,看起来数据"完整"
  • 时间戳全是 0 不会报错,只是后续分析时做不了时间轴关联
  • 报告里的卡顿事件数量可能比实际少(WS 通道有时拿不到全部事件)

排查过程

这个 Bug 不是被测试发现,是被一次 code review 抓到的。

当时在做 Perfetto 采集优化,重新审视 collector.py 的数据流。看到这段代码时,问题很明显------summary.block_events 先被 SQL 查询填充,然后被 WS 数据无条件覆盖。两行赋值之间没有合并逻辑。

用 git blame 看,这段 WS 拉取逻辑是后来加上去的。最初只有 Perfetto SQL 一条通道,summary.block_events 只有 SQL 数据。后来加了 WS 通道作为补充数据源,但实现时直接替换而不是合并。

这种 Bug 的典型特征:增量开发时,后加的逻辑没有考虑已有数据

修复:合并而非覆盖

修复的思路很清晰------SQL 数据为主(有精确时间戳),WS 数据补充 stack_trace:

python 复制代码
def _merge_block_events(
    sql_events: list[dict],
    ws_events: list[dict],
) -> list[dict]:
    """合并两条通道的卡顿事件数据。
    
    SQL 有精确 ts_ns;WS 有完整 stack_trace。
    按 (msg_class, dur_ms) 匹配合并。
    """
    # 按 (msg_class, dur_ms) 索引 WS 事件,保留 stack_trace 最长的
    ws_index: dict[tuple[str, float], dict] = {}
    for ev in ws_events:
        key = (ev["msg_class"], ev["dur_ms"])
        if key not in ws_index or len(ev.get("stack_trace", [])) > len(ws_index[key].get("stack_trace", [])):
            ws_index[key] = ev

    # 也按 dur_ms 索引,做模糊匹配
    ws_by_dur: dict[float, list[dict]] = {}
    for ev in ws_events:
        ws_by_dur.setdefault(ev["dur_ms"], []).append(ev)

    merged = []
    matched_ws_keys: set[tuple[str, float]] = set()

    for sql_ev in sql_events:
        result = dict(sql_ev)

        # 从 SQL raw_name 提取 msgClass
        # 格式: SI$block#MsgClass#250ms
        name = sql_ev.get("raw_name", "")
        parts = name.split("#")
        sql_msg_class = parts[1] if len(parts) >= 3 else ""
        sql_dur_ms = sql_ev.get("dur_ms", 0)

        # 精确匹配
        key = (sql_msg_class, sql_dur_ms)
        ws_match = ws_index.get(key)
        
        # 模糊匹配:按 dur_ms 找未匹配的事件
        if not ws_match and sql_dur_ms in ws_by_dur:
            for candidate in ws_by_dur[sql_dur_ms]:
                cand_key = (candidate["msg_class"], candidate["dur_ms"])
                if cand_key not in matched_ws_keys:
                    ws_match = candidate
                    break

        if ws_match:
            matched_ws_keys.add((ws_match["msg_class"], ws_match["dur_ms"]))
            # WS 有更可靠的 stack_trace,覆盖 SQL 的
            if ws_match.get("stack_trace"):
                result["stack_trace"] = ws_match["stack_trace"]

        merged.append(result)

    # 未匹配的 WS 事件也保留(没有精确 ts_ns,但 stack_trace 有价值)
    for ev in ws_events:
        key = (ev["msg_class"], ev["dur_ms"])
        if key not in matched_ws_keys:
            merged.append({
                "raw_name": f"SI$block#{ev['msg_class']}#{ev['dur_ms']}ms",
                "ts_ns": 0,
                "dur_ms": ev["dur_ms"],
                "stack_trace": ev.get("stack_trace", []),
            })

    return merged

关键设计决策:

  1. SQL 为主:遍历 SQL 事件列表,保证每个 SQL 事件都保留(有精确时间戳)
  2. 精确匹配优先 :按 (msg_class, dur_ms) 匹配,避免错误关联
  3. 模糊匹配兜底 :atrace 截断类名时,精确匹配可能失败,按 dur_ms 模糊匹配
  4. 不丢弃 WS 独有事件:WS 有但 SQL 没有的卡顿事件也保留(ts_ns=0)

调用处改为一行:

python 复制代码
sql_events = summary.block_events or []
ws_list = [{"msg_class": ev.get("msgClass", "Unknown"), ...} for ev in ws_events]
merged = _merge_block_events(sql_events, ws_list)
summary.block_events = merged

同一个 Commit 里的另一个优化

修这个 Bug 时顺便解决了一个性能问题。SQL 通道里,卡顿事件要和 logcat 日志按时间戳关联来获取 stack_trace。原来的实现是 O(n×m) 的双重循环:

python 复制代码
# 修复前:O(n*m)
for block in block_slices:
    for log in log_entries:
        dist = abs(log["ts_ns"] - block_ts)
        if dist < best_dist:
            best_dist = dist
            best_match = log

改成 bisect 二分查找,O(n log n + m log m):

python 复制代码
# 修复后:O(n log n + m log m)
log_timestamps = sorted([log["ts_ns"] for log in log_entries])
for block in block_slices:
    idx = bisect.bisect_left(log_timestamps, block_ts)
    # 只检查 idx-1 和 idx 两个候选
    for candidate_idx in (idx - 1, idx):
        ...

卡顿事件数量通常不多(一次采集 5-20 个),logcat 条目可能几百条,性能差异不大。但作为通用做法,bisect 比双重循环更正确。

教训

1. 静默覆盖比崩溃更难排查

如果是 null pointer 报错了,立刻就能发现。但数据被覆盖了,程序照常跑,报告照常出------只是数据不完整。这种 Bug 的危险在于:你可能根本不知道数据丢了

2. 增量开发时要审视数据流

加新通道时,要问自己:已有数据怎么办?是合并、替换、还是丢弃?如果选择替换,有没有充分理由?

3. 多数据源的合并策略要显式声明

_merge_block_events 这个函数的文档字符串明确写了:"SQL data has precise ts_ns timestamps; WS data has stack_traces. Merge by matching on msgClass + dur_ms." 以后再有人改这段代码,不会踩同样的坑。

4. Code Review 比测试更适合抓这类 Bug

单元测试很难覆盖这种"数据被覆盖但程序不报错"的场景------你得先知道数据应该是什么样子,才可能写出断言。而 code review 时,看数据流走向,"赋值又赋值"的模式一眼就能发现。


本文基于 SmartInspector 项目的真实开发过程。项目地址:github.com/mufans/AppS...

相关推荐
TYKJ02316 小时前
CDN加速的原理,远不止缓存这么简单
后端·性能优化·图片资源
山峰哥16 小时前
从Explain到SQL优化:一次生产环境慢查询的完整调优复盘
大数据·数据库·sql·性能优化·深度优先·宽度优先
三无推导17 小时前
《OpenHands 安装部署教程:用 Docker 在本地快速跑通开源 AI 编码助手》
人工智能·python·docker·性能优化·开源·github
海南java第二人17 小时前
ClickHouse 性能优化完全指南:从数据模型到生产调优
clickhouse·性能优化
爱和冰阔落18 小时前
Linux 性能优化基石:全景拆解 PRI/NI 优先级算力争夺与 O(1) 调度算法精髓
linux·算法·性能优化
鸽芷咕18 小时前
KingbaseES系统视图与Hints调优:从诊断到性能优化的进阶之路
数据库·oracle·性能优化
L、2181 天前
CANN ops-transformer 仓库详解:Transformer 算子的底层实现与性能优化
深度学习·性能优化·transformer