Block Events数据覆盖:一个静默Bug的排查过程
一个不会报错的Bug
SmartInspector 有两条卡顿检测的数据通道:
- Perfetto SQL :BlockMonitor 通过
Trace.beginSection("SI$block#MsgClass#250ms")写入 atrace 切片,Python 端用 SQL 查出来,有精确的纳秒级时间戳 - 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
关键设计决策:
- SQL 为主:遍历 SQL 事件列表,保证每个 SQL 事件都保留(有精确时间戳)
- 精确匹配优先 :按
(msg_class, dur_ms)匹配,避免错误关联 - 模糊匹配兜底 :atrace 截断类名时,精确匹配可能失败,按
dur_ms模糊匹配 - 不丢弃 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...