两个常量条件汇入同一个参数化结构,再分别形成总时间与平均时间摘要,要求身份与采集窗口相同。

pg_stat_statements 在有效采集窗口内比较查询代价

pg_stat_statements 把语句执行归组并累计代价。它既能找出调用很频繁、单次很短但总耗时很大的查询,也能帮助定位单次特别慢的查询。这是两个不同问题,任何一种排名都不能单独证明某项修改会改善业务延迟。执行时间也不等于处理器消耗、存储等待或完整请求时长。先明确要优化的现象,再选择统计列,才能避免把排行榜本身当成优化结论。 归组有用,但有明确边界 PostgreSQL 18 通常把代表性查询文本中的常量替换为参数符号,并按分析后的结构归组。条目身份包含用户、数据库、查询标识,以及是否为顶层执行;查询文本并不是完整身份。对象解析不同,可能让相同文本进入不同条目;相关分组范围内,也存在哈希碰撞把无关结构合并的微小可能。因此监控应保留足够身份字段,而不是只截取一段 SQL 当作永远可靠的主键。 代表性文本用于诊断,不是每条原始请求的审计流水。有些工具语句或归一化失效场景仍会保留字面量,应限制访问并避免把秘密嵌进 SQL。查询标识不承诺跨大版本或任意目录变化保持稳定。PostgreSQL 18 还会对某些长度不同的常量列表归一化;换到另一个版本时,需要查对应手册,不能把当前显示形式当成所有历史版本的统一行为。 根据问题选择排名 总执行时间适合寻找累计数据库工作量大的对象。平均值、最大值与离散程度描述单次调用的分布特点,但它们不是分位数,也不能单独指出原因。波动可能来自参数、缓存状态、竞争或计划变化。看到标准差较大时,应继续检查计划和等待,不能直接宣布发生了锁竞争;平均较高也不自动意味着规划器选择错误。 并发会话的执行区间会重叠,所以累计执行时长可以超过统计窗口的墙钟时间。这不证明服务器重启过,也不证明数据无效。启用计划统计时,规划耗时另有字段。共享块读取表示数据库缓冲管理器发起的读取,仍可能由操作系统缓存满足。乘以服务器块大小后得到的是请求读取量,不是物理设备实际流量;开启输入输出计时增加耗时测量,基础块计数并不依赖它。 坏掉的样子 模块需要在服务器启动时通过 shared_preload_libraries 加载,检查所用数据库也需要创建 SQL 扩展。查询标识计算必须可用,通常由 compute_query_id 的 auto 或 on 提供,也可以来自外部模块。缺少配置、权限限制和关闭跟踪是不同失败,应读取实际错误,而不是把任何缺失条目都解释成没有负载发生。监控查询能够运行,也不代表它有权看见所有其他用户的文本。 可靠增量要求条目身份与采集窗口都可比较。PostgreSQL 18 提供每条记录的 stats_since、最小最大值的独立起点,以及全局重置和淘汰信息。定向重置或淘汰可以只破坏一个条目的连续性,而不重置整个视图。差值非负也不足以证明窗口连续:重置之后积累足够工作,可能把异常掩盖。新条目和窗口变化应重新建立基线,不能跨越它们直接做减法。 归一化受条目身份和采集连续性约束。修订夹具绑定四项身份字段;执行仍有保留,未声明新的 SQL 或耗时结果。 快速验证 使用全新且没有其他负载的 PostgreSQL 18 可丢弃集群和数据库,预先加载模块、启用内置查询标识与顶层语句跟踪,并让测试角色能够看见和使用或安装扩展。保持角色、搜索路径与对象不变,实验期间不得有并发负载、重置或条目淘汰。在一个全新会话中执行下面 SQL,任何错误都应作为失败处理。成功时删除测试表和临时视图;错误可能留下这些对象,新安装的扩展也会保留。它不重置任何已有统计,因此不会为了演示排名而破坏监控窗口。 CREATE EXTENSION IF NOT EXISTS pg_stat_statements; CREATE TABLE pgss_demo (id integer); INSERT INTO pgss_demo SELECT generate_series(1,1000); CREATE TEMP TABLE pgss_key ( userid oid NOT NULL, dbid oid NOT NULL, toplevel boolean NOT NULL, queryid bigint NOT NULL CHECK (queryid <> 0), PRIMARY KEY (userid,dbid,toplevel,queryid) ); DO $$ DECLARE first_plan json; second_plan json; first_id bigint; second_id bigint; BEGIN IF current_setting('compute_query_id') NOT IN ('auto','on') OR current_setting('pg_stat_statements.track') <> 'top' THEN RAISE EXCEPTION 'requires built-in query identifiers and top-level tracking'; END IF; EXECUTE 'EXPLAIN (VERBOSE, FORMAT JSON) SELECT sum(id) FROM pgss_demo WHERE id < 100' INTO STRICT first_plan; EXECUTE 'EXPLAIN (VERBOSE, FORMAT JSON) SELECT sum(id) FROM pgss_demo WHERE id < 500' INTO STRICT second_plan; first_id := (first_plan->0->>'Query Identifier')::bigint; second_id := (second_plan->0->>'Query Identifier')::bigint; IF first_id IS NULL OR first_id = 0 OR second_id IS DISTINCT FROM first_id THEN RAISE EXCEPTION 'expected matching nonzero query identifiers'; END IF; INSERT INTO pgss_key SELECT (SELECT oid FROM pg_roles WHERE rolname=current_user), (SELECT oid FROM pg_database WHERE datname=current_database()), true,first_id; END $$; CREATE TEMP VIEW pgss_target AS SELECT s.* FROM pg_stat_statements AS s JOIN pgss_key AS k USING (userid,dbid,toplevel,queryid); CREATE TEMP TABLE pgss_window AS SELECT dealloc,stats_reset FROM pg_stat_statements_info; SELECT sum(id) FROM pgss_demo WHERE id < 100; CREATE TEMP TABLE pgss_first AS SELECT userid,dbid,toplevel,queryid,calls,stats_since FROM pgss_target; SELECT sum(id) FROM pgss_demo WHERE id < 500; SELECT userid,dbid,toplevel,queryid,query,calls,rows, round(total_exec_time::numeric,3) AS total_ms, round(mean_exec_time::numeric,3) AS mean_ms,stats_since FROM pgss_target; DO $$ BEGIN IF (SELECT count(*) FROM pgss_first) <> 1 OR (SELECT count(*) FROM pgss_target) <> 1 OR (SELECT count(*) FROM pgss_window) <> 1 THEN RAISE EXCEPTION 'expected one identity-bound entry and collection baseline'; END IF; IF NOT EXISTS ( SELECT 1 FROM pgss_first AS a JOIN pgss_target AS b USING (userid,dbid,toplevel,queryid) CROSS JOIN pgss_window AS w CROSS JOIN pg_stat_statements_info AS i WHERE a.calls=1 AND b.calls=2 AND b.calls-a.calls=1 AND b.stats_since=a.stats_since AND i.dealloc=w.dealloc AND i.stats_reset=w.stats_reset) THEN RAISE EXCEPTION 'expected calls 1 then 2 in one unchanged collection window'; END IF; END $$; SELECT userid,dbid,toplevel,queryid,calls, round(total_exec_time::numeric,3) AS total_ms, round(mean_exec_time::numeric,3) AS mean_ms FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10; SELECT userid,dbid,toplevel,queryid,calls, round(mean_exec_time::numeric,3) AS mean_ms FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10; SELECT dealloc,stats_reset FROM pg_stat_statements_info; SELECT round((sum(shared_blks_read)::numeric * current_setting('block_size')::numeric)/1073741824,6) AS requested_read_gib FROM pg_stat_statements; DROP VIEW pgss_target; DROP TABLE pgss_first,pgss_window,pgss_key; DROP TABLE pgss_demo; SELECT 'pgss fixture completed' AS completion; 两个求和结果应分别是四千九百五十和十二万四千七百五十。不带 ANALYZE 的 EXPLAIN 在不执行求和的情况下取得两个查询标识,要求二者相同且非零。目标视图使用该标识、当前用户、数据库和顶层标志做完整身份连接,不用代表性文本匹配。两份快照要求先一次、后两次调用,并核对 stats_since 与全局重置、淘汰元数据未变。这些检查依赖无其他负载的前提,不保证哈希永不碰撞,也不是并发监控方案。两个通用排名完全可能相同,它们展示的是排序依据的区别,不制造必然不一致的承诺。对双精度耗时先转换为 numeric,才可以使用指定小数位数的两参数 round。 ...

