ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

本地虚拟机搭建实时新闻舆情分析系统(Spark+Kafka+HBase)

本地虚拟机搭建实时新闻舆情分析系统(Spark+Kafka+HBase)

简介:本资源是一套高完成度的毕业设计级项目,面向大数据开发初学者与高校计算机专业学生,聚焦新闻网数据的实时采集、分析与可视化全流程实践。项目基于Spark 2.x构建,融合Flume日志采集、Kafka消息队列、HBase存储及前端Web展示,完整覆盖舆情监控、新闻热点挖掘等典型应用场景。压缩包共35个文件,含10个核心jar包(如flume-ng-hbase-sink.jar)、7个Scala主逻辑文件(含KfkAsyncHbaseEventSerializer等流处理组件)、6个Java工具类、3张系统效果截图(news1–3.png)及README.md、pom.xml、参考步骤.txt等关键工程文档,整体仅3.43MB,轻量易部署。目前已有45人学习下载,读者可直接获取可运行的源码结构、分层清晰的模块划分(src/ flume_hbase/ weblogs/等)、配套部署文档及全部原始新闻数据资料,快速复现从数据接入到动态图表展示的端到端链路。

1. 一个跑在本地虚拟机上的“新闻舆情雷达”:5分钟拉起实时词频+情感趋势+地域热力图,毕业答辩前夜我靠它救回了毕设答辩PPT

你有没有试过——凌晨两点改完Spark Streaming作业,把日志里刷屏的Task not serializable错误截图发给导师,换来一句“建议重读RDD闭包原理”?这个项目就是当年我在某高校实验室熬了三周调通的真实毕设源码:它不依赖云平台、不连外部新闻API、所有数据自带(含3.2GB清洗后新闻文本+17万条带时间戳的模拟用户点击日志),部署完就能看到“北京/上海/深圳”三地每分钟新闻热度曲线跳动,点开任意时段还能下钻到“人工智能”“碳中和”“新能源汽车”等关键词的情感倾向分布。核心不是炫技,而是把教科书里的“Lambda架构”拆成可触摸的模块——Flume抓日志、Kafka做缓冲、Spark Structured Streaming实时计算、HBase存中间结果、Vue+ECharts画图。新手照着参考步骤.txt走完6步能跑通;熟手看sparkStu/src/main/scala/com/news/analysis/下的RealTimeWordCount.scala和SentimentAnalyzer.scala,能直接抠出情感分析模型替换为自己的BERT微调版本。它解决的不是“能不能跑”,而是“怎么让答辩老师盯着大屏说‘这个实时性很扎实’”。


2. 环境筑基:从零搭建四节点伪分布式集群(含JDK8/Hadoop2.7/Spark2.4.8/Kafka2.12/HBase1.2)

提示:本项目严格绑定Spark 2.x生态,Spark 3.x会因StreamingContextAPI变更导致KfkAsyncHbaseEventSerializer.java编译失败,务必核对pom.xml中spark-streaming_2.11版本号。

2.1 四节点服务规划与端口清单

项目采用伪分布式模式(单机四进程模拟集群),避免新手陷入多机网络配置黑洞。各组件端口已固化在配置文件中,冲突时优先修改以下端口:

组件进程名默认端口修改配置文件关键作用
HadoopNameNode9000core-site.xmlHDFS文件系统入口
SparkMaster7077spark-env.sh资源调度中心
KafkaBroker9092server.properties实时日志消息总线
HBaseMaster16010hbase-site.xml存储实时计算结果(如每分钟词频)

注意:flume_hbase目录下的flume-ng-hbase-sink.jar是编译好的HBase Sink插件,不可用Maven中央仓库同名jar替代——原生Flume 1.9的HBase Sink不兼容HBase 1.2的Table接口,此jar已打patch修复put.addColumn()方法签名。

2.2 JDK8与Scala2.11环境强制校验

Spark 2.4.8要求Scala 2.11.x,而多数Linux发行版默认安装Scala 2.12+。执行以下命令验证并修正:

