文件上传为什么不能一步到位:票据、去重与引用计数

SailTrack
2026-08-05
点 赞
0
热 度
0
评 论
0
  1. 首页
  2. 系统设计
  3. 文件上传为什么不能一步到位:票据、去重与引用计数

写在前面

做文件上传的时候,我一开始的想法特别简单:前端把文件 POST 过来,后端存到对象存储,返回一个 URL,完事。

真做起来才发现这条路走不通。企业系统里的文件不是"存下来就行",它有一堆必须回答的问题:

  • 用户点了两次上传,会不会存两份?
  • 同一个文件被三个人分别上传,要占三份空间吗?
  • 文件被聊天、审批、头像同时引用,某处删除了,物理文件能跟着删吗?
  • 用户传了一半断网了,那个半成品文件谁来清理?

这篇记录一下我在 Workforce Hub 里设计文件中心的思路。核心是三个决定:上传拆成两阶段、内容按哈希去重、引用与存储分离

一、把上传拆成两阶段

最直接的做法是让文件流经后端:前端 → 我的服务器 → 对象存储。但这样有个致命问题:大文件会把应用服务器的内存和带宽吃满

所以改成了两阶段:

第一阶段  客户端请求「上传票据」
        后端校验权限、配额,返回票据 + 对象存储的上传地址
第二阶段  客户端拿票据直传对象存储(不经过我的服务器)
确认      客户端告诉后端「传完了」,后端校验后创建引用记录

第一阶段签发票据的核心逻辑:

public UploadTicketResponse createEmployeeUploadTicket(CreateUploadTicketRequest request) {
    Long userId = CurrentUserHolder.requireUserId();
    fileUploadRules.validateEmployeeOwner(request.getOwnerType(), request.getOwnerId());
    fileUploadRules.validateEmployeeFolder(request.getOwnerType(), userId, request.getFolderId());
    ensureUploadPolicy(request);
    fileUploadRules.ensureEmployeeQuota(request.getOwnerType(), userId, request.getFileSize());
    fileQuotaPolicyService.ensureGlobalQuota(request.getFileSize());
    // ...
    return createPendingUploadTicket("USER", userId, request, "FILE_UPLOAD_TICKET_CREATED");
}

关键在于票据是在校验通过后才签发的。权限、目录归属、个人配额、全局配额——全部在这一步查完。如果这里不放行,客户端连对象存储的地址都拿不到,根本传不上去。

这样应用服务器只处理轻量的元数据,文件本体走对象存储的直连通道。

二、确认阶段要校验六件事

文件传完了不代表可以入库。客户端调确认接口时,后端要逐项核对:

UploadTicketRow ticket = getUploadTicketOrThrow(request.getUploadTicketId());

// 1. 票据归属:是不是本人创建的票据
if (!"USER".equals(ticket.uploaderType()) || !userId.equals(ticket.uploaderId())) {
    throw new BizException(403, "无权确认该上传票据");
}
// 2. 重复确认:已经确认过就直接返回,不报错
Long existingRefId = attachmentRefRepository.findIdByUploadTicket(ticket.id());
if (existingRefId != null) { /* 返回已有结果 */ }
// 3. 票据状态与有效期
if ("CONFIRMED".equals(ticket.status())) {
    throw new BizException(409, "上传票据已确认");
}
if (ticket.expireAt().isBefore(LocalDateTime.now())) {
    throw new BizException(409, "上传票据已过期");
}
// 4. 对象路径必须与票据一致
if (!ticket.objectKey().equals(request.getObjectKey())) {
    throw new BizException(422, "文件对象不匹配,请重新上传");
}
// 5. 内容指纹与大小必须一致
if (!ticket.contentSha256().equalsIgnoreCase(request.getContentSha256())
    || !ticket.fileSize().equals(request.getFileSize())) {
    throw new BizException(422, "文件校验失败,请重新上传");
}
// 6. 业务归属不能变
if (!ticket.bizType().equals(request.getOwnerType()) || !sameId(ticket.bizId(), request.getOwnerId())) {
    throw new BizException(422, "业务归属不匹配,请重新申请上传票据");
}

这几道校验不是凑数的,每一道都对应一种真实的攻击或故障:

校验项 防的是什么
票据归属 拿别人的票据确认,把文件挂到自己名下
重复确认 客户端重试导致创建多条引用
有效期 票据泄露后被长期复用
对象路径 确认时偷换对象,指向别人的文件
SHA256 + 大小 传了个 A 文件,确认为 B 文件
业务归属 申请时挂给消息,确认时改成审批附件

第 5 项最容易被忽略。 如果确认时只信客户端报的哈希,攻击者可以先合法上传一个小文件拿到票据,再确认时声称这是另一个大文件的哈希。所以我用 verifyObject 向对象存储反查实际内容

storageProviderRegistry.require(ticket.storageProvider())
    .verifyObject(ticket.bucketName(), request.getObjectKey(),
                  request.getContentSha256(), request.getFileSize());

存储层说这个对象的 SHA256 确实是它,才放行。

三、按内容哈希去重(秒传)

一张公司logo可能被十几个页面引用,如果每次都存一份,存储会被吃爆。所以文件对象表里存了 content_sha256,上传前先查有没有现成的:

FileObjectRow existing = fileObjectRepository.findReusableForUploader(
    "USER", userId, request.getContentSha256(), request.getFileSize()
);
if (existing != null) {
    // 命中:不重复存文件,直接建引用
    deduplicatedTicketId = createConfirmedDeduplicatedUploadTicket("USER", userId, request, existing);
    attachmentRefId = createAttachmentRef(..., existing.id(), deduplicatedTicketId, ...);
    fileObjectRepository.increaseRefCount(existing.id());
    // ...
    return deduplicatedResponse(deduplicatedTicketId, "UP" + deduplicatedTicketId, existing, attachmentRefId);
}

命中时票据直接是「已确认」状态——因为文件本来就在存储里,不需要再传一遍。这就是网盘"秒传"的原理。

⚠️ 注意这里有个安全细节:查重范围限制在 ("USER", userId)不能让用户通过秒传探测到别人上传过什么文件——如果全局查重,A 用户传一个文件发现秒传成功,就等于确认了"这个文件公司里有人传过"。

四、引用计数:文件什么时候才能真正删

这是整个设计里我觉得最关键的一层。

假设一张图片同时被「聊天消息」和「审批附件」引用。用户在聊天里删了这条消息,这张图片能删吗?不能,审批那边还在用。

所以我把「文件对象」和「业务引用」拆成两张表:

file_object        物理文件(sha256、大小、存储路径、ref_count)
attachment_ref     业务引用(谁在引用它:消息?审批?头像?)

删文件时不是直接删物理对象,而是标记引用失效

LocalDateTime deletedAt = LocalDateTime.now();
LocalDateTime purgeAfter = deletedAt.plusDays(30);
int updated = jdbcTemplate.update("""
    UPDATE sys_attachment_ref
    SET deleted_at = ?, deleted_by = ?, purge_after = ?, updated_by = ?
    WHERE id = ? AND is_deleted = 0 AND deleted_at IS NULL
    """, deletedAt, userId, purgeAfter, userId, attachmentRefId);

这里有两个设计点:

第一,purge_after 给了 30 天宽限期。 用户误删了还能恢复:

public FileRestoreResponse restoreEmployeeFile(Long attachmentRefId, FileTrashRequest request) {
    AttachmentRow attachment = attachmentRefRepository.getAttachmentOrThrow(attachmentRefId);
    ensureUploaderOwnsAttachment(userId, attachment);
    if (attachment.deletedAt() == null) {
        return buildRestoreResponse(attachmentRefId);   // 本来就没删,幂等返回
    }
    // ...
}

第二,UPDATE 语句里带了 AND deleted_at IS NULL 这不是多余的——updated == 0 时我再去查一次当前状态:

if (updated == 0) {
    AttachmentRow latest = attachmentRefRepository.getAttachmentOrThrow(attachmentRefId);
    if (latest.isDeleted() != null && latest.isDeleted() == 1) {
        throw new BizException(404, "附件引用不存在");
    }
    return buildTrashResponse(attachmentRefId, latest.deletedAt(), latest.purgeAfter());
}

为什么要绕这一圈? 因为并发删除时,两个请求可能都通过了前面的检查。带上条件后只有一个能更新成功(updated == 1),另一个拿到 updated == 0,此时它需要区分两种情况:

  • 已经被别人删了 → 幂等返回当时的结果,不报错
  • 已经彻底清理了 → 报 404

如果这里直接抛异常,用户会看到莫名其妙的失败;如果无脑返回成功,又会掩盖真实的并发状态。把并发的结果如实分类返回,比"报错"或"假装成功"都更正确。

五、配额放在签发阶段而不是确认阶段

配额校验出现在两个地方——票据签发时和确认时都查一遍。看起来重复,但两次的目的不同:

时机 目的
签发时 提前拦截——配额不够就别让用户白传一遍大文件
确认时 兜底——签发到确认之间,用户可能已经用掉了配额

配额本身分了三层:

private static final long DEFAULT_PERSONAL_QUOTA_BYTES = 10L * 1024L * 1024L * 1024L;   // 10G
private static final long DEFAULT_GLOBAL_QUOTA_BYTES   = 400L * 1024L * 1024L * 1024L;  // 400G
private static final long DEFAULT_CACHE_QUOTA_BYTES    = 50L * 1024L * 1024L * 1024L;   // 50G

个人配额防止单个用户占满空间,全局配额是租户级的总闸,缓存配额留给临时文件。三层各管一件事,比一个总配额更可控。

六、小结

回头看,文件上传这个需求表面上很简单,但要在企业环境里做对,得回答清楚三个问题:

  1. 数据流经谁? 文件本体直传对象存储,应用服务器只管元数据。这是性能问题的答案。
  2. 怎么判断两个文件是同一个? 内容哈希(SHA256 + 大小),但查重范围要限定在用户自己的文件内,否则会泄露信息。
  3. 什么时候能删物理文件? 引用计数归零。逻辑引用和物理存储分开,删除走逻辑删除 + 宽限期。

如果只记一条,那就是:不要把"文件"当成一个东西。 它至少是三个:物理对象、业务引用、以及两者之间的计数关系。想清楚这三层,大部分坑就自然避开了。


让我们忠于理想,让我们面对显示

SailTrack

entp 辩论家

站长

不具版权性
不具时效性

文章内容不具时效性。若文章内容有错误之处,请您批评指正。

目录

欢迎来到SailTrack的站点,为您导航全站动态

42 文章数
11 分类数
5 评论数
53标签数