Flink 写入 Elasticsearch 实战:从 Demo 到生产级深度解析
你将看到一个 Flink 与 Elasticsearch 集成的完整示例,但不止于代码。我们会从一行简单的
addSink出发,逐层剥开 Elasticsearch Sink 的内部机制、一致性保证、动态索引、性能调优以及版本演进,让你写出真正可靠、高性能的写入链路。

目录
- [1. 引言:为什么要把 Flink 数据写入 Elasticsearch](#1. 引言:为什么要把 Flink 数据写入 Elasticsearch)
- [2. 环境准备与依赖](#2. 环境准备与依赖)
- [3. 一个最小的完整示例](#3. 一个最小的完整示例)
- [4. 代码逐层解析](#4. 代码逐层解析)
- [4.1 数据模型与模拟流](#4.1 数据模型与模拟流)
- [4.2 配置 ES 集群地址](#4.2 配置 ES 集群地址)
- [4.3 自定义 SinkFunction------如何把对象变成 HTTP 请求](#4.3 自定义 SinkFunction——如何把对象变成 HTTP 请求)
- [4.4 构建并添加 Sink](#4.4 构建并添加 Sink)
- [5. 深入 Elasticsearch Sink 工作机制](#5. 深入 Elasticsearch Sink 工作机制)
- [5.1 BulkProcessor:异步批量引擎](#5.1 BulkProcessor:异步批量引擎)
- [5.2 容错与一致性保证](#5.2 容错与一致性保证)
- [5.3 错误处理与重试](#5.3 错误处理与重试)
- [6. 生产必备:动态索引与自定义序列化](#6. 生产必备:动态索引与自定义序列化)
- [7. 性能调优清单](#7. 性能调优清单)
- [8. 验证数据写入](#8. 验证数据写入)
- [9. Flink Elasticsearch 连接器的演进与新 Sink API](#9. Flink Elasticsearch 连接器的演进与新 Sink API)
- [10. 常见问题与避坑指南](#10. 常见问题与避坑指南)
- [11. 总结](#11. 总结)
1. 引言:为什么要把 Flink 数据写入 Elasticsearch
在实时数据处理场景里,我们经常需要用 Flink 做流式 ETL、聚合或风控计算,而计算结果需要一个能够支撑全文检索、聚合分析、实时可视化的存储系统。Elasticsearch(ES)凭借其强大的搜索与分析能力,成为最受欢迎的下游之一。
Flink 官方提供了开箱即用的 Elasticsearch 连接器 ,它封装了 ES 的 BulkProcessor,能异步批量地将数据写入 ES,同时与 Flink 的 checkpoint 机制集成,保证数据不丢不重(至少一次)。本篇文章从一段看似简单的 sinkToEs 代码开始,逐层深挖,带你掌握生产环境中 Flink→ES 链路的核心知识。
2. 环境准备与依赖
本文示例基于 Flink 1.13 与 Elasticsearch 6.x 连接器,使用 Scala 2.12。实际项目中你只需在 pom.xml 或 build.sbt 中加入对应依赖:
xml
<!-- Flink Streaming Scala -->
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-streaming-scala_2.12</artifactId>
<version>1.13.6</version>
</dependency>
<!-- Flink Elasticsearch 6 Connector -->
<dependency>
<groupId>org.apache.flink</groupId>
<artifactId>flink-connector-elasticsearch6_2.12</artifactId>
<version>1.13.6</version>
</dependency>
如果你用的是 Elasticsearch 7.x 或 8.x,后面会介绍对应的连接器以及新 Sink API 的写法。
3. 一个最小的完整示例
下面就是那个"麻雀虽小五脏俱全"的示例代码,它从一个本地事件流中读取数据,并写入 ES 的 clicks 索引。
scala
package sink
import java.util
import org.apache.flink.api.common.functions.RuntimeContext
import org.apache.flink.streaming.api.scala._
import org.apache.flink.streaming.connectors.elasticsearch.{ElasticsearchSinkFunction, RequestIndexer}
import org.apache.flink.streaming.connectors.elasticsearch6.ElasticsearchSink
import org.apache.http.HttpHost
import org.elasticsearch.client.Requests
import source.ClickSource
// 事件样例类
case class Event(user: String, url: String, timestamp: Long)
object sinkToEs {
def main(args: Array[String]): Unit = {
// 1. 创建流执行环境
val env = StreamExecutionEnvironment.getExecutionEnvironment
// 2. 模拟事件流(生产环境中一般从 Kafka 等数据源读取)
val data = env.fromElements(
Event("Mary", "./home", 100L),
Event("Sum", "./cart", 500L),
Event("King", "./prod", 1000L),
Event("King", "./root", 200L)
)
// 3. 定义 Elasticsearch 集群主机列表
val hosts = new util.ArrayList[HttpHost]()
hosts.add(new HttpHost("master", 9200))
// 4. 自定义 ElasticsearchSinkFunction,定义如何将 Event 写入 ES
val esFun = new ElasticsearchSinkFunction[Event] {
override def process(
t: Event,
runtimeContext: RuntimeContext,
requestIndexer: RequestIndexer): Unit = {
// 构造一个简单的 Map 作为文档数据
val data = new util.HashMap[String, String]()
data.put(t.user, t.url)
// 创建索引请求
val request = Requests.indexRequest()
.index("clicks") // 索引名称
.source(data) // 文档内容
.`type`("event") // 类型(ES 6 支持,ES 7 已废弃)
// 将请求加入批量发送队列
requestIndexer.add(request)
}
}
// 5. 构建 ElasticsearchSink 并添加到数据流
data.addSink(new ElasticsearchSink.Builder[Event](hosts, esFun).build())
// 6. 启动作业
env.execute("flink-elasticsearch-sink-demo")
}
}
运行后,你便可以通过 curl 命令验证数据:
bash
curl 'localhost:9200/_cat/indices?v'
curl 'localhost:9200/clicks/_search?pretty'
下面,我们把这 6 个步骤逐一拆开,看清它背后的设计原理。
4. 代码逐层解析
4.1 数据模型与模拟流
Event 是一个简单的 Scala 样例类,包含用户、访问 URL 和时间戳。env.fromElements(...) 构造了一个有界流。生产环境中,这里通常是从 Kafka、Pulsar 等消息队列消费的无界流,处理逻辑完全一致。
4.2 配置 ES 集群地址
scala
val hosts = new util.ArrayList[HttpHost]()
hosts.add(new HttpHost("master", 9200))
ElasticsearchSink.Builder 接收一个 HttpHost 列表,允许配置多个节点,实现集群故障转移。如果你的 ES 集群启用了安全认证(用户名/密码或 API Key),可以通过 HttpHost 之外的方式配置,但在这个版本中需要借助 RestClientBuilder 的 setHttpClientConfigCallback 来设置认证(后文会提到)。
4.3 自定义 SinkFunction------如何把对象变成 HTTP 请求
ElasticsearchSinkFunction[Event] 是整个写入逻辑的核心。它的 process 方法会在每条数据到来时被调用,参数含义如下:
- t:当前事件对象。
- runtimeContext:Flink 运行时上下文,可以获取并行度、状态等。
- requestIndexer :请求提交器,只需要把
IndexRequest/DeleteRequest/UpdateRequest加入它即可 。它内部会将请求添加到BulkProcessor,由后台线程批量发送。
示例中我们构造了一个 HashMap 作为文档数据。这种简单的字段映射只适合演示,实际项目中你可能会用 JSON 序列化(FastJSON、Jackson)或者直接使用 XContentBuilder 来构造更复杂的文档。
注意 :
type("event")在 ES 6.x 中仍可使用,但从 ES 7 开始 type 被废弃,只能使用_doc。本文最后会介绍如何适配新版本。
4.4 构建并添加 Sink
scala
data.addSink(new ElasticsearchSink.Builder[Event](hosts, esFun).build())
ElasticsearchSink.Builder 不仅接收 hosts 和 SinkFunction,还提供了一系列链式配置方法:
setBulkFlushMaxActions(1000)------ 每攒够 1000 条请求刷写一次。setBulkFlushMaxSizeMb(5)------ 数据量超过 5MB 时刷写。setBulkFlushInterval(5000)------ 每隔 5 秒刷写一次,防止低流量场景长时间无写入。setBulkFlushBackoff(true)------ 失败时启用指数退避重试。setRestClientFactory(...)------ 自定义 REST 客户端,可用来设置认证头、超时等。
如果什么都不配,默认批量动作数是 1000,最大体积 5MB,间隔 1 秒。看似不太大的默认值其实已能满足多数场景。
5. 深入 Elasticsearch Sink 工作机制
只会调 API 远称不上掌握,只有理解它的运行机制才能在出现性能瓶颈或数据不一致时快速定位问题。
5.1 BulkProcessor:异步批量引擎
Flink 的 ElasticsearchSink 底层复用了 ES 官方客户端 BulkProcessor。每个 Sink 子任务内部会维护一个 BulkProcessor 实例:
add(request)只是把请求放入一个内存队列(List)中;- 当达到 批量大小 、数据量 或 时间间隔 任一阈值时,框架将这些请求打包成一个
BulkRequest并通过 HTTP 异步发送; - 发送结果通过
BulkProcessor.Listener回调处理,成功或失败都会被通知,失败时可以根据配置重试或丢弃。
这种设计最大程度平衡了吞吐量和延迟,并避免了频繁的单条写入导致的大量网络开销。
5.2 容错与一致性保证
Flink 的 checkpoint 机制会保存数据流的当前消费位置(Source 端)和各个算子的状态。但 ElasticsearchSink 并不会在 checkpoint 时同步等待所有 bulk 请求完成(否则会严重阻塞管道)。这意味着:
- 如果 Flink 从 checkpoint 恢复,
BulkProcessor中尚未发出去或者已经发出但未收到 ack 的请求可能被重复发送,造成 ES 中出现重复文档。 - 因此该 Sink 提供的是 至少一次(at-least-once) 语义。如果你的场景不能接受重复,可以在 ES 侧使用唯一键(
_id)实现幂等写入,这样即使重复插入也只会覆盖而不产生冗余。
从 Flink 1.15 开始引入的新 Sink API 能够结合 Elasticsearch 7.x 的事务写入能力,实现了端到端的 精确一次(exactly-once),这部分会在第 9 节展开。
5.3 错误处理与重试
默认情况下,BulkProcessor 在遇到 ES 繁忙或网络抖动时会自动重试(最多重试几次,然后丢弃失败请求)。这种粗暴的方式可能导致静默丢数据。我们可以通过 ActionRequestFailureHandler 自定义失败策略:
scala
val builder = new ElasticsearchSink.Builder[Event](hosts, esFun)
builder.setFailureHandler(new ActionRequestFailureHandler {
override def onFailure(
action: ActionRequest,
failure: Throwable,
restStatusCode: Int,
indexer: RequestIndexer): Unit = {
// 可以记录日志、把失败消息打入死信队列或直接重新加入 indexer
println(s"写入失败: ${failure.getMessage}")
}
})
对于要求更严的业务,建议把失败事件写入外部存储(如 Kafka 死信 Topic),然后人工介入修复。
6. 生产必备:动态索引与自定义序列化
多数日志分析场景都会按日期 分索引,例如 clicks-2023-11-20。在 ElasticsearchSinkFunction 中我们可以轻松实现:
scala
val esFun = new ElasticsearchSinkFunction[Event] {
override def process(t: Event, runtimeContext: RuntimeContext, indexer: RequestIndexer): Unit = {
val indexName = s"clicks-${t.timestamp / 86400000 * 86400000}" // 简单按天切分
val json = s"""{"user":"${t.user}","url":"${t.url}","ts":${t.timestamp}}"""
val request = Requests.indexRequest()
.index(indexName)
.source(json, XContentType.JSON) // 直接传 JSON 字符串
indexer.add(request)
}
}
注意事项:
- 如果索引名经常变化,记得提前在 ES 侧创建索引模板或使用 ILM(Index Lifecycle Management),否则每个新索引都会使用默认设置(分片数、副本数可能不合理)。
- 使用 JSON 字符串时务必避免硬拼接造成的注入风险,建议使用 Jackson 或 fastjson 序列化。
7. 性能调优清单
要让 Flink→ES 链路跑出高吞吐、低延迟的效果,可以从以下几个方面调优:
- 批量刷写策略
setBulkFlushMaxActions:根据文档大小调整,一般 2000~5000 条之间较为合理。setBulkFlushMaxSizeMb:通常设为 5~10MB,过大可能导致单次请求超时。setBulkFlushInterval:建议不低于 1 秒,避免极端情况下大量小包。
- 连接与并发
setRestClientFactory中配置合理的连接超时(Connect Timeout)和 Socket 超时,防止被慢节点拖死。- 对于高流量任务,适当提高 Sink 并行度 ,让多个子任务分别持有自己的
BulkProcessor,充分利用 ES 的多节点写入能力。
- ES 侧优化
- 关闭不需要的
_source、_all字段以减少存储和索引开销。 - 对写入密集型索引将
refresh_interval调大(如 30s),甚至禁用副本(number_of_replicas: 0),写入完成后再调整。 - 使用
auto_generated_id或者业务 ID,避免 ES 计算_id的开销(差别不大,但可关注)。
- 关闭不需要的
- Flink 侧 Checkpoint 间隔
env.enableCheckpointing(60000),如果间隔太短,频繁的 checkpoint 会影响吞吐,建议设置为 1~5 分钟。- 确保 Sink 所在算子链不会被频繁阻塞。
8. 验证数据写入
程序运行后,可使用以下命令快速验证:
bash
# 查看所有索引
curl 'localhost:9200/_cat/indices?v'
# 查询 clicks 索引中的全部文档
curl 'localhost:9200/clicks/_search?pretty'
# 精确查询某个用户
curl 'localhost:9200/clicks/_search?q=user:Mary&pretty'
如果返回的文档包含 Mary、Sum、King 的记录,说明写入成功。若查询为空,检查 Flink 日志,常见原因有:ES 集群不可达、索引不存在(ES 6 默认允许自动创建,但生产环境建议手动创建)、或者字段映射冲突。
9. Flink Elasticsearch 连接器的演进与新 Sink API
你可能会注意到,本文示例使用的是 ElasticsearchSink(基于旧版 SinkFunction API)。随着 Flink 和 Elasticsearch 的版本迭代,连接器也发生了重大变化:
| Flink 版本 | ES 连接器 | 说明 |
|---|---|---|
| 1.13 | flink-connector-elasticsearch6 / 7 |
基于 SinkFunction,仍广泛使用 |
| 1.14 | 引入新 Sink API(预览) | Elasticsearch7SinkBuilder,基于 AsyncSinkBase,支持 exactly-once |
| 1.15+ | 新 Sink API 稳定版 | 使用 Elasticsearch7SinkBuilder,内置事务支持,精确一次语义落地 |
新 Sink API 示例(Flink 1.15 + ES 7):
scala
val sink = new Elasticsearch7SinkBuilder[Event]
.setHosts(new HttpHost("master", 9200))
.setEmitter((event, context, indexer) => {
val json = Map("user" -> event.user, "url" -> event.url)
indexer.add(new IndexRequest("clicks").source(json))
})
.setDeliveryGuarantee(DeliveryGuarantee.EXACTLY_ONCE) // 开启事务
.build()
stream.sinkTo(sink)
新旧差异:
- API 更加简洁,发射逻辑使用 lambda 表达式。
- 一致性语义升级为 精确一次:利用 ES 7 的事务操作,在 checkpoint 时提交事务,恢复时幂等。
- 需要 ES 7.15+ 并开启 ILM 配合事务。
如果你仍在使用 Elasticsearch 6.x,旧连接器依然是可靠选择;若已经在用 7.x 或 8.x,强烈建议升级到新 Sink API,不仅能获得更强的语义保证,也能享受更好的维护与性能。
10. 常见问题与避坑指南
-
写入速度远低于预期
检查 bulk 批量参数是否设置得过小;确认 Sink 并行度是否与 ES 节点数匹配;在 ES 侧查看线程池是否饱和(
_cat/thread_pool?v)。 -
ES 集群经常超时或拒绝请求
调整 ES 的
thread_pool.write.queue_size,并启用客户端的指数退避重试;必要时降低 Flink 的写入速率(可通过反压机制自动调节)。 -
数据重复严重
旧版 Sink 是至少一次语义,这是预期行为。解决方案:在 ES 文档中使用确定的
_id(如订单号)实现幂等写入,避免重复插入不同 ID 的相同内容。 -
索引 mapping 冲突
示例代码直接用动态映射,可能导致字段类型不符合预期(如 URL 被映射为 text 而非 keyword)。生产环境应提前定义索引模板。
-
认证与安全通信
对于开启 Security 的 ES 集群,需要通过
setRestClientFactory注入认证凭据:javabuilder.setRestClientFactory( restClientBuilder -> { restClientBuilder.setDefaultHeaders(new Header[]{ new BasicHeader("Authorization", "Basic " + base64Auth) }); } );
11. 总结
我们从一段入门级代码出发,完整剖析了 Flink 写入 Elasticsearch 的方方面面:
- 如何定义 ElasticsearchSinkFunction 将事件转化为索引请求;
- BulkProcessor 的内部机制及它与 Flink checkpoint 的协作;
- 动态索引、自定义失败处理、性能调优等生产实践;
- 连接器从旧 Sink 到新 Sink API 的演进,以及不同 ES 版本的适配方案。
掌握这些知识后,你不仅能轻松实现"Flink→ES"的实时数据管道,还能从容应对数据量增长、异常恢复和精确一次语义等复杂需求。希望本文能成为你深入 Flink 与 Elasticsearch 集成的实用指南,让你的实时数据处理管道更加健壮高效。
如果对你有帮助,欢迎分享给更多正在折腾实时数据管道的朋友。若有疑问或指正,也请在评论区留言交流。