十二月 12, 2027 · 3 分钟 · XB Field Notes
十六个受控根操作分成八条保留链路和八条丢弃链路。恰好一半源于刻意安排的测试标识;配置采样比例不是成本上限。

链路采样减少数据量,但不会固定成本上限

链路采样决定哪些观测数据进入后续追踪管线,可以减少导出流量和存储量,但配置的百分比并不是字节数、处理器开销或费用的严格上限。不同请求产生的跨度数量不同,属性大小不同,业务流量也不断变化。即使一直保留百分之一的数据,如果请求流量突然增加十倍,被保留的数据量仍然可能大致增加十倍。不能只看到比例很低,就判断采集系统已经有了确定的成本边界。 头部采样在链路开始附近作出决定。基于链路标识的比例采样器,需要标识满足算法所依赖的随机性要求。其概率描述的是合适流量总体中的选择规律,并不承诺每十六个请求都恰好保留一半,也不保证每条业务路由都得到同等覆盖。对于数量很少的请求和罕见故障,即使整体采样百分比看起来正常,保留下来的实例仍可能不足以解释问题。 数量来自原文刻意安排标识的样例,不是生产流量逐批比例保证。远端父上下文的计数属于独立阶段。历史已修订、执行通过状态保持不变,本次未重跑样例或新增运行验证。 让同一项决定跨边界保持一致 如果系统期望同一条链路沿用上游决定,下游插桩就应遵循已经接受的父上下文。Python SDK 的 ParentBased 对根跨度调用配置的根采样器,并为已采样、未采样的父跨度应用对应策略。因此,把 TraceIdRatioBased(0.5) 包在父级策略中,与每个下游跨度都独立进行一次抽样不同。上下文传播方式和接收端采样配置必须配合,不能只修改入口的百分比,而忽略其余服务如何解释传播标志。在不可信边界,还应决定是否接受调用方的采样标志;W3C 将它视为提示,不是无限消耗追踪资源的授权。 未被采样的链路仍然要执行业务代码,也仍可能涉及上下文处理和插桩开销。省掉导出并不意味着整个请求没有成本。反过来,即使父级决定得到保留,也不能保证每个子操作都能在界面中出现:某个库可能没有插桩,服务可能丢失上下文,导出器也可能失败。采样一致性只解决数据缺失的一部分原因,无法独自证明整个观测传输过程都可靠。 坏掉的样子 请求在开始时可能一切正常,直到后半程才失败。如果头部采样器早已决定丢弃,应用之后设置错误状态,并不能把未记录的跨度补回来。尾部采样可以先收集更多跨度,再依据整条链路的信息判断,但需要缓冲数据、将相关跨度路由到一起,并处理迟到或缺失的数据。它同样无法重建到达自身之前就被上游舍弃的跨度,因此不能把尾部规则理解成无条件保留所有失败的保证。 另一种误用,是直接拿带有选择偏差的追踪数据计算业务可靠性。专门保留慢请求或错误请求的策略,已经改变了观察总体。应另外维护分母清晰的请求结果指标,并在排查说明中交代采样策略。要判断费用是否受控,还需要观察实际导出的跨度数和字节数、队列情况、采集器资源与数据保留期限。一个配置项只能说明计划采用怎样的选择规则,不能替代实际负载与费用证据。 快速验证 创建 Python 虚拟环境并安装 opentelemetry-sdk==1.35.0,将代码保存为 trace-sampling.py,再使用该环境运行。原始文章报告曾以 Python 3.12.14 和这个固定 SDK 版本运行。保留审核中没有对应的精确执行产物,本次环境也未安装该 SDK,因此这里没有运行完整示例。图中的历史通过标签沿用原始报告,不能当成独立恢复的执行证明。代码中的自定义标识生成器专门服务于确定性测试:十六个标识被有意安排在这个 SDK 版本的比例判断阈值两侧。生产环境不能照搬这种顺序标识方案,否则将破坏采样器依赖的随机分布假设。这里追求的是可重复核对已知输入的结果。 import json from collections import Counter from opentelemetry.context import Context from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import SimpleSpanProcessor from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter from opentelemetry.sdk.trace.id_generator import IdGenerator from opentelemetry.sdk.trace.sampling import ParentBased, TraceIdRatioBased from opentelemetry.trace import Status, StatusCode from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator class TestIds(IdGenerator): # Controlled test inputs only; production IDs need suitable randomness. def __init__(self): self.trace_index = 0 self.span_index = 0 def generate_trace_id(self): index = self.trace_index self.trace_index += 1 return ((index + 1) << 64) | (index * (1 << 60) + 1) def generate_span_id(self): self.span_index += 1 return self.span_index exporter = InMemorySpanExporter() provider = TracerProvider(sampler=ParentBased(TraceIdRatioBased(0.5)), id_generator=TestIds()) provider.add_span_processor(SimpleSpanProcessor(exporter)) tracer = provider.get_tracer("sampling-lab") executed_children = 0 late_error_recording = None for index in range(16): with tracer.start_as_current_span(f"root-{index}", context=Context()) as root: for child in range(2): with tracer.start_as_current_span(f"child-{index}-{child}"): executed_children += 1 if index == 15: late_error_recording = root.is_recording() root.set_status(Status(StatusCode.ERROR, "error discovered after start")) assert provider.force_flush() spans = exporter.get_finished_spans() counts = Counter(span.context.trace_id for span in spans) assert len(counts) == 8 and set(counts.values()) == {3} and len(spans) == 24 assert {s.name for s in spans if s.parent is None} == {f"root-{i}" for i in range(8)} assert executed_children == 32 and late_error_recording is False assert not any(span.name == "root-15" for span in spans) exporter.clear() propagator = TraceContextTextMapPropagator() for flag, label in (("01", "sampled"), ("00", "unsampled")): # High trace-ID bits do not override an accepted remote parent's flag. header = f"00-1111111111111111ffffffffffffffff-2222222222222222-{flag}" context = propagator.extract({"traceparent": header}, context=Context()) with tracer.start_as_current_span(label, context=context): with tracer.start_as_current_span(label + "-child"): pass assert provider.force_flush() remote_spans = exporter.get_finished_spans() assert {span.name for span in remote_spans} == {"sampled", "sampled-child"} assert next(span for span in remote_spans if span.name == "sampled").parent.is_remote print(json.dumps({"controlled_roots": 16, "exported_traces": len(counts), "exported_spans": len(spans), "executed_child_operations": executed_children, "late_error_was_recording": late_error_recording, "remote_parent_exported_spans": len(remote_spans)}, indent=2)) provider.shutdown() 十六个根操作分别启动两个子操作,最终应导出八条链路、二十四个跨度,而三十二个子操作全部实际执行。结果恰好为一半,是测试输入被精确安排的产物,不能推广成随机业务流量中的逐批保证。编号十五的根操作被故意放在不保留的集合内;后来设置错误状态,也不会令它转成记录状态或出现在导出结果里。断言直接验证这一限制,而不是通过几次随机运行估计采样比例。 ...

