工作流引擎Activiti核心概念与实战:从BPMN到流程变量管理

1. 为什么我们绕不开工作流引擎?

如果你在开发一个稍微复杂点的业务系统,比如OA审批、采购流程、请假报销,或者更复杂的金融信贷审批、生产线工单流转,你大概率会遇到一个头疼的问题:业务流程的代码怎么写?最直接的想法,可能是用一堆if-else或者switch-case来硬编码。一个简单的请假流程,你可能会写出这样的伪代码:

if (status.equals("提交")) { // 通知部门经理 notifyManager(); status = "经理审批中"; } else if (status.equals("经理审批通过")) { // 判断请假天数 if (leaveDays > 3) { // 通知总监 notifyDirector(); status = "总监审批中"; } else { // 直接归档 archive(); status = "已完成"; } } else if (status.equals("总监审批通过")) { // 归档 archive(); status = "已完成"; } else if (status.equals("经理驳回") || status.equals("总监驳回")) { // 通知申请人 notifyApplicant(); status = "已驳回"; }

看起来好像也能跑,对吧?但问题很快就会接踵而至:产品经理说,经理审批后要加一个财务知会环节;老板说,超过5天的假期需要HR备案;甚至法规变了,某个环节需要增加会签。每一次变动,你都需要去修改这坨已经盘根错节的业务代码,小心翼翼地测试,生怕改出新的Bug。更麻烦的是,流程的状态、当前处理人、历史记录这些信息,和你的业务数据(请假单本身)高度耦合,想单独查看流程的进展图?想统计每个节点的平均处理时间?几乎不可能。

这个时候,工作流引擎的价值就凸显出来了。它本质上是一个业务流程的建模、执行和监控平台。你把“谁在什么条件下做什么事”这个流程画出来(建模),引擎负责按照这个图纸去驱动流程流转(执行),并且完整地记录下每一步的痕迹(监控)。Activiti,作为Java领域最主流、最成熟的开源工作流引擎之一,就是帮你解决上述痛点的利器。它让你能把经常变化的业务流程从硬编码中剥离出来,变成可配置、可视化的模型,从而实现业务逻辑与流程控制的解耦。接下来的内容,我会结合自己多年在金融和政务项目中使用Activiti的经验,带你从零开始,彻底搞懂它。

2. 核心概念扫盲:BPMN2.0与Activiti的“世界观”

在动手写代码之前,必须理解Activiti赖以生存的“语言”——BPMN2.0。你可以把它理解为绘制流程的“工程制图标准”。Activiti是这套标准的Java实现。不理解这些核心概念,看代码和文档会非常吃力。

2.1 流程定义与流程实例

这是最容易混淆的一对概念,但至关重要。

  • 流程定义:就是你的流程图,是静态的模板。它定义了流程有哪些步骤、怎么连线。好比是“请假流程”这张设计图纸。
  • 流程实例:是流程定义的一次具体执行。好比是小张在2023年10月26日发起的那一次具体的请假申请。一个流程定义可以产生无数个流程实例。

在Activiti中,你首先需要部署一个流程定义(通常是一个.bpmn20.xml文件),部署成功后,引擎会将其解析并存储到数据库。当有人发起申请时,你就基于这个流程定义启动一个流程实例

2.2 关键元素与符号

BPMN2.0的元素很多,但掌握以下几个核心的,就能覆盖80%的场景:

  1. 事件:用圆圈表示,代表流程中“发生的事情”。

    • 开始事件:流程的起点,一个流程必须有且只有一个。
    • 结束事件:流程的终点,可以有多个。
    • 中间事件:挂在活动边界上的,比如“超时提醒”(定时中间事件)。
  2. 活动:用圆角矩形表示,代表需要完成的“工作”。

    • 用户任务:需要人工参与的活动,比如“经理审批”。这是最常用的活动类型,引擎会为此生成一条待办任务。
    • 服务任务:自动执行的活动,比如调用一个Java类(JavaDelegate)去发送邮件、更新某个业务状态。
    • 脚本任务:执行一段脚本(如Groovy)来自动处理逻辑。
  3. 网关:用菱形表示,负责控制流程的分支与合并。

    • 排他网关:像一道单选题。多条流出路径,引擎会只选择第一条条件为true的路径执行。这是最常用的网关。
    • 并行网关:允许多条路径同时进行,所有并行分支都完成后,流程才继续向下。比如“会签”,需要所有会签人都同意。
    • 包容网关:更灵活,可以同时激活多条符合条件的路径,也可以只激活一条。
  4. 顺序流:带箭头的实线,连接各个元素,表示执行顺序。

  5. 流程变量:这是Activiti的“血液”。它是在流程实例级别或任务级别存储的键值对数据。比如,leaveDays(请假天数)、applicant(申请人)、approvalComment(审批意见)都可以作为流程变量。网关的判断条件、任务的处理人指派,都依赖于流程变量。

注意:很多初学者喜欢把业务实体的ID(如leaveId)作为流程变量存进去,然后在任务处理时再去查业务库。这是一个好习惯,保持了工作流数据和业务数据的松耦合。工作流引擎只关心流程怎么走,不关心请假单的详细信息是什么。

3. 环境搭建与核心API初探

理论懂了,我们来点实际的。搭建一个可用的Activiti环境,远不止加一个Maven依赖那么简单。

3.1 依赖引入与版本选择

首先,通过Maven引入核心依赖。这里以Activiti 7.x(Spring Boot风格)为例,它比老版本更易于集成。

<dependency> <groupId>org.activiti</groupId> <artifactId>activiti-spring-boot-starter</artifactId> <version>7.1.0.M6</version> <!-- 注意使用稳定版本 --> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> <!-- 初期测试用H2内存数据库,方便 --> </dependency>

版本选择的经验谈:Activiti 5.x 是经典版本,极其稳定,文档丰富,但架构稍旧。Activiti 6.x 引入了新的ProcessEngineConfiguration配置方式。Activiti 7.x 最大的变化是拥抱了Spring Boot,自动配置做得很好,并且将REST API分离成了activiti-cloud项目。对于新项目,我建议从7.x开始。但要注意,7.x的某些小版本可能存在Bug,生产环境务必选择社区反馈稳定的版本。

3.2 数据库的考量与表结构

Activiti需要数据库来存储流程定义、实例、任务、变量等所有运行时数据。它会自动创建28张表(以ACT_为前缀)。这些表可以分为几类:

  • ACT_RE_*:RE代表repository,存储流程定义、模型等静态部署信息。
  • ACT_RU_*:RU代表runtime,存储运行时的流程实例、任务、变量等。这些表的数据在流程结束后会被删除(历史数据移入历史表)。
  • ACT_HI_*:HI代表history,存储所有历史数据,用于查询和报表。
  • ACT_GE_*:GE代表general,通用数据,如二进制资源(流程图的BPMN XML和图片)。

生产环境数据库选型:MySQL/PostgreSQL/Oracle均可。这里有个大坑:MySQL的默认存储引擎InnoDB对事务支持好,但早期版本在ACT_HI_历史表频繁插入时可能有性能问题。如果流程量非常大(日流程实例数万级),需要重点监控和优化历史表,或者考虑使用ACTIVITI_HISTORY级别配置来减少历史记录。另一个经验是,为ACT_RU_TASK(运行时任务表)和ACT_HI_TASKINST(历史任务表)的PROC_INST_ID_(流程实例ID)字段加上索引,能极大提升根据流程实例查任务的效率。

3.3 核心服务对象:你的操作手柄

Activiti的核心API围绕ProcessEngine展开,通过它你可以获取各种服务,每个服务职责单一:

// 通常由Spring容器注入,这里演示直接获取 ProcessEngine processEngine = ProcessEngines.getDefaultProcessEngine(); // 仓库服务:管理流程部署、定义 RepositoryService repositoryService = processEngine.getRepositoryService(); // 运行时服务:启动流程实例、管理流程变量 RuntimeService runtimeService = processEngine.getRuntimeService(); // 任务服务:查询、办理、指派任务 TaskService taskService = processEngine.getTaskService(); // 历史服务:查询历史数据 HistoryService historyService = processEngine.getHistoryService(); // 管理服务:管理引擎、数据库作业等(一般用不上) ManagementService managementService = processEngine.getManagementService();

记住这几个服务的分工,你就知道该找谁办事了:要部署流程找RepositoryService,要发起申请找RuntimeService,要处理待办找TaskService,要查统计报表找HistoryService

4. 第一个端到端流程:从建模到运行

让我们用一个极简的“请假申请-经理审批”流程,串起整个生命周期。我们会创建一个BPMN文件,部署它,然后启动实例并完成审批。

4.1 绘制流程模型

你可以使用Activiti官方提供的Eclipse插件,或者更流行的在线/桌面工具如Camunda Modeler(与Activiti兼容性好)来画图。这里我们直接写XML来理解本质。

创建一个文件simple-leave.bpmn20.xml:

<?xml version="1.0" encoding="UTF-8"?> <definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:activiti="http://activiti.org/bpmn" targetNamespace="http://www.activiti.org/processdef"> <!-- 定义一个流程,id是代码中引用的key,name是显示名 --> <process id="simpleLeaveProcess" name="简单请假流程" isExecutable="true"> <!-- 开始事件 --> <startEvent id="startEvent1" name="开始"></startEvent> <!-- 用户任务:填写申请 --> <userTask id="fillRequest" name="填写请假申请" activiti:assignee="${applicant}"> <extensionElements> <!-- 通常这里可以绑定前端表单 --> </extensionElements> </userTask> <!-- 用户任务:经理审批 --> <userTask id="managerApprove" name="经理审批" activiti:candidateGroups="manager"> <extensionElements> <!-- 审批表单 --> </extensionElements> </userTask> <!-- 排他网关:根据审批结果判断 --> <exclusiveGateway id="decisionGateway" name="审批结果?"></exclusiveGateway> <!-- 结束事件:同意 --> <endEvent id="endEventApprove" name="同意结束"></endEvent> <!-- 结束事件:驳回 --> <endEvent id="endEventReject" name="驳回结束"></endEvent> <!-- 顺序流连接所有元素 --> <sequenceFlow id="flow1" sourceRef="startEvent1" targetRef="fillRequest"></sequenceFlow> <sequenceFlow id="flow2" sourceRef="fillRequest" targetRef="managerApprove"></sequenceFlow> <sequenceFlow id="flow3" sourceRef="managerApprove" targetRef="decisionGateway"></sequenceFlow> <!-- 带条件的顺序流 --> <sequenceFlow id="flowToApprove" sourceRef="decisionGateway" targetRef="endEventApprove"> <conditionExpression xsi:type="tFormalExpression"> <!-- 判断变量 approvalResult 是否为 'agree' --> <![CDATA[${approvalResult == 'agree'}]]> </conditionExpression> </sequenceFlow> <sequenceFlow id="flowToReject" sourceRef="decisionGateway" targetRef="endEventReject"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${approvalResult == 'reject'}]]> </conditionExpression> </sequenceFlow> </process> </definitions>