# 检查当前Scala版本(必须为2.11.x) scala -version # 若显示2.12.x,则卸载并安装2.11.12 sudo apt-get remove scala-library scala wget https://downloads.lightbend.com/scala/2.11.12/scala-2.11.12.tgz sudo tar -xzf scala-2.11.12.tgz -C /opt/ sudo ln -sf /opt/scala-2.11.12 /usr/local/scala echo 'export SCALA_HOME=/usr/local/scala' >> ~/.bashrc echo 'export PATH=$SCALA_HOME/bin:$PATH' >> ~/.bashrc source ~/.bashrc

2.3 Hadoop伪分布式核心配置三步法

News_Spark-master/etc/hadoop/目录已提供适配本项目的core-site.xml、hdfs-site.xml、mapred-site.xml,但需手动注入主机名:

<!-- core-site.xml 中 <value> 标签内必须填你本机hostname --> <property> <name>fs.defaultFS</name> <value>hdfs://your-hostname:9000</value> </property>

执行生效命令(注意格式:hdfs namenode -format后必须重启DataNode):

# 格式化HDFS(仅首次执行) hdfs namenode -format # 启动HDFS(顺序不能错!) start-dfs.sh start-yarn.sh # 验证:jps应显示NameNode/DataNode/SecondaryNameNode/ResourceManager/NodeManager jps

2.4 Spark Standalone模式启动脚本定制

News_Spark-master/sparkStu/conf/spark-env.sh已预置关键参数,但需根据你的机器内存调整:

# 修改此行:将2g改为实际可用内存的70%(如16G机器填10g) export SPARK_WORKER_MEMORY=10g # 添加HBase依赖路径(否则Spark无法加载HBase Sink) export SPARK_CLASSPATH="/path/to/News_Spark-master/flume_hbase/flume-ng-hbase-sink.jar:/path/to/hbase/lib/*"

启动后访问http://localhost:8080确认Master UI中Worker状态为ALIVE。

2.5 Kafka Topic创建与HBase表预建

项目依赖两个预置Topic和一张HBase表,执行以下命令初始化:

# 创建Kafka Topic(分区数3,副本数1,保留7天) $KAFKA_HOME/bin/kafka-topics.sh --create \ --zookeeper localhost:2181 \ --replication-factor 1 \ --partitions 3 \ --topic news_log # 创建HBase表(列族cf存储词频,cf2存储情感分值) echo "create 'news_hotword', 'cf', 'cf2'" | hbase shell # 验证表结构 echo "describe 'news_hotword'" | hbase shell

关键逻辑:SimpleRowKeyGenerator.java生成的RowKey格式为yyyyMMddHHmm:keyword(如202310151430:人工智能),确保HBase按时间分片查询高效。


3. 数据链路贯通:Flume采集→Kafka缓冲→Spark流式计算→HBase持久化全链路调试

3.1 Flume Agent配置解析:flume.conf中的三个生死攸关参数

News_Spark-master/flume_hbase/flume.conf定义了日志采集管道,其中三处配置决定数据能否进Kafka:

# 1. source监听目录必须存在且有读写权限(常见翻车点!) a1.sources.r1.channels = c1 a1.sources.r1.type = spooldir a1.sources.r1.spoolDir = /opt/news_data/raw_logs # ← 此目录需手动创建并放测试日志 a1.sources.r1.fileSuffix = .COMPLETED a1.sources.r1.ignorePattern = ^$ # 2. sink指向Kafka的bootstrap.servers必须与kafka server.properties一致 a1.sinks.k1.type = org.apache.flume.sink.kafka.KafkaSink a1.sinks.k1.kafka.bootstrap.servers = localhost:9092 a1.sinks.k1.kafka.topic = news_log # 3. serializer必须使用项目自研类(原生StringSerializer无法处理中文) a1.sinks.k1.serializer.class = com.news.flume.KfkAsyncHbaseEventSerializer

启动Flume前必做检查:

