从 0 构建 AI Workload Platform(七):可观测性、故障注入与性能验证

摘要

我正在从零开发 AI Workload Platform:一个面向 Agent 与确定性程序任务的可靠运行时和调度平台。Agent(智能体) 是能够调用模型和工具、根据执行结果继续决定后续动作的程序;确定性程序任务则按照预先定义的代码和输入执行。工作流(Workflow) 是按依赖关系组织的多步骤执行定义,平台负责管理两类任务共有的工作流依赖、状态、重试、取消、恢复和执行节点。

模块 4 已经把任务交给多个独立 Worker 执行,并用 PostgreSQL 关系数据库保存分发记录、租约和执行结果。它证明了 Worker 失联后任务能够重新分配,但还不能系统回答故障发生在哪里、影响范围多大、恢复是否完成,以及增加诊断代码后性能是否发生变化。

模块 5 因此增加结构化日志、可聚合指标、跨组件调用链、告警状态机和测试专用故障注入,并在真实 PostgreSQL 与多进程 Worker 上复现八类故障、五类告警和四种观测配置的性能对照。本文不把日志和指标当作业务状态,也不把一次本机基准写成生产容量,而是从数据契约、失败边界和实验方法解释怎样建立可验证的可观测性。

目录

  1. 为什么多 Worker 之后需要可观测性
  2. 可观测性是什么
  3. 观测数据为什么不是业务事实
  4. 结构化日志怎样建立关联
  5. Counter、Gauge 和 Histogram 分别记录什么
  6. 低基数为什么是指标设计边界
  7. Trace、Span 与上下文传播
  8. 告警不是一次条件判断
  9. 滚动窗口、最小样本量与滞回
  10. 故障注入怎样证明恢复行为
  11. 性能数据怎样避免误导
  12. 技术选型与替代方案
  13. 真实验证与性能结果
  14. 当前限制与下一步
  15. 总结

1. 为什么多 Worker 之后需要可观测性

控制面(Control Plane) 是接收请求、保存运行状态并决定任务何时可以执行的服务部分。Worker(执行节点) 是从控制面领取并实际执行任务的独立进程。模块 4 在二者之间建立了持久化分发协议:

text 复制代码
控制面创建 Dispatch
  -> Worker 领取租约
  -> Worker 周期发送 Heartbeat
  -> Worker 提交 Complete
  -> PostgreSQL 原子推进 Task、Attempt 和 Dispatch 状态

Dispatch(分发记录) 表示一个任务已经进入可领取队列;租约(Lease) 表示 Worker 在有限时间内拥有执行权;Heartbeat(心跳) 用于报告 Worker 存活并续期租约;Complete(完成请求) 用于提交一次执行结果。

Run(运行实例) 是某个工作流版本的一次实际运行;Task(任务) 是 Run 中一个可调度工作单元;Attempt(执行尝试) 是 Task 的一次真实执行记录。

这条链路能够恢复,并不等于维护者能够快速解释它。只查询最终 Run 状态,无法区分以下情况:

  • 任务仍在等待上游依赖;
  • Dispatch 已经创建,但没有可用 Worker;
  • Worker 正在执行,尚未提交结果;
  • Complete 请求失败,Worker 正在重试;
  • 租约已经过期,协调器尚未完成回收;
  • PostgreSQL 连接池已经接近耗尽。

如果没有统一的诊断数据,维护者需要临时拼接进程输出、数据库表和请求结果。更严重的是,无法稳定复现故障时,"看起来恢复了"不能证明状态机在并发和中断下仍然正确。

模块 5 不重新设计模块 4 的租约和状态机,而是在已有操作边界旁增加诊断能力,并用故障实验检查这些诊断数据能否解释真实恢复过程。

2. 可观测性是什么

可观测性(Observability) 是通过系统输出推断内部状态的能力。它不是某一个软件名称,也不等于"多打日志"。本模块使用三类基础信号:

信号 适合回答的问题 本项目中的例子
日志(Log) 某个具体操作发生了什么 哪个 Worker 的哪次 Complete 返回 lease_lost
指标(Metric) 一段时间内发生多少、当前有多少 当前队列深度、在线 Worker 数、HTTP 错误量
调用链(Trace) 一次请求经过哪些组件、各阶段耗时多少 Worker Client 与控制面处理同一次 Heartbeat 的父子 Span(单个操作记录)

表中的 HTTP 是 Hypertext Transfer Protocol(超文本传输协议),它是控制面与客户端、Worker 交换请求和响应时使用的网络协议。三类信号解决的问题不同:

  • 日志可以保存单次操作的高基数标识,但大量日志不适合直接计算长期趋势;
  • 指标适合聚合、绘图和告警,但必须限制标签取值数量;
  • Trace 适合查看一次请求内部的时间关系,但需要采样、传播和存储策略。

一个成熟观测系统通常还包括采集代理、长期存储、查询语言、图表和通知平台。模块 5 先验证应用侧契约,不部署完整监控后台。当前提供的是日志、标准时序指标文本、开放标准调用链,以及通过 HTTP 回调发送的告警通知。

