OBS 同名文件互相覆盖,客户拿错了别人的报告:一次文件串号的排查与重构

老炮踩坑录 · F10 · 翻车现场系列

· 基于「企业融合评估系统」真实源码复盘

· 关键词:对象存储 · 文件名冲突 · Guava 缓存锁 · 多实例并发 · 越权下载

👋 欢迎阅读 🏠个人主页: 知守观

📘我的专栏: 老炮踩坑录

💻当前内容:策略模式

引言

复盘文件上传这块代码的时候,我先问了自己一个问题:这个系统里,一家企业的诊断评估报告从本地上传到客户能下载,中间要经过几道关?

答案是四道:本地临时文件、OBS 对象、附件记录表、下载接口。四道关里,每一道都做了防覆盖设计。

我翻完代码的感受很复杂------设计者明显认真想过并发,可整套防护建立在一个没说出口的前提上。前提一旦破,四道关就会一起漏。

今天这篇我们就讲这件事。分析防护是怎么设计的,漏在哪里,以及 "客户拿到别人的报告" 这条事故链是怎么一步步成立的。

先看当年做对了什么

文件名加 UUID。 HuaweiyunFileServiceImpl.java:398:

ini 复制代码
File file = new File(path + "/" + UUID.randomUUID() + files[i].getOriginalFilename().replaceAll(";", "."));

原始文件名前面拼一个 UUID,两家企业都传 诊断报告.pdf,落到 OBS 里是两个不同的 key。原名里的分号顺手替换成点号------分号是这个系统的多文件分隔符,防止文件名把列表拆错了。

objectKey 按 UUID 分目录。 同一个类的 getObjectKey方法:877:

typescript 复制代码
public static String getObjectKey(String filename) {
    if (StringUtils.isNotEmpty(filename)) {
        return filename;
    }
    if (filename.length() > 37 && StringUtils.isValidUUID(filename.substring(0, 36))) {
        return filename.substring(0, 36) + "/" + filename.substring(36);
    }
    return filename;
}

OBS 里的结构是 UUID/原始文件名。同名文件天然隔离,目录本身就是命名空间。

并发上传做了乐观检查。

同一家企业、同一个模型,双击或者网络重试会触发并发,两边都传成功,记录表该信谁?uploadDiagnosis 的处理是把自己的文件名先写进 Guava 缓存,传完再回头看缓存里还是不是自己:

scss 复制代码
// 上传开始:占位
CACHES.put(enterpriseid + paperid, name);
​
// 上传结束:核对
Object ifPresent = CACHES.getIfPresent(enterpriseid + paperid);
if (ifPresent != null && !ifPresent.toString().equals(uname)) {
    if (!StringUtils.isEmpty(oldFileName)) {
        asyncService.cleanHwyFile(endPoint, ak, sk, bucketName,Arrays.asList(oldFileName.split(";")));
    }
    return new Result().fail("不能覆盖最新的文件", did);
}

数据库更新加了本地锁。

紧接着的记录更新,封装在类锁里 第438行:

csharp 复制代码
synchronized (HuaweiyunFileServiceImpl.class) {
    params.put("fileName", newFileNames.toString());
    diagnosisInfoService.modifyFileName(params);
    this.applyEvaluationServiceService.initESData(params);
}

旧文件上传成功后异步清理 第448行,不会阻塞响应。

老实说,一个 2022 年写就的上传方法,能想到占位、核对、加锁、清理四件事,作者水平在线。我读的时候有点佩服。

漏点一:整套锁都在一个 JVM 里

佩服完后我看了一眼部署方式。这个项目打 WAR 包,外置 Tomcat 部署。

单机 Tomcat 时,Guava 缓存 + 类锁把同企业并发治得服服帖帖。可只要前面架两台 Tomcat、再挂个负载均衡器,局面立刻变了:

css 复制代码
企业A双击上传
   │
   ├─ 请求落到 Tomcat-1
   │     本地缓存查不到占位 → 认为自己是第一请求
   │     上传文件 a → 用类锁更新记录表为 a → 异步删除旧文件 X
   │
   └─ 请求落到 Tomcat-2
         本地缓存查不到占位 → 也认为自己是第一请求
         上传文件 b → 类锁更新记录表为 b → 异步删除旧文件 X