# 创建日志目录并放入测试文件(内容为JSON格式新闻日志) mkdir -p /opt/news_data/raw_logs echo '{"title":"AI技术突破","content":"深度学习模型精度提升","region":"北京","timestamp":"2023-10-15 14:30:00"}' > /opt/news_data/raw_logs/test.log # 启动Flume(-n指定agent名,-f指定配置文件) flume-ng agent -n a1 -c conf -f flume.conf -Dflume.root.logger=INFO,console

3.2 Spark Streaming消费Kafka的容错设计

sparkStu/src/main/scala/com/news/streaming/NewsKafkaStream.scala采用Direct Approach直连Kafka,关键代码段如下:

// 【核心】从Kafka读取数据并解析JSON val kafkaParams = Map[String, Object]( "bootstrap.servers" -> "localhost:9092", "key.deserializer" -> classOf[StringDeserializer], "value.deserializer" -> classOf[StringDeserializer], "group.id" -> "news-group", // ← 消费组ID,影响offset管理 "auto.offset.reset" -> "latest", // ← 启动时从最新offset消费,避免历史脏数据 "enable.auto.commit" -> "false" // ← 关闭自动提交,由Spark手动控制 ) val topics = Array("news_log") val stream = KafkaUtils.createDirectStream[String, String]( ssc, LocationStrategies.PreferConsistent, ConsumerStrategies.Subscribe[String, String](topics, kafkaParams) ) // 【关键】将Kafka消息转为NewsRecord样例类(含region/timestamp/title字段) val newsStream = stream.map(record => { val json = parse(record.value) // 使用json4s解析 NewsRecord( (json \ "region").extract[String], (json \ "timestamp").extract[String], (json \ "title").extract[String], (json \ "content").extract[String] ) })

参数说明:auto.offset.reset="latest"确保调试时不会重复消费旧数据;enable.auto.commit="false"配合后续stream.foreachRDD { rdd => ... }中的rdd.foreachPartition { partition => ... }实现精确一次(exactly-once)语义。

3.3 实时词频统计的窗口函数实战

RealTimeWordCount.scala使用滑动窗口计算每分钟词频,代码精要如下:

// 【核心】定义1分钟窗口,滑动间隔30秒(即每30秒输出一次最近1分钟词频) val windowedStream = newsStream .map(news => (news.title, 1)) // 提取标题作为词源 .reduceByKeyAndWindow( (x: Int, y: Int) => x + y, // 窗口内累加 (x: Int, y: Int) => x - y, // 窗口滑动时减去离开窗口的计数 Seconds(60), // 窗口长度 Seconds(30) // 滑动间隔 ) .filter(_._2 > 5) // 过滤低频词(出现少于5次不入库) .map { case (word, count) => // 【关键】生成HBase RowKey:yyyyMMddHHmm:word val timeKey = new SimpleDateFormat("yyyyMMddHHmm").format(new Date()) (s"$timeKey:$word", count) } // 【落地】写入HBase(调用自定义saveAsHadoopDataset) windowedStream.foreachRDD { rdd => rdd.saveAsHadoopDataset(hbaseConf) }

技术细节:reduceByKeyAndWindow的第三个参数Seconds(60)是窗口长度,第四个Seconds(30)是滑动步长。若改为Seconds(60), Seconds(60)则变成Tumbling Window(无重叠),适合日报表场景。

3.4 HBase写入的序列化陷阱与修复

SimpleHbaseEventSerializer.java负责将Spark计算结果转为HBase Put对象,其configure(Context context)方法中埋着一个经典坑:

// 【血泪经验】原生HBase 1.2的Put构造函数已废弃,必须用带timestamp的版本 // 错误写法(编译报错): // Put put = new Put(Bytes.toBytes(rowKey)); // 正确写法(项目已修复): long timestamp = System.currentTimeMillis(); Put put = new Put(Bytes.toBytes(rowKey), timestamp); put.addColumn( Bytes.toBytes("cf"), Bytes.toBytes("count"), timestamp, Bytes.toBytes(count.toString()) );

排查技巧:当HBase表中cf:count列为空时,先检查hbase shell中scan 'news_hotword'返回的timestamp是否为0——若是,则证明Put未传入timestamp参数。