3. 观测数据为什么不是业务事实

业务事实源(Source of Truth) 是系统判断正式状态时唯一可信的数据来源。本项目中,Workflow、Run、Task、Attempt、Dispatch 和租约的正式状态仍由 PostgreSQL 与工作流状态机保存。

日志、指标和 Trace 都可能丢失:

  • 状态事务提交后,进程可能在写日志前崩溃;
  • 指标保存在进程内存中,重启后会重新开始;
  • Trace 可能因采样被跳过,也可能因导出队列已满被丢弃;
  • 告警 HTTP 回调接收器可能超时或返回错误。

因此不能根据"一条成功日志"直接认定数据库状态已经提交,也不能因为"没有看到 Trace 记录"就认定操作没有发生。诊断过程应先确定问题范围,再回到状态 API、事件历史和 PostgreSQL 已提交记录确认最终事实。API(Application Programming Interface,应用程序编程接口) 是组件对外提供的结构化调用契约。

这个边界还影响指标设计。Run、Task、Attempt 和 Dispatch 的状态变化发生在 PostgreSQL 事务内。如果应用在事务外根据返回值增加生命周期 Counter(只增不减的累计计数器),可能出现两类不一致:

  1. 事务已经提交,进程在 Counter 增加前崩溃,计数偏少;
  2. 客户端没有收到响应而重放请求,应用重复增加 Counter,计数偏多。

所以模块 5 没有发布从事务外推测的 Run、Task、Attempt 和 Dispatch 生命周期总数。以后如果确实需要严格累计值,应从已提交事件或 outbox(事务内事件待发布表) 派生。Outbox 与业务状态在同一事务写入,提交后再异步发布,能够避免"业务状态成功但事件没有留下记录"的窗口。

4. 结构化日志怎样建立关联

结构化日志(Structured Logging) 是由稳定字段组成的键值记录。与一整段自由文本相比,固定字段更容易被程序过滤、聚合和建立查询条件。

模块 5 使用 Go 标准库 log/slog,统一支持 textjson 两种格式。text 便于本地终端阅读,json 便于日志采集系统解析。slog 的 Handler(日志处理器) 负责级别过滤、字段处理和最终编码。一次 Worker Complete 失败可以形成以下结构:

text 复制代码
level=WARN msg="worker operation failed" request_id=req_01 run_id=run_01 task_key=summarize attempt=2 worker_id=worker_01 dispatch_id=dispatch_01 operation=complete duration_ms=12 error_code=lease_lost

这些字段分别承担明确职责:

字段 含义
request_id 一次 HTTP 请求的标识
run_idtask_keyattempt 定位工作流运行中的一次任务尝试
worker_iddispatch_id 定位分布式执行者和分发记录
operation 有限操作名,例如 claimheartbeatcomplete
duration_ms 当前操作耗时,单位为毫秒
error_code 稳定错误类别,不使用任意错误文本做分类
trace_idspan_id 关联调用链中的具体 Trace 和 Span;Span 是其中一个操作记录

字段"可以省略"和"可以伪造默认值"是两件事。如果某个组件还不知道 RunID,就应省略该字段,不能生成一个看似真实但无法对应数据库记录的 ID。

4.1 日志级别

日志级别表示事件的重要程度:

  • DEBUG 记录轮询和成功的高频协议操作,默认生产配置可以关闭;
  • INFO 记录 Run、Worker 和恢复等生命周期事件;
  • WARN 记录可恢复错误、租约拒绝和告警通知失败;
  • ERROR 记录使当前进程无法继续履责的错误。

成功的 Claim、Heartbeat 和 Complete 属于高频操作。如果全部使用 INFO,正常流量会淹没真正需要关注的生命周期信息,因此本项目把成功协议操作放在 DEBUG,失败操作放在 WARN。

4.2 脱敏与长度限制

脱敏(Redaction) 是在日志写出前移除或替换凭据和敏感内容。模块 5 的日志 Handler 会过滤 Bearer Token、密码、API Key、Worker Session Token 和租约令牌等常见格式;生产错误文本还限制为 512 字节。

长度限制不仅控制日志量,也避免外部依赖返回超长正文。UTF-8(8-bit Unicode Transformation Format,8 位 Unicode 转换格式) 是 Go 字符串常用的可变长度字符编码;按字节截断时必须保证编码完整,否则一个中文字符可能在中间被切断,产生无效文本。完整任务输入、模型提示词和任务结果不进入通用日志;需要保存业务产物时,应使用后续专门设计的产物存储和访问权限。

5. Counter、Gauge 和 Histogram 分别记录什么

指标(Metric) 是可以随时间采集和聚合的数值。Prometheus 是一套开源监控与时序指标生态,其指标模型常用三种类型。

5.1 Counter:累计发生多少次

Counter(计数器) 只能增加,适合记录累计事件。例如:

text 复制代码
workload_http_requests_total
workload_alerts_total
workload_db_pool_wait_seconds_total

