拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java在线教育平台源码拆解:数仓分层与Flink实时窗口计算

Java在线教育平台源码拆解:数仓分层与Flink实时窗口计算

简介:基于Java技术的在线教育平台6.2版本设计源码,是一套面向教育机构、Java开发者和教育技术研究者的完整项目工程。平台涵盖用户管理、课程管理、视频播放、在线考试、作业提交等核心功能,后端以Java实现业务逻辑,并通过XML配置统一管理数据库连接、服务器设置与安全策略,整体具备良好的模块化和稳定性,适合长时间部署运行。压缩包共69个文件,其中46个Java源文件承担主要业务处理,20个XML配置文件负责运行参数与模块装配,另包含properties、txt及gitignore等辅助文件,包体仅267KB,结构紧凑便于快速查阅。项目目录按online-edu-dwd、dws、dim、common等模块划分,体现出清晰的数据仓库分层设计思路,用于组织事实数据、维度数据及通用业务逻辑,可帮助学习者理解在线教育场景下的数据加工与分析框架。该资源已有256人学习下载,6.2版本经过多轮迭代,既可作为二次开发的基础工程,也可为智慧教学平台建设提供高质量参考。

1. 基于Java的在线教育平台源码:先把这 68 个文件拆清楚

这份名为「在线教育平台 6.2 版本」的 Java 源码包,不是常见的单体 SSM 教务系统,而是一套按数据仓库四层架构组织的大数据教学平台工程。从目录里的online-edu-dwd、online-edu-dws、online-edu-dim、online-edu-common四个模块命名能直接看出来,它把在线教育业务拆成了「公共底座 + 维度层 + 明细层 + 服务层」,每个模块下再按主题拆分交易、流量、考试、互动等子工程。适合两类人:一是想学 Java 后端工程化拆分、Maven 多模块聚合的开发者,二是要做教育行业数据仓库建模、拿真实业务练手的数仓工程师。全文 68 个文件中 Java 源码占 46 个、XML 配置占 20 个,配合.gitignore和pom.xml,已经把一套可编译、可扩展、面向长周期迭代的 Java 在线教育平台骨架完整呈现出来。

2. 从 Maven 父工程开始:读懂多模块聚合与依赖收口

看这种带pom.xml的源码包,我习惯从父工程先读,而不是一头扎进业务代码。这套工程里pom.xml分三层:最外层是根聚合 POM,第二层是各模块(common、dim、dwd、dws)各自的 POM,第三层是 dwd、dws 内部按主题拆分的子模块 POM。三层结构对应的是 Maven 的继承与聚合关系,先厘清这层,后面编译、打包、定位依赖冲突都会轻松很多。

2.1 父 POM 的 dependencyManagement:版本统一是第一步

根pom.xml的dependencyManagement定义了所有子模块共享的依赖版本。Java 在线教育平台这类工程最常见的坑就是「A 模块用的 Fastjson 1.2.x,B 模块用了 2.0.x,联调时 JSON 序列化行为不一致」。在父 POM 里把版本统一收口,子模块只写groupId和artifactId,不写版本号,是 Maven 多模块项目的标准做法。我一般还会顺便确认.gitignore是否把target/、*.class、IDE 配置都排除干净,避免无关文件混进版本库。

<!-- 父POM片段:统一版本,子模块不重复声明 --> <dependencyManagement> <dependencies> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-java</artifactId> <version>1.13.6</version> </dependency> <!-- 其他公共依赖统一声明版本 --> </dependencies> </dependencyManagement> <modules> <module>online-edu-common</module> <module>online-edu-dim</module> <module>online-edu-dwd</module> <module>online-edu-dws</module> </modules>

dependencyManagement只管理版本、不引入依赖,子模块需要哪个依赖再显式声明groupId和artifactId,版本号自动继承自父 POM。<modules>里列出的四个模块,构建时 Maven 会按依赖关系自动排序,不用手动指定构建顺序。

2.2 common 模块:工具类与公共配置的落点

