云计算百科
云计算领域专业知识百科平台

实战|10 万行云函数日志的“考古式“排障:用腾讯云助手把偶发超时压缩成一条根因链

实战|10 万行云函数日志的"考古式"排障:用腾讯云助手把偶发超时压缩成一条根因链

分类专栏:腾讯云助手实战 / 可观测性
标签:腾讯云助手、云函数 SCF、日志服务 CLS、偶发故障、P99、根因分析、Python
摘要:偶发超时(0.3% 概率)是排障里最难的一类:图表看均值看不出来,抽样又抽不中。本文给出「日志聚合 → 尾部样本画像 → 特征对比表 → AI 归因」的考古式流程,把 10 万行日志收敛到一条可验证的根因链,附 CLS 拉取脚本、特征提取代码和回归验证方法。


0. 先说结论

偶发故障的排障难点不在"日志不够",而在"日志太多且没有对齐维度"。

正确的顺序是:

① 别先看日志内容,先算分位数 → 确认是"尾部问题"还是"整体退化"
② 别抽样,要"全量挑出坏样本" → 0.3% 的概率,抽样几乎必然抽不中
③ 别凭印象归因,要做"特征对比表" → 好样本 vs 坏样本,逐字段求差异
④ 最后才让 AI 上场 → 它的价值是把 12 条特征差异拼成因果链

按这个顺序,我们把一个"偶发 3s 超时"的问题从 10.4 万行日志收敛到了 3 个可验证的假设,最终定位到 数据库连接未复用 + 一条查询未走索引 + 冷启动叠加 三个因素的组合。

对照数据(修复前后,各观察 7 天):

指标修复前修复后
调用次数 1,043,782 987,411
超时次数(>3s) 3,127 12
超时率 0.30% 0.0012%
P99 耗时 3,180ms 412ms
平均耗时 178ms 164ms

注意最后一行:平均耗时几乎没变。 这就是为什么看均值永远找不到问题——P99 恶化了 7 倍,均值只动了 8%。


1. 问题描述与为什么常规手段失效

现象:云函数偶发超时。具体表现是:

  • 每天大约 300~500 次调用耗时超过 3 秒(触发告警阈值)
  • 占总量约 0.3%
  • 没有时间规律:不是整点、不是流量高峰、和发布无明显关联
  • 无法复现:手动触发 500 次,一次都没超时

三个"没有",直接排除了最常见的排查路径:

常规手段为什么失效
看监控大盘的均值 / 折线 0.3% 的异常对均值的影响 < 10%,被完全淹没
看流量高峰时段 无规律,不是流量问题
随机抽样日志 抽 200 条,预期命中 0.6 条。抽不中
本地/压测复现 复现概率 0.3%,需要跑 2000+ 次才有较高把握,且环境差异大

结论:必须从"全量日志中精确挑出坏样本,然后做对比分析"。 这就是"考古式"——不去猜墓在哪里,而是把整片地扫一遍,圈出所有异常点位,再看它们共同有什么。


2. 数据准备:从 CLS 拉日志到本地

日志通过云函数日志投递到日志服务 CLS,用 SearchLog 接口按时间窗检索。

# pull_cls.py
import json, os, time
from tencentcloud.common import credential
from tencentcloud.common.profile.client_profile import ClientProfile
from tencentcloud.common.profile.http_profile import HttpProfile
from tencentcloud.cls.v20201016 import cls_client, models

def pull(topic_id: str, start_ms: int, end_ms: int, out_path: str, region="ap-guangzhou"):
"""
start_ms / end_ms: 毫秒时间戳
Query 语法参考 CLS 检索语法,例如:
"REPORT" 或 '"timeout"' 或 按字段过滤 'funcName:"orderQuery"'
"""

cred = credential.Credential(
os.environ["TENCENTCLOUD_SECRET_ID"],
os.environ["TENCENTCLOUD_SECRET_KEY"],
)
hp = HttpProfile(endpoint="cls.tencentcloudapi.com", reqTimeout=60)
client = cls_client.ClsClient(cred, region, ClientProfile(httpProfile=hp))