Counter 重启后可以从零开始,查询端通常使用一段时间内的增长量或增长速率,而不是把进程启动以来的绝对值当作永久总数。

5.2 Gauge:当前有多少

Gauge(仪表值) 可以增加也可以减少,表示某一时刻的当前状态。例如:

text 复制代码
workload_queue_depth
workload_workers
workload_active_leases
workload_db_pool_connections

队列任务被领取后,队列深度会下降;Worker 离线后,在线数量也会下降。这类值不适合 Counter,因为它们不是只增不减的累计事件。

5.3 Histogram:数值分布是什么

Histogram(直方图) 把每次观测值放入预先定义的区间,并同时记录样本数和总和。它适合请求耗时与租约回收耗时:

text 复制代码
workload_http_request_duration_seconds
workload_operation_duration_seconds
workload_lease_reclaim_duration_seconds

Histogram 保存的是多个独立样本的分布。例如每次 Complete 花费 5 ms、8 ms、20 ms,就应该分别调用三次 Observe

5.4 为什么数据库累计等待时间不能重复写入 Histogram

pgx 是本项目使用的 PostgreSQL Go 驱动和连接池。它提供的 AcquireDuration 是"进程启动以来等待数据库连接的累计时长",不是最近一次等待耗时。

假设连续三次读取快照得到:

text 复制代码
1 秒 -> 3 秒 -> 6 秒

如果把 1、3、6 分别写入 Histogram,Histogram 会把它们误认为三次独立等待,得到总和 10 秒;真实累计增长只有 6 秒。模块 5 因此把相邻快照增量写入 Counter:

text 复制代码
第一次增加 1 秒
第二次增加 2 秒
第三次增加 3 秒
最终 Counter 为 6 秒

如果 pgx 连接池重建导致累计值变小,采集器先把上次快照归零,再从新累计周期计算增量。这个例子说明,选择指标类型前必须先确认源数据是"单次样本""当前值"还是"累计值"。

6. 低基数为什么是指标设计边界

标签(Label) 是指标旁用于分组的键值。以下指标可以按方法、路由模板和状态分组:

text 复制代码
workload_http_requests_total{method="GET",route="/api/v1/runs/{run-id}",status="200"}

基数(Cardinality) 是一组标签可能形成的不同组合数量。GETPOST 等 HTTP 方法只有少量稳定取值,属于低基数。RunID、TaskKey、WorkerID 和 DispatchID 会不断产生新值,属于高基数。

如果给每个 Run 建立一条指标时间序列,10 万个 Run 再乘以状态、路由和错误码,会迅速形成大量序列。每条序列都需要内存、磁盘和索引,查询与告警成本也会增加。因此模块 5 采用两层策略:

  • 指标只使用有限白名单标签,例如 operationoutcomeerror_code、HTTP 方法和路由模板;
  • 单个 Run 的定位使用日志、Trace、状态 API 和数据库事件。

原始 URL 也不能直接作为标签。/api/v1/runs/run-a/api/v1/runs/run-b 必须归一化为同一个路由模板 /api/v1/runs/{run-id}。未知标签值统一映射为 unknown,不能因为新增任意字符串而动态扩展序列。

/metrics 是只读取进程内 Registry 的 HTTP 端点。Registry(注册器) 保存当前进程已经注册的指标收集器。该端点不查询业务数据库,避免监控抓取反过来增加数据库压力;需要数据库快照的 Gauge 由协调器周期刷新。

7. Trace、Span 与上下文传播

Trace(调用链) 表示一次请求经过多个组件的完整时间关系。Span(跨度) 表示 Trace 中一个有开始时间、结束时间、状态和属性的操作。一个 Trace 由多个具有父子关系的 Span 组成。

模块 5 使用 OpenTelemetry。OpenTelemetry(开放遥测) 是定义日志、指标和 Trace API、上下文传播及导出协议的一组开放标准和实现。一次 Complete HTTP 请求当前可以形成:

text 复制代码
worker.complete                         Worker Client Span
  `- HTTP POST /workers/.../complete    控制面 HTTP Span
       `- worker.complete               控制面 Worker API Span

三个 Span 使用相同 TraceID,并通过不同 SpanID 和父 SpanID 表示层级。成功操作设置 Ok 状态;失败操作设置 Error 和有限 error_code,不把原始 Token、任务输入或结果写入属性。

7.1 W3C Trace Context

W3C(World Wide Web Consortium,万维网联盟)Trace Context 是在 HTTP 请求之间传播 Trace 身份的标准。Worker Client 把当前上下文写入 traceparent 请求头,控制面提取后创建子 Span。这里的 Context(上下文) 是随调用传递 Trace 身份、取消信号和截止时间的数据载体。该标准解决的是"同一次同步 HTTP 调用怎样跨进程保持父子关系"。

如果请求头缺失或格式非法,服务端创建新的根 Span。RequestID 与 TraceID 也不是同一概念:RequestID 用于日志和 API 排查,TraceID 用于调用链关系;二者可以同时出现,但不能互相伪造。

7.2 为什么整条 Run 还不是一条 Trace