​
最终状态:
  记录表 = b(后提交的覆盖了先提交的)
  OBS   = a 和 b 同时存在,X 被删了两次
  a 成了没有任何记录指向的孤儿文件

两个节点的乐观检查各自通过,因为是本地缓存根本不共享。两边查到的旧文件都是 X,谁都不会把对方刚传的文件视为冲突。

客户点开报告,看到的是 b。如果两次上传选的文件不一样------比如客户第一次传错了,立刻重传,第二次请求偏偏落到了先返回的节点上------他眼前就是那份以为已经被替换掉的旧文件。客户的感受很直接: "我明明传了新的,系统里怎么还是旧的?"

漏点二:100 条上限让保护静默失效

就算一直单机,这套缓存自身也藏着隐患------平时看着没问题,碰到特定场景就会突然出故障。看看它的声明 第70行:

scss 复制代码
private static final Cache<String, Object> CACHES = CacheBuilder.newBuilder()
        // 最大缓存 100 个
        .maximumSize(100)
        // 设置写缓存后24小时过期
        .expireAfterWrite(24, TimeUnit.HOURS)
        .build();

24 小时 TTL 让每个占位条目在缓存里躺一整天。

maximumSize(100) 在 24 小时窗口下意味着:超过 100 家企业在同一天上传,缓存开始驱逐旧条目。回头看,再核对下逻辑:

ini 复制代码
Object ifPresent = CACHES.getIfPresent(enterpriseid + paperid);
if (ifPresent != null && !ifPresent.toString().equals(uname)) {
    
}

自己的条目要是刚被驱逐,getIfPresent 返回 null,整个冲突检查直接跳过,不报错、不告警。防护从"乐观检查"退化成"看运气检查",调用方对此却一无所知。

key 的拼接也值得琢磨:enterpriseid + paperid,中间没有分隔符。要凑出碰撞,得有企业 ID 是另一家 ID 加上 paperid 数字的前缀。这个项目的企业 ID 走雪花算法,定长 19 位,现实中撞不上;可万一历史数据里混进过短 ID,就是一条极难排查的串号。

这种 key 我一律要求加分隔符,加的成本为零。

漏点三:下载接口没有归属校验

前面两个漏点还需要 "并发" 这个巧合才成立,这个漏点常年敞开。

下载接口在 FileController.java:98:

ini 复制代码
@GetMapping("/huawei/down")
public Result down(String fileName, HttpServletResponse response) {
    ObsClient obsClient = new ObsClient(ak, sk, endPoint);
    String objectKey = HuaweiyunFileServiceImpl.getObjectKey(fileName);
    GetObjectRequest request = new GetObjectRequest(bucketName, objectKey);
    ObsObject obsObject = obsClient.getObject(request);
    // ... 流式写回浏览器
}

方法上没有 @AuthCompany,类上也没有。这个项目的鉴权全部依赖 AuthAspect 切注解------我特意去查了全局拦截器,MvcConfig.java:27 里拦截器注册整段是注释掉的。也就是说,谁持有 fileName,谁就能下载。

fileName 会出现在哪些地方?客服在群里帮客户排查时贴的链接、客户转发给同事的链接、浏览器历史、Referer 头、Nginx 访问日志。任何一个环节漏给了第三方,对方打开链接就是完整报告,登录页面都不会拦他。

同文件里上传接口 `/files/huawei` 同样光着,匿名上传也没有拦。

严格说这条路径跟"覆盖"无关,可它直接通向标题里的后半句------客户拿错(拿到)别人的报告,而且拿得畅通无阻。

漏点四:删除路径两套行为

多文件上传时,fileName 字段用分号串起来。上传成功后清理旧文件,是拆开逐个删的。走删除接口时却换了一套做法deleteDiagnosis:571:

ini 复制代码
obsClient.deleteObject(bucketName, getObjectKey(fileName));

fileName 像 uuid1/报告1.pdf;uuid2/附件2.pdf 这种字符串,getObjectKey 只对前 36 位做 UUID 解析,返回一个畸形 objectKey。OBS 删除一个不存在的 key 不报错------接口返回成功,实际上两个文件都还在桶里。

