简介:面向高校期末大作业的Java与Hadoop结合游戏用户数据分析系统源码,适合正在学习大数据、Hadoop生态及Java Web开发的学生参考,可用于课程设计、毕业设计或实战训练。系统围绕游戏用户行为的数据处理链路设计,涵盖日志采集、数据清洗与预处理、HDFS分布式存储、MapReduce统计分析及Web可视化展示等核心模块,并涉及用户画像构建、流失预测、消费行为分析等典型场景。压缩包共21个文件,总大小2.1MB,包含JSP页面、Java源码、SQL脚本、JS/CSS前端资源、JAR依赖及项目配置文件,结构清晰便于查阅。目前已有42人学习使用。资料内附完整项目源码、数据库初始化脚本与README说明,导入开发环境即可运行调试,能帮助读者深入理解Java与Hadoop的集成方式,以及数据分析系统从数据采集、存储到结果展示的完整落地实现。
1. 期末大作业里的 Hadoop 游戏用户数据分析系统:它到底拆开是什么
游戏运营每天要处理上亿条玩家行为日志,单机写个统计程序跑两小时才出结果,Hadoop 的价值就是把这类批处理拆成分布式任务。这套 Java 与 Hadoop 结合的游戏用户数据分析系统源码,就是一个典型的期末大作业:用 Java 写 MapReduce 作业做离线统计,用 HDFS 存原始日志,再把结果落回 MySQL,最后由 JSP 页面展示玩家活跃、付费、习惯等报表。整个项目的核心不是算法,而是「日志怎么进、指标怎么算、页面怎么出」的数据链路。适合正在做 Hadoop 课程设计、想借鉴完整源码结构的人,也适合想搞懂 Java 和 Hadoop 怎么配合的初中级开发者。
2. 从游戏日志到 HDFS:数据采集与预处理的 Java 实现
2.1 src 与 dbgame 目录里藏着什么样的数据链路
项目源码打开后,src 目录通常被拆成几个包子模块,对应采集、预处理、MapReduce、DAO 和 Servlet。dbgame 是配套的 MySQL 数据库,保存最终统计结果和用户维度信息。先理清数据流向,写代码才不绕:游戏服务器产生的行为日志(玩家操作、充值到账、登录登出)先被采集端读取,经过清洗和字段规整后写入 HDFS;随后 MapReduce 作业按天或按小时读 HDFS 上的文本,算出活跃数、付费金额、会话时长等指标;结果写入 MySQL 的几张统计表;Web 层用 JSP 连接 MySQL 把结果渲染成页面。
2.2 采集端:用 Java IO 按行读日志,别一上来就接 Kafka
很多同学一提到日志采集就想到 Kafka、Flume,但期末项目的日志量往往在一个节点能扛住的范围内,直接用 Java 的 BufferedReader 按行读、批量写,反而更好调试。采集端最常见的做法是定时扫目录,把新产生的 .log 文件读取后写入 HDFS。下面这段代码演示了最基础的「读一行、切字段、写出」的骨架:
// LogCollector.java - 读取游戏日志并输出为 tab 分隔的清洗后文本 import java.io.BufferedReader; import java.io.FileReader; import java.io.IOException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.regex.Matcher; import java.util.regex.Pattern; public class LogCollector { // 示例日志格式:2025-01-05 10:23:45|user_1001|login|0|ios|1.2.3 private static final Pattern LOG_PATTERN = Pattern.compile( "^(\\S+)\\s+(\\S+)\\|(\\S+)\\|(\\S+)\\|(\\S+)\\|(\\S+)\\|(\\S+)$"); public static String parseLine(String line) { Matcher m = LOG_PATTERN.matcher(line); if (!m.matches()) { return null; // 非法的行直接丢弃 } String date = m.group(1); // 日期 String time = m.group(2); // 时间 String userId = m.group(3); // 玩家ID String event = m.group(4); // 事件类型 String amount = m.group(5); // 金额,登录为0 String platform = m.group(6); // 平台 return String.join("\t", date + " " + time, userId, event, amount, platform); } public static void main(String[] args) throws IOException { if (args.length < 2) { System.err.println("用法: java LogCollector <输入日志> <输出文件>"); return; } try (BufferedReader reader = new BufferedReader(new FileReader(args[0])); java.io.BufferedWriter writer = new java.io.BufferedWriter( new java.io.FileWriter(args[1]))) { String line; while ((line = reader.readLine()) != null) { String parsed = parseLine(line); if (parsed != null) { writer.write(parsed); writer.newLine(); } } } } }这段代码先把「|」分隔的原始日志转成统一使用「\t」分隔的格式,date 和 time 合并成完整时间戳,amount 在后续 MapReduce 里会转成数字累加。参数上,输入和输出路径均通过 main 参数传入,方便在 crontab 里配合不同的日期变量跑批。这段逻辑虽然简单,但要做好 batch 控制:常见做法是先用一个List<String>攒 1000 行再一次性写入,减少 IO 次数。采集端还需要补一条清理策略:HDFS 上按/user/game/logs/yyyy/MM/dd/建目录,定时检查某天之前的数据是否已经完成统计,完成就归档或删除,避免小文件堆积。
2.3 预处理:清洗规则不是越多越好,能落成代码的才写
预处理做得太重反而拖慢整条链路。我看到这套源码里的预处理职责集中在三件事:去重、补全、标记。去重是指玩家重复点击造成的同一条日志出现多次,可以简单用「时间戳+玩家ID+事件类型+金额」做 MD5 作为唯一键;补全是指把时区、平台、渠道这些固定字段补上;标记则是给日志打上「可分析」或「需人工复核」的标签。下面这张表是预处理阶段必须留意的字段:
| 字段 | 原始日志来源 | 清洗规则 | 写入后的格式 |
|---|---|---|---|
| event_time | 日期+时间 | 统一为 yyyy-MM-dd HH:mm:ss | 字符串 |
| user_id | 玩家唯一标识 | 去除首尾空格,非法字符替换 | 字符串 |
| event_type | login/pay/click 等 | 全转小写,白名单校验 | 枚举字符串 |
| pay_amount | 充值金额 | 保留两位小数,负数置 0 | 十进制字符串 |
| platform | ios/android/pc | 映射为统一代号 | 字符串 |
预处理模块的输出最好直接写成 MapReduce 能读的格式,不要输出 XML 或 JSON。因为 Hadoop 读纯文本加分隔符最简单,TextInputFormat直接按行切分,Mapper 里用String.split("\t")就可以拿到每个字段。这里还有一个关键点:游戏日志可能是 UTF-8,也可能混入 GBK 的旧数据,读取时统一用 UTF-8,遇到无法解码的字符直接替换为空格,避免后续正则匹配失败。可以在BufferedReader构造时指定Charset.forName("UTF-8")。另外,预处理后的数据如果量很大,建议用SequenceFile或 Parquet 存储,但期末项目里纯文本足够,别为了性能把复杂度拉高。
3. MapReduce 作业编写:用户活跃度与消费行为统计的核心逻辑
3.1 先拆需求:哪些统计指标适合用 MapReduce 算
游戏用户数据分析系统里最常见三类需求:活跃分析、付费分析、习惯分析。活跃分析关心 DAU/WAU、在线时长分布;付费分析关心 ARPU、付费率、首充金额;习惯分析关心登录时段、关卡停留时间。这些指标有一个共同点:输入是全量日志,输出是聚合值,天然适合 MapReduce 的 split-merge 模式。不要把所有需求都塞进一个作业,应该按指标拆分。比如日活跃用一个作业,消费聚合用另一个作业,这样单个作业失败时可以独立重跑,排查问题也更方便。
3.2 Mapper 与 Reducer 的 Java 实现:以日活跃与消费聚合为例
先看日活跃统计。输入是预处理后的日志行,字段顺序为:event_time、user_id、event_type、pay_amount、platform。这里只关心 login 事件,按日期作为 key,玩家 ID 写入 value,Reducer 里用 Set 去重。代码如下:
// DailyActiveMapper.java import org.apache.hadoop.io.IntWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Mapper; import java.io.IOException; public class DailyActiveMapper extends Mapper<Object, Text, Text, Text> { private Text outKey = new Text(); private Text outValue = new Text(); @Override protected void map(Object key, Text value, Context context) throws IOException, InterruptedException { String[] fields = value.toString().split("\t"); if (fields.length < 4) return; String eventTime = fields[0]; // 2025-01-05 10:23:45 String userId = fields[1]; String eventType = fields[2]; if (!"login".equals(eventType)) return; // 只统计登录事件 String day = eventTime.substring(0, 10); // 截取 yyyy-MM-dd outKey.set(day); outValue.set(userId); context.write(outKey, outValue); } }// DailyActiveReducer.java import org.apache.hadoop.io.IntWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Reducer; import java.io.IOException; import java.util.HashSet; import java.util.Set; public class DailyActiveReducer extends Reducer<Text, Text, Text, IntWritable> { @Override protected void reduce(Text key, Iterable<Text> values, Context context) throws IOException, InterruptedException { Set<String> userSet = new HashSet<>(); for (Text val : values) { userSet.add(val.toString()); } context.write(key, new IntWritable(userSet.size())); } }Mapper 里用event_type过滤,只有 login 才向下传递,减少了 shuffle 的数据量。Reducer 将所有相同的 day 的玩家 ID 放入HashSet,天然去重,输出 DAU。这里要注意:如果玩家昵称里有空格或 tab,预处理阶段必须清理,否则split后字段错位。
再做消费聚合。输入同样来自日志,但这次需要累加每个玩家的充值总金额和充值次数。Mapper 输出玩家 ID 和金额,Reducer 做 sum 和 count。注意金额字段可能是字符串,转 double 前需要判断是否为空。
// PayAggregateMapper.java import org.apache.hadoop.io.DoubleWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Mapper; import java.io.IOException; public class PayAggregateMapper extends Mapper<Object, Text, Text, DoubleWritable> { private Text outKey = new Text(); private DoubleWritable outValue = new DoubleWritable(); @Override protected void map(Object key, Text value, Context context) throws IOException, InterruptedException { String[] fields = value.toString().split("\t"); if (fields.length < 4) return; if (!"pay".equals(fields[2])) return; try { double amount = Double.parseDouble(fields[3]); outKey.set(fields[1]); // user_id outValue.set(amount); context.write(outKey, outValue); } catch (NumberFormatException e) { // 金额解析失败,跳过该条日志 } } }Reducer 里维护两个累加变量,最后输出玩家 ID、总金额、次数。如果只关心总金额,可以在 Map 端加一个 Combiner,它和 Reducer 的累加逻辑完全相同,直接在 job 里复用 Reducer 类即可。但要注意 Combiner 不能用于求平均值,因为这种操作不具备结合律,需要先求和再计数。这是 MapReduce 面试里常被问到的一个坑。
3.3 作业提交与参数调优:伪分布式和集群跑的差别
在伪分布式环境下跑通源码后,再上集群难度会小很多。提交命令如下:
hadoop jar game-analysis.jar com.game.analysis.DailyActiveDriver \ /user/game/logs/2025/01/05 \ /user/game/output/dau/20250105 \ -D mapreduce.job.reduces=3参数说明:game-analysis.jar是打包后的作业 JAR,主类要写在 MANIFEST 或手动指定;第一个路径是 HDFS 上的输入目录,第二个是输出目录,输出目录必须不存在,否则 Hadoop 会报FileAlreadyExistsException;-D mapreduce.job.reduces=3指定 reduce 数量。对于日活跃任务,reduce 数量建议保持为 1,因为只输出 31 个键,太多 reduce 会造成大量小文件。
| 参数 | 默认值 | 建议值 | 说明 |
|---|---|---|---|
| mapreduce.job.reduces | 1 | 看输出 key 数量 | DAU 用 1,消费聚合按用户数设 3~5 |
| mapreduce.map.memory.mb | 1024 | 2048 | 日志行长时提高 Mapper 内存 |
| mapreduce.reduce.memory.mb | 1024 | 2048 | 去重集合大时提高 |
| mapreduce.job.name | 类名 | 自定义 | 方便 YARN 界面识别作业 |
| mapreduce.map.output.compress | false | true | 压缩后 shuffle 更快 |
伪分布式下还要确认 HDFS 端口和core-site.xml配置正确,否则作业一直卡在Running job状态。集群搭建时要注意 NameNode 和 DataNode 的时间同步,误差过大会导致 RPC 握手失败。另外,如果你在 Windows 上开发,打包后提交到 Linux 集群,必须统一文本文件的换行符,\r\n会让最后一行字段多一个回车,清洗时记得 trim。
4. Web 展示层与 SQL 脚本:JSP 页面如何把 MR 结果变成可读报表
4.1 WebContent 目录结构与页面跳转逻辑
项目里的 WebContent 是标准 Java Web 应用的根目录。Player activity analysis.jsp、Player payment behavior analysis.jsp、Player game habit analysis.jsp 这些页面分别对应活跃、付费、习惯三类报表。js 和 css 目录负责前端样式,WEB-INF 下通常是 web.xml 配置和 class 文件。这几个 JSP 页面的命名非常直白,Servlet 或 JavaBean 从 MySQL 里取数后放到 request 作用域,JSP 用 JSTL 或 scriptlet 渲染表格。访问路径一般是http://localhost:8080/gameanalysis/Player%20activity%20analysis.jsp,注意空格要转义。example.jsp 和 test.jsp 是调试遗留页面,正式演示前建议删掉,避免出现杂乱的前端效果。
4.2 sql.sql 中的表结构设计与 MR 结果回填
sql.sql 是初始化脚本,里面建了 dbgame 库和若干张结果表。常见表结构如下:
CREATE DATABASE IF NOT EXISTS dbgame DEFAULT CHARSET utf8mb4; USE dbgame; CREATE TABLE daily_active_user ( stat_date DATE NOT NULL, dau INT NOT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (stat_date) ) ENGINE=InnoDB; CREATE TABLE user_pay_stat ( user_id VARCHAR(64) NOT NULL, total_amount DECIMAL(12,2) NOT NULL, pay_count INT NOT NULL, PRIMARY KEY (user_id) ) ENGINE=InnoDB;这两张表分别是日活跃统计结果和用户消费聚合结果。MapReduce 作业跑完后,结果在 HDFS 上是文本文件,需要回填到 MySQL。回填有两种常见做法:一是用sqoop export,二是写一个 Java DAO 类读取 HDFS 文件逐条 insert。期末项目用 DAO 类更符合「源码」的调性,你可以在代码里复用到很多次。如果写成ResultExporter.java,记得使用 PreparedStatement 的批量插入和事务,每 500 条提交一次,否则 MySQL 写入会越来越慢。注意daily_active_user表的主键是stat_date,重复统计同一日期时需要ON DUPLICATE KEY UPDATE,否则第二次跑作业会报主键冲突。
4.3 用 JSP + JDBC 渲染活跃报表的完整片段
下面给出一个最小可用的 JSP 片段,展示 DAU 表格。使用 JSTL 遍历结果集,但为了减少依赖,这里直接用 scriptlet 操作 JDBC。实际项目里建议把 JDBC 连接封装到 DAO,页面只做显示。
<%@ page import="java.sql.*, java.util.*" %> <%@ page contentType="text/html; charset=UTF-8" %> <html> <body> <h2>日活跃用户数</h2> <table border="1"> <tr><th>日期</th><th>DAU</th></tr> <% Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { Class.forName("com.mysql.jdbc.Driver"); conn = DriverManager.getConnection( "jdbc:mysql://localhost:3306/dbgame?useUnicode=true&characterEncoding=UTF-8", "root", "your_password"); ps = conn.prepareStatement("SELECT stat_date, dau FROM daily_active_user ORDER BY stat_date DESC LIMIT 30"); rs = ps.executeQuery(); while (rs.next()) { out.println("<tr>"); out.println("<td>" + rs.getDate("stat_date") + "</td>"); out.println("<td>" + rs.getInt("dau") + "</td>"); out.println("</tr>"); } } finally { if (rs != null) rs.close(); if (ps != null) ps.close(); if (conn != null) conn.close(); } %> </table> </body> </html>这段代码里 JDBC URL 带了useUnicode和characterEncoding,是为了避免中文乱码。Class.forName在较新版本的 MySQL 驱动里可以省略,但保留它兼容旧环境。连接密码建议通过配置文件读取,而不是硬编码在 JSP 里。
注意:生产环境不要用 root 直接连数据库,但期末项目可以,别把密码提交到班级仓库。
| JSP 文件 | 对应 SQL 查询 | 数据来源表 |
|---|---|---|
| Player activity analysis.jsp | 按日统计 DAU | daily_active_user |
| Player payment behavior analysis.jsp | 按用户聚合金额与次数 | user_pay_stat |
| Player game habit analysis.jsp | 按时段统计登录次数 | user_login_time_dist |
| New user analysis.jsp | 按注册日期统计新增 | user_info |
对于这个期末项目,Web 层并不需要做得美轮美奂,关键是让答辩老师看到「数据确实经过了 Hadoop 处理」。所以页面上的每个数字,最好再去 HDFS 上找原始输出确认一遍,口径对得上,项目就立住了。另外,数据库连接池可以用 DBCP 或 C3P0,但单机演示场景下直接用 JDBC 连接反而少踩坑,连接池配置不对会报Too many connections,而你只是在展示页面,完全没必要引入。
5. 伪分布式环境下跑通期末项目的四个排错细节
这里直接给四个我帮别人调这个项目时最常遇到的坑,每一个都能让作业卡半天。
第一,HDFS 路径权限。用hadoop fs -put把日志放到/user/game/logs后,如果运行作业时用另一个用户执行,会报Permission denied。简单做法是给该目录递归授权:hadoop fs -chmod -R 777 /user/game,虽然生产环境不推荐,但期末演示够用。这个权限问题只在 HDFS 的根目录下常见,如果你把数据放到当前用户自己的目录下,一般不会碰到。
第二,输出目录残留。同一个作业重复跑之前,必须删除上一次的输出:
hadoop fs -rm -r /user/game/output/dau/20250105或者改一个新的时间戳目录。Hadoop 的核心设计就是输出目录不能存在,这是为了防止覆盖产生脏数据。每次重跑都手动删目录容易忘,建议在工程入口里写一个cleanOutput方法,调用FileSystem.delete前判断路径是否存在。
第三,MySQL 驱动加载不到。JSP 里Class.forName("com.mysql.jdbc.Driver")报ClassNotFoundException时,检查WEB-INF/lib下有没有mysql-connector-java的 jar。Tomcat 的 lib 目录也建议放一份,避免多个应用抢类加载器。用 Maven 打 war 包时,确保pom.xml里的驱动 scope 不是provided,否则不会打进最终包。
第四,map 结果全是空值。先看预处理阶段输出的文件中,\t分割后每个字段是否完整。可以用一行命令快速检查:
hadoop fs -cat /user/game/logs/2025/01/05/part-00000 | head -20 | cat -Acat -A能显示^I代表 tab,$代表行尾。如果看到行尾是^M$,说明文件带了 Windows 换行符,需要在预处理代码里调用line = line.replace("\r", "")。这四个点排查完,项目基本能在一个伪分布式环境里稳定跑通。最后提醒一句:example.jsp 和 test.jsp 是源码附带的调试页,答辩前记得删掉或移出 WebContent,避免因为残留页面影响观感。
本文还有配套的精品资源,点击获取