ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

电商实时离线双链路数据工程实战:会话切割、宽表生成与指标对账

电商实时离线双链路数据工程实战:会话切割、宽表生成与指标对账

简介:本资源是面向大数据开发工程师、高校相关专业学生及技术进阶者的Hadoop/Spark数据算法实践套件,聚焦海量数据场景下的分布式计算原理与工程实现。资源包含876个文件,总大小204.27MB,以360个Java核心实现代码、242个依赖JAR包、34个Scala示例程序为主干,辅以63份Markdown说明文档、31个Shell脚本(含transform.awk等数据预处理工具)及15份PDF技术解析,覆盖MapReduce词频统计、Spark SQL结构化查询、MLlib分类聚类模型等典型任务。已有686人学习下载,可直接运行验证HDFS数据分块逻辑、RDD容错机制与迭代计算优化效果。资源目录结构清晰,含多级实验模块与_success标记文件,便于理解作业提交流程与结果校验规范,是掌握大数据算法落地细节与调优思路的高实用性代码库。

1. 这不是又一个“Hadoop入门Demo”:它把电商用户行为日志的实时漏斗归因、离线特征宽表生成、以及AB实验指标一致性校验,全压在一个可调试、可断点、带真实数据模拟器的源码包里

你见过凌晨三点还在改 Spark SQL 的 UDF,就为了把「从加购到下单」的路径中,剔除掉被风控拦截但未返回明确状态码的中间请求吗?这个项目就是为这种场景写的。它不教你怎么搭伪分布式集群,也不堆砌 WordCount 变体;它直接给你一套跑在 YARN 上、能连 Kafka + Hive + MySQL 三端、含完整血缘注释的生产级脚本集——核心是三个模块:基于 Flink State 的实时会话切割(非窗口式)、用 Spark GraphX 做用户行为图谱压缩后的 LTV 预估特征生成、以及最关键的「离线-实时双链路指标对账引擎」。适合两类人:一是刚从数仓转岗到大数据平台组、手握 HiveQL 但第一次要自己写 Spark Structured Streaming Checkpoint 的工程师;二是需要快速验证某算法逻辑(比如新设计的曝光衰减因子)能否在 PB 级日志中稳定产出特征的算法同学。所有代码都带// ← 此处可断点注释,数据生成器支持按天/小时/分钟粒度注入噪声,连 Kafka topic 的 partition 数和 replication factor 都写死在 config.yaml 里——不是为了炫技,是因为某次线上事故后,我们发现 90% 的“数据不准”问题,其实出在本地调试时用的单 partition topic 和线上 32 分区不一致。


2. 环境准备与最小可运行验证:跳过 Hadoop 安装,用 Docker Compose 拉起一套带监控的伪集群

这个项目最反直觉的设计是:它不依赖你本地装 Hadoop 或 Spark。所有计算引擎都打包进 Docker 镜像,你只需要 Docker Engine 20.10+ 和 8GB 可用内存。项目根目录下docker-compose.yml已预置四服务:namenode(HDFS)、resourcemanager(YARN)、kafka-broker(含 ZooKeeper)、mysql-db(存元数据与对账结果)。关键不是“能跑”,而是“跑得清楚”——每个容器都暴露 JMX 端口,docker-compose.yml里prometheus服务已配置好抓取规则,grafana面板模板直接放在deploy/grafana-dashboard.json。

2.1 三步启动:从 clone 到看到第一个 Flink JobManager 页面

先确认你的 Docker 资源限制:

# 必须!否则 YARN NodeManager 会因内存不足拒绝容器 docker system df -v | grep "Total Size" # 若 < 15GB,清理无用镜像:docker image prune -a

拉起服务(首次需约 3 分钟下载镜像):

cd /path/to/project-root docker-compose up -d --build # 等待 60 秒,检查服务健康状态 docker-compose ps | grep "Up.*healthy"

验证 HDFS 是否就绪(别急着跑 MR):

docker exec namenode hdfs dfs -ls / # 应输出:Found 3 items # drwxr-xr-x - root supergroup 0 2024-06-15 02:10 /data # drwxr-xr-x - root supergroup 0 2024-06-15 02:10 /tmp # drwxr-xr-x - root supergroup 0 2024-06-15 02:10 /user