online-edu-common是全工程的底座,其他三个模块都会依赖它。打开这个模块的源码,重点是看有没有统一返回结构、日期时间工具、JSON 工具、常量定义、数据库连接配置读取等。Java 工程里这些代码最容易写成「每个模块各写一份」,而好工程一定是把它们收敛到 common 里,通过mvn install让其他模块引用本地仓库里的 common 包。拆这份源码时,我会先看 common 里定义了哪些类,如果连「课程状态枚举」「用户角色常量」都在 common 里,说明代码规范意识是到位的。

2.3 避坑:Maven 多模块编译时的三个高频问题

  • 现象:mvn clean package报「Cannot resolve symbol」或「程序包不存在」,但 IDE 里明明能看到依赖。原因:子模块依赖的另一个模块没有先执行mvn install,本地仓库里没有对应构件。解决:在根目录先跑mvn clean install -DskipTests,让 Maven 按模块依赖顺序把每个模块安装到本地仓库,再打包就不会缺依赖了。
  • 现象:编译环境用 JDK 17,但源码的pom.xml写的是maven.compiler.source和target版本不一致,提示「源发行版 XX 需要目标发行版 XX」。原因:Java 编译器的 source 和 target 版本不匹配,比如 source 设为 8、target 设为 17,JDK 17 编译器会拒绝工作。解决:把<maven.compiler.source>和<maven.compiler.target>统一设为项目需要的版本,或在<properties>里加<java.version>并配合maven-compiler-plugin的release参数。
  • 现象:mvn test时单元测试连了数据库,导致构建挂掉。原因:某些测试类直接操作数据源,没有用内存数据库或 Mock 方式隔离。解决:构建时直接用-DskipTests跳过测试,或者把需要外部依赖的测试类用@EnabledIfSystemProperty做条件启用,本地验证再手动放开。

3. 仓库分层设计:DIM、DWD、DWS 的命名逻辑与实现方式

从目录结构能看出,这套工程不是普通的业务管理系统,而是一套「数据仓库 + 在线教育业务」的混合工程。online-edu-dim是维度层,online-edu-dwd是明细层,online-edu-dws是服务层,对应数仓建模里的「维度—明细—汇总」三层结构。理解这三层的关系,是拆解这份源码的关键。

3.1 DIM 维度层:课程、用户、教师维度的建模

维度层的核心是给事实数据提供描述信息。在在线教育场景里,至少会有这几个核心维度:用户维度(学生、教师、管理员)、课程维度、班级/年级维度、科目维度。online-edu-dim模块下对应的 Java 类会定义这些维度的主键、名称、状态、分类、创建时间、更新时间等字段。正常情况下,维度数据应该支持缓慢变化维(SCD)策略——比如学生从「在读」变「毕业」,如果要保留历史事实对应的旧状态,就需要用拉链表或增加失效时间字段的方式建模。多数初版工程只保留最新状态,做到 SCD1 就不错了。

3.2 DWD 明细层:交易、学习行为的事实数据落地

online-edu-dwd下面列出的子模块名称暴露了业务范围:trade-order-detail(交易订单明细)、trade-order-payment-sucess(支付成功明细)、base-db(基础数据)、base-log(日志数据)。从这里能看出两个数据来源:业务数据库的同步数据和用户行为日志数据。交易订单明细这类是业务库同步的,而视频播放、搜索关键词、页面浏览这类是日志埋点采集的。

// 订单明细 DWD 处理的伪代码示意:清洗并落事实表 public class TradeOrderDetailProcessor { public void process(JSONObject rawOrder) { // 1. 过滤无效订单:金额为0或状态非有效 if (rawOrder.getBigDecimal("amount").compareTo(BigDecimal.ZERO) <= 0) { return; } // 2. 补全维度外键:根据courseId关联课程维度表 Long courseDimId = getCourseDimId(rawOrder.getString("courseId")); // 3. 写入明细事实表,供DWS层聚合 insertFactOrder(buildFactRow(rawOrder, courseDimId)); } }

