首页 / 资讯中心 / 文章详情

Scala+Flink+Kafka+ES实时数据管道:从环境搭建到生产部署全解析

Scala+Flink+Kafka+ES实时数据管道:从环境搭建到生产部署全解析 ★ FEATURED ARTICLE
1. 为什么选 Scala Flink Kafka ES 这套组合1.1 这套技术栈到底解决什么问题先把场景说清楚。你有一堆数据源源不断地涌进来可能是用户行为日志、订单流水、设备上报的状态也可能是某个业务系统吐出来的事件流。这些数据先落到 Kafka 里因为 Kafka 扛得住高吞吐、能削峰、还能让多个下游各自消费互不干扰。然后你需要一个能持续跑、能容错、能处理乱序和延迟的计算引擎把 Kafka 里的原始数据做清洗、转换、聚合最后写进 Elasticsearch让业务方能在 Kibana 或者自研的查询界面上秒级检索。Flink 在这个链路里扮演的就是那个永不停机的加工车间。它跟 Spark Streaming 最大的区别在于Flink 是真正的流处理来一条处理一条延迟能压到毫秒级而 Spark Streaming 本质是微批最小批次也得几百毫秒起步。对于实时性要求高的场景比如风控、实时监控大盘Flink 的优势就很明显了。Scala 则是 Flink 的母语。Flink 本身是用 Java 写的但它的 API 设计大量借鉴了 Scala 的函数式特性很多算子用 Scala 写出来比 Java 简洁一大截。比如一个 map 操作Java 里你得写匿名内部类或者 LambdaScala 里一行就搞定。而且 Flink 的 Table API 和 DataStream API 在 Scala 下的类型推断更顺滑写起来没那么啰嗦。Elasticsearch 作为最终存储核心价值在于它的倒排索引和近实时搜索能力。你写进去的数据默认 1 秒后就能被搜到这个近实时对于大部分业务场景足够了。而且 ES 的水平扩展很简单加节点就能扛更多数据和查询。1.2 为什么不用别的方案有人会问我直接用 Kafka Connect 把数据从 Kafka 搬到 ES 不行吗行但 Kafka Connect 只能做简单的字段映射和过滤稍微复杂点的转换、聚合、窗口计算它就力不从心了。比如你要算每个用户过去 5 分钟的点击次数Kafka Connect 做不了必须上 Flink。那用 Logstash 呢Logstash 的吞吐量跟 Flink 不是一个量级而且它是基于 JVM 的重量级进程资源消耗大扩展性也一般。Flink 天生就是分布式的可以跑在几十上百个节点的集群上。还有人问我直接写个 Java 程序消费 Kafka 然后调 ES 的 REST API 写进去行不行小数据量可以但你要自己处理容错、Exactly-Once 语义、背压、检查点这些轮子造起来够你喝一壶的。Flink 把这些都封装好了你只需要关注业务逻辑。1.3 这套组合的典型应用场景实时日志分析应用日志打到 KafkaFlink 做解析和富化写入 ES 供运维排查问题。实时监控告警指标数据流经 Flink 做阈值判断和窗口聚合异常结果写 ES 并在前端展示。用户行为分析埋点数据进 KafkaFlink 做会话切分和路径分析结果存 ES 供产品团队查询。订单实时大屏订单事件流经 Flink 做实时统计写入 ES 后由大屏轮询展示。这些场景的共同点是数据持续不断、要求低延迟、需要复杂的流式计算逻辑、最终要支持灵活的检索。2. 环境准备与依赖选型2.1 版本匹配这件事比你想的重要Flink、Kafka、ES 三者的版本兼容性是个大坑。我见过太多人因为版本不匹配跑起来各种 ClassNotFoundException 或者序列化错误。下面这张表是我实测下来比较稳的组合组件推荐版本说明Flink1.17.x 或 1.18.x1.17 是 LTS 版本社区支持周期长Scala2.12.xFlink 1.17 默认编译版本是 2.12用 2.13 需要额外处理Kafka3.x客户端兼容 2.x broker但建议 broker 也升到 3.xElasticsearch7.17.x 或 8.x7.17 是 7.x 最后一个版本8.x 默认开启安全认证JDK11Flink 1.17 官方推荐 JDK 11JDK 8 也能跑但不推荐注意Flink 1.15 之后flink-connector-elasticsearch的官方连接器不再随 Flink 一起发布需要单独引入依赖。而且 ES 7 和 ES 8 用的连接器 artifactId 不一样别搞混了。2.2 Maven 依赖清单下面是我在项目里实际用的 pom 片段直接抄就行properties flink.version1.17.2/flink.version scala.binary.version2.12/scala.binary.version kafka.version3.5.1/kafka.version /properties dependencies !-- Flink 核心依赖scope 设为 provided因为集群上已经有 -- dependency groupIdorg.apache.flink/groupId artifactIdflink-streaming-java/artifactId version${flink.version}/version scopeprovided/scope /dependency dependency groupIdorg.apache.flink/groupId artifactIdflink-clients/artifactId version${flink.version}/version scopeprovided/scope /dependency !-- Scala API 支持 -- dependency groupIdorg.apache.flink/groupId artifactIdflink-streaming-scala_${scala.binary.version}/artifactId version${flink.version}/version scopeprovided/scope /dependency !-- Kafka 连接器 -- dependency groupIdorg.apache.flink/groupId artifactIdflink-connector-kafka/artifactId version${flink.version}/version /dependency !-- ES 连接器注意 ES7 和 ES8 的 artifactId 不同 -- dependency groupIdorg.apache.flink/groupId artifactIdflink-connector-elasticsearch7/artifactId version3.0.1-1.17/version /dependency !-- JSON 解析用 fastjson2 或 jackson 都行 -- dependency groupIdcom.fasterxml.jackson.module/groupId artifactIdjackson-module-scala_${scala.binary.version}/artifactId version2.15.2/version /dependency /dependencies这里有个细节要说明flink-connector-elasticsearch7的版本号格式是x.y.z-a.b其中a.b对应 Flink 版本。比如3.0.1-1.17就是给 Flink 1.17 用的。如果你用 ES 8要换成flink-connector-elasticsearch8但那个连接器对 ES 8 的安全认证支持还不完善生产环境用 ES 7.17 更省心。2.3 本地开发环境搭建本地跑通再上集群这是铁律。你需要本地 Kafka用 Docker 起一个单节点最方便。docker run -d --name kafka \ -p 9092:9092 \ -e KAFKA_CFG_NODE_ID0 \ -e KAFKA_CFG_PROCESS_ROLEScontroller,broker \ -e KAFKA_CFG_LISTENERSPLAINTEXT://:9092,CONTROLLER://:9093 \ -e KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAPCONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT \ -e KAFKA_CFG_CONTROLLER_QUORUM_VOTERS0localhost:9093 \ -e KAFKA_CFG_CONTROLLER_LISTENER_NAMESCONTROLLER \ bitnami/kafka:3.5本地 ES同样用 Docker。docker run -d --name es \ -p 9200:9200 \ -e discovery.typesingle-node \ -e xpack.security.enabledfalse \ docker.elastic.co/elasticsearch/elasticsearch:7.17.10Flink 本地运行直接在 IDE 里跑 main 方法就行Flink 会自动创建一个本地执行环境。不需要额外装 Flink 集群。实操心得本地跑的时候把并行度设成 1方便看日志和调试。上集群前再改成实际需要的并行度。3. 核心代码实现与逐段拆解3.1 整体数据流设计整个程序的数据流是这样的Kafka Source - JSON 解析 - 字段清洗/转换 - 可选窗口聚合 - ES Sink每一步都有讲究。Kafka Source 要配好反序列化和 offset 提交策略JSON 解析要处理脏数据字段转换要保证类型正确ES Sink 要配好批量写入和失败重试。3.2 Kafka Source 配置先看 Source 部分。Flink 1.17 推荐用新的KafkaSourceAPI比老的FlinkKafkaConsumer更灵活import org.apache.flink.api.common.serialization.SimpleStringSchema import org.apache.flink.connector.kafka.source.KafkaSource import org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer val kafkaSource: KafkaSource[String] KafkaSource.builder[String]() .setBootstrapServers(localhost:9092) .setTopics(user-behavior) .setGroupId(flink-es-group) .setStartingOffsets(OffsetsInitializer.latest()) .setValueOnlyDeserializer(new SimpleStringSchema()) .setProperty(enable.auto.commit, false) .build()几个关键点解释一下setStartingOffsets(OffsetsInitializer.latest())从最新位置开始消费。如果是第一次跑想消费历史数据改成OffsetsInitializer.earliest()。生产环境通常用OffsetsInitializer.committedOffsets()从上次提交的 offset 继续。enable.auto.commit设为false让 Flink 自己管理 offset 提交配合 checkpoint 实现 Exactly-Once。如果设成trueKafka 客户端会自己提交 offset跟 Flink 的 checkpoint 机制冲突可能导致数据重复或丢失。setGroupId消费者组 ID同一个组内的消费者会分摊分区。注意KafkaSource 的并行度不要超过 topic 的分区数否则多出来的并行度会空闲。比如 topic 有 6 个分区Source 并行度设 6 就够了设 12 的话有 6 个 slot 是浪费的。3.3 JSON 解析与数据清洗从 Kafka 拿到的是字符串需要解析成结构化对象。我用 case class 来定义数据模型case class UserBehavior( userId: String, itemId: String, categoryId: String, behavior: String, timestamp: Long )解析的时候要处理脏数据不能让一条坏数据把整个作业搞挂import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.scala.DefaultScalaModule val mapper new ObjectMapper() mapper.registerModule(DefaultScalaModule) val parsedStream: DataStream[UserBehavior] kafkaStream .map(raw { try { mapper.readValue(raw, classOf[UserBehavior]) } catch { case e: Exception // 记录脏数据返回 null后面过滤掉 println(s解析失败: $raw, 错误: ${e.getMessage}) null } }) .filter(_ ! null)这里有个性能考量ObjectMapper是线程安全的可以复用不要每条数据都 new 一个。我把它定义成 object 或者静态字段整个算子共享一个实例。脏数据处理策略有三种直接丢弃简单粗暴但会丢数据。写到侧输出流用ProcessFunction的 side output 把脏数据单独收集起来后续人工排查。降级处理给个默认值让数据继续往下走。生产环境推荐第二种至少你知道丢了多少、丢了什么。3.4 数据转换与富化解析完之后通常要做一些转换。比如把时间戳转成可读格式或者根据 itemId 关联维表补充商品信息val transformedStream: DataStream[java.util.Map[String, Any]] parsedStream .map(behavior { val doc new java.util.HashMap[String, Any]() doc.put(user_id, behavior.userId) doc.put(item_id, behavior.itemId) doc.put(category_id, behavior.categoryId) doc.put(behavior, behavior.behavior) doc.put(event_time, behavior.timestamp) doc.put(process_time, System.currentTimeMillis()) doc })为什么转成java.util.Map而不是继续用 case class因为 ES 连接器最终需要的是Map或者 JSON 字符串。提前转好Sink 那边就省事了。如果要做窗口聚合比如统计每个商品过去 5 分钟的点击量val windowedStream parsedStream .filter(_.behavior pv) .keyBy(_.itemId) .window(TumblingEventTimeWindows.of(Time.minutes(5))) .aggregate(new CountAggregator)这里涉及事件时间和水位线的概念。简单说事件时间就是数据里自带的时间戳水位线是用来处理乱序数据的机制。如果数据可能迟到还要配allowedLateness。3.5 ES Sink 配置这是最关键的一步。ES Sink 的配置直接决定了写入性能和稳定性import org.apache.flink.connector.elasticsearch.sink.{ElasticsearchSink, FailureHandler, RequestIndexer} import org.apache.http.HttpHost import org.elasticsearch.action.index.IndexRequest import org.elasticsearch.client.Requests val httpHosts new java.util.ArrayList[HttpHost]() httpHosts.add(new HttpHost(localhost, 9200, http)) val esSinkBuilder new ElasticsearchSink.Builder[java.util.Map[String, Any]]( httpHosts, new ElasticsearchSinkFunction[java.util.Map[String, Any]] { override def process( element: java.util.Map[String, Any], ctx: RuntimeContext, indexer: RequestIndexer ): Unit { val json mapper.writeValueAsString(element) val request: IndexRequest Requests.indexRequest() .index(user-behavior- LocalDate.now().toString) .source(json) indexer.add(request) } } ) // 批量写入配置 esSinkBuilder.setBulkFlushMaxActions(1000) // 每 1000 条刷一次 esSinkBuilder.setBulkFlushMaxSizeMb(5) // 或者每 5MB 刷一次 esSinkBuilder.setBulkFlushInterval(2000) // 或者每 2 秒刷一次 esSinkBuilder.setBulkFlushBackoff(true) // 开启退避重试 esSinkBuilder.setBulkFlushBackoffType(BulkFlushBackoffType.EXPONENTIAL) esSinkBuilder.setBulkFlushBackoffRetries(3) esSinkBuilder.setBulkFlushBackoffDelay(1000) val esSink esSinkBuilder.build() transformedStream.sinkTo(esSink)这段代码里有几个参数需要重点解释批量刷新的三个触发条件setBulkFlushMaxActions、setBulkFlushMaxSizeMb、setBulkFlushInterval任意一个满足就会触发一次 bulk 写入。这三个是或的关系。设 1000 条、5MB、2 秒意思是攒够 1000 条就写或者攒够 5MB 就写或者 2 秒到了不管攒了多少都写。为什么三个都要设因为数据流量可能不均匀。高峰期可能 1 秒就攒够 1000 条低峰期可能 10 秒才攒够 100 条。如果只设条数低峰期数据会一直卡在缓冲区里不写进去延迟很高。加上时间维度保证最坏情况下 2 秒也能写一次。退避重试ES 在压力大的时候会返回 429Too Many Requests这时候如果直接失败数据就丢了。开启退避重试后Flink 会等一段时间再重试而且重试间隔指数增长1 秒、2 秒、4 秒……给 ES 喘息的机会。索引名带日期user-behavior-2024-01-15这种命名方式方便做索引生命周期管理ILM。按天建索引老索引可以定期删除或者归档不会让单个索引无限膨胀。实操心得索引名一定要小写ES 不允许大写字母。另外不要用点号开头那是系统索引的命名规则。3.6 完整主程序组装把上面的部分串起来import org.apache.flink.streaming.api.scala._ import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment import org.apache.flink.api.common.eventtime.WatermarkStrategy object KafkaToEsJob { def main(args: Array[String]): Unit { val env StreamExecutionEnvironment.getExecutionEnvironment env.setParallelism(4) env.enableCheckpointing(60000) // 每 60 秒做一次 checkpoint val kafkaSource KafkaSource.builder[String]() .setBootstrapServers(localhost:9092) .setTopics(user-behavior) .setGroupId(flink-es-group) .setStartingOffsets(OffsetsInitializer.latest()) .setValueOnlyDeserializer(new SimpleStringSchema()) .setProperty(enable.auto.commit, false) .build() val kafkaStream env.fromSource( kafkaSource, WatermarkStrategy.noWatermarks(), Kafka Source ) // ... 解析、转换、Sink 逻辑 env.execute(Kafka to ES Job) } }enableCheckpointing(60000)这行很重要。它让 Flink 每 60 秒做一次状态快照作业失败重启后能从最近的 checkpoint 恢复保证数据不丢。checkpoint 间隔太短会影响性能太长则恢复时重放的数据多。60 秒是个比较平衡的值。4. 性能调优与常见问题排查4.1 并行度怎么定并行度是 Flink 作业最重要的调优参数。定低了跑不满资源定高了上下文切换开销大。我的经验公式是Source 并行度 Kafka topic 分区数。多了浪费少了消费不过来。中间算子并行度 Source 并行度或者根据 CPU 密集程度适当调整。Sink 并行度 ES 数据节点的数量 × 2 到 3。因为 ES 写入是 IO 密集型每个数据节点可以承受多个写入客户端。整体并行度还要受限于集群的 slot 总数。比如集群有 10 个 TaskManager每个 4 个 slot总共 40 个 slot。你的作业最大并行度不能超过 40否则会一直等资源。4.2 背压问题怎么排查背压Backpressure是流处理里最常见的问题。表现是 Source 的消费速度越来越慢Kafka lag 越来越大。根本原因是下游处理不过来数据在缓冲区里堆积。排查步骤看 Flink Web UI 的 Backpressure 页面如果某个算子显示 HIGH说明它下游有瓶颈。逐级往下查从 Source 开始找到第一个显示 HIGH 的算子它的下游就是瓶颈所在。常见瓶颈点ES 写入慢bulk 参数设得太小或者 ES 集群本身压力大。JSON 解析慢用了低效的解析库或者数据量太大单线程扛不住。窗口聚合慢窗口太大状态太多内存不够。解决办法加大瓶颈算子的并行度、优化 ES bulk 参数、换更快的 JSON 库比如 fastjson2 比 jackson 快不少。4.3 数据丢失与重复Exactly-Once 是流处理里的圣杯。要真正做到端到端的 Exactly-Once需要三个环节都支持Source 可重放Kafka 天然支持只要 offset 提交跟 checkpoint 绑定。状态可恢复Flink 的 checkpoint 机制保证。Sink 幂等或事务ES 连接器支持 at-least-once要 Exactly-Once 需要自己实现幂等写入比如用文档 ID 去重。实际生产中大部分场景 at-least-once 就够了下游做去重即可。如果业务要求严格 Exactly-Once可以考虑给每个文档生成确定性 ID比如userId timestamp的哈希这样重复写入会覆盖而不是新增。4.4 常见问题速查表问题现象可能原因解决办法ClassNotFoundException: scala.collection.JavaConvertersScala 版本不匹配检查 Flink 编译的 Scala 版本统一用 2.12ES 写入报 429ES 压力大bulk 太频繁加大 bulk interval开启退避重试Kafka lag 持续增长消费速度跟不上生产速度加大 Source 并行度或优化下游处理逻辑Checkpoint 超时状态太大或 barrier 对齐慢加大 checkpoint 超时时间考虑用 unaligned checkpoint作业频繁重启OOM 或反序列化异常加大 TaskManager 内存加异常捕获ES 索引 mapping 冲突同一字段在不同文档里类型不一致提前定义 index template固定字段类型4.5 几个我踩过的坑坑一ES 的字段类型自动推断。ES 默认会根据第一条数据的值来推断字段类型。如果第一条数据的user_id是数字123ES 就把这个字段设成long。后面来了个abc写入就失败了。解决办法是提前建好 index template明确指定每个字段的类型。坑二Kafka 消息太大。Kafka 默认单条消息最大 1MB超过会报错。如果你的数据里有大字段比如完整的 HTML 页面要么调大max.request.size要么在写入 Kafka 前压缩。坑三Flink 的map算子不是线程安全的。如果你在map里用了共享的可变对象比如一个SimpleDateFormat实例多并行度下会出问题。要么用RichMapFunction的open方法初始化要么用线程安全的替代品比如DateTimeFormatter。坑四ES 的refresh_interval。ES 默认 1 秒刷新一次索引这意味着写入的数据 1 秒后才能被搜到。如果你做的是实时大屏1 秒延迟可能可以接受。但如果要求更高可以把refresh_interval设成-1关闭自动刷新然后手动调_refreshAPI。不过这样会影响写入性能要权衡。5. 生产环境部署要点5.1 打包与提交用 Maven 的 shade 插件打一个 fat jar把所有依赖都打进去plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId version3.4.1/version executions execution phasepackage/phase goalsgoalshade/goal/goals configuration artifactSet excludes excludeorg.apache.flink:flink-streaming-java/exclude excludeorg.apache.flink:flink-clients/exclude /excludes /artifactSet /configuration /execution /executions /plugin注意把 Flink 核心依赖排除掉因为集群上已经有了打进去反而可能冲突。提交作业flink run -c KafkaToEsJob \ -p 8 \ -yjm 1024m \ -ytm 2048m \ your-job.jar-p 8指定并行度-yjm是 JobManager 内存-ytm是 TaskManager 内存。5.2 监控与告警生产环境必须配监控。关键指标Kafka lag消费延迟超过阈值告警。Checkpoint 成功率失败率高了说明作业不稳定。ES 写入延迟bulk 请求的响应时间。JVM GC 时间GC 频繁说明内存不够。Flink 自带 Metrics 系统可以对接 Prometheus Grafana。ES 也有自己的监控 API可以定期采集。5.3 资源规划参考一个中等规模的作业每秒 10 万条数据我的资源配置是角色数量单节点配置JobManager12 核 4GBTaskManager44 核 8GBKafka broker34 核 16GBES data node38 核 32GB这个配置下端到端延迟能稳定在 500ms 以内Kafka lag 基本为 0。5.4 上线前的检查清单[ ] 本地跑通数据能正确写入 ES[ ] 脏数据处理逻辑验证过不会导致作业崩溃[ ] Checkpoint 配置合理恢复测试通过[ ] ES index template 已创建字段类型明确[ ] 监控告警已配置[ ] 并行度与集群资源匹配[ ] 压测过知道极限吞吐量这套东西我从零搭到稳定运行前后迭代了三四版。最开始用老的FlinkKafkaConsumer和ElasticsearchSink后来升级到新的KafkaSource和ElasticsearchSink注意名字一样但包路径不同API 更清晰配置也更灵活。最大的体会是参数不要照抄要根据自己的数据量和集群情况调。别人设 1000 条刷一次你可能设 500 条更合适。多压测、多看监控慢慢就找到感觉了。
阅读完成 · 觉得有帮助?
咨询建站