Run 创建、Coordinator 异步扫描和 Worker 稍后领取不是同一个同步调用栈:

text 复制代码
创建 Run 请求结束
  -> 状态提交到 PostgreSQL
  -> 一段时间后 Coordinator 扫描
  -> 创建 Dispatch
  -> 又一段时间后 Worker Claim

原始 HTTP Context 在第一个请求结束后已经释放。当前 Dispatch 没有持久化 Trace Context,因此不能声称"创建 Run 到所有任务完成"自动属于一条 Trace。

要跨越异步边界,需要决定保存哪些 Trace 字段、保存多久、谁能读取、重试时继续旧 Trace 还是建立 Span Link。Span Link(跨度链接) 用于表示当前 Span 与另一个 Trace 或 Span 有因果关系,但不是严格父子调用。这个设计还会增加数据量和访问控制问题,所以模块 5 只保证单次 Claim、Heartbeat、Complete HTTP 请求的父子关系;整条 Run 继续通过 RunID、TaskKey、WorkerID 和 DispatchID 日志关联。

7.3 采样与批处理导出

采样(Sampling) 决定一个 Span 是否需要记录和导出。off 模式使用 NeverSample,不创建可记录 Span;stdout 模式只用于本地检查。

同步导出会让业务 goroutine 在 Span.End 时等待 Writer。goroutine 是 Go 运行时调度的轻量级并发执行单元;Writer(写入器) 是接收字节输出的 Go 接口。如果终端或网络后端变慢,诊断旁路就会直接增加业务延迟。当前 stdout 模式使用 Batch Span Processor(批量 Span 处理器):结束的 Span 先进入容量为 256 的有界队列,后台按最多 64 条一批导出,批处理等待上限为 100 ms,单次导出超时为 1 秒。

这样能够隔离短暂 Writer 延迟,但不是零丢失方案。队列已满时允许丢弃 Span;服务关闭时会在给定 Context 内刷新并停止 TracerProvider(调用链提供器),它负责统一配置采样、Span 处理和生命周期。观测系统的优先级低于任务状态提交,不能为了保存每条 Span 无限阻塞 Complete。

8. 告警不是一次条件判断

告警(Alert) 是聚合状态持续满足规则后产生的通知。它与"某次检查返回 true"不同,还需要保存跨检查周期的状态。

模块 5 的规则引擎使用以下状态:

text 复制代码
正常
  -> 条件首次满足:pending
  -> 持续时间满足:firing,发送一次触发通知
  -> 异常继续:保持 firing,不重复发送
  -> 恢复条件首次满足:进入恢复等待
  -> 恢复持续时间满足:resolved,发送一次恢复通知

如果必需输入缺失,规则进入 unknownunknown(未知) 表示当前证据不足,既不能确认异常仍在,也不能把它误判为恢复。unknown 只在状态变化时通知一次,持续缺少数据不会每秒重复发送。

模块 5 验证五类规则:

规则 触发条件 恢复条件
队列堆积 队列有任务且没有可用槽位,持续 10 秒 队列清空或出现可用槽位,持续 5 秒
Worker 全离线 队列有任务但在线 Worker 为 0,持续 5 秒 至少一个 Worker 在线,持续 5 秒
租约回收错误 30 秒窗口内出现回收错误 连续一个窗口没有回收错误
Complete 错误率 30 秒窗口内至少 20 个样本,错误率大于 5%,持续 30 秒 样本仍足够且错误率低于 1%
连接池接近耗尽 使用率大于等于 80%,持续 10 秒 使用率低于 70%,持续 10 秒

这些数值是本地实验默认值,不是 SLO(Service Level Objective,服务等级目标)。生产阈值必须结合真实流量、允许延迟和支持流程重新确定。

8.1 去重和非阻塞通知

同一规则使用固定名称作为去重键,不带 RunID、TaskKey 或 WorkerID。队列中有 1,000 个任务时,系统性 Worker 离线应该形成一条告警,而不是 1,000 条通知。

通知通过 AlertSink 接口发送;该接口把规则状态机与具体发送方式隔离,当前实现是本地 HTTP Webhook。Webhook 是由发送方主动向指定 HTTP 地址提交事件的一种回调方式。负载只包含规则名、状态、摘要、开始与结束时间、规则版本和有限标签。当前标签白名单只有 componentseverity,并且两者的值也来自固定集合;RunID、TaskKey、Token 或任意调用方字符串会被丢弃。超时、网络错误和 5xx 服务端错误使用有限重试,4xx 客户端错误不会通过重复请求解决,因此只发送一次。

告警 Runner(周期执行器)使用有界异步发送循环。Webhook 变慢或失败只记录通知结果,不阻塞下一轮数据库快照采集,也不能阻塞任务调度和状态提交。

9. 滚动窗口、最小样本量与滞回

9.1 为什么错误率需要时间窗口

如果使用进程启动以来的累计 Complete 数量,系统运行越久,早期大量成功样本越容易掩盖最近故障。模块 5 使用 30 秒滚动窗口,只统计近期 Complete 总数、Complete 错误数和租约回收错误数。

