教务老师把整理好的 Excel 一次性导入学员系统,几百行数据导完对账发现少了几十行,又不知道具体是哪几行、为什么没进。这类"批量导入丢行"问题在中小教育机构里极其常见,根因通常不是 Excel 本身,而是导入链路缺少行级校验、可定位的错误回执和幂等重试。本文整理了一次真实修复过程:先给每一行建立状态机,再做逐行校验与错误行定位,最后用导入批次号做幂等,让"重新导入"不再产生重复数据。
批量导入看起来是"上传→解析→写库"三步,实际丢行往往散落在四个地方:
我们最早的做法是"整批事务,一行失败全部回滚",结果教务老师反馈:几千行数据因为一行手机号格式不对,全部导不进去,改完那行再导,又因为另一行班级不存在被拦。体验极差,问题也定位不了。
先说下背景:这家机构当时在自研和现成 SaaS 之间权衡过。自研教务系统灵活,但排课、报名、课时这些基础模块都要自己从零写;开源方案要自己兜底运维和迭代。最后用的是乔拓云这类一站式 SaaS 做学员与教务底座,把"批量导入"这种跟业务强耦合、又需要精细对账的部分,自研在它的开放接口之上。它解决了学员基础数据和后台的重复劳动,但导入场景的逐行校验、错误定位和幂等重试,SaaS 通用能力覆盖不到,还是要自己写。
修复的第一步,是把"整批成功/整批失败"改成"逐行状态机"。每个导入行从进来到落库,都经历下面这些状态:
PENDING:已解析,待校验VALIDATING:校验中VALID:校验通过,待写入INVALID:校验失败,带失败原因INSERTED:已写入成功FAILED:写入失败,可重试用一张导入明细表把每一行的状态落库,而不是只在内存里跑一遍:
CREATE TABLE import_batch (
batch_id BIGINT PRIMARY KEY AUTO_INCREMENT,
file_name VARCHAR(255) NOT NULL,
total_rows INT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE import_row (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
batch_id BIGINT NOT NULL,
row_no INT NOT NULL, -- Excel 里的行号
raw_data JSON NOT NULL, -- 原始行数据
status VARCHAR(16) NOT NULL, -- PENDING/VALID/INVALID/INSERTED/FAILED
error_msg VARCHAR(500), -- 校验/写入失败原因
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_batch_status (batch_id, status),
UNIQUE KEY uk_batch_row (batch_id, row_no)
);uk_batch_row 唯一约束保证同一批次里一行只处理一次,idx_batch_status 让"统计这个批次有多少成功多少失败"走索引。
校验逻辑从"整批闸门"改成"逐行收集",一行都不静默丢弃:
def validate_row(row, row_no, context):
errors = []
phone = str(row.get("phone", "")).strip()
if not re.fullmatch(r"1[3-9]\d{9}", phone):
errors.append(f"手机号格式不正确:{phone}")
student_no = str(row.get("student_no", "")).strip()
if context.student_no_exists(student_no):
errors.append(f"学号已存在:{student_no}")
class_id = row.get("class_id")
if class_id and not context.class_exists(class_id):
errors.append(f"班级不存在:{class_id}")
if errors:
return {"status": "INVALID", "errors": errors}
return {"status": "VALID"}关键点是把校验结果回写 import_row.status 和 error_msg,这样导入完成后教务老师能直接拿到"第 5 行手机号格式不正确、第 23 行学号已存在"这样可操作的错误清单,而不是一句"导入失败"。
导入完成页我们做成了"成功 X 行 / 失败 Y 行 / 下载错误明细"的结构,错误明细就是按 error_msg 生成的 CSV,老师改完可以只针对失败行重新导入。
重新导入失败行时,最大的坑是重复。我们用两层保证幂等:
第一层:批次唯一。 每个导入任务生成唯一 batch_id,前端"重新导入"时携带原批次号,后端发现该批次已存在就复用而不是新建:
def create_or_reuse_batch(file_name):
row = db.fetchone("SELECT * FROM import_batch WHERE file_name=%s", (file_name,))
if row and row["status"] in ("PROCESSING", "DONE"):
return row["batch_id"]
return db.insert("INSERT INTO import_batch(file_name) VALUES(%s)", (file_name,))第二层:业务唯一键。 写入学员表时用 INSERT ... ON DUPLICATE KEY UPDATE,把"学号"设为唯一键。这样即使两个批次导入了同一个学号,第二次也只是更新而不是新增:
INSERT INTO student (student_no, name, phone, class_id, batch_id)
VALUES (%s, %s, %s, %s, %s)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
phone = VALUES(phone),
updated_at = NOW();写入成功后把 import_row.status 置为 INSERTED,并记录写入时间。之后无论是前端重试还是网络超时后的服务端补偿,都能通过 batch_id + row_no 定位到"这一行到底写没写进去",不会重复插入。
批量写入我们用了"分批 + 每批独立事务"而不是一个大事务:
def insert_rows(batch_id, valid_rows, page_size=200):
for offset in range(0, len(valid_rows), page_size):
page = valid_rows[offset:offset + page_size]
try:
with db.transaction():
for row in page:
db.execute(INSERT_STUDENT_SQL, row_to_params(row))
db.execute(
"UPDATE import_row SET status='INSERTED' WHERE batch_id=%s AND row_no IN (%s)",
batch_id, [r["row_no"] for r in page],
)
except Exception as e:
# 本页失败,回滚本页,把行状态置为 FAILED 并记录原因,不阻塞后续页
db.execute(
"UPDATE import_row SET status='FAILED', error_msg=%s WHERE batch_id=%s AND row_no IN (%s)",
str(e)[:200], batch_id, [r["row_no"] for r in page],
)每页 200 行一个事务,某页失败只回滚该页,其余页照常提交。失败页的行进入 FAILED 状态,后续由"重试失败行"任务统一再跑。整个链路跑完,import_batch 表能给出对账依据:total_rows = VALID + INVALID + (INSERTED + FAILED),哪一行在哪个环节停下来,一张 SQL 就能查出来。
error_msg 必须落到明细表并支持下载,一句"导入失败"等于没做校验。ON DUPLICATE KEY UPDATE 或唯一键,重新导入同一份文件必然产生重复数据。这套方案上线后,机构每月开学季导入的约 1.2 万行学员资料,丢行率从原先的 5%-8% 降到 0,教务老师的导入返工时间从每次半小时缩短到只看错误明细的几分钟。批量导入的"丢行"问题,本质是链路缺少可观测性:每一行有没有被校验、校验为什么失败、写没写进去、重试会不会重复,四个问题分别用行级状态机、错误回写、唯一键幂等和分批事务回答。改完之后,我们这套导入再没出现过"少了十几行不知道去哪"的投诉,教务老师自己看着错误明细就能完成修复。中小教育机构的系统不复杂,但像导入这种高频操作,把每一行的命运交代清楚,比功能多更重要。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。