提示:/data是项目约定的数据根目录,所有日志模拟器生成的原始文件、Spark 输出的宽表、Flink 的 savepoint 都落在此处。不要手动删,用./scripts/clean-hdfs.sh清理。

2.2 数据模拟器:用 Python 脚本生成带业务语义的伪造日志

项目不提供“假数据集下载”,因为静态数据无法验证时间窗口逻辑。src/simulator/下的log_generator.py是核心——它不是随机字符串生成器,而是按真实电商业务规则建模:

  • 用户行为流:view → cart_add → pay_submit → pay_success,每步带event_time(毫秒级)、session_id(30分钟无操作则新会话)、device_id(MD5 哈希设备指纹)
  • 异常注入:按配置比例插入pay_timeout(支付超时但未失败)、cart_clear(加购后清空购物车)、ab_test_group(A/B 实验分组标识)
  • 数据格式:严格遵循src/main/resources/log-schema.avsc中定义的 Avro Schema,字段名与 Hive 表完全对齐

运行模拟(生成 1 小时数据,写入 HDFS/data/raw_logs/2024/06/15/02/):

cd src/simulator python log_generator.py \ --hours 1 \ --output-dir hdfs://namenode:8020/data/raw_logs \ --date 2024-06-15 \ --hour 02 \ --ab-ratio 0.5 \ --timeout-rate 0.03

验证数据落地(注意路径中的02是小时,非月份):

docker exec namenode hdfs dfs -ls /data/raw_logs/2024/06/15/02/ # 应看到类似:/data/raw_logs/2024/06/15/02/part-00000-...avro

2.3 第一个可验证任务:运行离线漏斗统计脚本,比对 Hive 与 Spark 结果

别一上来就跑 Flink。先用最稳的 Spark SQL 验证数据链路是否通畅。src/main/spark/etl/funnel_offline.py是入口:

# src/main/spark/etl/funnel_offline.py from pyspark.sql import SparkSession from pyspark.sql.functions import * from pyspark.sql.types import * spark = SparkSession.builder \ .appName("offline-funnel") \ .config("spark.sql.adaptive.enabled", "true") \ .config("spark.sql.hive.convertMetastoreParquet", "false") \ .getOrCreate() # 读取 Avro 格式原始日志(注意:此路径必须与 simulator 生成路径一致) raw_df = spark.read \ .format("avro") \ .load("hdfs://namenode:8020/data/raw_logs/2024/06/15/02/") # 关键:业务漏斗定义 —— 不是简单 count,而是 session 级去重计数 funnel_df = raw_df \ .filter(col("event_type").isin("view", "cart_add", "pay_submit", "pay_success")) \ .withColumn("event_order", when(col("event_type") == "view", 1) .when(col("event_type") == "cart_add", 2) .when(col("event_type") == "pay_submit", 3) .otherwise(4)) \ .withColumn("is_first_in_session", row_number().over(Window.partitionBy("session_id").orderBy("event_time")) == 1) \ .filter("is_first_in_session") \ .groupBy("event_type") \ .count() \ .orderBy("event_order") funnel_df.show() # 输出应为四行:view, cart_add, pay_submit, pay_success 对应的 UV 数

提交到 YARN(注意--master yarn和--deploy-mode cluster):

spark-submit \ --master yarn \ --deploy-mode cluster \ --conf spark.yarn.submit.waitAppCompletion=true \ --conf spark.sql.adaptive.enabled=true \ --jars /opt/hadoop/share/hadoop/common/lib/avro-1.11.3.jar \ src/main/spark/etl/funnel_offline.py

