☰
C#工作流引擎实战:从手动审批到10万+TPS的性能优化
2026/10/1 3:24:13 网站建设 项目流程

去年年初,系统里堆了一百多个审批流程,从采购申请、合同会签,到假单、报销、用章申请,全是走人事手动流转的老路。业务高峰期,每个流程平均要两三天才能跑完一圈,光催办的消息一天就得几十条。后来实在扛不住,我带着团队用C#从零自研了一套轻量级工作流引擎,把流程定义、状态流转、审批节点、超时提醒全部接上自动化链路,单机实测TPS从原来的人工处理水平直接冲到10万+。这篇文章不吹数字,而是想把当时从架构设计、核心代码到性能调优的完整路径拆出来,给正在做类似系统、或者正被审批流程折磨的兄弟们一份能直接参考的实战经验。

1. 项目拆解与整体架构设计

1.1 先把标题里的水分挤干:10万+TPS到底意味着什么

聊性能指标之前,先把它说透。TPS(Transactions Per Second,每秒事务数)指的是一秒内系统能完成多少次完整的业务操作。工作流引擎里的一个事务,通常是从一个节点流转到下一个节点,包括状态变更、节点动作执行、日志写入、持久化提交这整套操作。

10万+TPS不是随便一台机器就能跑出来的。这个数字要成立,需要有四个前提:

  • 压测场景是纯内存执行、不落盘的极简单流程;
  • 节点动作只是状态切换,不涉及外部系统调用;
  • 数据库批量写入,而不是每条流程一次事务提交;
  • 后端服务本身扛住了并发,没有明显的锁竞争或GC瓶颈。

我们当时压测用的是一台8核16G的云主机,跑的是定义好的三节点直连流程,数据先打在内存缓冲队列里,异步批量写库,最终得到单机TPS破十万的结果。你要真拿这个数字去对标SAP、Flowable这些重型流程引擎的完整审批场景,那不公平,大家衡量口径不一样。

我提这个不是泼冷水,而是想说清楚一个道理:性能优化是系统工程,指标的成立范围和口径同样重要。你只有在知道自己到底测的是什么、优化的是什么之后,才可能把指标做上去。

1.2 为什么选C#而不是Java或Go来写工作流引擎

这个选择当初在团队里还争论过一阵子。Java有Flowable、Activiti这些成熟的开源工作流引擎,Go也有Conductor、Temporal这类分布式调度框架。为什么我们最终还是决定用C#从零实现?

最核心的原因是团队技术栈和现有系统高度统一。我们现有的业务后端都是.NET Core,如果引入一套Java生态的流程引擎,就意味着要多维护一套独立的微服务、一套CI/CD流水线,还有跨语言对接的序列化协议和异常排查成本。为了一个流程引擎,把整个研发体系的复杂度抬上去,不划算。

第二个原因是C#本身在这一领域的表达能力确实强。委托、事件、async/await、表达式树,这些特性非常适合描述状态迁移、节点触发条件和异步动作。尤其async/await,在处理超时提醒、外部回调、并行分支的时候写起来非常清爽。

第三个原因是性能可控。.NET Core以后,JIT和GC都有大幅优化,配合结构体和Span这类底层能力,单机的吞吐压到极致并不输给Go的Goroutine模型。更何况工作流引擎的瓶颈一般不在语言本身,而在持久化和锁设计上。

1.3 从“100个手动审批”到自动化:需求抽象是关键

手动审批流程之所以慢,不是因为审批的人慢,而是因为流程没有结构化。每个流转环节在哪个节点、多久没处理、超时该提醒谁、转给谁,全靠人脑去记。你要自动化,首先就得把所有流程抽象成机器能理解的结构。

我们花了两周时间盘点了一百多个现存审批流程,最后总结出四个共性概念:

  • 流程定义:一张流程长什么样,有哪些节点、什么顺序、什么条件分支;
  • 流程实例:一条具体的业务单据,走到哪了、状态如何、经历过哪些节点;
  • 节点:审批、会签、抄送、自动动作等最小执行单元;
  • 事件:触发节点流转的外部信号,比如提交、同意、驳回、超时。