关键点解析

  1. activiti:assignee="${applicant}":这是一个任务指派表达式${applicant}是一个流程变量,在运行时会被替换为实际的值(比如用户ID “zhangsan”)。这意味着任务的具体处理人是在流程运行时动态决定的。
  2. activiti:candidateGroups="manager":这是候选组。任务不是指派给具体某个人,而是指派给一个组(角色)。所有属于“manager”这个组的用户,都能看到并认领这个任务。
  3. 条件表达式${approvalResult == 'agree'}:网关的流转路径依赖流程变量approvalResult的值。

4.2 部署流程定义

有了BPMN文件,我们需要将其部署到引擎中,使其成为一个可用的流程定义。

@Autowired private RepositoryService repositoryService; public void deployProcess() { Deployment deployment = repositoryService.createDeployment() .addClasspathResource("processes/simple-leave.bpmn20.xml") // 从类路径加载 // .addInputStream("simple-leave.bpmn", inputStream) // 也可以从输入流部署 .name("简单请假流程部署") .deploy(); // 执行部署 System.out.println("部署成功,部署ID: " + deployment.getId()); System.out.println("部署名称: " + deployment.getName()); }

部署成功后,你可以通过repositoryService.createProcessDefinitionQuery().deploymentId(deployment.getId()).singleResult()查询到生成的流程定义。它的key就是XML中processid,即simpleLeaveProcess

4.3 启动流程实例

现在,员工“张三”要请假了,我们需要为他启动一个流程实例。

@Autowired private RuntimeService runtimeService; @Autowired private IdentityService identityService; // 用于设置流程发起人 public String startLeaveProcess(String applicantUserId, int leaveDays, String reason) { // 通常,我们会用业务键(businessKey)关联业务实体 String businessKey = "LEAVE_20231026001"; // 例如,请假单号 // 设置流程发起人(会记录到ACT_HI_PROCINST表的START_USER_ID_字段) identityService.setAuthenticatedUserId(applicantUserId); // 准备流程变量 Map<String, Object> variables = new HashMap<>(); variables.put("applicant", applicantUserId); // 用于填充 fillRequest 任务的 assignee variables.put("leaveDays", leaveDays); variables.put("reason", reason); variables.put("businessKey", businessKey); // 也可以作为变量存储 // 启动流程实例 ProcessInstance processInstance = runtimeService.startProcessInstanceByKey( "simpleLeaveProcess", // 流程定义的key businessKey, // 业务键 variables // 流程变量 ); System.out.println("流程实例启动成功,实例ID: " + processInstance.getId()); System.out.println("业务键: " + processInstance.getBusinessKey()); return processInstance.getId(); }

启动后,流程会停留在第一个用户任务节点fillRequest。因为assignee${applicant},而变量applicant被设置为了applicantUserId(比如“zhangsan”),所以这个任务会自动指派给张三。

4.4 查询与办理任务

现在,张三需要查询自己的待办任务并完成“填写申请”这个步骤。

@Autowired private TaskService taskService; // 1. 查询用户的任务列表 public List<Task> getTasksForUser(String userId) { return taskService.createTaskQuery() .taskAssignee(userId) // 查找指派给该用户的任务 // .taskCandidateUser(userId) // 查找该用户作为候选人的任务(组任务) // .processDefinitionKey("simpleLeaveProcess") // 按流程类型过滤 .orderByTaskCreateTime().desc() // 按创建时间排序 .list(); } // 2. 办理任务(填写申请) public void completeFillRequest(String taskId, Map<String, Object> moreVars) { // 办理任务时,可以提交更多的流程变量 // 例如,张三提交后,确定了具体的请假类型 moreVars.put("leaveType", "年假"); // 完成任务,流程会自动流向下一个节点 managerApprove taskService.complete(taskId, moreVars); System.out.println("任务[" + taskId + "]已完成,流程已进入经理审批环节。"); }

当张三完成任务后,流程会到达managerApprove节点。这是一个候选组任务,所有“manager”组的成员(比如李四、王五)都能在候选任务列表中看到它。

4.5 处理组任务与网关决策

经理“李四”需要先认领这个组任务,将其变为自己的个人任务,然后进行审批。

// 经理李四认领任务 public void claimTask(String taskId, String userId) { taskService.claim(taskId, userId); System.out.println("任务[" + taskId + "]已被用户[" + userId + "]认领。"); } // 经理李四审批(办理任务) public void completeManagerApprove(String taskId, String approvalResult, String comment) { Map<String, Object> vars = new HashMap<>(); vars.put("approvalResult", approvalResult); // 必须是 'agree' 或 'reject' vars.put("managerComment", comment); // 可以在完成任务时添加评论 taskService.addComment(taskId, null, comment); // null 代表关联到流程实例 taskService.complete(taskId, vars); System.out.println("经理审批完成,结果: " + approvalResult); }

taskService.complete被调用后,流程到达排他网关decisionGateway。引擎会计算两条流出顺序流的条件:

  • 如果approvalResult变量等于'agree',流程流向endEventApprove,流程实例正常结束
  • 如果等于'reject',流程流向endEventReject,流程实例同样结束,但路径不同,在历史记录中可以看到不同的结束节点。

至此,一个完整的、最简单的流程就跑通了。你可以通过HistoryService查询这个流程实例的完整生命周期轨迹。

5. 深入流程变量:引擎的“记忆”与“决策依据”

流程变量是Activiti中贯穿始终的核心概念,它决定了流程的走向、任务的处理人,也是业务数据与流程交互的桥梁。理解它的作用域和生命周期至关重要。

5.1 变量的作用域:流程实例 vs 任务实例

  • 流程实例变量:作用域是整个流程实例。在流程实例启动时或任何节点通过runtimeService.setVariable()设置。在任何后续的节点、网关、表达式中都可以访问。适合存储全局性的数据,如applicant,leaveDays,businessKey
  • 任务局部变量:作用域仅限于单个任务。通过taskService.setVariableLocal()设置。只有在这个任务执行期间可以访问,任务完成后,如果未将其提升为流程变量,它就会消失。适合存储仅在当前任务中有用的临时数据,比如一个临时的计算中间值。

一个常见的误用场景:在审批任务中,审批意见comment通常只与该任务相关。很多人会把它作为流程实例变量存储。这虽然可以,但会导致流程变量表膨胀。更好的做法是使用taskService.addComment()添加任务评论,或者将其作为任务局部变量。查询历史时,可以通过HistoryService专门查询任务评论。

5.2 变量的序列化与存储

当你调用setVariable(“myObject”, myComplexObj)时,Activiti需要把这个Java对象存到数据库的ACT_RU_VARIABLEACT_HI_VARINST表。这里有个大坑:默认的序列化机制是Java原生序列化。

public class LeaveRequest implements Serializable { // 必须实现Serializable private Long id; private String type; // getters and setters } LeaveRequest request = new LeaveRequest(); runtimeService.setVariable(processInstanceId, "leaveRequestObj", request);

为什么这是坑?

  1. 数据库可读性差:存进去的是二进制BLOB,无法直接查看和调试。
  2. 版本兼容性问题:如果LeaveRequest类结构变了(增删字段),反序列化旧流程实例的变量时会失败,导致流程无法继续。
  3. 数据库迁移困难:二进制数据在不同数据库间迁移可能有问题。

最佳实践建议

  • 存储引用,而非对象:只存业务实体的ID(如leaveRequestId),需要时再用这个ID去业务数据库查询完整的业务对象。
  • 必须存对象时,使用JSON:利用Activiti的JsonTypeCustomVariableType。你可以配置一个自定义的变量类型,使用Jackson等库将对象序列化为JSON字符串存入数据库的TEXT类型字段。这样数据可读,且对类结构变化的容忍度更高(反序列化时忽略未知字段)。

5.3 通过变量驱动动态指派与条件分支

这是流程变量的高级用法,让流程变得灵活。

动态任务指派

<userTask id="dynamicTask" name="动态审批" activiti:assignee="${nextApprover}"/>

在流程中,你可以在一个服务任务里,根据复杂规则计算出下一个审批人是谁,并将其赋值给流程变量nextApprover,从而实现动态路由。

复杂的网关条件

<sequenceFlow sourceRef="gateway" targetRef="pathA"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${leaveDays > 3 && leaveType == '病假'}]]> </conditionExpression> </sequenceFlow> <sequenceFlow sourceRef="gateway" targetRef="pathB"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${leaveDays <= 3}]]> </conditionExpression> </sequenceFlow>