4. 可视化层打通:Vue前端对接Spark计算结果的REST API开发与联调

4.1 后端API服务架构:Spring Boot暴露HBase查询接口

News_Spark-master/weblogs/src/main/java/com/news/controller/HotwordController.java提供三个核心接口:

接口路径HTTP方法功能前端调用场景
/api/hotwords/latestGET查询最近10分钟高频词TOP20首页热词云
/api/hotwords/trendPOST按时间范围查询词频趋势(JSON Body传{"start":"202310151400","end":"202310151500"})折线图
/api/sentiment/regionGET按地域聚合情感分值(正向/中性/负向占比)地图热力层

关键代码段(HBase Scan实现):

@GetMapping("/hotwords/latest") public List<HotwordVO> getLatestHotwords() { List<HotwordVO> result = new ArrayList<>(); try (Table table = connection.getTable(TableName.valueOf("news_hotword"))) { // 【关键】Scan最近10个时间分片(每分钟一个分片) String now = new SimpleDateFormat("yyyyMMddHHmm").format(new Date()); String startKey = calculateStartKey(now, 10); // 计算10分钟前的RowKey前缀 Scan scan = new Scan(); scan.setStartRow(Bytes.toBytes(startKey)); scan.setStopRow(Bytes.toBytes(now)); // ← StopRow是排他性,必须+1 scan.addFamily(Bytes.toBytes("cf")); ResultScanner scanner = table.getScanner(scan); for (Result r : scanner) { String rowKey = Bytes.toString(r.getRow()); String word = rowKey.substring(rowKey.indexOf(":") + 1); // 截取冒号后词 int count = Bytes.toInt(r.getValue(Bytes.toBytes("cf"), Bytes.toBytes("count"))); result.add(new HotwordVO(word, count)); } } return result.stream().sorted((a,b)->b.getCount()-a.getCount()).limit(20).collect(Collectors.toList()); }

注意:setStopRow是排他性(exclusive),若想包含202310151500分片,stopRow应设为202310151501,项目中calculateStartKey已内置此逻辑。

4.2 Vue前端数据请求封装:axios拦截器统一处理HBase时间戳

News_Spark-master/weblogs/src/main/resources/static/js/api.js中,getTrendData方法处理时间参数转换:

// 【玄学】HBase RowKey是字符串排序,必须补零保证位数一致 export function getTrendData(startTime, endTime) { // 将"2023-10-15 14:00:00"转为"202310151400" const formatTime = (dateStr) => { const d = new Date(dateStr); return `${d.getFullYear()}${pad(d.getMonth()+1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}`; }; return axios.post('/api/hotwords/trend', { start: formatTime(startTime), end: formatTime(endTime) }); } const pad = (num) => num.toString().padStart(2, '0'); // 补零工具函数

4.3 ECharts图表配置:地域热力图的geoJSON坐标映射

weblogs/src/main/resources/static/js/charts/regionChart.js中,convertToGeoData函数将HBase返回的{"region":"北京","score":0.85}转为ECharts可识别格式:

// 【避坑】ECharts中国地图geoJSON中省份名为"北京市"而非"北京" const regionMap = { '北京': '北京市', '上海': '上海市', '广东': '广东省', '浙江': '浙江省' // ← 全部34个省级行政区需在此补全,否则地图不渲染 }; function convertToGeoData(data) { return data.map(item => ({ name: regionMap[item.region] || item.region, // 防御性编程 value: item.score * 100 // 转为百分比便于颜色映射 })); }

提示:weblogs/src/main/resources/static/map/china.json是精简版中国geoJSON,已移除南海诸岛细节以减小体积,符合国内地图规范。

4.4 前端启动与跨域配置

weblogs模块使用Spring Boot内嵌Tomcat,启动前需修改application.yml:

# 解决Vue开发服务器(localhost:8080)与后端(localhost:8081)跨域 cors: allowed-origins: http://localhost:8080 allow-credentials: true

启动命令:

cd News_Spark-master/weblogs mvn spring-boot:run # 前端页面访问 http://localhost:8081

5. 避坑指南:生产环境部署中踩过的5个真实深坑及根治方案

5.1 现象:Flume采集日志后Kafka Topic无数据,jps显示Flume进程存活

原因:flume.conf中spoolDir目录权限不足,或目录内存在.COMPLETED后缀文件(Flume认为已处理完,不再监控)
解决:

  1. 执行chmod 755 /opt/news_data/raw_logs
  2. 删除目录下所有.COMPLETED文件:find /opt/news_data/raw_logs -name "*.COMPLETED" -delete
  3. 向目录放入新日志文件(文件名不能含空格或中文)

5.2 现象:Spark Streaming任务启动后立即报ClassNotFoundException: org.apache.hadoop.hbase.client.Put

原因:spark-submit未将HBase客户端jar包加入classpath,或SPARK_CLASSPATH环境变量未生效
解决:

  1. 确认spark-env.sh中SPARK_CLASSPATH包含HBase lib路径:
    export SPARK_CLASSPATH="/path/to/hbase/lib/hbase-client-1.2.6.jar:/path/to/hbase/lib/hbase-common-1.2.6.jar"
  2. 提交任务时显式添加:
    spark-submit --driver-class-path /path/to/hbase/lib/* \ --jars /path/to/hbase/lib/hbase-client-1.2.6.jar \ --class com.news.streaming.NewsKafkaStream \ target/sparkStu-1.0.jar

5.3 现象:HBase表中cf:count列值全为0,scan返回timestamp=0

原因:SimpleHbaseEventSerializer.java中Put构造未传入timestamp参数,HBase默认使用0
解决:

  1. 打开flume_hbase/SimpleHbaseEventSerializer.java
  2. 定位public void configure(Context context)方法内的Put创建代码
  3. 替换为带timestamp的构造:new Put(rowKeyBytes, System.currentTimeMillis())

5.4 现象:Vue前端调用/api/hotwords/trend返回空数组,但HBase中确认有数据

原因:HotwordController.java中setStopRow参数未加1,导致Scan范围不包含结束时间分片
解决:

  1. 在calculateStartKey方法后,对endTime执行加1操作:
    String stopKey = endTime.substring(0, 10) + String.format("%02d", Integer.parseInt(endTime.substring(10, 12)) + 1); scan.setStopRow(Bytes.toBytes(stopKey));
  2. 或更稳妥的做法:stopKey = String.valueOf(Long.parseLong(endTime) + 1);

5.5 现象:ECharts地域热力图只显示部分省份,北京/上海等直辖市不渲染

原因:HBase返回的region字段值为"北京",但ECharts中国地图geoJSON中对应名称为"北京市"
解决:

  1. 在convertToGeoData函数中补全regionMap:
    const regionMap = { '北京': '北京市', '天津': '天津市', '上海': '上海市', '重庆': '重庆市', '香港': '香港特别行政区', '澳门': '澳门特别行政区' };
  2. 前端调试时打印regionMap[item.region]确认映射成功

6. 二次开发实战:替换情感分析模型为LSTM+Attention,并接入自定义新闻源

6.1 情感分析模块解耦:从规则匹配到深度学习模型的平滑迁移

原项目SentimentAnalyzer.scala使用朴素贝叶斯+停用词词典(src/main/resources/stopwords.txt),准确率约72%。要升级为LSTM模型,需改造三处:

6.1.1 数据预处理层:增加文本向量化Pipeline

在sparkStu/src/main/scala/com/news/analysis/下新建TextVectorizer.scala:

import org.apache.spark.ml.feature.{StringIndexer, Tokenizer, HashingTF} import org.apache.spark.sql.DataFrame object TextVectorizer { def buildPipeline(): Pipeline = { val tokenizer = new Tokenizer().setInputCol("content").setOutputCol("words") val hashingTF = new HashingTF() .setInputCol("words") .setOutputCol("features") .setNumFeatures(10000) // 特征维度,根据词表大小调整 new Pipeline().setStages(Array(tokenizer, hashingTF)) } // 【关键】将训练好的LSTM模型权重加载为Broadcast变量 def loadLSTMModel(sc: SparkContext): Broadcast[LSTMModel] = { val modelPath = "/path/to/lstm_model.bin" sc.broadcast(LSTMModel.load(modelPath)) // 自定义LSTMModel类需实现load方法 } }
6.1.2 模型预测层:替换SentimentAnalyzer中的predict方法

原predict(content: String)方法被重构为:

def predict(content: String)(implicit modelBC: Broadcast[LSTMModel]): Double = { // 1. 文本清洗(去HTML标签、繁体转简体) val cleanText = HTMLCleaner.clean(content) // 2. 分词+向量化(复用TextVectorizer.pipeline) val vector = TextVectorizer.transform(cleanText) // 3. 调用广播模型预测(返回-1~1的连续值) modelBC.value.predict(vector) }

技术要点:Broadcast变量确保每个Executor只加载一次模型,避免重复IO;HTMLCleaner需自行实现,推荐使用Jsoup库。

6.2 自定义新闻源接入:绕过Flume,直连MySQL新闻库的CDC方案

若需接入企业内部MySQL新闻库,可弃用Flume,改用Debezium CDC:

6.2.1 MySQL Binlog配置(必须项)

在MySQL配置文件my.cnf中添加:

[mysqld] server-id=1 log-bin=mysql-bin binlog-format=ROW expire-logs-days=7 max-binlog-size=100M
6.2.2 Debezium Connector配置

创建mysql-connector.json:

{ "name": "mysql-connector", "config": { "connector.class": "io.debezium.connector.mysql.MySqlConnector", "tasks.max": "1", "database.hostname": "mysql-host", "database.port": "3306", "database.user": "debezium", "database.password": "dbz", "database.server.id": "184054", "database.server.name": "mysql-server-1", "database.include.list": "news_db", "table.include.list": "news_db.news_articles", "database.history.kafka.bootstrap.servers": "localhost:9092", "database.history.kafka.topic": "schema-changes.news" } }
6.2.3 Spark Structured Streaming消费Debezium事件

NewsKafkaStream.scala中新增分支逻辑:

// 当sourceType == "debezium"时,解析MySQL CDC事件 val debeziumStream = stream .filter(_.key != null) // 过滤心跳事件 .map(record => { val valueJson = parse(record.value) val after = (valueJson \ "after").extract[Map[String, String]] NewsRecord( after.getOrElse("region", "未知"), after.getOrElse("publish_time", "1970-01-01 00:00:00"), after.getOrElse("title", ""), after.getOrElse("content", "") ) })

6.3 可视化增强:在ECharts热力图上叠加新闻事件标记

修改regionChart.js,在option.series中增加effectScatter图层:

option = { series: [ // 原热力图... { name: '新闻事件', type: 'effectScatter', coordinateSystem: 'geo', data: geoData.map(item => ({ name: item.region, value: [item.lng, item.lat, item.eventCount], // 经纬度+事件数 symbolSize: item.eventCount * 5 // 事件越多圆圈越大 })), symbol: 'pin', // 使用图钉图标 symbolSize: 20, label: { show: true, formatter: '{b}' }, // 显示省份名 itemStyle: { color: '#f4e925' } } ] };

实现细节:geoData需从后端API获取经纬度,项目已提供weblogs/src/main/resources/static/js/utils/region2geo.js,内含34省会城市经纬度映射表。

从那以后我每次接入新数据源,都强制走一遍Flume→Kafka→Spark→HBase→API→Vue全链路日志追踪:在Flume日志里grepEvent took确认采集耗时,在Kafka消费者组里kafka-consumer-groups.sh --describe看lag,在Spark UI的Stage页签盯住Shuffle Write,在HBase Shell里count 'news_hotword'验数据量,在浏览器Network面板查API响应时间。这套组合拳下来,再复杂的实时系统也能在2小时内定位到瓶颈模块。希望帮到你。

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

RELATED READING

延伸阅读

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