ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spark Structured Streaming动态写入Elasticsearch:foreachBatch多索引路由实践

Spark Structured Streaming动态写入Elasticsearch:foreachBatch多索引路由实践

做实时入ES的同学应该都有类似的经历:Kafka里的数据明明已经解析好了,结果发现Structured Streaming没有一个官方接口能让你“根据数据内容决定写进哪个Index”。网上搜来搜去,基本都是让你把es.index写死,一个流只进一个索引。真到了多业务类型、按天分索引、多租户隔离的场景,这个死办法就直接卡壳了。

这篇文章是我在自己负责的一个日志实时入ES项目里,把“基于数据动态写入ES Index”这条路完整踩通之后的一次复盘总结。内容围绕三个问题展开:为什么动态Index这么难搞、最常用的实现套路是什么、实际落地的调优和避坑点有哪些。适合正在用Spark Structured Streaming做实时ETL、又不想为每种索引单独起一个作业的同学。

1. 先搞清楚“动态写Index”到底难在哪

1.1 需求拆解:业务要的其实是“按数据拆批”

先说需求本身。表面上“动态写ES Index”只是一句话,拆开看大概有这么几类场景:

  • 一个Kafka Topic里混了多种业务类型,比如用户行为、支付流水、风控日志,每种业务希望落到独立的Index里。
  • 数据量很大,希望按天或者按小时滚动索引,方便后续做冷热数据分离、索引关闭和删除,避免单个索引太大导致的查询性能下降。
  • 多租户系统里,不同的租户数据要物理隔离,索引名里带上租户标识。
  • 上游数据源的路由字段并不固定,索引名要根据某几个字段组合而成。

这些需求有一个共同点:索引名是行级概念,每一条数据都应该能根据自身字段映射到一个目标索引。而ES官方的Spark连接器里,es.index参数是作业级或者DataFrame级的静态配置,它不认识你的数据字段。这就出现了结构性冲突。

1.2 为什么不能指望官方接口直接搞定

ES Spark连接器对Structured Streaming的支持本身就是个“半成品”。它底层走的是批量写ES的API,虽然你在Structured Streaming里也能用:

df.writeStream.format("es").option("es.index", "my_index").start()

但这里有个很要命的问题:es.index的取值只能是一个字符串字面量。你没法写成:

.option("es.index", col("es_index")) // 这是行不通的

连接器压根不会解析这种表达式。就算你强行在option里传一个动态值,实际执行时所有的数据还是会写入同一个索引。换句话说,直接一条writeStream写到底的动态索引能力,官方暂时没有给。

那有人会问,既然一行流式写法不行,我能不能先把数据按照索引字段分区,再用批量接口写?这个思路方向对了。分区解决了“数据按索引聚在一起”的问题,剩下的只是如何把每个分区的数据分别写到对应索引里。

1.3 三种实现路线对比

我前期评估过几种路线,先说结论,再说为什么。

路线一:foreachBatch内按索引字段分组,循环调用ES批量写入。这是目前最通用、最可控的方案,代码量不大,可维护性也OK。它的核心就是把“动态索引”问题转化成“在一个微批里拆分静态索引”,ES连接器的能力完全够用。

路线二:全部数据写一个“宽表索引”,用ES别名和查询过滤隔离数据。这个方案不用动态索引了,但从根本上回避了需求。数据量一大,单个物理索引会很难维护,查询性能也会退化。适合数据量小、索引数量不敏感的场景,否则还是老老实实做动态索引。

路线三:先把所有数据写到一个临时索引,然后用ES的Reindex接口按条件转发到目标索引。这个方案引入了额外的ES内部链路,会增加数据延迟和ES集群压力,而且跨集群场景基本没法用。除非你有无法绕开的下游约束,否则不建议把它作为首选。

综合比较下来,路线一是最稳的。我最终落地的方案也以它为主体,配合一些编码技巧把写入并发和资源控制好。

2. 核心实现:foreachBatch内按索引字段拆分组再写ES

2.1 构造动态索引列:解析字段、生成命名