条件表达式支持EL表达式,可以组合多个变量进行复杂逻辑判断。但要注意,表达式不宜过于复杂,否则难以维护和调试。对于极其复杂的路由逻辑,建议在网关前用一个服务任务来计算一个简单的路由键(如nextStep),然后网关根据这个路由键做简单判断。

6. 用户任务与身份管理:谁来做?

Activiti自带一套简单的用户-组关系表(ACT_ID_USER,ACT_ID_GROUP,ACT_ID_MEMBERSHIP),但在实际企业应用中,99%的情况都不会直接使用它,而是与公司现有的LDAP、AD或自建用户系统集成。

6.1 集成外部身份系统

关键在于实现Activiti的IdentityService接口背后的UserIdentityManagerGroupIdentityManager。更常见的做法是完全绕过Activiti的身份表。

实践方案

  1. 任务查询时过滤:在调用TaskService.createTaskQuery()时,你手头有当前登录用户的ID和所属角色列表。你可以这样查询:

    // 查询指派给我的任务 taskService.createTaskQuery().taskAssignee(currentUserId).list(); // 查询我所属角色能看到的候选组任务 List<String> myGroupIds = getCurrentUserGroupIds(); // 从你的用户系统获取 taskService.createTaskQuery().taskCandidateGroupIn(myGroupIds).list();

    这样,任务的指派和候选组信息虽然存储在Activiti中,但用户和组的验证逻辑完全由你的外部系统控制。

  2. 在流程中使用角色名而非具体人:在BPMN中,任务的candidateGroups永远填写角色名(如dept_manager,hr_bp)。在启动流程或流转时,通过一个监听器或服务任务,根据业务规则和当前用户上下文,动态地将角色解析为具体的用户ID,并设置到assigneecandidateUsers中。

