切换主题
可观测性与排障
问题:任务一直在“处理中”
一个状态可能覆盖很多停滞:提交未送达、无合格节点、命令未 ACK、能力执行慢、事件未上传、回调失败。没有分阶段证据时,排障只能猜。
可观测性帮助从外部输出推断内部状态,但应结合持久化事实。日志说“准备发送”不代表消息已确认;日志说“执行完成”不代表结果已保存。
原理:从问题反推观测设计
日志描述离散事件,指标描述聚合变化,Trace 描述跨调用关联与耗时。稳定业务 ID 把多个进程的记录串起来;每个请求的 request_id 用于定位一次入口。
后台任务应记录阶段、重试、事件 ID、Attempt 和耗时。不要所有层都打印同一个错误形成噪声;核心包装错误,负责反馈或重试的边界记录决策。
最小例子:安全结构化日志
json
{
"level": "WARN",
"operation": "outbox.publish",
"event_id": "event-example",
"execution_id": "run-example",
"attempts": 3,
"error_kind": "unavailable",
"duration_ms": 800
}教学日志不包含凭据、签名 URL 或完整业务 payload。指标不把每个 event_id 当标签,避免高基数;需要精确 ID 时查询日志或状态库。
方案比较:选择能回答问题的信号
| 问题 | 证据 |
|---|---|
| 是否积压 | 待处理数量、最老记录年龄、吞吐 |
| 为什么没有节点 | 准入原因、队列年龄、Worker 可用状态 |
| 哪个阶段失败 | Execution/Attempt 状态与关联日志 |
| 重试是否有效 | 失败分类、attempts、下一次可用时间 |
| 用户能否使用 | readiness、关键行为与错误率 |
HTTP 200 比例不能说明长任务最终完成率。平均延迟会掩盖长尾与长期卡住工作;分位数与最老等待年龄更有价值。
真实案例:日志关联与当前能力
当前 Server/Worker 输出 slog JSON,日志包含 service/version/revision/instance_id 及可用业务 ID。各执行边界生成本地 trace_id,跨进程通过 attempt_id、command_id、event_id 关联。
已核对的实现 · 本地代码快照
日志关联与当前观测边界 backend · c6dbe05c
docs/implementation/logging.md · 第 27–68 行
符号:日志记录责任与关联 · 核对日期 2026-10-02
来源与提交版本一致
## 记录责任
- Core/Repository/Client 分类和包装错误;errors.Is/As 可继续检查原始 cause。
- HTTP 访问日志为 INFO;未预期错误由响应边界额外记录一次 ERROR,响应只返回安全 Problem Details。
- 认证无效为 401,禁止访问为 403,依赖不可用为 503,未知内部故障为 500。
- 消息处理失败记录投递次数、durable 和事件 ID,保持 NAK 延迟及原有最大投递次数;最终一次失败为 ERROR。
- 后台重试为 WARN,重试耗尽或进程退出为 ERROR;空轮询、正常关闭和预期取消不写错误日志。
- Worker 保留会话就绪、Attempt 开始/终态及失败决策日志;成功命令 ACK 降为 DEBUG,不为正常心跳新增 INFO 事件。HTTP 访问日志仍遵循上述统一规则。
- Worker Capability 已转换为 FAILED 事件的错误在转换处记录;事件上报失败由会话重试边界记录。
- timeline/审计辅助写入失败为 WARN,业务持久化错误继续返回。
- Temporal 使用 SDK 的 slog 适配器及 replay-aware Workflow logger;Activity 创建本地操作上下文。
## 关联与脱敏
基础字段为 service/version/revision/instance_id。操作日志附带 operation、trace_id,
以及当时已知的 request_id、worker_id、node_id、command_id、attempt_id、execution_id、event_id。
耗时统一为 duration_ms。异步错误通过 Failure 只保留来源操作的关联元数据,不持有整个 context。
独立请求、消息投递、轮询轮次、Worker 会话及命令创建本地 ID;命令启动的 Attempt、事件上报、
心跳及 SSE 重连复用所属范围的 ID。重试重新进入独立执行边界时才创建新 ID,跨进程使用业务 ID。
HTTP 的 request_id 与 trace_id 字段继续保留,以免改变现有日志及 Problem Details 消费方式。
这里的 trace_id 是日志关联 ID,不代表可以查询一棵 span 树。
PostgreSQL 和 SQLite 查询日志通常为 DEBUG,耗时达到阈值时为 WARN;
查询日志与边界错误日志分工明确,不在 SQL 层再输出 ERROR。
PostgreSQL 的 `sql` 字段记录脱敏后的语句,Worker SQLite 的 `sql` 字段记录稳定的 sqlc 查询名;
SQLite 包含事务查询以及 Scan/Rows 错误;启动迁移由启动边界诊断。
参数、外部响应体、签名 URL、密码、令牌及 PostgreSQL Detail 不进入日志。
未知错误只输出分类和类型,数据库错误保留 SQLSTATE;需要更详细诊断时,
在适配器补充安全的 Fault.Operation,不能直接开放任意 err.Error()。
在 Workbench 的 Loki 中可用以下方式关联同一进程内的操作:
```logql
{compose_project="tdp-workbench"} | json | trace_id="<trace-id>"
```
跨 Server/Worker 查询使用 attempt_id、command_id 或 event_id。本改动不需要新建
数据库字段或部署 Trace 收集服务。
## 使用与日志示例
临时开启 SQL(下一次启动生效):片段展示核对时的源码;完整文件指纹用于检测后续变化。这里的路径用于定位,不要求手机访问源码仓库。
单发送器及网络重试 backend · c6dbe05c
worker/internal/core/execution/application/outbox.go · 第 34–87 行
符号:sendEvents · 核对日期 2026-10-02
来源与提交版本一致
// sendEvents 独占当前会话的事件发送;能力协程只负责持久化并唤醒发送器。
// 临时网络失败保留事件重试,不取消其他运行中的尝试。
func (runtime *Runtime) sendEvents(ctx context.Context, session Session) error {
delay := time.Second
timer := time.NewTimer(0)
defer timer.Stop()
for {
var drained chan error
select {
case <-ctx.Done():
return nil
case <-runtime.outboxWake:
case drained = <-runtime.outboxDrain:
case <-timer.C:
}
err := runtime.flushEvents(ctx, session)
if drained != nil {
drained <- err
}
if err == nil {
delay = time.Second
timer.Reset(time.Second)
continue
}
if ctx.Err() != nil {
return nil
}
if !retryableDelivery(err) {
return err
}
runtime.observer.Event(ctx, "event delivery deferred", err)
// 退避期间不响应生产者唤醒;新增事件仍保留在本地数据库中。
retry := time.NewTimer(delay)
select {
case <-ctx.Done():
retry.Stop()
return nil
case <-retry.C:
}
timer.Reset(0)
delay = min(delay*2, 30*time.Second)
}
}
// 仅标记远程投递失败;本地数据库错误不能被当作网络故障无限重试。
type deliveryError struct{ error }
func (e *deliveryError) Unwrap() error { return e.error }
func retryableDelivery(err error) bool {
var delivery *deliveryError
return errors.As(err, &delivery) && domain.Kind(err) != domain.Unauthenticated && domain.Kind(err) != domain.Forbidden
}
片段展示核对时的源码;完整文件指纹用于检测后续变化。这里的路径用于定位,不要求手机访问源码仓库。
这里的 trace_id 是日志关联标识,没有配置分布式 tracing exporter,不代表有可查询的 span 树。Workbench 的 Alloy/Loki/Grafana 提供本地容器日志汇集;不能由目录存在推断生产集中观测已经部署。
失败与边界:排障先读事实
推荐顺序是找业务 ID → 当前执行 → 当前步骤/Attempt → 命令与 Inbox → Outbox → 回调投影。确认哪一阶段缺少完成事实,再查对应日志和依赖。
readiness 检查服务所需依赖,liveness 检查进程是否应重启;把所有下游短暂故障都变成 liveness 失败可能造成重启风暴。辅助日志写入故障不应无条件改变业务结果。
迁移练习与参考答案
练习:通知发送接口成功率 99.9%,用户仍大量未收到。怎样补观测?
参考答案:区分请求接受、Outbox 发布、消费、第三方确认和最终投递。统计最老待发、死信、消费延迟与最终结果;按通知 ID 找持久记录与关联日志。第三方“接受”不一定等于送达,依据供应商契约接收回执或对账。
继续阅读:Outbox 故障窗口、端到端案例。