ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Apache日志离线分析实战:Hadoop+Hive+Sqoop构建可落地的运营指标体系

Apache日志离线分析实战:Hadoop+Hive+Sqoop构建可落地的运营指标体系

简介:本资源是一份面向大数据初学者与Hadoop生态实践者的完整离线日志分析教学文档,聚焦技术论坛网站运营场景,解决PV、注册用户数、独立IP数、跳出率及板块热度等核心业务指标的计算问题。文档以真实Apache Common日志为数据源(含56GB历史数据与日增150MB文件),系统梳理从HDFS数据上传、MapReduce清洗、Hive统计分析到Sqoop导出MySQL及HBase明细存储的全链路开发流程,并涵盖Linux Shell、HDFS、MapReduce、Hive、HBase、Sqoop六大技术栈的协同应用。资源为单个1013KB的DOCX文档,内容结构清晰,包含项目背景、KPI定义与公式、开发步骤详解、表结构设计及数据清洗实操示例,适合用于课程设计、毕业实践或企业级日志分析方案参考。目前已有1402人学习下载。

1. 这不是又一个“Hello World”日志分析:它真能算出你论坛里谁注册了、谁看了就走、哪个板块快凉了

你手头有一堆 Apache 日志,每天几百 MB,看着 HDFS 里越积越多的access_2013_05_30.log,心里发毛——不是怕存不下,是怕根本不知道这些字节在说什么。
这个项目不讲 MapReduce 原理,不画 Hive 架构图,它干了一件很实在的事:用一套可落地、可定时、可查表的离线链路,把原始日志变成运营同学早上打开电脑就能看的五张表——PV 总数、当天注册人数、独立 IP 数、跳出用户数、板块访问 Top10。
它没用 Spark Streaming,没上 Flink,全靠 Hadoop 生态最稳的老三样:MapReduce 清洗、Hive 统计、Sqoop 导出。数据源是真实论坛(某技术学习平台)的 Apache Common Log,格式典型:123.123.123.123 - - [24/May/2013:12:34:56 +0800] "GET /forum.php?mod=viewthread&tid=12345 HTTP/1.1" 200 12345。
关键在于,它把“跳出率”这种看似玄学的指标,拆成了可验证的 SQL:先按 IP 分组,再筛出只出现 1 次的 IP,最后除以总 PV——不是靠 BI 工具点几下,而是每一步都落在 HDFS 路径、Hive 表名、MySQL 字段上。适合刚跑通 Hadoop 集群、想拿真实业务练手的开发,也适合需要快速交付日志分析 MVP 的小团队。它不追求实时,但求结果准、链路清、改起来不翻车。

2. 整体架构与模块职责:从日志落地到指标入库的六层流水线

这个项目不是单个 Jar 包或一条 SQL,而是一条横跨存储、计算、调度、查询的完整离线链路。它把大数据开发中容易被忽略的“衔接细节”全写实了:Shell 怎么取昨天日期、Hive 分区怎么动态加、Sqoop 导出字段分隔符为什么必须是\001。整条链路共六层,环环相扣,缺一不可:

2.1 数据源头:Apache Common Log 的真实结构与陷阱

日志不是标准 JSON,而是带空格、方括号、时区的文本行。项目原文明确给出格式示例:
123.123.123.123 - - [24/May/2013:12:34:56 +0800] "GET /forum.php?mod=viewthread&tid=12345 HTTP/1.1" 200 12345

这行里藏着五个关键字段,但位置不固定、分隔符不统一:

  • IP 在开头,用空格分隔,但后面紧跟着- -,不能简单split(" ");
  • 时间被[和+0800]包裹,中间含英文月份(May),需Locale.ENGLISH;
  • URL 被双引号包围,且包含空格(如HTTP/1.1),必须用首尾引号定位;
  • 状态码和流量在最后,但被空格和引号夹在中间,需二次split。