6.2 任务监听器与指派

activiti:assignee="${applicant}"这种静态表达式有时不够用。我们可以使用任务监听器在任务创建时动态指派。

<userTask id="hrApprove" name="HR备案"> <extensionElements> <activiti:taskListener event="create" class="com.yourcompany.listener.HrTaskAssignmentListener"/> </extensionElements> </userTask>
public class HrTaskAssignmentListener implements TaskListener { @Override public void notify(DelegateTask delegateTask) { // 从流程变量或业务规则中计算负责人 String processInstanceId = delegateTask.getProcessInstanceId(); String businessKey = (String) delegateTask.getVariable("businessKey"); // 根据业务键查询业务数据,决定HR负责人 String hrPersonInCharge = yourService.findHrByBusinessKey(businessKey); // 动态指派 delegateTask.setAssignee(hrPersonInCharge); // 或者设置候选组/人 // delegateTask.addCandidateGroup("hr_team"); // delegateTask.addCandidateUser("user1"); } }

监听器非常强大,除了create事件,还有assignment(任务被指派时)、complete(任务完成时)等事件,可以用于自动设置变量、发送通知、记录日志等。

7. 历史数据与流程监控:事后如何复盘?

流程跑起来不是终点,运维和业务分析更需要关注流程的运行情况。HistoryService是你的得力工具。

7.1 历史数据查询

Activiti默认会将运行时的数据(ACT_RU_*)在流程结束后归档到历史表(ACT_HI_*)。你可以查询:

  • 历史流程实例createHistoricProcessInstanceQuery()
  • 历史活动createHistoricActivityInstanceQuery()—— 可以看到流程走过的每一个节点、开始和结束时间。
  • 历史任务createHistoricTaskInstanceQuery()—— 每个用户任务的详细信息、办理人、耗时。
  • 历史变量createHistoricVariableInstanceQuery()—— 流程变量的历史值。
// 查询某个业务键对应的已结束的流程实例 HistoricProcessInstance historicInstance = historyService .createHistoricProcessInstanceQuery() .processInstanceBusinessKey("LEAVE_20231026001") .finished() // 只查已结束的 .singleResult(); if (historicInstance != null) { // 查询这个实例的所有活动记录 List<HistoricActivityInstance> activities = historyService .createHistoricActivityInstanceQuery() .processInstanceId(historicInstance.getId()) .orderByHistoricActivityInstanceStartTime().asc() .list(); // 可以据此绘制出实际的流程轨迹图 }

7.2 流程性能分析与瓶颈定位

通过历史数据,我们可以做很多分析:

  • 节点耗时分析:计算每个userTask从创建到完成的平均耗时,找出审批瓶颈。
  • 流程超时分析:结合ACT_HI_ACTINST表的DURATION_字段,找出长时间卡住的节点。
  • 流程吞吐量统计:按天/周统计流程实例的启动和完成数量。

一个实用的技巧:在流程启动和每个关键任务节点,通过监听器或服务任务,向一张自定义的业务统计表插入时间戳和节点信息。这样,你可以完全根据自己的业务维度(部门、流程类型)进行高效的聚合查询,而不必每次都去解析复杂的历史表结构。

7.3 历史级别配置

历史数据不是越多越好,记录太多会影响性能。在ProcessEngineConfiguration中,可以配置history属性:

  • none: 不保存任何历史数据。性能最好,但无法审计。
  • activity: 只保存流程实例和活动节点信息。这是最常用的平衡选择,知道流程走过哪些路。
  • audit: 在activity基础上,增加任务、变量等信息。满足大部分审计需求。
  • full: 保存所有细节,包括流程变量的每次变更。性能开销最大,仅用于调试或极高审计要求的场景。
# 在Spring Boot配置中 spring.activiti.history-level=audit

对于超高频的流程,我通常在生产环境使用activity级别,并将历史数据定期归档到独立的分析库,以减轻主业务库的压力。