成功标志:YARN UI(http://localhost:8088)中 Application Status 显示FINISHED,且控制台输出类似:

+------------+-----+ | event_type|count| +------------+-----+ | view| 1248| | cart_add| 327| |pay_submit | 189| |pay_success | 152| +------------+-----+

这一步的意义在于:确认了Avro Schema 解析正确、HDFS 路径可读、Spark SQL 执行计划无异常。如果卡在Application Status: ACCEPTED,大概率是 YARN 内存配额不足——见下一节避坑。


3. 核心模块拆解:实时会话切割与离线宽表生成的协同设计

项目真正的价值不在单点技术,而在如何让实时(Flink)与离线(Spark)两条链路共享同一套会话定义、同一套用户 ID 映射逻辑、同一套 AB 实验分组口径。src/main/flink/session/和src/main/spark/feature/目录下的代码不是孤立的,它们通过src/main/common/中的SessionConfig.java和UserMapping.scala强耦合。

3.1 实时会话切割:为什么不用 EventTimeWindow,而用 KeyedProcessFunction?

Flink 官方文档推荐用TumblingEventTimeWindows做会话,但本项目选择KeyedProcessFunction,原因很现实:业务要求“30分钟无操作即关闭会话”,但用户可能在第29分59秒发一个心跳事件,此时窗口已触发,无法回溯。KeyedProcessFunction允许你注册定时器,在收到事件时动态调整下次触发时间。

核心代码在src/main/flink/session/SessionProcessor.java:

public class SessionProcessor extends KeyedProcessFunction<String, LogEvent, SessionEndEvent> { private ValueState<Long> lastActiveTime; // 存储该 session 最后一次活跃时间戳 private ValueState<Long> timerTimestamp; // 存储当前注册的定时器时间戳 @Override public void open(Configuration parameters) throws Exception { ValueStateDescriptor<Long> lastActiveDesc = new ValueStateDescriptor<>("last-active", Types.LONG); this.lastActiveTime = getRuntimeContext().getState(lastActiveDesc); ValueStateDescriptor<Long> timerDesc = new ValueStateDescriptor<>("timer-ts", Types.LONG); this.timerTimestamp = getRuntimeContext().getState(timerDesc); } @Override public void processElement(LogEvent value, Context ctx, Collector<SessionEndEvent> out) throws Exception { Long currentTs = value.getEventTime(); Long lastTs = lastActiveTime.value(); // ← 此处可断点:观察 session_id 是否重复、event_time 是否乱序 if (lastTs == null || currentTs > lastTs) { lastActiveTime.update(currentTs); long nextTimer = currentTs + 30 * 60 * 1000L; // 30分钟 timerTimestamp.update(nextTimer); // 取消旧定时器(如果存在) ctx.timerService().deleteEventTimeTimer(timerTimestamp.value()); ctx.timerService().registerEventTimeTimer(nextTimer); } } @Override public void onTimer(long timestamp, OnTimerContext ctx, Collector<SessionEndEvent> out) throws Exception { // 定时器触发:会话结束 String sessionId = ctx.getCurrentKey(); Long endTime = timestamp; Long startTime = lastActiveTime.value(); // ← 此处可断点:验证 startTime 是否合理 out.collect(new SessionEndEvent(sessionId, startTime, endTime)); lastActiveTime.clear(); timerTimestamp.clear(); } }

逻辑说明:

  • KeyedProcessFunction按session_id分组,每个 key 独立维护状态;
  • lastActiveTime记录该 session 最后一次事件时间,不是窗口开始时间;
  • 每次收到新事件,都重新计算nextTimer = currentTs + 30min,并取消旧定时器;
  • onTimer触发时,timestamp就是nextTimer,即会话理论结束时间;
  • 关键参数:30 * 60 * 1000L是硬编码的会话超时阈值,修改它必须同步更新src/main/spark/feature/中的离线会话切割逻辑(见 3.2 节)。

3.2 离线宽表生成:Spark 如何复用实时会话逻辑?

src/main/spark/feature/WideTableBuilder.scala不是重写一遍会话逻辑,而是读取 Flink 实时作业输出的会话快照(存于 HDFS/data/session_snapshots/),再与用户基础信息、商品维度表 Join。这样做的好处是:离线宽表中的session_duration_sec字段,与实时链路完全一致,避免 AB 实验指标因会话定义差异而漂移。

关键步骤:

  1. 读取 Flink 保存的会话快照(Parquet 格式,含session_id,start_time,end_time,user_id)
  2. 读取 Hive 中的dim_user表(含user_id,age_group,city_tier)
  3. 读取dim_item表(含item_id,category_level1,brand)
  4. 按user_id和session_idJoin,生成宽表fact_session_wide

代码片段:

// src/main/spark/feature/WideTableBuilder.scala val sessionSnap = spark.read.parquet("hdfs://namenode:8020/data/session_snapshots/2024-06-15") val dimUser = spark.table("hive_db.dim_user") val dimItem = spark.table("hive_db.dim_item") // ← 此处可断点:检查 sessionSnap 的分区数是否过大(影响 Join 性能) val wideTable = sessionSnap .join(dimUser, Seq("user_id"), "left") .join(dimItem, Seq("item_id"), "left") .withColumn("session_duration_sec", col("end_time") - col("start_time")) .withColumn("is_ab_group_a", col("ab_test_group") === "A") .select( "session_id", "user_id", "item_id", "age_group", "city_tier", "category_level1", "session_duration_sec", "is_ab_group_a" ) wideTable.write .mode("overwrite") .partitionBy("dt") // 按日期分区 .parquet("hdfs://namenode:8020/data/wide_table/2024-06-15")

参数说明:

  • sessionSnap路径中的2024-06-15必须与 Flink 作业的--date参数一致;
  • dim_user和dim_item表需提前在 Hive Metastore 中创建,建表语句见ddl/hive_ddl.sql;
  • partitionBy("dt")是强制约定,后续对账引擎依赖此分区字段定位数据。

3.3 离线-实时指标对账引擎:用 SQL 做最终一致性校验

src/main/sql/reconcile/下的funnel_reconcile.sql是项目灵魂。它不比较原始日志,而是比较两条链路最终产出的核心指标:pay_success_uv(支付成功 UV)。

对账逻辑:

  • 实时链路:Flink 作业将每分钟的pay_success_uv写入 MySQL 表realtime_metrics(字段:dt,hour,minute,metric_name,value)
  • 离线链路:Spark 作业将每小时的pay_success_uv写入 Hive 表offline_metrics(字段:dt,hour,metric_name,value)
  • 对账脚本:Join 两表,计算ABS(realtime.value - offline.value) / offline.value > 0.05(误差超 5% 即告警)

SQL 片段:

-- src/main/sql/reconcile/funnel_reconcile.sql SELECT r.dt, r.hour, r.minute, r.value AS realtime_value, o.value AS offline_value, ABS(r.value - o.value) / NULLIF(o.value, 0) AS error_rate FROM realtime_metrics r JOIN offline_metrics o ON r.dt = o.dt AND r.hour = o.hour WHERE r.metric_name = 'pay_success_uv' AND o.metric_name = 'pay_success_uv' AND ABS(r.value - o.value) / NULLIF(o.value, 0) > 0.05;

执行方式(用 beeline 连接 HiveServer2):

beeline -u jdbc:hive2://namenode:10000 -f src/main/sql/reconcile/funnel_reconcile.sql

输出示例:

2024-06-15 02 37 152 148 0.027 2024-06-15 02 45 158 152 0.039 -- 无输出表示对账通过

注意:NULLIF(o.value, 0)是防除零错误的血泪经验——某次凌晨部署后,离线作业因 Hive 表锁未释放导致o.value=0,整个对账脚本崩溃。


4. 避坑指南:那些让你在凌晨三点对着 YARN 日志抓狂的典型问题

4.1 现象:Spark Submit 后 Application Status 长期卡在 ACCEPTED,YARN ResourceManager UI 显示 “AM Container is launched, waiting for AM container to Register with RM”

原因:YARN 默认给 ApplicationMaster(AM)分配的内存太小(yarn.scheduler.minimum-allocation-mb=1024),而本项目 Spark 作业设置了--executor-memory 4g,AM 自动申请 2g 内存,但集群总资源不足或队列配额耗尽。

解决:

  1. 查看 YARN 队列配额:yarn queue -status default
  2. 在spark-submit中显式指定 AM 内存:
    --conf spark.yarn.am.memory=2g \ --conf spark.yarn.am.memoryOverhead=1g \
  3. 或修改docker-compose.yml中resourcemanager服务的 JVM 参数,增大YARN_HEAPSIZE。

4.2 现象:Flink JobManager Web UI 可访问,但 TaskManager 一直显示UNAVAILABLE,日志报Failed to register at JobManager

原因:Docker 网络模式问题。默认 bridge 模式下,TaskManager 容器内localhost指向自身,无法连接到 JobManager 容器(其 hostname 是jobmanager)。

解决:

  1. 修改flink-conf.yaml(位于docker/flink/conf/):
    jobmanager.rpc.address: jobmanager # ← 必须是容器名,不能是 localhost taskmanager.host: taskmanager # ← 同理
  2. 重建镜像:docker-compose build flink-taskmanager
  3. 重启:docker-compose up -d flink-jobmanager flink-taskmanager

4.3 现象:log_generator.py运行时报错AvroTypeException: The datum ... is not an example of schema,且错误指向ab_test_group字段

原因:Avro Schema 中ab_test_group定义为["null", "string"](可空),但 Python 脚本生成时传入了None,而 Avro Python 库对None的序列化行为与 Java 不一致。

解决:

  1. 在log_generator.py中,将ab_test_group字段统一设为空字符串而非None:
    # 错误写法 record["ab_test_group"] = None if random.random() > ab_ratio else "A" # 正确写法 record["ab_test_group"] = "" if random.random() > ab_ratio else "A"
  2. 同步更新src/main/resources/log-schema.avsc,将ab_test_group类型改为["string"](非空),保持 Schema 与代码一致。

4.4 现象:Spark SQL 执行funnel_offline.py时抛org.apache.spark.sql.AnalysisException: cannot resolve 'event_type' given input columns,但showSchema()显示字段存在

原因:Avro 文件中字段名是eventType(驼峰),而 Spark 默认将 Avro 字段转为event_type(下划线),但log_schema.avsc中定义的是event_type,实际写入时却用了eventType。

解决:

  1. 检查log_generator.py中 Avro writer 的 schema 使用:
    # 必须确保用的是 src/main/resources/log-schema.avsc 的内容 schema = avro.schema.parse(open("src/main/resources/log-schema.avsc", "r").read()) writer = DataFileWriter(open(output_path, "wb"), DatumWriter(), schema)
  2. 在funnel_offline.py中显式指定字段映射:
    raw_df = spark.read \ .format("avro") \ .option("avroSchema", open("src/main/resources/log-schema.avsc").read()) \ .load("hdfs://namenode:8020/data/raw_logs/2024/06/15/02/")

4.5 现象:MySQL 中realtime_metrics表有数据,但funnel_reconcile.sql查询无结果,beeline返回空

原因:Hive 和 MySQL 的时区不一致。Flink 写入 MySQL 用的是 UTC 时间(2024-06-15 02:37:00),而 Hive 表offline_metrics中的dt字段是东八区时间(2024-06-15),hour字段也是东八区小时(02),但realtime_metrics表的dt和hour是 UTC 时间(2024-06-14和18)。

解决:

  1. 统一所有系统时区为 UTC(推荐):在docker-compose.yml中为mysql-db和namenode添加环境变量:
    environment: - TZ=UTC
  2. 或在 SQL 中做时区转换:
    -- 将 realtime_metrics 的 UTC 时间转为东八区 JOIN offline_metrics o ON DATE_ADD('day', 1, r.dt) = o.dt AND r.hour + 8 = o.hour

5. 二次开发实战:如何接入你自己的 Kafka Topic 并替换用户行为事件类型

你不可能永远用view/cart_add/pay_submit这套电商模型。项目预留了清晰的扩展点:src/main/common/event/下的EventType.java是事件类型的中心注册表,所有模块(Flink、Spark、Simulator)都通过它获取事件定义。改造只需三步,无需动核心引擎代码。

5.1 步骤一:定义新事件类型,更新 EventType 枚举

假设你要接入 IoT 设备上报的temperature_alert和battery_low事件。编辑src/main/common/event/EventType.java:

public enum EventType { VIEW("view"), CART_ADD("cart_add"), PAY_SUBMIT("pay_submit"), PAY_SUCCESS("pay_success"), // ← 新增以下两行 TEMPERATURE_ALERT("temperature_alert"), BATTERY_LOW("battery_low"), // ← 保留原有构造方法 private final String code; EventType(String code) { this.code = code; } public String getCode() { return code; } // ← 新增静态方法:根据 code 查找枚举,用于反序列化 public static EventType fromCode(String code) { for (EventType type : values()) { if (type.code.equals(code)) { return type; } } throw new IllegalArgumentException("Unknown event type: " + code); } }

5.2 步骤二:扩展 Avro Schema,支持新事件字段

log-schema.avsc是数据契约,必须同步更新。原 Schema 中LogEvent记录包含event_type: string和event_data: string(JSON 字符串)。现在temperature_alert需要额外字段temperature_celsius: double和device_id: string。

修改src/main/resources/log-schema.avsc:

{ "type": "record", "name": "LogEvent", "fields": [ {"name": "event_time", "type": "long"}, {"name": "session_id", "type": "string"}, {"name": "event_type", "type": "string"}, {"name": "event_data", "type": ["null", "string"]}, // ← 新增字段(仅当 event_type 为 temperature_alert 时有效) {"name": "temperature_celsius", "type": ["null", "double"]}, {"name": "device_id", "type": ["null", "string"]} ] }

注意:Avro 不支持条件字段,所以temperature_celsius和device_id对所有事件都存在,但只在对应事件中赋值。这是权衡——避免 Schema 太复杂,且 Spark/Flink 处理 null 字段成本低。

5.3 步骤三:修改 Simulator 和 Flink Consumer,接入真实 Kafka Topic

src/simulator/kafka_producer.py是模拟器向 Kafka 发送数据的入口。你需要:

  1. 修改KAFKA_TOPIC常量为你的真实 topic 名;
  2. 在generate_event()函数中,按比例生成新事件:
# src/simulator/kafka_producer.py KAFKA_TOPIC = "iot_device_events" # ← 替换为你的真实 topic def generate_event(): # 原有电商事件逻辑... if random.random() < 0.05: # 5% 概率生成 IoT 事件 if random.random() < 0.7: # temperature_alert return { "event_type": "temperature_alert", "event_time": int(time.time() * 1000), "session_id": f"iot-{uuid.uuid4()}", "event_data": json.dumps({"alert_level": "high"}), "temperature_celsius": round(random.uniform(80.0, 120.0), 1), "device_id": f"dev-{random.randint(1000, 9999)}" } else: # battery_low return { "event_type": "battery_low", "event_time": int(time.time() * 1000), "session_id": f"iot-{uuid.uuid4()}", "event_data": json.dumps({"remaining_percent": random.randint(5, 15)}), "device_id": f"dev-{random.randint(1000, 9999)}" } # ... 其他事件

Flink 端修改src/main/flink/consumer/KafkaLogConsumer.java:

// src/main/flink/consumer/KafkaLogConsumer.java Properties props = new Properties(); props.setProperty("bootstrap.servers", "kafka-broker:9092"); props.setProperty("group.id", "log-consumer-group"); // ← 修改 topic 名 FlinkKafkaConsumer<LogEvent> consumer = new FlinkKafkaConsumer<>( "iot_device_events", // ← 与 simulator 一致 new LogEventSchema(), props );

5.4 验证:用 Flink SQL Client 快速测试新事件流

别等整个 pipeline 跑完。Flink 自带 SQL Client,可交互式验证:

# 进入 Flink 容器 docker exec -it flink-jobmanager /bin/bash # 启动 SQL Client ./bin/sql-client.sh # 执行查询(自动识别 Kafka topic schema) Flink SQL> CREATE TABLE iot_events ( > event_time BIGINT, > session_id STRING, > event_type STRING, > temperature_celsius DOUBLE, > device_id STRING > ) WITH ( > 'connector' = 'kafka', > 'topic' = 'iot_device_events', > 'properties.bootstrap.servers' = 'kafka-broker:9092', > 'format' = 'avro' > ); Flink SQL> SELECT event_type, COUNT(*) FROM iot_events GROUP BY event_type;

如果看到temperature_alert和battery_low的计数持续增长,说明接入成功。此时,你已完成了从事件定义、Schema 扩展、数据生成到实时消费的全链路改造——而核心的会话切割、宽表生成、对账逻辑,一行代码未改。

从那以后我每次接到新业务事件需求,第一件事就是打开EventType.java和log-schema.avsc,花 10 分钟把枚举和字段补全,再跑一遍kafka_producer.py和 Flink SQL Client。这套契约驱动的扩展方式,让我在三个月内支撑了 7 个不同部门的事件接入,没出过一次 Schema 不一致的线上事故。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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