☰
简易异步任务中心与批量导入:任务表、幂等、分批与进度回写
2026/10/27 23:29:22 网站建设 项目流程

1. 为什么我要自己搭一个异步任务中心

后台管理系统里最容易被低估、也最容易在深夜把人叫起来的功能,就是批量导入。表面上看,它只是"上传一个 Excel,然后把数据写进数据库",产品经理在需求评审会上五分钟就能讲完,可真到了实现阶段,问题一个接一个冒出来:文件有几万行、解析要几十秒、写库要几分钟、中间某一行格式不对、用户刷新了页面、请求超时返回 504、重复点击提交导致数据写了两遍。

我最早的做法是同步处理——用户点上传,后端一次性解析、校验、写库,接口返回结果。数据量小的时候一切正常,等到导入量上来,前端转圈转到天荒地老,网关超时先一步把连接掐了,用户以为失败又点了一次,于是同一批数据进了两张表。那段时间我几乎每周都要去数据库里手工清理重复数据,痛苦程度不用多说。

后来我把这类"耗时长、用户不需要立刻拿到结果、失败可重试"的操作统一抽出来,做成一个简易异步任务中心:提交任务只负责落库和返回任务 ID,真正的处理交给后台任务执行,前端拿着任务 ID 轮询进度。批量导入的技术处理方案也顺势被拆成了"上传校验—任务拆分—分批处理—进度回写—结果下载"这条流水线。这篇就围绕这套东西展开,把我踩过的坑、参数怎么定、异常怎么兜底,尽量讲透。

适合谁看:正在做后台系统、需要处理批量数据导入导出的后端同学;被超时和重复提交折腾过的前端同学;以及想把"异步任务"这套通用能力沉淀成团队基础设施的开发者。不需要多深的分布式经验,一台机器、一个数据库、一个进程内任务队列就能跑起来,后面再谈怎么平滑演进。

2. 整体设计与方案选型,先把骨架想清楚

2.1 从同步到异步,核心变化到底在哪

同步接口的本质是"调用方等待结果",它把处理耗时直接暴露在 HTTP 连接上。只要处理时间超过网关或浏览器能忍耐的阈值,链路就会断,而断的那一刻任务其实还在跑,于是产生状态不一致。异步化要解决的核心不是"性能",而是把处理周期从请求周期里剥离出来,让请求快速返回、处理独立推进、状态可被查询。这个认知很关键,因为很多人一上来就想着上消息队列、上分布式调度,其实第一步只是把"等待"去掉。

我采用的模型很简单:任务表 + 执行器。任务表记录任务 ID、类型、参数、状态、进度、结果文件地址、错误信息、创建时间;执行器从任务表里捞待处理任务,按类型分发给对应的处理器。请求侧只做三件事——校验参数、插入任务记录、返回任务 ID。整个过程与业务处理彻底解耦,接口响应时间从几十秒降到几十毫秒。用户可以关掉页面去做别的事,回来再查进度,体验上反而比干等更自然。

这里有个容易忽略的点:任务状态机必须收敛。我最初的状态有七八个,什么"排队中、解析中、校验中、写库中、合并中",结果前端要写一堆判断,后端也经常出现状态卡死。后来压缩成四个:待执行、执行中、成功、失败,中间过程全部用进度百分比和阶段描述字段表达。状态越少,出 bug 的概率越低,这个教训值不少钱。

2.2 任务队列选型:为什么先不上重型中间件

很多方案文章一开篇就上消息队列,我的建议是分阶段。项目初期数据量不大、部署单机、团队对中间件运维不熟,硬上队列只会增加故障点。我选的是数据库任务表 + 进程内定时拉取,原因有这么几条。

第一,任务本身就是业务数据,需要被查询、被展示、被人工干预。放数据库里天然支持列表页、详情页、重试按钮、按用户筛选,这些需求用队列消息反而要额外做一套存储。第二,数据库的持久化是现成的,服务重启后待执行任务还在,不会丢。第三,事务好处理,任务创建和业务数据写入可以放在同一个事务里,避免"任务建了但业务数据没写上"的尴尬。

