ARTICLE DETAIL

资讯详情

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

HDFS元数据优化实战:小文件合并与NameNode内存治理

HDFS元数据优化实战:小文件合并与NameNode内存治理 1. 先搞清楚NameNode 为什么会被元数据压垮这标题一看就是被 NameNode 堆内存爆掉、集群卡到心跳超时折磨过的人写出来的。说实话HDFS 的元数据问题几乎是每个上生产的大数据集群都会撞上的墙——文件数一多、小文件一多NameNode 就成了整个集群的“命门”它一倒所有读写、所有计算任务全部瘫痪而且恢复起来极其痛苦。先说个数字这也是长期以来业内判断集群健康度的一个重要经验值NameNode 大约每存储一个文件或目录、数据块对象需要占用 100~150 字节的堆内存具体数值取决于你用的 Hadoop 版本、是否开启 HA、是否启用联邦、以及 RPC 协议细节。早年 CDH 的调优文档里经常提到一个参考量1 亿个文件对象大概就要吃掉 10GB左右 的堆内存。换句话说一个只有几台节点的小集群如果塞了 3000 万个 10KB 级别的小文件光元数据就能吃掉 3~5GB 堆内存。你以为你在做数据存储其实你在拿 NameNode 的内存给文件系统当“账本”每一条文件记录都真实占用内存。再叠加一个致命因素HDFS 的 NameNode 是 Java 进程Java 的堆内存大了以后 Full GC 的时间会越来越长、越来越频繁。Full GC 一旦拉长NameNode 就会进入 SafeMode 或者出现长达几十秒的“假死”状态这时候 DataNode 会误判它挂了开始走 Active-Standby 切换流程。切换本身又是一堆 RPC 和磁盘同步操作过程中整个集群写入直接不可用线上业务像样的都受不了。我自己就遇到过两次因为小文件太多导致的 NameNode 频繁 Full GC一次是凌晨两点被线上告警叫醒另一次是直接在生产集群跑批的时候看到“Lost heartbeats to NameNode”刷屏。所以做 HDFS 元数据优化本质只有两条路一是减少元数据条数二是降低单条元数据的成本。前者靠小文件合并后者靠元数据精简。两条路不是二选一而是双管齐下。这篇文章就把这两条线完整拆开。先说清楚原理再给出实操步骤和参数最后把我踩过的坑和排查办法都整理出来。适合谁看大数据平台运维工程师、HDFS 使用方数据开发、数仓工程师以及准备做集群容量规划的技术负责人。全文基于 Hadoop 2.x/3.x 的常见企业版环境CDH、HDP、开源版都适用原理一致只是监控入口和配置文件路径略有差异。2. 小文件合并真正解决“元数据条数爆炸”的手段2.1 小文件为什么是 HDFS 的天敌小文件的定义没有绝对标准通常指远小于 HDFS 默认 Block 大小128MB的文件比如几 KB 到几 MB 的日志、埋点数据、临时导出文件。HDFS 的设计目标是“大文件、流式访问”一个文件无论多大NameNode 上只存它的元数据而元数据条数与文件个数成正比。小文件出现在 HDFS 里通常有三个来源每个都很典型上游 Flume/Kafka 写入的日志按小时或按天分目录但写入频率高、文件刷盘频繁每个 Flume agent 每分钟生成一个文件一个 200 台节点的日志集群一天就能产生百万级小文件。Hive/Spark 动态分区写入SQL 里开了dynamic partition如果分区粒度太细比如按小时、按省份、按用户标识一次性写出几百上千个分区每个分区里又是几十个几十KB 的 task 输出小文件成堆。业务导出的零碎 CSV运营、算法、模型训练团队经常直接hadoop fs -put几十万个 5KB 的样本文件上来用完也不清理。小文件的危害不只是占 NameNode 内存。它还会连带引发一连串问题读写时 DataNode 和 NameNode 之间的 RPC 次数爆炸。open一个 128MB 的大文件只需要一次元数据请求但打开一万个 13KB 的小文件你需要发起一万次以上的元数据请求RPC 吞吐直接被打满。MapReduce/Spark 计算时每个小文件至少会生成一个 InputSplit每个 Split 至少对应一个 Task。map task 过多会让 YARN 调度器压力倍增任务启动的 JVM 开销甚至比数据处理本身还大跑一个 30 秒的 job 光是启动 5000 个 map 就要花 20 分钟。数据清理和迁移速度慢。distcp迁移集群时文件数多会导致每次 checkpoint 和比对耗时极长迁移进度条卡在一个数字上半天不动。2.2 三个合并思路合并文件本身、合并计算输入、合并写入端针对上面提到的小文件来源合并的思路也要分层不能一篇 code 走天下。第一层对已经存在 HDFS 上的小文件做“事后合并”这是最直接、见效最快的方案。核心工具有两个hadoop archiveHAR和自研 MapReduce/Spark 合并任务。hadoop archive是 Hadoop 自带的归档工具可以把一个目录下的大量小文件打包成一个.har文件。它的优点是不需要搬动原始文件——归档过程是复制一份元数据到归档目录原始文件块还在读取时通过har://协议透明访问。缺点也很明显HAR 格式本质是“只读归档”不能 append不能删除单个文件不能在归档目录下继续写入新文件。这就意味着它适合“冷数据”归档比如存放超过 90 天不再变动的历史日志、历史结果表而不是跑批频繁写入的活跃数据。实际生产里我更推荐用 MR/Spark 写一个通用的“合并任务”按目录扫描小文件读出来拼接写回大文件。合并逻辑不复杂核心就两步扫描指定目录找出所有大小低于阈值的文件按文件所属的“逻辑分组”比如同一天、同一个分区、同一张表把内容拼接成若干个接近 128MB 的大文件同时生成一张“旧文件路径到新文件路径的映射表”供下游数据字典和血缘系统更新。用 Spark 实现时有一个细节要特别注意合并任务的并行度不能盲目调大。如果 10 万个 10KB 文件你开 1000 个 partition 去读每个 task 又要写一个文件那合并完等于没合并。正确的做法是把并发控制在“期望生成的大文件个数”量级比如想生成 200 个大文件就设置 200 个 partition每个 partition 内部用collect_list按目录聚合成一个 RDD再repartition(1)写出——这样能保证一个 partition 只产生一个输出文件。第二层解决“计算时小文件拖垮任务”的问题如果数据已经合并成大文件了但下游跑 SparkSQL 时仍然小文件爆炸那问题往往出在动态分区写出环节而非存储层。Hive 在开启动态分区后每个 reducer 会为每个目标分区打开一个文件写入器。假设有 500 个 reducer、200 个动态分区一次写入就可能产生 10 万个文件。哪怕每个文件里有几 MB 数据NameNode 上照样多出 10 万条元数据。常规解法是开启 Hive 的小文件合并参数让每个 reducer 写完后做一次“按分区合并文件”-- 每个 reducer 最多输出 256MB SET hive.exec.reducers.bytes.per.reducer268435456; -- 开启合并小文件 SET hive.merge.mapfilestrue; SET hive.merge.mapredfilestrue; SET hive.merge.size.small.files.avghreshold16777216; SET hive.merge.smallfiles.avgsize16777216; SET hive.merge.tezfilestrue;如果你是 Spark 引擎就用coalesce和partitionBy配合控制写出的分区文件数量。这句话说起来轻松但实际生产里最大的坑在于很多人只设置了参数没检查执行计划里是否有MergeOperator步骤。如果没生效就要去排查是不是 reducer 的数量设置过高导致合并阶段仍然产生大量碎片文件。第三层从源头减少小文件产生这是最理想但最容易被忽视的一层。很多业务方并不知道自己每天产生的小文件对 NameNode 意味着什么需要平台方主动推动规范合理规划目录层级和分区粒度。日志类数据按天分区就够没必要按小时建分区目录如果必须按小时务必让 Flume 聚合到“小时级”再落 HDFS。设置 Flume 的 HDFS Sink 参数让文件刷新的时机由“时间”和“大小”共同控制而不是一个文件到 10KB 就 flush 一次。对 Kafka-HDFS 的数据链路中间加一层“消息聚合”步骤比如在 Flink 里开窗口攒批攒够 64MB 再写文件。这是最彻底的方式也不影响实时性——窗口延迟设置成 30~60 秒即可。2.3 实战命令hdfs fsck 快速定位小文件分布做合并之前第一件事是搞清楚小文件到底在哪里、有多少。不要凭感觉去合并要先用数据说话。最常用的命令是这个hdfs fsck /path/to/dir -files -blocks -locations | grep .*\.csv.* | awk {print $1, $3}更直观一点我会在脚本里加一个“按文件大小分桶”的统计逻辑比如写个 Shell 脚本拉取目录信息再按小于 1MB、1MB~32MB、大于 32MB 分桶计数。也可以用hadoop fs -du -h看目录整体大小再配合hdfs fsck看文件总数两者一除平均文件大小就出来了。hdfs fsck还有一个容易被忽略但很有用的功能检测“副本不足”的块。小文件合并之后副本数可能因为迁移或节点故障降低fsck会直接提示哪些 block 需要复制这对于排查合完文件后数据是否“完整可用”很有帮助。不过要提醒一句hdfs fsck全目录扫描本身会触发大量的 NameNode 元数据读取操作最好在业务低峰期跑不要在大促或者凌晨跑批高峰期同时执行。3. 元数据精简降低 NameNode 内存压力的另一条腿3.1 fsimage 与 edits元数据是怎么被存下来的很多人做优化只盯着“文件个数”完全忽略了一个事实HDFS 元数据不仅存在于内存中还会周期性落盘为fsimage文件系统镜像和edits编辑日志。fsimage是某一时间点的全量元数据快照edits是两次快照之间的增量操作记录。NameNode 每次重启或者 Active-Standby 切换时都要把最新的fsimage加载到内存再回放edits里的操作记录最终恢复出完整的元数据内存态。理解了这两个文件的机制你就能理解为什么“元数据精简”不只是省内存还能直接影响集群的可用性fsimage体积过大时NameNode 冷启动或故障恢复会变得极其漫长。我见过一个 40GB 的 fsimage加载加回放用了将近 1 小时。这对一个要求 99.9% 可用性的在线集群来说是不可接受的。edits文件过大时Checkpoint 过程会长时间占用 CPU 和磁盘 IO还可能挤压数据节点的正常读写带宽。所以元数据精简要做的就是让 fsimage 瘦身、让内存里的冗余对象变少、让操作日志的累积恢复时间可控。3.2 精简手段一目录层级瘦身与重复路径清理HDFS 的目录树本身就是一棵“大对象树”目录也是 NameNode 内存中的对象。你每建一层目录、每多一个中间路径就多一条 INode 记录。大量冗余目录会让元数据量在“文件数不变”的情况下白白增加 30% 以上。常见的垃圾模式有无实际用途的_temporary、_spark_temporary残留目录。Spark 任务如果异常退出_temporary下的子目录不会被自动清理时间一长整个集群到处都是空的临时文件夹。按业务线、项目、子项目、环境、场景、日期嵌套 6~7 层的“深路径”。定义深目录之前先问一句这个层级真的需要吗目录层数越深不光元数据多hadoop 命令的路径解析也慢。只建目录、不写数据、且保留超过 180 天的“死目录”。这类目录通常被写成定时任务自动建出来的数据没来目录留下来了。清理规则建议用脚本定期巡检。判断一个目录是否为“死目录”可以用文件数、改动时间、占用量三个维度连续 90 天无新增文件、目录内文件均为旧数据就标记为可清理/可归档。还有一个企业版才有的功能如果你的集群用的是 Apache Hadoop 3.2可以尝试开启NameNode 内的软链接 路径合并。但社区版没有就不展开了。3.3 精简手段二调优 name 系统自身的存储参数HDFS 元数据的内存开销并不是固定的它受几个关键参数影响参数一文件块引用方式在 Hadoop 2.x 之后HDFS 引入了erlpExtended Block机制让多个文件可以共享同一个块引用。当你开启dfs.namenode.avoid.read.stale.datanode或者配合erasure coding使用时Block 内存对象的占用会有差异。更直接的优化是开启dfs.namenode.udir.allow.quota和dfs.block.storage.cache但这两个参数不是所有场景都建议动属于有收益但有副作用的调优项需要在测试环境验证后再上生产。参数二目录配额和文件数限制如果你知道某个目录下的小文件只会越来越多不加控制的话NameNode 迟早被拖垮可以考虑给目录设配额。HDFS 支持两种配额# 限制目录下的文件/目录总数为 100000 hdfs dfsadmin -setQuota 100000 /data/biz/raw # 限制目录的存储空间为 1TB hdfs dfsadmin -setSpaceQuota 1T /data/biz/raw配额本身是存储在 NameNode 内存里的一个计数器和上限值对象数量有限可控性好。一旦达到配额业务方再写入就会直接报错倒逼他们自己做小文件合并或清理。这是运维手段里“强制治理”的有效工具。参数三快照与审计日志HDFS 快照Snapshot是一个非常容易被忽视的内存杀手。每次hdfs dfsadmin -allowSnapshot打快照NameNode 就要为快照目录维护一份额外的 INode 引用链快照数量越多、被快照的目录树越大内存消耗就越高。我见到的一个典型案例某个集群的管理员为了做数据保护把/user/hive/warehouse整个仓库打了 20 个快照每个快照都对应百万级文件NameNode 堆内存直接涨了 2GB。后来我们梳理快照策略改成“只对核心结果表目录快照保留最近 7 天”内存立刻降了下去。审计日志同理。Hadoop 默认的audit.log如果不控制会记录所有 RPC 操作长时间积累会严重影响 NameNode 的性能。生产建议开log4j.logger.org.apache.hadoop.hdfs.server.namenode.FSNamesystemWARN级别的降噪或者定期清理 audit 日志文件。3.4 精简手段三NameNode 联邦与目录挂载拆分如果整个集群因为业务捆绑、权限模型复杂导致单一 Namespace 里积累了大量跨业务线的文件即使你把小文件合并做到极致、把目录清理做到极致元数据总量仍然在千万甚至亿级别那就要考虑更宏观的手段HDFS 联邦Federation。联邦的思路是把一个大的命名空间拆成多个独立的命名空间每个由一组独立的 NameNode 管理公共存储层由多个命名空间共享 Block Pool。通过ViewFs把多个挂载点组合成一个“虚拟命名空间”暴露给用户。实现联邦的核心操作新增 NameNode 节点并配置独立的nameservice在客户端通过viewfs://挂载不同 nameservice 的子目录逐步迁移数据使用distcp把/old/path下的数据拷贝到新命名空间对应目录同时把旧目录标记为只读。联邦的好处是立竿见影的但代价也很明显运维复杂度和故障面增大。多个 NameNode 意味着要监控多个进程、多份 fsimage、多套 Checkpoint 流程故障恢复时要考虑多 NameNode 之间的数据一致性。所以联邦一般只在“单 NameNode 内存无法满足需求且短期无法通过合并精简解决”的极端场景才推荐引入不要因为“觉得元数据有点多”就轻率上。4. 实操实录一次完整的“FSImage 瘦身 小文件合并”优化项目4.1 项目背景与优化目标我当时负责的是一个中型生产集群共 42 个 DataNode 节点提供 HDFS 存储服务给数仓团队和算法团队使用。某次巡检发现 NameNode 的堆内存使用率持续逼近 85%Full GC 频率从每天的 3 次飙升到每小时 8 次RPC 处理延迟中位数从 2ms 涨到了 50ms。初步诊断文件总数从半年前的 300 万涨到 1500 万其中小于 1MB 的文件占了 89%/user/hive/warehouse 目录由于动态分区写大量明细表单日新增 12 万个小文件历史快照有 31 个将近一半指向同一个数仓根目录快照占用的元数据开销估算有 800MBfsimage 已经达到 9.6GBNameNode 重启预计需要 25 分钟。优化目标定得很明确文件总数降到 300 万以内NameNode 堆内存下降到 60% 以下fsimage 3GBFull GC 降回每天 3 次以内。4.2 优化步骤拆解第一步先用脚本摸清文件分布写一个 Python 脚本调用hdfs fsck的 JSON 输出按大小区间统计文件数量hdfs fsck / -files -blocks -locations -json /tmp/fsck.json拿到 JSON 之后解析一下numBlocks、fileSize按照 0~128KB、128KB~1MB、1MB~32MB、32MB~128MB、128MB 分桶统计。当时的结果是文件大小区间文件数占比0~128KB47%128KB~1MB42%1MB~32MB8%32MB~128MB2%128MB1%看到这个比例基本确定了治理方向对 1MB 的 89% 文件做全量合并对 1~32MB 的文件按目录场景选择性地合并到至少 60MB 以上。第二步制定“冷热分离 分区合并”策略我们把几百个 HDFS 根目录按业务归属分成三类A 类热数据7 天内活跃只做轻度合并合并后保留 64MB 左右的文件大小避免影响下游读取性能B 类温数据7~90 天活跃做标准化合并目标文件 128MBC 类冷数据90 天以上不活跃直接归档到 HAR或者迁移到独立冷存储目录避免挤占主线命名空间。同时定下原则合并任务只能在凌晨 2:00~5:00 这段缓冲区执行合并前必须在目标目录下发一个“禁止上游写入”的锁文件防止边合并边写入造成数据错乱。第三步用 Spark 写通用合并框架为了适配不同的目录和文件类型我没有硬编码一堆临时脚本而是写了一个通用合并任务核心逻辑大概长这样// Spark 通用合并示例省略部分配置细节 val srcPath args(0) // 输入目录 val dstPath args(1) // 输出目录 val targetSize args(2).toLong * 1024 * 1024 // 目标文件大小默认128MB val df spark.read.format(binaryFile).load(srcPath) .repartition(calculatePartitionNum(df, targetSize)) .write.mode(overwrite) .format(parquet) .save(dstPath) def calculatePartitionNum(df: DataFrame, targetSize: Long): Int { val totalBytes df.agg(sum(length)).collect()(0).getLong(0) Math.max(1, Math.ceil(totalBytes.toDouble / targetSize).toInt) }这只是一个简化版。实际生产里因为文件格式多样性有 CSV、JSON、Parquet、SequenceFile我用了spark.read.format(binaryFile)统一按二进制流读写并在写出时动态计算结果强制生成的目标文件落在 90%~110% 目标大小区间内。这样做的最大好处是不依赖上游数据的 schema任何非结构化文本都能合并。合并完之后要对被合并的原始文件做“重命名备份”而不是直接删除。我当时在源目录旁边建.backup_2021xxxx目录放了 3 天确认下游任务无异常后再用hdfs dfs -rm -skipTrash物理删除降低事故风险。第四步fsimage 与目录精简做完合并后文件数从 1500 万降到 220 万。接下来开始处理“目录和系统层”的冗余清理数仓根目录下 1000 多个空目录_temporary和_spark_temporary占绝大部分把 31 个快照改成 7 个并把快照粒度从“整个 warehouse”细化到“核心结果表目录”对 80 个高风险目录设了setQuota文件数上限根据各自业务量定防止再次爆量。为了验证清洗效果我用 HDFS 自带工具分析一下 fsimagehdfs oiv -p FileDistribution -i /path/to/fsimage -o /tmp/fsimage_analysis这个命令会把 fsimage 解析成文本文件按文件大小分布输出。优化前后对比最明显的是“小于 128KB 的文件数”直接掉了 93%。4.3 优化效果量化对比整个优化周期约 3 周前两周做数据摸底和合并第三周做系统参数与快照清理。最终的量化结果指标优化前优化后NameNode 堆内存使用率85%48%文件总数1500万220万fsimage 大小9.6GB2.2GB平均 RPC 延迟50ms3msFull GC 频率每小时8次每天2次小文件1MB占比89%12%这个收益是非常可观的而且后半段基本不需要再动底层代码全是通过“数据治理”的方式实现的。5. 常见问题与排查技巧实录5.1 NameNode 启动缓慢如何定位是不是 fsimage 太大如果你重启 NameNode发现 SafeMode 迟迟不退出八成是 fsimage 加载 edits 回放耗时过长。可以先在 NameNode 日志里找这么几行Loading fsimage ... took 235 seconds Replaying edits log ... took 432 seconds如果加载时间超过 10 分钟基本可以判定 fsimage 已经大到需要治理了。临时缓解可以调-Xmx内存但这是“止痛药”不是“消炎药”治标不治本。也可以直接查看 fsimage 文件本身大小ls -lh /dfs/nn/current/fsimage_*顺带说一句edits还有一个常见坑由于大量文件合并操作会产生海量的“创建/删除/重命名”操作记录这些操作会累积在 edits 里导致第二次 NameNode 启动时回放 edits 的时间甚至比加载 fsimage 还久。优化阶段如果你在同一时段集中做大量合并操作务必留意 Checkpoint 的频率必要时手动触发一次hdfs dfsadmin -saveNamespace让 edits 及时合并进 fsimage避免 edits 无限膨胀。5.2 合并完成之后下游 SparkSQL 任务反而变慢了听起来很反直觉但这种情况真的会出现。原因是 HDFS 上的文件合并成 128MB 大文件之后SparkSQL 的输入不再“天然均分”某些 Executor 可能只拿到一个超大分区而另一些 Executor 没有数据导致数据倾斜。解法是在 SparkSQL 里设置“写入后二次 Repartition”或在读入阶段显式控制spark.sql.files.maxPartitionBytes。这个参数默认是 128MB它会限制每个分区读取的最大字节数读大文件时会自动拆分。实操建议合并后的文件大小为 128MBspark.sql.files.maxPartitionBytes建议也设为 128MB 或者稍微小于目标文件大小这样每个文件可以拆成 1 个分区既保证任务并行度又不至于生成过多小任务。5.3 HAR 归档后文件怎么读读不了怎么办hadoop archive出来的.har文件下游直接用 Spark 读会失败因为 Spark 默认的文件系统不认har://协议。解决办法有两个项目内集成hadoop-archives包在客户端配置fs.har.implorg.apache.hadoop.hdfs.HarFileSystem更简单的方案是归档不影响原始文件直接用原始路径读即可。注意HAR 归档的“合并”并不真正减少底层 Block 数量——每个原始文件仍然有自己的 Block只是元数据被打包到一个“归档元数据文件”里NameNode 内存中的 INode 数量会减少但 DataNode 上的块数量没有变。所以 HAR 适合“清理命名空间”不适合“减少存储占用的 RPC 开销”。使用场景务必选对。5.4 小文件合并导致在线数据重复我自己踩过的坑某个日志表是“追加写”模式合并任务把 3 天前的小文件合并成一个大文件后上游 Flume 还在继续往原来的目录追加新文件结果合并任务扫描时把“正在写入但尚未刷盘的临时文件”也读了进来合并后的结果里出现了重复数据。解法很简单合并任务读取目录时加上“仅处理修改时间在 N 天前的文件”的过滤条件或者由上游提供“当日已关闭”的目录标识。如果数据不是幂等的合并前务必和业务方确认写入窗口的关闭时间千万别硬着头皮扫描整个目录。5.5 缓存目录和 Trash 目录的坑hdfs dfs -rm删除的文件默认会进/user/xxx/.Trash小文件合并时如果你用rm删除几百万个旧文件Trash 里会积累海量元数据等于白做了合并。所以在批量合并场景删除旧文件时建议直接加-skipTrash或者定期清理 Trashhdfs dfs -expunge另外要注意.Trash本身也占 NameNode 元数据。如果开启了fs.trash.interval建议通过监控定期强制清理避免“账没消、债还在”的情况。6. 经验总结稳住 NameNode 的关键是“监控 治理机制”最后说几句实在话。HDFS 元数据优化不是一个“做一次就永逸”的项目。合并再干净如果上游的业务方还是源源不断地产生小文件三个月后一切会复胖。我在这个项目里还做了一件后来被验证非常有效的事把“小文件率”和“NameNode 内存使用率”接入监控告警平台每天定时跑一次hdfs fsck的分桶统计一旦小文件占比超过阈值自动告警。另外在 YARN 队列层面限制“单用户每日小文件写入配额”万一业务方任性系统会自动拒绝超出配额的写入强制他们走合并流程。再提一个小技巧给文件数很多的目录设置“目录文件数配额”之后DBA 和业务侧一开始会抱怨写不进去但过两周他们就会自己研究怎么合并了。治理的本质是“用机制逼着所有人遵守规则”而不是一个人替所有人擦屁股。最后分享一个我在调优完成后一直保留的习惯每次大版本升级或核心参数变更后手动触发一次dfsadmin -saveNamespace观察新的 fsimage 大小是否符合预期。这个习惯救过我很多次——有一次就是升级后 NameNode 堆内存异常增长我一查 fsimage发现某些新版本默认开了 snapshot 没告诉我。这个内容后续还可以扩展比如结合官网的 HDFS Erasure Coding 降低副本开销、通过 Ozone 替换 HDFS 存储层等。但对绝大多数现网集群来说把“小文件合并 元数据精简”这两件基本功做到位NameNode 就已经能稳稳站住了。
返回列表