十月 23, 2027 · 2 分钟 · XB Field Notes
Prometheus 主动向两个目标端点发出 HTTP 请求。合法响应返回样本并产生 up=1;被拒绝的抓取在红色停止标记处终止,产生 up=0。这些都是采集结果。

Prometheus 主动抓取指标,并记录抓取结果

Prometheus 会主动请求每个已配置目标的 HTTP 指标端点。目标负责暴露观测值,采集器决定抓取时刻、解析响应并保存样本。这种安排让采集故障能够从采集器一侧被看见,但不能保证端点一定可达、所有预期目标都被发现,也不能保证用户的登录或下单流程正常。理解这些边界,才能把采集结果转换成准确的告警和排查动作。 对于已配置实例,Prometheus 生成 up:抓取成功为 1,失败为 0。移除的目标之后可能不再出现在查询中。采集成功不保证应用健康,也不保证全部业务指标存在。本轮绘图没有重跑原文实验。 一个任务包含多个独立抓取的实例 任务通常把用途相同的目标组织在一起,例如一组接口服务副本。每个目标仍是独立实例,具有自己的抓取结果。常见的 job 与 instance 标签标识这层上下文,最终标签也可能经过重新标记而改变。服务发现提供候选目标,抓取配置再决定实际访问的端点、协议、周期、超时和认证方式。因此,目标没有被发现与目标已经配置但抓取失败,是两类不同的问题。 采集器必须能够连接目标。虽然导出器不需要主动向采集器上传指标,目标仍然需要允许经过授权的采集器访问其入站端口。网络策略、证书身份、认证凭据与路由不会因为采用拉取模式而消失。拉取只是改变连接由哪一方发起,不能把任意导出器变成可以直接暴露在公网的安全接口。部署时要从采集器所在网络检查这条路径。 准确理解系统生成的指标 对于已配置的实例,Prometheus 会生成 up 指标,抓取成功时为一,失败时为零。它是指标名称,不是所谓的 up 标签。抓取耗时与样本数量则补充了采集质量的证据。导出器可能在后端数据库不可用时仍返回合法响应,也可能漏掉一项重要业务指标,因此即使 up 为一,也不能推导后端依赖和全部业务观测都正常。 这些进一步的问题应由应用结果指标、导出器专门提供的依赖状态,以及实际业务探测回答。反过来,up=0 描述的是采集失败,监控网络故障可能使它变为零,而用户请求仍然成功。告警措辞应写清采集目标与错误证据,避免让响应人员把所有监控链路问题直接当成客户故障。业务影响仍需要另一组证据确认。 指标重新标记不会应用于 up 这类自动生成序列。因此,即使过滤掉了全部业务样本,也可能仍然看到抓取成功。调整过滤规则时,应把预期指标族或样本量纳入验收,分清主动丢弃观测与导出器故障。一个绿色采集指标不能证明过滤后的业务数据仍满足面板和告警所需的契约。 坏掉的样子 连接被拒绝、请求超时、证书校验失败或导出格式无效,都可能让抓取失败。先查看目标的最近抓取错误,再决定需要调整什么。延长周期无法修复错误端口;增大超时可能让缓慢的导出器看起来恢复,却不会消除它的执行成本。采集变贵时还应检查输出序列数量和响应大小,确认是否新增了不必要的标签组合或昂贵查询。抓取超时不能超过配置周期,缩短周期前则应评估导出器负载。更快的轮询可能成倍增加导出器对数据库的查询次数,却不会降低每次查询的成本。 ...