当然它也有明显短板:轮询有延迟、数据库压力随任务量上升、并发控制比较粗糙。所以我在设计时留好了演进空间——执行器与任务存储之间定义一层接口,将来量上来了,把"数据库轮询"换成"消息队列推送"只需要替换实现类,业务处理器完全不动。实测在单机、任务量日均几千的场景下,数据库方案完全够用,延迟控制在秒级。

提示:如果团队已经在用消息队列,直接用它没问题;但没有的话,别为了"架构好看"引入一个新中间件,运维成本会以你意想不到的方式找上门。

2.3 批量导入为什么必须拆成多阶段

批量导入最忌讳"一口气干完"。我把它拆成五个阶段:文件上传与格式预检、行级解析与字段校验、数据分批入库、关联数据补全、结果汇总与文件生成。每个阶段都可以独立失败、独立重试、独立记录进度,前端也能显示"正在校验第 12000 行"这种具体信息,比一个转圈图标强太多。

拆分还有一个隐性收益:错误定位成本大幅下降。全流程一次性执行时,报错只告诉你"导入失败",你得翻日志找是哪一行。分阶段之后,解析阶段失败就是格式问题、校验阶段失败就是数据合法性问题、入库阶段失败就是唯一键或外键冲突,排查路径立刻清晰。我在实际项目里,光这一条就让导入问题的平均处理时间从半天缩到十几分钟。

另外要考虑幂等。用户可能重复上传同一个文件,网络抖动也可能导致重试。我的做法是给每次导入生成一个基于文件内容哈希 + 用户标识的幂等键,提交时先查有没有同键的进行中任务,有就直接返回原任务 ID。这样即使前端按钮被连点,也只会产生一个任务。

2.4 数据流与职责边界的一次梳理

把整条链路画清楚很重要,我用文字描述一遍:客户端把文件上传到文件服务(或本地磁盘目录),拿到文件标识;然后调用创建任务接口,参数里带文件标识、导入类型、业务参数;服务端校验后写入任务表并返回任务 ID;执行器轮询到任务,按类型路由到导入处理器;处理器下载文件、流式解析、分批写库,过程中不断更新任务进度;结束后生成结果文件(成功条数、失败明细)写入任务记录;客户端轮询任务详情,成功后展示汇总并提供结果下载。

职责边界上,我坚持三条:接口层不碰业务逻辑,只做参数和权限;处理器不碰 HTTP,它只依赖任务上下文和业务服务;进度回写走独立通道,避免频繁更新拖慢主流程。尤其是第三条,我见过有人在每写一行数据就更新一次任务表,一万行数据就是一万次 update,数据库直接被拖垮。正确做法是每批更新一次,或者按时间节流,比如至少间隔一秒。

3. 核心细节拆解:文件解析、分批策略与进度回写

3.1 文件解析的两条路线:全量加载还是流式读取

解析 Excel 或 CSV 时,第一个决策是"把整个文件读进内存"还是"边读边处理"。小文件(几千行以内)全量加载最省事,代码简单,还能做跨行校验;但超过一两万行,内存占用和 GC 压力就很明显,尤其在多任务并发时容易把服务压垮。我的选择是统一走流式,无论文件大小,行为一致,避免"小文件能跑、大文件崩"这种偶发问题。

流式解析的关键是逐行产出、逐批落库,中间不保留全部数据。以 CSV 为例,按行读取、跳过表头、每满一个批次就交给写库逻辑,然后清空缓冲。Excel 相对麻烦一些,不同库对内存的控制差异较大,我一般优先建议业务方用 CSV 或 xlsx 的小数据量模式,并在上传时做文件大小和行数上限的硬性限制。这个限制不是偷懒,而是保护——一个 50MB 的 Excel 能轻易把单机服务打挂。

解析过程中还有几件必须做的事:字符编码识别(CSV 的 BOM 头、GBK 与 UTF-8 混用是经典坑)、空行与合并单元格处理、列数不匹配的容错。我给每个文件解析都加了"前 N 行预检",先抽 20 行做字段数量、类型、表头的核对,不通过直接失败并返回具体行号和原因,避免用户等了三分钟才被告知表头写错了。

