流程图在测试环境跑得很顺,上线几周后开始出现乐观锁异常堆积、连接池排队、历史查询越来越慢——这三件事看起来是三个问题,其实是同一笔账的三个面。
拆开看,它们的因果是串在一条链上的。作业执行器处理不过来,作业表里的到期时间越堆越多,节点为了抢作业就得更高频地跑作业获取查询;历史表越长越大,每次流程推进要写的行数和索引维护成本上升,单个事务持有数据库连接的时间跟着变长;事务变长、连接被占久,并发更新的事务撞在一起的概率就高,乐观锁冲突抛出来的异常让作业重试,重试又变成新的作业,再次占连接。
所以一个常见的误判是:把乐观锁异常当成代码 bug 去改,把连接池排队当成池参数没调好去加,把历史查询慢当成缺索引去补。三个动作各自都能缓解一点,但过两周又会以另一种形式回来。真正要回答的问题是:这个业务流程一天往数据库里写多少行、每个事务占着连接多久、有多少个后台线程在同时抢同一批行。
审批流、订单履约、报销会签、工单流转这类业务有个共同点:单实例生命周期是天甚至月级的,中间大量时间在等人或等外部系统回调。这类负载的曲线形状跟普通的 Web 请求完全不一样,用"并发量"去套它,一开篇就偏了。
长流程的资源占用模型是三个量相乘:存活实例数 × 每实例的推进次数 × 每次推进的写入行数。并发数在这个模型里几乎是无关量。
举个口径演算。假设日发起 2000 个实例,平均生命周期 30 天,那么稳态下存活实例数约 6 万个——注意这 6 万个实例在大部分时间里什么都不做,它们只占空间,不占 CPU。真正产生负载的是"推进":每个实例从发起到结束,平均经过 20 个节点推进点,加上几次定时器触发和外部回调,按 15 次推进算,一天就是 2000 × 15 = 3 万次推进。
3 万次推进平摊到 86400 秒,平均不到 0.4 次/秒。如果按 QPS 思路做容量规划,这个数字会让你觉得随便一台机器都够。但每次推进不是一个请求,而是一组写:运行时执行行更新、任务行插入或删除、变量行写入、历史表的一批插入,粗估 10 行上下。3 万次推进 × 10 行就是每天 30 万行量级的写入,其中历史表部分只增不减。峰值时段的秒级 QPS 可能只有个位数,但秒级写入行数会跳到几百。
于是估算单位要换成三个:一是在跑的实例库存(决定运行时表体积和查询扫描范围),二是每天的推进次数(决定写入总量),三是每次推进的平均事务时长(决定连接池占用)。这三个量都只能从真实业务的 BPMN 图上数出来,不能从"我们每天多少单"直接换算。
还需要分清"被等待的实例"和"被推进的实例"。等人工审批的实例不消耗作业执行器线程,也不占连接池,但它们的执行行和变量行一直在库里,参与每次作业获取查询的扫描范围;被推进的实例才是消耗线程、连接和写入的那部分。把这两类混在一起算,是低估的第二个常见来源。
第三个常见来源是只算业务量、不算边界定时器。一个"超过 48 小时未审批自动升级"的边界定时事件,会在每个用户任务创建时插入一条定时器作业,即使 99% 的实例根本走不到超时。定时器作业的数量与"任务创建次数"成正比,而不是与"超时发生次数"成正比,这两者的比值在有人工环节的流程里通常是几十倍。
本文只讨论流程引擎部署的资源估算口径与常见瓶颈;不给出具体机型与报价,不提供任何性能承诺;所有容量与线程数值均为估算起点,必须按实际负载压测确认。
流程引擎里绝大部分"后台在干活"的部分,落到实现上就是作业执行器不断从作业表里捞记录、加锁、执行、删记录。要估这一笔,先得知道作业是谁生成的。
Flowable 的作业分两类。第一类是定时器作业:用户任务上的边界定时事件、中间定时捕获事件、定时器启动事件,都会在推进时算出一个到期时间,写进定时器作业表,等到期了才被捞出来执行。第二类是异步延续作业:服务任务上打了异步标记之后,节点本身不再在调用方的事务里执行,而是在流程推进的 API 调用中往作业表插一条记录,等事务提交后由本引擎的异步执行器接手。
这两类的调度逻辑不同,估算方式也不同。定时器作业带到期时间,作业获取线程按"到期时间小于等于当前时间"去捞,捞不到就说明要么还没到期、要么已经积压;异步作业没有到期约束,事务一提交就期望尽快被执行,它的排队长度直接反映执行器吞吐够不够。Camunda 侧的对应概念是节点上的 asyncBefore / asyncAfter,把节点执行放进作业执行器异步处理,本质上是把事务边界切到一个独立作业上。
作业表上有两个字段值得盯着看:锁拥有者和锁过期时间。节点捞到作业后会把自己的标识写进锁拥有者字段,并设一个未来的锁过期时间;在这段时间内别的节点不会碰这条记录;如果持有者没能按时完成(进程被杀、GC 停顿、数据库连接卡住),过期时间一过,这条作业就重新变成可被任何人捞走的状态。这个机制是集群能够无协调运行的基础,也是后面第二笔账和第三笔账的放大器。
Flowable 异步执行器的常见默认值大致是:线程池队列大小 100、核心线程数 8、最大线程数 8、线程存活时间 5000 毫秒、作业重试次数 3,每次获取的定时器作业数默认 1(具体以实际版本文档为准)。Camunda 的作业执行器核心线程数常见默认值为 3。这两个默认值放在"演示项目"里完全够用,放到有几百个边界定时器的生产流程上,是典型的开箱即不够。
估算这一笔的口径是:并发度 = 线程数 ÷ 单次作业平均耗时。8 个线程、单次作业 200 毫秒,理论上是 40 个作业/秒;但如果作业里同步调用了一个平均耗时 2 秒的外部接口,吞吐立刻掉到 4 个作业/秒。同一套参数,因为作业内容不同,能力差了 10 倍——这就是为什么"默认 8 个线程够不够"这个问题没有通用答案。
作业执行失败后不是直接消失。以 Flowable 为例,失败的异步作业会被转成一条带到期时间的定时器作业,等时间到了再变回异步作业重试一次;重试次数(常见默认 3 次)耗尽后,这条作业被认为是"死"作业,被移入死信作业表,只能人工介入看异常栈。这意味着一次持续性的外部接口故障,会在作业表里产生"1 条原始作业 × N 次重试 × 实例数"的放大效应,而且这些重试本身每条都要占一次执行、一个连接、一组写入。
队列满的含义也常被误读。作业获取线程从库里捞回一批作业交给线程池,线程池处理不过来的部分进队列;队列大小常见默认 100,一旦填满,获取线程就不会继续从库里捞,作业表的积压开始累积。这时候你在监控上看到的现象不是报错,而是"到期时间已经过了很久、作业还躺在表里"。所以作业积压的正确观测指标不是队列长度,而是到期时间与当前时间的差值分布。
还有一个容易被忽略的表:被挂起的流程定义或流程实例,其作业会被放到单独的挂起作业表。官方给出的理由是让作业获取查询的条件尽量少。这句话很值钱——它说明了引擎作者已经把"作业获取查询的复杂度"当成一等公民在优化。反过来讲,任何让这张查询变慢的因素(历史表膨胀导致的统计信息过期、索引碎片、锁等待)都会直接打在作业吞吐上。
定时器作业的获取条数是个容易被忽略的旋钮。常见默认值里每次获取的定时器作业数为 1,配合固定的获取间隔,等价于"定时器吞吐上限 = 1 ÷ 间隔"。在一个大量使用边界定时器的报销或工单流程里,这个上限会成为硬天花板:定时器作业明明已经到期,却因为每次只捞 1 条而排队。调大每次获取条数的同时要同步看线程池队列,捞回来却排不下只会把积压从库里挪到内存里。
死信作业表是需要被纳入运维流程的。它不会自动清理,里面躺着的每一条都代表一个卡住的实例和一个等待中的业务结果。如果一个流程上线半年没人看过死信表,那里面堆积的数量本身就是对"这套流程是否真的在闭环"的回答。
连接池这条规则在官方讨论里说得很直白:池大小必须覆盖异步执行器线程数加上峰值并发 API 请求,因为每个后台作业线程在运行时都需要自己的连接。提高异步执行器线程数必须同步提高池大小,否则线程池扩容是完全无效的——线程起来了,卡在等连接上。
Spring Boot 默认用 HikariCP,其默认池大小常见为 10。对一个纯 REST 应用来说 10 是个合理的起点,但对一个后台就开着 8 个作业线程、前台还有几十个并发请求的流程引擎应用来说,10 意味着作业线程和 API 请求在同一时间只有 10 个能真正往下走。这个默认值偏小不是设计缺陷,是因为默认配置的作者无法预知你的线程池开多大。
池不足时的表现很有迷惑性。第一层是获取连接超时(HikariCP 的连接获取超时常见默认 30 秒),API 侧表现为一批请求同时 500;第二层更隐蔽:作业线程卡在等连接,持有作业的锁却干不了活,锁过期时间一到,这条作业被别的节点(或本节点的下一次获取)重新捞走,形成同一作业被执行两次的窗口;第三层是重试与冲突叠加,把第一笔账的压力又放大一轮。你在日志里看到的是乐观锁异常,根因却是池子小了几格。
调大的代价要一并算进去。每个连接都要占用数据库侧的内存与工作线程,连接数上去之后,同一张运行时表的行锁与页锁竞争会更频繁;在 SQL Server 上连接数过高还会挤压 worker threads。所以池大小不是一个"越大越好"的旋钮,它的上界由数据库的并发承载能力决定,而且池大小和作业线程数必须成对调整。
数据库侧还有两条针对 SQL Server 的常见建议值得放进部署清单:一是使用 READ_COMMITTED_SNAPSHOT 隔离级别,因为引擎基于乐观锁与事务级缓存工作,快照隔离能显著降低锁争用与死锁;二是 JDBC URL 加 sendStringParametersAsUnicode=false,仅在文本字段确实不需要 Unicode 专用字符时使用,否则驱动会把所有字符串参数按 Unicode 发送,导致相关索引失效。第二条属于典型的"不调不知道,一调差十倍"的参数,但前提要先确认业务里没有非 Unicode 不能表达的字符。
运行时表是有出有进的:流程实例结束后,对应的运行时数据会被删掉。历史表不是,它只增不减,是这三笔账里唯一随时间单调增长的部分,也是唯一一个"上线第一天没问题、半年后一定出问题"的部分。
Flowable 官方给出的历史级别由低到高是 none、instance、task、activity、audit、full,默认级别是 audit。级别越高,写入量和表体积越大;官方也明确提到,对高吞吐的一次性编排,可以通过降低历史级别显著减少写入。Camunda 的历史级别同样分档命名,默认 AUDIT 会记录流程实例、活动实例与变量更新(具体档位与行为以所使用版本的官方文档为准)。
增长最快的两张表通常是变量历史表和历史明细表。原因是它们的行数和"变量更新次数"成正比,而不是和"流程实例数"成正比。一个订单履约流程如果在每个节点都更新一次同一批变量,20 个节点就是 20 组变量历史行;如果变量里还塞了整单 JSON,那么每组变量历史行都是一次大值写入。这就是官方反复提醒的性能陷阱:大对象放进流程变量,会被持久化到运行时变量表,并在每次推进时被加载进 JVM 堆——它同时打击数据库连接、磁盘与 JVM 内存三个维度。
清理任务这条也要算进写入负载里。Flowable 内置历史清理任务默认关闭,可配置保留期、执行周期与批量大小,官方特别提示批量大小需要控制在舒适的事务规模内。原因很直接:清理本身是一批批量删除,批量太大就变成一个大事务,会撑爆事务日志、可能触发锁升级、和在线写入抢资源;批量太小则永远追不上增长速度。清理窗口应该放在业务低峰,并且要盯着"清理速率 vs 日增量"这一对数字——如果清理一天能删的量还不到一天新增的量,那保留期设多少都没意义。
历史查询变慢也不是单纯缺索引的问题。当历史表达到千万行量级,慢的往往不是单次查询,而是查询期间持有的连接时间与缓冲池占用,它会回过头去挤占第二笔账。三笔账在这里完成闭环。
| 三笔账 | 主要增长变量 | 耗尽时的具体表现 | 该看的指标 | 调整时的连带影响 |
|---|---|---|---|---|
| 作业执行器 | 定时器数量、异步延续节点数、单次作业耗时 | 作业表积压,到期时间远早于当前时间仍未被捞走;队列填满后获取线程停止取作业 | 到期时间与当前时间的差值分布、队列长度、重试次数、死信作业数 | 线程数上调必须同步上调连接池,否则线程卡在等连接;并发度上升会加剧乐观锁冲突 |
| 数据库连接池 | 后台作业线程数 + 峰值并发 API 请求 + 单事务持有时长 | 获取连接超时导致批量 API 失败;作业线程持锁空转,锁过期后被重复捞取 | 池活跃连接数与等待线程数、连接获取等待时长、单事务平均持有时长 | 池上调受数据库最大连接数与工作线程上限约束,也会加剧同一张表的锁竞争 |
| 历史表写入 | 历史级别、变量更新频次、变量体积 | 单次推进事务变长,历史查询与清理任务同时变慢,缓冲池被历史数据挤占 | 历史表日增行数、变量历史与明细表体积、清理速率与日增量之比 | 降低历史级别可减少写入但丢失审计粒度;清理批量过大形成大事务,需控制事务规模 |
| 乐观锁冲突 | 并行分支数、同一实例上的异步节点密度、非作业路径的并发更新 | 乐观锁异常抛出后作业进入重试,重试再转定时器作业,压力被放大回前两笔账 | 乐观锁异常速率、作业重试次数分布、同一实例上的并发更新命中率 | 减少不必要的异步节点或改用外部任务可降低冲突;独占作业串行化会牺牲部分并行吞吐 |
| 作业获取查询 | 节点数、每次获取条数、获取间隔、作业表体积与索引健康度 | 多节点反复扫描同一张作业表,锁等待上升,作业捞取延迟掩盖真实执行延迟 | 获取查询的平均与 P95 耗时、扫描行数、锁等待次数 | 加大每次获取条数需同步加大队列;加快获取间隔会线性抬高数据库的查询压力 |
流程引擎的并发控制是乐观锁:执行行带版本号,提交时版本对不上就抛异常,不加行锁、不等锁。这套设计在低冲突场景下吞吐最好,代价是冲突必须靠重试消化。
并行分支是冲突的主要来源。一个并行网关分出三条分支,每条分支上都是异步服务任务,那么作业执行器完全可能同时派出三个线程去推进同一个流程实例。三个线程各自读到同一个执行行版本,各自算完、各自提交,只有第一个成功,另外两个抛出乐观锁异常,作业进入重试。从业务视角看,流程最终是对的;从资源视角看,同一份工作被做了两遍以上,而且每次都要重新读、重新算、重新写。
引擎其实留了缓解开关。多数版本的异步作业默认带独占标记,同一流程实例上的独占作业会被串行调度,不会被两个线程同时执行(具体行为以实际版本文档为准)。这能压掉大部分由异步延续引发的冲突,但它是一笔交换:串行化意味着并行分支在作业层面不再并行,你为一致性让出了吞吐。
真正难消的是另一类冲突:不走作业执行器的路径。用户任务的 complete 调用、外部消息事件、定时器作业触发,这些来自 API 线程或定时器线程,它们与正在跑的异步作业线程指向同一个流程实例。独占标记管不到它们。所以一个"用户点了审批"的瞬间,如果同一实例上恰好有一个异步作业在提交,冲突就发生了。这类冲突的频率不取决于你画了多少并行网关,而取决于用户操作与后台作业的碰撞概率——实例越多、推进越密,概率越高。
这就是为什么乐观锁异常会把问题放大到连接池:异常不是终态,它触发重试,重试是新的作业执行,新的作业执行需要新的连接、产生新的写入、占用新的持锁时间。一个池子本来就紧的系统,会在冲突发生后突然进入正反馈,几小时内从"偶发几条异常"变成"大面积排队"。
缓解方向上,除了减少不必要的异步节点,更有效的做法是把热点分支上的工作改造成外部任务,让业务逻辑在引擎进程之外执行,只在开始和结束时与引擎交互两次;或者把一个大实例拆成多个小实例,让冲突域从"一个实例的所有分支"缩小到"一个实例的一个节点"。
加节点是这类系统最直觉的扩容动作,而且确实有效——前提是先算清楚代价。Flowable 的集群模型描述得很清楚:每个节点无状态,连同一个共享数据库;没有集群管理器,没有节点间通信与成员协议,没有分布式缓存。后台作业完全通过数据库协调,节点执行前对作业加锁,锁过期后其他节点自动回收。
这个模型的好处是运维极简,节点可以随便启停、随便扩缩,不需要选主、不需要 gossip。坏处是所有的协调成本都落到了共享库上,而且随节点数上涨。
第一项代价是连接数。每加一个节点,就在共享库上多占一份池大小。三个节点、每节点池 50,数据库侧就要承接 150 个常驻连接,再加上管理连接和清理任务的连接。这个数字常常不是被 CPU 顶住,而是被数据库的最大连接数和工作线程上限顶住。
第二项代价是作业获取查询。每个节点都在按固定间隔扫描同一张作业表、抢同一批行。节点越多,同一行被多个节点同时尝试加锁的次数越多,锁等待与索引竞争越频繁。官方把"挂起的作业单独放一张表"作为优化手段,目的就是让这张查询的条件尽量少——这也侧面说明作业获取查询是整个集群里最热的 SQL。
第三项代价来自"没有分布式缓存"这一条。流程定义的解析结果在每个节点各存一份,节点之间不会同步缓存失效。发布新版本流程定义时,各节点是在各自的下一次访问时才发现缓存过期并重新解析加载,节点越多,这个"各自重载"的时间窗越分散,期间同一份流程定义的新旧版本可能同时在处理实例。这不是故障,但会让灰度发布期间的观测数据变得难以解释。
第四项代价是锁过期时间的正确性依赖时钟。锁回收判断的是"过期时间是否已过",节点之间的时钟如果存在明显偏差,就可能出现作业被提前回收或迟迟不被回收。多节点部署之前,时钟同步是必须落在运维规范里的一条,而不是可选项。
还有一个判断值得写进扩容评估:横向扩展的收益在"作业执行耗时较长(外部调用、计算密集)"时很显著,在"作业本身很短、瓶颈是数据库写入"时几乎为零。后者加节点只会增加数据库 contention。所以加节点之前先确认一件事——当前瓶颈到底是线程不够,还是库写不动。官方的表述可以作为参考:数据库是 Flowable 部署中最重要的性能组件,所有运行时与历史状态都在数据库中以事务方式存放,通常最先成为瓶颈,而不是引擎本身。
三笔账算完,会得到一个反直觉但很实用的结论:有些逻辑放进引擎是亏的,应该主动留在引擎之外。
第一类是纯定时触发、没有人工环节、也不需要审计轨迹的批处理编排。它没有"等人"的部分,长流程的优势完全用不上,却要承担每次推进的完整写入。这类场景用调度器加任务队列更合适。
第二类是把大对象当状态在节点之间搬运的流程。整单 JSON、附件二进制、长列表一旦进了流程变量,就会被持久化到运行时变量表并在每次推进时加载进 JVM 堆,历史变量表也会同步放大。正确做法是把业务数据留在业务库,流程里只放 ID 和少量决策字段。
第三类是需要跨系统强一致的流程。引擎能保证的是本地事务加补偿,它无法提供跨库的分布式事务一致性。如果业务语义要求"扣款和发货要么都成要么都不成",那么这部分一致性应该在业务层用对账与补偿机制实现,而不是指望引擎的事务边界。
架构层面最常见的调整是外部任务模式。分布式环境下,官方建议用外部任务替代把业务逻辑写在 Java Delegate 里:业务进程自己去引擎拉取任务、执行、回传结果。这样做有两个直接收益,一是业务进程与引擎进程不再强耦合,业务发版不用动引擎;二是避免了长事务占用数据库连接——Java Delegate 里如果有一段 5 秒的同步调用,这个连接就被占 5 秒,而外部任务模式下引擎侧只有两次极短的事务。
取舍也要说清楚:外部任务模式把一次推进拆成了拉取、执行、回传三次交互,单实例的推进次数和写入量会上升,作业获取查询的压力也会上升。它换来的是线程与连接的短占用,代价是总交互次数变多。是否划算,取决于你当前的瓶颈是"连接被长事务占死"还是"库写入总量已经到顶"。
回到物理层,无论编排逻辑放在哪里,最终承载这些写入的还是那一套服务器与数据库。一万网络深耕 IDC 行业 19 年(成立于 2007 年),在华南、华东、华北及中国香港、海外多地整合自营节点与 BGP 多线资源,提供服务器租用、机柜托管与带宽接入的一体化交付;具体机型、带宽与报价以官网实时信息或咨询确认为准。
能直接从文档查到的是参数名和默认值,查不到的是你这幅流程图对应的负载系数。下面这几个数字只能靠压测取,任何"经验值"都不能替代。
第一个是每个实例的平均推进次数和每次推进的写入行数。做法是在测试环境开一个历史级别为 full 的实例,跑完整条路径,然后直接数运行时表与历史表各涨了多少行。这个数字乘上日发起量,就是数据库的日写入量,也是后面所有容量判断的分母。
第二个是单次作业的平均耗时与 P95 耗时。它决定了作业执行器的有效并发度,也直接决定连接池该开多大。注意要把外部调用的真实耗时算进去,测试环境里被 mock 掉的 2 秒,生产上就是吞吐除以 5。
第三个是作业积压曲线。用固定速率灌入实例,观察作业表里"到期时间与当前时间差值"随时间的变化:如果它保持平稳,说明吞吐够;如果它单调上升,说明吞吐已经不够,此时再怎么调池子都只是延后爆炸时间。
第四个是历史表日增量与清理速率的比值。跑满一天的真实流量,量出历史表涨了多少行、多少 GB,再用目标批量大小跑一次清理,看一个清理周期能删掉多少。清理速率必须持续大于日增量,否则保留期配置只是把问题推迟。
第五个是连接池的占用时长分布。要看的不是平均活跃连接数,而是"等待获取连接的线程数"在峰值时段是否长期大于零,以及单事务持有连接的 P95 时长。这两个数字一起决定了池大小的下界。
还要重申一遍边界:本文讨论的是流程引擎部署的资源估算口径与常见瓶颈,不涉及具体机型与报价,也不提供任何性能承诺。文中出现的线程数、队列大小、重试次数等均为公开文档中的常见默认值或估算起点,具体取值需以实际使用版本的官方文档为准,并按真实负载压测确认。
本文引用的机制性描述来自 Camunda 与 Flowable 的公开官方文档与官方论坛中可查的通用事实,包括作业的两类生成方式、锁拥有者与锁过期时间的协调机制、失败作业转定时器再重试直至进入死信作业表的链路、挂起作业单独建表的动机、无状态共享库集群模型的边界、历史级别的分档与默认级别、内置历史清理任务默认关闭及其批量大小需控制在舒适事务规模内的提示、连接池需覆盖后台作业线程数与峰值 API 并发的规则、以及 SQL Server 上 READ_COMMITTED_SNAPSHOT 与 sendStringParametersAsUnicode 两条建议。
涉及具体默认值的地方,文中一律标注"常见默认值,以实际版本文档为准",因为同一个参数在不同大版本之间确实存在调整,照抄旧文档的数值去配新版本是常见的事故来源。涉及容量与吞吐的地方,文中一律以"估算""口径""需按实际负载压测确认"表述,不给出任何基准测试数据、性能承诺或具体项目数据。
本文也不是两个引擎的功能对比评测。拿 Camunda 的 asyncBefore / asyncAfter 与 Flowable 的异步延续标记放在一起,是因为它们在资源模型上是同一个东西——都是把事务边界切到独立作业上,都会产生一条作业表记录和一次执行器调度。真正需要读者带走的不是"哪个引擎更好",而是"每加一个异步标记,就等于往三笔账里各加一笔"。
Copyright © 2013-2020 idc10000.net. All Rights Reserved. 一万网络 科技有限公司 版权所有 深圳市科技有限公司 粤ICP备07026347号
本网站的域名注册业务代理北京新网数码信息技术有限公司的产品