把业务需求抽象成这四个概念以后,你就会发现,无论是采购申请还是用章申请,本质上都是同一套流转模型。所谓自动化革命,说穿了就是把“人眼判断下一步”变成“引擎根据定义和上下文自动判断下一步”。

2. 核心引擎模块的代码深度解析

2.1 流程定义模型:用代码把流程画出来

工作流引擎的基石是流程定义。不能把流程写死在业务代码里,否则每次改审批路径都要重新发版。所以第一步就是把流程定义设计成可持久化、可解析的数据模型。

下面是我们当时的核心定义类,代码量不大,但撑起了整个引擎的节点导航能力:

public class WorkflowDefinition { public string WorkflowId { get; set; } public string Version { get; set; } public string Name { get; set; } public Dictionary<string, WorkflowNode> Nodes { get; set; } public WorkflowNode GetNode(string nodeId) { return Nodes.GetValueOrDefault(nodeId); } public WorkflowNode EnterNode => Nodes["start"]; public void Validate() { if (!Nodes.ContainsKey("start")) throw new InvalidOperationException("流程必须包含 start 节点"); if (!Nodes.Any(n => n.Value.Type == NodeType.End)) throw new InvalidOperationException("流程必须包含至少一个 End 节点"); } } public class WorkflowNode { public string NodeId { get; set; } public NodeType Type { get; set; } public string NextNodeId { get; set; } public Func<WorkflowContext, bool> Condition { get; set; } public Action<WorkflowContext> Action { get; set; } public TimeSpan? Timeout { get; set; } } public enum NodeType { Start, Approve, Condition, Fork, Join, End }

有几个设计细节说一下。

节点用字典存储而不是列表,是为了O(1)的节点查找。Condition是一个Func<WorkflowContext, bool>委托,把条件判断从硬编码的高阶if-else里解放出来。比如在审批流里,“金额大于五万走总经理审批,否则部门经理审批”,就是两个节点挂上不同的委托。

Timeout字段也很关键,它内置了超时定义。因为手动审批的老大难问题之一,就是审批单在某个节点睡觉没人管。有了超时定义,引擎就可以在超时后自动触发提醒或回调。

流程定义本身是纯内存对象树,至于如何从数据库或JSON反序列化出来,我们在后续持久化模块里会处理。这样做的好处非常直接:定义和运行分离,改流程定义不影响正在运行的流程实例。

2.2 状态机引擎:从状态到状态的流转核心

流程引擎的核心驱动力,就是状态机。每一个流程实例,本质上就是一个状态,节点就是触发状态迁移的事件。这里我们摒弃了传统的状态机框架,因为工作流的状态迁移不仅要考虑当前状态,还要考虑流程的上下文数据,比如金额、审批人、附件。

看这个核心的流转方法:

public WorkflowNode Step(WorkflowContext ctx, string currentNodeId) { if (ctx == null) throw new ArgumentNullException(nameof(ctx)); var definition = _cache.GetDefinition(ctx.WorkflowId, ctx.Version); var node = definition.GetNode(currentNodeId); if (node == null) throw new InvalidOperationException($"节点 {currentNodeId} 不存在"); // 执行节点的动作,比如状态更新、通知发送 node.Action?.Invoke(ctx); // 到达终点的判断 if (node.Type == NodeType.End) { ctx.Instance.Status = InstanceStatus.Completed; return null; } WorkflowNode nextNode; // 如果节点自带条件,走条件路由 if (node.Type == NodeType.Condition) { var matched = definition.Nodes.Values .Where(n => n.Condition != null) .FirstOrDefault(n => n.Condition(ctx)); if (matched == null) throw new InvalidOperationException($"节点 {currentNodeId} 没有匹配的条件分支"); nextNode = matched; } else { nextNode = definition.GetNode(node.NextNodeId); } // 节点迁移 ctx.Instance.MoveTo(nextNode.NodeId); return nextNode; }

这段代码的关键点不只是状态迁移,还有它把“执行动作”和“迁移”拆开了。现实中审批流的动作五花八门,同意之后要更新订单状态、要通知财务、要写审计日志,这些都是动作,不是流程控制逻辑。把动作挂在节点上,引擎只负责迁移,业务代码只关心动作,两者解耦。

再仔细看条件路由那一段:我们没有用复杂的规则引擎,而是简单地遍历所有带Condition的节点,找到第一个返回true的节点。这在大多数企业审批流场景里够用了。如果有一天条件多了、复杂了,再考虑引入规则引擎也不迟,但起步阶段务必保持简单。

2.3 持久化与并发一致性:绝对不能丢状态的底层保障

工作流引擎和普通业务接口最大的区别在于,它是长生命周期业务。一个审批实例可能存活几天甚至几周。这期间服务可能重启、断电、升级,流程状态必须能够无损恢复。所以持久化不是辅助功能,而是核心功能。

我们的持久化结构主要三张表:

表名说明关键字段
WorkflowInstance流程实例主表InstanceId、WorkflowId、Version、CurrentNodeId、Status、PayloadJson
WorkflowHistory节点流转历史Id、InstanceId、FromNode、ToNode、Operator、Action、Timestamp
WorkflowTimer超时与延时任务表Id、InstanceId、DueTime、CallbackType、Status

初始化实例时一次性写入实例主表,流转时先写历史、再更新主表。这两个操作放在同一个数据库事务里,保证不会出现“历史记录说走了,主表还在原地”的数据不一致。

并发这块直接上一个硬结论:同一流程实例的并发流转,必须用行锁或乐观锁拦住。我们采用乐观锁方案,在WorkflowInstance表上加了RowVersion字段,每次更新时校验版本号:

var rows = db.WorkflowInstances .Where(w => w.InstanceId == instanceId && w.RowVersion == expectedVersion) .ExecuteUpdate(w => w .SetProperty(a => a.CurrentNodeId, nextNodeId) .SetProperty(a => a.RowVersion, a => a.RowVersion + 1)); if (rows == 0) throw new ConcurrencyException($"流程实例 {instanceId} 已被其他线程修改");

这里ExecuteUpdate是EF Core 7的原子写操作。它直接在数据库层完成条件更新,避免了先查后改的竞态窗口。为什么不用数据库悲观锁?因为工作流引擎里大部分实例都是空闲的,等待审批人处理,悲观锁会让这条数据在整个处理周期内被锁住,拖垮数据库并发能力。

2.4 引擎协调器:驱动整个流转的“心脏”

有了定义、有了状态机、有了持久化,还差一个把所有部件串起来的东西。我们内部叫它引擎协调器,职责很简单:拿一个待执行的流程实例,驱动它不断执行,直到遇到阻塞节点或者流程结束。

public sealed class WorkflowEngine { private readonly IDefinitionCache _cache; private readonly IWorkflowRepository _repository; private readonly INotificationService _notifier; public async Task<WorkflowResult> ExecuteAsync( string instanceId, string currentNodeId, WorkflowInput input) { // 1. 加载实例 var instance = await _repository.GetInstanceAsync(instanceId); // 2. 创建上下文 var ctx = new WorkflowContext(instance, input); // 3. 循环推进,直到碰到需要人工介入的节点或结束节点 var node = _cache.GetDefinition(instance.WorkflowId, instance.Version) .GetNode(currentNodeId); var visitedNodes = new List<string>(); while (node != null && node.Type != NodeType.Approve) { node = Step(ctx, node.NodeId); visitedNodes.Add(node?.NodeId ?? string.Empty); // 防止死循环:连续执行超过 100 个自动节点时熔断 if (visitedNodes.Count > 100) throw new InvalidOperationException("流程出现循环或自动节点过多"); await _repository.SaveHistoryAsync(instance, node); } // 4. 如果卡在审批节点,发通知 if (node?.Type == NodeType.Approve) { await _notifier.NotifyApproverAsync(instance, node); } return new WorkflowResult(instance.Status, node?.NodeId, visitedNodes); } }

这个类的设计妙处在于,它把“自动节点”和“人工节点”的处理完全分离开来。自动节点(条件判断、消息推送、数据回写)可以一口气跑完,直到遇到审批节点停下来等待人工。这就解释了为什么自动化能把效率拉起来——一条链路里七八个自动节点,一个循环全跑完,完全不耗人时。

防死循环那一行也是血泪教训。最初我们没有这个熔断机制,有一次线上流程定义配错了,一个条件判断节点指回自己,导致引擎无限循环,直接把数据库读写放大到异常。后来加了这个100节点的上限,再配合告警日志,这类问题就能在造成事故前及时发现。

3. 高性能路径:从100个手动审批到10万+TPS的实战调优

3.1 第一步:从同步阻塞到异步事件流

性能优化的第一步永远不是加机器,而是找到CPU和IO都在等的场景。原型的第一个版本,审批动作是同步调用的:引擎启动一个线程,调数据库查询,查完调动作,动作里如果有外部接口就继续等响应,整个流程是串行阻塞模型。

这种模式的直接后果是:高峰期一百个审批同时进来,线程池被挤得满满当当,事件循环卡死,系统响应时间飙到几秒。

把同步改成异步,整体过程是痛苦的,但收益巨大。关键在于理解async/await的本质是让出线程,而不是把同步代码包一层async就完事。正确的做法是这样的:

public async Task<WorkflowResult> HandleSubmittedAsync(string instanceId, int userId) { var instance = await _repository.GetInstanceAsync(instanceId); var ctx = new WorkflowContext(instance) { OperatorId = userId, StartedAt = DateTime.UtcNow }; // 这里完全是非阻塞的,IO等待时线程自动让出 var node = await StepAsync(ctx, instance.CurrentNodeId); await _repository.SaveInstanceAsync(instance); await _notifier.SendNotificationAsync(ctx, node); return new WorkflowResult(instance.Status, node?.NodeId); }

改造完成后,单从线程利用率上看,同一个线程单位时间能处理的请求量就翻了几倍。这只是性能优化的第一层,后面还有更狠的。

3.2 第二步:把热点数据全部打进内存缓存

工作流的流量特征和普通业务系统不太一样:流程定义的读频率极高,但写频率极低。一个流程定义上线之后可能几个月都不变,但每天有成千上万个实例在按照它的定义流转。

我们一开始傻乎乎地每次都从数据库里查流程定义,结果数据库的读IO在节点流转时成了瓶颈。后来加了一个内存缓存层,用ConcurrentDictionary做定义缓存:

public sealed class InMemoryDefinitionCache : IDefinitionCache { private readonly ConcurrentDictionary<string, WorkflowDefinition> _cache = new(); public WorkflowDefinition GetDefinition(string workflowId, string version) { var key = $"{workflowId}:{version}"; return _cache.GetOrAdd(key, k => LoadFromDatabase(workflowId, version)); } public void Invalidate(string workflowId, string version) { var key = $"{workflowId}:{version}"; _cache.TryRemove(key, out _); } }

这个优化立竿见影。流程定义的读取从一次数据库查询变成一次字典查找,耗时从毫秒级降为微秒级。而且ConcurrentDictionary的并发读性能极高,完全能满足10万+TPS场景下的定义读取需求。

除了流程定义,审批人列表、部门层级、常用通知模板也是热点数据,我们统一做了缓存处理。缓存失效策略很简单,管理员在后台修改定义后,手动调用Invalidate方法清掉对应key,不给缓存留下长期脏数据的机会。

3.3 第三步:批量提交与缓冲队列,把数据库读写压缩到极致

TPS要想真正冲到10万,还有一个绕不开的坎:数据库写入。即便用最快的方式写库,单条事务的提交起码也要几十微秒到数百微秒,10万TPS意味着每秒要执行10万次数据库写入,这在单机数据库上基本不可能。

解决思路是拉长批量的时间窗口。我们设计了一个内存缓冲队列,引擎将节点流转结果先写入内存队列,后台的批量提交任务每隔100毫秒或者累积到1000条记录才统一执行批量插入:

public sealed class BatchPersistenceService : BackgroundService { private readonly Channel<WorkflowEvent> _queue = Channel.CreateBounded<WorkflowEvent>( new BoundedChannelOptions(50000) { SingleWriter = false, SingleReader = true }); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { var buffer = new List<WorkflowEvent>(1000); while (!stoppingToken.IsCancellationRequested) { buffer.Clear(); // 100ms 超时窗口内尽可能多地收集事件 using var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken); cts.CancelAfter(TimeSpan.FromMilliseconds(100)); try { while (buffer.Count < 1000) { var item = await _queue.Reader.ReadAsync(cts.Token); buffer.Add(item); } } catch (OperationCanceledException) { // 时间窗口到,执行批量写 } if (buffer.Count > 0) await BulkInsertAsync(buffer); } } private Task BulkInsertAsync(List<WorkflowEvent> events) { var histories = events.Select(e => new WorkflowHistory { InstanceId = e.InstanceId, FromNode = e.FromNode, ToNode = e.ToNode, Operator = e.Operator, Action = e.Action, Timestamp = e.Timestamp }).ToList(); return _repository.BulkInsertHistoryAsync(histories); } }

代码里用了Channel<T>作为生产者消费者队列,这是.NET内置的高性能内存队列,吞吐量比普通ConcurrentQueue高出几个量级。批量插入用的是EF Core的SqlBulkCopy扩展,一次可以插入上千条历史记录。

这里要强调一点:批量写入必须考虑故障恢复。内存缓冲意味着事件还没有落库,一旦进程崩溃,数据就丢了。我们的补偿方案是:批量插入完成后,同时记录一个批量事件的聚合日志,并给主表打上最后一次实例状态的快照。如果崩溃发生,重启时可以从快照恢复实例状态,丢失的中间历史事件通过重放原始请求重新生成。这个机制我们内部叫“半异步持久化”:主实例状态同步写库,历史流水异步批量落库。

3.4 性能压测结果与热点瓶颈复盘

优化完成以后,我们做了一轮完整的压测。测试环境是8核16G云主机,SQL Server 2019标准版,压测工具用NBomber开了一个协程压力客户端。场景是一个三节点的直连流程:启动节点、审批节点、结束节点。

优化阶段平均延迟(P50)P99延迟TPS
同步阻塞版420ms1.3s850
异步改造后45ms180ms6200
加缓存后12ms40ms21000
批量持久化后3ms15ms102400

可以看到,瓶颈是一层一层被削掉的。同步改成异步解决的是线程池耗尽问题;加缓存解决的是IO次数过多问题;批量写库解决的是数据库写入吞吐问题。每一步的收益都清晰可见。

有一个数据很有意思:最终瓶颈不再是数据库或引擎本身,而是网络包处理。压测机器上的网卡带宽跑满了,单机吞吐上不去了。这时候想再往上提TPS,就需要横向扩展多台实例,配合负载均衡来做集群部署。

4. 自动化落地的集成与扩展

4.1 把引擎嵌入业务系统的三种方式

引擎写完只是第一步,怎么和现有业务系统无缝集成才是真正的高频问题。我们实际尝试过三种方案,各有各的适用场景。

第一种是引入NuGet包,直接嵌入业务进程。这种方案最简单,业务流程代码直接在同一个进程里调用引擎,没有网络开销,排错也直观。缺点是引擎和业务系统强耦合,引擎版本升级会影响整个业务系统。

第二种是独立部署为微服务,通过gRPC对外提供流程服务。这个方案的好处是业务系统只需要关心提交指令和接收回调,不接触引擎内部的任何代码。缺点是跨进程调用的序列化开销大,对分布式事务的要求也随之提高。

第三种是事件驱动集成,推荐这种模式。引擎只负责状态的流转,不直接调用业务动作。节点动作改由事件订阅方来实现:

事件订阅方动作
流程启动订单服务生成订单状态快照
审批通过财务服务创建付款单
审批驳回通知服务发送驳回通知邮件
超时定时任务重新提醒审批人

事件驱动的好处是,引擎不需要知道业务代码在哪里、长什么样,它只管发出领域事件。订阅方自己决定如何处理。相当于引擎从“指挥家”变成了“信号灯”,业务逻辑真正分散到各个服务里自治。

4.2 多租户与动态审批路由

企业级需求逃不开多租户问题。不同部门、不同业务线的审批规则可能完全不同。为此我们在流程定义里增加了一个上下文路由机制,不通过硬编码的if-else,而是通过一个可插拔的路由策略接口:

public interface IRoutingStrategy { string ResolveNode(WorkflowContext ctx); } public sealed class AmountRoutingStrategy : IRoutingStrategy { public string ResolveNode(WorkflowContext ctx) { var amount = ctx.Input.Get<decimal>("Amount"); if (amount > 50000) return "gm_approve"; // 50万以上走总经理审批 if (amount > 10000) return "dir_approve"; // 1万以上走总监审批 return "dept_approve"; // 默认部门经理审批 } }

这个接口非常干净。新增加一种路由维度,比如按区域、按部门、按风险等级,只需要实现一个接口,并在配置里声明启用哪个策略就行,不需要改动引擎核心代码。

多租户的实际做法是每个租户一套自己的流程定义版本,定义缓存里用租户ID加上WFID做key。这样就不会出现“A部门的流程配置污染B部门”的情况。同时每个流程实例也要带上租户标识,所有的查询和写入都走租户隔离。这是企业级系统的基本素养。

4.3 监控与运维:自动化引擎的“仪表盘”

自动化之后,最怕的就是自动化出问题你都不知道。我们上线之前就搭了一套监控体系,现在回头想,这绝对是最该提前做的事情。

核心监控指标有四类:

  • 流转量:每秒节点流转数、流程实例创建数、审批节点阻塞数;
  • 延迟:节点流转P50/P95/P99延迟、审批节点停留时间;
  • 错误:引擎异常数、超时数、并发冲突数、批量写失败数;
  • 资源:线程池等待队列长度、内存缓冲队列水位、数据库连接池用量。

日志统一采用结构化日志,每条日志都带上公司内部要求的追踪ID格式,方便查询链路时把整个流程执行过程拉起来看。告警策略是:节点流转延迟P99超过200毫秒持续5分钟,或者内存缓冲队列水位超过80%,就立即告警到值班群。

这里有个非常容易踩的坑:监控系统本身不能成为性能瓶颈。我们之前用了纯日志库逐条打日志,结果日志吞吐反而把CPU先打满了。后来改成批量写日志,压缩采样,才解决这个问题。监控的优先级永远是“诊断能力第一,实时性第二”,千万不要为了所谓的实时看板把系统拖垮。

5. 上线五个月踩过的坑:问题排查与应对策略

5.1 高频问题速查表

以下这些问题是我们实际上线过程中真实遇到过的,也是面试时我问候选人的高频考题。整理成速查表发给大家:

问题出现原因解决方案
同一流程实例状态错乱多个节点同时提交,乐观锁失效校验RowVersion并抛出ConcurrencyException,由调用方重试
流程丢失批量未落库就崩溃主表状态同步写库,历史流水异步批量落库
审批卡死无响应节点没有超时机制所有审批节点默认配置24小时超时,超时自动转交
条件路由匹配异常多个节点Condition同时为true路由规则里加优先级字段,取优先级最高的
数据库死锁高并发批量写同一流程历史按InstanceId哈希,同一实例路由到同一分区
定义缓存脏数据后台改定义没清缓存所有定义修改API统一调用Invalidate方法
线程池饥饿异步方法里写了Wait()导致线程阻塞全链路使用async/await,消灭任何阻塞调用

第一个问题是最折磨人的。上线初期,因为多个审批人同时点了同意,同一流程被并发执行,导致状态跳变到错误节点。我们当时的处理是在仓储层加上乐观锁校验,谁先提交谁成功,后提交的人拿到异常,前端弹窗提示重新刷新。尽管体验不太好,至少数据不会乱。

第二个问题的应对方式前面已经详细说过。核心原则只有一个:流程数据不能丢。宁可多一次状态快照的成本,也不要在崩溃恢复时无从下手。

5.2 幂等性设计:自动化引擎必须过的鬼门关

工作流引擎的调用方可能是消息队列,可能是定时任务,也可能是人工点击。这三类调用方有一个共同特点:都可能重复调用。消息队列至少一次投递,定时任务可能多实例并发跑,人工点击可能手抖点了两次。

这就要求引擎的每个接口都必须幂等。我们当时的做法是引入一个全局唯一的RequestId,每次提交指令时带上来。引擎执行前先去查指令流水表,如果同一RequestId已经执行过,直接返回之前的结果,不再重复流转:

public async Task<WorkflowResult> ExecuteIdempotentAsync(string requestId, ...) { var exists = await _repository.IsRequestProcessedAsync(requestId); if (exists) { return new WorkflowResult(InstanceStatus.AlreadyProcessed, null, null); } try { var result = await ExecuteAsync(...); await _repository.MarkRequestProcessedAsync(requestId); return result; } catch (Exception ex) { await _repository.MarkRequestFailedAsync(requestId, ex.ToString()); throw; } }

有了这个幂等层之后,我们才敢放心接入各类消息队列。因为无论队列重投多少次,流程实例都不会被重复执行。这是自动化系统稳定运行的根基。

5.3 超时与补偿机制的完整设计

审批流程有三个需要注意的超时场景:节点超时、外部系统调用超时、数据库操作超时。每个场景的应对策略不一样。

节点超时是指审批人在某个节点停留时间过长,我们的方案是每个审批节点默认挂一个Timeout属性,后台有一个定时任务扫描所有状态为“审批中”的实例,当当前时间超过节点的Timeout时限时,自动触发超时回调:

public async Task CheckTimeoutAsync() { var expiredInstances = await _repository.GetExpiredInstancesAsync(DateTime.UtcNow, 1000); foreach (var instance in expiredInstances) { var ctx = new WorkflowContext(instance); var node = _cache.GetDefinition(instance.WorkflowId, instance.Version) .GetNode(instance.CurrentNodeId); await _notifier.SendTimeoutAlertAsync(ctx, node); if (node.AutoEscalate) { var next = _routing.ResolveEscalationNode(ctx); await MoveToNodeAsync(instance, next); } } }

超时处理不是简单的催办就完事。我们的经验是提供两档升级策略:第一档超时,给当前审批人发提醒消息;如果超过第二档时限,自动转交给上一级领导。这才真正解决了审批阻塞问题。

外部系统调用超时则比较简单,所有下游调用必须在设定的时间内给出响应,否则引擎默认该节点处理失败,抛出异常回滚到上一个稳定状态。这里不要偷偷吞掉异常,否则业务就在你毫无察觉的情况下中断了。宁可让调用方感知,也不要让流程默默卡死。

5.4 给开发者的几个善意提醒

最后分享几点我踩了无数坑以后总结出来的经验,这些比代码本身更值钱。

第一,先跑通单机,再考虑分布式。很多团队一上来就想搞分布式工作流,又是Temporal又是Saga,结果基础的单机流程都没跑顺畅。真正的性能提升来自优化单机的资源利用率和代码质量,分布式带来的复杂度是你难以想象的。我们做到10万+TPS时,其实还是单机架构。

第二,流程定义必须有版本管理。流程上线后不可避免要改版,如果老流程实例仍然使用旧定义,新提交的实例却要按新定义走,你就必须给定义加版本号,并制定切换策略。我们在定义表增加了版本号字段,支持同ID多版本共存,切换版本时只影响新实例。这个看似简单,却是避免“改一个流程全部崩盘”的关键。

第三,不要过度设计。我们的引擎核心代码只有不到两万行,相比Flowable动辄几十万行的代码量,反而更容易维护。工作流引擎的本质就是“状态管理 + 路由策略 + 事件通知”,你只要抓住这三条主线,其他功能都可以根据需要逐步加。

第四,自动化必须配可观测性。自动化程度越高,出问题时越不容易定位。我们上线初期的最大教训就是:没有配套的监控和链路追踪,一旦流程出问题,只能靠手工查数据库翻历史表,效率极低。后来补上了全链路日志和追踪ID,排查问题的效率提升了至少五倍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询