1. 项目概述
那天凌晨三点,我被一阵急促的电话铃声惊醒。生产环境的工作流引擎突然卡死,2000多张采购订单滞留在审批节点。监控系统显示CPU和内存都正常,但流程实例就像被施了定身咒一样纹丝不动。这个使用Activiti 7.1.0构建的采购审批系统,已经稳定运行了两年多,却在最不该出问题的月末结算日突然罢工。
当我远程连上服务器查看日志时,一条诡异的警告引起了我的注意:"Unable to evaluate expression: ${}"。这个看似无害的空EL表达式,最终揭开了一场由过度封装引发的技术灾难。本文将还原这次事故的完整排查过程,剖析Activiti框架中那些容易被忽视的设计陷阱。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心问题解析
2.1 空节点的致命伪装
在Activiti的流程定义XML中,我们经常会看到这样的服务任务配置:
xml复制<serviceTask id="approvalTask"
activiti:expression="${approvalService.process(execution)}" />
但很少有人会警惕下面这种配置:
xml复制<serviceTask id="emptyTask" activiti:expression="${}" />
这个没有指定任何实现类的空节点,在流程设计器里显示为普通任务节点,部署时不会报错,甚至能正常启动流程实例。问题在于:
- 静默吞噬异常:Activiti对空表达式的处理不是抛出错误,而是记录WARN日志后继续执行
- 上下文污染:当这个节点被跳过时,会往执行上下文中注入null值
- 蝴蝶效应:后续节点获取变量时可能触发NPE,但堆栈不会指向问题根源
2.2 过度封装的代价
事故系统采用了三层封装架构:
- 基础层:原生Activiti API
- 中间层:自定义流程引擎模板
- 应用层:业务注解驱动开发
问题出在中间层的"智能容错"设计上:
java复制public class SafeExpressionResolver {
public Object evaluate(String expression) {
try {
return defaultResolver.evaluate(expression);
} catch (Exception e) {
logger.warn("表达式执行失败: {}", expression);
return null; // 致命操作!
}
}
}
这种封装导致:
- 原始异常信息被掩盖
- 错误处理逻辑与业务代码耦合
- 故障传播路径被截断
3. 问题排查全记录
3.1 第一阶段:表象观察
现象描述:
- 流程实例卡在"财务复核"节点
- 日志无ERROR记录
- 数据库的ACT_RU_TASK表有任务记录,但无人可签收
错误排查:
- 检查任务分配逻辑:
sql复制SELECT * FROM ACT_RU_IDENTITYLINK
WHERE TASK_ID_ = '47503'
- 验证服务是否注册:
java复制applicationContext.getBean("financeApprovalService")
3.2 第二阶段:深度追踪
使用BPMN调试模式发现异常:
java复制// 关键诊断代码
ProcessDebugger debugger = new ProcessDebugger(runtimeService);
debugger.traceExecution(executionId);
输出显示流程变量中存在诡异变化:
| 变量名 | 前置节点值 | 空节点后值 |
|---|---|---|
| formData | JSON对象 | null |
| applicant | 用户对象 | 用户对象 |
3.3 第三阶段:真相浮出
最终在历史表中发现蛛丝马迹:
sql复制SELECT * FROM ACT_HI_VARINST
WHERE PROC_INST_ID_ = '23501'
ORDER BY TIME_ DESC
时间线还原:
- 流程正常经过5个节点
- 第6个空服务任务执行后,关键变量被置null
- 第7个节点因NPE导致事务回滚
- 重试机制使流程不断在第6-7节点间循环
4. 解决方案与优化措施
4.1 紧急修复方案
- 数据库热修复:
sql复制UPDATE ACT_RU_VARIABLE
SET TEXT_ = (SELECT TEXT_ FROM ACT_HI_VARINST
WHERE NAME_='formData' AND PROC_INST_ID_='23501'
ORDER BY TIME_ DESC LIMIT 1)
WHERE NAME_ = 'formData';
- 流程定义修正:
diff复制- <serviceTask id="placeholderTask" activiti:expression="${}" />
+ <serviceTask id="placeholderTask" activiti:class="com.util.NoOpTask" />
4.2 长期架构优化
- 表达式校验拦截器:
java复制public class ExpressionValidator extends AbstractBpmnParseListener {
@Override
public void parseServiceTask(Element serviceTaskElement) {
String expression = serviceTaskElement.attribute("activiti:expression");
if (StringUtils.isBlank(expression)) {
throw new ActivitiException("空表达式禁止部署: " + serviceTaskElement.attribute("id"));
}
}
}
- 封装层设计原则:
- 保持异常传播链完整
- 防御性编程不等于吞噬异常
- 关键操作必须有审计日志
- 监控增强方案:
python复制# 日志监控规则示例
alert: ActivitiEmptyExpression
expr: |
log_entries{component="activiti"}
|= "Unable to evaluate expression: ${}"
5. 经验总结与避坑指南
5.1 开发者自查清单
- 流程设计阶段:
- [ ] 所有服务任务必须指定class/expression/delegateExpression之一
- [ ] 在BPMN设计器中禁用空属性保存
- [ ] 对测试环境启用严格模式校验
- 代码审查重点:
- 检查所有catch块是否至少记录WARN日志
- 验证封装层是否保留了原始异常上下文
- 确认重试逻辑有最大次数限制
5.2 性能与安全的平衡
- 表达式预编译:
java复制Expression compiledExp = expressionManager.createExpression("${approvalService.process(execution)}");
// 部署时检查可解析性
compiledExp.getValue(execution);
- 沙箱模式:
xml复制<activiti:configuration>
<activiti:field name="expressionManager">
<bean class="org.activiti.engine.impl.javax.el.SandboxExpressionManager"/>
</activiti:field>
</activiti:configuration>
5.3 监控指标设计
关键监控项建议:
| 指标名称 | 阈值 | 检测方式 |
|---|---|---|
| 空表达式警告 | 0 | 日志关键字扫描 |
| 变量异常变更 | 同比>50% | 历史变量表对比 |
| 节点平均停留时间 | <30min | ACT_HI_ACTINST统计 |
| 事务回滚率 | <0.1% | 数据库事务日志分析 |
这次事故给我们的核心教训是:框架封装不是银弹,越是复杂的中间层,越需要保持底层异常的可见性。就像医生诊断需要完整的症状链一样,系统排错也依赖完整的异常传播路径。那些出于"友好性"考虑的异常捕获,很可能正在为系统埋下定时炸弹。