先写一个示例场景。假设Kafka里消费到的消息是JSON字符串,包含biz_type(业务类型)和event_time(事件时间),目标索引格式是app_log_{biz_type}_{yyyyMMdd}。

这里第一步是解析字段,并生成一个单独的索引列:

import org.apache.spark.sql.functions._ val rawDF = spark.readStream .format("kafka") .option("kafka.bootstrap.servers", "node1:9092,node2:9092") .option("subscribe", "app_log_raw") .option("startingOffsets", "latest") .load() val parsedDF = rawDF .selectExpr("CAST(value AS STRING) AS message") .select( get_json_object(col("message"), "$.biz_type").as("biz_type"), get_json_object(col("message"), "$.event_time").as("event_time_str") ) .withColumn("event_time", to_timestamp(col("event_time_str"), "yyyy-MM-dd'T'HH:mm:ss")) .withColumn("event_date", to_date(col("event_time"))) .withColumn("es_index", concat_ws("_", lit("app_log"), col("biz_type"), date_format(col("event_date"), "yyyyMMdd")))

这里有个细节想提醒一下:event_date建议先在代码里按照业务时区算好,不要直接用UTC时间。日期边界差8小时这件事,等索引写完之后再发现就晚了。下面这段是我在项目里实际用过的时区处理:

.withColumn("event_date", to_date(from_utc_timestamp(col("event_time"), "Asia/Shanghai")))

2.2 foreachBatch分组写入:完整可跑的代码骨架

有了索引列之后,就到了最关键的一步:在foreachBatch里按索引分组,然后逐个写入ES。这里给出Scala版本的完整骨架:

import org.apache.spark.sql.DataFrame def writeToESByIndex(batchDF: DataFrame, batchId: Long): Unit = { // 1. 从当前微批中提取出去重后的索引列表 val indexList = batchDF .select("es_index") .distinct() .collect() .map(_.getString(0)) .toList // 2. 逐个索引写入 indexList.foreach { idx => val toWrite = batchDF.filter(col("es_index") === idx).drop("es_index") toWrite.write .format("es") .option("es.index", idx) .option("es.mapping.id", "doc_id") // 幂等写入的关键 .option("es.batch.size.entries", "3000") .option("es.batch.size.bytes", "10mb") .option("es.batch.write.retry.count", "5") .option("es.batch.write.retry.wait", "10s") .mode("append") .save() } } parsedDF.writeStream .foreachBatch(writeToESByIndex _) .outputMode("append") .trigger(Trigger.ProcessingTime("30 seconds")) .option("checkpointLocation", "hdfs:///streaming/checkpoint/app_log_es") .start() .awaitTermination()

这段代码的核心逻辑不复杂:先从微批里取出有哪些索引需要写,再对每个索引过滤出对应数据,调用ES批量写入。foreachBatch的本质是让一个流式query在每次微批时执行一个自定义函数,写ES这个动作就藏在这个函数里。

有两点需要解释清楚:

第一,collect()会把索引列表拉到driver端。如果你的索引数量有几千个,这里会是一个性能瓶颈,所以索引粒度控制很重要,后面调优章节会展开。

第二,.drop("es_index")这步不能省。es_index只是我们用来路由的中间字段,写在ES文档里没有意义,而且会污染数据。同样的道理,doc_id字段最好是在上游生成的一个唯一ID,比如UUID、订单号、事件ID,没有唯一ID的话ES写入就很难幂等。

如果你用的是PySpark,逻辑完全一样,只是语法上略有差异:

def write_to_es_by_index(batch_df, batch_id): from pyspark.sql import functions as F index_list = [row.es_index for row in batch_df.select("es_index").distinct().collect()] for idx in index_list: to_write = batch_df.filter(F.col("es_index") == idx).drop("es_index") (to_write.write.format("es") .option("es.index", idx) .option("es.mapping.id", "doc_id") .option("es.batch.size.entries", "3000") .mode("append") .save())

2.3 为什么必须设置es.mapping.id

这条是我踩过坑之后才彻底理解的。ES Spark连接器如果不指定es.mapping.id,写入时会自动生成一个随机文档ID。这个行为在批量一次性写入的时候问题不大,但放在流式场景下就非常危险。

Structured Streaming的checkpoint只能保证偏移量的进度,没办法保证sink一定只写一次。一旦某个批次写入半途失败,任务重启后会从上次记录偏移量的位置重放数据,ES里就会出现重复文档。设置es.mapping.id之后,重复写入同一ID时会走覆盖逻辑,至少能做到幂等写入。

这个幂等有个前提:ID本身必须是稳定的。如果你上游给的ID是随机生成的,每次重放都不一样,那幂等就没意义了。我见过有人直接拿当前时间戳当ID,结果写出来的数据全是重复的,这个坑要特别注意。

2.4 重试参数怎么配才合理

ES写入失败通常不是立刻发生的,连接器内部有批量请求的机制。合理配置重试参数能有效避免瞬时抖动导致整个批次失败:

.option("es.batch.size.entries", "3000") // 按条数控制批量大小 .option("es.batch.size.bytes", "10mb") // 按大小控制批量大小 .option("es.batch.write.retry.count", "5") // 批量请求失败重试次数 .option("es.batch.write.retry.wait", "10s") // 重试间隔,指数退避

重试次数不建议太大。流式作业和离线作业不一样,重试会拖长整个微批的处理时间,可能导致后续批次堆积。重试间隔默认是10秒,实际配置时可以根据ES集群的响应情况调整,一般5到15秒是常见范围。

另外还有一个参数值得关注:es.http.timeout。如果ES节点负载高,请求可能会长时间挂起,建议显式设置一个合理超时,不要让任务无限等下去。

3. 落地调优:攒批、索引粒度与ES端配置

3.1 攒批策略:别让每一次微批都打爆ES

动态索引写ES最怕的其实是“太碎”。Spark默认的微批频率如果设置成几秒一次,每次批里可能只有几千条数据,但索引类型有几十种,算下来每个索引可能只有几百条记录。也就是说,每次微批会发起几十个小的ES批量请求,虽然数据量不大,但请求次数非常密集,ES的线程池会扛不住。

我自己的调法是用两套参数配合控制攒批节奏:

.trigger(Trigger.ProcessingTime("30 seconds")) .option("maxOffsetsPerTrigger", "200000")

Trigger.ProcessingTime("30 seconds")控制的是最低触发间隔,maxOffsetsPerTrigger控制的是每批次最多读取的Kafka消息条数。两者的实际效果分情况:

  • 数据量特别大时,以条数上限为准,30秒内优先拉满指定条数就触发。
  • 数据量平稳且不大时,以时间间隔为准,每30秒出一个批次。

这样做能保证一个批次里积累足够多的数据再写ES,避免频繁的小批量请求。我实际项目的经验是,单批数据量在10万到50万条时,ES写入吞吐和集群稳定性都比较理想。如果你的ES集群写入压力已经很大,可以把间隔再调到60秒,效果会更明显。

3.2 控制索引粒度:按天是最稳妥的起步配置

动态索引虽然灵活,但索引粒度太细是真的会坑到自己。假设你按“业务类型+小时”来生成索引,一天的Index数量就是业务种类数乘以24。如果业务有50种,一天就是1200个索引,一个月就是3万多个。

ES的索引是要吃内存和元数据成本的。索引多了以后,集群master节点要维护的元数据数量暴涨,分片调度压力加大,每次查询还需要判断涉及哪些索引。索引碎片化带来的性能劣化,往往比“单索引过大”更让人头疼。

所以我的建议是:

  • 先按“天”做索引粒度,数据量真的到了单索引几十GB以上,再考虑按小时拆分。
  • 合并索引粒度的方式很简单,就是改一下date_format的格式串,不需要改动整体代码结构。
  • 如果业务上有多个维度,优先保留“业务线”,把“日期”作为第二级,不要所有维度都塞进索引名。

我最终落地的索引格式是app_log_{biz_type}_{yyyyMMdd},物理索引总数稳定在“业务线数量乘以保留天数”,集群压力完全可控。

3.3 索引命名清洗:一条隐藏的硬规则

ES索引名有一套命名规则,很容易被忽略:不能包含大写字母,不能包含\ / * ? " < > |这些特殊字符,不能以-、_、+开头。Kafka里的业务类型字段往往是自由的,比如“UserAction”“user action”“USER”这种,直接拼到索引名里,写入ES必报错。

我专门写了一个清洗的UDF来处理:

import org.apache.spark.sql.expressions.UserDefinedFunction def cleanIndexName(prefix: String, bizType: String, dateStr: String): String = { val cleaned = bizType.toLowerCase .replaceAll("[\\\\/*?\"<>|\\s]", "_") .replaceAll("^[-_+]+", "") if (cleaned.isEmpty) s"${prefix}_unknown_${dateStr}" else s"${prefix}_${cleaned}_${dateStr}" } val cleanIndexUdf: UserDefinedFunction = udf(cleanIndexName _)

这个函数做的事情不多,但每一条都对应一个坑:

  • 强制转小写,因为ES索引名不支持大写。
  • 把特殊字符和空格替换成下划线,避免命名冲突。
  • 去掉开头可能出现的非法前缀,防止索引名不合法。
  • 清洗后如果为空,给一个兜底的unknown,绝不让空字符串落到ES。

字段过滤完了之后再进es_index列,后面写入就不会再报非法索引名的错了。

3.4 ES端配置:索引模板和自动创建

动态索引意味着ES集群里会不断出现新的物理索引。如果这些索引都是靠写入时“自动创建”,你会面临两个问题:

第一,自动创建的索引使用的是ES默认的分片数和副本数设置,默认主分片数在某些版本是1,但如果不小心继承了一些历史模板,可能建出大量分片数不合理的索引。

第二,自动创建的索引没有mapping定义,动态映射可能导致字段类型识别错误,比如时间字段被映射成text,后续聚合查询就麻烦了。

我的做法是在ES端提前定义一个覆盖所有动态索引的模板:

PUT _index_template/app_log_template { "index_patterns": ["app_log_*"], "template": { "settings": { "number_of_shards": 3, "number_of_replicas": 1, "refresh_interval": "10s" }, "mappings": { "properties": { "biz_type": { "type": "keyword" }, "event_time": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss||strict_date_optional_time" }, "doc_id": { "type": "keyword" } } } } }

index_patterns匹配所有app_log_*开头的索引,新索引第一次写入时会自动套用这个模板设置。这样做的好处是,不管你后面新增多少个业务线,ES端的配置都是自适应的,不需要每次人工建索引。

如果你的ES集群开了action.auto_create_index的限制,还要确保白名单里包含匹配这个前缀,否则写入时会被拒绝。

3.5 资源与并发控制:避免foreachBatch里的常见资源浪费

foreachBatch里按索引循环写ES,有一个隐含的资源浪费点:每轮循环对当前索引的数据调用一次save(),底层会对这个DataFrame触发一个Spark job。如果索引数量多,每个job里的task数量又不少,整体调度开销会很大。

建议做两件事:

第一,在过滤后对写ES的DataFrame做一个按需的repartition。比如当前索引的数据只有5万条,默认分区数是200,会产生大量空task,白白占用executor资源。按ES批量请求的规模控制分区数,比如每个分区对应1万到5万条数据,写入效率会明显提升。

第二,多个索引的写入循环之间改成并行。这里要小心,并行度过高会直接打满ES连接池。我一般用固定线程池把并发控制在2到4个索引同时写,不再往上加。ES集群不是写入连接的瓶颈,批量请求的频率才是。

import java.util.concurrent.Executors val pool = Executors.newFixedThreadPool(4) indexList.foreach { idx => pool.submit(new Runnable { override def run(): Unit = { val toWrite = batchDF.filter(col("es_index") === idx).drop("es_index") .repartition(2) toWrite.write.format("es") .option("es.index", idx) .option("es.mapping.id", "doc_id") .mode("append") .save() } }) } pool.shutdown() while (!pool.awaitTermination(60, TimeUnit.SECONDS)) {}

注意线程池里的batchDF被多个线程同时使用时,filter操作不会互相影响,因为Spark的DataFrame是惰性求值的。但driver端同时触发多个Spark job,内存压力会有所增加,所以并发数一定不要拍脑袋设大。

4. 常见问题与排查实录

4.1 问题速查表

问题现象可能原因处理建议
所有数据写入同一个索引在writeStream的option里直接传了列名或表达式必须走foreachBatch,在批内按索引字段分组再写
写入报非法索引名索引名含大写、空格、特殊字符增加清洗UDF,转小写并替换非法字符
数据重复未设置es.mapping.id或ID不稳定配置稳定唯一ID,保证幂等写入
ES返回429/节点响应慢批量请求过密、单批过大调大攒批间隔,调整es.batch.size.entries和重试参数
driver端OOMcollect()拉回太多索引收敛索引粒度,减少批内索引种类
新索引分片数不符合预期缺少索引模板或模板被跳过提前配置_index_template,匹配索引前缀
checkpoint启动时报错同一个checkpoint目录被多个query复用每次新query用新目录,不要共用
时区原因导致索引日期错一天直接用UTC时间生成日期用from_utc_timestamp转换到业务时区

4.2 印象深刻的一个坑:以为动态Index能用表达式直接传

最开始接触这个需求时,我的第一反应比较简单:既然ES连接器不支持动态索引,那在writeStream的option里把索引名传成一个字符串变量总可以吧?代码如下:

df.writeStream .format("es") .option("es.index", dynamicIndexName) // 运行时动态算出来的字符串 .start()

结果代码不报错,但数据全写到了同一个索引里。原因后来才搞清楚:writeStream在启动时就会把option里的值固化下来,整个streaming query期间不会重新计算。也就是说,哪怕你在代码里动态计算了索引名,也只是“启动时计算一次”,后续所有微批都会写到那个固定索引。

这件事让我明白了一个本质:Structured Streaming的option是查询级别静态配置,跟批量DataFrame的action完全不同。想每条数据都动态路由,就必须把“写ES”这个动作搬进foreachBatch内部,每次微批重新决定目标索引。

4.3 第二个坑:事件时间没归一化,跨日索引错乱

日志系统里经常处理跨时区数据。我们的业务集群时间统一用UTC,但业务侧要求索引按北京时间切分。一开始没做时区转换,直接用event_time取日期拼索引。结果每天晚上8点之后的数据,索引日期都比实际业务日期早一天。这个问题不好排查,因为单看每条数据的event_time是对的,只有翻ES索引统计时才发现对不上。

后来统一在生成es_index之前,先把UTC时间转换成Asia/Shanghai时区再取日期,问题立刻消失了。这一点在实际项目里非常建议直接写死在代码里,不要指望上游给你传对时区。

4.4 第三个坑:动态索引太多,collect把driver端打挂了

有一段时间我们把索引粒度做到了“业务类型精确值+小时”。业务类型没有做任何归一化,导致一个微批里出现了上百个索引。distinct().collect()之后,driver端内存直接告警,加上后续每个索引都要触发一次Spark job,整个任务跑得越来越慢。

那次的教训让我彻底调整了方案:业务类型先做归一化映射,把几十种零散值收敛成几个大类;日期从小时改成天。调整之后,批内索引数从上百个降到了个位数,任务稳定了很多。动态索引不是越大越细越好,能合并的一定要合并。

5. 写在最后的个人体会

项目做完之后回头看,动态写入ES Index这件事本质上没有特别高深的技术,核心思路就是把“流的问题”降维成“批的问题”:Structured Streaming的foreachBatch负责拆流成批,批内的动态索引路由用简单的分组循环完成。难的地方全在细节上,比如时区归一化、索引命名清洗、幂等ID、索引粒度控制。

我个人现在做类似需求时,会先问自己三个问题:业务上能不能接受按天分索引?数据里有没有一个天然稳定的唯一ID?ES集群允不允许自动创建索引并套模板?这三个问题都有答案之后,代码写起来基本一次成型。后续如果数据量继续涨,我可能会进一步把ES写入这块改成自定义sink,但那是另一个话题了。至少在目前这个阶段,foreachBatch分组写ES的方案已经足够稳,也足够容易维护。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进