AI 应用后端工程化:从原型到可交付系统 · 第 10 篇 · 第三章 · 数据管道
文件落盘只是第一步,还要记录所有者、类型、状态和调用轨迹,才能让后续处理可恢复、可追踪。
用户点下“上传”后看到成功提示,不代表文件已经成为可用数据。服务至少要回答:文件属于谁、内容是否完整、格式能否处理、下一步任务是什么、失败发生在哪一环。文件上传是数据管道的入口,日志则是理解这条管道的观察窗。
先给文件一个稳定身份
原始文件名既不唯一也不可信。两个用户都可能上传 report.pdf,文件名还可能包含路径字符。接入层应该生成内部 ID,保存原始名称作为展示信息,用受控路径或对象存储键保存内容。
flowchart LR
Client[上传请求] --> Validate[大小/类型校验]
Validate --> Store[写入临时对象]
Store --> Hash[计算摘要]
Hash --> Record[(文件记录)]
Record --> Accepted[返回 file_id]
Accepted --> Job[后续处理任务]
Log[结构化日志] -.关联 request_id/file_id.-> Validate
Log -.关联.-> Job
文件记录可以包含所有者、大小、MIME、哈希、存储位置、处理状态和创建时间。接口返回 file_id,后续解析和查询都围绕这个 ID,而不是围绕用户可改的文件名。
流式写入保护内存
直接读取整个请求体再写文件,在小样本中没有问题;几十个用户同时上传大文件时,进程内存会迅速上涨。应限制请求大小、分块读取,并在超限或连接中断时删除临时对象。
def save_stream(stream, target, max_bytes: int) -> int:
written = 0
with target.open("wb") as output:
while chunk := stream.read(1024 * 1024):
written += len(chunk)
if written > max_bytes:
raise ValueError("file is too large")
output.write(chunk)
return written
真实实现还要先写临时位置,完成哈希和校验后再原子重命名,避免其他任务读取半个文件。扩展名只能帮助展示,不能代替 MIME 检测与内容解析。即使格式合法,也要考虑压缩炸弹、恶意宏和解析器漏洞。
状态比一个布尔值有用
uploaded=true 无法表达解析中、索引中、失败和可重试。可以定义 received、parsing、indexing、ready、failed,并记录状态更新时间和公开错误码。用户看到的是“解析失败,请重新导出 PDF”,日志中则保留解析器异常与调用栈。
状态转换应由任务拥有者更新,并防止旧任务覆盖新状态。例如同一文件重试两次,第一次较晚失败,不应把第二次已经成功的记录改回失败。任务版本或乐观锁可以解决这种竞争。
日志要回答问题
一句 upload error 几乎没有排查价值。结构化日志应包含 request ID、file ID、actor ID、阶段、耗时、结果和稳定错误类型。不要记录文件正文、授权头和密钥。
{
"event": "file.parse.finished",
"request_id": "req_72c",
"file_id": "file_b81",
"stage": "parse",
"duration_ms": 842,
"result": "failed",
"error_code": "unsupported_encryption"
}
同一个 request ID 应从 HTTP 入口传到后台任务。异步执行后线程已经变化,依赖线程局部变量会丢失关联,需要把 trace context 放进任务消息。
日志级别不是严重程度装饰
用户传错格式属于可预期业务失败,通常记录为 info 或 warning;数据库不可用影响整个服务,应是 error;逐块读取进度只适合 debug。生产环境把所有内容设为 debug 会增加成本并淹没信号,把所有失败都设 error 又会让告警疲劳。
指标负责聚合“每分钟多少失败、P95 多慢”,日志负责解释某一次为什么失败,trace 负责连接跨服务步骤。三者互补,不能用海量日志代替指标。
失败后的清理
数据库记录创建成功而文件写入失败,会留下空记录;文件写入成功而数据库提交失败,会留下孤儿对象。可以先写临时对象,再在数据库事务成功后标记归属;定时任务清理过期临时对象。完全跨存储原子事务通常代价很高,明确补偿动作更实际。
上传同一文件的重试应具备幂等性。内容哈希可以帮助去重,但要结合所有者与处理配置,同一内容在不同权限或切片策略下不一定能共享全部结果。
不只适用于知识库
头像上传、账单导入、视频转码和 CSV ETL 都遵循同一路径:受控接收、稳定身份、异步处理、状态机、可观察日志和失败清理。AI 只改变后续处理器,不改变入口可靠性的基本问题。
练习:模拟上传过程中在写入 60% 时中断,检查临时文件是否被清理、数据库状态是否可重试、日志能否用同一 file ID 串起全过程。然后并发触发两次重试,确认旧任务无法覆盖新结果。
两个提交节点
上传与日志相邻出现很合理:一旦系统开始接收用户数据,失败就不再只发生在开发者眼前,必须能跨请求和任务追踪它的去向。