摘要
我正在从零开发 AI Workload Platform:一个面向 Agent 与确定性程序任务的可靠运行时和调度平台。Agent(智能体) 是能够调用模型和工具、根据执行结果继续决定后续动作的程序;确定性程序任务则按照预先定义的代码和输入执行。工作流(Workflow) 是按依赖关系组织的多步骤执行定义,平台负责管理两类任务共有的工作流依赖、状态、重试、取消、恢复和执行节点。
模块 4 已经把任务交给多个独立 Worker 执行,并用 PostgreSQL 关系数据库保存分发记录、租约和执行结果。它证明了 Worker 失联后任务能够重新分配,但还不能系统回答故障发生在哪里、影响范围多大、恢复是否完成,以及增加诊断代码后性能是否发生变化。
模块 5 因此增加结构化日志、可聚合指标、跨组件调用链、告警状态机和测试专用故障注入,并在真实 PostgreSQL 与多进程 Worker 上复现八类故障、五类告警和四种观测配置的性能对照。本文不把日志和指标当作业务状态,也不把一次本机基准写成生产容量,而是从数据契约、失败边界和实验方法解释怎样建立可验证的可观测性。
目录
- 为什么多 Worker 之后需要可观测性
- 可观测性是什么
- 观测数据为什么不是业务事实
- 结构化日志怎样建立关联
- Counter、Gauge 和 Histogram 分别记录什么
- 低基数为什么是指标设计边界
- Trace、Span 与上下文传播
- 告警不是一次条件判断
- 滚动窗口、最小样本量与滞回
- 故障注入怎样证明恢复行为
- 性能数据怎样避免误导
- 技术选型与替代方案
- 真实验证与性能结果
- 当前限制与下一步
- 总结
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(只增不减的累计计数器),可能出现两类不一致:
- 事务已经提交,进程在 Counter 增加前崩溃,计数偏少;
- 客户端没有收到响应而重放请求,应用重复增加 Counter,计数偏多。
所以模块 5 没有发布从事务外推测的 Run、Task、Attempt 和 Dispatch 生命周期总数。以后如果确实需要严格累计值,应从已提交事件或 outbox(事务内事件待发布表) 派生。Outbox 与业务状态在同一事务写入,提交后再异步发布,能够避免"业务状态成功但事件没有留下记录"的窗口。
4. 结构化日志怎样建立关联
结构化日志(Structured Logging) 是由稳定字段组成的键值记录。与一整段自由文本相比,固定字段更容易被程序过滤、聚合和建立查询条件。
模块 5 使用 Go 标准库 log/slog,统一支持 text 和 json 两种格式。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_id、task_key、attempt |
定位工作流运行中的一次任务尝试 |
worker_id、dispatch_id |
定位分布式执行者和分发记录 |
operation |
有限操作名,例如 claim、heartbeat、complete |
duration_ms |
当前操作耗时,单位为毫秒 |
error_code |
稳定错误类别,不使用任意错误文本做分类 |
trace_id、span_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) 是一组标签可能形成的不同组合数量。GET、POST 等 HTTP 方法只有少量稳定取值,属于低基数。RunID、TaskKey、WorkerID 和 DispatchID 会不断产生新值,属于高基数。
如果给每个 Run 建立一条指标时间序列,10 万个 Run 再乘以状态、路由和错误码,会迅速形成大量序列。每条序列都需要内存、磁盘和索引,查询与告警成本也会增加。因此模块 5 采用两层策略:
- 指标只使用有限白名单标签,例如
operation、outcome、error_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,发送一次恢复通知
如果必需输入缺失,规则进入 unknown。unknown(未知) 表示当前证据不足,既不能确认异常仍在,也不能把它误判为恢复。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 地址提交事件的一种回调方式。负载只包含规则名、状态、摘要、开始与结束时间、规则版本和有限标签。当前标签白名单只有 component 与 severity,并且两者的值也来自固定集合;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 故障实验需要哪些证据
一次可靠实验至少记录:
- 环境和输入规模;
- 注入操作与发生时机;
- 预期中间状态和最终状态;
- 日志、指标、Trace 或数据库记录;
- 恢复耗时;
- 没有覆盖的真实故障。
只证明"测试函数返回成功"不够。例如 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;
- 观测配置:
off、logs、logs_metrics、logs_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/op 和 allocs/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 串联同步请求,告警状态机处理持续异常与恢复,故障注入用可重复实验验证失败路径。
这组实现还给出四个重要结论:
- 状态事务与观测旁路必须分开,不能用事务外 Counter 伪造业务事实;
- 指标类型取决于源数据语义,累计值不能被当作多次 Histogram 样本;
- W3C Trace Context 能串联一次 HTTP 请求,但跨异步阶段需要额外持久化设计;
- 性能结论必须同时给出输入、轮次、中位数、范围和离群值,不能只选一个最好或最差数字。
可观测性让已经存在的执行语义更容易解释和验证,但它不会自动修复状态机、撤销外部副作用,也不会替代资源隔离和生产监控平台。