实现使用 64 个秒级固定桶。每个桶保存某一秒的计数,当前秒通过时间戳对数组长度取余定位。读取 30 秒窗口时只汇总时间范围内的桶:

text 复制代码
固定 64 个桶
  -> 当前秒覆盖对应旧桶
  -> 查询时汇总最近 30 秒
  -> 内存占用不随进程运行时间增长

固定容量的代价是只能准确查询不超过桶容量的近期窗口。模块 5 的规则固定使用 30 秒,因此 64 个桶能够覆盖需求并容纳时间边界。

9.2 为什么要有最小样本量

只有 1 个 Complete 且恰好失败时,错误率是 100%,但样本不足以说明系统性问题。Complete 错误率规则要求窗口内至少 20 个样本,避免低流量下一个错误立即触发比例告警。

最小样本量也有代价:低流量系统可能长期达不到 20 个样本,此时规则无法仅凭比例发现问题。生产方案可以同时保留"连续错误次数"和"错误率"两类规则,但必须分别定义用途,不能让一个阈值承担所有流量形态。

9.3 为什么触发和恢复阈值不同

滞回(Hysteresis) 是使用不同的触发和恢复阈值,避免数值在边界附近反复切换状态。例如连接池使用率:

text 复制代码
达到 80%:允许触发
降到 79%:仍保持 firing
低于 70% 且持续满足:恢复

Complete 错误率同样使用"大于 5% 触发、低于 1% 恢复"。如果触发和恢复都使用 5%,4.9% 与 5.1% 的轻微波动会不断产生 firing 和 resolved,增加通知噪声。

10. 故障注入怎样证明恢复行为

故障注入(Fault Injection) 是在受控测试环境中主动加入错误、延迟、取消、进程退出或资源耗尽,用实际结果验证恢复行为。它与在代码中随意返回错误不同:每个实验必须明确注入点、次数、预期状态、恢复条件和证据边界。

模块 5 使用构造函数传入故障 Plan(计划)。构造函数注入 是创建组件时显式提供依赖或测试替身,而不是让生产代码读取隐藏的全局开关。Plan 为指定操作保存按顺序消费的 Action:

text 复制代码
OperationComplete:
  第一次 -> 返回一次性错误
  第二次 -> 不再注入,调用真实 Repository

Plan 内部使用锁保护并深拷贝动作列表,使并发测试不会共享消费位置。延迟等待响应 Context 取消;Plan 关闭后不再执行新动作。生产 HTTP API 没有故障注入路由,也不读取 WORKLOAD_FAULT_* 环境变量。

10.1 故障真实度必须分级

不同注入方法证明的范围不同:

级别 示例 能证明什么 不能证明什么
调用边界错误 Repository 前返回一次错误 调用方错误处理和下一次调用恢复 真实网络断开、数据库重启和协议行为
真实资源竞争 用 pgx 单连接池占满连接 Context 超时和释放连接后恢复 多节点数据库容量与云网络抖动
真实进程故障 强杀一个测试 Worker 进程 租约过期、Attempt 中断和其他 Worker 接管 机器掉电、磁盘故障和跨主机网络分区
真实时间状态 等待租约过期并提交迟到结果 旧租约拒绝和新租约完成 外部副作用能够自动撤销

postgres-unavailable 场景是在真实 Repository 前注入一次错误。Repository(仓储接口) 隔离应用逻辑和 PostgreSQL 持久化操作;该场景没有停止共享 Docker PostgreSQL,只能证明调用方在一次 Repository 错误后能够继续,不等价于 PostgreSQL 进程崩溃恢复。

Worker 强杀使用真实子进程 Kill,连接池耗尽使用真实 pgx 连接池,这两类实验比函数返回错误更接近目标故障,但仍然只发生在本机测试环境。

10.2 故障实验需要哪些证据

一次可靠实验至少记录:

  1. 环境和输入规模;
  2. 注入操作与发生时机;
  3. 预期中间状态和最终状态;
  4. 日志、指标、Trace 或数据库记录;
  5. 恢复耗时;
  6. 没有覆盖的真实故障。

只证明"测试函数返回成功"不够。例如 Worker 强杀实验还应看到第一次 Attempt 为 interrupted、第二次 Attempt 为 succeeded,并确认 Run 最终收敛为 succeeded

11. 性能数据怎样避免误导

11.1 输入必须可比较

模块 5 的每组基准都处理 1,000 个无依赖 Mock(模拟)任务,它们不执行真实业务动作。测试使用同一台 Mac mini M4、同一 PostgreSQL 16 Docker Compose 镜像和相同连接池上限。Docker Compose 使用配置文件定义并启动一组本地容器,本项目当前只用它提供 PostgreSQL。变量只有三组:

  • Worker 数量:1、4、16;
  • Run 形态:单个 1,000 任务 Run,或 8 个各 125 任务 Run;
  • 观测配置:offlogslogs_metricslogs_metrics_tracing

