尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

后台任务管理流程设计:状态机、数据模型与异常处理

后台任务管理流程设计:状态机、数据模型与异常处理 一次需求评审会上业务方提出要在后台新增任务管理流程。我的第一反应不是问页面怎么设计也不是问任务表怎么建而是先问了一句任务执行中突然失败、应用重启、同一份数据被重复提交系统要怎么收场现场安静了几秒。这个提问背后是一个很基础的判断新增任务管理流程表面看是多了一张任务表和几个按钮实际上是在把一个异步过程从“靠人盯、靠临时改数据库、靠猜测”变成“有状态、可追踪、能恢复”。真正的难点从来不在于把一条任务跑通而是在所有异常路径里系统还能给出确定性的结果。下面我以常见的后台批量导入任务为例讲清楚从状态机、数据模型、调度执行到异常排查的完整过程。如果你的需求是批量导出、报表生成、定时对账或消息推送这套思路同样适用。1. 先别急着建任务表把任务生命周期当成状态机来画1.1 为什么任务管理流程不能按普通 CRUD 来做普通 CRUD 是同步的。用户提交一条数据接口处理完直接返回成功或失败。任务管理不是这样用户点完提交后台可能要处理几秒甚至几十分钟。用户离开页面时任务可能还在跑。此时如果任务没有状态产品和研发就无法回答三个问题它到哪一步了成功没有如果失败了下一步怎么办这三个问题回答不了页面做得再漂亮也没有用。所以新增任务管理流程的第一步不是设计列表页而是设计状态集合和流转规则。状态不是用来“显示给用户看的”而是用来让系统自己对当前处境做出判断的。1.2 一个最小任务状态集合六种状态通常够用我见过不少团队一开始只设计了三个状态进行中、成功、失败。运行一段时间后需求就会慢慢逼出第四个、第五个状态比如重试中、等待人工复核、已过期。既然任务管理迟早会遇到这些情况不如一开始就把最小状态集合定清楚。状态含义通常由谁触发可去向PENDING待执行用户提交、定时器触发EXECUTING / CANCELLEDEXECUTING执行中worker 拾取任务并锁定SUCCESS / FAILED / CANCELLED / RETRY_PENDINGSUCCESS成功业务处理完成终态FAILED失败业务校验未过、系统异常RETRY_PENDING / 终态RETRY_PENDING待重试失败后按重试策略生成EXECUTING / CANCELLEDCANCELLED已取消用户取消、超时取消终态这六个状态基本上覆盖了任务管理的正常路径和异常路径。你还可以根据业务增加“WAITING_DEPENDENCY”“REVIEW_PENDING”等状态但不要一开始就把状态复杂化。所有状态都要有一个共同原则每个状态必须有明确的进入条件和离开条件。加一个状态就意味着要多处理一条流转路径这是在给系统和后人加负担。1.3 状态机真正要防的不是状态缺失而是状态乱跳很多新手在设计时只关心任务正常跑通于是写了三个状态进行中、成功、失败。这套简化在后端任务里会很危险。举例一条任务失败后重试逻辑生成了一个新的执行实例但旧实例状态还停留在 FAILED新实例还没跑。这时候如果用户不停地点重试按钮可能同时创建多个执行实例最后写入结果互相覆盖。所以状态变更不能只靠 if 判断要在数据库层面做条件更新。比如从 PENDING 进入 EXECUTING标准写法是这样UPDATE task_instance SET status EXECUTING, start_time NOW(), version version 1 WHERE id ? AND status PENDING;只有当 UPDATE 影响的行数为 1说明当前节点拿到执行权影响行数为 0说明状态已经被别的节点改成了 EXECUTING当前节点应该放弃。通过状态条件和版本号保证任务不会重复执行。这就是状态机落到代码里的最小形式。一个提醒状态机的价值不是画一张漂亮的流程图而是让每一次状态变更都带上边界条件。任何一次 UPDATE 都要能回答它凭什么可以从这个状态变成那个状态。2. 数据模型设计别只做一张任务表2.1 把“任务”和“一次执行”拆开我见过一个小型项目新增任务管理流程时就建了一张 task 表所有字段都堆在里面。第一版跑得很顺直到出现重试需求。重试意味着同一条任务要执行两次但成功后结果应该在哪一条记录里体现如果还用同一行第二次执行就会覆盖第一次的启动时间、错误信息、处理结果排查时什么都看不出来。更合理的做法是把任务主体和任务实例分开task 记录业务意图task_instance 记录每次执行。一个任务可以有多次执行实例。这样每次重试都会留下独立记录成功、失败、耗时、结果都分开。如果只是极低频的内部脚本可以不拆但一旦你说要“管理任务”就应该把二者分层。2.2 三个最小表任务表、实例表、日志表下面是简化后的表结构示例字段命名、大小写和类型按你的数据库习惯调整即可。CREATE TABLE task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_code VARCHAR(64) NOT NULL COMMENT 任务编码用于业务追踪, task_type VARCHAR(32) NOT NULL COMMENT 任务类型IMPORT/EXPORT/REPORT, biz_id VARCHAR(64) COMMENT 业务主键, title VARCHAR(255) COMMENT 任务名称, status VARCHAR(32) NOT NULL COMMENT 当前状态, priority INT DEFAULT 5 COMMENT 优先级越小越优先, owner VARCHAR(64) COMMENT 创建人, input_params TEXT COMMENT 输入参数JSON 格式, timeout_seconds INT DEFAULT 300 COMMENT 超时阈值, retry_policy VARCHAR(64) COMMENT 重试策略名称, version INT DEFAULT 0 COMMENT 乐观锁版本, created_at DATETIME, updated_at DATETIME ); CREATE TABLE task_instance ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id BIGINT NOT NULL, instance_no INT NOT NULL COMMENT 第几次执行, status VARCHAR(32) NOT NULL, retry_count INT DEFAULT 0, last_error TEXT, start_time DATETIME, end_time DATETIME, exec_result TEXT, created_at DATETIME ); CREATE TABLE task_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id BIGINT NOT NULL, instance_id BIGINT, level VARCHAR(16), message TEXT, created_at DATETIME );三个表各管一件事task 管业务意图task_instance 管每一次执行task_log 管过程痕迹。input_params 里如果包含手机号、文件路径、请求报文这类信息落库前要脱敏也不要存完整敏感内容。2.3 接口能力创建、查询、取消、重试、结果下载当后台要新增任务管理流程时接口设计通常要覆盖这几个能力创建任务接收任务类型、业务 ID、输入参数生成 task 和初始 instance。查询列表按状态、任务类型、创建人、时间范围分页筛选。查看详情展示任务基本信息、当前状态、执行实例列表、最近几条日志。取消任务只有处于 PENDING 或 RETRY_PENDING 状态时才可以直接取消处于 EXECUTING 时取消只是标记需要等处理中的实例感知。重试任务从失败的任务实例生成新的执行实例不允许直接改原实例状态为 PENDING。结果下载返回结果文件路径或临时下载链接。这些接口看起来简单真正要小心的是取消和重试。取消不能只把状态改成 CANCELLED因为执行中的 worker 可能还在跑业务需要配合中断信号或阶段检查点。重试不能简单把状态改回 PENDING而应新建实例保留失败现场。3. 手工做一个最小可用流程以批量导入任务为例3.1 为什么选批量导入作为示例批量导入是任务管理最典型的使用场景。运营上传一个 Excel 文件里面可能有一万条数据系统要解析、校验、写入数据库还要给用户返回哪些行导入失败。如果同步处理请求很容易超时页面要一直转圈。整合成任务后用户上传完成就得到一个任务 ID可以离开页面等任务变成 SUCCESS 后再下载错误报告。这个场景覆盖了任务创建、状态流转、失败重试、结果下载也是大多数新增任务管理流程的样板。3.2 创建任务入口先把状态写成 PENDING创建任务时不要把业务处理放在请求里先只做两件事插入 task 主表插入第一条 task_instance状态都是 PENDING。public Long createImportTask(String operator, String fileUrl, MapString, Object input) { Task task new Task(); task.setTaskType(IMPORT); task.setTitle(operator 的批量导入任务); task.setStatus(PENDING); task.setOwner(operator); task.setInputParams(JSON.toJSONString(input)); task.setTimeoutSeconds(600); task.setRetryPolicy(retry_3_5); taskDao.insert(task); TaskInstance instance new TaskInstance(); instance.setTaskId(task.getId()); instance.setInstanceNo(1); instance.setStatus(PENDING); instanceDao.insert(instance); return task.getId(); }这里只是常见写法实际项目里请替换成你自己的 DAO 和 JSON 工具。为什么创建任务就要立即生成 instance因为后续调度只需要扫 instance不需要再 join task 判断。任务主体和实例状态解耦重试时才不会覆盖主任务。3.3 调度执行CAS 锁定是核心最简单的调度方式就是用一个后台轮询任务不断查询 PENDING 状态的实例然后逐个尝试锁定。SELECT t.*, ti.id AS instance_id FROM task_instance ti JOIN task t ON t.id ti.task_id WHERE ti.status PENDING ORDER BY t.priority ASC, ti.created_at ASC LIMIT 20;查询出来后不能直接执行业务要先抢锁。抢锁的方式就是前面那两条 UPDATE 语句。影响行数是 1说明这个实例归你处理影响行数是 0说明被别的 worker 抢走了直接跳过。业务执行完再把实例更新为 SUCCESS 或 FAILED。需要强调的是“先查再改”存在并发缝隙如果查完之后不立刻通过条件更新抢占两个 worker 可能同时处理同一条任务。CAS 这一步不能省。一个容易踩的坑测试环境里手动把任务状态改回 PENDING 来触发重试。如果生产业务处理已经把部分数据写进去了这种操作会让任务变得不可恢复。重试一定是新建实例不是把旧实例状态改回去。3.4 执行结果和错误报告落库批量导入任务的结果通常有三种全部成功、全部失败、部分成功。对于部分成功结果要能清楚地告诉用户哪些行失败、失败原因是什么。常见做法是把执行结果摘要写进 task_instance.exec_result把详细错误报告保存为文件数据库里只存文件路径。{ status: PARTIAL_SUCCESS, totalCount: 10000, successCount: 9800, failedCount: 200, reportFile: /data/import_report/2025/xx.xlsx }生产环境建议把 reportFile 指向对象存储或文件服务不要把文件内容直接存进数据库字段。文件路径要做好过期策略避免后台变成一个无人清理的临时文件仓库。3.5 前端与接口协作列表页优先实时推送可选大多数后台并不需要 WebSocket 实时推送。前端打开任务列表每 5 到 10 秒轮询一次即可。状态从 PENDING 变成 SUCCESS 再刷新一次详情就足够让运营接受。重点是把操作按钮按状态禁用PENDING 可以取消FAILED 可以重试EXECUTING 不能直接重试。页面上的按钮集合其实就是状态机的前端投影。4. 跑通以后四道关卡决定这个流程能不能长期用4.1 并发与幂等同一个任务不能被执行两次单节点跑通不代表多节点没问题。只要你有两个 worker 同时扫描 PENDING 任务就可能同时读到同一条记录。CAS 更新能挡住一部分但业务处理本身也要幂等。以批量导入为例同一个任务实例重复执行不能给数据库插入两遍数据。常见的做法有三种处理前先校验本任务是否已经处理过。用 biz_id instance_no 做业务唯一键重复插入直接失败。把导入解析结果先写到临时表再一次事务写入。幂等不是一个数据库唯一索引就能完全解决的还是要看你的业务逻辑是否天然可重入。比如对账任务重复执行一次可能没有影响但导入任务重复执行一次可能就会产生重复数据。4.2 失败重试不是所有失败都适合自动重试一个最常见的危险想法是任务失败了就自动重试重试到成功为止。实际上所有失败都重试会让系统在故障期间疯狂打请求把自己和依赖服务一起拖垮。需要先区分失败类型失败类型例子是否自动重试可重试的系统异常数据库连接闪断、目标服务超时、网络抖动建议自动重试不可重试的业务异常参数校验失败、Excel 格式错误、权限不足不自动重试进入 FAILED 或人工复核资源受限线程池满、磁盘写满、锁等待可以延迟重试但不要无限重试重试策略要包含最大次数、退避时间、最大并行实例。例如第一次失败后等 5 秒第二次等 30 秒第三次等 5 分钟超过三次进入 FAILED由用户决定是否手动重试。不要设置 100 次自动重试也不要让同一条任务在系统抖动时瞬时打出大量重试请求。4.3 超时与卡死状态停在 EXECUTING 怎么办任务管理流程上线后最常见的异常不是任务失败而是任务卡在 EXECUTING。原因通常是 worker 进程被 kill、线程池异常、数据库连接池耗尽。这时候任务没有失败也没有成功就是停在那里。如果只靠人工去数据库修正流程就失去意义了。你需要一个超时看门狗定期扫描执行中的实例如果开始时间和当前时间相差超过 timeout_seconds就把实例标记为 FAILED 或 RETRY_PENDING并记录 last_error 为 timeout。// 定时任务每 60 秒扫描一次超时的执行中实例 public void detectTimeoutInstances() { ListTaskInstance instances instanceDao.findTimeout( EXECUTING, timeoutSeconds); for (TaskInstance instance : instances) { instanceDao.markTimeout(instance.getId(), FAILED, timeout exceeded); } }这个定时任务建议独立于业务 worker 运行避免和业务进程一起挂掉。超
返回列表