1. 为什么流式计算离不开checkpoint这个"存档点"
第一次在生产环境跑Spark Streaming任务的人,十有八九会在某个凌晨被报警电话叫醒——任务挂了,重启之后发现之前处理过的数据又被重新算了一遍,或者更糟,某些状态直接丢了,业务方追着问"昨天的统计数字怎么对不上"。这类问题的根源,往往就出在checkpoint机制没有配置好,或者配置了但没理解它到底在干什么。
Spark Streaming的本质是把连续的流数据切成一个个小批次(batch),然后像处理普通RDD一样去处理每个批次。这个设计很巧妙,但它带来一个天然的难题:流是无限的,而计算任务的生命周期是有限的。任务可能因为各种原因中断——代码bug、资源不足、节点故障、甚至运维手动重启。如果没有一种机制能把"算到哪儿了""中间状态是什么"记录下来,那每次重启都等于从零开始,这在生产环境里是不可接受的。
checkpoint就是解决这个问题的核心机制。你可以把它理解成游戏里的存档点:打Boss之前先存个档,万一挂了,读档重来,不用从头打一遍。Spark Streaming的checkpoint做的事情类似,它定期把两类关键信息持久化到可靠的存储上:一类是元数据,包括你定义的DStream操作逻辑、未完成的批次信息、配置参数等;另一类是RDD数据,主要是那些有状态操作产生的中间结果。
这里有个常见的误解需要先澄清:很多人以为checkpoint是"可选优化",配不配都行。实际上,只要你用了updateStateByKey、mapWithState、窗口操作(尤其是带滑动窗口的聚合),或者用了reduceByKeyAndWindow这类跨批次的状态计算,checkpoint就是强制要求的。不配?Driver会直接抛异常告诉你"必须设置checkpoint目录"。这不是Spark在刁难你,而是因为这些操作的状态必须能跨批次恢复,没有checkpoint就没有恢复的依据。
从架构层面看,checkpoint机制解决的是容错和状态持久化两个核心问题。容错方面,Driver端记录了整个计算图的"配方",Executor端的数据通过checkpoint落盘,任务重启后Driver能从checkpoint恢复计算逻辑,Executor能从checkpoint恢复状态数据。状态持久化方面,像实时累计UV、滑动窗口内的TopN这类需求,状态可能跨越几十个甚至上百个批次,不落盘根本没法保证一致性。
我见过不少团队在开发环境跑得好好的,一上生产就出问题,排查半天发现是checkpoint目录指向了本地文件系统。开发环境单节点,本地路径当然能用;生产环境多节点,Driver重启后可能调度到另一台机器,本地路径上的checkpoint文件根本读不到。这种坑后面会详细展开,这里先建立一个认知:checkpoint目录必须是所有节点都能访问的可靠存储,通常是HDFS、S3这类分布式文件系统。
还有一个容易被忽略的点:checkpoint会显著影响性能。每次checkpoint都要把状态数据序列化后写到外部存储,这个IO开销不小。如果checkpoint间隔设得太短,任务大部分时间都在做checkpoint,真正处理数据的时间被压缩;设得太长,故障恢复时需要重算的数据量又太大。这个平衡怎么找,后面会用实际案例来说明。
2. checkpoint到底存了什么:元数据与RDD数据的双轨机制
2.1 元数据checkpoint:Driver的"复活甲"
元数据checkpoint保存的是定义流计算逻辑的所有信息。具体来说,它序列化了StreamingContext的完整状态,包括:
- DStreamGraph:你写的所有转换操作(map、filter、reduceByKey、window等)构成的有向无环图。这个图定义了"数据从哪来、经过哪些处理、输出到哪去"。
- 未完成的批次信息:哪些批次已经生成但还没处理完,它们的Job进度如何。
- 配置参数:batch interval、checkpoint interval、spark配置项等。
- JobScheduler的状态:当前调度到哪个批次了,有哪些等待调度的Job。
这些信息被序列化后写到checkpoint目录下的一个特殊文件中。当Driver崩溃重启时,StreamingContext.getOrCreate(checkpointPath, functionToCreateContext)会先尝试从checkpoint目录读取元数据。如果读到了,就直接恢复StreamingContext,不需要重新执行你代码里定义DStream的那部分逻辑;如果没读到(比如第一次启动),才调用functionToCreateContext去创建新的上下文。
这里有个关键细节:元数据checkpoint的序列化是Java序列化。这意味着你代码里定义的所有函数、闭包、自定义类都必须是可序列化的。我踩过的一个坑是,在map操作里引用了一个外部类的非序列化字段,开发时没报错,一开checkpoint就抛NotSerializableException。排查这类问题有个技巧:把checkpoint目录先设上,在本地模式跑一遍,序列化问题会提前暴露出来。
元数据checkpoint的触发时机和RDD数据checkpoint是独立的。它由ssc.checkpoint(directory)设置后,按照ssc.graph.batchDuration的倍数定期触发,默认是batch interval的10倍(可以通过spark.streaming.checkpoint.interval调整)。注意,元数据checkpoint不依赖于是否有状态操作,只要你调了ssc.checkpoint(),它就会定期保存。
2.2 RDD数据checkpoint:有状态算子的"记忆"
RDD数据checkpoint只针对有状态操作产生的RDD。什么是"有状态"?简单说就是当前批次的输出依赖于之前批次的数据。典型的有:
updateStateByKey:维护每个key的累计状态。mapWithState:更高效的state管理,返回MapWithStateDStream。reduceByKeyAndWindow:窗口聚合,窗口内的数据跨多个批次。window操作后接聚合:窗口本身不产生状态,但窗口内的聚合需要保存中间结果。
这些操作产生的RDD会被标记为需要checkpoint。Spark Streaming在JobScheduler里有一个CheckpointWriter,定期把这些RDD的数据序列化后写到checkpoint目录。序列化格式默认是SequenceFile(Hadoop的二进制格式),存储的是RDD的分区数据和元信息。
RDD数据checkpoint的触发间隔由ssc.checkpoint(directory)设置,但实际写入频率还受spark.streaming.checkpoint.interval影响。默认情况下,它和元数据checkpoint使用同一个间隔。但你可以通过DStream.checkpoint(interval)为特定的DStream单独设置更短或更长的间隔。比如,一个状态更新非常频繁的updateStateByKey,可能需要更短的checkpoint间隔来减少恢复时的重算量;而一个窗口很大的聚合操作,checkpoint间隔可以适当放长,因为状态本身变化不快。
这里要区分一个容易混淆的概念:checkpoint和persist(缓存)不是一回事。persist是把RDD缓存在内存或磁盘上,用于加速同一批次内的多次访问;checkpoint是把RDD写到外部存储,用于跨批次恢复。一个RDD可以同时被persist和checkpoint,但它们的生命周期和用途完全不同。我见过有人用persist(StorageLevel.DISK_ONLY)来代替checkpoint,结果任务重启后状态全丢——因为persist的数据在Executor进程结束后就没了,而checkpoint的数据是写到HDFS这种独立存储上的。
2.3 两者的协作关系
元数据和RDD数据checkpoint是互补的。恢复时,Driver先从元数据checkpoint恢复计算图,然后根据元数据里记录的"哪些批次已完成、哪些RDD已checkpoint",去RDD数据checkpoint目录加载对应的状态数据。如果某个RDD的checkpoint数据不存在(比如刚好在两次checkpoint之间崩溃),Spark会从更早的checkpoint点开始重算,这就是所谓的"至少一次"语义。
用一个实际场景来说明:假设你有一个updateStateByKey在统计每个用户的累计访问次数,checkpoint间隔是10秒,batch interval是1秒。在第100秒时任务崩溃,最近一次RDD checkpoint是在第95秒。恢复时,Driver从元数据checkpoint读到计算图,然后加载第95秒的RDD状态,从第96秒开始重新处理数据。这意味着第96到100秒的数据会被处理两次,如果你的输出端是幂等的(比如写入数据库时用upsert),最终结果是对的;如果不是幂等的(比如直接追加到文件),就会出现重复。
注意:checkpoint机制提供的是"至少一次"(at-least-once)语义,不是"精确一次"(exactly-once)。要实现精确一次,需要输出端配合做幂等处理,或者使用支持事务的输出方式。
3. 配置checkpoint时最容易踩的五个坑
3.1 目录选型:本地路径是开发环境的"甜蜜陷阱"
这是新手最常踩的坑,没有之一。开发环境用ssc.checkpoint("/tmp/spark-checkpoint")跑得好好的,一上生产就各种诡异问题。原因很简单:生产环境是分布式部署,Driver可能在任何一台节点上启动,本地路径/tmp在不同节点上是不同的磁盘。Driver在节点A写的checkpoint,重启后调度到节点B,读不到,于是当成新任务从头开始,状态全丢。
正确的做法是使用所有节点都能访问的共享存储。HDFS是最常见的选择:
ssc.checkpoint("hdfs://namenode:8020/spark/checkpoint/myapp")如果用的是对象存储(比如S3兼容的存储),路径写成s3a://bucket/path。关键原则是:这个路径必须独立于Spark集群的生命周期。集群重启、节点替换都不应该影响checkpoint数据的可读性。
还有一个细节:checkpoint目录的权限。生产环境通常用特定的用户跑Spark任务,如果checkpoint目录的权限不对,Driver写不进去,任务会直接失败。建议在创建目录时就设好权限,或者用spark-submit时指定--principal和--keytab(如果启用了Kerberos)。
3.2 间隔设置:太短伤性能,太长伤恢复
checkpoint间隔的设置是一个权衡。间隔太短,每次checkpoint的IO开销会拖慢整体吞吐;间隔太长,故障恢复时需要重算的数据量大,恢复时间长。
我的一般经验是:checkpoint间隔设为batch interval的5到10倍。比如batch interval是1秒,checkpoint间隔设5到10秒。这个范围的理由是:一方面,5到10个批次的数据量通常不会太大,序列化和写入的耗时可控;另一方面,即使崩溃,最多重算5到10秒的数据,恢复时间可以接受。
但这只是起点,实际值要根据状态大小和集群IO能力调整。如果状态非常大(比如几十GB的累计状态),checkpoint本身就要花好几秒,那间隔可能需要放到20秒甚至更长。反过来,如果状态很小但要求快速恢复,可以缩短到3到5秒。
可以通过Spark UI的Streaming标签页观察checkpoint的实际耗时。如果发现"Checkpoint Duration"经常超过batch interval的50%,说明checkpoint太频繁了,需要放长间隔。
3.3 序列化问题:闭包里的"隐形炸弹"
前面提到过,元数据checkpoint用Java序列化,所有闭包和自定义类必须可序列化。但实际开发中,有些序列化问题很隐蔽。比如:
val config = ConfigFactory.load() // 假设这是一个不可序列化的配置对象 val stream = kafkaStream.map(record => { val value = config.getString("some.key") // 闭包引用了config process(value, record) })这段代码在不开checkpoint时可能正常运行(因为闭包在Driver端执行时能访问config),但一旦开启checkpoint,Driver会尝试序列化整个闭包,包括config,如果config不可序列化,就会抛异常。
解决办法是在闭包内部创建需要的对象,或者把需要的值提取成基本类型:
val someKey = config.getString("some.key") // 提前提取 val stream = kafkaStream.map(record => { process(someKey, record) // 闭包只引用String,可序列化 })另一个常见问题是自定义的State类没有实现Serializable接口。用mapWithState时,状态类型必须可序列化。建议所有用于状态管理的类都显式实现java.io.Serializable,并且给一个serialVersionUID,避免版本不一致导致的反序列化失败。
3.4 代码变更后的checkpoint兼容性
这是生产环境升级时的大坑。假设你有一个跑了三个月的任务,现在要改代码——加了一个filter,或者改了一个聚合逻辑。如果你直接停掉旧任务,用新代码启动,并且指向同一个checkpoint目录,会发生什么?
大概率是反序列化失败。因为checkpoint里保存的是旧代码的DStreamGraph和闭包,新代码的类结构可能已经变了(比如某个类删了一个字段),Java反序列化时会抛InvalidClassException。
正确的做法是:代码逻辑变更时,使用新的checkpoint目录。旧目录保留一段时间作为回滚备份。如果状态需要迁移,得写专门的迁移程序,从旧checkpoint读出状态,转换成新格式,再写到新checkpoint。这个过程比较麻烦,所以设计初期就要考虑状态的向后兼容性——尽量用简单的、稳定的数据结构来存状态,避免频繁改字段。
如果只是改了不影响状态的逻辑(比如输出端的格式),可以尝试复用checkpoint,但要做好测试。最稳妥的方式还是在预发环境验证一遍:用旧checkpoint启动新代码,看能否正常恢复。
3.5 首次启动的"空目录"问题
StreamingContext.getOrCreate的逻辑是:如果checkpoint目录存在且能读到数据,就恢复;否则调用创建函数新建。这里有个边界情况:如果checkpoint目录存在但是空的(比如手动创建了目录但还没写入过checkpoint),getOrCreate会怎么处理?
实测下来,它会尝试读取,读不到有效数据,然后调用创建函数。但有些版本的Spark在这里会抛异常,提示"目录存在但无法恢复"。为了避免这种不确定性,建议首次启动时不要预先创建checkpoint目录,让Spark自己去创建。或者,在代码里加一层判断,捕获异常后走新建逻辑。
还有一个相关的问题:如果任务正常运行了一段时间,checkpoint目录里有了数据,但你手动删除了目录,然后重启任务,会发生什么?任务会当成全新任务启动,之前的状态全丢。所以,千万不要手动清理生产环境的checkpoint目录,除非你明确知道后果。
4. 从零搭建一个带checkpoint的可靠流任务
4.1 环境准备与依赖确认
在开始写代码之前,先确认环境里有哪些组件可用。一个典型的Spark Streaming任务依赖:
- Spark集群(2.x或3.x,API略有差异)
- 分布式存储(HDFS或对象存储)
- 数据源(Kafka、Flume、Socket等)
- 输出端(数据库、文件系统、消息队列等)
以Spark 3.x + Kafka + HDFS为例,build.sbt里需要:
libraryDependencies ++= Seq( "org.apache.spark" %% "spark-core" % "3.3.0" % "provided", "org.apache.spark" %% "spark-streaming" % "3.3.0" % "provided", "org.apache.spark" %% "spark-streaming-kafka-0-10" % "3.3.0" )注意spark-core和spark-streaming用provided,因为集群上已经有这些jar包;Kafka连接器需要打包进去,因为集群默认没有。
4.2 编写可恢复的StreamingContext
核心代码结构如下:
object ReliableStreamingApp { def main(args: Array[String]): Unit = { val checkpointDir = "hdfs://namenode:8020/spark/checkpoint/myapp" def createContext(): StreamingContext = { val conf = new SparkConf() .setAppName("ReliableStreamingApp") .set("spark.streaming.stopGracefullyOnShutdown", "true") val ssc = new StreamingContext(conf, Seconds(1)) ssc.checkpoint(checkpointDir) val kafkaParams = Map( "bootstrap.servers" -> "kafka1:9092,kafka2:9092", "group.id" -> "my-group", "auto.offset.reset" -> "latest", "enable.auto.commit" -> "false" ) val topics = Array("input-topic") val stream = KafkaUtils.createDirectStream[String, String]( ssc, PreferConsistent, Subscribe[String, String](topics, kafkaParams) ) val stateStream = stream .map(_.value()) .flatMap(_.split(" ")) .map(word => (word, 1)) .updateStateByKey[Int]((newValues: Seq[Int], state: Option[Int]) => { Some(state.getOrElse(0) + newValues.sum) }) stateStream.foreachRDD { rdd => rdd.foreachPartition { partition => // 输出逻辑,注意幂等性 } } ssc } val ssc = StreamingContext.getOrCreate(checkpointDir, createContext) ssc.start() ssc.awaitTermination() } }这段代码有几个关键点:
getOrCreate是入口,它决定了是恢复还是新建。createContext里定义了完整的计算逻辑,包括checkpoint目录的设置。updateStateByKey触发了RDD数据checkpoint。stopGracefullyOnShutdown设为true,确保停止时能完成当前批次的处理并做一次checkpoint。
4.3 优雅停止与恢复验证
生产环境停止任务时,不要用kill -9。应该用ssc.stop(stopSparkContext = true, stopGracefully = true),或者通过外部信号触发优雅停止。优雅停止会等待当前批次处理完,并做一次最终的checkpoint,这样下次启动时能精确地从停止点恢复。
验证恢复是否正常,可以这样做:
- 启动任务,让它处理一些数据,观察checkpoint目录下生成了文件。
- 用优雅停止的方式停掉任务。
- 再次启动任务,观察日志里是否有"Restoring from checkpoint"之类的信息。
- 对比停止前后的状态,确认没有丢失或重复计算(如果输出端幂等,重复计算不会影响最终结果)。
我一般会在预发环境做一次完整的"崩溃-恢复"演练:直接kill掉Driver进程,然后重启,看状态是否能从最近的checkpoint恢复。这个演练能暴露很多配置问题,比如checkpoint目录权限、序列化兼容性等。
4.4 监控checkpoint的健康状态
任务跑起来之后,怎么知道checkpoint是否正常工作?几个监控点:
- checkpoint目录的文件数量:正常情况下,元数据checkpoint会生成一个文件(名字类似
checkpoint-<timestamp>),RDD数据checkpoint会生成一个目录(名字类似rdd-<id>)。如果长时间没有新文件,说明checkpoint没触发。 - Spark UI的Streaming页面:有"Checkpoint"相关的指标,包括最近一次checkpoint的耗时、大小等。
- 日志:搜索"Checkpoint"关键字,看是否有异常或警告。
如果发现checkpoint耗时越来越长,可能是状态在膨胀。比如updateStateByKey的key数量持续增长,每个key的状态越来越大,序列化和写入的时间就会增加。这时候需要考虑给状态设置TTL(通过StateSpec的timeout),或者定期清理不再活跃的key。
5. 状态膨胀与恢复缓慢的排查实录
5.1 一个真实的状态膨胀案例
之前遇到过一个任务,用updateStateByKey统计每个设备ID的累计消息数。上线第一周正常,第二周开始checkpoint耗时从200ms涨到3秒,第三周直接涨到15秒,导致批次处理严重延迟。
排查过程:
- 先看Spark UI,发现"Checkpoint Duration"确实在持续增长。
- 去HDFS上看checkpoint目录的大小,发现RDD数据checkpoint的目录从几百MB涨到了几十GB。
- 分析状态数据,发现设备ID的数量从最初的几万涨到了几百万——因为有些设备ID是临时生成的,用完就不再出现,但状态一直保留着。
- 根因确认:
updateStateByKey没有设置状态超时,所有出现过的key都永久保留。
解决方案是改用mapWithState,并设置StateSpec.timeout:
val stateSpec = StateSpec.function((key: String, value: Option[Int], state: State[Int]) => { val newSum = state.getOption().getOrElse(0) + value.getOrElse(0) state.update(newSum) (key, newSum) }).timeout(Seconds(3600)) // 1小时不更新则超时 val stateStream = stream.map(...).mapWithState(stateSpec)mapWithState比updateStateByKey更高效,因为它只处理有更新的key,而且支持超时自动清理。改完之后,checkpoint耗时稳定在500ms以内,状态大小也控制住了。
5.2 恢复缓慢的另一个原因:小文件太多
RDD数据checkpoint默认会为每个分区生成一个文件。如果RDD有几千个分区,每次checkpoint就会生成几千个小文件。时间一长,checkpoint目录下会有几十万个小文件,不仅占用NameNode内存,恢复时读取这些文件也很慢。
解决办法是控制RDD的分区数。在checkpoint之前做一次coalesce或repartition,把分区数降到合理范围(比如几百个)。但要注意,repartition会触发shuffle,有额外开销;coalesce不触发shuffle,但可能造成数据倾斜。需要根据实际数据分布来选择。
另一个办法是调整checkpoint的存储格式。Spark Streaming默认用SequenceFile,可以改成用HDFS的har(Hadoop Archive)来打包小文件,但配置起来比较麻烦。更简单的做法是定期做一次checkpoint目录的清理,把旧的、不再需要的checkpoint文件删掉。但删除时要小心,确保不会删掉正在使用的文件。
5.3 排查checkpoint问题的通用思路
遇到checkpoint相关的问题,我一般按这个顺序排查:
| 排查步骤 | 检查内容 | 常见问题 |
|---|---|---|
| 1. 看日志 | 搜索"Checkpoint"、"Serialization"、"Restore" | 序列化异常、恢复失败 |
| 2. 看目录 | HDFS上checkpoint目录的文件数量、大小、修改时间 | 目录为空、文件不更新、小文件过多 |
| 3. 看UI | Streaming页面的Checkpoint Duration、Batch Processing Time | checkpoint耗时超过batch interval |
| 4. 看状态 | 状态key的数量、单个key的状态大小 | 状态膨胀、key泄漏 |
| 5. 看配置 | checkpoint间隔、batch interval、序列化方式 | 间隔设置不合理、序列化方式不匹配 |
这个顺序的逻辑是:先从最直观的日志和目录入手,快速定位是"没写"还是"写失败";然后看性能指标,判断是"写得太慢"还是"状态太大";最后检查配置,看是否有优化空间。
6. 关于checkpoint的几个进阶思考
6.1 checkpoint与Kafka offset管理的配合
用Kafka DirectStream时,offset的管理和checkpoint是两套独立的机制。checkpoint保存的是Spark内部的状态,offset默认保存在Kafka的__consumer_offsets主题里(如果enable.auto.commit为true),或者由应用自己管理。
这里有个坑:如果checkpoint恢复后,offset没有同步恢复,可能会导致数据重复消费或漏消费。比如,任务在第100个批次崩溃,checkpoint恢复到第95个批次,但Kafka的offset已经提交到了第100个批次。恢复后,Spark从第95个批次开始处理,但Kafka从第100个offset开始读,第96到99批次的数据就丢了。
解决办法是把offset和checkpoint绑定在一起管理。具体做法是:在foreachRDD里手动提交offset,并且把offset信息也写入checkpoint(或者写入外部存储,和状态一起更新)。这样恢复时,offset和状态能保持一致。
更现代的做法是使用Spark 3.x的Structured Streaming,它把offset管理和状态管理统一在了checkpoint机制里,不需要手动处理。但如果是维护老代码,还是得注意这个配合问题。
6.2 checkpoint目录的清理策略
checkpoint目录不能无限增长,需要定期清理。但清理策略要谨慎:
- 元数据checkpoint:通常只保留最近几个版本,旧的可以删。Spark默认会保留最近10个元数据checkpoint文件。
- RDD数据checkpoint:只保留最近一次成功的checkpoint,更早的可以删。因为恢复时只需要最近的状态,更早的用不上。
可以写一个定时任务,定期扫描checkpoint目录,删除超过一定时间(比如7天)的文件。但删除前要确认任务没有在运行,或者删除的是明确不再需要的旧文件。我一般会在任务停止后做清理,避免误删。
6.3 什么时候不该用checkpoint
虽然checkpoint很重要,但也不是所有场景都必须用。如果你的任务满足以下条件,可以考虑不用checkpoint:
- 没有状态操作(没有
updateStateByKey、mapWithState、窗口聚合等)。 - 输出端是幂等的,重复处理不会影响结果。
- 数据源支持重放(比如Kafka可以按offset重新消费)。
这种情况下,任务重启后从头开始消费,虽然会重复处理一些数据,但输出端能保证最终一致性。不过,即使不用checkpoint,也建议设置一个checkpoint目录,因为Spark Streaming在某些情况下(比如Driver重启)还是需要它来恢复计算图。只是RDD数据checkpoint可以不开,减少IO开销。
6.4 从checkpoint机制看流计算的容错设计
checkpoint机制背后反映的是流计算系统的一个核心设计理念:用存储换可靠性。通过把状态定期持久化到外部存储,系统获得了从故障中恢复的能力。这个理念在Flink的checkpoint、Kafka的副本机制里都能看到。
理解了这个理念,就能更好地设计流任务。比如,状态的大小直接决定了checkpoint的开销和恢复的时间,所以设计状态结构时要尽量精简——只存必要的字段,用高效的序列化格式(比如Avro、Protobuf),避免存大对象。再比如,checkpoint的频率要和业务的容错要求匹配——金融交易可能要求秒级恢复,checkpoint间隔就得短;日志统计可以容忍分钟级恢复,间隔就可以放长。
我在实际项目里的体会是,checkpoint配置没有"一刀切"的最优解,必须结合具体的状态大小、集群IO能力、业务容错要求来调。最好的办法是在预发环境做压力测试,模拟崩溃恢复,观察恢复时间和数据一致性,然后根据结果调整参数。这个过程可能比较繁琐,但比起生产环境出问题后再救火,提前做好测试要划算得多。
最后分享一个小技巧:如果发现checkpoint耗时波动很大,可以检查一下HDFS的写入延迟。有时候不是Spark的问题,而是HDFS集群在某个时段负载高,导致写入变慢。这种情况下,可以考虑把checkpoint目录放到独立的存储集群上,避免和其他业务争抢IO。