每个组合先预热 1 轮,再正式执行 5 轮。预热(Warm-up) 用于提前完成编译缓存、连接建立和代码路径初始化,预热数据不进入正式结果。

11.2 吞吐和延迟回答不同问题

吞吐(Throughput) 表示单位时间完成的任务数,本报告使用 tasks/s。延迟(Latency) 表示一次操作从开始到结束的时间,分别记录 Claim、Heartbeat 和 Complete。

P50、P95 和 P99 是延迟的 百分位数(Percentile)

  • P50 表示 50% 的样本不超过该值;
  • P95 表示 95% 的样本不超过该值;
  • P99 表示 99% 的样本不超过该值。

P99 比 P50 更能反映少量慢请求,但 1,000 个本机样本仍不能代表生产网络中的长期尾延迟。

11.3 为什么报告中位数和范围

每个组合有 5 个正式吞吐结果。中位数(Median) 是排序后位于中间的值;范围(Range) 是最小值到最大值。中位数不容易被单个离群值拉动,范围则保留波动信息。

离群值(Outlier) 是明显偏离其余样本的观测值。它不应被无声删除,因为它可能来自系统真实抖动;也不能只用一个离群值判断某种观测配置必然更慢。本文同时报告中位数和最小至最大范围,不只报告均值。

比较观测开销时还要检查差值是否大于自然波动。如果 off 的五轮范围已经覆盖 logs_metrics 的中位数,就不能把一个很小的百分比差异解释为确定开销。Trace 基准写入 io.Discard;它是 Go 标准库提供的丢弃型 Writer,接收数据但不保存。因此基准只测量应用侧 Span 创建、排队和批处理,不代表网络导出与后端存储成本。基准还直接调用 Repository,没有经过生产 HTTP 中间件、Coordinator 聚合查询和 Webhook,也没有采集最大队列、连接池峰值、CPU、goroutine 或 Span 丢弃数;这些未测路径不能从表格差值反推。

11.4 单 Run revision 为什么限制并发

revision(修订号) 是 Run 每次状态提交后递增的版本号,用于拒绝旧快照覆盖新状态。同一个 Run 的 1,000 个任务共享一条 revision 序列,因此每次 Claim 和 Complete 都会竞争同一个 Run 的串行提交点。

8 个 Run 各自拥有独立 revision,可以并行提交。基准中多 Run 吞吐明显高于单 Run,说明增加 Worker 不能绕过单 Run 状态序列化。这不是观测代码制造的新限制,而是当前一致性模型的性能证据。

基准还记录 B/opallocs/op:前者表示每轮分配的内存字节数,后者表示内存分配次数。单 Run 路径需要反复加载和推进较大的完整 Run 状态,内存分配远高于 8×125 多 Run 路径。这里的数字用于发现优化方向,不能直接换算成生产容量。

12. 技术选型与替代方案

12.1 为什么继续使用 log/slog

项目已有 slog 请求日志,标准库支持结构化字段、级别和 Handler 包装。Zap 和 Zerolog 是两套常用的 Go 高性能结构化日志库;当前没有证据证明日志编码是主要瓶颈,因此没有引入它们,避免同时维护两套日志 API。代价是极端高日志吞吐下性能可能不如专用库;只有基准证明 slog 成为主要瓶颈时才值得迁移。

12.2 为什么选择 Prometheus 指标模型

Prometheus 官方 Go Client 提供 Counter、Gauge、Histogram、标签约束和标准文本输出,测试可以直接读取独立 Registry。StatsD 是通过网络发送计数和耗时的轻量指标协议;本模块需要显式标签、Histogram 契约和测试内直接读取,因此没有选择它。项目也没有自研指标格式,因为自研会增加采集、兼容和查询成本。当前只提供 /metrics,没有部署 Prometheus 服务和 Grafana 可视化后台。

12.3 为什么选择 OpenTelemetry

OpenTelemetry 的 API 和上下文传播不绑定 Jaeger、Tempo 或某个商业后端;Jaeger 和 Tempo 都是存储、查询 Trace 的后端。自动化测试为内存中的采样 Provider 注册 SpanRecorder(Span 记录器) ,它保存测试期间已经结束的 Span,供断言父子关系、状态和属性;stdout 导出器则适合本地检查。没有选择厂商专用 SDK(Software Development Kit,软件开发工具包),是为了保留后端替换能力;没有立即部署 Jaeger 或 Tempo,是因为本模块先验证 Span 契约和传播边界。代价是新增 SDK 生命周期、采样和导出队列配置。

12.4 为什么先使用进程内告警与 Webhook

当前还没有长期 Prometheus 服务,因此使用进程内规则状态机能够直接验证持续时间、去重、unknown 和恢复逻辑;AlertSink 接口使通知方式可以替换。Alertmanager 是 Prometheus 生态中负责告警去重、分组、路由和通知的组件;现在接入它会同时增加一套外部配置和部署。项目也没有接入邮件或企业即时通信,因为开源仓库不能依赖个人账号和真实凭据。代价是进程停止后告警状态不会保留,不能承担生产级监控职责。

