简介这是一份面向大数据初学者与平台运维/数据分析人员的大数据综合案例文档聚焦“网站日志分析”完整离线链路。案例以某技术学习论坛 Apache Common 日志为数据源说明如何围绕 PV、注册用户数、独立 IP 数、跳出率、板块热度排行榜等 KPI 展开分析并梳理从日志上传、MapReduce 清洗、Hive 统计、Sqoop 导出 MySQL 到 HBase 明细查询的实战流程。压缩包共 1 个文件类型为 docx大小约 1013KB属图文讲解型资料。文档还给出了 MySQL/HBase 表结构设计、历史 56GB 日志与每日 150MB 日志的差异处理思路以及 shell 定时上传、NFS/Flume 选型等细节便于按步骤复现或改造到自有日志分析场景。已有 1401 人学习下载适合用于课程设计、离线数仓入门练习或大数据综合实训参考。1. 网站日志分析到底分析什么一个能直接落地的离线统计闭环打开一份 Nginx 访问日志你会看到一行行夹杂着 IP、时间、URL、状态码和 User-Agent 的字符串。新手容易以为网站日志分析就是写几条 SQL 数 PV而真正把项目跑起来的人都知道这条链路上 80% 的工作量不在统计而在把分散在多台服务器上的日志可靠地采集进数仓再清洗成结构化数据。这不是一个「写 SQL 题」项目而是一个从采集、预处理、存储、统计到可视化的完整离线数据管道。它能解决三类问题流量趋势与高峰时段识别、热门内容与来源渠道评估、异常访问与爬虫行为初筛。适合正在学大数据生态、想找一个能串联 Flume、MapReduce、Hive、Sqoop 的完整案例或者刚接手公司日志分析任务但不知道从哪下手的从业者。本文按一条可复现的链路来讲日志怎么进 HDFS、怎么洗成表、怎么算指标、怎么排查翻车现场。2. 整体架构与日志采集Flume 把 Nginx 日志安全搬进 HDFS2.1 为什么选 Flume这条链路上每个环节的选型理由网站日志分析最常见的数据来源是 Nginx 或 Apache 的 access.log分布在若干台 Web 服务器上。把这些日志汇聚到 HDFS可选方案不止一个直接用scp加 crontab 定时拷贝是最原始的做法但做不到断点续传文件一多就丢数据用 Kafka 做缓冲对日志量不大的场景又显得过重还要额外维护一套 ZooKeeper。Flume 是这条链路上最「刚刚好」的组件它天然面向日志文件用 taildir source 可以监听文件新增内容并记录读取位置即使 agent 重启也能从上次位置继续读不丢数据。Sink 直接对接 HDFS写完自动按大小或时间滚动文件省去自己写上传脚本的麻烦。我一般会把整个离线链路分成四层采集层用 Flume存储层用 HDFS计算层先用 MapReduce 做一次预处理清洗再用 Hive 做统计最后用 Sqoop 把统计结果导出到 MySQL交给可视化层展示。这个分层的好处是每一层都能独立验证——采集层看 HDFS 有没有文件落地预处理层看清洗前后的行数对比统计层看指标是否符合常识。任何一个环节出问题都能快速定位到具体那一层不需要从头查起。2.2 最小可跑通的 Flume 配置从 tail 到 HDFS 的完整 agent以最常见的单机日志为例Nginx 日志路径为/var/log/nginx/access.log我们希望按天把数据落到 HDFS 的/data/weblog/20250120/这样的目录下。一个能直接改 IP 和路径就用的 Flume agent 配置长这样# flume-weblog.conf a1.sources s1 a1.channels c1 a1.sinks k1 # source监听文件尾部新增内容 a1.sources.s1.type taildir a1.sources.s1.filegroups f1 a1.sources.s1.filegroups.f1 /var/log/nginx/access.log a1.sources.s1.positionFile /opt/flume/data/taildir_position.json a1.sources.s1.fileHeader true # channel用 memory 做缓冲容量调大防止 source 快于 sink a1.channels.c1.type memory a1.channels.c1.capacity 20000 a1.channels.c1.transactionCapacity 5000 # sink写入 HDFS按天分目录 a1.sinks.k1.type hdfs a1.sinks.k1.hdfs.path /data/weblog/%Y%m%d a1.sinks.k1.hdfs.filePrefix access a1.sinks.k1.hdfs.rollInterval 60 a1.sinks.k1.hdfs.rollSize 134217728 a1.sinks.k1.hdfs.rollCount 0 a1.sinks.k1.hdfs.fileType DataStream a1.sinks.k1.hdfs.writeFormat Text a1.sources.s1.channels c1 a1.sinks.k1.channel c1这段配置里有几个参数决定了数据安全和文件形态值得单独说。positionFile是 taildir source 的断点记录文件默认记录每个文件读到哪个字节位置agent 重启后从这里续读这就是「不丢数据」的底气所在。rollInterval表示每隔 60 秒强制滚动一个文件rollSize表示文件达到 128 MB 时滚动rollCount设为 0 表示不按事件条数滚动。实际项目中这三个值要按日志量配合日志量大的站点rollInterval可以调到 300让文件尽量攒到接近rollSize再滚动减少小文件数量日志量小的站点rollInterval保持 60 反而合适因为查询时希望看到更细的时间分片。启动命令也很直接flume-ng agent \ --name a1 \ --conf-file /opt/flume/conf/flume-weblog.conf \ --conf /opt/flume/conf \ -Dflume.root.loggerINFO,console日志量大的生产环境建议把flume.root.logger改成 INFO,file 并配置独立的 log4j 文件因为 console 输出在后台运行时会产生大量无意义日志半年下来能占掉好几个 GB 磁盘。2.3 验证采集链路先看文件落地再谈数据质量配置写完先别急着往 Hive 里灌数据第一步是验证采集链路通不通。启动 agent 后手动往 access.log 里追加几行测试日志然后查 HDFShdfs dfs -ls /data/weblog/ hdfs dfs -cat /data/weblog/20250120/access.1234567890.tmp注意Flume 正在写的文件后缀是.tmp滚动完成后才会去掉。如果你看到.tmp文件持续存在且大小不变通常不是链路断了而是rollInterval还没到触发时间或者rollCount设置了非零值导致按条数提前滚动。这个阶段最常见的错误是 sink 的hdfs.path写成了带单引号的静态路径比如/data/weblog/20250120这样所有时间的数据都会堆到同一个目录后面的分区表就彻底没法按天查了。路径里的%Y%m%d会被 Flume 自动替换成当前时间不需要自己在脚本里拼日期。另外一个常被忽略的点是fileHeader true。开启后 Flume 会在每行事件头附加一个{file}头字段标记该行来自哪个原始文件。对于单机单日志场景这个字段用处不大但如果是多台 Web 服务器日志汇聚到同一个 HDFS 目录这个字段能帮你回溯某一行到底来自哪台机器排错时非常有用。3. 离线预处理用 MapReduce 做一次干净的解析与清洗3.1 为什么先做预处理Hive 直接查原始日志的代价很多人拿到日志后的第一反应是直接建 Hive 表用regexp_extract在 SQL 里解析字段。这个做法在小数据量下没问题但一旦进入生产就会暴露两个毛病第一Nginx 日志里的 User-Agent 字段包含各种引号和特殊字符正则写差一个转义整行解析结果就是 NULL而且这种错误在 SQL 里极难排查第二日志里混着图片、CSS、JS 等静态资源请求还有监控探测和爬虫流量这些脏数据如果不清洗统计出的 PV 和 UV 会明显失真。先跑一个 MapReduce 预处理任务把解析、过滤、标准化一次做完后面 Hive 查的就是干净结构化数据SQL 写起来简单出问题也容易回溯。我建议预处理阶段做三件事解析出 IP、时间、请求方法、请求路径、状态码、响应字节数、Referer、User-Agent 这八个核心字段过滤掉静态资源请求.js、.css、.png、.jpg、.ico等和状态码为 444 或 499 的异常请求把时间字段统一成yyyy-MM-dd HH:mm:ss格式方便 Hive 里直接按小时做分组统计。3.2 MapReduce 解析逻辑从一行日志到结构化字段以下是一个可跑的 Mapper 核心逻辑用正则从一行 Nginx 日志中提取字段输出以\t分隔。Reduer 不需要做聚合直接原样输出所以这里只贴 Mapperpublic class WeblogMapper extends MapperObject, Text, Text, Text { // 匹配 Nginx 默认 combined 格式日志 private static final Pattern LOG_PATTERN Pattern.compile( ^(\\S) (\\S) (\\S) \\[([^]])\\] \(\\S) (\\S) (\\S)\ (\\d{3}) (\\d) \(\\S)\ \([^\]*)\ ); Override protected void map(Object key, Text value, Context context) throws IOException, InterruptedException { String line value.toString(); Matcher m LOG_PATTERN.matcher(line); if (!m.find()) { context.getCounter(weblog, parse_failed_lines).increment(1); return; } String ip m.group(1); String timeStr m.group(4); // 原始格式 20/Jan/2025:12:00:01 0800 String method m.group(5); String requestPath m.group(6); String protocol m.group(7); String status m.group(8); String bytesSent m.group(9); String referer m.group(10); String userAgent m.group(11); // 过滤静态资源命中直接跳过不进入输出 if (requestPath.matches(.*\\.(js|css|png|jpg|jpeg|gif|ico|svg)$)) { context.getCounter(weblog, filtered_static).increment(1); return; } // 时间格式转换解析原始时间再格式化成 Hive 友好的形式 String normalizedTime normalizeTime(timeStr); StringBuilder sb new StringBuilder(); sb.append(ip).append(\t) .append(normalizedTime).append(\t) .append(method).append(\t) .append(requestPath).append(\t) .append(protocol).append(\t) .append(status).append(\t) .append(bytesSent).append(\t) .append(referer).append(\t) .append(userAgent); context.write(new Text(normalizedTime.substring(0, 10)), new Text(sb.toString())); } }这里有两个关键点需要说明。context.getCounter(weblog, parse_failed_lines)是 Hadoop 自定义计数器任务跑完后在控制台能看到解析失败和静态资源过滤的数量这是验证预处理质量最直接的依据——如果解析失败行数占比超过 1%说明正则和实际日志格式不匹配需要回头核对 Nginx 的 log_format 配置。输出的 key 取normalizedTime.substring(0, 10)即日期部分这样同一个日期的数据在 shuffle 阶段会落到同一个 reducer按天写出文件天然形成分区目录。3.3 把处理后的数据落成 Hive 外部表的分区目录预处理任务的输出直接写到 HDFS 的/data/weblog_clean/20250120/目录下这个目录就是后面 Hive 外部表的一个分区。注意这里有个顺序依赖Hive 分区表的目录结构必须是表目录/dt20250120/而不是表目录/20250120/。所以预处理输出路径应该写成hadoop jar weblog-preprocess.jar com.example.WeblogPreprocess \ -D mapreduce.job.reduces1 \ /data/weblog/20250120 \ /user/hive/warehouse/weblog.db/weblog_clean/dt20250120mapreduce.job.reduces设为 1 在这个场景下是合理的因为单日单机日志量不大一个 reducer 足够同时保证输出只有一个文件不会产生大量小文件。如果日志量每天超过几 GB这个参数就要调大否则单 reducer 会成为瓶颈下面第 4 章会专门说参数怎么配。关于目录层级多说一句踩过的坑Hive 外部表默认的存储路径是/user/hive/warehouse/weblog.db/weblog_clean你在预处理脚本里写输出路径时务必带上dt20250120这个等号结构。如果漏了等号或者把日期直接拼在表目录后面Hive 建表后 MSCK 扫描分区时认不出目录结构表就一直是空的。3.4 一个容易翻车的细节IP 解析与字段缺失预处理里最容易翻车的不是正则而是你以为正则对了。Nginx 日志在特定配置下会缺少部分字段比如没有配置$http_x_forwarded_for时经过 CDN 的请求 IP 字段是内网地址后面的-占位符依然存在而如果 Nginx 关闭了log_format里的某个变量字段会直接消失而不是变成-正则会因此匹配失败整行被归入parse_failed_lines。处理办法是写一个容错分支先按完整正则匹配失败后用宽松正则只取前五个字段其余字段置为-并把这种行单独计数。宁可保留字段缺失的行也不要直接丢弃因为事后排查问题时这些「半个字段」的行常常就是线索。字段缺失还有一个隐蔽场景响应字节数在某些请求下是 0但 Nginx 用-表示。预处理时要判断bytesSent是否为-是则替换为0否则后续在 Hive 里做CAST(bytes_sent AS BIGINT)时会把整行变成 NULL导致求和结果偏小而不报错。4. Hive 统计层用 SQL 把流量指标算出来4.1 建表与分区挂载外部表 分区目录怎么对齐预处理数据已经在 HDFS 上按dt20250120的目录结构落好了接下来建 Hive 外部表。之所以用外部表而不是内部表是因为数据文件是预处理任务生成的不在 Hive 管控范围内用外部表删表时不会误删 HDFS 上的原始文件重跑预处理后表结构还能继续复用。建表语句如下CREATE EXTERNAL TABLE weblog_clean ( ip STRING, access_time STRING, method STRING, request_path STRING, protocol STRING, status INT, bytes_sent BIGINT, referer STRING, user_agent STRING ) PARTITIONED BY (dt STRING) ROW FORMAT DELIMITED FIELDS TERMINATED BY \t STORED AS TEXTFILE LOCATION /user/hive/warehouse/weblog.db/weblog_clean;建完表后必须执行分区挂载这一步是新手最容易卡住的地方。你可以用MSCK REPAIR TABLE weblog_clean;让 Hive 自动扫描目录并注册分区也可以手动指定ALTER TABLE weblog_clean ADD PARTITION (dt20250120);两条命令的差别在于MSCK REPAIR TABLE会扫描表目录下所有符合分区结构的新目录适合批量补分区但如果目录结构不规范比如漏写dt它会静默跳过而不报错手动ALTER TABLE则像一把钥匙目录不存在时直接报错能立刻发现问题。建议第一次跑通用MSCK后面重跑单日数据用手动ADD PARTITION出问题能第一时间感知。4.2 最常用的四类指标PV、UV、独立 IP、热门页面分区挂载完成后网站日志分析最核心的四个指标就可以用 SQL 直接算了。下面这组 SQL 我强烈建议存成 Hive 脚本文件而不是在命令行逐条敲因为后面会反复重跑按天追加数据。-- 日 PV统计当天所有有效请求的总数 SELECT COUNT(*) AS pv FROM weblog_clean WHERE dt 20250120; -- 独立 IP 数按 IP 去重 SELECT COUNT(DISTINCT ip) AS uv_by_ip FROM weblog_clean WHERE dt 20250120; -- 热门页面 TOP 10按请求路径分组统计 SELECT request_path, COUNT(*) AS cnt FROM weblog_clean WHERE dt 20250120 AND status 200 GROUP BY request_path ORDER BY cnt DESC LIMIT 10; -- 按小时 PV 分布用于识别流量高峰时段 SELECT SUBSTRING(access_time, 12, 2) AS hour, COUNT(*) AS pv FROM weblog_clean WHERE dt 20250120 GROUP BY SUBSTRING(access_time, 12, 2) ORDER BY hour;这几个 SQL 有一个共同点都带WHERE dt 20250120条件。这是分区表唯一正确的查询姿势——Hive 会基于这个条件做分区裁剪只扫描对应目录而不是全表扫。如果你漏写了dt条件Hive 会扫描所有分区数据量一大就直接把集群跑垮。对比一下只想看总 PV 时用COUNT(*)即可COUNT(ip)会忽略 ip 为 NULL 的行两种写法在日志场景下结果几乎一样但语义上COUNT(*)更符合「所有有效请求」的定义。再补充一点上面对 UV 的口径是「独立 IP 数」。严格来说UVUnique Visitor基于用户标识而不是 IP因为同一用户可能换 IP同一 IP 也可能被公司出口 NAT 共享。真实项目中如果拿不到 Cookie 或用户 ID 字段可以退而求其次用IP User-Agent的组合来近似 UV这个口径会在第 5 章的踩坑记录里详细展开。4.3 新增指标的 3 个必调参数与调优当你要在这条 SQL 链路上加新指标或者把统计范围从单天扩展到一个月时会遇到明显的性能瓶颈。常见的做法是给 Hive 任务加以下三个参数它们分别对应并行度、小文件合并和 reducer 资源上限-- 允许 Hive 内部的多个 stage 并行执行例如多张表的独立统计 SET hive.exec.parallel true; -- 合并 Map 端输出的小文件减少落盘数量和 reducer 拉取压力 SET hive.merge.mapfiles true; SET hive.merge.size.per.task 128000000; -- 设置 reducer 数量的经验公式参考值输入大小 / 128MB SET hive.exec.reducers.bytes.per.reducer 128000000;hive.exec.parallel在跑多条独立 SQL 时收益最明显比如同时算 PV 和热门页面这两个任务互不依赖默认串行执行打开并行后能省掉近一半时间。但如果你的 SQL 里涉及多张表 JOINhive.exec.parallel不会带来任何收益因为 JOIN 的 stage 天然有依赖关系。hive.merge.mapfiles是在 Map 端做合并日志分析链路里预处理任务产出的文件一般已经通过控制 reducer 数量做到了合理大小此时把参数设为 true 更多是防患于未然。参数调优有一个原则要守住不要照抄网上的参数值每个参数都要先想清楚它影响的是哪一个环节。比如有一天你发现 SQL 跑得慢先去yarn logs -applicationId看 Map 和 Reduce 阶段的耗时占比Map 慢是输入文件太多太碎Reduce 慢是数据倾斜或 reducer 数不够。参数是最后的手段而不是第一反应。5. 日志分析常见问题排查从采集到统计的 5 个踩坑记录5.1 现象Flume 写出的 HDFS 文件一直不滚动小文件堆积某天登录集群一看/data/weblog/下全是 1 MB 不到的小文件每个文件后缀还是.tmp数量上千个。查询时 NameNode 内存告急Hive 扫目录时明显变慢。原因出在hdfs.rollInterval和hdfs.rollSize的配合上日志量小、rollInterval设得短比如 30 秒每个文件还没攒够数据就被滚动一次反过来如果日志量很大但rollInterval设成 3600文件会一直写不滚动直到超过rollSize才切。解决方法是按日志产生速率反推参数统计一下每分钟新增日志的字节数让rollSize / 每分钟字节数落在 5 到 15 分钟之间。比如每分钟产生 8 MBrollSize设 128 MB约 16 分钟滚动一次文件大小和滚动频率都比较健康。另外记得把rollCount保持为 0不要让条数提早触发滚动。5.2 现象Hive 表查询结果为空但 HDFS 上明明有数据建好外部表后执行SELECT COUNT(*) FROM weblog_clean WHERE dt 20250120返回 0但hdfs dfs -ls看目录下确实有数据文件。这是外部表分区未注册的典型症状。Hive 元数据库里还没有这个分区的记录查询时的分区裁剪直接把目录过滤掉了。解决方法是执行MSCK REPAIR TABLE weblog_clean;让 Hive 扫描目录并补注册分区然后重新查询。但要留意如果目录结构写成了/dt20250120而表 location 指向了上级目录MSCK REPAIR同样能识别如果结构是/20250120这种没有dt前缀的MSCK也不会报错只是静默跳过。判断依据是执行完MSCK后看返回信息里有没有Partitions repaired数字为 0 就是目录结构不对不是命令没生效。5.3 现象UV 统计值明显偏高同一个用户被统计成了几十次日 UV 算出来比 PV 的一半还多明显不合理。原因是统计口径用了COUNT(DISTINCT ip)而该站点用户大量来自运营商 NAT 出口成百上千个用户共用同一批公网 IP或者同一个用户在不同时间分配到了不同 IP。以 IP 统计会同时高估和低估方向取决于 NAT 强度和用户 IP 变化频率。解决方法是改成IP User-Agent组合去重SELECT COUNT(DISTINCT CONCAT(ip, |, user_agent)) AS uv FROM weblog_clean WHERE dt 20250120;这个近似口径仍然不是完美的 UV因为同一用户换浏览器或清 Cookie 后 User-Agent 会变。更好的是在日志里接入 Cookie 或用户 ID 字段但那是接入层的事在现有日志格式下IP UA组合是比较务实的折中。同时要注意如果预处理阶段没有对 User-Agent 做去特殊字符处理组合字段里的|可能造成拼接冲突建议用CONCAT_WS或直接取 UA 的哈希。5.4 现象预处理任务跑完清洗前后的行数对不上前一天清洗完行数 100 万今天同样量级的日志清洗完只有 60 万差得离谱。这是我在实际项目中排查最久的一个问题最后发现是服务器上多了一个日志轮转文件Nginx 触发logrotate后旧的 access.log 被重命名为access.log.1Flume 的 taildir source 默认监控的是access.log轮转后新文件从头写而 Flume 不知道要读access.log.1里的旧内容这部分日志就丢了。解决方法是把 filegroups 监控通配路径/var/log/nginx/access.log*并利用 taildir 的filegroups.f1支持多个路径逗号分隔的特性把轮转文件也纳入监听。但这样会引入重复读取的风险正确的配合是只读一次轮转文件读完靠 positionFile 记录位置不重复消费。验证方法是查 preprocessing 任务里的计数器对比parse_failed_lines和filtered_static的占比是否稳定。5.5 现象SQL 能跑但结果明显偏小部分小时段没有数据按小时统计 PV 时凌晨 03:00 到 05:00 的数据缺失但这些时段 Nginx 日志里明明有请求。查下来发现是 Flume agent 所在服务器与 HDFS 集群之间存在时钟偏差hdfs.path里的%Y%m%d用的是 Flume 所在机器的本地时间而日志内容里记录的时间是 Web 服务器的时间。两台机器相差两小时凌晨的请求被写到了「前一天」的 HDFS 目录里但日志内容里的日期仍是当天预处理按内容日期分区时自然找不到这些文件。解决方法是先统一各服务器时间用 NTP 同步如果暂时没法同步Flume 的hdfs.path改用%Y%m%d%H按小时分目录并在预处理时用日志内容时间而不是目录名来分区从根上绕开这个问题。这类问题隐蔽在「两边时间对不上」查的时候先看 Flume 落盘文件的目录日期和文件里日志的日期是否一致能省下大量排查时间。6. 把统计结果落到可视化看板Sqoop 导出与 ECharts 展示链路走到 Hive 统计这步数据已经能查了但业务方要的是一眼能看的图表不是 SQL 查询结果。常见做法是用 Sqoop 把 Hive 里的统计结果导出到 MySQL后端接口读 MySQL前端用 ECharts 渲染。导出命令如下sqoop export \ --connect jdbc:mysql://192.168.1.100:3306/weblog_db \ --username analyst --password \ --table daily_report \ --export-dir /user/hive/warehouse/weblog.db/weblog_result/dt20250120 \ --input-fields-terminated-by \t \ --columns dt,pv,uv,ip_cnt,avg_duration \ --num-mappers 4导出前记住一个原则daily_report表在 MySQL 里要预先建好字段类型和 Hive 表严格对应dt用VARCHAR(10)pv用BIGINT否则 Sqoop 按字符串写入时INT类型字段会因隐式转换失败而报错。导出完成后前端拿到数据用 ECharts 画一个按小时 PV 折线图的核心片段是这样fetch(/api/hourly_pv?dt20250120) .then(res res.json()) .then(data { const chart echarts.init(document.getElementById(pvChart)); chart.setOption({ xAxis: { type: category, data: data.hours }, yAxis: { type: value }, series: [{ type: line, data: data.pv_list, areaStyle: { opacity: 0.3 }, smooth: true }] }); });整个可视化模块有一个习惯我坚持了很多年图表上线前先手动把 Hive 里某天的 SQL 结果算出来和页面展示的值对一遍再让业务方看。因为 ECharts 渲染本身几乎不会错错多半出在接口取数范围或者时区转换上。比如你要展示「今天」的曲线接口定义的是自然日 00:00 到 23:59但后端代码可能在 UTC 时间上做了8处理导致曲线整体偏移。在页面上加一个「最后更新时间」字段既能提醒自己数据是否是新的也能让业务方在数据还没跑完时不会误以为系统出了故障。这个案例教会我一条经验日志分析项目的成败不取决于你会不会写复杂的 SQL而取决于你对数据从哪来、经过哪些处理、在哪里可能丢这一整条链路的掌控力。拿到任何一份日志先画链路图标出每个环节的输入输出和验证方式再动手写代码能避开绝大多数翻车现场。希望帮到你。本文还有配套的精品资源点击获取