spark streaming(非structured steaming)消费rocketMQ,并手动提交
一、背景
最近有一个需求是:要求有一个类对象为Order,它有string类型的字段orderNo和Long类型的字段cost,生产者写到rocketMQ的value是Order对象序列化后的字节数组、key值是orderNo字段,要求spark以手动提交的方式消费rocketMQ,并将数据依次写入到hive表中,并且spark中有一个5分钟滑动窗口,滑动步长为1分钟,统计5分钟内的cost总值并输出。
二、实战演练
RocketMQ 官方并没有像 Kafka 那样的「Spark DirectStream 一键式手动提交 offset」通用方案;因此我用的是一种常见的、工程可控的做法来实现“手动提交 offset”语义:
-
使用 Spark Streaming (DStream)(不是 Structured Streaming,因为你明确要手动提交 offset;Structured Streaming 的 offset 管理是自动的,不能手动提交)。
-
我实现了一个 自定义 Receiver(RocketMQReceiver),该 Receiver 在每个 batch 周期从 RocketMQ 拉取消息(Pull 模式),并把
Order
对象推入 DStream。 -
手动提交 offset 的实现:Receiver 不把 offset 自动永久化到 Broker,而是把“当前读取到的 offset”存放在一个外部可共享位置(示例使用 HDFS/local 文件作为简单的 offset store)。Driver 在确认该 batch 的 RDD 已成功写入 Hive 后,会把“该 batch 已处理的 offsets”写回到同一 offset store(即手动提交)。下一次 Receiver 启动 / 拉取时,会从 offset store 读取这些已提交的 offsets 继续拉取。
这种方案与 Kafka 的
commitAsync
思路等价:只有当你确认完成写入(输出)后,才把 offset 持久化(提交)。该方法是通用的、工程上常用的“外部 offset 管理”策略,能满足“手动提交”需求。
下面包含:pom.xml
、Order.java
、RocketMQ 生产者 OrderProducer.java
、Hive 建表 SQL、Spark 程序 SparkRocketMQToHive.java
(包含自定义 Receiver、window 逻辑与手动 offset 提交),以及运行说明与注意事项。
1) pom.xml(主要依赖)
<project xmlns="http://maven.apache.org/POM/4.0.0" > <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>spark-rocketmq-hive-demo</artifactId> <version>1.0-SNAPSHOT</version> <properties> <java.version>1.8</java.version> <scala.version>2.12</scala.version> <spark.version>3.3.2</spark.version> <rocketmq.version>4.9.4</rocketmq.version> </properties> <dependencies> <!-- Spark --> <dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-core_2.12</artifactId> <version>${spark.version}</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-streaming_2.12</artifactId> <version>${spark.version}</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-sql_2.12</artifactId> <version>${spark.version}</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.apache.spark</groupId> <artifactId>spark-hive_2.12</artifactId> <version>${spark.version}</version> <scope>provided</scope> </dependency> <!-- RocketMQ client (producer & pull consumer) --> <dependency> <groupId>org.apache.rocketmq</groupId> <artifactId>rocketmq-client</artifactId> <version>${rocketmq.version}</version> </dependency> <!-- Logging --> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>1.7.36</version> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-log4j12</artifactId> <version>1.7.36</version> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.8.1</version> <configuration> <source>1.8</source> <target>1.8</target> </configuration> </plugin> </plugins> </build> </project>
2) Hive 建表 SQL
在 Hive CLI 或 Hue 执行:
CREATE DATABASE IF NOT EXISTS demo_db; -- 原始订单表,包含事件时间 ts(毫秒) CREATE TABLE IF NOT EXISTS demo_db.orders ( orderNo STRING, cost BIGINT, ts BIGINT ) STORED AS PARQUET; -- 窗口聚合表 CREATE TABLE IF NOT EXISTS demo_db.order_cost_window_agg ( window_start TIMESTAMP, window_end TIMESTAMP, total_cost BIGINT ) STORED AS PARQUET;
3) Order 类(Order.java
)
1 package com.example; 2 3 import java.io.Serializable; 4 5 public class Order implements Serializable { 6 private static final long serialVersionUID = 1L; 7 8 private String orderNo; 9 private Long cost; 10 private Long ts; // 事件时间,毫秒 11 12 public Order() {} 13 14 public Order(String orderNo, Long cost, Long ts) { 15 this.orderNo = orderNo; 16 this.cost = cost; 17 this.ts = ts; 18 } 19 20 public String getOrderNo() { return orderNo; } 21 public void setOrderNo(String orderNo) { this.orderNo = orderNo; } 22 23 public Long getCost() { return cost; } 24 public void setCost(Long cost) { this.cost = cost; } 25 26 public Long getTs() { return ts; } 27 public void setTs(Long ts) { this.ts = ts; } 28 29 @Override 30 public String toString() { 31 return "Order{" + "orderNo='" + orderNo + '\'' + ", cost=" + cost + ", ts=" + ts + '}'; 32 } 33 }
序列化方案:示例使用 Java 原生序列化(ObjectOutputStream → byte[])。生产环境建议改为 JSON/Avro/ProtoBuf。
4) RocketMQ 生产者示例(OrderProducer.java
)
1 package com.example; 2 3 import org.apache.rocketmq.client.producer.DefaultMQProducer; 4 import org.apache.rocketmq.common.message.Message; 5 6 import java.io.ByteArrayOutputStream; 7 import java.io.ObjectOutputStream; 8 import java.util.UUID; 9 10 public class OrderProducer { 11 public static byte[] serialize(Order order) throws Exception { 12 ByteArrayOutputStream bos = new ByteArrayOutputStream(); 13 ObjectOutputStream oos = new ObjectOutputStream(bos); 14 oos.writeObject(order); 15 oos.flush(); 16 return bos.toByteArray(); 17 } 18 19 public static void main(String[] args) throws Exception { 20 String namesrvAddr = "localhost:9876"; // 修改为你的nameserver地址 21 String topic = "orders-topic"; 22 23 DefaultMQProducer producer = new DefaultMQProducer("order-producer-group"); 24 producer.setNamesrvAddr(namesrvAddr); 25 producer.start(); 26 27 for (int i = 0; i < 50; i++) { 28 String orderNo = "ORD-" + UUID.randomUUID().toString().substring(0, 8); 29 long cost = (long)(Math.random() * 1000); 30 long ts = System.currentTimeMillis(); 31 32 Order order = new Order(orderNo, cost, ts); 33 byte[] body = serialize(order); 34 35 Message msg = new Message(topic, "tagA", orderNo, body); 36 producer.send(msg); // 同步发送 37 System.out.println("Sent: " + order); 38 Thread.sleep(200); 39 } 40 41 producer.shutdown(); 42 } 43 }
5) Spark 程序(完整):SparkRocketMQToHive.java
主要思路:
-
自定义
RocketMQReceiver extends Receiver<Order>
:在onStart
启动一个拉取线程,周期性从 RocketMQ 的每个 MessageQueue 拉取消息(从我们持久化的 offset store 开始),并store(order)
推送给 Spark Streaming。 -
offset store
:示例用一个简单的文件(可以是 HDFS 路径)保存 JSON 格式的已提交 offsets(per-queue)。 -
Driver 端的
foreachRDD
会把 RDD 写入 Hive(append),并且在成功写入后把该 batch 的最大 offsets 写入 offset store(即手动提交)。 -
窗口聚合:用 DStream 的
window(Durations.minutes(5), Durations.minutes(1))
,在每个窗口上做map(Order->("all", cost))
reduce,最后写入聚合表。
注意:代码做了较多注释,请按注释中的
TODO
修改 RocketMQ namesrv、topic、以及 offsetStorePath(HDFS 路径建议用 hdfs://...)。
1 package com.example; 2 3 import org.apache.hadoop.fs.*; 4 import org.apache.hadoop.conf.Configuration; 5 import org.apache.rocketmq.client.consumer.DefaultMQPullConsumer; 6 import org.apache.rocketmq.client.consumer.PullResult; 7 import org.apache.rocketmq.client.consumer.PullStatus; 8 import org.apache.rocketmq.common.message.MessageExt; 9 import org.apache.rocketmq.common.message.MessageQueue; 10 import org.apache.spark.SparkConf; 11 import org.apache.spark.api.java.*; 12 import org.apache.spark.streaming.*; 13 import org.apache.spark.streaming.api.java.*; 14 import org.apache.spark.streaming.receiver.Receiver; 15 import org.apache.spark.storage.StorageLevel; 16 import org.apache.spark.sql.*; 17 18 import java.io.*; 19 import java.util.*; 20 import java.util.concurrent.ConcurrentHashMap; 21 import java.util.stream.Collectors; 22 23 import scala.Tuple2; 24 25 public class SparkRocketMQToHive { 26 27 // ------------------------- 28 // OffsetStore: 通过文件(HDFS/local)保存已提交 offsets(简单实现) 29 // 存储格式(简单 JSON-like): 30 // queueId|brokerName|queueOffset\n 31 // ------------------------- 32 public static class OffsetStore { 33 private final String path; // e.g., hdfs://namenode:8020/user/offsets/orders_offsets.txt or file:///tmp/... 34 private final Configuration hadoopConf = new Configuration(); 35 36 public OffsetStore(String path) { 37 this.path = path; 38 } 39 40 // 读取已提交 offsets -> Map<MessageQueue, Long> 41 public Map<MessageQueue, Long> readOffsets() throws IOException { 42 Map<MessageQueue, Long> map = new HashMap<>(); 43 Path p = new Path(path); 44 FileSystem fs = p.getFileSystem(hadoopConf); 45 if (!fs.exists(p)) return map; 46 47 try (FSDataInputStream in = fs.open(p); 48 BufferedReader br = new BufferedReader(new InputStreamReader(in))) { 49 String line; 50 while ((line = br.readLine()) != null) { 51 // 格式: brokerName|queueId|offset 52 String[] parts = line.trim().split("\\|"); 53 if (parts.length == 3) { 54 String broker = parts[0]; 55 int queueId = Integer.parseInt(parts[1]); 56 long offset = Long.parseLong(parts[2]); 57 // MessageQueue requires topic too — we will reconstruct with topic externally 58 // To simplify, we return map keyed by "broker:queueId" in caller 59 MessageQueue mq = new MessageQueue(); // placeholder, set fields later 60 mq.setBrokerName(broker); 61 mq.setQueueId(queueId); 62 // cannot set topic here; caller will match based on broker+queueId. 63 map.put(mq, offset); 64 } 65 } 66 } catch (Exception e) { 67 throw new IOException("readOffsets failed", e); 68 } 69 return map; 70 } 71 72 // 简化写入:由 caller 提供 list of entries (broker|queueId|offset) 73 public void writeOffsets(List<String> lines) throws IOException { 74 Path p = new Path(path); 75 FileSystem fs = p.getFileSystem(hadoopConf); 76 // 覆盖写入 77 try (FSDataOutputStream out = fs.create(p, true); 78 BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(out))) { 79 for (String line : lines) { 80 bw.write(line); 81 bw.newLine(); 82 } 83 } 84 } 85 } 86 87 // ------------------------- 88 // RocketMQReceiver:自定义 Receiver,从 RocketMQ pull 消息并 store() 89 // ------------------------- 90 public static class RocketMQReceiver extends Receiver<Order> { 91 private volatile boolean stopped = false; 92 private final String namesrvAddr; 93 private final String topic; 94 private final String consumerGroup; 95 private final String offsetStorePath; // offset store path 96 private transient DefaultMQPullConsumer pullConsumer; 97 98 // local map to hold the latest pulled offsets per MessageQueue 99 private final Map<MessageQueue, Long> latestPulledOffsets = new ConcurrentHashMap<>(); 100 101 public RocketMQReceiver(String namesrvAddr, String topic, String consumerGroup, String offsetStorePath) { 102 super(StorageLevel.MEMORY_AND_DISK_2()); 103 this.namesrvAddr = namesrvAddr; 104 this.topic = topic; 105 this.consumerGroup = consumerGroup; 106 this.offsetStorePath = offsetStorePath; 107 } 108 109 @Override 110 public void onStart() { 111 stopped = false; 112 // start pulling thread 113 new Thread(this::pullLoop, "rocketmq-pull-thread").start(); 114 } 115 116 @Override 117 public void onStop() { 118 stopped = true; 119 if (pullConsumer != null) { 120 try { 121 pullConsumer.shutdown(); 122 } catch (Exception ignored) { 123 } 124 } 125 } 126 127 private void pullLoop() { 128 try { 129 pullConsumer = new DefaultMQPullConsumer(consumerGroup + "_pull"); 130 pullConsumer.setNamesrvAddr(namesrvAddr); 131 pullConsumer.start(); 132 133 // 获取所有 message queues for topic 134 Set<MessageQueue> mqs = pullConsumer.fetchSubscribeMessageQueues(topic); 135 136 // 读取已提交 offsets(从 offset store) 137 Map<MessageQueue, Long> committed = readOffsetStore(); 138 139 while (!stopped) { 140 for (MessageQueue mq : mqs) { 141 long offset = 0L; 142 // find committed offset matching same broker+queueId 143 Optional<Map.Entry<MessageQueue, Long>> found = committed.entrySet().stream() 144 .filter(e -> e.getKey().getBrokerName().equals(mq.getBrokerName()) 145 && e.getKey().getQueueId() == mq.getQueueId()) 146 .findFirst(); 147 if (found.isPresent()) offset = found.get().getValue(); 148 // Pull 149 try { 150 PullResult pullResult = pullConsumer.pullBlockIfNotFound(mq, null, offset, 32); 151 if (pullResult != null) { 152 if (pullResult.getPullStatus() == PullStatus.FOUND) { 153 List<MessageExt> msgs = pullResult.getMsgFoundList(); 154 if (msgs != null) { 155 for (MessageExt me : msgs) { 156 try { 157 byte[] body = me.getBody(); 158 Order o = deserialize(body); 159 if (o != null) { 160 store(o); // 推入 DStream 161 } 162 } catch (Exception e) { 163 // log and continue 164 e.printStackTrace(); 165 } 166 } 167 } 168 } 169 // 更新本地 latestPulledOffsets:下次从 pullResult.getNextBeginOffset() 拉 170 long nextOffset = pullResult.getNextBeginOffset(); 171 latestPulledOffsets.put(mq, nextOffset); 172 } 173 } catch (Exception ex) { 174 ex.printStackTrace(); 175 } 176 if (stopped) break; 177 } 178 // 拉完一轮 messageQueues 之后,短睡眠一下,等待下一 batch 拉取 179 Thread.sleep(500); 180 } 181 } catch (Exception e) { 182 e.printStackTrace(); 183 restart("rocketmq pull failed", e); 184 } 185 } 186 187 // 从 offset store 读取已提交 offsets(简化) 188 private Map<MessageQueue, Long> readOffsetStore() { 189 OffsetStore store = new OffsetStore(offsetStorePath); 190 try { 191 return store.readOffsets(); 192 } catch (Exception e) { 193 // log and return empty 194 e.printStackTrace(); 195 return new HashMap<>(); 196 } 197 } 198 199 // 反序列化 Order(Java 原生序列化) 200 private Order deserialize(byte[] bytes) { 201 if (bytes == null) return null; 202 try (ByteArrayInputStream bis = new ByteArrayInputStream(bytes); 203 ObjectInputStream ois = new ObjectInputStream(bis)) { 204 return (Order) ois.readObject(); 205 } catch (Exception e) { 206 e.printStackTrace(); 207 return null; 208 } 209 } 210 211 // 提供外部访问最新 pulled offsets 的方法(driver 可以读取并在处理完写入后提交) 212 public Map<MessageQueue, Long> getLatestPulledOffsets() { 213 return new HashMap<>(latestPulledOffsets); 214 } 215 } 216 217 // ------------------------- 218 // 主程序:Spark Streaming 作业 219 // ------------------------- 220 public static void main(String[] args) throws Exception { 221 // config 222 String namesrv = "localhost:9876"; // TODO 修改 223 String topic = "orders-topic"; 224 String consumerGroup = "spark-rocketmq-group"; 225 String offsetStorePath = "file:///tmp/rocketmq_offsets.txt"; // TODO 改为 HDFS 路径:hdfs://namenode:8020/user/offsets/orders_offsets.txt 226 227 SparkConf conf = new SparkConf() 228 .setAppName("SparkRocketMQToHive") 229 .setIfMissing("spark.master", "local[2]") 230 .set("spark.serializer", "org.apache.spark.serializer.KryoSerializer"); 231 232 // batch interval = 1 minute (符合滑动步长) 233 JavaStreamingContext jssc = new JavaStreamingContext(conf, Durations.minutes(1)); 234 // checkpoint dir for Spark Streaming state (if needed) 235 jssc.checkpoint("/tmp/spark-streaming-checkpoint"); 236 237 // 创建并注册 Receiver 238 RocketMQReceiver receiver = new RocketMQReceiver(namesrv, topic, consumerGroup, offsetStorePath); 239 JavaReceiverInputDStream<Order> stream = jssc.receiverStream(receiver); 240 241 // 将原始 order 写入 Hive(每个 micro-batch 的 RDD) 242 stream.foreachRDD((JavaRDD<Order> rdd, Time time) -> { 243 if (rdd.isEmpty()) { 244 System.out.println("No data in this batch: " + time); 245 return; 246 } 247 248 // 创建 SparkSession(在 executor 上创建会出问题,因此在 driver 上操作) 249 SparkSession spark = SparkSession.builder() 250 .config(conf) 251 .enableHiveSupport() 252 .getOrCreate(); 253 254 Dataset<Row> df = spark.createDataFrame(rdd, Order.class); 255 // 写入 orders 表(append) 256 df.write().mode(SaveMode.Append).insertInto("demo_db.orders"); 257 258 System.out.println("Wrote batch to Hive: " + time + ", count=" + rdd.count()); 259 260 // 手动提交 offset:从 receiver 获取 latestPulledOffsets,然后把它持久化到 offsetStorePath(覆盖写) 261 Map<MessageQueue, Long> latestOffsets = receiver.getLatestPulledOffsets(); 262 List<String> lines = latestOffsets.entrySet().stream() 263 .map(e -> e.getKey().getBrokerName() + "|" + e.getKey().getQueueId() + "|" + e.getValue()) 264 .collect(Collectors.toList()); 265 OffsetStore store = new OffsetStore(offsetStorePath); 266 store.writeOffsets(lines); 267 System.out.println("Committed offsets for this batch: " + lines); 268 }); 269 270 // 窗口聚合:5 分钟窗口,1 分钟滑动,统计 cost 总和 271 JavaPairDStream<String, Long> kv = stream.mapToPair(order -> new Tuple2<>("all", order.getCost() == null ? 0L : order.getCost())); 272 JavaPairDStream<String, Long> windowed = kv.reduceByKeyAndWindow((a, b) -> a + b, 273 Durations.minutes(5), Durations.minutes(1)); 274 275 // 每个窗口写入聚合表 276 windowed.foreachRDD((rdd, time) -> { 277 if (rdd.isEmpty()) return; 278 SparkSession spark = SparkSession.builder().config(conf).enableHiveSupport().getOrCreate(); 279 280 final long windowEndMs = time.milliseconds(); 281 final long windowStartMs = windowEndMs - 5 * 60 * 1000 + 1; 282 283 List<Row> rows = rdd.map(tuple -> RowFactory.create(new java.sql.Timestamp(windowStartMs), 284 new java.sql.Timestamp(windowEndMs), tuple._2())).collect(); 285 286 StructType schema = new StructType(new StructField[]{ 287 new StructField("window_start", DataTypes.TimestampType, false, Metadata.empty()), 288 new StructField("window_end", DataTypes.TimestampType, false, Metadata.empty()), 289 new StructField("total_cost", DataTypes.LongType, false, Metadata.empty()) 290 }); 291 292 Dataset<Row> df = spark.createDataFrame(rows, schema); 293 df.write().mode(SaveMode.Append).insertInto("demo_db.order_cost_window_agg"); 294 System.out.println("Wrote window agg: start=" + new java.sql.Timestamp(windowStartMs) + " end=" + new java.sql.Timestamp(windowEndMs)); 295 }); 296 297 // 启动 298 jssc.start(); 299 jssc.awaitTermination(); 300 } 301 }
6) 运行步骤(本地测试)
-
启动 RocketMQ (nameserver + broker)。确保
namesrvAddr
在 producer/consumer/receiver 中一致。 -
在 Hive 中执行建表 SQL。
-
用 Maven 打包:
-
先运行
OrderProducer
发送一些消息: -
使用
spark-submit
提交 Spark Streaming 作业(注意把 rocketmq-client 的 jar 加到 classpath,或者使用 --packages/--jars):或把 rocketmq-client 打包进 fat-jar。
-
观察 Hive 表
demo_db.orders
和demo_db.order_cost_window_agg
是否有数据。
7) 设计说明与注意事项(必须阅读)
-
为什么用 Receiver + 外部 offset store?
-
RocketMQ 与 Kafka 在生态、客户端 API 上不同,且没有像 Kafka DirectStream 那样的“offsetRanges 可在 driver 中获取并 commitAsync”现成模式;用 Receiver + 自己维护 offset store(HDFS/file/ZK)可以实现“在确认写入外部 sink 后再提交 offset”这一语义。
-
-
offset 的一致性
-
该实现保证:只有在 Spark 确认该 batch 数据已写入 Hive 后,才把最新 pulled offsets 持久化(提交)。若写 Hive 失败,则不会提交 offsets,下次作业重启或 receiver 重新读取时会从上次提交点重新读取(幂等问题需用户在写入时处理,比如去重或使用唯一 key 覆盖写入策略)。
-
-
OffsetStore 的实现示例简单
-
示例使用了一个非常简单的文件格式。生产环境建议使用 Zookeeper、HBase、MySQL 或 RocketMQ 的 broker commit 接口(如果可用)来存放 offsets,需要考虑并发/权限/原子写入等问题。使用 HDFS 写入需确保 driver 与 receiver 都能访问同一路径。
-
-
性能 & 并发
-
示例中 receiver 一次拉取固定数量(32 条)并逐条
store()
。你可以根据需要调整并行度(多个 receiver 实例、每 queue 的并发拉取、批量 store 等)。另外,window 聚合会把数据在 driver 上合并写入 Hive(请按集群资源调优并行度、分区数等)。
-
-
序列化方案
-
示例使用 Java native serialization(ObjectOutputStream)。生产环境应优先使用 JSON / Avro / Protobuf 以便于兼容与演进。
-
-
幂等性
-
如果你的 Hive 写入不是幂等的(例如重复写会产生重复记录),建议在订单表中使用
orderNo
做主键去重或在写入前做去重。因为若写失败后重试,会产生重复消费。
-
-
时间与窗口
-
示例窗口使用 processing-time(因为 DStream 没有像 Structured Streaming 那样方便的事件时间 watermark)。如果你需要基于
ts
(Order 内的事件时间)做更准确的事件时间窗口,并处理乱序数据,建议使用 Structured Streaming(但 Structured Streaming 不允许手动提交 offset)。这两者之间是权衡:事件时间 + watermark(Structured Streaming)VS 可控手动提交 offset(本示例用 DStream + 自管理 offset)。
-
8) 下步可选改进
-
把 offsetStore 从文件改成 ZooKeeper 实现(更可靠,适合分布式部署)。
-
把序列化改成 JSON(Jackson):更易于调试和兼容。
-
把 Receiver 的拉取逻辑改成批量化、高吞吐版本(同时按 partition 拉取并做并行 store)。
-
如果你想改用 Structured Streaming(事件时间 + watermark 优势),我可以给出无法手动提交 offset 的替代方案:例如把 Hive 写入做幂等化(基于 orderNo)从而允许使用 Structured Streaming 的自动 offset 管理与 exactly-once 语义。
转载请注明出处:https://www.cnblogs.com/fnlingnzb-learner/p/19073689