简介:这份资源是面向大数据与人工智能方向学习者、开发者的一份Hadoop网站日志分析实战项目,围绕MapReduce编程模型展开,帮助读者理解如何从海量Web日志中挖掘用户行为、访问模式与服务器性能等关键信息。压缩包共14个文件,以7个java源码与7个class编译文件为主,源码便于阅读与二次修改,class文件可直接运行验证,整体约16KB,结构紧凑、便于快速上手。项目覆盖日志格式解析、数据清洗、访问统计、用户行为分析、异常检测、资源利用率评估及HDFS存储等常见知识点,并可与推荐系统、用户画像等人工智能场景衔接。目前已有170人学习下载,适合作为课程设计、毕业设计或自学练手的参考案例,帮助读者掌握MapReduce作业的编写思路与性能优化方向。
1. 从一份日志压缩包说起:Hadoop 离线分析到底在算什么
电商大促结束第二天,运维群里甩来一个access.log压缩包,几百 MB 到几十 GB 不等,产品那边只问一句:昨天哪些页面被爬得最狠、哪个时段下单转化掉得最厉害。用grep和awk硬扛,单机跑一晚上还容易 OOM,这就是「基于 Hadoop 的网站日志分析程序」要解决的场景。它本质是把 Nginx/Apache 产生的半结构化文本,通过 HDFS 存储、MapReduce 或 Hive 做分布式聚合,最终产出 PV、UV、Top URL、状态码分布、独立 IP 等指标。适合谁?手里有日志、有几十 GB 以上数据量、又不想上实时链路(Flink/ClickHouse)的团队。下面我按自己落地的顺序,把选型、代码、参数和踩过的坑讲清楚。
2. 日志字段拆解与 Hadoop 侧选型:为什么不是直接上 Hive
2.1 先看清 Nginx 日志里到底有什么
一份典型的 combined 格式日志长这样:
127.0.0.1 - - [10/Oct/2024:13:55:36 +0800] "GET /product/1024 HTTP/1.1" 200 5321 "https://example.com/" "Mozilla/5.0 ..."拆开看,真正能用的字段有:客户端 IP、时间戳、请求方法、URL、协议版本、状态码、响应字节数、Referer、User-Agent。做分析时,PV 靠行数,UV 靠 IP 去重,Top URL 靠 URL 分组计数,状态码分布靠状态码分组。这里有个反直觉的点:UV 用 IP 去重并不准,NAT 出口和爬虫会让数字虚高,但离线场景下大多数团队还是接受这个近似,因为精确 UV 需要埋点,日志里拿不到。
2.2 MapReduce 还是 Hive:按团队能力选
我一般这样判断:
| 方案 | 适合场景 | 开发成本 | 维护成本 |
|---|---|---|---|
| 原生 MapReduce | 字段解析逻辑复杂、需要自定义输出 | 高,Java 代码量大 | 中,逻辑全在自己手里 |
| Hive SQL | 字段规整、指标固定、团队会 SQL | 低,几十行 SQL | 低,依赖 Hive 元数据 |
| Spark | 需要迭代计算或后续接流 | 中 | 中 |
如果只是 PV/UV/Top URL 这类固定指标,优先 Hive,把日志建外部表、写正则 SerDe,一条 SQL 出结果。但标题里是「程序」,说明大概率要交一份可跑的 MapReduce 代码,所以下面两条路都给。
2.3 环境准备:HDFS 目录与输入约定
不管走哪条路,先把日志按天丢进 HDFS,目录按/logs/raw/yyyyMMdd/组织,方便后续按分区处理:
# 本地日志上传到 HDFS,按天分目录 hdfs dfs -mkdir -p /logs/raw/20241010 hdfs dfs -put ./access.log.20241010 /logs/raw/20241010/ # 确认文件到位,注意块大小和副本数 hdfs dfs -ls -h /logs/raw/20241010/逻辑说明:按天分目录是为了让后续 Hive 分区表或 MapReduce 的输入路径能直接对应日期,避免全量扫描。参数上,-put默认按dfs.blocksize(常见 128MB)切块,副本数默认 3;如果日志是冷数据、集群紧张,可以在上传时用-Ddfs.replication=2降副本,但别低于 2,否则节点故障时容易读失败。
3. 用 MapReduce 跑通 PV/UV/Top URL 的最小实现
3.1 Mapper:一行日志切出你要的字段
// LogMapper.java public class LogMapper extends Mapper<LongWritable, Text, Text, IntWritable> { private static final Pattern PATTERN = Pattern.compile( "^(\\S+) \\S+ \\S+ \\[([^\\]]+)\\] \"(\\S+) (\\S+) [^\"]*\" (\\d{3}) (\\d+|-)"); private Text outKey = new Text(); private final static IntWritable ONE = new IntWritable(1); @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String line = value.toString(); Matcher m = PATTERN.matcher(line); if (!m.find()) { // 脏数据直接跳过,但用计数器记下来,方便排查 context.getCounter("LOG", "MALFORMED").increment(1); return; } String ip = m.group(1); String url = m.group(4); String status = m.group(5); // 输出三类 key,用前缀区分,Reducer 里按前缀分流 outKey.set("PV\t" + url); context.write(outKey, ONE); outKey.set("UV\t" + ip); context.write(outKey, ONE); outKey.set("STATUS\t" + status); context.write(outKey, ONE); } }逻辑说明:正则里(\\S+)抓 IP,\\[([^\\]]+)\\]抓时间戳,\"(\\S+) (\\S+) [^\"]*\"抓方法和 URL,(\\d{3})抓状态码。用find()而不是matches(),因为日志行尾还有 Referer 和 UA,全匹配容易失败。参数上,context.getCounter是排查脏数据的后悔药,跑完在 Job 计数器里能看到MALFORMED数量,如果占比超过 5%,说明日志格式和正则对不上,得先对齐格式再跑。
3.2 Reducer:按前缀分流做聚合
// LogReducer.java public class LogReducer extends Reducer<Text, IntWritable, Text, IntWritable> { @Override protected void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException { String k = key.toString(); String[] parts = k.split("\t", 2); String type = parts[0]; String dim = parts[1]; if ("UV".equals(type)) { // UV 只需要去重计数,value 恒为 1,直接输出 1 表示该 IP 出现 context.write(new Text("UV\t" + dim), new IntWritable(1)); return; } int sum = 0; for (IntWritable v : values) { sum += v.get(); } context.write(new Text(type + "\t" + dim), new IntWritable(sum)); } }逻辑说明:UV 这里做了简化——Reducer 对同一个 IP 的 key 只会调用一次,所以直接输出 1,后续再对 UV 结果做一次计数就是独立 IP 数。PV 和 STATUS 则正常累加。参数上,如果 UV 量特别大(比如上亿 IP),Reducer 端会成为瓶颈,常见做法是改用DistinctCount的 HyperLogLog 近似,或者把 UV 拆到第二个 Job 用set去重。
3.3 Driver:把 Job 串起来并设对 Reduce 数
// LogDriver.java public class LogDriver { public static void main(String[] args) throws Exception { Configuration conf = new Configuration(); Job job = Job.getInstance(conf, "website-log-analysis"); job.setJarByClass(LogDriver.class); job.setMapperClass(LogMapper.class); job.setReducerClass(LogReducer.class); job.setMapOutputKeyClass(Text.class); job.setMapOutputValueClass(IntWritable.class); job.setOutputKeyClass(Text.class); job.setOutputValueClass(IntWritable.class); // Reduce 数按数据量估:一般 1 个 Reduce 处理 1~2GB 输入 job.setNumReduceTasks(4); FileInputFormat.addInputPath(job, new Path(args[0])); FileOutputFormat.setOutputPath(job, new Path(args[1])); System.exit(job.waitForCompletion(true) ? 0 : 1); } }逻辑说明:setNumReduceTasks(4)不是拍脑袋,经验值是单个 Reduce 处理 1~2GB 输入,10GB 日志给 4~8 个比较稳。设太少会拖长单任务时间,设太多会产生大量小文件,拖垮 NameNode。跑的时候用:
hadoop jar log-analysis.jar com.example.LogDriver /logs/raw/20241010 /logs/output/20241010跑完看_SUCCESS文件是否存在,以及计数器里的MALFORMED数量。
4. 换 Hive 实现:几十行 SQL 出同样指标
4.1 建外部表并挂正则 SerDe
-- 建外部表,指向 HDFS 上的日志目录 CREATE EXTERNAL TABLE IF NOT EXISTS ods_access_log ( ip STRING, log_time STRING, method STRING, url STRING, status STRING, bytes STRING ) ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.RegexSerDe' WITH SERDEPROPERTIES ( "input.regex" = "^(\\S+) \\S+ \\S+ \\[([^\\]]+)\\] \"(\\S+) (\\S+) [^\"]*\" (\\d{3}) (\\d+|-)" ) STORED AS TEXTFILE LOCATION '/logs/raw/20241010/';逻辑说明:EXTERNAL表示删表不删数据,安全。input.regex里的捕获组顺序必须和字段声明顺序一致,错一个位置整列就串了。参数上,如果日志里有-表示字节数为空,字段用 STRING 接住,别用 INT,否则会变 NULL。
4.2 PV、UV、Top URL 三条 SQL
-- PV:按 URL 统计访问次数 SELECT url, COUNT(*) AS pv FROM ods_access_log GROUP BY url ORDER BY pv DESC LIMIT 20; -- UV:按 IP 去重计数 SELECT COUNT(DISTINCT ip) AS uv FROM ods_access_log; -- 状态码分布 SELECT status, COUNT(*) AS cnt FROM ods_access_log GROUP BY status ORDER BY cnt DESC;逻辑说明:COUNT(DISTINCT ip)在数据量大时会触发单 Reduce 瓶颈,Hive 会把它压到一个 reducer 上算。参数上可以开hive.map.aggr=true做 map 端预聚合,或者用GROUP BY ip子查询再 count 的方式绕开。Top URL 的LIMIT 20只是展示,真正落地时把结果INSERT OVERWRITE到结果表里。
5. 避坑与排查:日志分析里最容易翻车的 5 个点
5.1 时间戳时区错位,指标整体偏移 8 小时
现象:按小时统计的 PV 曲线整体平移,凌晨高峰跑到白天。原因:日志里是+0800,但解析时按 UTC 存了。解决:在 Mapper 或 Hive 里显式按+0800解析,或者统一转成 UTC 后再展示,别混着来。
5.2 正则贪婪匹配,URL 里带空格直接截断
现象:部分 URL 只取到一半,状态码列变成 NULL。原因:(\\S+)遇到 URL 里编码后的空格或引号会提前结束。解决:把 URL 的捕获改成([^\\s\"]+),并在上线前用一批真实日志做抽样验证,别只拿一条样例测。
5.3 Reduce 数设成 1,10GB 日志跑 3 小时
现象:Job 卡在 reduce 阶段不动,进度条长时间 99%。原因:setNumReduceTasks默认是 1,所有 key 挤一个 reducer。解决:按输入大小估 Reduce 数,同时检查是否有数据倾斜——某个 URL 占了 80% 流量时,加盐打散或单独处理。
5.4 小文件太多,NameNode 内存告警
现象:跑完一批日志,HDFS 上多了几万个几十 KB 的小文件。原因:每个 Reduce 输出一个文件,Reduce 数多、批次多就堆积。解决:跑完后用hdfs dfs -getmerge合并,或者用 Hive 的INSERT OVERWRITE重写一遍,把分区文件数压下来。
5.5 脏数据没计数,结果对不上却找不到原因
现象:PV 总数和 Nginx 侧统计差 3%,但没人知道差在哪。原因:格式异常的行被静默跳过。解决:Mapper 里加计数器(前面代码里的MALFORMED),跑完先看计数器,再决定是修正则还是接受损耗。
6. 进阶:把离线结果接上调度与可视化
跑通单天只是起点。我一般会做三件事:一是用调度工具(比如 Azkaban、DolphinScheduler 这类)把「上传日志 → 跑 Job → 写结果表」串成日任务,失败重试和告警配好;二是把结果落到 Hive 结果表后,用 BI 工具或简单的 Web 页面读出来,别每次手动cat输出文件;三是加一层数据质量校验,比如 PV 环比波动超过 30% 就告警,防止日志采集断流导致指标假跌。
验证方法上,我习惯拿一天日志同时跑 MapReduce 和 Hive 两条路,对比 PV 总数是否一致,差在 1% 以内才算解析逻辑没问题。如果差得多,先查正则,再查脏数据计数器,最后查是否有重复上传的文件。
有个具体技巧:日志按天分区后,跑历史回溯时用hive.exec.dynamic.partition=true开启动态分区,一条 SQL 就能把多天结果一次性写入对应分区,不用一天天手动跑。参数上记得同时设hive.exec.dynamic.partition.mode=nonstrict,否则会报错。
我自己踩过最深的坑是早期没做脏数据计数,结果指标对不上时排查了两天,最后发现是某台机器的日志格式和集群其他机器不一致。从那以后,任何日志分析任务上线前,我都会先跑一遍计数器,确认MALFORMED占比在可接受范围再继续。希望帮到你。
本文还有配套的精品资源,点击获取