这段逻辑做了三件事:第一步校验订单有效性,过滤金额非法或状态异常的数据,数仓里最怕脏数据流入上层;第二步通过课程 ID 关联维度表,补齐课程维度外键,这是星型模型的标准做法;第三步写入明细层事实表。如果你的工程里订单量不大,也可以不做关联,直接存原始课程 ID,由查询时再关联,但数据量上来后查询性能会明显下降。

3.3 DWS 服务层:按主题聚合出可查的宽表

online-edu-dws下的模块名值得逐一看:traffic-video-video-play-window(视频播放窗口聚合)、traffic-source-keyword-page-view-window(搜索关键词页面浏览)、trade-cart-add-window(加购窗口)、test-question-exam-window(试题考试窗口)、interaction-course-comment-window(课程评论窗口)。模块名里都带window,可以判断这套工程的 DWS 层用的是流式窗口计算,按 Flink 的 TUMBLE、HOP 窗口做实时聚合——课程 id、UV、PV、播放时长、完播率、加购次数、考试平均分,这些指标落成宽表后,供上层可视化大屏或报表直接查询。

// DWS 层:统计每分钟课程视频播放窗口的聚合指标 DataStream<VideoPlayEvent> stream = ...; // 来源:DWD层明细数据 stream.keyBy(event -> event.getCourseId()) .window(TumblingProcessingTimeWindows.of(Time.minutes(1))) .aggregate(new VideoPlayAggregateFunction()) .map(agg -> { // 往DWS宽表写入课程ID、窗口开始时间、PV、UV、总播放时长 return new CourseVideoPlayWindow( agg.getCourseId(), agg.getWindowStart(), agg.getPv(), agg.getUv(), agg.getTotalDuration() ); });

keyBy按课程 ID 分区,保证同一课程的数据进入同一个窗口计算;TumblingProcessingTimeWindows.of(Time.minutes(1))定义 1 分钟的滚动窗口,每 1 分钟产出一条统计结果;aggregate传入自定义的聚合函数,AggregateFunction需要实现createAccumulator、add、getResult、merge四个方法,底层维护一个累加器,把 PV、UV、总播放时长都放在累加器里增量更新,避免每条数据都全量重算。

3.4 维度与事实的关联:广播流还是预关联

维表数据通常不大,处理方式有两种:一种是把维表做成 Flink 广播流,用BroadcastStream在算子内部做维度补全,适合维表频繁更新且实时性要求高的场景;另一种是启动时加载全量维表到 Map 或本地缓存,定时刷新,适合课程、用户这类变化不频繁的维度。这套工程里我注意到online-edu-dws-traffic-sc-isNew-page-view-window的isNew标记——判断新老访客一定得访问 Redis 或状态存储,这属于跨算子状态访问,在实际调优时是最容易性能翻车的地方,后面会展开讲。最终的验证指标是:新建维表后 1 分钟内能查询到,关联维表的数据在 5 秒内完成补全,窗口数据落库后延迟不超过 30 秒。

4. 实时窗口计算的核心:各主题窗口的实现拆解

前面说过 DWS 层的模块名都带window,这说明 6.2 版本已经把实时计算作为核心能力。实时数仓和离线数仓的差别在于:离线是每日 T+1 批量计算,实时是秒级或分钟级产出。这套工程把交易、流量、考试、互动四个业务域都做了窗口聚合,拆源码时值得重点跟踪的是每张窗口表的事件字段、窗口类型和输出格式。

4.1 交易域窗口:加购、支付成功、订单明细的指标口径

online-edu-dws-trade-cart-add-window统计加购事件,输出的是每分钟、每门课程的加购次数和加购人数;online-edu-dws-trade-payment-sucess-window统计支付成功订单,指标包括支付金额 GMV、支付订单数、支付人数。这两个窗口的代码实现模式基本一致:从 Kafka 读 DWD 明细 → 按课程 ID keyBy → 开窗口 → 聚合并输出到 ClickHouse 或 Doris。需要注意的是支付成功金额的累计,如果窗口内出现重复支付回调,需要依靠订单号 + 支付流水号去重,不少工程在窗口去重上翻过车,明明支付成功了两次回调,统计的 GMV 翻了一倍。

