简介:一套基于SpringBoot与ECharts的新闻可视化分析平台源码数据库,面向Java Web开发者与数据可视化学习者。平台整合新闻数据采集、存储、统计分析到图表展示的完整流程,覆盖SpringBoot分层架构、持久层映射、RESTful接口以及ECharts动态渲染配置,有助于理解前后端数据交互与可视化实现思路。资源包共69个文件,大小约1.68MB,其中17个Java文件承载实体、服务、控制层等核心业务逻辑,SQL脚本与多个CSV文件提供建表和新闻样本数据,HTML/JS/CSS实现图表页面,另附Maven包装器、说明文档及界面截图。项目内置Maven Wrapper,可直接运行构建;同时提供zbak备份文件,便于对比调试和排错。该平台以教学交流为目的,适合课程设计或毕业设计参考,当前已有37人学习使用;整体体量紧凑但结构完整,是SpringBoot与ECharts结合项目的一份实用样例。
1. SpringBoot + ECharts 新闻可视化分析平台:一份能直接跑通的数据分析与展示骨架
拿到这套基于SpringBoot+Echart新闻可视化分析平台源码数据库,你实际拿到的是一条完整的「新闻数据 → 词频统计 → 可视化展示」链路。后端用 SpringBoot 出 REST 接口,数据落在 MySQL,前端用 ECharts 画词云、趋势线和分类占比,中间还串了定时任务和中文分词。它解决的是「一堆新闻文本摆在库里,却不知道怎么看」的尴尬:标题和正文是死的,词频和趋势是活的。做舆情监控落地、编辑部选题辅助,或者拿它当课程设计参考骨架,都能直接套。适合刚入门 SpringBoot 的开发者照着改,也适合写过 CRUD、想体验完整分析型项目的熟手换数据源。下面先从数据表拆起。
2. 架构与数据表设计:三张表怎么撑起词频、趋势和分类分析
2.1 技术选型:为什么是 SpringBoot 2.7 + MyBatis-Plus + ECharts 5
先看整体。这套平台不是一个大而全的新闻发布系统,而是一个「薄后端 + 厚分析」的典型结构。后端只做三件事:收数据、算词频、出接口;前端只做一件事:把接口数据画出来。正因为职责单一,技术选型就非常收敛,没有引入搜索引擎,也没有上消息队列。
组件版本我建议按表里的来,这套组合经过大量项目验证,坑最少:
| 组件 | 版本建议 | 职责 |
|---|---|---|
| spring-boot-starter-parent | 2.7.18 | Web 服务、定时任务、JSON 序列化 |
| mybatis-plus-boot-starter | 3.5.3.1 | 免写 Mapper XML,条件构造器查数据 |
| mysql-connector-j | 8.0.33 | JDBC 驱动 |
| hanlp | portable-1.8.4 | 中文分词,自带词典 |
| echarts | 5.x | 词云、折线、柱状图渲染 |
| echarts-wordcloud | 2.x | 词云扩展插件 |
为什么用 2.7.18 而不是 SpringBoot 3.x,后面避坑章会专门讲。这里先说一点:国内大量 MyBatis-Plus 配置、HanLP 集成案例都是基于 javax 命名空间写的,Boot 3 全面切到 jakarta 之后,老代码直接报 NoClassDefFoundError。这套资源如果按 3.x 跑,你得手动改掉所有 javax.servlet 引用,成本不低。2.7.18 是 Boot 2 的最后一个维护版本,安全补丁一直更新到 2024 年,足够用。
MyBatis-Plus 在这个项目里最大的价值不是 CRUD 少写代码,而是它的 LambdaQueryWrapper 可以让你把「按日期范围查趋势」「按分类查词频」这类条件查询写得像 Java 代码,而不是拼 SQL 字符串。词频统计是分析项目的核心,接口会经常加筛选条件,用 MyBatis-Plus 改起来最快。
2.2 三张核心表:news_article、news_word_freq、news_trend
数据库设计是这套资源里最值得先看的部分。一共三张表,职责分得非常干净:原始数据、词频结果、趋势结果。不要试图把它们合并成一张大表,词频和趋势都是统计结果,属于「可重算」的数据,每次跑批直接清掉重写,不需要保留历史变更记录。
第一张表存新闻原文:
CREATE DATABASE IF NOT EXISTS news_analysis DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; USE news_analysis; CREATE TABLE news_article ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, title VARCHAR(255) NOT NULL COMMENT '新闻标题', content TEXT COMMENT '新闻正文,用于分词', source VARCHAR(100) DEFAULT '' COMMENT '来源媒体', category VARCHAR(50) DEFAULT '综合' COMMENT '分类:国内/国际/科技/财经等', publish_time DATETIME NOT NULL COMMENT '发布时间', url VARCHAR(500) DEFAULT '' COMMENT '原文链接', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '入库时间', PRIMARY KEY (id), KEY idx_category_time (category, publish_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='新闻原始表';content 用 TEXT 而不是 VARCHAR,因为新闻正文动辄几千字,VARCHAR 在 MySQL 里最长也就 65535 字节,存长文会截断。publish_time 和 create_time 是两个字段,前者是新闻发布时间,后者是数据入库时间,跑批时要用 create_time 判断新增数据,展示时用 publish_time 画时间轴。联合索引 idx_category_time 直接支撑「某分类某时间段的词频统计」这个高频查询,type 字段在 MySQL 8.0 之后建议用 utf8mb4,emoji 和生僻字都能存。
第二张表存词频统计结果:
CREATE TABLE news_word_freq ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, word VARCHAR(50) NOT NULL COMMENT '词', freq INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '出现次数', category VARCHAR(50) DEFAULT '综合', stat_date DATE NOT NULL COMMENT '统计日期', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_word_date (word, stat_date), KEY idx_cat_date (category, stat_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='词频统计表';这张表是词云的数据源。word 字段长度给 50,中文词一般不超过 12 个汉字,50 字节足够;freq 是统计结果,不用 DECIMAL,高频词一天出现几千次,INT UNSIGNED 不会溢出。stat_date 单独存而不是用 create_time 推断,因为你可能在凌晨补跑前一天的数据,重算时 stat_date 要能指回业务日期。
第三张表存关键词趋势:
CREATE TABLE news_trend ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, keyword VARCHAR(50) NOT NULL COMMENT '关键词', stat_date DATE NOT NULL COMMENT '日期', article_cnt INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '当日包含该词的新闻数', source VARCHAR(100) DEFAULT '' COMMENT '来源维度,可为空', PRIMARY KEY (id), KEY idx_kw_date (keyword, stat_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关键词趋势表';news_trend 和 news_word_freq 的区别在于:词频表统计的是「所有词各出现了多少次」,趋势表统计的是「某个关键词每天有多少条新闻提到」。前者是全局画像,后者是单点追踪。趋势表按 keyword + stat_date 查询,所以建 idx_kw_date。source 字段预留了来源维度,你可以扩展成「某个媒体对某个关键词的报道趋势」。
2.3 数据初始化:项目包里的 SQL 怎么导入
解压项目包后,Database 目录下的 news_analysis.sql 就包含了上面三张表的建表语句,以及一批样例新闻数据。导入方式有两种,二选一。
命令行方式最稳:
mysql -uroot -p < news_analysis.sql执行前确认 MySQL 服务已启动。如果你用的是 Navicat 或 DataGrip,直接新建连接后选择「运行 SQL 文件」也可以。我一般推荐命令行,因为图形工具在导入大 SQL 时经常因为字符集问题把中文变成乱码,命令行用系统默认编码读取文件,反而更可靠。
导入后验证一下数据量:
SELECT COUNT(*) FROM news_article; SELECT category, COUNT(*) FROM news_article GROUP BY category;样例数据一般覆盖国内、国际、科技、财经四个分类,每类几十条新闻。验证时重点看 content 字段是否完整——如果 content 为空,后面的分词统计就是空转。这里有一个容易忽略的点:SQL 文件的编码必须是 UTF-8。Windows 下用记事本另存为时容易存成带 BOM 的 UTF-8 或 ANSI,MySQL 导入后中文直接变问号。用 VS Code 打开文件看右下角编码,确认是 UTF-8 再导入。
3. 数据来源与中文分词:从采集入库到 HanLP 词频统计
3.1 数据从哪来:项目包自带的 SQL 和爬虫补数据两条路
这套资源的数据库脚本里已经预置了一批新闻样例数据,你要验证流程、改接口、调图表,直接拿这批数据就够了。但如果你想跑一个真实场景,比如追踪某个行业近一周的舆情,就得自己补数据。
补数据有两条路:一是手动在后台录入,适合小批量;二是写脚本抓取,适合持续跟进。这里必须说一句:抓取新闻数据前先看目标站点的 robots.txt 和服务条款,很多网站禁止抓取正文,只抓标题和摘要用于统计分析,一般在许可范围内。抓取时一定要限速,每秒最多两三个请求,否则很容易把对方服务器打挂,你的 IP 也会被拉黑。
3.2 Python 采集脚本:把新闻标题批量写进 news_article
采集脚本我用 Python 写,因为 requests + BeautifulSoup 的组合在处理 HTML 页面时比 Java 的 HttpClient 简单得多。你拿到手的 SpringBoot 项目里如果已经带了采集模块,可能是 Java 定时任务,原理一样,只是解析库不同。
import requests from bs4 import BeautifulSoup import pymysql import datetime # 以示例站点为目标,实际使用时替换成你确认过 robots.txt 的站点 HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"} conn = pymysql.connect( host="localhost", user="root", password="123456", database="news_analysis", charset="utf8mb4" ) def fetch_news(): try: resp = requests.get("https://news.example.com/", headers=HEADERS, timeout=10) resp.encoding = "utf-8" soup = BeautifulSoup(resp.text, "html.parser") # 选择器需要按目标站点的实际 HTML 结构调整 links = soup.select("a[href*='/news/']")[:20] with conn.cursor() as cursor: for link in links: title = link.get_text().strip() href = link.get("href") if len(title) < 5: continue sql = """INSERT INTO news_article (title, source, category, publish_time, url) VALUES (%s, %s, %s, %s, %s)""" cursor.execute(sql, (title, "示例网", "综合", datetime.datetime.now(), href)) conn.commit() except requests.RequestException as e: print("抓取失败:", e) if __name__ == "__main__": fetch_news()这个脚本有两个关键参数:links = soup.select("a[href*='/news/']")[:20]里的选择器按目标站点的链接结构调整,:20是单次抓取上限,别贪多;resp.encoding = "utf-8"是硬性要求,如果目标站点是 GBK 编码,这里要改成对应的编码,否则入库的中文全是乱码。pymysql 连接时显式指定 charset="utf8mb4",和数据库表字符集保持一致。
脚本写好后,配合系统的 crontab 或者 Windows 任务计划程序,每天早上跑一次,新闻就是持续更新的状态。不想自己爬的,直接用项目包里的 SQL 样例数据也完全能跑通整个分析流程。
3.3 HanLP 分词与去停用词:词频计算的核心逻辑
新闻文本进到 news_article 之后,下一步就是分词和词频统计。中文分词不是简单的按空格切分,「人工智能」如果按单字切会变成「人工」「智能」两个词,语义就丢了。HanLP 是开源的中文 NLP 工具包,portable 版本自带词典,不需要额外下载数据文件,对词频统计这种场景最合适。
在 pom.xml 里加依赖:
<dependency> <groupId>com.hankcs</groupId> <artifactId>hanlp</artifactId> <version>portable-1.8.4</version> </dependency>核心的词频统计逻辑写成一个 Service:
@Service public class NewsAnalysisService { @Resource private NewsArticleMapper articleMapper; @Resource private NewsWordFreqMapper wordFreqMapper; private final Set<String> stopWords = loadStopWords(); public void computeWordFreq(LocalDate statDate) { // 读取 statistics 日期当天的新闻,按 content 分词后统计词频 List<NewsArticle> articles = articleMapper.selectList( new LambdaQueryWrapper<NewsArticle>() .isNotNull(NewsArticle::getContent)); Map<String, Long> freqMap = articles.parallelStream() .flatMap(a -> HanLP.segment(a.getContent()).stream()) .map(Term::getWord) .filter(w -> w.length() >= 2 && !stopWords.contains(w)) .collect(Collectors.groupingBy(Function.identity(), Collectors.counting())); // 只保留 Top 200,清掉旧数据后重新写入 List<NewsWordFreq> records = freqMap.entrySet().stream() .filter(e -> e.getValue() >= 3) .sorted(Map.Entry.<String, Long>comparingByValue().reversed()) .limit(200) .map(e -> new NewsWordFreq(e.getKey(), e.getValue().intValue(), statDate)) .collect(Collectors.toList()); wordFreqMapper.deleteByStatDate(statDate); records.forEach(wordFreqMapper::insert); } private Set<String> loadStopWords() { // 从 classpath:stopwords.txt 读取,按行加载 try (InputStream is = getClass().getResourceAsStream("/stopwords.txt")) { return new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8)) .lines().collect(Collectors.toSet()); } catch (IOException e) { return new HashSet<>(Arrays.asList("的", "了", "在", "是", "我", "你")); } } }这段代码有几个关键点。HanLP.segment()返回的是 Term 对象列表,Term::getWord取出词本身,Term::getNature可以拿到词性,如果你想过滤掉标点符号和纯数字,可以用nature.startsWith("w")排除掉。
w.length() >= 2能滤掉「的」「了」「是」这些单字虚词,但滤不掉「我们」「他们」「可以」这类双字停用词,所以必须搭配stopWords集合。stopwords.txt 建议从网上找一份常用中文停用词表,大约 1300 行左右,覆盖「啊、吧、吗、哪个、那些、然后」这类无信息量词汇。
parallelStream()在新闻量大时能利用多核加速,但 collect 到groupingBy时是线程安全的,因为并行流的收集器会自动做结果合并。如果你改成ConcurrentHashMap.computeIfAbsent那套写法,反而容易出并发问题。这里用 collect 是最稳的。
3.4 词云数据落表:TopN 怎么定
词频算完之后,不是所有词都值得展示。词云上展示的词要「既高频又有区分度」。我一般取 Top 200,频率阈值设为 3,也就是说一个词在统计日期内至少出现 3 次才入库。低于 3 次的词大多是噪声,比如某篇新闻里专有的人名。
落表时按 stat_date 清掉旧数据再写入,deleteByStatDate是 MyBatis-Plus 里自定义的方法,对应的 SQL 就是DELETE FROM news_word_freq WHERE stat_date = ?。为什么清掉而不是增量更新?因为词频是「重算」逻辑,每次跑批的输入是当天全量新闻,直接覆盖最简单,不会出现残留数据导致词频翻倍。
TopN 到底取多少,取决于你的前端图表尺寸。ECharts 词云在 1200px 宽的容器里,展示 80~120 个词效果最好,太少显得空,太多全部挤在一起看不清。接口层面把 limit 作为参数暴露给前端,后端默认 100,前端可以随时调整。
4. 后端接口与前端图表:一套 REST API 喂饱词云和趋势线
4.1 接口设计:/api/news/wordcloud 与 /api/news/trend
后端一共暴露四个核心接口,全部走 REST 风格,返回统一的 Result 包装体:
| 接口 | 参数 | 返回内容 |
|---|---|---|
| GET /api/news/wordcloud | category 可选,limit 默认 100 | 词频列表 [{word, freq}] |
| GET /api/news/trend | keyword 必填,days 默认 30 | 每日词频统计 [{date, count}] |
| GET /api/news/category | 无 | 各分类新闻数占比 |
| GET /api/news/latest | limit 默认 20 | 最新新闻列表 |
Controller 层只做参数接收和返回,不写业务逻辑:
@RestController @RequestMapping("/api/news") public class NewsController { @Resource private NewsAnalysisService analysisService; @Resource private NewsTrendService trendService; @GetMapping("/wordcloud") public Result<List<NewsWordFreq>> wordcloud( @RequestParam(required = false) String category, @RequestParam(defaultValue = "100") int limit) { List<NewsWordFreq> data = analysisService.topWords(category, limit); return Result.ok(data); } @GetMapping("/trend") public Result<List<TrendPoint>> trend( @RequestParam String keyword, @RequestParam(defaultValue = "30") int days) { LocalDate start = LocalDate.now().minusDays(days); List<TrendPoint> points = trendService.selectTrend(keyword, start); return Result.ok(points); } }category参数是可选的,不传就统计全量,传了就走「某分类下的词频」查询。这个设计是为了前端做分类筛选时不用拆成多个接口。days默认 30,前端图表上有 7 天/30 天/90 天三个切换按钮,本质是同一个接口传不同的 days 值。
很多新手会把「时间范围」做成两个参数 startDate 和 endDate 传进来,其实对新闻趋势这种场景,传 days 让后端算 start 日期就够了,接口更简洁,也避免前端传错格式。Result 包装体代码如下:
public class Result<T> { private int code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 200; r.message = "success"; r.data = data; return r; } }统一包装的好处是前端不用每个请求都判断返回结构,code 不为 200 就在页面上给个提示。code 用 HTTP 状态码还是业务码?这里建议直接用 200/500 这类 HTTP 语义,项目规模不大,不需要搞 10001、10002 那套业务码体系。
4.2 定时任务:每天凌晨 2 点重算词频
数据是每天更新的,词频也必须跟着重新算。定时任务用 SpringBoot 自带的 @Scheduled,不需要引入 Quartz:
@Configuration @EnableScheduling public class SchedulerConfig { } @Component public class AnalysisScheduler { @Resource private NewsAnalysisService newsAnalysisService; // cron: 秒 分 时 日 月 周 —— 每天凌晨 2 点 0 分 0 秒执行 @Scheduled(cron = "0 0 2 * * ?") public void dailyWordFreqTask() { newsAnalysisService.computeWordFreq(LocalDate.now().minusDays(1)); } }为什么选凌晨 2 点?因为新闻采集脚本一般晚上更新完数据,凌晨算词频不会和用户访问高峰抢数据库连接。LocalDate.now().minusDays(1)算出的是昨天,因为凌晨 2 点跑批时今天的数据还没采完,统计昨天才是完整的一天。cron 表达式里*表示任意值,?表示不指定,日和周两个位置要留一个?,这是 Spring 定时任务和 Quartz 一致的规定。
如果你在本地调试想手动触发,直接在 Controller 里加一个临时接口POST /api/news/trigger-analysis调用computeWordFreq,调试完删掉就行。我一般会加这个接口,因为每次等定时任务太浪费时间。
4.3 ECharts 词云与动态排序图:axis.type 是关键
前端页面放在src/main/resources/static目录下,直接由 SpringBoot 托管,访问http://localhost:8080/就能看到首页。词云使用 echarts-wordcloud 插件,它扩展了 ECharts 的 series 类型。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"> <title>新闻可视化分析</title> <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/echarts-wordcloud@2/dist/echarts-wordcloud.min.js"></script> </head> <body> <div id="wordcloud" style="width: 100%; height: 480px;"></div> <script> const wcChart = echarts.init(document.getElementById('wordcloud')); fetch('/api/news/wordcloud?limit=100') .then(r => r.json()) .then(res => { wcChart.setOption({ series: [{ type: 'wordCloud', shape: 'circle', sizeRange: [14, 64], rotationRange: [0, 0], gridSize: 4, width: '90%', height: '90%', textStyle: { fontWeight: 'bold' }, data: res.data.map(w => ({ name: w.word, value: w.freq })) }] }, true); }); </script> </body> </html>这段配置里有四个值值得解释。type: 'wordCloud'必须是这个字符串,它由 echarts-wordcloud 插件注册,用原生 ECharts 不认识这个类型。rotationRange: [0, 0]是我强烈建议的配置,中文词云如果允许随机角度旋转,会出现大量竖排文字,阅读体验非常差,设成[0, 0]强制所有词水平展示。sizeRange: [14, 64]控制词的字号范围,14 是最小号,64 是最大号,根据词频映射。gridSize: 4是词之间的间距,太小挤成一团,太大气势不足,4 是一个平衡值。
词云不需要 xAxis 和 yAxis,它是通过引力布局把词铺满整个画布的。但趋势折线图就必须要坐标轴,这里最容易翻车:
fetch('/api/news/trend?keyword=芯片&days=30') .then(r => r.json()) .then(res => { trendChart.setOption({ xAxis: { type: 'category', data: res.data.map(d => d.statDate) }, yAxis: { type: 'value' }, series: [{ type: 'line', smooth: true, areaStyle: {}, data: res.data.map(d => d.articleCnt) }] }); });xAxis 的type: 'category'必须和data数组里的字符串日期配对。如果你写成type: 'value',而 data 传的是字符串,ECharts 会直接渲染空白,控制台还会报错。这是 ECharts 使用里最典型的「数据格式和轴类型不匹配」问题。
4.4 静态资源托管:SpringBoot 直接当 Web 服务器
这个项目的前端是纯静态页面,没有用 Vue 或 React,所以不需要额外的前端服务器。SpringBoot 对classpath:/static/目录下的文件自动做静态映射,index.html作为默认首页。
如果你之后想把前端换成 Vue 项目,在项目根目录执行npm run build,然后把 dist 目录下的文件全部拷到src/main/resources/static/下覆盖,重新打包即可。SpringBoot 会接住所有静态资源请求,这就是「vue打包放进springboot中」的标准做法,不需要改任何后端代码。唯一要注意的是:如果 Vue 用了 history 路由模式,刷新子路由会 404,需要加一个转发规则把非 API 请求转发到 index.html,这个项目当前用不到,但提前知道有这回事会省很多排查时间。
5. 联调避坑实录:五个高频问题与排查顺序
5.1 中文乱码:JSON 正常,图表标签却全是问号
现象:浏览器 Network 面板里看到接口返回的 JSON 是正常中文,但 ECharts 图表的图例、词云上的词全部显示成???或者方块。
原因:这个现象一般是两个叠加因素。第一,HTML 页面没有声明 charset,或者声明放在<title>之后;浏览器在解析脚本里的中文字符串时用了错误的编码。第二,数据库连接串没带 characterEncoding 参数,从 MySQL 读出来的数据在 JDBC 层就已经转坏了,存进变量时就是乱码,后面怎么渲染都是乱的。
解决:先修 HTML,<meta charset="utf-8">必须放在<head>第一行。再检查application.yml里的数据库连接 URL,确保是下面这样:
spring: datasource: url: jdbc:mysql://localhost:3306/news_analysis?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=falseuseUnicode=true 和 characterEncoding=utf8 两个参数必须成对出现,缺一个都可能触发问题。如果是 SpringBoot 2.7 + mysql-connector-j 8.x,serverTimezone 也建议显式指定为 Asia/Shanghai,否则 JDBC 会拿服务器默认时区,在部署到云服务器时容易和本地相差 8 小时。
5.2 词频统计被「的」「了」霸榜:停用词没生效
现象:词云里最大的字是「的」「了」「在」「是」,整个词云毫无信息量;检查数据库的 news_word_freq 表,词频最高的 20 条几乎全是虚词。
原因:过滤器里只写了w.length() >= 2,这个条件能滤掉单字虚词,但滤不掉「我们」「他们」「一个」「没有」这类双字停用词。更隐蔽的问题是:如果 stopwords.txt 是从资源目录加载的,但没有放到src/main/resources下,getResourceAsStream返回 null,loadStopWords 走的是 fallback 分支,只有六个词,等于停用词表没生效。
解决:写一个临时接口打印stopWords.size(),确认加载到的是 1300 行左右的完整词表,而不是 fallback 的 6 个词。发布前把词频 Top 50 打出来人工过一遍,看到「记者」「报道」「新闻」这类词也属于无区分度词汇,可以追加进停用词表。从那以后我每次跑完词频都会先看 Top 10 再决定是否上线,这一步能挡掉 80% 的词云翻车。
5.3 定时任务更新后图表纹丝不动:setOption 的合并机制
现象:手动触发了一次词频重算,数据库里数据确实变了,前端刷新页面、重新拉接口,能拿到新数据,但 ECharts 图表显示的还是旧图。
原因:ECharts 的setOption(option)默认是 merge 模式,不是 replace 模式。如果新旧 option 结构相同,只是数据变了,ECharts 会按引用和索引做合并,部分 series 的 data 没有真正替换。这不是图表缓存,是 ECharts 的合并机制造成的「假缓存」。
解决:所有动态更新的场景,统一在 setOption 第二个参数传 true 强制覆盖:
wcChart.setOption(option, true);或者更保险的做法,先wcChart.clear()再setOption(option)。我推荐传 true,因为 clear() 会把已经绑定的 resize 监听和 tooltip 配置也清掉,重新绑定又容易漏。
5.4 SpringBoot 版本太高导致 javax 报错:3.x 与 2.7 的差别
现象:项目启动报NoClassDefFoundError: javax/servlet/...或ClassNotFoundException: javax.servlet.Filter,换成 3.x 版本后 MyBatis-Plus 的 AutoConfiguration 也报类找不到。
原因:SpringBoot 3.0 开始全面从 javax 命名空间迁移到 jakarta 命名空间,Tomcat 10、Servlet 6.0、部分第三方 starter 的旧版本都不再兼容。我之前踩过这个坑,拿一套 2.7 的项目直接改 parent 版本到 3.1,结果启动直接抛异常,排查了半天才发现是 javax 和 jakarta 的包名问题。
解决:这个项目就用 2.7.18,不要升级到 3.x。如果一定要用 Boot 3,需要同时满足:SpringBoot parent 3.x、MyBatis-Plus 3.5.4+、所有 javax.servlet 引用改为 jakarta.servlet。这个改造工作量不小,收益却不明显,新闻可视化这种项目在 2.7 上跑完全没问题。
5.5 折线图 x 轴数据是字符串却配了 value:axis.type 的边界
现象:趋势折线图渲染出来是空白,浏览器控制台没有红色报错,但整个图表区域什么都没有;有些版本会报[ECharts] xAxis 0 is not a category axis。
原因:xAxis 配置了type: 'value',但 data 里传的是["2025-03-01", "2025-03-02"]这种字符串日期。value 轴要求数据是数值或时间戳,字符串直接没解析出来。
解决:字符串日期用分类轴:
xAxis: { type: 'category', data: res.data.map(d => d.statDate) }如果你想让 x 轴自动按时间间隔展示,就用type: 'time',但 data 必须传时间戳数字,比如2025-03-01转成Date.parse(d.statDate)。time 轴的问题在于它自己决定刻度间隔,可能显示成 3 月 1 日、3 月 11 日、3 月 21 日这种不规则的间隔,对 30 天趋势图反而没有 category 直观。我的习惯是:30 天以内用 category,一年以上的数据才考虑 time。
6. 进阶:让词云滚动起来,数据量上来后往哪走
6.1 30 秒轮询刷新:词云变动态
静态词云看久了没意思,而且新闻是持续更新的,词频也在变。给词云加一个轮询刷新,用 setInterval 每 30 秒拉一次最新数据,配合 setOption(option, true) 做整图覆盖:
setInterval(() => { fetch('/api/news/wordcloud?limit=100') .then(r => r.json()) .then(res => { wcChart.setOption({ series: [{ data: res.data.map(w => ({ name: w.word, value: w.freq })) }] }, true); }); }, 30000);注意这里只更新 series 里的 data,tooltip、shape 这些配置不重复声明,因为 setOption(option, true) 会用新对象完整替换图表配置,缺失的配置项走默认值。这就是为什么我把公共配置提取到外部变量,轮询只传变化的 data。30 秒的间隔对本地接口毫无压力,但如果部署到公网,建议改成 60 秒,降低无效请求。
有个细节:词云在轮询更新时,之前看过 5.3 节的话会想到 notMerge 的问题。这里传了 true,每次都是全新渲染,词的位置会重新布局,视觉上就是「跳动」的效果。想要平滑过渡的话,可以用 setOption 默认的 merge 模式,但要注意只传 data 字段会和其他 series 配置合并,需要保证 data 的字段名一致。
6.2 部署与验证:jar 包一键起服务
项目打包部署是常规操作,但有两个参数值得注意。先用 Maven 跳过测试打包:
mvn clean package -DskipTests然后启动:
java -Xmx512m -jar target/news-analysis-1.0.0.jar-Xmx512m 是堆内存上限,新闻文本和词频统计都放在内存里,512M 够支撑几万条新闻的分析。如果新闻量到十万级以上,把堆调到 1G。启动后验证接口:
curl -s "http://localhost:8080/api/news/wordcloud?limit=10" | python3 -m json.tool看到 JSON 正常返回就说明服务起来了。前端直接访问http://localhost:8080/就能看到词云页面。生产环境建议用 systemd 托管,写一个 service 文件,Restart=always,避免进程挂了没人管。
6.3 扩展方向:Flink 流式统计与时序库
数据量到百万级之后,每天凌晨跑一次批处理就不够实时了,词频统计从离线变成流式,这是最自然的演进方向。一个可行的架构是:新闻采集后写入 Kafka,后端用 SpringBoot 整合 Flink 消费消息做窗口聚合,词频结果落到 Redis,接口直接从 Redis 读,替代现在的 MySQL 词频表。这套资源里的表结构和接口设计不用大改,Redis 里存的还是 word 和 freq 两个字段。趋势数据如果跨年查询变慢,可以把 news_trend 表迁到时序数据库 TDengine 里,日期范围聚合查询性能比 MySQL 高一个量级,但表结构几乎可以原样迁移。
从那以后我每次接到可视化需求,都先想清楚「数据多久更新一次、要实时还是离线」,再决定吃这套 SpringBoot 全套方案还是要上 Flink。版本坑、编码坑、setOption 的合并坑,这些靠记忆都不如现场走一遍排错流程,希望帮到你。
本文还有配套的精品资源,点击获取