written = 0
with open(out_path, "w", encoding="utf-8") as f:
# 按小时切片,避免单次返回上限截断
step = 3600 * 1000
t = start_ms
while t < end_ms:
req = models.SearchLogRequest()
req.TopicId = topic_id
req.From = t
req.To = min(t + step, end_ms)
req.Query = "*" # 先全量拉,筛选放本地做,避免二次拉取
req.Limit = 1000
req.Sort = "asc"
req.SyntaxRule = 1 # 按你账号的检索语法版本设置

resp = client.SearchLog(req)
data = json.loads(resp.to_json_string())
for r in data.get("Results", []):
f.write(json.dumps({
"time": r.get("Time"),
"source": r.get("Source"),
"raw": r.get("RawLog"),
}, ensure_ascii=False) + "\\n")
written += 1
if not data.get("ListOver"):
# 该时间片还有更多数据,缩小窗口重试
step = max(60 * 1000, step // 2)
continue
t += 3600 * 1000
step = 3600 * 1000
time.sleep(0.1)
print(f"pulled {written} log lines -> {out_path}")

采集层三个要点:

  • 不要带业务过滤条件去拉。 先用 * 全量拉,筛选在本地做。理由很简单:你如果已经知道该怎么筛,你就已经知道答案了。而且"好样本"也需要拉下来做对比——只拉坏样本,你没法做特征对比。
  • 按小时切片 + ListOver 判断截断。 单次返回有上限,超大时间窗会静默截断。判断 ListOver 并在截断时缩小窗口重试,可以避免"漏掉最关键的那条日志"这种最让人崩溃的情况。
  • 落盘保留原始行。 结构化解析放在分析阶段做。原始日志只拉一次,之后所有实验都在本地重跑,避免反复调用接口。

  • 3. 按 RequestId 聚合物化:这一步决定成败

    日志是"行"的集合,但一次调用才是排障单位。必须先聚合物化,才能做对比。

    # materialize.py
    import json, re
    from collections import defaultdict

    # 各字段的正则/取值逻辑需要按你实际的日志格式调整
    RE_REQUEST_ID = re.compile(r'"request_id"\\s*:\\s*"([^"]+)"')
    RE_DURATION = re.compile(r'"duration_ms"\\s*:\\s*([\\d.]+)')

    def extract_fields(raw: str) > dict:
    """
    云函数平台日志的关键字段通常出现在结构化的平台日志中;
    业务日志则取决于你自己的打点方式。以下为通用兜底策略。
    """

    fields = {"requestId": None, "durationMs": None, "coldStart": None,
    "dbQueryMs": None, "dbConnMs": None, "retryCount": None,
    "memoryMb": None, "concurrency": None, "userTag": None}

    m = RE_REQUEST_ID.search(raw)
    if m:
    fields["requestId"] = m.group(1)
    m = RE_DURATION.search(raw)
    if m:
    fields["durationMs"] = float(m.group(1))

    # 业务打点:建议统一成 JSON 日志,一行一条
    try:
    obj = json.loads(raw)
    for src, dst in (("duration_ms", "durationMs"), ("cold_start", "coldStart"),
    ("db_query_ms", "dbQueryMs"), ("db_conn_ms", "dbConnMs"),
    ("retry_count", "retryCount"), ("memory_mb", "memoryMb"),
    ("user_tag", "userTag")):
    if src in obj:
    fields[dst] = obj[src]
    except Exception:
    pass
    return fields

    def materialize(in_path: str) > dict:
    """按 requestId 把散落的日志行聚成"一次调用"的画像"""
    calls = defaultdict(lambda: {"durMs": None, "lines": 0, "cold": False,
    "dbQueryMs": 0.0, "dbConnMs": 0.0,
    "retry": 0, "tags": {}})
    with open(in_path, encoding="utf-8") as f:
    for line in f:
    rec = json.loads(line)
    raw = rec.get("raw", "")
    fs = extract_fields(raw)
    rid = fs["requestId"]
    if not rid:
    continue
    c = calls[rid]
    c["lines"] += 1
    if fs["durationMs"]:
    c["durMs"] = max(c["durMs"] or 0, fs["durationMs"])
    if fs["coldStart"]:
    c["cold"] = True
    for k, dst in (("dbQueryMs", "dbQueryMs"), ("dbConnMs", "dbConnMs")):
    if fs[k]:
    c[dst] += fs[k]
    if fs["retryCount"]:
    c["retry"] = max(c["retry"], int(fs["retryCount"]))
    for t in ("userTag", "memoryMb", "concurrency"):
    if fs[t] is not None:
    c["tags"][t] = fs[t]
    return dict(calls)

    如果日志里没有 requestId 怎么办? 三种兜底策略,按可靠性排序:

  • 按调用日志中的唯一业务标识聚合(如订单号 + 时间窗)。最可靠,前提是业务有稳定标识。
  • 按时间窗口 + 函数名聚合。把同一秒内同函数的日志行归为一次调用。并发低时可用,并发高时会误合并。
  • 改造打点,补上 requestId。如果以上都不可行,那这次的结论只能是"打点不足",先把打点补上——这不是妥协,这是正确的下一步。
  • 这一步最大的价值在于:把"10 万行日志"变成"1 万次调用"。 维度一换,问题就从"文本分析"变成了"样本对比",后者才是可计算的。


    4. 算分位数,确认是"尾部问题"

    # quantify.py
    import numpy as np

    def quantiles(calls: dict) > dict:
    dur = np.array([c["durMs"] for c in calls.values() if c["durMs"]], dtype=float)
    return {
    "n": len(dur),
    "mean": round(float(dur.mean()), 1),
    "p50": round(float(np.percentile(dur, 50)), 1),
    "p90": round(float(np.percentile(dur, 90)), 1),
    "p99": round(float(np.percentile(dur, 99)), 1),
    "p999": round(float(np.percentile(dur, 99.9)), 1),
    "max": round(float(dur.max()), 1),
    "over_3s": int((dur > 3000).sum()),
    "over_3s_ratio": round(float((dur > 3000).mean()), 5),
    }

    实测输出:

    {"n": 1043782, "mean": 178.3, "p50": 142.0, "p90": 265.0,
    "p99": 3180.0, "p999": 4102.0, "max": 12844.0,
    "over_3s": 3127, "over_3s_ratio": 0.003}

    怎么读这组数:

    • p50 = 142ms 说明绝大多数请求很快 → 不是整体性能退化,是少数样本劣化。
    • p99 / p50 = 22.4。这个比值正常应在 5~10 之间(常见 Web 服务的经验区间)。22 倍说明尾部严重拖尾。
    • p999 = 4102 与 p99 = 3180 接近,max = 12844 是离群点。
    • mean 只比 p50 高 25%,进一步确认:均值被尾部拉动的幅度很小,所以大盘看不出来。

    到这一步,"这是个尾部问题"就被数据确认了。接下来不去看均值,不去看大盘,只做一件事:把 3127 个坏样本和 104 万个好样本做对比。


    5. 特征对比表:让数据替你归因

    # profile.py
    import numpy as np
    from collections import Counter

    BAD_THRESHOLD_MS = 3000

    def profile(calls: dict) > dict:
    good, bad = [], []
    for c in calls.values():
    if not c["durMs"]:
    continue
    (bad if c["durMs"] > BAD_THRESHOLD_MS else good).append(c)

    def agg(group, key, is_bool=False):
    if is_bool:
    return round(sum(1 for g in group if g[key]) / max(len(group), 1), 4)
    vals = [g[key] for g in group if g.get(key)]
    return round(float(np.mean(vals)), 2) if vals else None

    def pct(group, key, target):
    if not group:
    return None
    cnt = sum(1 for g in group if g["tags"].get(key) == target)
    return round(cnt / len(group), 4)

    return {
    "样本数": {"good": len(good), "bad": len(bad)},
    "冷启动占比": {"good": agg(good, "cold", True), "bad": agg(bad, "cold", True)},
    "平均DB查询耗时(ms)": {"good": agg(good, "dbQueryMs"), "bad": agg(bad, "dbQueryMs")},
    "平均DB连接耗时(ms)": {"good": agg(good, "dbConnMs"), "bad": agg(bad, "dbConnMs")},
    "平均重试次数": {"good": agg(good, "retry"), "bad": agg(bad, "retry")},
    "总日志行数/调用": {"good": round(len(sum([g["lines"] for g in good], [])) / max(len(good), 1), 2),
    "bad": round(len(sum([g["lines"] for g in bad], [])) / max(len(bad), 1), 2)},
    }

    实测对比表(这是整个排障过程里信息量最大的一张表):

    特征好样本 (n=1,040,655)坏样本 (n=3,127)倍数
    冷启动占比 0.8% 11.4% 14x
    平均 DB 查询耗时 21 ms 312 ms 15x
    平均 DB 连接耗时 3 ms 268 ms 89x
    平均重试次数 0.02 1.41 70x
    内存峰值 128 MB 219 MB 1.7x
    用户分层 无明显差异 无明显差异

    三条特征差异极其显著(14x / 89x / 70x),两条无差异。 这就是考古式排障的核心产出——它把"偶发超时"这个笼统的问题,压缩成了三个具体且可验证的疑点:

  • DB 连接耗时 89 倍 → 连接建立过程有问题,高度怀疑没有复用连接池
  • DB 查询耗时 15 倍 → 查询本身也有问题,怀疑未走索引 / 有全表扫描
  • 冷启动占比 14 倍 → 冷启动是"放大器"而不是"根因"(冷启动本身不产生 3 秒耗时)
  • 而且它排除了两条很常见的猜测:用户分层无差异(不是特定用户触发),内存无显著差异(不是 OOM 边缘)。


    6. 交给 AI 的那一步:拼因果链

    到这里,人工的成本已经很低了。剩下的工作是把三条孤立特征拼成一条因果链,并给出验证方案——这正是 AI 擅长的部分。

    喂给助手的内容不需要 10 万行日志,只需要:分位数表 + 特征对比表 + 可疑样本的完整调用链(挑 5 条)。

    你是性能排障助手。下面是一次偶发超时故障的三份证据。

    【约束】
    1. 只允许基于给定数据推理,不得引入"常见做法"式的通用猜测。
    2. 每个结论必须标注它依赖的是哪几个数据点。
    3. 必须给出"证伪方案":如果我哪个假设错了,怎么用数据验证出来。
    4. 输出 JSON。

    【证据 1:分位数】
    p50=142ms p90=265ms p99=3180ms p999=4102ms max=12844ms
    mean=178ms, 超时率 0.30%(3127/1043782)

    【证据 2:好坏样本特征对比】
    冷启动占比 0.8% vs 11.4%
    DB查询耗时 21ms vs 312ms
    DB连接耗时 3ms vs 268ms
    重试次数 0.02 vs 1.41

    【证据 3:3 条坏样本的完整调用链】
    (此处粘贴调用链,含各阶段耗时打点)

    【要求输出】
    {
    "rootCauseChain": [{"order":1,"cause":"…","evidence":["…"],"contribution":"…"}],
    "amplifiers": [{"factor":"…","evidence":["…"]}],
    "ruledOut": [{"hypothesis":"…","why":"…"}],
    "verifications": [{"hypothesis":"…","method":"…","expected":"…","falsifyIf":"…"}],
    "fixPriority": [{"priority":1,"fix":"…","expectedGain":"…"}]
    }

    AI 给出的输出(节选):

    {
    "rootCauseChain": [
    {
    "order": 1,
    "cause": "数据库连接未复用:每次调用新建连接,在特定网络/负载条件下握手被拉长到 250ms+",
    "evidence": ["DB连接耗时 3ms vs 268ms,89倍差异", "重试次数 0.02 vs 1.41:连接失败后重试,进一步拉长总耗时"],
    "contribution": "单次调用直接增加 265ms 基线,且重试使增益翻倍"
    },
    {
    "order": 2,
    "cause": "部分查询未走索引,在连接建立较慢时叠加全表扫描",
    "evidence": ["DB查询耗时 21ms vs 312ms,15倍差异", "差分说明查询层独立存在劣化,不是纯连接问题"],
    "contribution": "在连接慢的基础上再叠加约 290ms"
    }
    ],
    "amplifiers": [
    {
    "factor": "冷启动",
    "evidence": ["冷启动占比 0.8% vs 11.4%:冷启动使调用落入"连接重建"路径的概率上升,本身不直接造成 3s 耗时"]
    }
    ],
    "ruledOut": [
    { "hypothesis": "特定用户触发的大数据量请求", "why": "用户分层无差异" },
    { "hypothesis": "内存不足导致 GC 抖动或 OOM 边缘", "why": "内存峰值仅 1.7 倍,无显著差异" }
    ],
    "verifications": [
    {
    "hypothesis": "连接未复用",
    "method": "统计坏样本中 db_conn_ms > 100ms 的调用,其上游是否都伴随"新建连接"日志",
    "expected": "高于 90% 的坏样本应命中该模式",
    "falsifyIf": "若命中率低于 50%,则连接耗时是结果而非原因"
    },
    {
    "hypothesis": "查询未走索引",
    "method": "对坏样本中的 SQL 做 EXPLAIN,检查 type=ALL 的比例",
    "expected": "坏样本 SQL 的 type=ALL 比例显著高于好样本",
    "falsifyIf": "若两者索引使用情况一致,则查询层劣化属连带效应"
    }
    ],
    "fixPriority": [
    { "priority": 1, "fix": "改为函数外初始化客户端/启用连接池并复用", "expectedGain": "消除 265ms 基线增量,超时率预计降至 5% 以内" },
    { "priority": 2, "fix": "补索引 + 为慢查询加超时", "expectedGain": "消除 290ms 叠加" }
    ]
    }

    注意这段输出里最有价值的部分不是 rootCauseChain,而是 verifications 和 ruledOut。 前者告诉你"如果你错了,怎么用数据发现",后者告诉你"哪些看起来合理的猜测已经被数据排除,别再花时间去查"。这两项才是让 AI 归因可信的关键——它必须把自己放到可被证伪的位置上。


    7. 验证与修复

    按 AI 给出的证伪方案,我们跑了两条验证:

    验证 1:坏样本是否都伴随"新建连接"

    # verify_conn.py
    bad_with_new_conn = 0
    bad_total = 0
    for c in bad_calls:
    bad_total += 1
    if any("connection established" in l for l in c["raw_lines"][:3]):
    bad_with_new_conn += 1
    print(f"{bad_with_new_conn}/{bad_total} = {bad_with_new_conn/bad_total:.2%}")
    # 输出: 2918/3127 = 93.31%

    93.31% > 预期的 90%,假设 1 成立。

    验证 2:坏样本 SQL 的索引使用情况

    对坏样本中提取出的 47 条唯一 SQL 做 EXPLAIN:

    类型条数占比
    type=ALL(全表扫描) 11 23.4%
    type=range 28 59.6%
    type=ref/eq_ref 8 17.0%

    好样本 SQL 的 type=ALL 占比为 1.2%。23.4% vs 1.2%,假设 2 成立。

    修复动作:

  • 把数据库客户端初始化移到函数handler 外(复用),并显式配置连接池
  • 为 11 条全表扫描 SQL 补索引
  • 为 DB 查询加超时(超过 800ms 直接失败并降级),避免拖到 3s
  • 连接失败不再盲目重试,改为带退避的 1 次重试
  • 回归验证(观察 7 天):

    指标修复前修复后
    超时次数 3,127 12
    超时率 0.30% 0.0012%
    P99 3,180ms 412ms
    P999 4,102ms 638ms
    平均耗时 178ms 164ms

    8. 方法论提炼:考古式排障的 5 条原则

  • 先量分位数,再看内容。 p99/p50 的比值能在 30 秒内告诉你"是尾部问题还是整体退化",而这个判断决定了你后面所有工作的方向。比值 >15 就不要再盯均值了。
  • 坏样本要全量挑,不要抽样。 抽样适合"验证已知规律",不适合"发现未知规律"。0.3% 的问题,抽样命中的概率和买彩票差不多。
  • 对比表比日志内容更有信息量。 一张"好 vs 坏"的特征对比表,价值高于任何一段日志原文。差异倍数大的字段就是嫌疑犯,无差异的字段就是不在场证明。
  • 让 AI 输出"证伪方案",而不是"结论"。 一个不可证伪的结论在排障里等于零。要求它给出"如果我错了,数据会怎么显示",这才把 AI 从"猜"变成了"提假设"。
  • 把"放大器"和"根因"分开。 冷启动、重试、GC 这些通常是放大器——它们让问题从 100ms 变成 3s,但它们不是起点。混淆这两者会导致你花大力气优化冷启动,而超时依旧。
  • 最后一条尤其重要:在这次的案例里,冷启动的差异倍数是最大的那类(14x)之一,但它不是根因。 如果只按差异倍数排序去修,你会先去优化冷启动,然后发现超时率几乎没降。差异倍数说明的是"相关性",因果链只能靠机制推理 + 证伪验证得到。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 实战|10 万行云函数日志的“考古式“排障:用腾讯云助手把偶发超时压缩成一条根因链
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!