引言
工作流的故障形态与普通服务不同。普通服务的故障是「请求失败」,看得见、有错误码、有告警;工作流的故障往往是「实例停住了」——没有错误、没有日志、没有告警,只是某个实例永远停在某个节点上,直到业务方发现「这单怎么三个月没动静」。
这类静默故障的排查成本极高,因为缺少现成的工具:日志里搜不到错误,指标上只看到「活跃实例数缓慢上升」,而要知道「卡在哪个节点、为什么卡」,必须能查到实例的完整状态与历史。
所以工作流的可观测要专门设计,它需要回答四个问题:这个实例现在在哪一步、它在等什么、它已经等了多久、同类实例里有多少也卡在同样位置。这四个问题分别对应实例查询、等待条件可视化、时长分布与聚合分析。
本文按「从实例到全局」的顺序展开:先讲实例级查询与事件历史调试,再讲跨服务的链路透传,然后是工作流级指标与告警设计,最后讲可视化、流量回放与成本观测。想先理解引擎的执行模型,可以从 Temporal 与持久化执行 或 BPMN 2.0 与 Camunda 实战 开始。
目录
- 工作流可观测的特殊性
- 三个层次:实例、流程、业务
- 实例查询:卡在哪一步
- 等待条件的可视化
- 事件历史与重放调试
- 链路追踪的透传
- 工作流级指标设计
- 日志与关联 ID
- 告警设计:卡住与积压
- 实时状态可视化
- 调试工具与技巧
- 灰度与流量回放
- 成本观测
- 与可观测平台的集成
- 常见故障模式速查
- 落地路线图
- 权衡取舍
- 常见坑清单
- 小结
1. 工作流可观测的特殊性
工作流的可观测与传统服务有三个结构性差异。
第一,时间尺度不同。一次 HTTP 请求的生命周期是毫秒到秒,一次工作流的生命周期可能是天到月。这意味着传统监控的「滚动窗口」(1 分钟、5 分钟)完全看不到工作流的问题,需要「按实例年龄」的观测维度。
第二,状态在引擎里而不在日志里。传统服务可以通过日志重建状态,工作流的状态存在引擎的状态存储里(事件历史、执行树、流程变量)。只看日志会漏掉「引擎认为它在等什么」这个关键信息。
第三,故障是「停滞」而不是「失败」。失败有错误码,停滞没有。所以工作流的告警必须建立在「预期应该发生但没有发生」的判断上,比如「创建超过 2 小时仍未完成的实例数」。
这三个差异决定了工作流可观测的三条设计原则:以实例为中心(而不是以请求为中心)、以状态存储为真相来源(而不是日志)、以「停滞检测」为告警核心(而不是错误率)。
2. 三个层次:实例、流程、业务
工作流的观测分三个层次,各自回答不同的问题:
| 层次 | 问题 | 数据来源 |
|---|---|---|
| 实例层 | 这个单子现在在哪、在等什么 | 引擎状态存储、事件历史 |
| 流程层 | 这个流程的健康度如何、哪个节点是瓶颈 | 聚合指标、节点耗时分布 |
| 业务层 | 业务目标达成了吗、有没有对不上的账 | 业务表、对账任务 |
实例层是排查问题的入口,流程层是发现问题的入口,业务层是兜底的入口。三者缺一,会出现不同的盲区:只有实例层会「出了问题才知道」;只有流程层会「知道有瓶颈但不知道哪一单受影响」;只有业务层会「知道账不对但不知道哪一步错的」。
实践中,流程层与业务层的指标应该在同一个仪表盘上并排展示,因为它们回答的是同一个问题的两面:「流程跑得好不好」与「业务结果对不对」。
3. 实例查询:卡在哪一步
实例查询是所有排查的起点。它的关键是「用业务键查,而不是用引擎 ID 查」——因为排查时手上只有订单号,没有引擎内部的实例 ID。
-- Camunda 7:用 businessKey 查实例与当前节点
SELECT p.ID_ AS instance_id, p.BUSINESS_KEY_, p.START_TIME_,
e.ACT_ID_ AS current_node, e.ACT_NAME_ AS node_name,
t.NAME_ AS task_name, t.ASSIGNEE_, t.CREATE_TIME_ AS task_created
FROM ACT_HI_PROCINST p
JOIN ACT_RU_EXECUTION e ON e.PROC_INST_ID_ = p.ID_
LEFT JOIN ACT_RU_TASK t ON t.EXECUTION_ID_ = e.ID_
WHERE p.BUSINESS_KEY_ = 'ORDER-20261007-001';
BUSINESS_KEY_ 必须建唯一索引(如果业务上唯一)或普通索引,否则这条查询在千万级实例的表上会全表扫描。这是最基础也最容易被忽略的优化。
Temporal 侧的等价查询:
temporal workflow describe --workflow-id order-1001
temporal workflow query --workflow-id order-1001 --name getStatus
temporal workflow show --workflow-id order-1001 --output json
describe 给出当前状态与待处理任务,show 给出完整事件历史。排查时先用 describe 确认「在等什么」,再用 show 看「为什么会这样」。
4. 等待条件的可视化
「卡住」的本质是「在等某个条件」,而条件有三类:
- 等定时器:比如「30 分钟后自动取消」。
- 等外部事件:比如「等支付回调」「等人工审批」。
- 等上游完成:比如「等子流程返回」。
三类等待的排查方式不同。等定时器的实例要查「定时器什么时候触发」;等外部事件的要查「消息有没有发出、对方有没有回」;等上游的要查「上游实例的状态」。
-- 查询所有等待中的定时器(Camunda 7)
SELECT j.ID_, j.DUEDATE_, j.RETRIES_, j.EXCEPTION_MSG_,
e.PROC_INST_ID_, p.BUSINESS_KEY_
FROM ACT_RU_JOB j
JOIN ACT_RU_EXECUTION e ON e.ID_ = j.EXECUTION_ID_
JOIN ACT_HI_PROCINST p ON p.ID_ = e.PROC_INST_ID_
WHERE j.DUEDATE_ < NOW()
ORDER BY j.DUEDATE_;
上面这条查询找的是「已过期但还没触发的定时器」,通常意味着作业执行器(Job Executor)出问题了。这是 Camunda 里最常见的静默故障之一。
一个实用的观测手段是「等待原因标签」:在实例上显式记录「当前在等什么」的业务标签(比如 waiting_for=PAYMENT_CALLBACK),而不是让运维去猜流程语义。这个标签在告警聚合时特别有用,可以快速回答「有多少单在等支付回调」。
5. 事件历史与重放调试
事件历史是工作流调试最有价值的工具,它记录了「引擎做过的每一个决策」。排查问题时的读法是「从后往前找最后一个未完成的事件」。
# Temporal 事件历史(简化)
1 WorkflowExecutionStarted {input: {orderId: "order-1001"}}
2 WorkflowTaskScheduled
3 WorkflowTaskStarted
4 ActivityTaskScheduled {activityType: "lockStock"}
5 ActivityTaskCompleted {result: "OK"}
6 ActivityTaskScheduled {activityType: "charge"}
7 ActivityTaskStarted
8 ActivityTaskFailed {error: "InsufficientBalance", attempt: 3}
9 WorkflowTaskScheduled # 引擎重新调度 Workflow 处理失败
第 8 条告诉我们「扣款失败且已重试 3 次」,第 9 条说明引擎正在把失败交给 Workflow 代码处理。如果历史停在第 9 条不再前进,说明 Workflow 代码本身卡住了或者 Worker 没有运行。
重放调试是 Temporal 特有的能力:可以用 temporal workflow replay 在本地重放历史,配合断点调试 Workflow 代码。当遇到 NonDeterminismError 时,这是唯一能定位到「哪一步代码分支不一致」的手段。
6. 链路追踪的透传
工作流的每一步可能跨越多个服务,要把它们串成一条链路,必须把 TraceContext 在实例里传递。
// 启动工作流时把当前 TraceContext 存进流程变量
Span span = tracer.currentSpan();
Map<String, String> traceContext = new HashMap<>();
tracer.getTextMapPropagator().inject(Context.current(),
traceContext, Setter.INSTANCE);
WorkflowOptions options = WorkflowOptions.newBuilder()
.setWorkflowId("order-" + orderId)
.build();
client.newUntypedWorkflowStub("OrderWorkflow", options)
.start(Map.of("orderId", orderId, "traceContext", traceContext));
// Activity 里恢复 TraceContext,让 span 挂到同一条链路上
@ActivityMethod
public void charge(String orderId, Map<String, String> traceContext) {
Context ctx = tracer.getTextMapPropagator().extract(Context.current(),
traceContext, Getter.INSTANCE);
try (Scope s = ctx.makeCurrent()) {
Span span = tracer.spanBuilder("charge").startSpan();
try {
paymentClient.charge(orderId);
} finally {
span.end();
}
}
}
关键点是「跨实例的等待要断开链路」:一个等了 3 天的工作流,如果把它和最初的请求串成一条 span,会得到一条跨 3 天的超长链路,既无意义也会撑爆追踪系统。正确做法是「每个 Workflow Task 的执行开一个新链路,用 workflowId 作为关联字段」。OpenTelemetry 的语义约定与传播机制见 OpenTelemetry 完整指南 。
7. 工作流级指标设计
工作流指标的核心是「按状态与年龄分桶」,而不是简单的计数。
# 实例状态分布(按年龄分桶)
workflow_instances{workflow="OrderWorkflow", state="running", age_bucket="1h"} 1204
workflow_instances{workflow="OrderWorkflow", state="running", age_bucket="1d"} 87
workflow_instances{workflow="OrderWorkflow", state="running", age_bucket="7d"} 12
workflow_instances{workflow="OrderWorkflow", state="failed", age_bucket="1d"} 3
# 节点耗时分布
workflow_node_duration_seconds{workflow="OrderWorkflow", node="charge", quantile="0.99"} 4.2
# 完成率与补偿率
workflow_completed_total{workflow="OrderWorkflow"} 98500
workflow_compensated_total{workflow="OrderWorkflow"} 1500
「按年龄分桶」是这类指标的关键设计:age_bucket="7d" 有 12 个实例,这个信号比「总活跃实例 1204」有价值得多,因为 7 天没完成的实例几乎肯定有问题。
另一个关键指标是「节点停留时长」:charge 节点的 P99 耗时是 4.2 秒,如果某天变成 40 秒,说明下游变慢了。这个指标能提前发现下游退化,而不是等实例大面积超时。
8. 日志与关联 ID
工作流的日志必须带关联 ID,否则跨服务的日志无法串联。推荐在日志上下文里固定三个字段:
MDC.put("workflowId", workflowId);
MDC.put("businessKey", businessKey);
MDC.put("activityId", activityId);
try {
log.info("charging order {}", orderId);
} finally {
MDC.clear();
}
日志的三个层次要分清:引擎日志(引擎自身的运行日志,排查引擎问题用)、节点日志(每个节点的开始与结束,排查流程问题用)、业务日志(业务逻辑自身的日志)。混在一起会让排查变得困难。
日志量的控制也很重要。一个每天 10 万实例、每个实例 20 个节点的工作流,如果每个节点打 5 条日志,一天就是 1000 万条。建议节点级日志只记录「开始、结束、失败」三条,业务细节放在业务日志里并按需开启。
9. 告警设计:卡住与积压
工作流告警的核心是「停滞检测」,四类告警覆盖大部分问题:
alerts:
- name: 实例卡在关键节点
expr: workflow_instances{state="running", node="charge", age_bucket="1h"} > 10
for: 10m
severity: warning
- name: 长时间未完成实例
expr: workflow_instances{state="running", age_bucket="7d"} > 0
for: 1h
severity: critical
- name: 任务队列积压
expr: workflow_task_queue_backlog > 1000
for: 5m
severity: warning
- name: 补偿率异常上升
expr: rate(workflow_compensated_total[1h]) / rate(workflow_completed_total[1h]) > 0.1
for: 30m
severity: critical
这四条对应四类故障:节点级阻塞(下游挂了)、实例级停滞(状态机逻辑有问题)、容量不足(Worker 不够)、业务级异常(新上线的代码有 bug)。
「预期应该发生但没有发生」类告警最容易被忽略也最重要:每天凌晨 2 点应该有 200 个实例启动,如果只有 3 个,说明调度或上游出问题了。这类告警的写法是「检查预期存在的实例数」,比如 count(workflow_started_total offset 1d) < 100。告警设计的通用原则见 告警设计与事故响应
。
10. 实时状态可视化
在流程图(BPMN 图或资产图)上叠加实时状态,是最直观的观测方式:每个节点的颜色表示「有多少实例在这里、停留多久」。
-- 聚合查询:每个节点的实例数与平均停留时长
SELECT e.ACT_ID_ AS node,
COUNT(*) AS instance_count,
AVG(TIMESTAMPDIFF(SECOND, e.START_TIME_, NOW())) AS avg_stay_seconds,
MAX(TIMESTAMPDIFF(SECOND, e.START_TIME_, NOW())) AS max_stay_seconds
FROM ACT_RU_EXECUTION e
WHERE e.PROC_DEF_ID_ = ?
GROUP BY e.ACT_ID_;
Camunda 的 Cockpit 与 Temporal 的 Web UI 都提供类似能力,但自建的话要注意查询性能:这类聚合查询在运行时表上执行,表大时要加缓存(比如每 30 秒刷新一次)而不是实时查。
除了「当前状态」,还有一个更有价值的视图是「历史热力图」:过去 7 天每个节点的平均停留时长。它能直接暴露流程瓶颈——某个节点平均停留 3 天,通常意味着审批人不足或规则设置不合理。
11. 调试工具与技巧
排查工作流问题的实用技巧清单:
- 用业务键而不是实例 ID 查询,这是最快定位实例的方式。
- 先看「当前在等什么」,再看「为什么在等」,不要一上来就翻全量历史。
- 对比「正常实例」与「异常实例」的差异:同一个流程定义、同样的输入,为什么这个卡住了。
- 用
businessKey把引擎状态与业务数据(订单表、支付表)对照,找出「引擎认为的」与「业务实际的」差异。 - 对重放失败(
NonDeterminismError),用引擎提供的重放工具在本地复现,而不是在生产环境反复试。
# Temporal 本地重放,复现 NonDeterminismError
temporal workflow replay --workflow-id order-1001 \
--namespace default \
--workflow-file ./OrderWorkflowImpl.java
最后一条特别重要:NonDeterminismError 几乎无法靠读代码定位,必须在本地重放才能看到「历史要求走 A 分支,但代码走了 B 分支」的差异。
12. 灰度与流量回放
工作流引擎的版本升级(引擎升级、流程定义升级)风险很高,因为它影响的是运行中的实例。灰度与回放是降低风险的两个手段。
流量回放:把生产环境的事件历史导出,在测试环境用新版本的代码重放,验证「所有实例都能正常推进」。这是验证「版本兼容性」最直接的手段。
# 导出历史并回放(Temporal 示例)
temporal workflow show --workflow-id order-1001 --output json > history.json
temporal workflow replay --history-file history.json --workflow-file ./NewImpl.java
灰度发布:新启动的实例用新版本,运行中的实例继续用旧版本(这是引擎的默认行为)。但要监控「新版本的补偿率与失败率」是否高于旧版本,否则灰度失去意义。
灰度期间的关键指标是「新旧版本的对比」:同样的流程定义版本、同样的输入分布,新版本的失败率是否上升。这需要指标上带版本标签。
13. 成本观测
工作流的成本有三个来源:引擎的资源消耗(数据库、Worker)、下游调用(每次 Activity 调用可能产生费用)、以及存储(历史数据)。
# 按流程类型统计 Activity 调用次数与耗时
workflow_activity_invocations_total{workflow="OrderWorkflow", activity="charge"} 98500
workflow_activity_duration_seconds_sum{workflow="OrderWorkflow", activity="charge"} 413700
按流程类型统计「每个业务单产生多少次 Activity 调用」很有价值:如果一个订单流程产生 47 次 Activity 调用,而实际业务只需要 8 步,说明存在过度的重试或轮询。这类成本优化往往能带来数量级的改善。
存储成本也要监控:历史数据的增长速率、清理任务的执行情况。Camunda 的历史表与 Temporal 的可见性存储都会随时间增长,不监控会在某天突然发现磁盘满了。成本治理的通用思路见 可观测与 FinOps 成本智能 。
14. 与可观测平台的集成
工作流的指标应该汇入统一的可观测平台,而不是自成一套。集成的三个要点:
- 指标用 Prometheus 格式暴露,带统一的标签体系(
workflow、version、node、state)。 - 日志汇入统一日志系统,带关联 ID。
- 链路追踪用 OpenTelemetry 标准,跨度命名遵循语义约定。
# Camunda 7 的指标暴露(Prometheus 端点)
management:
endpoints:
web:
exposure:
include: prometheus,health,metrics
metrics:
tags:
application: camunda-engine
env: prod
统一的标签体系是集成的前提。如果每个引擎用不同的标签命名(flow_name vs workflow vs process),跨引擎的对比分析就无法进行。建议在接入时就约定好标签规范。
仪表盘的组织建议按「受众」而不是按「数据源」:业务方看业务指标(完成率、平均时长),运维看资源与告警,开发看节点耗时与失败分布。仪表盘的构建实践见 Grafana 可视化 。
15. 常见故障模式速查
把高频故障整理成一张对照表,能大幅缩短排查时间:
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 实例停在某节点不动 | 下游超时未返回、Worker 未运行 | 查当前节点 + 任务队列积压 |
| 定时器到点未触发 | 作业执行器停摆、时钟漂移 | 查过期作业表(ACT_RU_JOB) |
| 实例数缓慢增长不下降 | 完成条件写错、等待事件永不到达 | 查节点停留时长分布 |
| 同一实例重复执行 | 租约过期、幂等未做 | 查事件历史里的重复调度 |
| 重放报 NonDeterminismError | 代码变更破坏确定性 | 本地重放定位分支差异 |
| 补偿率突然上升 | 新版本代码有 bug、下游变更 | 按版本标签对比补偿率 |
| 实例创建后立即失败 | 输入校验不通过、流程定义不匹配 | 查实例启动事件与错误 |
| 任务队列积压但 CPU 空闲 | Worker 并发数配置过低 | 查 Worker 并发与队列深度 |
| 历史表查询变慢 | 历史数据未清理、缺索引 | 查表大小与执行计划 |
| 部分实例在灰度版本卡住 | 新旧版本流程定义不兼容 | 按流程定义版本分组统计 |
前三行覆盖了绝大多数「实例不动」的问题。排查的顺序是「先确认它是不是真的不动」(有可能只是正常等待),再确认「引擎是否在正常工作」(任务队列与作业执行器),最后才看业务逻辑。
还有一个诊断技巧是「注入观测点」:在流程的关键节点上加一个「心跳」动作,记录节点开始与结束的时间戳。这样即使引擎的观测能力不足,也能通过业务数据判断「哪一步耗时异常」。
-- 心跳表:每个节点的开始与结束时间
CREATE TABLE workflow_heartbeat (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
instance_id VARCHAR(64) NOT NULL,
node_id VARCHAR(64) NOT NULL,
started_at TIMESTAMP NOT NULL,
finished_at TIMESTAMP NULL,
KEY idx_instance_node (instance_id, node_id)
);
心跳表在排查「引擎 UI 看不到细节」的场景下特别有用,比如自研状态机或者引擎的观测能力受限时。
16. 落地路线图
- 第 1 周:给
businessKey建索引,实现「按业务键查实例当前状态」的查询接口。 - 第 2 周:暴露核心指标(实例状态按年龄分桶、节点耗时分布、补偿率),接入 Prometheus。
- 第 3 周:配置四类告警(节点阻塞、实例停滞、队列积压、补偿率异常),并做告警演练。
- 第 4 周:实现链路透传与节点级日志关联 ID,做一次跨服务的故障排查演练。
验收标准是「给定一个业务单号,能在 5 分钟内回答:它现在在哪一步、在等什么、等了多久、同类实例有多少卡在同样位置」。这四问答不上来,说明观测体系还不完整。
17. 权衡取舍
| 选择 | 收益 | 代价 |
|---|---|---|
| 引擎自带 UI | 开箱可用 | 定制能力弱,跨引擎不统一 |
| 自建实例查询页 | 可定制、可聚合 | 开发与维护成本 |
| 事件历史全量保留 | 排查最完整 | 存储成本高,查询慢 |
| 历史按 TTL 清理 | 存储可控 | 老实例无法追溯 |
| 链路跨实例串联 | 端到端可见 | 长链路撑爆追踪系统 |
| 每个 Task 独立链路 | 链路短、成本低 | 需要 workflowId 关联 |
| 实时状态图 | 直观 | 聚合查询有性能开销 |
| 缓存的状态图 | 性能好 | 有延迟(30 秒级) |
18. 常见坑清单
businessKey没有索引,按业务单号查询全表扫描,排查时等几分钟。- 只监控「失败数」,不监控「长时间未完成实例」,静默停滞无人发现。
- 指标只统计总数不按年龄分桶,看不出「有 12 个实例已经挂了 7 天」。
- 把跨天等待的实例串进同一条链路,产生超长 span 撑爆追踪系统。
- 日志不带关联 ID,跨服务排查时无法把日志串起来。
- 事件历史全量保留不做 TTL,存储成本持续增长直到磁盘告警。
- 重放失败(NonDeterminismError)只在生产环境反复试,应该在本地重放定位。
- 告警只覆盖「实例失败」,不覆盖「预期应该启动但没有启动」。
- 实时状态图直接查运行时表做聚合,页面卡死并拖慢数据库。
- 灰度发布不监控新旧版本对比,新版本问题被平均掉。
- 不统计「每个业务单产生多少次 Activity 调用」,过度的重试与轮询无人发现。
- 各引擎用不同的标签命名,无法做跨引擎的统一视图。
19. 小结
工作流可观测的核心是「以实例为中心、以状态存储为真相、以停滞检测为告警核心」。它要求把观测的对象从「请求」换成「实例」,把观测的维度从「时间窗口」换成「实例年龄」,把告警的触发从「错误率」换成「预期未发生」。
落地时的最小可用集是四件事:businessKey 索引加实例查询接口、按年龄分桶的实例指标、四类告警(节点阻塞、实例停滞、队列积压、补偿率异常)、以及跨服务的关联 ID。这四件事做完,绝大多数工作流故障都能在 5 分钟内定位。
下一步建议读 OpenTelemetry 完整指南 把链路透传做规范,读 告警设计与事故响应 把告警的有效性(信噪比、认领机制)做扎实;如果工作流里包含 AI 步骤,成本观测的权重会显著上升,可以看 工作流与 AI Agent 编排 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。