4.2 流量域窗口:页面浏览、关键词搜索、视频播放的埋点聚合

在线教育平台的流量分析比电商复杂,因为要关注视频播放这类独特行为。视频播放事件至少要记录:videoId、courseId、userId、playDuration、playTimestamp。播放时长的统计口径要提前定清楚——是「本次播放的连续时长」还是「累计播放时长」,不同口径对完播率、平均观看时长这些指标影响巨大。online-edu-dws-traffic-page-view-window就是 PV/UV 的基础统计,online-edu-dws-traffic-source-keyword-page-view-window则统计用户通过搜索关键词进入页面的情况,从教育平台的获客视角看,这个数据能直接反馈哪些搜索词带来了有效流量。做这类聚合时有个常见问题:前端上报事件有延迟或乱序,开窗口时如果不做 Watermark + 允许延迟。具体做法是设置WatermarkStrategy.forBoundedOutOfOrderness(Duration.ofSeconds(10)),让轻微乱序的数据不至于丢掉,但延迟数据过多时窗口输出会晚,这属于实时计算里的时间语义取舍。

4.3 考试与互动窗口:在线考试和课程评论的实时监控

online-edu-dws-test-question-exam-window这个模块名有点意思——它是按题目维度(test-question)和考试维度(exam)分别建的窗口。统计维度包括某个时间段内的考试提交次数、平均得分、试题正确率、参与考试人数。教育场景里,这些指标可以在考试进行中实时展示给监考教师,比如某道题错误率超过 60%,教师可以在线介入讲解。interaction-course-comment-window统计课程评论区的新增评论量、评论用户数,再配合关键词过滤,可以实现敏感词的实时预警。实现评论窗口聚合时,要注意文本字段占用的内存比较大,聚合结果不要保留评论原文,只保留commentCount和commentUserCnt这样的数值指标。

// 考试提交窗口:求每分钟考试平均分 DataStream<ExamSubmitEvent> examStream = ...; examStream.keyBy(e -> e.getExamId()) .window(TumblingEventTimeWindows.of(Time.minutes(5))) .aggregate(new AggregateFunction<ExamSubmitEvent, ExamAccumulator, ExamScoreStat>() { @Override public ExamAccumulator createAccumulator() { return new ExamAccumulator(); // 累加器:总分数、总人数 } @Override public ExamAccumulator add(ExamSubmitEvent value, ExamAccumulator acc) { acc.setTotalScore(acc.getTotalScore() + value.getScore()); acc.setUserCount(acc.getUserCount() + 1); return acc; } @Override public ExamScoreStat getResult(ExamAccumulator acc) { return new ExamScoreStat(acc.getExamId(), acc.getUserCount(), acc.getTotalScore() / (double) acc.getUserCount()); // 平均分 } @Override public ExamAccumulator merge(ExamAccumulator a, ExamAccumulator b) { return ExamAccumulator.merge(a, b); // 分布式合并累加器 } });

这里用TumblingEventTimeWindows,意思是以事件时间而不是处理时间为准,相比前面的处理时间窗口更贴近业务真实发生时刻。AggregateFunction的merge方法用于并发分区的累加器合并,如果两个子任务的累加器无法合并,窗口结果就会算错。每到一个窗口结束点,getResult被调用,输出平均分等最终数值。