八月 30, 2027 · 2 分钟 · XB Field Notes
两条请求通道经同步格式化,分别保留 req-a 和 req-b,形成四条独立 JSON 记录。图示文章实验,非本次新运行。

结构化日志让查询约定明确可见

结构化日志为字段赋予稳定含义,使查询不必每次都从说明文字中推断全部信息。一条记录可以包含事件名称、请求编号、数值时长、严重级别,以及供人阅读的消息。自由文本仍然有价值,也可以被搜索或解析。结构化的实际收益,是让常用问题依赖一份明确的数据约定,即使说明措辞改变,字段的语义仍然保持稳定,而不是简单地给原来的一段话套上花括号。 这份约定必须涉及类型和单位。作为数字保存的时长可以直接进行数值比较,作为字符串保存时,索引和比较方式却可能不同。毫秒与秒不能共用一个含义模糊的字段名称,事件名称也应跨版本稳定描述所发生的动作。模式版本能够标识有意进行的格式变化,但它不会自动转换旧记录,也不会自动保证全部查询消费者继续兼容。字段设计需要围绕真实查询,而不是追求看起来丰富的输出。 文章实验:两个开始事件和两个完成事件,每个请求两条记录。图示边界是任务局部同步捕获与按行安全 JSON;调度次序仅示意,未新增运行、队列、采集器、索引或脱敏证据。 把上下文放到正确的记录上 请求编号只有属于当前发出日志的操作时才有帮助。多个请求并发执行时,使用一个进程级可变全局变量保存当前编号并不合适。Python 上下文变量可以在异步任务范围内保持局部状态,但前提是在正确作用域设置并恢复值。这并不意味着所有线程或自定义执行机制都采用同一种继承方式;经过真正的并发边界时,仍需验证上下文怎样到达执行位置。 需要在上下文尚可取得的位置捕获它。本例在发出日志的任务中同步格式化记录,因此格式化器能够读取该任务的上下文变量。如果改成通过队列交给另一线程或进程处理,就应在交接前把所需字段附加到记录上。让监听线程临时读取它自己的当前请求上下文,并不能可靠恢复原发出者的身份。数据最终都进入同一个日志文件,不代表它们在跨线程之后仍然拥有正确的关联关系。 坏掉的样子 手工拼接 JSON 时,消息中的引号或换行可能破坏格式。按行读取的采集器随后可能把一个应用事件拆成多个看似独立的事件。应使用 JSON 序列化器,并保证每个物理行包含一个完整对象。消息的逻辑字符串可以保留换行,但在线上表示中它会被转义,不会产生新的记录边界。后续界面展示解码后的消息时,也应继续清楚区分记录,避免展示方式重新造成误读。 结构化输出不会自动建立索引、降低查询账单或保证投递成功。不受约束的字段、意外变化的类型、缺失的编号和不该记录的敏感内容,都可能使一份合法 JSON 流难以使用。需要依据实际问题选择字段,保持模式稳定,并核对采集器的解析规则和数据保留方式。可查询的结构是生产者与消费者之间的约定,不是文件扩展名或者一次序列化成功就能单方面建立的能力。 快速验证 将脚本保存为 structured-logging.py 并用 Python 执行;2026-10-19 以 Python 3.12.14 完整运行原样程序并通过,只使用标准库。两个异步请求各自写入开始事件,在共同的放行点等待,再写入包含刻意构造消息的完成事件。这会让两个请求的存活区间重叠,便于检查编号是否混淆。时长是用于检查 JSON 类型的固定合成值,并不是测得的请求性能,输出也只保存在内存里供断言读取。 import asyncio import io import json import logging from collections import Counter from contextvars import ContextVar request_id = ContextVar("request_id", default=None) stream = io.StringIO() class JsonFormatter(logging.Formatter): def format(self, record): return json.dumps({"schema_version": 1, "event": record.event, "request_id": request_id.get(), "worker": record.worker, "duration_ms": record.duration_ms, "level": record.levelname, "message": record.getMessage()}, ensure_ascii=False, separators=(",", ":")) logger = logging.getLogger("structured-log-lab") logger.setLevel(logging.INFO) logger.propagate = False handler = logging.StreamHandler(stream) handler.setFormatter(JsonFormatter()) logger.addHandler(handler) async def request(name, ready, release): token = request_id.set(name) try: logger.info("starting %s", name, extra={"event": "request.started", "worker": name, "duration_ms": 0}) ready.set() await release.wait() await asyncio.sleep(0) logger.info('untrusted line\n{"event":"forged"}', extra={"event": "request.completed", "worker": name, "duration_ms": 4}) finally: request_id.reset(token) async def main(): ready = [asyncio.Event(), asyncio.Event()] release = asyncio.Event() tasks = [asyncio.create_task(request(name, signal, release)) for name, signal in zip(("req-a", "req-b"), ready)] await asyncio.gather(*(signal.wait() for signal in ready)) release.set() await asyncio.gather(*tasks) assert request_id.get() is None try: asyncio.run(main()) finally: logger.removeHandler(handler) handler.close() lines = stream.getvalue().splitlines() records = [json.loads(line) for line in lines] assert len(lines) == 4 assert Counter(record["request_id"] for record in records) == {"req-a": 2, "req-b": 2} assert all(record["request_id"] == record["worker"] for record in records) assert all(type(record["duration_ms"]) is int for record in records) completed = [record for record in records if record["event"] == "request.completed"] assert len(completed) == 2 and all("\n" in record["message"] for record in completed) assert not any(record["event"] == "forged" for record in records) print(json.dumps({"physical_lines": len(lines), "parsed_records": len(records), "requests": 2, "context_mixing": False, "numeric_duration_preserved": True, "forged_records": 0}, indent=2)) 断言要求最终恰好有四个物理行、四条可解析记录,每个请求编号对应两条记录。即使请求并发,记录中的编号也必须与实际发出日志的工作任务一致。时长在解析后仍然是数字,完成消息中的换行保留在字符串内部,却不会多出一个名为 forged 的伪造事件。每个任务在 finally 块恢复自己的上下文令牌。外层上下文没有请求编号的断言检查的是它与子任务之间的隔离,单靠这一项并不能独立证明每个子任务内部已经恢复原值。 ...

