拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java OBS 对象存储同名文件覆盖排查:文件名唯一性方案与源码分析

Java OBS 对象存储同名文件覆盖排查:文件名唯一性方案与源码分析

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

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

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

👋 欢迎阅读


🏠个人主页:知守观
📘我的专栏:老炮踩坑录
💻当前内容:策略模式

引言

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

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

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

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

先看当年做对了什么

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

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

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

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

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 缓存,传完再回头看缓存里还是不是自己:

// 上传开始:占位 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行]:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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]:

@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]:

obsClient.deleteObject(bucketName, getObjectKey(fileName));

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

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

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

@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); } }); }

怎么改呢?

第一步先堵下载。

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

@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 加分隔符,锁的粒度精确到企业加模型:

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 版本字段,更新时带版本条件:

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 的删除结果:

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老炮踩坑录」,真实项目复盘,让你少走弯路。

返回列表