记录表那边已经把 fileName 清空了。于是文件还在 OBS 占着空间,系统里却没有任何记录知道它在。这类残留会积累,直到某天账单上的存储量对不上,或者有人按前缀翻桶时翻出一堆 "已删除" 的客户报告。

旧文件的异步清理同样如此。AsyncService.cleanHwyFile()方法里每个删除操作各自 try-catch,失败只打一条日志,没有重试,没有告警,没有对账。删没删掉,系统从不复核。

arduino 复制代码
@Async
public void cleanHwyFile(String endPoint, String ak, String sk,
                         String bucketName, List<String> oldFileNameList) {
   /**
    * 删除华为云上旧的文件
    */
    ObsConfiguration config = new ObsConfiguration();
    config.setSocketTimeout(30000);
    config.setConnectionTimeout(10000);
    config.setEndPoint(endPoint);
    final ObsClient obsClient = new ObsClient(ak, sk, config);
    oldFileNameList.forEach(fileName -> {
        try {
            obsClient.deleteObject(bucketName, fileName);
        } catch (Exception e) {
            log.error(e.getMessage(), e);
        }
    });
}

怎么改呢?

第一步先堵下载。

给下载接口加鉴权,并强制归属校验------光校验登录还不够,登录用户也不能下载别人家的报告文件:

less 复制代码
@AuthCompany
@GetMapping("/huawei/down")
public void down(String fileName, HttpServletResponse response) {
    String enterpriseid = SessionCacheUtils.getEnterpriseid();
​
    // fileName 必须属于当前企业的申报记录
    int owned = fileDao.countOwnedFile(enterpriseid, fileName);
    if (owned == 0) {
        throw new SystemException(ResultEnum.FAIL_FORBIDDEN);
    }
​
    // 校验通过再走 OBS 流式下载
}

countOwnedFile方法就是一条 SQL:在 diagnosis_info(和其他存文件名的表)里按企业 ID 和 fileName 匹配。fileName 不再只是 "知道就能用" 的通行证。

并发锁搬出 JVM。

两种方案。如果有 Redis,用分布式锁,key 加分隔符,锁的粒度精确到企业加模型:

csharp 复制代码
String lockKey = "report:upload:" + enterpriseid + ":" + paperid;
RLock lock = redissonClient.getLock(lockKey);
if (!lock.tryLock(0, 30, TimeUnit.SECONDS)) {
    return new Result().fail("已有上传正在处理,请稍候");
}
try {
    // 上传 + 更新记录
} finally {
    lock.unlock();
}

如果不想引入 Redis,就用数据库乐观锁。diagnosis_info 加 version 版本字段,更新时带版本条件:

bash 复制代码
UPDATE diagnosis_info
SET fileName = #{fileName}, version = version + 1
WHERE enterpriseid = #{enterpriseid}
  AND paperid = #{paperid}
  AND version = #{version}

执行时如果影响行数为 0,说明有并发抢先,本次上传的文件要立刻从 OBS 删掉(这次删除发生在同一个方法里,同步等待结果),再返回冲突提示。

OBS 对象和记录的顺序也要控制:先传对象、后更记录,失败时删对象;倒过来做,记录指向一个没传成功的 key,就是空链接。

  • 多文件删除收缩到一个方法中。

OBS SDK 提供批量删除,一次请求带回每个 key 的删除结果:

vbscript 复制代码
DeleteObjectsRequest request = new DeleteObjectsRequest(bucketName)
        .withKeys(keys.toArray(new String[0]));
DeleteObjectsResult result = obsClient.deleteObjects(request);
​
if (!result.getErrorResults().isEmpty()) {
    log.error("OBS部分文件删除失败: {}", result.getErrorResults());
    // 抛给异步重试队列或告警,不能静默
}

所有需要删文件的地方------上传后清理、用户主动删除、并发冲突回滚------全部调这一个入口,删除失败必须能被感知 ------要么同步等待结果,要么失败进重试队列,要么至少有对账任务兜底,不能让它"发出去就完事。

  • objectKey 加业务前缀。

这个我最推荐:rapplyid/uuid-原始文件名。归属信息直接写在 key 里,鉴权时从 key 就能反查归属,不用每张业务表都扫一遍;按前缀列表、按前缀清理也方便。要做彻底,桶里开一个临时前缀,未完成的上传先进临时区,记录更新成功后再拷贝(或重命名)到正式前缀,两步走,任何一步失败都不留正式数据。

  • 加个对账任务。