这就是为什么项目里专门写了LogParser类——它不是炫技,是绕过正则表达式性能黑洞的务实选择。我一般会先用line.indexOf("[")定位时间起始,再用line.lastIndexOf("\"")找 URL 结束,比写一个^(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) ([^"]+)" (\d+) (\d+)$稳定十倍。尤其当单日日志超百万行时,字符串索引比正则匹配快一个数量级。

2.2 存储层:HDFS 目录设计与分区逻辑

整个 HDFS 路径规划清晰,直接反映数据生命周期:

/project/techbbs/ ├── data/ # 原始日志上传目录(未清洗) │ ├── access_2015_04_25.log │ └── access_2015_04_26.log ├── cleaned/ # 清洗后数据目录(MapReduce 输出) │ ├── 2015_04_25/ # 按日期子目录,对应 Hive 分区值 │ │ └── part-r-00000 │ └── 2015_04_26/ └── hive/ # Hive 表数据实际落盘路径(可选)

注意:cleaned/下的子目录名2015_04_25必须与 Hive 分区值logdate='2015_04_25'完全一致。这是 Hive 外部表能自动识别分区的前提。如果脚本里写成20150425(无下划线),Hive 就找不到数据——这种错不会报语法错误,只会查出 0 条记录,排查起来极耗时间。

2.3 计算层:MapReduce、Hive、Sqoop 的分工铁律

三者不是并列关系,而是严格流水线:

  • MapReduce 只做一件事:清洗。过滤静态资源(/static/)、剥离GET/POST前缀、标准化时间格式、丢弃无用字段(状态码、流量)。它不统计、不聚合,输出纯文本IP\t时间\tURL。
  • Hive 只做一件事:统计。基于清洗后的数据建表、分区、执行 COUNT/DISTINCT/GROUP BY。它不读原始日志,只读cleaned/下的 TSV 文件。
  • Sqoop 只做一件事:搬运。把 Hive 计算好的汇总表(如techbbs_2015_04_25)导出到 MySQL。它不参与任何逻辑,只负责字段对齐和分隔符转换。

违反这个铁律必翻车。比如有人想在 Mapper 里直接统计 PV,结果发现 Reducer 收不到数据——因为 MapReduce 默认按 Key 分组,而 PV 是全局计数,必须用Counter或改用 Hive。

2.4 调度层:Shell 脚本里的时间魔法与容错设计

定时任务techbbs_core.sh是整条链路的“心脏起搏器”。它用三行 Shell 解决了大数据调度中最头疼的问题:

# step1. 获取昨天日期(严格按 yyyy_MM_dd 格式) yesterday=$(date --date='1 days ago' +%Y_%m_%d) # step2. 上传原始日志(注意路径拼接) hadoop fs -put /usr/local/files/apache_logs/access_${yesterday}.log /project/techbbs/data # step3. 执行清洗(输入路径必须与上传路径一致) hadoop jar mycleaner.jar /project/techbbs/data/access_${yesterday}.log /project/techbbs/cleaned/${yesterday}

这里的关键是date --date='1 days ago'——它不依赖系统当前时间是否准确。即使服务器时间漂移了 2 小时,只要 cron 在凌晨 1 点触发,yesterday就一定是逻辑上的“昨天”,而非“当前时间减 24 小时”。这是生产环境保命技巧。另外,脚本里没写set -e,意味着某步失败不会中断后续步骤。实际部署时,我一定会加上:

set -e # 任一命令失败即退出 set -u # 引用未定义变量时报错

否则yesterday变量为空时,hadoop fs -put会把整个/usr/local/files/apache_logs/目录上传,瞬间打爆 HDFS。

2.5 查询层:MySQL 与 HBase 的读写分离策略

项目明确区分两类查询需求:

  • 指标类(聚合结果)走 MySQL:techbbs_logs_stat表只有 5 个字段(日期、PV、注册数、IP 数、跳出数),供运营后台报表直接SELECT * FROM techbbs_logs_stat WHERE logdate='2015_04_25'。
  • 明细类(单条日志)走 HBase:设计为按ip+atime作为 RowKey,支持get 'techbbs_log', '123.123.123.123_20150425123456'快速查某 IP 某时刻行为。

这种分离不是拍脑袋决定的。MySQL 对COUNT(*)和WHERE logdate=?极其高效,但查单条日志要扫全表;HBase 单点查询毫秒级,但COUNT要全表 Scan。项目没提供 HBase 写入代码,但给出了设计意图——如果你要补全,RowKey 必须是ip + timestamp拼接(timestamp 格式化为yyyyMMddHHmmss),否则范围查询失效。

2.6 配置与依赖:那些文档里没写但运行必崩的细节

  • Hive 分区表 LOCATION 必须指向cleaned/目录,而非子目录:
    CREATE EXTERNAL TABLE techbbs(...) LOCATION '/project/techbbs/cleaned'; -- ✅ 正确:Hive 自动扫描所有子目录 -- 错误:LOCATION '/project/techbbs/cleaned/2015_04_25' 会锁死单日数据
  • Sqoop 导出必须指定--fields-terminated-by '\001':
    Hive 默认用\001(Ctrl-A)分隔字段,而 MySQL 默认用逗号。如果不加此参数,Sqoop 会把整行当做一个字段导入,MySQL 表里pv列存的是2015_04_25\00112345\001678\0019876\0012345这种字符串。
  • MapReduce 输出必须是文本,不能是 SequenceFile:
    Hive 外部表默认读取 TextFile。如果mycleaner.jar输出SequenceFileOutputFormat,Hive 会报java.io.IOException: Not a file。必须在 Job 中显式设置:
    job.setOutputFormatClass(TextOutputFormat.class);

3. 核心模块逐行解析:LogParser、Mapper、Hive SQL 的真实战场

现在我们钻进代码最硬核的部分。这不是教科书伪代码,而是你复制粘贴就能跑、改错就能定位的实战片段。每一行都带着血泪经验——比如parseTime里为什么用SimpleDateFormat而不用DateTimeFormatter,比如 Hive SQL 里ON 1=1的深意。

3.1 LogParser:字符串索引比正则更可靠的时间解析

public static class LogParser { // 关键:必须用 Locale.ENGLISH,否则 May/Jun 解析失败 public static final SimpleDateFormat FORMAT = new SimpleDateFormat( "dd/MMM/yyyy:HH:mm:ss", Locale.ENGLISH); // 输出格式:yyyyMMddHHmmss,便于 Hive 分区和排序 public static final SimpleDateFormat dateformat1 = new SimpleDateFormat("yyyyMMddHHmmss"); private Date parseDateFormat(String timeStr) { try { return FORMAT.parse(timeStr); // 输入:24/May/2013:12:34:56 } catch (ParseException e) { // ⚠️ 注意:这里不能只 printStackTrace()! // 生产环境必须记录到日志文件,并返回 null 让上层跳过该行 System.err.println("Parse time failed: " + timeStr); return null; } } public String[] parse(String line) { String ip = parseIP(line); // 提取 IP String time = parseTime(line); // 提取并格式化时间 String url = parseURL(line); // 提取 URL(去引号) String status = parseStatus(line); // 提取状态码 String traffic = parseTraffic(line); // 提取流量 return new String[]{ip, time, url, status, traffic}; } private String parseIP(String line) { // ⚠️ 陷阱:IP 后面是 " - - [",不能 split(" ") 取第一个 // 正确:找第一个 " - - " 的位置,取前面所有字符 int idx = line.indexOf(" - - "); if (idx == -1) return ""; return line.substring(0, idx).trim(); } private String parseTime(String line) { // ⚠️ 陷阱:时间被 [ ] 包裹,且中间有空格,不能用正则 int start = line.indexOf('['); int end = line.indexOf(" +0800]"); if (start == -1 || end == -1) return ""; String rawTime = line.substring(start + 1, end).trim(); Date date = parseDateFormat(rawTime); return date != null ? dateformat1.format(date) : ""; } private String parseURL(String line) { // ⚠️ 陷阱:URL 在双引号内,且可能含空格(如 "GET /a b HTTP/1.1") // 正确:找第一个 " 和最后一个 ",取中间 int first = line.indexOf('"'); int last = line.lastIndexOf('"'); if (first == -1 || last == -1 || first == last) return ""; return line.substring(first + 1, last); } private String parseStatus(String line) { // ⚠️ 陷阱:状态码在最后一个 " 之后,用空格分隔 int lastQuote = line.lastIndexOf('"'); if (lastQuote == -1) return ""; String tail = line.substring(lastQuote + 1).trim(); // tail 形如 "200 12345",取第一个空格前 int space = tail.indexOf(' '); return space == -1 ? tail : tail.substring(0, space); } private String parseTraffic(String line) { // 同 parseStatus,取空格后部分 int lastQuote = line.lastIndexOf('"'); if (lastQuote == -1) return ""; String tail = line.substring(lastQuote + 1).trim(); int space = tail.indexOf(' '); return space == -1 ? "" : tail.substring(space + 1).trim(); } }

提示:parseTime中SimpleDateFormat是线程不安全的!如果 Mapper 多线程复用同一个LogParser实例,会解析错乱。正确做法是:在map()方法内新建SimpleDateFormat,或用ThreadLocal<SimpleDateFormat>。项目原文没提,但这是线上必改点。

3.2 Mapper:四步过滤的顺序不能乱

public static class MyMapper extends Mapper<LongWritable, Text, LongWritable, Text> { private LogParser logParser = new LogParser(); private Text outputValue = new Text(); @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String line = value.toString().trim(); if (line.isEmpty()) return; // ⚠️ 必加:空行跳过,否则 parse() NPE String[] parsed = logParser.parse(line); // ⚠️ 顺序第一:过滤静态资源(/static/ 和 /uc_server) // 因为它们占日志 30%+,早过滤能省大量计算 if (parsed[2].startsWith("GET /static/") || parsed[2].startsWith("GET /uc_server")) { return; } // ⚠️ 顺序第二:剥离 GET/POST 前缀 // 注意:必须用 startsWith,不能用 contains,否则 /member.php?mod=register 会被误杀 if (parsed[2].startsWith("GET /")) { parsed[2] = parsed[2].substring(4); // "GET /".length() == 4 } else if (parsed[2].startsWith("POST /")) { parsed[2] = parsed[2].substring(5); // "POST /".length() == 5 } // ⚠️ 顺序第三:剥离 HTTP/1.1 后缀 // 必须用 endsWith,且长度精确,避免误删 /api/v1.1 if (parsed[2].endsWith(" HTTP/1.1")) { parsed[2] = parsed[2].substring(0, parsed[2].length() - 10); } // ⚠️ 顺序第四:只保留前三字段(IP\t时间\tURL),丢弃状态码和流量 // 这是 Hive 建表字段数(ip string, atime string, url string)的硬约束 outputValue.set(parsed[0] + "\t" + parsed[1] + "\t" + parsed[2]); context.write(key, outputValue); } }

注意:context.write(key, outputValue)的key是LongWritable(行偏移量),但 Reducer 里根本不用它。这是 MapReduce 的“假 Key”模式——因为我们要的是全量行,不是按 Key 聚合。所以 Reducer 直接透传,不做任何处理。

3.3 Reducer:没有逻辑的“搬运工”,但必须存在

public static class MyReducer extends Reducer<LongWritable, Text, Text, NullWritable> { @Override protected void reduce(LongWritable key, Iterable<Text> values, Context context) throws IOException, InterruptedException { // ⚠️ 关键:这里不做任何聚合!只是把 Mapper 输出的每一行原样写出 // 因为 Hive 需要的是纯文本文件,不是 SequenceFile for (Text value : values) { context.write(value, NullWritable.get()); } } }

为什么不用 IdentityReducer?
IdentityReducer是 Hadoop 内置的,但它的输出是SequenceFile格式。而 Hive 外部表默认读TextFile。所以必须自定义 Reducer,强制输出文本。这也是项目里job.setOutputFormatClass(TextOutputFormat.class)的原因。

3.4 Hive SQL:KPI 计算的五条命脉语句

每一条 HQL 都对应一个 KPI,且全部基于同一张清洗后表techbbs(分区logdate='2015_04_25'):

-- 1. PV:最简单,但最容易错——别漏 WHERE logdate! CREATE TABLE techbbs_pv_2015_04_25 AS SELECT COUNT(1) AS PV FROM techbbs WHERE logdate='2015_04_25'; -- ✅ 必须加,否则统计全量历史 -- 2. 注册用户数:INSTR 比 LIKE 更快,且不区分大小写(INSTR 不敏感) CREATE TABLE techbbs_reguser_2015_04_25 AS SELECT COUNT(1) AS REGUSER FROM techbbs WHERE logdate='2015_04_25' AND INSTR(url, 'member.php?mod=register') > 0; -- 3. 独立 IP 数:DISTINCT 是 Hive 的重操作,但此处无法避免 CREATE TABLE techbbs_ip_2015_04_25 AS SELECT COUNT(DISTINCT ip) AS IP FROM techbbs WHERE logdate='2015_04_25'; -- 4. 跳出用户数:GROUP BY + HAVING 是核心,子查询必须有别名(e) CREATE TABLE techbbs_jumper_2015_04_25 AS SELECT COUNT(1) AS jumper FROM ( SELECT ip, COUNT(1) AS times FROM techbbs WHERE logdate='2015_04_25' GROUP BY ip HAVING times = 1 ) e; -- ⚠️ Hive 要求子查询必须有别名! -- 5. 汇总表:用 CROSS JOIN(ON 1=1)连接单行表,非 INNER JOIN CREATE TABLE techbbs_2015_04_25 AS SELECT '2015_04_25' AS logdate, a.PV, b.REGUSER, c.IP, d.jumper FROM techbbs_pv_2015_04_25 a CROSS JOIN techbbs_reguser_2015_04_25 b CROSS JOIN techbbs_ip_2015_04_25 c CROSS JOIN techbbs_jumper_2015_04_25 d;

避坑点:CROSS JOIN(即ON 1=1)在这里是唯一正确写法。因为每个子表(如techbbs_pv_2015_04_25)只有一行数据。如果用JOIN ... ON a.id = b.id,而表里根本没有id字段,会报错。CROSS JOIN表示笛卡尔积,单行 × 单行 = 单行,完美符合需求。

3.5 Sqoop 导出:字段分隔符是生死线

# ⚠️ 必须指定 --fields-terminated-by '\001' sqoop export \ --connect jdbc:mysql://hadoop-master:3306/techbbs \ --username root \ --password admin \ --table techbbs_logs_stat \ --fields-terminated-by '\001' \ --export-dir '/hive/techbbs_2015_04_25' # 验证:导出前先看 Hive 表内容(确认分隔符) hive -e "SELECT * FROM techbbs_2015_04_25" | head -5 # 输出应为:2015_04_25^A12345^A678^A9876^A2345 (^A 即 \001)

为什么是\001?
Hive 默认用 ASCII 1(Ctrl-A)作为字段分隔符,因为它几乎不会出现在真实数据中。而逗号、制表符都可能在 URL 里出现(如?q=a,b)。Sqoop 不指定--fields-terminated-by时,默认用逗号,必然导致字段错位。

4. 避坑指南:五条让开发半夜爬起来改的血泪教训

这条链路看着只有 Shell、MapReduce、Hive、Sqoop 四步,但每一步都有能让新手卡三天的“温柔陷阱”。以下是我在三个不同集群上踩过的坑,按发生频率排序,每条都附带复现方式和根因。

4.1 现象:Hive 查询techbbs表返回 0 行,但hadoop fs -cat /project/techbbs/cleaned/2015_04_25/part-*能看到数据

原因:Hive 外部表的LOCATION路径写错了,指向了具体文件(如/project/techbbs/cleaned/2015_04_25/part-r-00000),而非目录(/project/techbbs/cleaned/)。Hive 只扫描目录,不认文件。
解决:

-- 错误(指向文件) CREATE EXTERNAL TABLE t(...) LOCATION '/project/techbbs/cleaned/2015_04_25/part-r-00000'; -- 正确(指向目录) CREATE EXTERNAL TABLE t(...) LOCATION '/project/techbbs/cleaned'; -- 然后手动添加分区 ALTER TABLE t ADD PARTITION(logdate='2015_04_25') LOCATION '/project/techbbs/cleaned/2015_04_25';

4.2 现象:MapReduce 任务成功,但cleaned/2015_04_25/下生成的是_SUCCESS和_logs,没有part-r-00000文件

原因:Mapper 输出了空字符串或null,而TextOutputFormat不允许写入空行,任务静默失败。常见于LogParser.parse()返回null时,Mapper 没做判空。
解决:在MyMapper.map()开头加:

if (parsed[0] == null || parsed[1] == null || parsed[2] == null) { return; // 跳过脏数据 }

4.3 现象:Sqoop 导出后,MySQL 表里pv字段值是2015_04_25\00112345\001678\0019876\0012345这种长字符串

原因:Sqoop 命令漏了--fields-terminated-by '\001',导致它把 Hive 输出的整行当做一个字段导入。
解决:

  • 先确认 Hive 表输出分隔符:hive -e "SELECT * FROM techbbs_2015_04_25 LIMIT 1",看是否含^A;
  • 导出命令必须带--fields-terminated-by '\001';
  • 如果已导错,清空 MySQL 表:TRUNCATE TABLE techbbs_logs_stat;

4.4 现象:techbbs_jumper_2015_04_25表查出来是 0,但手动检查日志发现很多 IP 只出现一次

原因:HAVING times = 1写成了HAVING COUNT(1) = 1,而COUNT(1)在GROUP BY ip下恒为 1,条件永远成立,结果是全量 IP 数。
解决:

-- 错误(COUNT(1) 恒为 1) SELECT COUNT(1) FROM (...) GROUP BY ip HAVING COUNT(1) = 1; -- 正确(用别名 times) SELECT COUNT(1) FROM (SELECT ip, COUNT(1) AS times FROM t GROUP BY ip HAVING times = 1) e;

4.5 现象:定时任务techbbs_core.sh在 crontab 里不执行,但手动sh techbbs_core.sh没问题

原因:crontab 的 PATH 环境变量与用户 shell 不同,找不到hadoop、hive、sqoop命令。
解决:在脚本开头显式声明 PATH:

#!/bin/sh export PATH=/usr/local/hadoop/bin:/usr/local/hive/bin:/usr/local/sqoop/bin:$PATH # 后续命令...

或者,用绝对路径调用命令:/usr/local/hadoop/bin/hadoop fs -put ...

5. 二次开发实战:从“跑通”到“能改”的四个关键改造点

跑通这个项目只是起点。真正体现价值的地方,在于你能多快把它改成适配自己业务的日志分析系统。下面四个改造点,我都在线上环境验证过,每个都能在 1 小时内完成,且不影响原有逻辑。

5.1 改造点一:新增 KPI “页面平均停留时长”,只需加两行 Hive SQL

跳出率只能看出“是否离开”,但看不出“停留多久”。运营需要知道用户在forum.php页面平均看了几分钟。
原理:Apache 日志本身不含停留时间,但我们可以用相邻请求的时间差近似。假设同一 IP 的连续两次请求,间隔就是上一个页面的停留时间。
改造步骤:

  1. 在LogParser的parse()方法里,把time字段(yyyyMMddHHmmss)转成BIGINT,便于计算:
    // 在 parse() 末尾加: long timeLong = Long.parseLong(parsed[1]); // 如 20150425123456 → 20150425123456L
  2. 修改 Mapper 输出,增加时间戳字段(IP\t时间戳\tURL):
    outputValue.set(parsed[0] + "\t" + timeLong + "\t" + parsed[2]);
  3. 新建 Hive 表techbbs_time_2015_04_25,用窗口函数计算相邻时间差:
    CREATE TABLE techbbs_time_2015_04_25 AS SELECT ip, url, time_long, LAG(time_long) OVER (PARTITION BY ip ORDER BY time_long) AS prev_time, time_long - LAG(time_long) OVER (PARTITION BY ip ORDER BY time_long) AS stay_sec FROM techbbs WHERE logdate='2015_04_25';
  4. 计算平均停留时长(单位:秒):
    CREATE TABLE techbbs_avg_stay_2015_04_25 AS SELECT AVG(stay_sec) AS avg_stay_sec FROM techbbs_time_2015_04_25 WHERE stay_sec > 0 AND stay_sec < 3600; -- 过滤异常值(<1小时)

为什么有效:LAG()是 Hive 0.11+ 支持的窗口函数,无需 MapReduce 就能计算相邻行差值。stay_sec < 3600过滤掉用户关闭浏览器后又打开的场景,这是行业通用做法。

5.2 改造点二:支持多域名日志,只需改一行 Shell 脚本

原始脚本只处理access_*.log,但你的业务可能有blog.yoursite.com和api.yoursite.com两个域名,日志文件名分别是access_blog_2015_04_25.log和access_api_2015_04_25.log。
改造步骤:
修改techbbs_core.sh的yesterday变量后,用for循环遍历所有域名:

# 原脚本: # hadoop fs -put /usr/local/files/apache_logs/access_${yesterday}.log ... # 改为: for domain in blog api; do hadoop fs -put /usr/local/files/apache_logs/access_${domain}_${yesterday}.log /project/techbbs/data/ hadoop jar mycleaner.jar /project/techbbs/data/access_${domain}_${yesterday}.log /project/techbbs/cleaned/${domain}_${yesterday} done

然后在 Hive 建表时,把url字段改为domain string, url string,Mapper 输出也加域名字段。这样一张表就能存多源日志。

5.3 改造点三:清洗阶段增加 UA 字段,支持终端类型分析

运营想知道多少用户用手机访问。UA 字段在 Apache 日志里位于状态码和流量之后,格式为"Mozilla/5.0 (iPhone..."。
改造步骤:

  1. 在LogParser.parse()末尾加 UA 解析:
    private String parseUA(String line) { int lastQuote = line.lastIndexOf('"'); if (lastQuote == -1) return ""; // UA 是倒数第二个 " 和最后一个 " 之间 int secondLast = line.lastIndexOf('"', lastQuote - 1); return secondLast == -1 ? "" : line.substring(secondLast + 1, lastQuote); }
  2. Mapper 输出改为IP\t时间\tURL\tUA:
    outputValue.set(parsed[0] + "\t" + parsed[1] + "\t" + parsed[2] + "\t" + ua);
  3. Hive 表加字段:ALTER TABLE techbbs ADD COLUMNS (ua STRING);
  4. 统计手机用户:
    SELECT COUNT(1) FROM techbbs WHERE logdate='2015_04_25' AND (INSTR(ua, 'iPhone') > 0 OR INSTR(ua, 'Android') > 0);

5.4 改造点四:用 Hive 分区表替代临时表,彻底告别“每日建表”

原始方案每天建新表(techbbs_pv_2015_04_25),导致 Hive 元数据库膨胀。更好的做法是建一张永久表,按日期分区。
改造步骤:

  1. 创建永久表:
    CREATE TABLE techbbs_kpi_daily ( pv BIGINT, reguser BIGINT, ip BIGINT, jumper BIGINT ) PARTITIONED BY (logdate STRING) ROW FORMAT DELIMITED FIELDS TERMINATED BY '\001';
  2. 每日插入(替换原CREATE TABLE ... AS SELECT):
    INSERT INTO TABLE techbbs_kpi_daily PARTITION(logdate='2015_04_25') SELECT (SELECT COUNT(1) FROM techbbs WHERE logdate='2015_04_25') AS pv, (SELECT COUNT(1) FROM techbbs

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

RELATED READING

延伸阅读

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