Java OBS 对象存储同名文件覆盖排查:文件名唯一性方案与源码分析
发布时间:2026/10/1 16:12:07来源:尧图网络
老炮踩坑录 · 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 CacheString, 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, ListString 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老炮踩坑录」真实项目复盘让你少走弯路。
网站建设高端定制企业官网