// 伪代码:流式解析 + 分批提交 try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, charset))) { String line; long lineNo = 0; List<RowData> buffer = new ArrayList<>(BATCH_SIZE); while ((line = reader.readLine()) != null) { lineNo++; if (lineNo == 1) { validateHeader(line); continue; } RowData row = parseLine(line, lineNo); // 解析失败记录到错误列表,不中断 if (row != null) buffer.add(row); if (buffer.size() >= BATCH_SIZE) { flushBatch(buffer, taskContext); // 批量写库 + 更新进度 buffer.clear(); } } if (!buffer.isEmpty()) flushBatch(buffer, taskContext); }

上面这段代码里有个细节值得说:单行解析失败不抛异常中断,而是记录到错误列表继续往下读。批量导入的用户预期是"尽可能多地导入成功",不是"一行错全盘皆输"。只有文件级的问题(比如表头不对、编码无法识别)才整体失败。这个取舍我和产品来回拉扯过,最终按"容错优先"定下来,用户的反馈也确实好很多。

3.2 批次大小的确定:不是拍脑袋,要算

批次大小直接影响三个指标:单次写库耗时、事务大小、失败重试的粒度。太小则数据库往返次数多,整体慢;太大则单事务时间长、锁持有久、内存占用高,一次失败要重试很多数据。我的经验区间是500 到 2000 行,具体值通过一次简单测算确定。

测算方法是:固定数据量(比如 2 万行),分别用 200、500、1000、2000、5000 的批次跑一遍,记录总耗时和峰值内存。通常会在某个点之后耗时不再明显下降,而内存继续上升,那个拐点就是合适值。在我最近的一个中等字段宽度(约 20 列)的项目里,1000 行/批的总耗时比 500 行/批快约 18%,内存只多一点点,2000 行以上收益就很小了,于是定为 1000。

还有一个必须一起定的参数是批次间是否需要休眠。如果导入任务和其他在线业务共用数据库,我建议每批之间 sleep 几十毫秒,主动让出资源,避免大批量导入把在线查询拖慢。这个"礼貌性休眠"看起来降低了导入速度,但它保护的是整体可用性,属于典型的用局部换全局。

批次大小2万行总耗时峰值内存适用场景
200偏慢,往返次数多低字段多、单行处理重
500中等低与在线业务混跑
1000较快中通用推荐值
2000与1000接近较高独占窗口期跑批
5000提升有限高不推荐,失败重试代价大

3.3 事务边界:每一批一个事务,还是整个任务一个事务

这是我在评审时被问得最多的问题。答案很明确:按批提交,不要让整个导入共用一个事务。原因有两点。其一,大事务会长时间持有锁和 undo 日志,数据库压力陡增,还可能触发事务超时;其二,用户要的是"部分成功",全有或全无反而不符合预期。

按批提交带来的问题是"部分成功"的语义怎么表达。我的处理是:每批成功就累加成功计数,失败的批次把失败行记录下来,任务最终状态取决于失败比例——全部成功则成功,有失败但不影响主体则标记为"部分成功",超过阈值(比如失败率大于 30%)则整体标记失败。这样用户既能看到导入了多少,也能下载失败明细去修正后重新导入。

注意:失败明细文件的生成不要等到最后一次性在内存里拼,行数多的时候同样会 OOM。我一般把失败行实时追加写入临时文件,任务结束时再把临时文件转成正式结果文件。

3.4 进度回写:别让更新任务状态成为新瓶颈

进度回写看似简单,实则最容易出问题。我最初在每批结束后更新一次任务表,批次 1000 行、2 万行数据就是 20 次更新,完全没问题;但有些处理器是按行更新的,那就是 2 万次 update,直接把数据库写满。所以定下规则:进度更新必须按批或按时间节流,绝不按行。