12.5 为什么使用构造函数级故障注入

构造函数注入让每个测试拥有独立故障计划,不需要修改生产请求协议。没有开放故障 HTTP API,是为了避免远程破坏入口;没有只依赖 Mock Repository,是因为关键实验仍需要真实 PostgreSQL、pgx 连接池和真实 Worker 进程。代价是部分调用边界故障的真实度有限,必须在报告中明确分级。

13. 真实验证与性能结果

13.1 故障与告警结果

模块 5 在本地 PostgreSQL 16 上完成八类故障实验:Repository 一次性不可用、Claim 延迟取消、Complete 一次性错误重试、Heartbeat 一次性错误恢复、协调锁检查错误、Worker 进程强杀、租约过期与迟到结果、连接池耗尽。

Worker 强杀后,第二个 Worker 在约 2.05 秒内接管;第一次 Attempt 记录为 interrupted,第二次 Attempt 为 succeeded,Run 最终为 succeeded。租约过期实验中,旧 Worker 的迟到 Complete 返回 lease_lost,新租约提交成功。

五类告警使用本地 HTTP Webhook 接收器完成端到端验证。每条规则持续满足后只发送一次 firing,恢复条件持续满足后只发送一次 resolved,共收到 10 个生命周期事件。Webhook 返回 5xx 或超过客户端超时时,告警 Runner 仍继续采集快照;4xx 不重试,任意标签被过滤,通知失败没有阻塞调度路径。

13.2 正式性能矩阵

下面两张表给出五轮正式运行的总耗时中位数与最小至最大范围。每个组合都处理 1,000 个任务;总耗时越小、吞吐越高。

单个 1,000 任务 Run:

Worker 观测配置 总耗时中位数(范围) 吞吐中位数(范围)
1 off 46.754 s(46.060~47.279) 21.39 tasks/s(21.15~21.71)
1 logs 47.214 s(46.212~77.671) 21.18 tasks/s(12.87~21.64)
1 logs_metrics 47.703 s(47.528~77.726) 20.96 tasks/s(12.87~21.04)
1 logs_metrics_tracing 47.651 s(47.243~49.970) 20.99 tasks/s(20.01~21.17)
4 off 25.804 s(25.634~30.521) 38.75 tasks/s(32.76~39.01)
4 logs 26.558 s(25.903~30.116) 37.65 tasks/s(33.20~38.61)
4 logs_metrics 26.087 s(25.600~30.066) 38.33 tasks/s(33.26~39.06)
4 logs_metrics_tracing 26.577 s(25.735~30.256) 37.63 tasks/s(33.05~38.86)
16 off 27.130 s(26.069~67.528) 36.86 tasks/s(14.81~38.36)
16 logs 28.046 s(27.089~66.976) 35.66 tasks/s(14.93~36.91)
16 logs_metrics 27.157 s(25.939~30.714) 36.82 tasks/s(32.56~38.55)
16 logs_metrics_tracing 28.863 s(25.952~69.232) 34.65 tasks/s(14.44~38.53)

8 个各 125 任务的 Run:

Worker 观测配置 总耗时中位数(范围) 吞吐中位数(范围)
1 off 35.604 s(35.472~35.706) 28.09 tasks/s(28.01~28.19)
1 logs 35.624 s(35.611~35.652) 28.07 tasks/s(28.05~28.08)
1 logs_metrics 35.650 s(35.474~35.689) 28.05 tasks/s(28.02~28.19)
1 logs_metrics_tracing 35.576 s(35.455~35.659) 28.11 tasks/s(28.04~28.20)
4 off 7.513 s(6.384~7.867) 133.1 tasks/s(127.1~156.6)
4 logs 7.115 s(6.338~8.281) 140.5 tasks/s(120.8~157.8)
4 logs_metrics 7.072 s(6.247~7.662) 141.4 tasks/s(130.5~160.1)
4 logs_metrics_tracing 6.745 s(6.655~6.876) 148.3 tasks/s(145.4~150.3)
16 off 6.079 s(5.877~6.357) 164.5 tasks/s(157.3~170.2)
16 logs 5.797 s(5.693~6.655) 172.5 tasks/s(150.3~175.7)
16 logs_metrics 5.992 s(5.714~6.213) 166.9 tasks/s(160.9~175.0)
16 logs_metrics_tracing 5.726 s(5.663~6.409) 174.6 tasks/s(156.0~176.6)

单 Run 中,三种观测配置相对同组 off 的中位耗时差值约为 0.10%~6.39%,但多个组合存在 30~69 秒或约 77 秒的离群轮次。多 Run 中,部分观测配置的中位耗时反而比 off 低 1.43%~10.22%。观测代码不会因此让状态提交更快;负差值说明这些顺序执行的本机样本包含数据库缓存、系统调度和运行时波动。当前证据只支持"应用侧观测开销没有稳定大到压过样本波动",不支持一个可推广到所有输入的固定开销百分比。

