加载中...
  • 文件上传只是入口:数据接入与日志可观测性loading

    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 无法表达解析中、索引中、失败和可重试。可以定义 receivedparsingindexingreadyfailed,并记录状态更新时间和公开错误码。用户看到的是“解析失败,请重新导出 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 串起全过程。然后并发触发两次重试,确认旧任务无法覆盖新结果。

    两个提交节点

    • e573856:增加文件上传相关模块,为数据建立接入口。
    • 99654dd:加入日志扩展并接入 HTTP 服务,为处理过程增加观察能力。

    上传与日志相邻出现很合理:一旦系统开始接收用户数据,失败就不再只发生在开发者眼前,必须能跨请求和任务追踪它的去向。

    本文目录
    本文目录