4.4 避坑:实时窗口任务常见的四个运行期问题

  • 现象:窗口算出来的 PV/UV 和离线报表对不上,总是偏低或偏高。原因:时间语义不一致——有的窗口用处理时间ProcessingTime,有的用事件时间EventTime,或上报日志里没有携带统一格式的时间戳。解决:在main方法里显式设置env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime),并在数据源端保证timestamp字段是毫秒级 Linux 时间戳,统一语义后再核对指标。
  • 现象:窗口数据一直不出结果,等了十几分钟才有输出。原因:水位线设置不收敛。比如forBoundedOutOfOrderness(Duration.ofSeconds(300))设成了 5 分钟,窗口要等水位线越过窗口末尾才会触发计算,延迟被拉长。解决:线上根据日志乱序情况实测,通常 10~30 秒够用,不要为了「稳」无限放大。
  • 现象:某个课程的 UV 特别高,远超正常范围。原因:用户去重逻辑不对。如果按userId去重,游客没有 userId,就会每个事件都算一个新用户,UV 虚高。解决:游客用sessionIdIP UA 生成匿名 ID 参与去重,并把isNew标记逻辑入口单独收敛到一个类里维护,前后端同时复用。
  • 现象:窗口数据全部输出正常,但隔几天内存溢出或检查点恢复失败。原因:开窗的 key 数量过多,状态数据增长超过 RocksDB 能力,或窗口生命周期没有配置allowedLateness和stateTtl。解决:给窗口状态配置 TTL,例如stateTtlConfig设置为 1 小时;同时allowedLateness根据业务容忍度设置为 1~2 个窗口周期,不要让迟到的数据无限修改结果。

5. 从 DWD 到 DWS 的关键链路:跨层代码生成和隐患排查

这套工程里 DWD 和 DWS 模块各自按主题细分了十几个子工程,如果每个子工程的代码都是手写的,工作量很大,所以 6.2 版本这类工程通常都配合了代码生成器,从 SQL 建表语句自动生成 Java 的实体类、Mapper 和 XML 配置。在online-edu-dwd里看到的pom.xml和src结构,就是用 Maven 多模块的方式把生成结果统一管理起来。

5.1 DWD 层事实表的幂等写入与主键策略

事实表数据的写入场景是「昨天已经同步过的订单今天又同步了一次」,如果直接 insert 就会产生重复数据。解决办法是给事实表设置业务主键,来源数据里同一个订单号(orderId)在表里只能存在一份,更新策略用insert on duplicate key update或者「先删除后插入」。这套工程的online-edu-dwd-trade-order-detail里,orderId 就是主键。如果源系统发生订单纯退款、改金额、课程更换,重跑时以 orderId 为键整行覆盖,就能避免底层明细出现重复记录。我自己拆工程时,会重点确认这个键是否在实体类上有唯一索引标记,很多工程的坑在于「代码上设了主键,但建表 SQL 里没有唯一索引,数据库层面约束缺失」。

5.2 DWS 层指标的可累加性与 rollup 设计

DWS 层输出的宽表不能只是把明细原样搬过来,而是要做指标的预聚合。像 PV、加购次数、播放次数、评论数、考试提交数这类可累加指标,直接做 sum 预聚合是安全的;但 UV、平均播放时长、完播率这类非可累加指标,必须先算好中间结果再在查询层二次计算。例如 UV 如果要按「一天内访问多次算一次」,必须在窗口内用布隆过滤器或 KeyedState 去重后输出,不能把每分钟 UV 加起来当作当天 UV。阅读这份源码时,建议关注online-edu-dws-trade-payment-sucess-window的累加器实现,看它是不是在累加器里同时维护了去重集合和数值累加两种状态。

5.3 避坑:跨层代码容易出现的三种隐患

  • 隐患一:层与层之间的字段命名不统一。比如 DWD 叫total_pay_amount,到 DWS 改成了sum_amount,数仓工具链里排查口径就会很痛苦。解决办法是进入工程第一天就讨论确认公共字段规范,沉淀成文档,在所有模块统一遵守。
  • 隐患二:维表更新后,已算好的宽表没重算。课程从「未上架」改成「已上架」,DWS 查询结果里还是旧状态,导致报表数据不一致。解决办法是维表变更时给 DWS 表加dim_version字段,版本号变化时触发数据重算或加装分区。
  • 隐患三:ID 生成策略冲突。早期的 Java 工程用数据库自增主键或UUID,到分布式的数仓环境里,多节点并发写入会出现主键冲突。解决办法是统一用雪花算法生成 ID,并在实体类上留好id字段位。