更明确的结果来自 Run 形态。关闭观测时,1、4、16 Worker 的单 Run吞吐中位数为 21.39、38.75、36.86 tasks/s;多 Run 为 28.09、133.1、164.5 tasks/s。16 Worker 没有继续提高单 Run 吞吐,而多 Run 能利用独立 revision 并行提交,符合第 11.4 节的串行点分析。

关闭观测时,单 Run 每轮内存分配中位数约为 10.7 GiB、分配次数约 1.02 亿;多 Run 约为 1.3 GiB、分配次数约 1,350 万。GiB(Gibibyte,吉比字节) 按 1,073,741,824 字节计算。这里使用的 Go Benchmark(Go 基准测试) 会重复执行目标路径并统计时间和分配;这些数值是整轮 1,000 任务状态推进的累计分配量,不是进程同时占用的常驻内存,但已经说明完整 Run 快照推进值得在后续模块单独分析和优化。

完整原始输出保存在本机临时证据目录,不提交到 Git。仓库中的验证报告记录环境、正式汇总和限制;任何单次运行都不代表生产 SLA(Service Level Agreement,服务等级协议) 或容量承诺。

14. 当前限制与下一步

模块 5 已经建立应用侧可观测契约和可重复实验,但仍有以下边界:

  • PostgreSQL 仍是业务事实源,日志、指标、Trace 和 Webhook 都允许丢失或重启归零;
  • stdout Trace 导出和测试 SpanRecorder 不代表生产采集后端,有界队列也不承诺零丢失;
  • Run 创建与异步调度之间没有持久化 Trace Context,当前只能保证单次 Worker HTTP 请求的父子关系;
  • lease_reclaim_errors 规则状态机已经验证,但生产 Coordinator 遇到回收数据库错误会安全退出,不能保证退出前一定等到一个告警周期并发送通知;
  • postgres-unavailable 是一次性 Repository 包装器错误,不等价于真实网络分区或数据库进程崩溃;
  • 本地 Webhook 没有持久化告警状态、生产认证和真实通知路由;
  • 当前基准使用 Mock Executor、本机回环网络和单机 PostgreSQL,不包含模型调用、真实任务资源消耗或跨主机网络;
  • 性能矩阵直接调用 Repository,不包含生产 HTTP、Coordinator 聚合查询和 Webhook,也没有最大队列、连接池峰值、CPU、goroutine 或 Span 丢弃数;
  • 单 Run revision 是串行提交点,完整 Run 状态推进产生较高内存分配,仍需后续按性能证据优化。

模块 6 将处理另一类尚未解决的问题:Worker 当前仍只执行安全 Mock Action,没有真实资源和权限隔离。下一阶段会进入受限执行环境、容器和 Kubernetes,同时继续沿用模块 5 的日志、指标、Trace、告警和故障验证方法。容器与编排系统会增加新的网络、调度和节点故障,不能因为加入 Kubernetes 就默认获得可靠性。

15. 总结

模块 5 的核心不是增加几个输出端点,而是建立一组不能改变业务事实的诊断契约:结构化日志定位单次操作,低基数指标观察聚合趋势,Trace 串联同步请求,告警状态机处理持续异常与恢复,故障注入用可重复实验验证失败路径。

这组实现还给出四个重要结论:

  1. 状态事务与观测旁路必须分开,不能用事务外 Counter 伪造业务事实;
  2. 指标类型取决于源数据语义,累计值不能被当作多次 Histogram 样本;
  3. W3C Trace Context 能串联一次 HTTP 请求,但跨异步阶段需要额外持久化设计;
  4. 性能结论必须同时给出输入、轮次、中位数、范围和离群值,不能只选一个最好或最差数字。

可观测性让已经存在的执行语义更容易解释和验证,但它不会自动修复状态机、撤销外部副作用,也不会替代资源隔离和生产监控平台。

参考资料

相关推荐
蒲锘3 小时前
Devops项目阶段 10:Prometheus 监控与 Alertmanager 邮件告警
prometheus
我的div丢了肿么办4 小时前
go语言中基本数据类型的转换
后端·go
newerp1 天前
语法分析与 AST:Parser 与 go/ast
后端·程序员·go
newerp1 天前
Go 编译过程全景
后端·程序员·go
ZGi.ai1 天前
ZGI 运行日志:还原任务与节点状态
可观测性·工作流·企业ai·zgi·运行日志
stark张宇2 天前
Go语言runtime全景图:从编译到GC,带你彻底吃透Go的底层血脉
后端·go
名字还没想好☜2 天前
Go 用 slices/maps 标准库泛型函数:告别手写 Contains、Sort、去重(Go 1.21)
开发语言·后端·算法·golang·go
.柒宇.2 天前
运维常见面试题_07_配置管理与监控(Ansible · Prometheus · Alertmanager)· 容器编排(Kubernetes)
运维·k8s·ansible·prometheus
Akiyama_Mio-Kon3 天前
GitHub Code Quality 有了趋势面板后:怎样把 Agent 修复从“刷绿”变成可验证的质量闭环
devsecops·可观测性·代码质量·ai agent·codeql·github code·工程治理