title: "分布式文件系统与对象存储:把对象存储当文件系统用,一次 LIST 遍历把元数据节点打满"
tags: [对象存储, 分布式文件系统, ceph, minio, 存储选型]
categories: [后端, 分布式]
我们曾经以为"对象存储就是无限容量的网盘",于是很自然地把文件系统那套习惯搬了过去:用/2024/06/17/order_123.json当路径、用 LIST 接口遍历某天目录做对账、用"复制再删除"实现文件移动。直到对象数涨到 2 亿,一次对账脚本的 LIST 请求把存储集群的元数据节点 CPU 打到 100%,读写全链路抖动 20 分钟。
这篇讲清楚对象存储和分布式文件系统(HDFS/CephFS)在"你能怎么用它"上的根本差异,以及那次事故里我们改掉的 3 个文件系统式坏习惯。
事故现场:对账脚本把存储集群打挂
背景是我们用自建 MinIO 集群(兼容 S3 协议,版本 RELEASE.2023-05 那代)存订单快照 JSON,key 设计成/{date}/{orderId}.json。运营每天凌晨跑对账,逻辑是"列出昨天的所有对象,逐个比对 DB"。代码长这样:
// 错误示范:用 LIST 当目录遍历,前缀下对象越多越慢 ListObjectsV2Request req = new ListObjectsV2Request() .withBucketName("order-snapshot") .withPrefix("2024/06/17/"); // 一天约 600 万个对象 ListObjectsV2Result result; do { result = s3.listObjectsV2(req); for (S3Object obj : result.getObjectSummaries()) { reconcile(obj.getKey()); // 逐个拉下来比对 } req.setContinuationToken(result.getNextContinuationToken()); } while (result.isTruncated());逐行看:第 4 行withPrefix("2024/06/17/")前缀匹配,一天 600 万个对象;第 8 行listObjectsV2每次最多返回 1000 个,isTruncated()为真就翻页。问题在于 S3 的 LIST 是最终一致 + 前缀无索引的——它本质是扫描元数据,前缀越长、对象越多,单次 LIST 越慢。我们那天 600 万对象的目录,LIST 翻了 6000 页,每页平均 80ms,光拉列表就花了 8 分钟,且每页请求都压在元数据节点上,把它的 CPU 顶满,连带正常读写PUT/GET一起变慢。
对象存储的真相:扁平 key 空间,没有目录树
很多人不知道,S3/MinIO 的"目录"是用 key 里包含的/模拟出来的。存储引擎底层是一个扁平的 key-value 空间,/2024/06/17/order_123.json和/a/b/c没有层级关系,那个/只是 key 字符串的一部分。所以:
- 没有"目录"概念:
GET一个对象就是一次 KV 查;LIST prefix是前缀扫描,不是"进目录"。 - rename = copy + delete:你想"把文件从 A 移到 B",在对象存储里是整文件复制再删原 key,大文件代价极高。
- LIST 有频率与一致性限制:S3 对 LIST 限流更狠,且返回可能不是实时全量(最终一致)。
对应的正确做法是:对象元数据别靠 LIST 来管,用 DB 或专用索引。对账时直接查自己库的记录,对象存储只当最终落地点:
// 正确示范:元数据走 DB,对象存储只负责存/取,不做遍历 @Scheduled(cron = "0 0 2 * * ?") public void reconcile() { // 对账从业务库拿昨天的 orderId 列表,而不是 LIST 对象存储 List<String> yesterdayOrders = orderMapper.listByDate(LocalDate.now().minusDays(1)); for (String orderId : yesterdayOrders) { String key = "2024/06/17/" + orderId + ".json"; // 只校验"这个 key 是否真实存在",一次 HEAD,不是遍历 try { s3.getObjectMetadata("order-snapshot", key); } catch (AmazonS3Exception e) { if (e.getStatusCode() == 404) alarm("丢失快照: " + key); } } }逐行看:第 5 行从业务库orderMapper拿订单列表(这是索引该待的地方);第 9 行对每个 key 只发一次getObjectMetadata(HEAD 请求,轻量,不拉内容);第 11-13 行 404 才告警。整次对账从"扫描 600 万对象"变成"按已知订单数发 HEAD",对存储集群几乎零压力。
第三个坑:上传后立刻读,拿到 404
我们把用户头像上传到对象存储后,立刻把 URL 返回给前端,前端马上GET展示——偶发 404。根因是对象存储(尤其自建 MinIO 前面挂了 Nginx/网关 + CDN 边缘)存在边缘缓存与域名解析的短暂不一致。虽然 AWS S3 现在对新对象保证读写一致,但很多自建集群和带 CDN 的场景并不保证。
// 正确示范:上传后给前端读的 URL 加一段"就绪校验",必要时重试 HEAD public String publishAvatar(MultipartFile file, String userId) throws IOException { String key = "avatar/" + userId + ".jpg"; s3.putObject("user-assets", key, file.getInputStream(), new ObjectMetadata() {{ setContentType("image/jpeg"); setContentLength(file.getSize()); }}); // 主动 HEAD 确认已可读,最多重试 3 次,间隔 200ms for (int i = 0; i < 3; i++) { try { s3.getObjectMetadata("user-assets", key); break; // 读到了,说明已生效 } catch (AmazonS3Exception e) { if (e.getStatusCode() == 404) Thread.sleep(200); // 再等等 else throw e; } } return cdnBase + key; // 确认就绪再返回给前端 }逐行看:第 4 行putObject上传;第 8 行起循环getObjectMetadata做 HEAD 校验,第 12 行遇到 404 就 sleep 200ms 再试。只有确认能读到才把 CDN URL 返回前端。这个"写后读校验"把头像 404 率从千分之三降到零。注意:如果前面挂了 CDN,还要在上传后主动调一次 CDN 刷新(refresh/purge),否则边缘节点可能返回旧的 404 缓存。
选型对比:HDFS / CephFS / 对象存储
| 维度 | HDFS | CephFS(文件接口) | 对象存储(S3 兼容) |
|---|---|---|---|
| 接口模型 | 专有 API + POSIX 网关 | POSIX 文件系统 | REST API,扁平 KV |
| 小文件友好度 | 差(NameNode 内存瓶颈) | 中(MDS 元数据压力) | 优(无目录树开销) |
| 跨地域/无限扩容 | 中( Federation 复杂) | 中 | 优(天然水平扩展) |
| 遍历/重命名 | 有专门工具 | 支持(但慢) | 弱(LIST 慢、rename 贵) |
| 适合场景 | 离线大数据、批处理 | 需要 POSIX 的通用存储 | 图片/视频/快照/静态资源 |
我们最终的划分:订单快照、用户头像这类"一次写、少改、海量"的归对象存储;需要频繁随机改、当文件系统挂载的归 CephFS;TB 级离线分析归 HDFS。之前那次是把"该放对象存储但当文件系统遍历"的活干错了。
一个反直觉的成本点:请求费比存储费贵
很多人算对象存储成本只看"每 GB 多少钱",忽略了请求次数计费。我们早期把订单快照按"每笔订单一个对象"存,峰值 600 万笔/天就是 600 万次 PUT + 对账时 600 万次 LIST/HEAD,请求费反超了存储费。后来对 7 天前的冷快照做"合并归档"——把同一天的零散对象在离线任务里拼成少量大对象(比如按 1MB 一个打包),PUT 次数降到原来的 1/200,请求费直接砍掉一个数量级。存储选型不能只比容量单价,请求模式(读写比、对象大小、是否遍历)才是成本的决定项。
复盘真实数字
- 事故当天,对账 LIST 涉及600 万个对象、6000 次翻页,拉列表耗时8 分 12 秒,期间元数据节点 CPU 峰值100%,正常
GET延迟从 30ms 涨到1.8 秒。 - 改成"DB 拿订单 + HEAD 校验"后,对账对存储集群的请求量从 600 万次 LIST 降到约 12 万次 HEAD(仅校验缺失),元数据节点 CPU 峰值回到15%。
- 头像 404 率从千分之三(约每天 900 次)降到0,加的写后读校验平均只多花200-600ms。
- 对象数当时已2.1 亿,单桶;后来按
userId哈希打散到 16 个桶前缀,避免单一前缀热点。
我的取舍判断
第一,对象存储不是文件系统,别用 LIST 当目录遍历、别用 rename 当移动。这是两个最常见的"文件系统式误用",每一个都是元数据节点的噩梦。元数据该放 DB 就放 DB,对象存储只做它擅长的"按 key 存/取"。
第二,key 设计要打散前缀、避免时间序热点。我们最早用/日期/...做前缀,导致每天的对象天然聚在同一个前缀下,LIST 慢、底层分区也容易热点。改成hash(userId) % 16 / userId / ...后,读写压力均匀分摊。
第三,对象存储的"最终一致"在带缓存的链路里会放大。如果你前面有 CDN 或网关,写后立刻读一定要做 HEAD 校验 + CDN 刷新,别想当然认为"PUT 完就能 GET"。
思考题
如果你的系统现在用 LIST 接口做"统计某目录下有多少文件",对象数涨到千万级时,这个操作的延迟和成本会怎么变?你打算怎么改造?欢迎评论区交流。
存储选型的坑往往要等规模上来才暴露。下一篇写混沌工程,讲一次给 Redis 注入超时、结果本地缓存没兜底把 DB 打挂的演练。