具体实现上,我在任务上下文里维护一个"最后更新时间戳",每次想更新进度时先比较当前时间与上次更新时间的差值,小于阈值(比如 800 毫秒)就只更新内存中的进度值,不写库;超过阈值才落库。这样无论处理多快,数据库压力都被限制在每秒一两次更新,前端轮询看到的进度也足够平滑。

另外,进度百分比的算法要选对。用"已处理行数 / 总行数"最直观,但总行数需要在解析时先统计一遍,对超大文件意味着多读一次。我的做法是:文件可 seek 时按字节偏移估算,不可 seek 时先快速扫一遍行数(只数换行符,不做解析,成本很低)。别小看这个,前端进度条从 0 缓慢爬到 100 和从 0 跳到 100,用户的心理感受差别巨大。

3.5 结果文件与错误明细的存储设计

结果文件不能只放内存,也不能随便丢在临时目录里被系统清理。我的方案是落到一个专门的结果目录,文件名用任务 ID 命名,任务表里记录相对路径和过期时间。清理策略上,起一个每日的清理任务,删除超过 7 天的结果文件,避免磁盘无限增长。

错误明细的格式我建议直接用 CSV,带上原始行号、错误字段、错误原因。用户拿到之后可以在 Excel 里直接筛选修改,比 JSON 友好得多。这里有个小技巧:错误原因要写人话。"字段校验失败"没人看得懂,"手机号格式不正确,应为 11 位数字"才是用户想要的。我在项目里专门维护了一份错误码到用户可读文案的映射表,维护成本很低,收益很高。

4. 完整实操:从上传到结果下载的落地过程

4.1 第一步:任务表设计与建表语句

先给出一张精简但够用的任务表。字段不用多,关键是覆盖状态、进度、参数、结果、错误、时间这几个维度。我用的是 MySQL 语法,其他数据库照搬思路即可。

