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