1. 项目概述:这不是一个“大数据Demo”,而是一套可落地的实时访问统计流水线
你点开一个网页,页面加载完成——这个动作,在后台可能触发一整条数据链路的运转。我做的这个项目,就是把“用户访问网页”这件事,从最原始的日志源头开始,一环扣一环地捕获、传输、缓冲、计算、落库、展示,全程不丢、不乱、不积压,最终在Spring Boot后台接口里返回“过去5分钟UV 1287,PV 4321”这样的结果。它不是教科书里的概念拼图,而是我在某次高并发活动预演中,为解决Nginx日志堆积、实时指标延迟超10秒、后台查询卡顿这三个痛点,硬生生搭出来的一套轻量级实时统计系统。
核心关键词就藏在标题里:Crontab、Flume、Kafka、Spark Streaming、Spring Boot。但别被这串名词吓住——它们各自只干一件小事,合起来才构成闭环。Crontab不是主角,它只是个“闹钟”,定时把Nginx的access.log切片归档;Flume是“搬运工”,专盯新生成的日志文件,一行行抽出来塞进管道;Kafka是“中转站+缓冲池”,扛住突发流量,让上游抽得稳、下游算得定;Spark Streaming是“流水线工人”,每10秒拉一次Kafka里的数据,清洗、去重、聚合,算出UV/PV/热门路径;Spring Boot是“前台接待员”,把算好的结果存进Redis或MySQL,再通过REST接口吐给前端图表。整条链路没有用Hadoop YARN调度,没上Flink,也没碰任何云原生组件,全部基于Linux服务器原生服务搭建,部署包加起来不到80MB,三台4核8G虚拟机就能跑满日均300万PV的业务量。
适合谁参考?如果你正面临这几个场景之一:运维同学想把零散的日志变成可查可看的指标,但又不想立刻上ELK全家桶;后端开发接到“做个实时访问看板”的需求,手头只有几台旧服务器;或者刚学完Spark Streaming却苦于找不到真实数据源练手——那这个项目就是为你准备的。它不追求技术炫技,所有选型都基于“够用、稳定、易排查”六个字。比如为什么不用Logstash而选Flume?因为Flume的TailDirSource对日志轮转(logrotate)天然友好,不会漏掉最后一秒写入又被mv走的半截日志;为什么Spark Streaming不用Structured Streaming?因为前者API更直白,checkpoint机制在小集群上更省资源,调试时直接print()就能看到每批次数据流经哪一步。这些选择背后,全是踩坑换来的经验值。
2. 整体架构设计与技术选型逻辑:每一环都为解决一个具体问题
2.1 为什么是这条技术栈?而不是其他组合?
先说结论:这不是为了堆砌热门词,而是针对“网页访问日志”这一特定数据源的物理特性,倒推出来的最优解。我们来拆解日志本身的三个硬约束:
写入不可控:Nginx access.log是追加写,每秒可能产生几百行,也可能瞬间爆发几千行(比如秒杀开场),且日志文件会按时间或大小自动轮转(logrotate)。这意味着采集端必须能感知文件新增、处理文件重命名、跳过正在写的临时文件。
数据无结构但有规律:一行日志形如
192.168.1.100 - - [10/Jan/2024:14:22:35 +0800] "GET /product?id=123 HTTP/1.1" 200 3421 "https://ref.com" "Mozilla/5.0..."。它没有JSON schema,但字段位置固定、分隔符明确(空格+中括号),解析成本低,无需Schema Registry。业务指标简单但时效敏感:UV(独立访客)、PV(页面浏览)、热门URL、地域分布——这些计算逻辑本身不复杂,但要求“5分钟内可见”,不能像离线数仓那样T+1。
对照这三点,我们逐层排除其他方案:
| 对比项 | 被排除方案 | 排除原因 | 本项目选择 |
|---|---|---|---|
| 日志采集 | Logstash + Filebeat | Filebeat在logrotate场景下需额外配置close_inactive和clean_removed,实测偶发漏读最后一行;Logstash JVM开销大,单节点吞吐难超5MB/s | Flume的TailDirSource内置inflight状态跟踪,自动处理mv操作,实测连续72小时无漏行 |
| 消息中间件 | RabbitMQ / Redis Stream | RabbitMQ持久化性能瓶颈在磁盘IO,突发流量易堆积;Redis Stream内存占用不可控,且缺乏消费组偏移量管理 | Kafka分区+副本机制天然适配日志流,单Topic 3分区即可承载5000msg/s,消费位点由ZooKeeper/KRaft托管,故障恢复快 |
| 流计算引擎 | Flink | 功能强大但学习曲线陡峭,本地调试需启动JobManager+TaskManager,小项目显得笨重;且Flink的Event Time水位线在日志场景下需额外处理Nginx时区问题 | Spark Streaming微批处理模型更贴近日志的“按秒切片”特性,batchDuration=10s配置直观,本地IDEA直连调试零配置 |
| 后端服务 | Node.js / Django | 需额外引入Redis客户端、Kafka消费者、Spark连接器,依赖管理复杂;HTTP服务与流计算混部易相互干扰 | Spring Boot Starter生态成熟,spring-kafka、spring-boot-starter-data-redis开箱即用,模块职责清晰 |
提示:技术选型不是比参数,而是比“谁更少出错”。Flume配置文件只有37行,Kafka server.properties仅修改了
log.retention.hours=24和num.partitions=3两个关键项,Spark Streaming的StreamingContext初始化代码不超过20行——所有组件都保持最小必要配置,这是稳定性的第一道防线。
2.2 架构拓扑图与数据流向:从日志诞生到接口返回
整个系统运行在三台CentOS 7虚拟机上,角色划分极简:
- Server A(Web Server):Nginx部署机,同时运行Flume Agent和Crontab
- Server B(Stream Server):Kafka Broker + ZooKeeper + Spark Streaming Driver
- Server C(App Server):Spring Boot应用 + Redis(或MySQL)
数据流向严格遵循单向链路:
Nginx access.log → Crontab每日0点切割 → Flume TailDirSource监听新文件 → Flume Channel缓存 → Flume KafkaSink推送至Kafka Topic → Spark Streaming ConsumerGroup订阅Topic → Spark Streaming DStream解析日志→ 计算UV/PV/TopN → 结果存入Redis Hash → Spring Boot REST API读取Redis → 前端调用接口这里有两个关键设计决策值得展开:
第一,为什么Crontab只负责日志切割,而不直接用它触发Flume?
因为Crontab是“事件驱动”的,而日志产生是“持续流式”的。如果用crontab -e写*/5 * * * * /opt/flume/bin/flume-ng ...,会导致Flume进程频繁启停,每次启动都要重建Channel、重连Kafka,5秒间隔下CPU飙升。正确做法是让Flume常驻运行,Crontab只做logrotate——Nginx配置log_format后,logrotate脚本执行kill -USR1 $(cat /var/run/nginx.pid)通知Nginx重开日志文件,Flume的TailDirSource会自动发现access.log.1这类新文件并开始读取。实测Flume进程7×24小时稳定运行,内存占用恒定在380MB±20MB。
第二,Spark Streaming为什么不直连Nginx机器读日志?
看似省掉Kafka一环,实则埋雷。Nginx机器通常是业务核心,磁盘IO和网络带宽已趋饱和,Spark Streaming的shuffle操作会加剧负载;更致命的是,一旦Spark任务异常退出,未处理的日志行就永久丢失。Kafka作为缓冲层,提供了“至少一次”语义保障:Flume推送成功即认为数据已入站,Spark消费失败可重置offset重试。我们在Kafka Topic上设置了retention.ms=86400000(24小时),确保即使Spark停机一天,数据也不丢失。
2.3 各组件版本与环境约束:拒绝“最新版陷阱”
很多教程一上来就写“安装Kafka 3.6.0”,结果读者在CentOS 7上编译报错。本项目所有组件版本均经过生产环境验证:
Flume:1.11.0(Apache官网tar.gz包,非CDH发行版)
理由:1.10.0存在TailDirSource在ext4文件系统下偶发inode缓存不更新的bug,1.11.0已修复;且1.11.0对Java 11完全兼容,避免降级JDK的麻烦。Kafka:3.4.0(Scala 2.13构建版)
理由:3.5.0强制要求ZooKeeper 3.8+,而CentOS 7默认YUM源只有ZK 3.4;3.4.0在ZK 3.4.14上运行完美,且KRaft模式在3.4.0尚未成熟,仍用ZK管理元数据更稳妥。Spark:3.3.2(Pre-built for Hadoop 3.3)
理由:3.4.0开始要求Java 17,而Spring Boot 2.7.x最高只支持Java 17,版本链断裂;3.3.2用Java 11编译,与Flume 1.11.0、Kafka 3.4.0完全兼容,且spark-sql_2.12包已内置Kafka集成。Spring Boot:2.7.18(最后的2.7.x LTS版本)
理由:3.x系列强制要求Java 17,而线上多数服务器仍为Java 11;2.7.18已修复所有已知安全漏洞,且spring-boot-starter-kafka2.7.18与Kafka 3.4.0客户端API完全匹配。
注意:所有组件均使用
tar -xzf解压即用,绝不通过yum install或apt-get安装。因为系统仓库里的包往往滞后2-3个大版本,且修改了默认配置路径(如将Flume配置文件放在/etc/flume-ng/而非$FLUME_HOME/conf/),导致教程步骤失效。我的经验是:下载官方二进制包,解压到/opt/目录,用ln -s创建软链接(如/opt/flume → /opt/flume-1.11.0),升级时只需改链接目标,零配置迁移。
3. 核心环节实现详解:从配置文件到关键代码
3.1 Crontab日志切割:不只是“每天备份”,而是为Flume铺路
Nginx日志切割不是目的,目的是让Flume能精准识别“哪些是新日志”。关键在于logrotate配置与Flume监听路径的协同。
第一步:配置Nginx日志格式
在nginx.conf的http块中添加:
log_format main '$remote_addr - $remote_user [$time_local] ' '"$request" $status $body_bytes_sent ' '"$http_referer" "$http_user_agent" ' '$request_time $upstream_response_time $pipe';注意:$request_time(请求处理耗时)和$upstream_response_time(上游响应耗时)是后续分析性能的关键字段,必须开启。
第二步:编写logrotate脚本
创建/etc/logrotate.d/nginx:
/var/log/nginx/*.log { daily missingok rotate 30 compress delaycompress notifempty create 0644 nginx nginx sharedscripts postrotate if [ -f /var/run/nginx.pid ]; then kill -USR1 `cat /var/run/nginx.pid` fi endscript }重点在postrotate段:kill -USR1是Nginx的“优雅重开日志”信号,它会让Nginx立即关闭当前access.log文件句柄,并打开新的access.log继续写入。此时旧文件被重命名为access.log-20240110(日期格式),Flume的TailDirSource正是监听这种带日期后缀的文件。
第三步:Crontab触发时机crontab -e中添加:
# 每天凌晨0:05执行logrotate,避开业务高峰 5 0 * * * /usr/sbin/logrotate -f /etc/logrotate.d/nginx >/dev/null 2>&1为什么是0:05而不是0:00?因为Nginx日志按秒写入,0:00:00那一秒的数据可能还在内存buffer中未刷盘,提前5分钟执行,确保所有0点前的数据都已落盘并被重命名。
实操心得:曾因误将
postrotate写成prerotate,导致Nginx在logrotate前就重开了日志,造成access.log被清空,Flume读到空文件。务必记住口诀:“rotate后,再USR1”。
3.2 Flume配置:37行搞定高可靠日志采集
Flume配置文件flume-conf.properties位于$FLUME_HOME/conf/,核心只有4个Section:
1. Agent定义(5行)
# 定义agent名称为a1,source为r1,channel为c1,sink为k1 a1.sources = r1 a1.channels = c1 a1.sinks = k1 a1.sources.r1.channels = c1 a1.sinks.k1.channel = c12. Source配置(12行)——TailDirSource的精髓
a1.sources.r1.type = TAILDIR # 监听目录,注意结尾必须有/,否则不生效 a1.sources.r1.filegroups = f1 a1.sources.r1.filegroups.f1 = /var/log/nginx/access.log.* # 正则匹配日志文件,.*匹配access.log-20240110这类文件 a1.sources.r1.positionFile = /opt/flume/positions.json # 记录每个文件读取到哪一行,断电重启后从此处续读 a1.sources.r1.headers.f1.headerKey = type a1.sources.r1.headers.f1.headerValue = nginx_access a1.sources.r1.fileHeader = true a1.sources.r1.basenameHeader = true # 这两行让每条event带上文件名,便于后续Kafka分区 a1.sources.r1.maxBatchCount = 1000 # 每次批量读取最多1000行,防止单次IO过大关键细节:
positionFile路径必须是Flume进程有写权限的目录,且不能放在/tmp(可能被系统清理)。我习惯建/opt/flume/positions.json,首次运行时Flume会自动创建该文件并写入初始offset。
3. Channel配置(6行)——MemoryChannel的取舍
a1.channels.c1.type = memory a1.channels.c1.capacity = 10000 # Channel最大缓存1万条,足够应对10秒内峰值 a1.channels.c1.transactionCapacity = 1000 # 单次事务最多处理1000条,与Source的maxBatchCount对齐 a1.channels.c1.byteCapacity = 100000000 # 字节上限100MB,防止单条超长日志撑爆内存 a1.channels.c1.byteCapacityBufferPercentage = 20 # 预留20%内存给JVM自身,避免OOM为什么不用FileChannel?因为FileChannel的磁盘IO会成为瓶颈,实测在1000msg/s下,FileChannel的写入延迟从2ms升至15ms,而MemoryChannel稳定在0.3ms。只要Channel容量设置合理(10000 > 1000msg/s × 10s),配合Kafka的持久化,可靠性不输FileChannel。
4. Sink配置(14行)——KafkaSink的稳定性要点
a1.sinks.k1.type = org.apache.flume.sink.kafka.KafkaSink a1.sinks.k1.kafka.bootstrap.servers = server-b:9092 a1.sinks.k1.kafka.topic = nginx-access-log a1.sinks.k1.kafka.producer.acks = 1 # 设为1:Leader写入成功即返回,平衡性能与可靠性 a1.sinks.k1.kafka.producer.linger.ms = 5 # 批量发送延迟5ms,提升吞吐 a1.sinks.k1.kafka.producer.batch.size = 16384 # 批量大小16KB,与Kafka默认值一致 a1.sinks.k1.kafka.producer.max.request.size = 1048576 # 单请求最大1MB,防止单条超长日志被拒绝 a1.sinks.k1.kafka.producer.compression.type = snappy # Snappy压缩率适中,CPU开销小 a1.sinks.k1.kafka.producer.retries = 3 # 网络抖动时重试3次 a1.sinks.k1.kafka.producer.security.protocol = PLAINTEXT # 本项目不启用SSL,简化配置 a1.sinks.k1.defaultPartitionId = 0 # 固定发往分区0,避免Kafka分区倾斜(因日志无业务key) a1.sinks.k1.defaultTopic = nginx-access-log a1.sinks.k1.channel = c1注意:
kafka.producer.acks = 1是关键。设为all虽强一致,但当Kafka某个Broker宕机时,Flume会阻塞等待ISR同步,导致日志堆积;设为0则可能丢数据。acks=1意味着只要Leader写入成功就返回,配合retries=3,在99.9%场景下既保证不丢,又不阻塞。
启动命令:$FLUME_HOME/bin/flume-ng agent --conf $FLUME_HOME/conf/ --conf-file $FLUME_HOME/conf/flume-conf.properties --name a1 -Dflume.root.logger=INFO,console
3.3 Kafka Topic创建与参数调优:为日志流定制的“高速公路”
Kafka不是拿来即用,必须根据日志特性调参。在Server B上执行:
# 创建Topic,3分区+1副本(单机测试用) /opt/kafka/bin/kafka-topics.sh --create \ --bootstrap-server localhost:9092 \ --topic nginx-access-log \ --partitions 3 \ --replication-factor 1 \ --config retention.ms=86400000 \ --config segment.bytes=1073741824 \ --config flush.messages=10000参数解读:
--partitions 3:分区数=Flume Sink并发数,每个Flume Sink实例对应一个Kafka Producer,3分区可并行推送。--replication-factor 1:单机测试环境,不启用副本;生产环境必须≥2。--config retention.ms=86400000:数据保留24小时,覆盖Spark Streaming最长停机窗口。--config segment.bytes=1073741824:日志段大小1GB(默认1GB),避免小文件过多;日志写入密集,大segment减少文件切换开销。--config flush.messages=10000:Producer每积累10000条消息强制刷盘,比默认的flush.ms=1000(1秒)更适应日志批量写入场景。
验证Topic是否创建成功:
/opt/kafka/bin/kafka-topics.sh --describe \ --bootstrap-server localhost:9092 \ --topic nginx-access-log输出应显示Topic: nginx-access-log PartitionCount: 3 ReplicationFactor: 1,且每个Partition的Leader都在本机。
实操心得:曾因忘记设置
segment.bytes,Kafka在24小时内生成了2000+个小文件(每个几MB),导致ls -l /opt/kafka/logs/卡死,ZooKeeper连接超时。调大segment是日志类Topic的必选项。
3.4 Spark Streaming日志解析与计算:10秒一批的精准统计
Spark Streaming作业LogStatsStreaming.scala是核心计算逻辑,用Scala编写(Java亦可,但Scala的函数式语法更简洁):
import org.apache.spark.SparkConf import org.apache.spark.streaming.{Seconds, StreamingContext} import org.apache.spark.streaming.kafka010.{ConsumerStrategies, KafkaUtils, LocationStrategies} import org.apache.spark.sql.functions._ import org.apache.spark.sql.SparkSession import redis.clients.jedis.Jedis object LogStatsStreaming { def main(args: Array[String]): Unit = { // 1. 初始化Spark配置 val conf = new SparkConf() .setAppName("NginxAccessLogStats") .setMaster("local[2]") // 本地模式,2个线程模拟Worker .set("spark.serializer", "org.apache.spark.serializer.KryoSerializer") .set("spark.sql.adaptive.enabled", "true") // 启用自适应查询优化 val ssc = new StreamingContext(conf, Seconds(10)) // 10秒一个批次 // 2. 从Kafka读取数据 val kafkaParams = Map[String, Object]( "bootstrap.servers" -> "server-b:9092", "key.deserializer" -> "org.apache.kafka.common.serialization.StringDeserializer", "value.deserializer" -> "org.apache.kafka.common.serialization.StringDeserializer", "group.id" -> "log-stats-group", "auto.offset.reset" -> "latest", // 启动时从最新offset开始 "enable.auto.commit" -> "false" // 手动提交offset,确保exactly-once ) val topics = Array("nginx-access-log") val stream = KafkaUtils.createDirectStream[String, String]( ssc, LocationStrategies.PreferConsistent, ConsumerStrategies.Subscribe[String, String](topics, kafkaParams) ) // 3. 解析日志行(核心!) val parsedStream = stream.map(record => { val line = record.value() // 正则提取关键字段,注意转义 val pattern = """^(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) ([^\s]+) \S+" (\d+) (\S+) "([^"]*)" "([^"]*)".*$""".r line match { case pattern(ip, time, method, url, status, size, referer, ua) => (ip, time, method, url, status.toInt, size.toLong, referer, ua) case _ => ("0.0.0.0", "", "", "", 0, 0L, "", "") // 解析失败兜底 } }).filter(_._1 != "0.0.0.0") // 过滤解析失败行 // 4. 统计逻辑(每批次独立计算) parsedStream.foreachRDD { rdd => if (!rdd.isEmpty()) { val spark = SparkSession.builder.config(conf = rdd.sparkContext.getConf).getOrCreate() import spark.implicits._ // 转为DataFrame进行SQL分析 val logDF = rdd.toDF("ip", "time", "method", "url", "status", "size", "referer", "ua") // UV计算:基于IP去重(实际项目应结合User-Agent+IP+Cookie,此处简化) val uvCount = logDF.select("ip").distinct().count() // PV计算:总行数 val pvCount = logDF.count() // Top5 URL val topUrls = logDF.groupBy("url").count().orderBy(desc("count")).limit(5) .collect().map(row => (row.getString(0), row.getLong(1))) // 5. 写入Redis(关键:用Hash结构存多维指标) val jedis = new Jedis("server-c", 6379) try { val timestamp = System.currentTimeMillis() / 1000 val hashKey = s"log_stats:$timestamp" jedis.hset(hashKey, "uv", uvCount.toString) jedis.hset(hashKey, "pv", pvCount.toString) jedis.hset(hashKey, "top_urls", topUrls.map { case (u, c) => s"$u:$c" }.mkString("|")) jedis.expire(hashKey, 3600) // 1小时后自动过期 // 更新最新统计的Hash Key,供Spring Boot读取 jedis.set("log_stats:latest", hashKey) } finally { jedis.close() } } } ssc.start() ssc.awaitTermination() } }关键点解析:
批次时长
Seconds(10):不是越短越好。设为1秒,Kafka Consumer频繁拉取小批次,网络开销大;设为30秒,指标延迟过高。10秒是日志场景的经验值,兼顾时效性与吞吐。日志解析正则:
"""^(\S+) \S+ \S+ \[([^\]]+)\] "(\S+) ([^\s]+) \S+" (\d+) (\S+) "([^"]*)" "([^"]*)".*$"""这个pattern覆盖了Nginx默认日志格式,捕获ip、time、method、url、status、size、referer、ua八个字段。实测解析速度达8000行/秒,远超日志产生速率。UV计算的简化与权衡:真实UV应基于设备指纹(IP+UA+Cookie),但本项目为降低复杂度,先用IP代替。若需精确UV,可在Nginx中注入
$cookie_uid变量,Flume采集时一并传入。Redis写入策略:不用String类型存JSON,而用Hash结构
hset log_stats:1704902400 uv 1287 pv 4321,好处是原子性好、内存占用低、可单独更新某个字段。log_stats:latest作为指针,Spring Boot永远读取最新key,避免时间戳计算误差。
3.5 Spring Boot接口实现:3个Endpoint搞定数据服务
Spring Boot项目结构精简,核心是LogStatsController.java:
@RestController @RequestMapping("/api/log-stats") public class LogStatsController { @Autowired private StringRedisTemplate redisTemplate; // 1. 获取最新统计(推荐前端轮询此接口) @GetMapping("/latest") public ResponseEntity<Map<String, String>> getLatestStats() { String latestKey = redisTemplate.opsForValue().get("log_stats:latest"); if (latestKey == null) { return ResponseEntity.ok(Collections.emptyMap()); } BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(latestKey); Map<String, String> result = hashOps.entries(); // 将top_urls字符串转为List if (result.containsKey("top_urls")) { String topUrlsStr = result.get("top_urls"); List<Map<String, String>> topUrls = Arrays.stream(topUrlsStr.split("\\|")) .map(pair -> { String[] kv = pair.split(":"); if (kv.length == 2) { Map<String, String> item = new HashMap<>(); item.put("url", kv[0]); item.put("count", kv[1]); return item; } return null; }) .filter(Objects::nonNull) .collect(Collectors.toList()); result.put("top_urls", new ObjectMapper().writeValueAsString(topUrls)); } return ResponseEntity.ok(result); } // 2. 获取指定时间范围统计(用于图表回溯) @GetMapping("/range") public ResponseEntity<List<Map<String, String>>> getRangeStats( @RequestParam("start") long startTimestamp, @RequestParam("end") long endTimestamp) { List<Map<String, String>> results = new ArrayList<>(); // 生成时间范围内每10秒一个key(与Spark批次对齐) for (long t = startTimestamp; t <= endTimestamp; t += 10) { String key = "log_stats:" + t; BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(key); Map<String, String> entry = hashOps.entries(); if (!entry.isEmpty()) { entry.put("timestamp", String.valueOf(t)); results.add(entry); } } return ResponseEntity.ok(results); } // 3. 重置统计(运维用) @PostMapping("/reset") public ResponseEntity<String> resetStats() { // 删除所有log_stats:* key Set<String> keys = redisTemplate.keys("log_stats:*"); if (keys != null && !keys.isEmpty()) { redisTemplate.delete(keys); } redisTemplate.delete("log_stats:latest"); return ResponseEntity.ok("Reset done"); } }配置文件application.yml关键项:
spring: redis: host: server-c port: 6379 timeout: 2000 lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0 max-wait: -1注意:
getRangeStats接口不建议前端直接调用,因为Redis的keys命令在大数据量下会阻塞。生产环境应改用scan游标分页,或由定时任务预聚合历史数据存入MySQL。
4. 实操过程中的典型问题与独家排查技巧
4.1 日志采集“断流”:Flume卡在某个文件不往下读
现象:Flume进程正常运行,jps能看到Application进程,但Kafka Topic中消息量骤降为0,/opt/flume/positions.json文件不再更新。
排查步骤:
- 检查Flume日志:
tail -f $FLUME_HOME/logs/flume.log,搜索ERROR或WARN。常见报错:Failed to open file /var/log/nginx/access.log-20240110: java.io.FileNotFoundException。 - 验证文件权限:
ls -l /var/log/nginx/access.log-20240110,确认文件属主是nginx,而Flume进程以flume用户运行。解决方案:chown flume:nginx /var/log/nginx/并chmod 755 /var/log/nginx/,让flume用户有目录读取权。 - 检查inode缓存:
ls -i /var/log/nginx/access.log*,对比positions.json中记录的inode与当前文件inode是否一致。若不一致,说明文件被mv或cp操作过,Flume的TailDirSource会停止读取。此时需手动删除positions.json,让Flume重新扫描。
独家技巧:在Flume配置中加入
a1.sources.r1.fileHeader = true后,每条event会带上fileheader。在Kafka中用kafka-console-consumer.sh消费时,加--property print.headers=true,可直观看到Flume正在读哪个文件,快速定位卡点。
4.2 Kafka消息“堆积”:Consumer Group Lag飙升
现象:kafka-consumer-groups.sh --describe显示LOG-END-OFFSET - CURRENT-OFFSET差值超10万,Spark Streaming处理不过来。
根因分析:
- Spark批次处理慢:
foreachRDD中logDF.count()触发全表扫描,若日志量大(单批次超10万行),Driver端GC频繁。 - Redis写入阻塞:
Jedis是同步阻塞客户端,单线程写入Redis,若网络延迟高,整个批次卡住。
解决方案:
- 优化Spark计算:将
count()改为logDF.select("ip").distinct().count()(UV)和logDF.count()(PV)合并为一次行动操作:val stats = logDF.agg( countDistinct("ip").as("uv"), count("*").as("pv") ).first() val uvCount = stats.getLong(0) val pvCount = stats.getLong(1) - 异步写入Redis:改用
JedisPool+ 多线程:val pool = new JedisPool(new JedisPoolConfig(), "server-c", 6379) // 在foreachRDD内,用Future提交写入任务 import scala.concurrent.Future import scala.concurrent.ExecutionContext.Implicits.global Future { val jedis = pool.getResource try { jedis.hset(...) } finally { jedis.close() } }
4.3 Spring Boot接口“返回空”:Redis Key不存在
现象:调用/api/log-stats/latest返回空Map,但kafka-console-consumer.sh确认Kafka有数据,Spark日志显示“Write to Redis success”。
排查链路:
- 确认Spark是否真的写入:登录Server C,执行
redis-cli,输入KEYS log_stats:*,看是否有匹配key;再执行GET log_stats:latest,看返回的key是否存在。 - 检查时间戳对齐:Spark中
System.currentTimeMillis() / 1000得到秒级时间戳,而Nginx日志中的[10/Jan/2024:14:22:35 +0800]是