三月 9, 2027 · 2 分钟 · XB Field Notes
request、outbound、handle/ok 三个跨度共享一条链路,并通过两处父子关系连接。第二个不携带传播头的请求创建独立的 handle/fail 根跨度。链路连接与失败状态是两个问题。

链路追踪连接一次请求中已被记录的操作

分布式链路追踪利用共同的链路标识和明确的关系,连接一次请求中被记录下来的操作。一个跨度表示一项操作,带有开始与结束时间、属性、事件和状态。当结账请求依次涉及应用、库存服务和支付服务时,这些信息可以帮助我们还原处理路径。但最终图形取决于哪些位置做了插桩、哪些数据通过采样并成功导出,不能把它当成请求实际执行过的全部动作清单。 链路标识用于归集相关跨度,跨度标识用于区分同一条链路中的具体操作,父跨度标识则连接直接的上游操作。通过 HTTP 跨越边界时,W3C 的 traceparent 请求头携带链路标识、发送端跨度标识和链路标志。接收端必须提取这些上下文,再创建自己的跨度。仅仅在几条互不关联的日志里写入同一个请求编号,并不会自动形成可供追踪系统识别的跨度父子关系。对于由多个上游共同触发的工作,可以用跨度链接表达额外关系,而不是给一个跨度分配多个父级。 链路 A 与 B 是原文两组链路的图解名称,不是真实标识。图解仅依据原文叙述与未执行代码;历史状态仍为已阅读、执行受阻,本次没有重跑或新增运行验证。箭头长度不表示耗时。 先读关系,再分析耗时 一个服务端跨度可能包含数据库操作,也可能包含多个同时进行的外部请求。把所有跨度的时长相加,会重复计算重叠的时间;如果兄弟跨度并发执行,用父跨度时长减去所有子跨度时长,也可能得到毫无意义的负数。应当阅读时间线,判断哪些区间位于影响请求完成的关键路径。跨机器的时钟差异以及缺失的插桩,还会限制我们对图上空白区间的解释精度。 跨度状态也需要单独理解。HTTP 响应码、异常事件和 OpenTelemetry 的错误状态是三份不同的信息,插桩库可能依据自己的约定将它们关联起来。手工记录异常时,不能因此假定所有有关的状态字段都已经改变。服务名称等资源属性回答数据由哪个服务发出,而某个跨度的名称、属性和事件回答该服务中发生了什么操作。混淆这两个层次,会使跨服务排查失去可靠的定位依据。 坏掉的样子 代理可能丢弃传播请求头,接收服务也可能没有调用上下文提取逻辑。此时业务请求已经成功抵达下游,但下游工作在追踪系统里属于另一条链路。还有一种错误是链路标识相同,父跨度却连接错误:数据看似聚到一起,实际因果位置已经不对。验证一个服务边界时,既要检查链路标识,也要检查直接父跨度,不能只确认后端收到了几条跨度就宣布传播正确。 找不到某个跨度,可能是采样舍弃、尚未插桩、异步执行中丢失上下文,或者导出失败造成的,不能据此证明该操作没有运行。同样,界面里一片绿色,也不代表所有下游依赖都成功了。需要结合请求最终结果指标和相关日志,核对采集器及导出器的健康状况,然后再解释链路图。不完整的数据可以帮助提出问题,却不能独自证明一条没有观测缺口的完整处理路径。 快速验证 先创建 Python 虚拟环境,安装 opentelemetry-sdk==1.35.0,其依赖会安装匹配的 API 版本。将以下代码保存为 trace-flow.py,再使用该环境的 Python 执行;原文报告曾使用 Python 3.14.4 执行该练习。本次修订保留原练习但未重新运行,当前执行检查仍受阻。脚本启动仅监听本机回环地址的 HTTP 服务,分别为调用者和接收者创建追踪提供器,把完成的跨度写入内存导出器。请求结束后会关闭服务,无须准备独立的遥测采集器。获得授权后运行之前,还应确认代理配置绕过 127.0.0.1,否则 urllib 可能使用已配置的代理。建立本地监听和发起 HTTP 请求需要合适的可丢弃环境。 ...

