简介:这份PDF文档面向企业信息化建设者、后端开发与数据库设计人员,聚焦OA系统中流程审批模块的数据库建模问题,帮助读者理清审批流程从发起到归档的完整数据支撑思路。内容围绕流程实例表、活动实例表、审批任务表、规则配置表、历史版本表以及用户与角色权限表等核心结构展开,讲解各表字段含义、表间关联关系与状态流转设计,并涉及条件判断、审批权限分配、历史追溯与审计等实际场景,同时提及索引、分区等性能优化方向。资源包内共1个PDF文件,压缩包约35KB,篇幅精炼,适合作为数据库表结构设计的参考模板或方案速查。目前已有743人学习下载,读者可从中获取审批流程数据模型的整体框架、关键字段定义与扩展性设计思路,用于指导自身系统的表结构落地与后续迭代。
1. 审批流数据库到底难在哪:从一张请假单说起
很多团队做 OA 系统,前端页面画得飞快,流程引擎也选型完毕,结果上线两周就卡在数据库上:审批节点一改,历史单据全乱;一个人转岗,待办列表里冒出几百条脏数据;想查「谁在什么时间把单子打回给谁」,翻遍日志表也拼不出完整链路。问题不在引擎,而在流程审批数据库的设计从一开始就没把「流程定义」和「流程实例」分开。
这篇内容讲的是 OA 系统中流程审批模块的数据库设计,覆盖表结构怎么拆、节点和流转怎么落库、审批记录怎么留痕、并发和撤回怎么处理。适合正在自研 OA 的后端同学、需要给现有系统补审批模块的开发者,以及被「流程一改数据就崩」折磨过的维护者。读完你能拿到一套可直接建表的方案,也能看清哪些字段是后悔药,哪些设计是血泪经验换来的。
2. 流程审批数据库的表结构拆分:定义、实例、任务三分离
流程审批数据库设计最容易翻车的地方,是把「流程长什么样」和「这张单子走到哪了」塞进同一张表。前者是模板,后者是运行态,生命周期完全不同。模板可能一年改三次,运行态单据每天都在产生。混在一起,改模板就会污染历史数据。
2.1 为什么必须拆成流程定义表和流程实例表
先看一个真实场景:公司把「报销审批」从两级改成三级,如果流程配置和单据数据在同一张表,那么改完配置后,所有在途单据的审批路径会被一起改写,原本只需要总监签字的单子突然多出一个财务复核节点,历史已完成的单据也会显示成「未走完」。这不是引擎的锅,是表结构没有隔离。
常见做法是拆成三层:流程定义层(模板)、流程实例层(单据)、任务层(待办)。定义层描述「这类流程有几个节点、什么顺序、谁审批」;实例层描述「这张具体单据当前处于哪个节点、状态是什么」;任务层描述「当前这个节点该谁处理、处理结果是什么」。三层通过外键关联,模板变更只影响新发起的实例,在途实例按发起时快照走。
这里有个关键决策:实例要不要保存流程定义的快照。我一般会存。因为流程定义随时可能被修改甚至删除,如果实例只存一个 definition_id,半年后想还原这张单子当时走的路径,定义已经变了,查不出来。快照可以是一个 JSON 字段,也可以是独立的实例节点表,前者省事,后者便于按节点查询。
2.2 核心表结构设计与建表语句
下面这套表结构是我在多个 OA 项目里沉淀下来的,字段做了精简,保留关键部分。数据库以 MySQL 8.0 为例,字符集统一 utf8mb4。
-- 流程定义表:描述一类审批流程的模板 CREATE TABLE `wf_definition` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `def_key` VARCHAR(64) NOT NULL COMMENT '流程标识,如 leave_approval', `def_name` VARCHAR(128) NOT NULL COMMENT '流程名称,如 请假审批', `version` INT NOT NULL DEFAULT 1 COMMENT '版本号,同 key 可多版本', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1启用 2停用', `form_schema` JSON NULL COMMENT '表单字段定义', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_key_version` (`def_key`, `version`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='流程定义表'; -- 流程节点表:定义某个版本下有哪些节点 CREATE TABLE `wf_node` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `definition_id` BIGINT NOT NULL COMMENT '所属流程定义', `node_key` VARCHAR(64) NOT NULL COMMENT '节点标识,如 dept_manager', `node_name` VARCHAR(128) NOT NULL COMMENT '节点名称,如 部门经理审批', `node_type` TINYINT NOT NULL COMMENT '1审批 2抄送 3条件 4开始 5结束', `approver_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1指定人 2角色 3部门主管 4发起人自选', `approver_value` VARCHAR(255) NULL COMMENT '审批人标识,角色ID或用户ID', `sort_no` INT NOT NULL DEFAULT 0 COMMENT '节点顺序', `next_node_key` VARCHAR(64) NULL COMMENT '默认下一节点', PRIMARY KEY (`id`), KEY `idx_def` (`definition_id`, `sort_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='流程节点定义表'; -- 流程实例表:一张具体单据的运行态 CREATE TABLE `wf_instance` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `definition_id` BIGINT NOT NULL COMMENT '发起时的流程定义ID', `def_snapshot` JSON NULL COMMENT '发起时的流程快照,防止定义变更影响在途', `biz_key` VARCHAR(64) NOT NULL COMMENT '业务单据标识,如报销单号', `biz_type` VARCHAR(32) NOT NULL COMMENT '业务类型,如 expense', `title` VARCHAR(255) NOT NULL COMMENT '单据标题,用于待办展示', `initiator_id` BIGINT NOT NULL COMMENT '发起人', `current_node` VARCHAR(64) NULL COMMENT '当前节点key', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1审批中 2通过 3驳回 4撤回 5作废', `started_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `finished_at` DATETIME NULL, PRIMARY KEY (`id`), KEY `idx_biz` (`biz_type`, `biz_key`), KEY `idx_initiator` (`initiator_id`, `status`), KEY `idx_status_node` (`status`, `current_node`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='流程实例表'; -- 审批任务表:每个节点产生的待办/已办 CREATE TABLE `wf_task` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `instance_id` BIGINT NOT NULL COMMENT '所属实例', `node_key` VARCHAR(64) NOT NULL COMMENT '节点标识', `node_name` VARCHAR(128) NOT NULL COMMENT '节点名称冗余,便于列表展示', `assignee_id` BIGINT NOT NULL COMMENT '处理人', `action` TINYINT NULL COMMENT '1同意 2驳回 3转办 4加签', `comment` VARCHAR(500) NULL COMMENT '审批意见', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待办 1已办 2已取消', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `handled_at` DATETIME NULL, PRIMARY KEY (`id`), KEY `idx_assignee_status` (`assignee_id`, `status`), KEY `idx_instance` (`instance_id`, `node_key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批任务表';这套结构的关键点:wf_instance里的def_snapshot是后悔药,流程定义改了也不影响在途单据;wf_task里冗余了node_name,因为待办列表要展示节点名,如果每次都 join 定义表,定义一改历史任务显示就变了;assignee_id加status的联合索引,是待办列表查询的核心索引,没有它,用户待办一多就慢。
参数说明:def_key和version做唯一键,支持同一流程多版本共存,新版本启用后老实例继续走老版本。status用 TINYINT 而不是字符串,省空间且索引效率高。form_schema用 JSON 存表单定义,MySQL 8.0 支持 JSON 索引,如果表单字段需要检索,可以加虚拟列。
2.3 审批记录留痕表:怎么查「谁在什么时候做了什么」
任务表只记录当前节点的处理结果,但审批过程中还有转办、加签、撤回、催办这些动作,如果都塞进任务表,字段会爆炸。我一般单独建一张操作日志表,只追加不修改。
CREATE TABLE `wf_operation_log` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `instance_id` BIGINT NOT NULL, `task_id` BIGINT NULL COMMENT '关联任务,部分操作无任务', `operator_id` BIGINT NOT NULL COMMENT '操作人', `op_type` VARCHAR(32) NOT NULL COMMENT 'APPROVE/REJECT/TRANSFER/ADD_SIGN/WITHDRAW/URGE', `from_node` VARCHAR(64) NULL, `to_node` VARCHAR(64) NULL, `detail` JSON NULL COMMENT '操作详情,如转办目标人', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_instance_time` (`instance_id`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批操作日志表';这张表是黑匣子,出问题时全靠它还原现场。op_type用字符串而不是数字,因为操作类型会随业务扩展,字符串可读性更好,且这张表写入量远小于任务表,空间换可读性划算。detail用 JSON 存差异化字段,避免为每种操作加列。
3. 流程流转的落库逻辑:从发起到归档的完整链路
表建好了,接下来是流转逻辑。流程审批数据库设计里,流转是最容易写出并发 bug 的地方。两个人同时审批同一个节点、撤回和审批同时发生、加签后原审批人还能不能操作,这些都要在数据库层面兜住。
3.1 发起流程:实例创建与首个任务生成
发起流程时要做三件事:写实例、生成首个任务、写操作日志。这三步必须在同一个事务里,否则会出现实例创建了但没有待办任务的脏数据。
def start_process(def_key, biz_type, biz_key, title, initiator_id, form_data): with db.transaction(): # 1. 取当前启用的流程定义 definition = db.query_one( "SELECT * FROM wf_definition WHERE def_key=%s AND status=1 " "ORDER BY version DESC LIMIT 1", (def_key,)) if not definition: raise BizError("流程未启用") # 2. 取定义下的节点,生成快照 nodes = db.query( "SELECT * FROM wf_node WHERE definition_id=%s ORDER BY sort_no", (definition['id'],)) snapshot = {"nodes": nodes, "version": definition['version']} # 3. 写实例 instance_id = db.insert("wf_instance", { "definition_id": definition['id'], "def_snapshot": json.dumps(snapshot), "biz_key": biz_key, "biz_type": biz_type, "title": title, "initiator_id": initiator_id, "current_node": nodes[0]['node_key'], "status": 1 }) # 4. 解析首个节点的审批人并生成任务 assignees = resolve_assignees(nodes[0], initiator_id, form_data) for uid in assignees: db.insert("wf_task", { "instance_id": instance_id, "node_key": nodes[0]['node_key'], "node_name": nodes[0]['node_name'], "assignee_id": uid, "status": 0 }) # 5. 写操作日志 db.insert("wf_operation_log", { "instance_id": instance_id, "operator_id": initiator_id, "op_type": "START", "to_node": nodes[0]['node_key'] }) return instance_id逻辑说明:resolve_assignees是审批人解析函数,根据节点的approver_type决定审批人。如果是「部门主管」,需要查发起人所在部门的负责人;如果是「角色」,查角色下的用户;如果是「发起人自选」,从form_data里取。这个函数是流程引擎的核心扩展点,不同公司规则不同,但接口要统一。
参数说明:def_snapshot存的是发起时刻的节点列表,后续流转全部基于快照,不再查wf_node。这样即使管理员改了流程定义,在途单据也不受影响。代价是快照占空间,但一条 JSON 通常几 KB,可接受。
3.2 审批通过:节点推进与任务状态更新
审批通过是最常见的操作,逻辑是:校验任务归属、更新任务状态、判断当前节点是否全部处理完、推进到下一节点或结束实例。
def approve(task_id, operator_id, comment): with db.transaction(): # 1. 锁定任务行,防止并发重复审批 task = db.query_one( "SELECT * FROM wf_task WHERE id=%s FOR UPDATE", (task_id,)) if not task or task['status'] != 0: raise BizError("任务不存在或已处理") if task['assignee_id'] != operator_id: raise BizError("无权处理该任务") # 2. 更新任务为已办 db.update("wf_task", { "action": 1, "comment": comment, "status": 1, "handled_at": now() }, where={"id": task_id}) # 3. 判断同节点是否还有未处理任务(会签场景) pending = db.query_one( "SELECT COUNT(*) AS c FROM wf_task " "WHERE instance_id=%s AND node_key=%s AND status=0", (task['instance_id'], task['node_key'])) if pending['c'] > 0: return # 会签未完成,等待其他人 # 4. 取实例快照,找下一节点 instance = db.query_one( "SELECT * FROM wf_instance WHERE id=%s FOR UPDATE", (task['instance_id'],)) snapshot = json.loads(instance['def_snapshot']) next_node = find_next_node(snapshot, task['node_key']) # 5. 推进或结束 if next_node is None: db.update("wf_instance", { "status": 2, "current_node": None, "finished_at": now() }, where={"id": instance['id']}) else: assignees = resolve_assignees(next_node, instance['initiator_id'], None) for uid in assignees: db.insert("wf_task", { "instance_id": instance['id'], "node_key": next_node['node_key'], "node_name": next_node['node_name'], "assignee_id": uid, "status": 0 }) db.update("wf_instance", { "current_node": next_node['node_key'] }, where={"id": instance['id']}) db.insert("wf_operation_log", { "instance_id": instance['id'], "task_id": task_id, "operator_id": operator_id, "op_type": "APPROVE", "from_node": task['node_key'], "to_node": next_node['node_key'] if next_node else None })逻辑说明:FOR UPDATE是并发控制的关键。两个人同时点同意,第一个事务锁住任务行,第二个事务等待,等第一个提交后第二个读到status=1,直接报「已处理」,避免重复推进。第 3 步的会签判断,如果同节点还有待办,就不推进,等所有人处理完。
参数说明:find_next_node根据快照里的next_node_key找下一节点,如果节点是条件类型,需要根据表单数据判断走哪个分支。条件分支的表达式可以存在wf_node的扩展字段里,这里简化处理。
3.3 驳回、撤回与转办:三种特殊流转的落库差异
驳回和同意不同,驳回要决定回到哪个节点。常见做法是驳回到发起人,或者驳回到上一个审批节点。我一般让发起人配置,默认驳回到发起人。
def reject(task_id, operator_id, comment, target_node=None): with db.transaction(): task = db.query_one( "SELECT * FROM wf_task WHERE id=%s FOR UPDATE", (task_id,)) if not task or task['status'] != 0: raise BizError("任务不存在或已处理") db.update("wf_task", { "action": 2, "comment": comment, "status": 1, "handled_at": now() }, where={"id": task_id}) instance = db.query_one( "SELECT * FROM wf_instance WHERE id=%s FOR UPDATE", (task['instance_id'],)) # 驳回目标:默认发起人节点,也可指定 back_node = target_node or "start" db.update("wf_instance", { "status": 3, "current_node": back_node }, where={"id": instance['id']}) # 取消同实例其他待办任务 db.update("wf_task", {"status": 2}, where={"instance_id": instance['id'], "status": 0}) db.insert("wf_operation_log", { "instance_id": instance['id'], "task_id": task_id, "operator_id": operator_id, "op_type": "REJECT", "from_node": task['node_key'], "to_node": back_node })撤回是发起人的特权,只能撤回还在审批中、且没有其他人处理过的单据。转办是把当前任务换个人,任务本身不结束,只换assignee_id。这三种操作的共同点是都要写操作日志,且都要在事务里锁实例行,防止和审批操作交叉。
4. 审批数据库的避坑与排查:五个真实踩坑记录
4.1 待办列表越查越慢,索引没建对
现象:上线三个月后,用户待办列表加载超过 3 秒,DBA 发现wf_task全表扫描。
原因:待办查询条件是assignee_id = ? AND status = 0,但只建了assignee_id单列索引,MySQL 优化器在数据量大时可能不走索引,或者走索引后回表过滤status效率低。
解决:建(assignee_id, status)联合索引,且顺序不能反。status区分度低,放后面。如果还有按时间排序,可以再加created_at做覆盖索引。建完后用EXPLAIN确认type=ref、key=idx_assignee_status。
4.2 流程定义改了,在途单据节点错乱
现象:管理员把请假流程从两级改成三级,结果所有在途单据的当前节点显示成新流程的节点名,历史审批记录对不上。
原因:实例表只存了definition_id,流转时实时查wf_node,定义一改,在途单据跟着变。
解决:实例表加def_snapshot字段,发起时把节点列表序列化存进去,后续流转只读快照。已经上线的系统可以写迁移脚本,给在途实例补快照,补的时候用当前定义,虽然不完美,但能止血。
4.3 并发审批导致重复推进
现象:同一个节点有两个审批人,两人同时点同意,结果流程直接跳过了下一个节点,或者下一节点生成了两份任务。
原因:审批逻辑没有加行锁,两个事务同时读到任务status=0,都执行了推进。
解决:查询任务时加FOR UPDATE,并在更新任务状态时加WHERE status=0条件,用影响行数判断是否更新成功。如果affected_rows=0,说明已被处理,直接返回。这是乐观锁和悲观锁的结合用法。
4.4 转办后原审批人还能操作
现象:A 把任务转给 B,B 还没处理,A 的待办列表里还能看到这条任务并点同意。
原因:转办只更新了assignee_id,但前端缓存了旧数据,或者查询待办时没有实时过滤。
解决:转办时除了更新assignee_id,还要写操作日志,并且前端待办列表每次进入都重新拉取。如果用了缓存,转办后要主动失效相关用户的缓存。数据库层面,待办查询始终以wf_task当前assignee_id为准,不要冗余到其他表。
4.5 驳回后重新提交,历史任务状态混乱
现象:单据被驳回后发起人修改重新提交,原来的审批任务还显示「待办」,新任务又生成了,用户看到两条待办。
原因:驳回时只更新了实例状态,没有取消同实例的其他待办任务。
解决:驳回逻辑里加一步,把该实例下所有status=0的任务批量更新为status=2(已取消)。重新提交时生成全新任务,历史任务保留但状态为已取消,查询待办时只查status=0,就不会重复。
5. 进阶技巧:用状态机校验和归档策略让审批库长期可维护
前面讲的都是单点设计,这一章说两个让审批库能扛住长期运行的习惯。
第一个习惯是给实例状态加状态机校验。wf_instance.status有审批中、通过、驳回、撤回、作废五种,不是任意状态都能互相跳转。比如「通过」不能直接变「审批中」,「作废」不能变「通过」。我一般会在代码里维护一张状态转移表,每次更新前校验,不合法直接抛异常。这样能挡住很多因为代码分支写错导致的状态污染。
TRANSITIONS = { 1: [2, 3, 4, 5], # 审批中 -> 通过/驳回/撤回/作废 2: [], # 通过 -> 终态 3: [1, 5], # 驳回 -> 重新提交(审批中)/作废 4: [1, 5], # 撤回 -> 重新提交/作废 5: [] # 作废 -> 终态 } def update_instance_status(instance_id, new_status): inst = db.query_one("SELECT status FROM wf_instance WHERE id=%s", (instance_id,)) if new_status not in TRANSITIONS.get(inst['status'], []): raise BizError(f"非法状态转移: {inst['status']} -> {new_status}") db.update("wf_instance", {"status": new_status}, where={"id": instance_id})第二个习惯是归档策略。审批任务表是增长最快的表,一年可能几百万行。如果一直不归档,待办查询会越来越慢。我的做法是按finished_at分区,或者定期把已完成超过一年的实例和任务迁移到历史库。迁移时保留wf_instance主表记录,只把wf_task和wf_operation_log的明细搬走,查询历史时走历史库。这样主表始终保持在可控规模,待办查询不受影响。
还有一个容易被忽略的点:审批意见字段comment用VARCHAR(500),但实际业务里有人粘贴大段文字,超长会报错。我一般改成TEXT,或者在前端限制字数并在后端截断。这个坑不常遇到,但遇到一次就要改表,不如一开始就留够。
最后说个我自己的教训:早期做 OA 时,我觉得流程定义不会经常改,实例表没存快照,结果业务部门一个月改了四次审批流,在途单据全乱,加班写数据修复脚本。从那以后,凡是流程引擎相关的表,我都坚持「运行态存快照、定义态可版本化、操作全留痕」这三条。数据库设计没有银弹,但把这三条做到位,能省掉后面 80% 的救火时间。希望帮到你。
本文还有配套的精品资源,点击获取