CREATE TABLE async_task ( id BIGINT NOT NULL AUTO_INCREMENT, task_no VARCHAR(40) NOT NULL COMMENT '对外任务号', task_type VARCHAR(40) NOT NULL COMMENT '任务类型,如 IMPORT_USER', biz_key VARCHAR(128) DEFAULT NULL COMMENT '幂等键', params TEXT COMMENT '任务参数JSON', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待执行 1执行中 2成功 3失败 4部分成功', progress INT NOT NULL DEFAULT 0 COMMENT '0-100', stage_desc VARCHAR(100) DEFAULT NULL COMMENT '当前阶段描述', total_count INT DEFAULT 0, success_count INT DEFAULT 0, fail_count INT DEFAULT 0, result_path VARCHAR(255) DEFAULT NULL, error_msg VARCHAR(1000) DEFAULT NULL, creator VARCHAR(64) NOT NULL, start_time DATETIME DEFAULT NULL, end_time DATETIME DEFAULT NULL, heartbeat DATETIME DEFAULT 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_task_no (task_no), KEY idx_status_created (status, created_at), KEY idx_biz_key (biz_key) ) COMMENT='异步任务表';

几个字段值得展开说。biz_key上加了唯一索引,配合"先查后插 + 唯一键兜底"实现幂等:并发提交时第二个插入会因唯一键冲突失败,捕获异常后查出已有任务返回,逻辑就闭环了。heartbeat是给僵死任务兜底用的——执行器每处理一批更新一次心跳,扫描线程发现某任务"执行中"但心跳超过 5 分钟没动,就判定为异常中断,把状态改回待执行或直接标记失败,避免任务永远卡在执行中。

idx_status_created这个联合索引是给轮询查询用的。执行器拉任务的 SQL 是WHERE status = 0 ORDER BY created_at LIMIT n,没有这个索引会全表扫描,任务一多就慢。

4.2 第二步:创建任务的接口逻辑

创建任务接口要做的事很克制:参数校验、幂等判断、落库、返回任务号。不要在这里做任何耗时操作,包括读文件内容。文件内容的解析全部交给执行器。

public String createImportTask(ImportRequest req, String userId) { // 1. 基础校验:文件标识、导入类型、文件大小上限 Assert.notNull(req.getFileId(), "文件不能为空"); Assert.isTrue(req.getFileSize() <= MAX_FILE_SIZE, "文件超过 50MB 限制"); // 2. 幂等键:文件哈希 + 用户 + 类型 String bizKey = DigestUtils.md5Hex(req.getFileHash() + userId + req.getType()); AsyncTask exist = taskMapper.selectByBizKey(bizKey); if (exist != null && isRunning(exist)) { return exist.getTaskNo(); // 直接返回进行中的任务,不重复创建 } // 3. 落库 AsyncTask task = new AsyncTask(); task.setTaskNo(generateTaskNo()); task.setTaskType(req.getType()); task.setBizKey(bizKey); task.setParams(JSON.toJSONString(req)); task.setStatus(TaskStatus.PENDING.getCode()); task.setCreator(userId); try { taskMapper.insert(task); } catch (DuplicateKeyException e) { return taskMapper.selectByBizKey(bizKey).getTaskNo(); // 并发兜底 } return task.getTaskNo(); }

这段代码里唯一的"技巧"就是幂等的双层实现:先查一次是性能优化(大部分重复请求能快速返回),唯一键冲突是正确性保证(并发下也不出错)。如果只做前者,高并发下仍然会插两条。

4.3 第三步:执行器的拉取与派发机制

执行器是一个独立线程池,负责拉任务、派发、更新状态。核心逻辑是"抢任务—执行—回写",抢的过程用乐观锁或UPDATE ... WHERE status = 0的返回行数来判断,避免多实例重复执行。

@Scheduled(fixedDelay = 2000) public void poll() { if (runningCount.get() >= MAX_CONCURRENT) return; // 本地并发控制 List<AsyncTask> tasks = taskMapper.lockPendingTasks(POLL_SIZE); // status=0 limit n for (AsyncTask task : tasks) { int affected = taskMapper.markRunning(task.getId()); // 乐观抢锁 if (affected == 0) continue; // 被别的实例抢走 runningCount.incrementAndGet(); executor.submit(() -> { try { TaskHandler handler = handlerRegistry.get(task.getTaskType()); handler.handle(TaskContext.of(task)); taskMapper.markSuccess(task.getId()); } catch (Exception e) { taskMapper.markFail(task.getId(), truncate(e.getMessage(), 900)); } finally { runningCount.decrementAndGet(); } }); } }

几个参数我在这里固定一下,方便直接抄:轮询间隔 2 秒(兼顾延迟与数据库压力)、单次拉取 10 条(防止一次捞太多占内存)、最大并发任务数按机器核数配置,一般是核数的 1 到 2 倍,我常用核数 × 2,比如 4 核就跑 8 个并发任务。注意并发任务是"导入这种重 IO 任务"的数量,每个任务内部还有批处理,两者叠加后的实际压力要压测验证,别凭感觉。

处理器注册表是个很值得加的设计。所有导入类型实现同一个接口,启动时按类型注册到 Map 里,执行器根据任务类型路由。新增一种导入只需要加一个实现类,执行器和任务表都不用动,扩展性很好。

4.4 第四步:导入处理器的完整实现要点

处理器要做的事按顺序是:下载文件、预检、统计总行数、逐批解析入库、写错误明细、生成结果文件、更新汇总。每一步都及时更新阶段描述和进度,让用户看得见进展。

handle(task): 1. 更新状态 -> 执行中,阶段"准备中",进度 0 2. 从文件服务下载到本地临时目录(带超时与重试) 3. 预检前 20 行:编码、表头、列数 失败 -> 标记任务失败,错误信息写人话描述 4. 快速扫描总行数,写入 total_count 5. 分批循环: a. 读取 BATCH_SIZE 行 b. 逐行校验(格式、必填、枚举、长度) c. 校验通过的行 -> 批量入库(同批一个事务) d. 校验失败的行 -> 追加写入临时错误文件 e. 更新进度(节流)、成功数、失败数、心跳 f. sleep 50ms 让出资源 6. 生成正式结果文件(CSV,含成功数与失败明细) 7. 根据失败率决定最终状态:全成功 / 部分成功 / 失败 8. 清理本地临时文件

预检阶段的编码识别我一般用"读前 3 个字节判断 BOM,没有 BOM 就尝试 UTF-8 解码,失败则回退 GBK"。这个组合覆盖了绝大多数实际文件,用户导出的 CSV 经过各种工具转手,编码五花八门,不做这一步一定会踩坑。入库那一批如果遇到唯一键冲突,我会把整批降级为逐行插入,把冲突的行挑出来记为失败,其余继续成功。这个降级逻辑很有必要——批量插入遇到一行冲突会导致整批失败,用户看到的失败数会虚高。

4.5 第五步:前端轮询与交互设计

前端这块有两个细节直接影响体验。第一是轮询频率,我定的是任务进行中每 1.5 秒一次,结束后停止,页面隐藏时(visibilitychange)暂停轮询,用户切回来再恢复。别小看暂停,一个后台页面开着一天,不暂停就是几万次无效请求。

async function pollTask(taskNo) { let timer = null; const tick = async () => { if (document.hidden) return schedule(); const res = await fetch(`/api/task/${taskNo}`); const task = await res.json(); renderProgress(task); // 进度条 + 阶段描述 + 计数 if (task.status === 1 || task.status === 0) return schedule(); stop(); // 终态,停止轮询 if (task.status === 2 || task.status === 4) showResult(task.resultPath, task.failCount); if (task.status === 3) showError(task.errorMsg); }; const schedule = () => { timer = setTimeout(tick, 1500); }; const stop = () => clearTimeout(timer); tick(); }

第二个细节是任务与页面的解耦。用户提交后可能刷新、切页、关掉浏览器再回来,所以任务号要存在会话级存储里,进入导入列表页时自动查一次最近的任务并展示状态。我见过只在前端内存里保存任务号的实现,用户一刷新就"任务消失",然后重新上传,重复导入就此产生。

用户等待期间动辄几分钟,此时"看得见进展"比"跑的快一点"更重要。我给出建议是进度条旁边永远显示三件事:当前阶段(正在校验/正在写入)、已处理条数、成功与失败计数。这三项信息能覆盖 95% 的用户疑问,客服工单直接少一半。

5. 常见问题排查与踩坑实录

5.1 高频问题速查表

下面这张表是我在多个项目里反复遇到并解决的问题,按"现象—原因—处理"整理,遇到时可以直接对照。

现象可能原因排查与处理
用户点了两次,数据导入两遍前端未禁用按钮且无幂等按钮点击后立即置灰;后端用 biz_key 唯一索引兜底
任务一直停在"执行中"执行器异常退出或线程卡死靠 heartbeat 心跳超时扫描,超时任务重置或标记失败
进度条长时间不动进度未节流但批次处理慢,或卡在单批查看批次耗时日志;检查是否有单行处理超时的外部调用
导入后中文变乱码文件编码与读取编码不一致按 BOM 判断,无 BOM 先试 UTF-8 再回退 GBK
部分数据缺失某批事务回滚且未记录失败明细检查批次异常日志,确认失败行是否写入错误文件
大批量导入时在线接口变慢导入抢占数据库资源降低批次大小,批次间 sleep,必要时限流导入并发
结果文件下载 404临时文件被清理或路径记录错误检查结果目录与过期清理策略,路径存相对值
内存持续上涨甚至 OOM全量加载文件或错误明细全存内存改流式解析,错误明细实时落文件

5.2 那次让我印象深刻的重复导入事故

前面提到过,我确实经历过一次比较严重的重复导入。起因是用户量上来之后接口响应变慢,前端请求超时但后端还在处理,用户没看到结果又点了一次;两次请求都通过了校验,先后执行,数据写了两份。修复方案分三层:接口层加幂等键,数据库层加唯一索引兜底,前端层在点击后立刻禁用按钮并在超时后给出"处理中请勿重复提交"的提示。

这件事给我的教训是:幂等不能只靠前端,也不能只靠"查一下有没有",必须有一条数据库层面的硬约束。查询有天然的检查-使用竞态,唯一索引才是真正的防线。

5.3 一个坑:事务里调用外部服务

导入过程中,我一开始会在解析每一批数据时调用外部接口做数据补全(比如查部门信息),并且把它放在事务里。结果事务持有时间被外部接口的响应时间放大,锁等待迅速上升,并发时直接死锁。后来改成两阶段:先在一个短事务里写主体数据,事务提交后再做外部调用补全,补全失败不影响主体,记为待重试。

核心原则是:事务里只做数据库操作,且越快越好。任何可能超时的外部调用都要挪出去。这条经验写在这里,是因为它不只适用于批量导入,几乎所有后台处理都适用。

5.4 避坑心得:几个不起眼但很值钱的细节

第一个是文件大小与行数双限制。文件大小限制防止大文件打挂服务,行数限制防止"小体积超多行"的诡异文件(比如大量空白行)。两个都要做,配合明确的错误提示。

第二个是任务列表的查询权限。任务参数里可能带业务敏感信息,列表接口要按创建人或租户过滤,不能简单地"查询全部"。这个在开发阶段常被忽略,上线后就是隐患。

第三个是日志带上任务号。导入排查最痛苦的是"日志里找不到这个任务的相关记录"。我给所有导入相关的日志都加了 taskNo 前缀,排查时一个 grep 就能拿到完整链路,非常省事。

第四个是结果文件里带上原始行号。用户在 Excel 里修改错误数据时,靠行号能快速定位到原文件对应位置,否则只能一行一行比对。

5.5 容量与演进:什么时候该换方案

数据库任务表方案能撑到什么程度,我给个大致的量级参考(基于单机、单库、简单业务):日均任务数千到上万、平均单任务耗时在一分钟以内,基本没问题。超出这个范围,会开始出现轮询扫表变慢、多个执行器抢锁冲突增多、任务表体积膨胀的问题。

真到了这一步,演进路径也很清晰:第一步把轮询换成"数据库通知 + 短轮询"或直接引入消息队列推送,降低扫描开销;第二步把任务表按月归档,历史任务迁到冷表;第三步把执行器拆成独立服务,按任务类型分不同的消费组,做真正的水平扩展。这些演进之所以平滑,靠的就是前面说的那层存储接口抽象和处理器注册表——业务代码不动,只换基础设施。

我个人的建议是:别提前上复杂方案。很多团队在日均几十个任务的时候就把架构做得极其复杂,最后维护成本吃掉所有收益。先用最简单的方案把功能跑通,把状态机、幂等、心跳、结果文件这些基础做扎实,等量真的上来了再演进,一点都不晚。

6. 一些关于参数与收益的实际体会

再补充几个我在不同项目里反复验证过的量化感受,供参考。批次大小从 500 调到 1000,一般能带来 10% 到 20% 的总耗时下降;批次间 50 毫秒的休眠,对导入总时长的影响通常不到 5%,但对在线接口的 P99 响应时间改善明显,这笔账很划算。心跳超时阈值设 5 分钟,是因为单批处理正常在秒级,5 分钟没动静基本可以断定异常;如果你们单批可能跑很久(比如涉及复杂外部调用),这个阈值要相应放大,宁可晚点回收也不能误判。

任务表要定时清理,我一般保留 90 天,超期的归档而不是物理删除,方便事后追溯。结果文件保留 7 天,错误明细在任务完成后随结果文件一起给用户,不在库里长期存文本。这些策略看起来琐碎,但真出问题时,它们决定了你能不能快速定位。

这套简易异步任务中心落地之后,最直接的变化是:半夜处理重复数据的次数没了,导入失败能直接给出失败明细让用户自己改,接口平均响应时间从十几秒降到几十毫秒。它不是什么高深架构,但把异步、幂等、分批、进度、错误明细这几个基础点做扎实,就已经能解决绝大多数批量数据处理场景的实际问题了。我后来新起的几个后台系统,基本是把这套东西直接搬过去改改任务类型就能用,复用的收益比我预期的还高。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询