十二月 15, 2026 · 2 分钟 · XB Field Notes
同一个二百五十毫秒输入分为两条路径。除以一千得到零点二五零秒;只改后缀仍保留数值二百五十,错误地将其解释为秒。上方换算路径与下方仅改名路径明确分开。

把单位写进指标名里

名为 request_duration 的指标会让读者猜测,二百五十究竟表示秒、毫秒还是别的量。面向 Prometheus 的名称,例如 http_request_duration_seconds,能够把单位约定带入查询、记录规则与告警配置。但这个约定必须由正确实现支撑:给毫秒数据的名字加上秒后缀,不会自动改变数值大小,反而可能让监控产生一千倍的误差。名称提供线索,转换逻辑才决定数据是否可信。 图解依据原文的三条十进制定点输入与阈值。历史 promtool 解析及命名检查结果保持不变。名称正确不证明真实埋点正确,应沿采集、导出、查询与显示核对已知输入。 在明确的边界完成换算 每个指标族应只表示一种量和一种单位。Prometheus 建议使用秒、字节等基础单位,并在适用时把单位写为后缀。持续时间与绝对时间戳都可以用秒表示,却不是同一种量,因此不能因为名字都含秒,就把上次成功的时间戳与一次请求的耗时直接相加。帮助文本应写明测量对象、开始结束边界以及含义,让简短名称背后仍有明确的契约。 如果底层库输出毫秒,应在观测或导出边界转换一次,并保留足够精度,避免把很短的耗时截断为零。仪表盘可以按基础单位配置显示,让零点二五秒自动显示为二百五十毫秒,这只改变呈现方式,不改变存储样本。不能仅因为坐标轴显示毫秒,就在记录规则中再做一次换算,否则查询和图表会在不同环节重复缩放。 单位与类型回答不同问题 累计传输字节数通常使用字节加总量后缀,当前内存占用仪表则只使用字节后缀。两者单位相同,但只有前者表示不断累积的工作量,适合计算计数器速率。请求次数按惯例使用总量后缀,无须编造一个物理单位。直方图的桶、总和与计数,还会在耗时指标族名称后加入各自的组件后缀,因此阅读名称时也要识别指标族与导出组件之间的关系。 比例通常表示零到一之间的分数,而百分数使用另一种数值尺度,面板应明确自己期待哪种输入。计数器可以在进程重启时归零,Prometheus 的计数器速率函数会根据观测序列中的下降处理重置,并不要求额外存在创建时间序列。应先分别计算独立计数器的速率,再执行聚合,让各自的重置仍能被识别;不要先把多个实例的累计值混成一条看似连续的曲线。 坏掉的样子 名字正确的指标仍然可能承载错误数值。如果同一个标识下混入秒和毫秒,历史数据已经被污染,修改帮助文字或补一个说明标签不能修复旧样本。单位迁移应被视为契约变化,生产端、查询、告警和仪表盘需要一起核对,历史区间的比较也必须说明边界。旧序列含义不兼容时,可以采用新的指标标识,避免新旧数值被误当成同一种量连续使用。 名称后缀也不会强制执行计数器语义。一个普通运行过程中可以升降的仪表,即使名字带总量后缀,也不适合按累计计数器解释。反过来,已经得到每秒速率后再除以时间窗口,会制造另一种量。这些是维度和测量语义的问题,不能只靠显示格式解决。其他遥测体系可能把单位放在元数据中,应遵循已定义的导出映射,而不是机械重命名所有上游仪器。 快速验证 把代码保存为 units.py,使用 Python 3 执行。程序转换三条人为构造的延迟观测,并输出带帮助文字和类型声明的 Prometheus 文本格式。Decimal 十进制运算使毫秒到秒的换算清楚可见;阈值比较则展示,仅仅改名、却不转换数据的生产端,为什么不能和正确的生产端共用同一条延迟规则。 from decimal import Decimal latency_ms = [Decimal('120'), Decimal('250'), Decimal('630')] latency_seconds = [value / Decimal('1000') for value in latency_ms] threshold_seconds = Decimal('0.300') correct_good = sum(value <= threshold_seconds for value in latency_seconds) rename_only_good = sum(value <= threshold_seconds for value in latency_ms) transferred_bytes = 1024 * 1024 used_ratio = Decimal('25') / Decimal('100') assert correct_good == 2 and rename_only_good == 0 assert sum(latency_seconds) == Decimal('1.000') assert used_ratio == Decimal('0.25') print(f"# within 0.300s: converted={correct_good}/3, rename_only={rename_only_good}/3") print('# HELP demo_last_request_duration_seconds Duration of the most recent completed request.') print('# TYPE demo_last_request_duration_seconds gauge') print(f'demo_last_request_duration_seconds {latency_seconds[-1]:.3f}') print('# HELP demo_transferred_bytes_total Bytes transferred since this process started.') print('# TYPE demo_transferred_bytes_total counter') print(f'demo_transferred_bytes_total {transferred_bytes}') print('# HELP demo_capacity_used_ratio Fraction of configured capacity currently used.') print('# TYPE demo_capacity_used_ratio gauge') print(f'demo_capacity_used_ratio {used_ratio}') 首行注释说明,转换后有三分之二的观测不超过零点三秒,而把毫秒数值直接当成秒时,三条都无法满足阈值。导出的最后请求耗时为零点六三秒,累计字节数为一百零四万八千五百七十六,容量使用比例为零点二五,在百分比界面上对应百分之二十五。三个数分别对应持续时间、累计大小与无量纲比例,不能因为都能画成曲线就混用。 ...

十月 30, 2026 · 1 分钟 · XB Field Notes
单个响应记录由 URL、表示、观测点与时间限定范围。旁边,多条观察形成聚合趋势,来源关系回指原始记录。

可读的证据优先于仪表盘

当论断必须经受审计时,选择可核验的句子而不是彩色面板。

八月 18, 2026 · 1 分钟 · XB Field Notes