Hadoop网站日志离线分析:Apache日志清洗到Hive统计与Sqoop导出 简介《大数据综合案例-网站日志分析》是一份面向大数据离线计算学习者与项目实践者的案例文档以某技术学习论坛的 Apache Common 日志为对象解答如何从原始访问记录中提炼运营决策所需关键指标这一问题适合已具备 HDFS、MapReduce、Hive 基础、希望串联完整离线链路的中级读者。包内共 1 个 docx 文档约 1013KB篇幅紧凑但结构完整先交代 56GB 历史数据与每日约 150MB 增量文件的数据背景再逐一拆解 PV、注册用户数、独立 IP 数、跳出率、板块热度排行榜五项 KPI 的定义与计算口径随后按上传 HDFS、MapReduce 清洗、Hive 统计、Sqoop 导出 MySQL、HBase 存明细的顺序梳理开发步骤并给出 MySQL 与 HBase 的表结构设计。目前已有 1403 人学习。读者可从中掌握日志字段裁剪、静态资源请求过滤、日期格式转换等清洗思路并理解指标结果库与明细库分离的存储取舍。1. 从一份 56GB 的 Apache 日志说起网站日志分析这套离线链路到底能落地什么拿到这份资源的时候我先翻的是数据描述而不是代码历史日志约 56GB 且全部追加在一个文件里从某一天起改成每天一个约 150MB 的独立文件。这个细节决定了整套网站日志分析的架构走向——追加写的大文件适合一次性跑批按天切分的小文件适合做定时调度而这份案例恰好把两种形态都覆盖了所以它不是一个玩具 Demo而是一条能对着改的离线链路。它解决的问题很具体某技术学习论坛想拿到 PV、注册用户数、独立 IP、跳出率、板块热度这五个指标而第三方统计工具给不出「按栏目拆分的浏览量」和「跳出用户明细」这类口径只能自己从原始日志里算。技术栈是 Shell HDFS MapReduce Hive Sqoop MySQLHBase 存明细做二级查询。适合已经会写 MapReduce、但没完整跑过一条「采集—清洗—统计—导出—展示」链路的人也适合要交大数据课程设计、需要一个能讲清楚每步为什么这么做的参照案例的人。2. 日志解析与清洗MapReduce 里那五个字段的切法与四个边界坑清洗是整个链路里最容易翻车的一环。后面 Hive 的每一条 HQL 都建立在「清洗后的文件是严格的制表符分隔三列」这个前提上一旦解析器把某一行切歪了错误不会在清洗阶段报出来而是等到统计结果对不上业务方预期时才暴露。所以这一章把解析逻辑拆到字符级别来看。2.1 一行日志的五段边界在哪里Apache common 日志的典型一行长这样192.168.1.10 - - [30/May/2013:00:00:01 0800] GET /forum.php?modviewthreadtid123 HTTP/1.1 200 3421按段落拆开看五个字段的边界分别靠不同的锚点定位字段起始锚点结束锚点备注访问者 IP行首- -之前用- -切分取第 0 段访问时间[之后0800]之前需按英文 Locale 解析访问资源第一个之后最后一个之前含方法、路径、协议三部分HTTP 状态码最后一个之后空格本次流量状态码之后行尾单位字节关键认知是不能用空格 split 整行。URL 里可能出现编码后的空格%20User-Agent 段虽然在 common 格式里没有但不少论坛会在行尾追加 referer 或 UA一旦按空格切字段位置就全乱了。这个案例选择「前缀- -、方括号、双引号」三种结构性锚点来定位是稳妥的做法。2.2 LogParser为什么用 indexOf 而不是 split解析类单独抽出来是为了让 Mapper 只关心过滤规则。核心代码如下static class LogParser { // 日志里的月份是英文缩写必须指定 Locale.ENGLISH否则中文环境下解析失败 public static final SimpleDateFormat FORMAT new SimpleDateFormat(d/MMM/yyyy:HH:mm:ss, Locale.ENGLISH); // 输出统一成 14 位紧凑格式方便后续按字符串比较和分区 public static final SimpleDateFormat dateformat1 new SimpleDateFormat(yyyyMMddHHmmss); private Date parseDateFormat(String string) { Date parse null; try { parse FORMAT.parse(string); } catch (ParseException e) { e.printStackTrace(); // 注意这里吞掉异常会返回 null后面 format 会 NPE } return parse; } /** 返回 5 个元素ip、时间、url、状态码、流量 */ public String[] parse(String line) { String ip parseIP(line); String time parseTime(line); String url parseURL(line); String status parseStatus(line); String traffic parseTraffic(line); return new String[] { ip, time, url, status, traffic }; } private String parseIP(String line) { return line.split(- -)[0].trim(); } private String parseTime(String line) { final int first line.indexOf([); final int last line.indexOf(0800]); // 时区写死换机房要改 String time line.substring(first 1, last).trim(); return dateformat1.format(parseDateFormat(time)); } private String parseURL(String line) { final int first line.indexOf(\); final int last line.lastIndexOf(\); return line.substring(first 1, last); } private String parseStatus(String line) { // 双引号之后的部分是 状态码 流量按空格取第 0 段 String[] parts line.substring(line.lastIndexOf(\) 1).trim().split( ); return parts[0]; } private String parseTraffic(String line) { String[] parts line.substring(line.lastIndexOf(\) 1).trim().split( ); return parts[1]; } }逻辑说明几个点。parseTime用indexOf(0800])而不是正则是因为时区段在一行里只出现一次且格式固定indexOf 的常数开销比 Pattern 编译小得多在千万行级别上是实打实的差距。parseURL用lastIndexOf(\)而不是找HTTP/是为了兼容某些日志里协议段缺失的情况。参数上的可调项有两处值得留意一是时区锚点0800]如果日志来自不同时区的服务器这个硬编码会让时间整体偏移 8 小时进而导致分区挂错二是dateformat1的输出格式选yyyyMMddHHmmss是为了和目录名yyyy_MM_dd区分开前者是行级时间戳后者是天级分区两个格式不要混。提示SimpleDateFormat不是线程安全的。这里每个 Mapper 实例持有一个 LogParsermap 方法是单线程调用所以没事但如果哪天把 LogParser 提成 static 共享给多线程就会出现随机的时间解析错乱这是老坑。2.3 Mapper 的三步过滤与 Reducer 的直通写法清洗的目标不是解析全部字段而是只留分析要用的。访问状态码和流量这两个字段在五个 KPI 里一个都用不上注册用户数靠 URL 匹配、PV 靠计数、独立 IP 靠去重、跳出率靠 IP 分组全都用不到状态码。所以 Mapper 只输出三列。static class MyMapper extends MapperLongWritable, Text, LongWritable, Text { LogParser logParser new LogParser(); Text outputValue new Text(); Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { final String[] parsed logParser.parse(value.toString()); // step1. 过滤静态资源图片、样式、后台管理入口都不计入页面浏览 if (parsed[2].startsWith(GET /static/) || parsed[2].startsWith(GET /uc_server)) { return; } // step2. 去掉请求方法前缀只保留路径 if (parsed[2].startsWith(GET /)) { parsed[2] parsed[2].substring(GET /.length()); } else if (parsed[2].startsWith(POST /)) { parsed[2] parsed[2].substring(POST /.length()); } // step3. 去掉协议后缀 if (parsed[2].endsWith( HTTP/1.1)) { parsed[2] parsed[2].substring(0, parsed[2].length() - HTTP/1.1.length()); } // step4. 只写 ip、时间、url 三列用制表符分隔 outputValue.set(parsed[0] \t parsed[1] \t parsed[2]); context.write(key, outputValue); } }Reducer 写成直通不做任何聚合static class MyReducer extends ReducerLongWritable, Text, Text, NullWritable { Override protected void reduce(LongWritable k2, IterableText v2s, Context context) throws IOException, InterruptedException { for (Text v2 : v2s) { context.write(v2, NullWritable.get()); // 值原样落地key 置空 } } }为什么保留一个 Reducer 而不干脆用mapreduce.job.reduces0两个原因。其一是清洗后的文件要作为 Hive 外部表的数据源单文件比一堆 part 文件在排查时方便得多其二是设 0 会走 map-only 模式输出文件数等于分片数150MB 的日志按 128MB 块切大约 2 个分片看着不多但历史那 56GB 一跑就是几百个小文件后面对 Hive 输入分片不友好。折中方案是保留 Reducer 并把数量显式设成 1。过滤规则的边界要说清楚。/static/只是这个论坛的静态资源目录名换一个论坛可能是/images/、/assets/或者干脆走 CDN 不落日志。判断依据应该是「这条请求会不会被前端算作一次页面浏览」而不是照抄字符串。同理/uc_server是论坛后台管理入口运营决策指标不该把它算进去。2.4 打包提交与清洗结果的自检代码写完后导出 jar提交命令大致是这样hadoop jar /usr/local/files/apache_logs/mycleaner.jar \ /project/techbbs/data/access_2015_04_25.log \ /project/techbbs/cleaned/2015_04_25参数说明第一个路径是 HDFS 上的输入文件或目录第二个是输出目录输出目录必须不存在否则作业在提交阶段就会抛FileAlreadyExistsException。这一点和第 3 章的定时脚本直接相关——如果脚本每天用同一个输出路径第二天必然失败。跑完之后不要只看「Job complete」要对着计数器核三个数计数器含义判断标准Map input records输入总行数应等于日志文件行数Map output records过滤后保留的行数明显小于输入否则过滤规则没生效Reduce output records落地行数应等于 Map output records这个案例的一次实际运行里输入 548160 行、输出 169857 行保留了约 31%说明静态资源和后台请求占了近七成。如果这个比例是 99%基本可以断定过滤条件写错了前缀如果是 0%那就是全部被误杀去检查startsWith的字符串有没有多一个空格。注意清洗后的 URL 里如果残留制表符Hive 建表时会多切出一列表现为某一行的第三列之后全是 NULL。落盘前对 URL 做一次replace(\t, %09)是最省事的兜底。3. 数据怎么进 HDFSShell 上传、目录规划与定时脚本的写法清洗代码只是链路中间的一段两头都得靠调度串起来。这一章解决的是「原始日志怎么从日志服务器进到 HDFS并且每天自动进一次」。3.1 三种上传路径怎么选这个案例给了三个选项选择依据是数据量和服务器压力不是技术新旧方案适用场景代价Shell 直接 put单机日志、数据量小、磁盘压力低上传时占用日志服务器带宽NFS 挂载后从另一台机器 put日志服务器磁盘和 CPU 吃紧需要维护挂载故障点多Flume 采集日志服务器多、实时性要求高组件多、配置复杂、要单独运维这个案例每天 150MB放在凌晨 1 点传对线上几乎无感所以选第一种。判断标准可以粗暴一点单日增量在 1GB 以内、上传窗口在业务低峰Shell 足够超过就得考虑 Flume因为 put 是一次性大文件传输中断后要整份重传而 Flume 的 source-channel-sink 结构支持断点续传和批量攒批。HDFS 侧的目录规划建议按「层 日期」两级拆开# 原始数据按天一个文件文件名带日期 /project/techbbs/data/access_2015_04_25.log # 清洗后数据按天一个目录目录名就是 Hive 分区值 /project/techbbs/cleaned/2015_04_25/清洗目录的命名格式yyyy_MM_dd是刻意选的因为它后面会直接作为 Hive 分区字段logdate的值。如果这里写成2015-04-25那 Hive 建分区时也要跟着写带横线的格式两边必须严格一致错一个字符就是分区查不到数据。3.2 定时脚本的骨架脚本要做三件事算出昨天的日期字符串、把日志传到 HDFS、跑清洗作业。骨架如下#!/bin/sh # 失败即退出避免上传失败还继续跑清洗 set -e # step1. 计算昨天的日期格式与 HDFS 目录一致 yesterday$(date --date1 days ago %Y_%m_%d) # step2. 上传原始日志-f 表示目标已存在时覆盖 hadoop fs -put -f /usr/local/files/apache_logs/access_${yesterday}.log \ /project/techbbs/data/ # step3. 清洗输出目录带上日期天然做到幂等 hadoop jar /usr/local/files/apache_logs/mycleaner.jar \ /project/techbbs/data/access_${yesterday}.log \ /project/techbbs/cleaned/${yesterday}几个细节值得展开。date --date1 days ago在 GNU date 下可用如果换成别的实现等价写法是date -d yesterday输出格式用%Y_%m_%d保证两位数补零否则 5 月 9 日会变成2015_5_9和分区值对不上。-f参数是覆盖上传加上之后脚本可以重复执行而不会因为文件已存在中断这一条在补数据的时候非常关键。set -e是给脚本加后悔药上传失败了就不该继续跑清洗否则清洗作业会读到不存在的输入路径报一个和真实原因无关的错排查时容易绕远路。脚本里的 jar 路径和日志路径都写成了绝对路径这是刻意为之。cron 执行时的PATH和登录 shell 不一样hadoop往往不在里面绝对路径能绕开一大半环境问题剩下的部分在第 5 章展开。3.3 输出目录与重跑的处理清洗的输出目录带了日期所以同一天重跑会撞上FileAlreadyExistsException。有两种处理方式取决于数据是否可以信任# 方式一重跑前先删掉当天目录干净但危险删错了要重跑全链路 hadoop fs -rm -r -skipTrash /project/techbbs/cleaned/${yesterday} # 方式二输出到临时目录成功后再 mv 覆盖失败不影响已有数据 hadoop jar mycleaner.jar /project/techbbs/data/access_${yesterday}.log \ /project/techbbs/tmp/${yesterday} hadoop fs -rm -r -skipTrash /project/techbbs/cleaned/${yesterday} hadoop fs -mv /project/techbbs/tmp/${yesterday} /project/techbbs/cleaned/${yesterday}我一般用第二种。第一种的问题在于「删除」和「重跑」之间有一个时间窗口如果这时候下游 Hive 正好在跑当天的统计会读到空分区产出一份 PV 为 0 的报表而且不会报错。第二种先用临时目录成功后再原子替换虽然多一次 mv但把风险窗口压到了毫秒级。提示-skipTrash是为了让删除真正生效。不带这个参数时文件会进回收站HDFS 空间不会立刻释放跑几天之后可能因为配额不足而失败而错误信息只会说写入失败。还有一个容易忽略的点单天 150MB 的文件清洗后大约 40MB 左右远小于 HDFS 的块大小默认 128MB所以每天产出一个小文件。短期没问题但一个月就是 30 个小文件一年 365 个。Hive 查询单个分区时无所谓做跨月聚合时这些小文件会拖慢任务启动。常见做法是按月做一次合并或者在 Hive 侧开启合并小文件的参数hive.merge.mapfiles和hive.merge.size.per.task让它读的时候自己合。4. Hive 统计五个 KPI外部表、分区与 HQL 口径清洗完的数据是「IP 时间戳 URL」三列文本接下来要用 Hive 把它变成五个能直接给运营看的数字。这一章的重点不是 HQL 语法而是每个指标的口径怎么落成 SQL 才不会算错。4.1 外部表加分区LOCATION 指到哪一层建表语句如下CREATE EXTERNAL TABLE techbbs( ip string, atime string, url string ) PARTITIONED BY (logdate string) ROW FORMAT DELIMITED FIELDS TERMINATED BY \t LOCATION /project/techbbs/cleaned;四个决定要说清楚。用EXTERNAL是因为数据的所有权在 HDFS不在 Hive——万一表被误删只删元数据文件还在重建表就能恢复。字段分隔符必须是\t和 Mapper 输出的分隔符严格一致不一致的表现是所有行都只有一个字段全部塞进ip列。LOCATION指到cleaned这一层而不是具体某天因为分区目录是它的下级。分区字段logdate是虚拟列不占实际列位查询时可以直接WHERE logdate2015_04_25。加分区的命令ALTER TABLE techbbs ADD PARTITION(logdate2015_04_25) LOCATION /project/techbbs/cleaned/2015_04_25;分区值用下划线格式和目录名逐字符对应。如果分区加了但查出来是 0 行八成是 LOCATION 的路径多写或漏写了一层先用hadoop fs -ls确认目录里确实有part-r-00000这样的文件再回头查路径。4.2 五个 KPI 的 HQL 与口径陷阱PV最简单就是对分区内的行计数CREATE TABLE techbbs_pv_2015_04_25 AS SELECT COUNT(1) AS pv FROM techbbs WHERE logdate2015_04_25;COUNT(1)和COUNT(*)在这个场景没区别都是行数。要点在于 PV 的定义是「打开一个页面记一次」所以清洗阶段必须已经把静态资源过滤掉否则图片请求会被当成页面浏览PV 会虚高好几倍。注册用户数靠 URL 匹配CREATE TABLE techbbs_reguser_2015_04_25 AS SELECT COUNT(1) AS reguser FROM techbbs WHERE logdate2015_04_25 AND INSTR(url, member.php?modregister) 0;用INSTR而不是是因为注册请求可能带额外参数验证码、来源渠道精确匹配会漏。这里有个口径问题值得和业务方对齐这个数字统计的是「注册页被请求了多少次」不是「成功注册了多少人」。用户刷新一次注册页就多一次机器人爬一次也多一次。想拿到更准的数字得配合状态码过滤只留 200或者去重 IP但那就要把状态码也清洗进来成本会上升。独立 IP 数CREATE TABLE techbbs_ip_2015_04_25 AS SELECT COUNT(DISTINCT ip) AS ip FROM techbbs WHERE logdate2015_04_25;COUNT(DISTINCT)在单天 40MB 数据上毫无压力但如果要算整月写法应该改成子查询先按 ip 去重再外层计数或者直接上GROUP BY ip再 count后者在数据倾斜时更稳。另外这个指标在 NAT 环境下天然偏低一个公司出口 IP 后面几百个人也只算一个解读时要带上这句。跳出用户数是最容易写错的一个CREATE TABLE techbbs_jumper_2015_04_25 AS SELECT COUNT(1) AS jumper FROM ( SELECT ip FROM techbbs WHERE logdate2015_04_25 GROUP BY ip HAVING COUNT(1) 1 ) e;内层按 IP 分组HAVING COUNT(1)1筛出当天只有一条记录的 IP也就是「来了一次、看了一页、走了」。外层再数这些人有多少个。跳出率就是jumper / pv。这里的坑在「一天」这个窗口。凌晨 23:59 来的用户和第二天 00:01 来的用户在按天分区下会被算成两个跳出用户而实际上是一次连续访问。做日粒度报表时这是行业通病但如果业务方拿跳出率去评估广告效果最好补一句「跨天访问会被重复计入」。板块热度需要从 URL 里抽板块标识CREATE TABLE techbbs_block_2015_04_25 AS SELECT regexp_extract(url, modforumdisplayfid(\\d), 1) AS fid, COUNT(1) AS pv FROM techbbs WHERE logdate2015_04_25 AND INSTR(url, modforumdisplay) 0 GROUP BY regexp_extract(url, modforumdisplayfid(\\d), 1) ORDER BY pv DESC;regexp_extract的第三个参数 1 表示取第一个捕获组写 0 会返回整个匹配串。这种写法强依赖论坛的 URL 规则换个系统可能参数名是boardid或cid别照抄先去日志里 grep 几个真实样本看清楚。4.3 汇总表与 join on 11五个指标分散在五张表里Sqoop 一条命令只能导一张表所以要先合成一张宽表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 JOIN techbbs_reguser_2015_04_25 b ON 11 JOIN techbbs_ip_2015_04_25 c ON 11 JOIN techbbs_jumper_2015_04_25 d ON 11;ON 11就是无条件笛卡尔积。因为每张子表都只有一行结果也只有一行这种做法在这里是安全的但必须确认每张子表真的只有一行——如果哪一步的 HQL 漏写了聚合变成多行这里会瞬间放大成 N×N 行而且不报错。跑完先SELECT COUNT(1)验一下是不是等于 1。还有一个细节CREATE TABLE ... AS SELECT建出来的表没有指定分隔符Hive 会给默认值\001。这一点在第 5 章的 Sqoop 导出里会变成一个大坑这里先记住结论CTAS 出来的表和外部表的分隔符不是一回事。4.4 分区维护与历史回刷每天新增一个分区长期跑下去SHOW PARTITIONS会列出几百行。日常查询靠WHERE logdate做分区裁剪不影响的性能但有两件事要定期做一是清理超期分区。保留策略通常是明细 90 天、汇总永久ALTER TABLE techbbs DROP PARTITION(logdate...)只删元数据HDFS 上的文件还在要配合删目录才算真正释放。二是历史回刷。如果某天发现清洗规则有问题比如漏了某个静态资源目录需要从原始日志重跑那一天的清洗和统计。这时候前面按日期命名的目录结构和带日期后缀的 Hive 表名就体现出价值了——把日期作为参数传进脚本一条命令就能把某一天从头走一遍不用手工改代码。代价是会产生一堆带日期后缀的临时表回刷完成后要记得清理否则元数据库里表数量会一直涨。5. 数据链路排查手册从 HDFS 到 MySQL 的五个翻车现场这条链路横跨五个组件任何一段出错现象都可能指向别处。下面五条是我踩过或见过的真实翻车按「现象—原因—解决」记下来出问题时按顺序对照能省掉大量时间。5.1 定时任务一天跑了几十次现象第二天看 HDFSdata目录里同一个日志文件出现了几十份副本或者清洗作业的日志刷屏。原因crontab 写成了* 1 * * * techbbs_core.sh。五个字段依次是「分 时 日 月 周」第一个*表示每分钟。这一行的意思是「每天 1 点这一小时内的每一分钟都执行」也就是 60 次。原案例里就是这么写的实测时因为只手工跑了一次没暴露出来。解决改成0 1 * * *。上线前用crontab -l看一眼实际生效的配置比看脚本文件可靠。同时给脚本加锁防止上一轮没跑完下一轮就起来#!/bin/sh # 用文件锁保证同一时刻只有一个实例在跑 LOCK/tmp/techbbs_core.lock if [ -f $LOCK ]; then echo previous job still running, exit exit 0 fi touch $LOCK trap rm -f $LOCK EXIT # 以下才是正式逻辑trap ... EXIT保证脚本无论正常结束还是异常退出都会删锁否则一次失败就会永久阻塞后续任务。5.2 cron 里报 hadoop 找不到现象手工执行脚本一切正常加到 crontab 之后没有任何输出或者日志里写着hadoop: command not found。原因cron 执行时的环境变量极简PATH通常只有/usr/bin:/bin登录 shell 里那种/etc/profile、~/.bashrc都不会被加载Hadoop 的 bin 目录自然不在PATH里。解决三条路按推荐顺序。第一在脚本开头显式source /etc/profile或source ~/.bash_profile第二命令全部写绝对路径比如/opt/hadoop/bin/hadoop第三在 crontab 里直接设PATH/opt/hadoop/bin:/usr/local/bin:/usr/bin:/bin。我一般前两条一起用因为有些环境变量比如JAVA_HOME是 hadoop 启动脚本依赖的光加 PATH 不够。5.3 重跑时报输出目录已存在现象org.apache.hadoop.mapred.FileAlreadyExistsException: Output directory ... already exists或者put: File exists。原因MapReduce 为了保证输出不重复强制要求输出目录预先不存在hadoop fs -put默认也不覆盖同名文件。定时任务重跑同一天的数据时必然撞上。解决put 加-f覆盖MapReduce 的输出目录要么带日期天然不重复要么先rm -r -skipTrash再跑要么改成写临时目录后 mv见 3.3。注意-skipTrash不能省否则文件进回收站磁盘空间照样占着。5.4 Sqoop 导出的数字全挤在一列现象MySQL 里查techbbs_logs_statlogdate字段是一长串2015_04_25169857...后面的pv、reguser全是 NULL或者整行数据只有一条。原因分隔符不匹配。Sqoop 的--fields-terminated-by要和 Hive 表在 HDFS 上实际使用的分隔符一致。第 4 章那张汇总表是CREATE TABLE AS SELECT建的没显式指定分隔符Hive 用的是默认的\001如果导出时按外部表的习惯写\tSqoop 就会把整行当成一个字段。解决先hadoop fs -cat看一下实际文件里字段之间是什么字符\001在终端显示为^A制表符会显示成空白。然后用对应的分隔符导出sqoop export \ --connect jdbc:mysql://mysql-host:3306/techbbs \ --username root --password *** \ --table techbbs_logs_stat \ --fields-terminated-by \001 \ --export-dir /hive/techbbs_2015_04_25--export-dir指向的是 Hive 表在 HDFS 上的实际位置不是 Hive 表名。可以用DESCRIBE FORMATTED techbbs_2015_04_25查Location那一行确认。5.5 第二天导出报主键冲突现象Duplicate entry 2015_04_25 for key PRIMARYSqoop 任务失败。原因MySQL 里logdate是主键同一天的数据重复导出自然冲突。定时任务里如果因为前面某步失败而重跑这一步就会撞上。解决两种方式看是否需要保留历史版本。只补当天的话导出前DELETE FROM techbbs_logs_stat WHERE logdate...要支持幂等重跑的话用 upsertsqoop export \ --connect jdbc:mysql://mysql-host:3306/techbbs \ --username root --password *** \ --table techbbs_logs_stat \ --fields-terminated-by \001 \ --export-dir /hive/techbbs_2015_04_25 \ --update-key logdate \ --update-mode allowinsert--update-key指定判断重复的列--update-mode allowinsert表示存在则更新、不存在则插入。注意这只是让重复导出不报错如果 Hive 侧本身算错了upsert 会把错的数据安静地覆盖进去所以导出前先比对一下 PV 数字。注意这条链路上每一段都可能静默产出错误数据而不报错——清洗漏过滤会让 PV 虚高、Hive 分区挂错会查出 0 行、Sqoop 分隔符错位会把数字塞进字符串列。所以每一段跑完都要有一个显式的数字核对别只看到「成功」两个字就放过。6. 把七条命令收成一个可重跑的脚本以及怎么验证它真的算对了前面几章的步骤都是手工敲的真跑一个月就会知道手工不可持续。把第 3 章的脚本扩成覆盖全链路的版本关键改动是把每一步都变成「可重复执行而不报错」顺便带上失败即停#!/bin/bash set -e yesterday$(date --date1 days ago %Y_%m_%d) # 1. 上传覆盖模式重跑安全 hadoop fs -put -f /usr/local/files/apache_logs/access_${yesterday}.log \ /project/techbbs/data/ # 2. 清洗到临时目录成功后原子替换 hadoop fs -rm -r -skipTrash /project/techbbs/tmp/${yesterday} || true hadoop jar /usr/local/files/apache_logs/mycleaner.jar \ /project/techbbs/data/access_${yesterday}.log \ /project/techbbs/tmp/${yesterday} hadoop fs -rm -r -skipTrash /project/techbbs/cleaned/${yesterday} || true hadoop fs -mv /project/techbbs/tmp/${yesterday} /project/techbbs/cleaned/${yesterday} # 3. 分区先删后加避免重复分区报错 hive -e ALTER TABLE techbbs DROP IF EXISTS PARTITION(logdate${yesterday}); ALTER TABLE techbbs ADD PARTITION(logdate${yesterday}) LOCATION /project/techbbs/cleaned/${yesterday}; # 4. 统计五个指标并合成汇总表表名带日期 hive -e CREATE TABLE IF NOT EXISTS techbbs_pv_${yesterday} AS SELECT COUNT(1) AS pv FROM techbbs WHERE logdate${yesterday}; CREATE TABLE IF NOT EXISTS techbbs_ip_${yesterday} AS SELECT COUNT(DISTINCT ip) AS ip FROM techbbs WHERE logdate${yesterday}; # 5. 导出upsert 保证重跑不报主键冲突 sqoop export --connect jdbc:mysql://mysql-host:3306/techbbs \ --username root --password *** --table techbbs_logs_stat \ --fields-terminated-by \001 \ --export-dir /hive/techbbs_${yesterday} \ --update-key logdate --update-mode allowinsert脚本化之后验证就成了唯一能判断「跑完了」和「跑对了」差别的手段。我习惯用下面这张表逐个对任何一格对不上就停下来查校验点计算方式常见偏差原因输入文件行数hadoop fs -cat ... | wc -l日志文件本身被截断清洗后行数同上对 cleaned 目录过滤规则误杀或漏杀Hive 分区行数SELECT COUNT(1) FROM techbbs WHERE logdate...分区 LOCATION 挂错PV 值与上一条相等不等说明分区里混了别的日期MySQL 行数SELECT COUNT(1) FROM techbbs_logs_stat导出字段错位会少行跳出率jumper / pv落在 01 之间超出范围说明分母取错表最后一条最有用。跳出率的合理区间很窄论坛类站点通常在 30%60%一旦算出 300% 或者 0.03不用看代码就知道分子分母取错了表——大概率是汇总表 join 的时候某张子表返回了多行笛卡尔积把 jumper 放大了。还有一个进阶一点的做法把每天的 PV、IP、跳出率写进一张按天累积的宽表连续观察一周的波动。如果某个指标突然跳变 3 倍以上而当天日志量没有对应变化基本可以断定是清洗规则或分区出了问题而不是业务真的涨了。这种「看趋势不看单点」的校验习惯比任何单次核对都更容易发现问题。从那以后我每次接这类离线链路都强制先把「输入行数—清洗行数—分区行数—导出行数」四个数字打在一张纸上跑完逐个对一遍才允许下游看报表。血泪经验是这四行数字花不了五分钟但能挡掉后面几天的返工。希望帮到你。本文还有配套的精品资源点击获取