简介:面向Hadoop与Java大数据学习者,这套基于Hadoop的游戏数据分析系统项目,解决了游戏行业中用户行为日志难以高效处理与可视化分析的问题,适合作为课程设计、毕业设计或大数据入门实践参考。压缩包共包含20个文件,大小约2.1MB;包含6个JSP页面、3个JS文件、2个JAR依赖包、2个CSS样式文件,以及SQL建表脚本、Java源码、工程配置与运行类文件,覆盖从Web展示、前端交互、后端逻辑到数据库初始化的完整结构。目前已有220人学习下载。系统内置玩家活跃度分析、付费行为分析、游戏习惯分析、新用户分析等模块,可帮助开发者学习用Java编写MapReduce作业,理解HDFS存储与分布式计算的执行流程;同时也能掌握原始日志清洗、指标统计、结果存储以及在Web端呈现分析结果的闭环开发方法,适合需要快速上手大数据分析项目的开发者。
1. 游戏日志不会说谎:Hadoop 离线分析怎么支撑运营决策
某个版本更新后,运营发现付费玩家流失率异常,但游戏里没有任何告警。真正暴露问题的是玩家行为日志——某张地图的进入次数骤降、对应充值按钮的点击率跌了一半。这种问题靠数据库聚合查不出来,因为日志分散在上百台游戏服务器上,一天产生几个 GB 的原始数据。这时就需要把日志集中起来做离线分析,也就是这套基于 Hadoop 的游戏数据分析系统在做的事。它是一个典型的"采集 -> 清洗 -> 计算 -> 展示"闭环,用 HDFS 存原始日志、MapReduce 算指标、JSP 页面出报表。适合两类读者:一是想学 Hadoop 但不知道从哪个项目入手的 Java 开发,二是已经跑通 Hadoop 伪分布式、想知道怎么把生态组件串成业务系统的运维或数据工程师。项目的分析维度很务实——玩家活跃度、付费行为、游戏习惯、新增用户,都是运营每天都在看的指标。
2. HDFS 与 MapReduce 选型:为什么游戏日志分析默认走 Hadoop
2.1 游戏日志的数据特征决定了存储方案
游戏日志和业务数据库数据有本质区别。账号表、背包表是结构化数据,读写频繁,适合 MySQL。而玩家行为日志是追加型写入、极少修改、量大且字段不固定——一条支付日志可能带订单号,一条登录日志带设备型号,强行设计成关系表反而痛苦。HDFS 的定位就是"一次写入、多次读取",文件追加写入后不再修改,配合默认 128 MB 的 Block 大小和 3 副本策略,能扛住 PB 级别的日志堆积。
这套系统里,日志的上传路径通常是这样:游戏服务器产生日志 -> Flume 或直接走 HDFS API 落盘到/user/gamelogs/目录 -> 按日期分区,比如logs/2024/06/01/。目录设计很关键,后面跑 MapReduce 时可以直接用日期目录作为输入路径,避免全表扫描。
2.2 MapReduce 的适用边界:离线批处理而非实时计算
很多初学者分不清 MapReduce 和 Flink/Spark Streaming 的使用场景。这套系统选 MapReduce,原因是分析的指标——日活跃用户、付费转化率、留存率——都是 T+1 类型的离线报表,对延迟不敏感,但对吞吐量有要求。MapReduce 的模型足够简单,一个作业由 Map 阶段和 Reduce 阶段组成,中间通过 Shuffle 机制把相同 key 的数据分发给同一个 Reduce 节点。
以玩家活跃度分析为例,日志中的一条记录大致是:
2024-06-01 10:23:45|user_10086|login|ip=192.168.1.10|device=androidMap 阶段把日期和用户 ID 提取出来,输出(2024-06-01, user_10086)。Reduce 阶段对同一日期的用户 ID 去重计数,就是当日活跃用户数(DAU)。注意这个场景和"计数"的区别:去重必须在 Reduce 端完成,不能在 Map 端直接用计数器累加,因为同一用户可能被多个 Map 任务处理。
2.3 项目目录结构与模块职责
解压这个项目以后,能看到明确的模块划分,先从目录结构理解系统边界:
src/ # Java 源码,MapReduce 作业和 Servlet WebContent/ # JSP 页面与前端资源 player activity analysis.jsp payment behavior analysis.jsp Player game habit analysis.jsp New user analysis.jsp WEB-INF/ # web.xml、 classes、 lib sql.sql # 结果表建表脚本 build/ # 编译输出四个 JSP 页面对应四类运营报表:player activity analysis.jsp关注 DAU、留存率;payment behavior analysis.jsp关注付费率、ARPU(平均每用户收入);Player game habit analysis.jsp分析玩家在线时长、登录时段分布;New user analysis.jsp统计新增用户数和新用户转化。sql.sql里是分析结果表,一般会落在 MySQL 中供 JSP 查询展示。这个设计思路值得学习:Hadoop 集群算完的结果不要直接暴露给 Web 层,而是下沉到 MySQL,让 JSP 通过 JDBC 按需查询。
3. 从日志到指标:MapReduce 作业与 Hive ETL 的双链路实现
3.1 DAU 统计的 MapReduce 完整实现
项目里最核心的作业就是玩家活跃度统计。先看一段符合项目结构的标准写法:
import org.apache.hadoop.conf.Configuration; import org.apache.hadoop.fs.Path; import org.apache.hadoop.io.IntWritable; import org.apache.hadoop.io.Text; import org.apache.hadoop.mapreduce.Job; import org.apache.hadoop.mapreduce.Mapper; import org.apache.hadoop.mapreduce.Reducer; import org.apache.hadoop.mapreduce.lib.input.FileInputFormat; import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat; import java.io.IOException; import java.util.HashSet; import java.util.Set; public class DailyActiveUser { // 输入格式:2024-06-01 10:23:45|user_10086|login|device=android public static class DAMapper 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 line = value.toString(); String[] fields = line.split("\\|"); if (fields.length < 3) { return; // 脏数据直接跳过,避免后续 NPE } String date = fields[0].substring(0, 10); // 截取日期部分 String userId = fields[1]; outKey.set(date); outValue.set(userId); context.write(outKey, outValue); } } // Reduce 端用 Set 去重,统计当天活跃用户数 public static class DAReducer extends Reducer<Text, Text, Text, IntWritable> { private IntWritable result = new 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()); } result.set(userSet.size()); context.write(key, result); } } public static void main(String[] args) throws Exception { Configuration conf = new Configuration(); Job job = Job.getInstance(conf, "daily-active-user"); job.setJarByClass(DailyActiveUser.class); job.setMapperClass(DAMapper.class); job.setReducerClass(DAReducer.class); job.setOutputKeyClass(Text.class); job.setOutputValueClass(IntWritable.class); FileInputFormat.addInputPath(job, new Path(args[0])); FileOutputFormat.setOutputPath(job, new Path(args[1])); System.exit(job.waitForCompletion(true) ? 0 : 1); } }参数和逻辑说明:
DAMapper继承Mapper<Object, Text, Text, Text>,四个泛型依次是输入 key 类型(偏移量)、输入 value 类型(日志行)、输出 key 类型(日期)、输出 value 类型(用户 ID)。fields.length < 3的校验不能省。游戏日志偶尔会出现半行写入或网络闪断导致的截断记录,不过滤会让 Reduce 端ArrayIndexOutOfBoundsException直接杀掉整个作业。- 这里没有自定义
WritableComparable,直接用Text当 key。如果后续要按"日期 + 区服"维度统计,就把 key 拼成"2024-06-01|server_01",Reduce 端再拆分。 job.setJarByClass(DailyActiveUser.class)是提交到集群的必要条件,否则 YARN 不知道去哪找业务类。本地跑伪分布式时这个配置同样生效。- Shuffle 过程会把同一天的
(date, userId)对按字典序排序再发给 Reduce。如果数据量极大,Reduce 端的HashSet会吃掉大量堆内存,这时需要换用Combiner先做一次局部去重。
3.2 留存率与付费转化:用 Hive SQL 替代手写 MapReduce
留存率计算比 DAU 麻烦的地方在于跨天关联——需要知道某天新增的用户在次日、7 日、30 日是否再次登录。手写 MapReduce 要实现多阶段 join,代码冗长且维护成本高。项目中的sql.sql走的是一条更轻的路线:用 Hive 把 HDFS 上的日志映射成表,用 SQL 完成各项核心指标。
常见的 Hive 建表方式:
CREATE EXTERNAL TABLE IF NOT EXISTS game_logs ( log_time STRING, user_id STRING, action STRING, device STRING, ip STRING ) ROW FORMAT DELIMITED FIELDS TERMINATED BY '|' LOCATION '/user/gamelogs/';注意EXTERNAL关键字在这里必须保留。因为日志文件是从 Flume 或游戏服务器直接拷到 HDFS 的,Hive 只负责建立元数据映射,不能把数据文件移动到 Hive 仓库目录。如果建内部表,DROP TABLE会连带删掉原始日志,这在生产环境是灾难。FIELDS TERMINATED BY 要和日志实际分隔符一致,项目里日志分隔符是|,不要想当然地写成\t。
留存率指标直接看一段模板:
-- 计算 6 月 1 日新增用户在 6 月 2 日的留存率 WITH new_users AS ( SELECT user_id FROM game_logs WHERE log_time >= '2024-06-01' AND log_time < '2024-06-02' AND action = 'register' GROUP BY user_id ), next_day_actives AS ( SELECT DISTINCT user_id FROM game_logs WHERE log_time >= '2024-06-02' AND log_time < '2024-06-03' AND action = 'login' ) SELECT COUNT(nu.user_id) AS new_user_cnt, COUNT(nda.user_id) AS retained_cnt, COUNT(nda.user_id) / COUNT(nu.user_id) AS retention_rate FROM new_users nu LEFT JOIN next_day_actives nda ON nu.user_id = nda.user_id;这段 SQL 的语义逻辑:
WITH new_users是 CTE(公共表表达式),先圈定注册用户集合。GROUP BY user_id的作用是排除恶意重复注册的脏数据。- 留存率的口径不是
COUNT(DISTINCT ...)直接除,而是先分别求出用户集合再做 LEFT JOIN。如果直接对两张表做 join 再 count,会在"一个用户多次登录"时被放大,得到错误的留存率。 - 这类 SQL 作业在 Hive 底层会被翻译成 MapReduce 或 Tez 任务。日志表按天分区可以极大减少扫描量,所以建表时建议加上
PARTITIONED BY (dt STRING),查询时指定dt='2024-06-01'。
3.3 付费行为与 ARPU 的统计口径
付费行为分析比活跃度复杂在金额的聚合维度。常见口径有:付费用户数(当日至少充值一次的去重用户)、付费率(付费用户数 / DAU)、ARPU(总收入 / DAU)、ARPPU(总收入 / 付费用户数)。这些指标在 Hive 里用一次 GROUP BY 加多个聚合函数就能算完:
SELECT dt, COUNT(DISTINCT CASE WHEN pay_amount > 0 THEN user_id END) AS paying_users, COUNT(DISTINCT user_id) AS dau, SUM(pay_amount) AS total_revenue, SUM(pay_amount) / COUNT(DISTINCT user_id) AS arpu FROM game_logs WHERE dt = '2024-06-01' GROUP BY dt;这里有一个新手常见的坑:不要把CASE WHEN放到 COUNT 外面,COUNT(DISTINCT CASE WHEN pay_amount > 0 THEN user_id END)和COUNT(CASE WHEN pay_amount > 0 THEN user_id END)的语义完全不同。前者是在过滤后的集合中去重,后者是统计所有充值记录的行数——一个用户充值 10 次会被重复计算进付费用户数,指标直接失真。
4. 从 HDFS 到页面:结果下沉与 JSP 查询链路的完整闭环
4.1 为什么分析结果要回写 MySQL
Hadoop 算出来的结果存在 HDFS 上,但 Web 层不可能直接读 HDFS 文件渲染页面。原因有三:一是 HDFS 的 NameNode 处理的是文件元数据请求,不适合高频并发查询;二是结果文件是文本格式,JSP 拿到后还要手动解析,效率低下;三是 Hadoop 集群通常在内网,运营同事没有直接访问权限。所以项目的做法是:MapReduce 作业输出结果到 HDFS 指定目录后,用一条export命令把结果导入 MySQL,或者让结果表直接以 Hive 表形式暴露给 Presto/Impala 查询。考虑到项目里带了sql.sql建表脚本,MySQL 回写路径是明确的。
4.2 Servlet + JDBC 的查询实现
player activity analysis.jsp这类页面的数据链路是:浏览器请求 -> JSP 中的 Servlet -> JDBC 查询 MySQL -> 渲染 HTML 表格。核心 DAO 层代码大致如下:
@WebServlet("/player/activity") public class PlayerActivityServlet extends HttpServlet { private DataSource dataSource; @Override public void init() throws ServletException { // 生产环境用阿里的 Druid 或 HikariCP,不要每次请求都新建连接 HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://192.168.1.50:3306/game_analysis"); config.setUsername("analysis"); config.setPassword("******"); config.setMaximumPoolSize(20); dataSource = new HikariDataSource(config); } @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String startDate = req.getParameter("startDate"); String endDate = req.getParameter("endDate"); String sql = "SELECT dt, dau, new_users, retention_rate " + "FROM daily_activity WHERE dt BETWEEN ? AND ? ORDER BY dt"; List<ActivityVO> list = new ArrayList<>(); try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, startDate); ps.setString(2, endDate); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { ActivityVO vo = new ActivityVO(); vo.setDt(rs.getString("dt")); vo.setDau(rs.getInt("dau")); vo.setNewUsers(rs.getInt("new_users")); vo.setRetentionRate(rs.getDouble("retention_rate")); list.add(vo); } } } catch (SQLException e) { throw new ServletException("查询玩家活跃数据失败", e); } req.setAttribute("activityList", list); req.getRequestDispatcher("/player_activity.jsp").forward(req, resp); } }参数说明:
HikariDataSource的maximumPoolSize需要按 QPS 调整。游戏分析报表属于低频查询,20 个连接绰绰有余,设太大会浪费数据库连接资源。PreparedStatement的setString绑定参数防止 SQL 注入。运营在页面上输入的日期参数是外部输入,直接拼 SQL 字符串会被工具抓到注入点。req.setAttribute配合/player_activity.jsp的forward转发,把数据传到 JSP 页面用 JSTL 的<c:forEach>渲染。这里不要用sendRedirect,因为它会丢request域中的数据。
4.3 任务调度:MapReduce 作业谁来定时触发
分析系统不能每次靠人工去命令行执行hadoop jar。参考这个项目的结构,调度层通常用 Oozie 或 Cron 表达式包一层。如果不想引入额外组件,最简单的方式是写一个 shell 脚本挂在 Linux crontab 里:
#!/bin/bash # 每天凌晨 2 点跑昨天全量日志的 DAU 统计 YESTERDAY=$(date -d "yesterday" +%F) HADOOP_HOME=/usr/local/hadoop INPUT_PATH="/user/gamelogs/$YESTERDAY" OUTPUT_PATH="/user/analysis/dau/$YESTERDAY" # 先清理同名输出目录,否则 MapReduce 会因为目录已存在而失败 hdfs dfs -rm -r -f "$OUTPUT_PATH" # 提交作业,并指定 HDFS 输出路径 hadoop jar /opt/game-analysis/lib/dau-job.jar \ com.game.analysis.DailyActiveUser \ "$INPUT_PATH" "$OUTPUT_PATH" # 结果导出到 MySQL,注意用 --update 避免重复行 sqoop export \ --connect jdbc:mysql://192.168.1.50:3306/game_analysis \ --username analysis --password ****** \ --table daily_activity \ --export-dir "$OUTPUT_PATH" \ --input-fields-terminated-by '\t' \ --update-mode allowinsert \ --update-key dt这段脚本里有三个细节决定任务能否稳定运行:
- 输出目录必须先清理。MapReduce 要求输出路径不存在,否则报
FileAlreadyExistsException。用-f强制删除避免交互确认。 - 输入路径按天分区。
/user/gamelogs/2024-06-01是前一天的数据目录,这样每次作业只处理当天新增文件,不需要全量扫描历史数据。 --update-key dt支持幂等。如果当天作业失败后重跑,不会产生重复记录,而是按日期字段覆盖更新。注意 Sqoop 导出的字段分隔符要和 MapReduce 输出的一致,项目里 Reduce 输出用\t,这里--input-fields-terminated-by也必须写\t,否则 MySQL 里的表数据会变成一列。
| 组件 | 本项目角色 | 生产环境替代方案 |
|---|---|---|
| HDFS | 存储原始游戏日志 | 可加 Ozone 做对象存储分层 |
| MapReduce | 计算 DAU、留存等离线指标 | Spark 批量计算,吞吐更高 |
| Hive | 分析日志表、跑留存 SQL | 可上 Spark SQL 加速 |
| MySQL | 存储最终指标供 JSP 查询 | TiDB/ClickHouse 应对更高并发 |
| JSP/Servlet | 报表展示层 | Spring Boot + ECharts |
5. 伪分布式复现与集群部署时的参数坑和验证方法
5.1 伪分布式与真实集群的内存配置差异
用伪分布式方式复现这套系统时,最容易踩的坑是内存不足导致 DataNode 或 NodeManager 进程被系统杀掉。默认配置下,Hadoop 各守护进程的堆内存设置偏保守,但一旦同时跑多个作业,yarn.nodemanager.resource.memory-mb设得太小会导致 Container 频繁被杀。
以下是一组适合伪分布式环境(8 GB 内存机器)的配置:
<!-- core-site.xml --> <property> <name>hadoop.tmp.dir</name> <value>/home/hadoop/tmp</value> <description>NameNode 和 DataNode 的数据目录,需要手动创建</description> </property> <property> <name>fs.defaultFS</name> <value>hdfs://localhost:9000</value> </property> <!-- hdfs-site.xml --> <property> <name>dfs.replication</name> <value>1</value> <description>伪分布式只有一个 DataNode,副本数必须为 1,否则一直处于 Under-Replicated 状态</description> </property> <property> <name>dfs.namenode.name.dir</name> <value>/home/hadoop/tmp/namenode</value> </property> <property> <name>dfs.datanode.data.dir</name> <value>/home/hadoop/tmp/datanode</value> </property> <!-- yarn-site.xml --> <property> <name>yarn.nodemanager.resource.memory-mb</name> <value>4096</value> <description>分配给 YARN 容器使用的最大内存,留一部分给操作系统</description> </property> <property> <name>yarn.scheduler.maximum-allocation-mb</name> <value>2048</value> <description>单个 Map/Reduce 任务最多申请的内存</description> </property> <property> <name>yarn.nodemanager.vmem-check-enabled</name> <value>false</value> <description>关闭虚拟内存超限检查,否则物理内存够也会被误杀</description> </property>参数说明与调优思路:
dfs.replication在伪分布式下必须改成 1。保持默认 3 的话,DataNode 只有一份副本,NameNode 会持续输出块副本不足的告警,hdfs dfsadmin -report看到的状态是Under-Replicated。yarn.nodemanager.resource.memory-mb要留出系统和其他进程的余量。8 GB 机器分配 4 GB 给 YARN 是合理值,配 6 GB 会让操作系统开始 swap,作业反而变慢。vmem-check-enabled这个参数是很多初学者忽视的一环。Java 进程申请的虚拟内存远大于物理内存,默认开启检查后,Container 会因其虚拟内存超限而被杀死,日志里出现Container killed by YARN for exceeding memory limits。- 生产集群单节点 64 GB 内存时,一般把
nodemanager.resource.memory-mb配到 48 GB,看数据节点上同时跑的作业数决定是否调整scheduler.maximum-allocation-mb。
5.2 数据倾斜:游戏热门区服的日志量不在一个量级
多人在线游戏的数据天然有倾斜问题:热门区服可能贡献一个集群 70% 的日志量,冷门区服只有零头。当 MapReduce 按区服 ID 做聚合时,热门区服的 Reduce 任务负载极高,其他 Reduce 早就跑完等它一个,整个作业挂起。
应对手段有两个层面。第一,在 Hive 中开启倾斜连接优化:
SET hive.groupby.skewindata=true; SET hive.optimize.skewjoin=true; SET hive.skewjoin.key=100000;skewindata会引入一次额外的 MapReduce 任务,把第一期聚合后的数据再做一轮随机分发,让负载重新均衡。代价是作业时间变长,适合数据倾斜明显而任务本身耗时可控的场景。第二,在 HDFS 文件层面提前做大小分区合并,用hdfs balancer检查数据块分布,把日志采集端的 Flume 按区服分目录收集,从源头缓解 Reduce 端的压力。
5.3 验证系统正确性的三个手段
跑通之后不能只看 Web 页面有数字就认为正确。我用过最有效的方法是三重验证:
第一,抽样比对 HDFS 原始日志和 MySQL 结果表。用awk从原始日志里抽一个小时的记录,手工统计登录用户数,与报表里对应小时的数据对比,误差在 0 以内才算过。注意要带时间维度,不能抽全天,因为作业是按天统计的。
第二,检查 MapReduce 计数器的数值:
hadoop jar dau-job.jar DailyActiveUser /user/gamelogs/2024-06-01 /user/analysis/dau/2024-06-01作业跑完后,看输出末尾的Map input records和Reduce output records。如果Reduce output records的行数和预期日期分区数对不上,说明有脏数据或者日期解析逻辑有遗漏。这个检查在自动化调度里也应该加一步,把输出行数写入日志,异常时触发告警。
第三,检查 HDFS 目录的增量和文件大小。正常情况每天新增一个日期目录,文件大小在稳定范围内波动。如果某天目录大小突然膨胀十倍,大概率是日志采集端出了问题,出现了重复推送。用hdfs dfs -du -h /user/gamelogs/按目录列出即可快速定位。
5.4 伪分布式下最常见的三个错误排查
这半年帮人看类似项目,遇到过最多的问题集中在以下三个点,这里直接给排查命令:
第一个是 NameNode 启动失败,报NameNode is not formatted。新装的 Hadoop 必须先执行:
hdfs namenode -format这个命令只清空 NameNode 元数据,不影响 DataNode 数据块。注意hadoop.tmp.dir目录路径里如果之前有旧数据,需要先手动删除再 format,否则新集群和旧元数据混在一起,启动后 DataNode 注册不上。
第二个是跑作业时提示Input path does not exist。先确认文件确实存在:
hdfs dfs -ls /user/gamelogs/如果文件在本地而不是 HDFS,必须用hdfs dfs -put先上传。很多刚接触 Hadoop 的人习惯性地用cat创建本地文件就直接跑作业,报错后一头雾水。
第三个是网页端看不到 DataNode,访问localhost:9870状态信息里 DataNode 数量为 0。优先检查dfs.datanode.data.dir指定的目录是否存在且权限正确,以及core-site.xml里的hadoop.tmp.dir是否和实际路径一致。DataNode 启动失败的信息不在控制台,要看日志:
tail -100 /usr/local/hadoop/logs/hadoop-hadoop-datanode-*.log日志里如果有Permission denied,说明目录的属主不是启动 Hadoop 的用户,直接chown -R hadoop:hadoop /home/hadoop/tmp解决。整个过程里,每次修改配置文件之后都要重启对应守护进程,且要确认实际生效——在hdfs getconf -confKey dfs.replication里看到 1 才代表配置真的读进去了。
本文还有配套的精品资源,点击获取