每天扫一次:OBS 里存在但记录表查不到的 key、记录里有但 OBS 已不存在的 key,两边各出一份差异报表,进告警群。残留文件和空链接这类静默问题,靠对账兜住底。

自查清单

检查项 怎么查 危险信号
文件下载是否校验归属 看下载接口,先鉴权再按企业 ID 匹配 fileName 只判断登录状态,或接口完全没注解
并发防护是否跨实例 查锁的实现,Guava/synchronized 只在单 JVM 生效 多实例部署配本地锁,防护互相不可见
本地缓存是否会让检查跳过 看 getIfPresent 返回 null 时的分支 null 被当作"无冲突"放行,且无告警
缓存配置与注释是否一致 对照 TTL、容量的代码与注释 注释说 1 秒实际 24 小时,没人知道真实行为
多文件删除是否逐个/批量处理 搜删除调用,看入参有没有先 split 整个分号串当一个 key 删,OBS 静默成功
删除失败是否可感知 看 catch 分支有没有重试/告警/对账 只打日志,失败永久淹没
key 拼接有没有分隔符 搜缓存 key、分布式锁 key 的构造 两个字段裸拼,靠 ID 长度防碰撞

老炮点评

这个案例我复盘了两遍,因为它不典型。通常踩坑文章里的烂代码一眼就能闻到味,这次的代码不一样------命名、分层、并发意识全在线,读者的第一反应会是"这写得挺好"。

危险就藏在这份挺好里。单机前提下,它确实挺好;多一个实例、超一个容量、漏一个注解,防护各自静默失效,系统连一句报错都不给。

我现在 review 这类代码,先不看写得对不对,先找它依赖的前提:锁的前提是单机,缓存检查的前提是条目不被驱逐,下载安全的前提是链接不外流。前提写没写进注释、有没有监控守着,比实现本身更决定这套东西能不能活到扩容那天。

客户拿到错报告这种事故,复盘会上最常见的结论是"并发没考虑全"。翻到代码底层你会发现,作者考虑得挺全,只是没人把那台迟早要加的第二台 Tomcat 写进他的考虑范围。

如果本文对你有帮助,欢迎:

👍 点赞 | ⭐ 收藏 | 👤 关注 | 💬 留言

你的每一次互动,都是我继续更新的动力🚀


下期预告:《AI 生码率进 KPI 了,18 年老炮的三个保命技能》

文件覆盖的坑填完了。但说句实在话,比起文件串号,更让我焦虑的是另一件事------AI 生码率已经进了 KPI,末位淘汰不是段子。

下期不讲焦虑,讲三个老炮的保命技能:拆需求、验代码、兜底线。结合 AI 重构 1600 行 Controller、AI 审查 20 个坑只认 15 个的实测数据,告诉你老炮在 AI 时代到底靠什么吃饭。

如果你也在担心 AI 抢饭碗,下期这篇得看。

我是老炮,18年Java老兵,仍在一线。关注「Java老炮踩坑录」,真实项目复盘,让你少走弯路。

相关推荐
墨家句子1 小时前
AnythingLLM 搭本地知识库:文档问答不准怎么调
linux·后端
特立独行的猫A1 小时前
Godot 游戏编辑器移植鸿蒙 PC:难度与可行性分析
后端·harmonyos
章鱼哥19711 小时前
DeepSeek Harness 插件开发新手教程
后端·deepseek
特立独行的猫A1 小时前
C++ 异步编程:std::future 与 std::promise 详解(含 RPC 客户端实现实践)
c++·后端
Anymous1 小时前
支付为什么要验两次签?——从渠道验签到服务间信任边界与 RSA2
后端
泡海椒2 小时前
JQuick-Excel 字段映射实战:用 MAPPING 固化 Excel 表头与业务字段契约
xml·java·开发语言·excel
梦幻通灵2 小时前
IDEA 实用快捷键【持续更新】
java·ide·intellij-idea
南归北隐2 小时前
Spring AI Alibaba Graph框架实现Tools工具调用
java·后端·spring·spring ai·spring ai tools
开开心心就好2 小时前
视频模糊怎么修复?免费工具支持批量处理
java·前端·人工智能·智能手机·pdf·excel