6. 最后的细节:把这份源码跑起来的完整验证路径

很多拿到源码的人会犯一个错——到处翻代码,却不去验证「这份工程能不能在本地跑起来」。工程是否能编译、是否有测试数据、有没有文档说明,才是它真正能带来价值的起点。能跑起来的源码和只能看的源码,是两种完全不同的东西。

6.1 本地验证:从源码到可执行

先准备一个干净的 JDK 环境和 Maven 环境(建议 JDK 1.8 或 JDK 11,不要直接用 JDK 17 跑老工程,兼容性风险很高)。然后在工程根目录依次执行:

# 先跳过测试打包,验证所有子模块能否通过编译 mvn clean install -DskipTests # 查看各子模块的依赖树,排查冲突 mvn dependency:tree > dependency-tree.txt # 若工程带可执行的 main 入口,单独启动某个模块 mvn exec:java -pl online-edu-dws-test-question-exam-window \ -Dexec.mainClass="com.onlineedu.dws.exam.ExamWindowJob"

第一步如果通过,说明这份源码在「编译层面」是完整的,不会缺类缺依赖;第二步把依赖树打印出来,能直接看到有没有多个版本的冲突,比如commons-lang3同时出现 3.4 和 3.12,这时候需要手动排除低版本;第三步是模块级验证,可选,看模块是否自带 main 入口。如果这套工程的数据源是 Kafka 和 MySQL,那本地跑实时窗口任务还需要准备一套测试环境或者用测试数据文件塞进去,这一步是没文档时最费时间的部分。

6.2 对关键指标做一线验证

在你真正动手改造任何字段之前,先验证三件事:第一,DWD 层的订单与播放事件能否正常产生数据;第二,DWS 层的窗口计算能不能按设定周期输出结果;第三,输出表的字段和上游 join 之后有没有明显的数据质量问题。我用这套思路拆过不少源码,最快的一次花了两个晚上才理清某个模块的完整流程,但走通一遍之后,后面改字段、调整窗口粒度、新增指标就快很多了。

# 查询最近5分钟的窗口输出(以DWS视频播放窗口为例) SELECT course_id, window_start, SUM(pv) AS total_pv, COUNT(DISTINCT user_id) AS uv FROM dws_traffic_video_play_window WHERE window_start >= DATE_SUB(NOW(), INTERVAL 5 MINUTE) GROUP BY course_id, window_start ORDER BY total_pv DESC LIMIT 10;

这个查询就是验证“窗口数据落库后延迟不超过 30 秒”这个服务等级协议的常规手段。如果查出来的数据和明细层直接COUNT(*)对不上,优先怀疑上游上报日志少了字段,或者窗口聚合条件漏了keyBy分区的 key——这类问题排查起来,基本就是「看日志 → 翻源码 → 定位逻辑」的循环,和网上说的「玄学定位」完全不同,每一步都有迹可循。

6.3 别急着改功能:先定好改造边界

拿到这份 6.2 版本源码之后,我最大的忠告是:先用最多半天把模块结构、数据流向、核心聚合逻辑看完,然后立刻锁定你要改的一个点,而不是一上来就想着「重构全部」。这份工程的价值在于它是一套按业务域拆好的教育数仓骨架——如果你想加「直播观看」这个新业务,最合理的做法是参照traffic-video-video-play-window的代码,加一个traffic-live-play-window模块,复用它的窗口聚合逻辑、输出结构、维表关联方式,而不是在原有模块里改来改去。从那次之后,我拆每一份源码都会强制先跑一遍mvn clean install、先写一个验证 SQL,再动业务代码——顺序反了,后面一定会翻车,这是我希望帮你避开的那个坑。

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

返回列表