简介:这套作物生长监控系统基于 Java SSM 框架与 MySQL 数据库实现,定位为毕业设计或课程设计项目,面向需要完成 Web 系统开发任务的学生与开发者。系统针对实际农业基地调研场景,硬件侧采用已开发好的多点温湿度采集系统,软件侧提供实时推送、站点地图、HTTP 抓取农业新闻、数据查询、温湿度数据分析、角色权限管理及站点创建等功能,完整覆盖数据采集、展示与权限控制流程。压缩包共 564 个文件,大小 86.02MB,含 Java 后端源码、SSM 相关 XML 配置、JSP/HTML/JS/CSS 前端页面、jar 依赖、SQL 数据库脚本、日志等,按目录模块可快速定位并二次开发。目前已有 127 人学习下载,借助完整工程代码、数据库脚本与配置说明,可快速搭建运行环境,理解从硬件采集到前端展示的实现链路,也为撰写毕业设计文档、答辩演示和扩展功能提供现成参考。
1. 作物生长监控系统:一套 SSM+MySQL 的 Web 项目,能解决什么
农业基地的管理员最头疼的不是种地,而是“今天大棚里的温度到底适不适合”,靠人工记录温湿度不仅慢,而且数据散落得到处都是。这个基于 Java(SSM)+MySQL 实现的 Web 作物生长监控系统,就是干这个的:它把多点温湿度采集硬件的数据接到 Web 端,你打开浏览器就能看实时数据、历史曲线、站点分布,还能自动抓农业新闻。软件部分用 SpringMVC + Spring + MyBatis 整合,前端是 HTML/CSS/JavaScript,数据库用的 MySQL,硬件部分采用已经封装好的多点温湿度采集系统。功能覆盖实时推送、站点地图、HTTP 爬取农业新闻、数据查询、温湿度分析、角色权限管理以及站点创建。
如果你是准备毕业设计或课程设计的学生,这个项目能让你看到 SSM 框架在真实业务里的落地方式;如果你是在职 Java 工程师,想快速搭一套监控类的 Web 系统,它的模块拆分和权限设计也有参考价值。接下来我按“框架怎么搭→功能怎么实现→最容易翻车的地方”这个顺序拆给你看。
2. SSM 框架整合与数据库设计:先从 MVC 分层和表结构说起
2.1 为什么选 SSM 而不是其他组合
Java Web 课程设计里 SSM 是常青树,SpringMVC 负责路由和参数绑定,Spring 管 Service 层和事务,MyBatis 管持久层。这种组合对于“业务逻辑不复杂但查询条件多变”的管理系统特别合适,尤其是后面要做温湿度统计,MyBatis 的 XML 里写 SQL 可以精准控制分组和排序,比 JPA 的自动 SQL 更直观。相比 SpringBoot,SSM 需要手动配置容器、拦截器、事务,但这也意味着你能真正理解 Web 应用的启动过程,面试被问到“SSM 整合流程”的时候,可以拿这个项目当范例展开。
我一般会在项目的表述上把它定位成“面向监控场景的管理端”,这样在答辩或汇报的时候,面试官不会觉得你只是照着网上的博客拼了个 CRUD。常见做法是后端只返回 JSON,前端页面用 Ajax 拿数据,这样部署时前端可以放到 Nginx,后端放 Tomcat,前后端分离的雏形也有了。如果你准备用 JSP 或者 Thymeleaf,也不是不行,但和实时推送配合起来没有纯 JSON 顺手。
2.2 核心表设计:站点、传感器、温湿度记录、用户与角色
先看最核心的几张表。站点表记录大棚或监测点的基本信息,传感器表挂在站点下面,一个站点可能挂多个温湿度传感器,温湿度记录表是高频写入表,用户和角色表支撑权限控制。我拆这套资源的时候,发现它的建表脚本是标准 MySQL 5.7 语法,字符集统一用 utf8mb4,避免中文乱码。核心建表语句如下:
CREATE TABLE t_site ( id BIGINT PRIMARY KEY AUTO_INCREMENT, site_name VARCHAR(50) NOT NULL COMMENT '站点名称', address VARCHAR(200) DEFAULT '' COMMENT '站点地址', longitude DECIMAL(10,6) NOT NULL COMMENT '经度', latitude DECIMAL(10,6) NOT NULL COMMENT '纬度', status TINYINT DEFAULT 1 COMMENT '1正常 0停用', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_site_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='监测站点表'; CREATE TABLE t_sensor ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sensor_code VARCHAR(30) NOT NULL UNIQUE COMMENT '传感器编号', site_id BIGINT NOT NULL COMMENT '所属站点', type TINYINT DEFAULT 1 COMMENT '1温湿度传感器', status TINYINT DEFAULT 1, KEY idx_sensor_site (site_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='传感器表'; CREATE TABLE t_temp_humidity ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sensor_id BIGINT NOT NULL, site_id BIGINT NOT NULL, temperature DECIMAL(5,2) NOT NULL COMMENT '温度℃', humidity DECIMAL(5,2) NOT NULL COMMENT '湿度%', collect_time DATETIME NOT NULL COMMENT '采集时间', KEY idx_th_site_time (site_id, collect_time), CONSTRAINT fk_th_sensor FOREIGN KEY (sensor_id) REFERENCES t_sensor(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='温湿度记录表'; CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL COMMENT '加密存储', real_name VARCHAR(30) DEFAULT '', status TINYINT DEFAULT 1 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(20) NOT NULL UNIQUE COMMENT 'ADMIN / USER / VISITOR', role_name VARCHAR(30) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_user_role ( user_id BIGINT NOT NULL, role_id BIGINT NOT NULL, PRIMARY KEY (user_id, role_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意几点:温湿度记录表里冗余了site_id,这在这个场景里是故意的。因为查询高频是“按站点看一段时间内的温湿度”,如果每次都去 join 传感器表查站点,多一次连接开销,在数据量起来之后差别很明显。采集时间字段用DATETIME而不是TIMESTAMP,避免 2038 年问题,也方便直接格式化。外键只保留了传感器对记录表的约束,站点和传感器之间没有加物理外键,因为站点停用或删除时要先解除传感器绑定,物理外键反而容易卡住业务。
2.3 MySQL 初始化与 MyBatis 映射配置
数据库初始化直接用上面脚本建库建表,然后准备一份jdbc.properties。这个文件是 SSM 项目的“大门”,很多跑不起来的问题都出在这里:
jdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/crop_monitor?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=123456 jdbc.maxPoolSize=20 jdbc.minPoolSize=5 jdbc.initialPoolSize=5 jdbc.maxIdleTime=60参数说明:如果你用的是 MySQL 8.0,driver必须改成com.mysql.cj.jdbc.Driver,否则启动会直接报 ClassNotFound;serverTimezone=Asia/Shanghai一定不能省,不然后端new Date()插到库里会差 8 小时。连接池的maxPoolSize先给 20,如果监控站点超过 50 个,这个值要往上调,否则后面并发查询一多就等着收 Connection timeout 报错吧。
MyBatis 的映射配置也顺便说一下。主配置文件mybatis-config.xml里要指定 mapper 位置:
<mapper resource="mapper/DataAnalysisMapper.xml"/>对应的接口DataAnalysisMapper.java里方法名和 XML 中的 id 必须一致,namespace 写接口的全限定名。如果你用 IDEA 写接口,XML 的 namespace 很容易因为复制粘贴漏掉包名,这个问题当时排查了我半个下午,后面避坑章节还会专门讲。
3. 功能模块拆解与实现:实时推送、站点地图、新闻爬取和数据分析
3.1 实时温湿度推送:Ajax 轮询还是 WebSocket
“实时推送”这个说法听起来高级,但实际上这套系统用的是前端定时轮询接口,每隔 3 秒请求一次最新温湿度。真正的服务端推送要用 WebSocket,但在 SSM 课程设计这个体量下,轮询完全够用,还能避免引入额外的依赖和处理握手状态的复杂度。如果你在浏览器里看到数据“自己滚动”,实际上就是前端定时器在干活。
前端核心代码:
function refreshLatestData(siteId) { // 加时间戳参数,防止浏览器缓存 GET 请求 fetch('/monitor/latest?siteId=' + siteId + '&t=' + Date.now()) .then(response => response.json()) .then(data => { document.getElementById('temp').innerText = data.temperature.toFixed(2); document.getElementById('hum').innerText = data.humidity.toFixed(2); document.getElementById('time').innerText = data.collectTime; }) .catch(error => console.error('刷新失败', error)); } // 每3秒拉一次最新数据 setInterval(() => { const siteId = document.getElementById('currentSite').value; refreshLatestData(siteId); }, 3000);后端对应的 Controller:
@Controller @RequestMapping("/monitor") public class MonitorController { @Autowired private MonitorService monitorService; @ResponseBody @RequestMapping(value = "/latest", method = RequestMethod.GET) public Map<String, Object> latest(Long siteId) { TempHumidity record = monitorService.getLatestRecord(siteId); Map<String, Object> map = new HashMap<>(); if (record != null) { map.put("temperature", record.getTemperature()); map.put("humidity", record.getHumidity()); map.put("collectTime", record.getCollectTime()); } else { map.put("code", 404); map.put("msg", "该站点暂无数据"); } return map; } }逻辑说明:前端通过setInterval每 3 秒请求一次,URL 后面带Date.now()是为了破坏浏览器对 GET 请求的缓存,不然你用了fetch也可能会拿到旧数据。后端getLatestRecord在 Mapper 里按collect_time倒序排,加LIMIT 1取最新一条。这里没有做 WebSocket,但如果你后续想升级,Spring 对 WebSocket 有现成的HandlerTextMessage,只是要在登录鉴权时额外维护 Session,工作量会增加不少。
3.2 站点地图展示:坐标点与 ECharts 可视化
站点地图模块的价值是让管理员一眼看到整个基地的站点分布和数据健康度。后端返回每个站点的经纬度和最新温湿度,前端用 ECharts 的 scatter 图展示坐标点,气泡颜色映射状态。不需要真的去接高德地图或百度地图,那样反而受限于第三方服务的 token,课程设计里用 ECharts 纯前端展示更可控。
后端查询 SQL:
SELECT s.id, s.site_name, s.longitude, s.latitude, s.status, t.temperature, t.humidity, t.collect_time FROM t_site s LEFT JOIN ( SELECT site_id, temperature, humidity, collect_time FROM t_temp_humidity th WHERE th.collect_time = ( SELECT MAX(collect_time) FROM t_temp_humidity WHERE site_id = th.site_id ) ) t ON s.id = t.site_id WHERE s.status = 1这条 SQL 里子查询先查出每个站点最新一条记录,再和站点表左连接。第一次写的时候容易写成直接JOIN t_temp_humidity,那样一个站点有几万条历史记录,接口一调用直接卡死。加LIMIT 1只能限制单站点,整个站点列表要取“每个站点最新一条”,必须用子查询或者窗口函数。
前端 ECharts 配置片段:
const option = { tooltip: { trigger: 'item', formatter: function(params) { return params.data.name + '<br/>温度:' + params.data.temperature + '℃ 湿度:' + params.data.humidity + '%'; } }, series: [{ type: 'scatter', coordinateSystem: 'cartesian2d', data: siteData.map(item => ({ name: item.siteName, value: [item.longitude, item.latitude], temperature: item.temperature, humidity: item.humidity })) }] };报表展示时,可以把temperature挂到visualMap上,让点颜色随温度从蓝变红,这是答辩时很加分的细节。实现思路是为每个数据点额外传temperature字段,ECharts 的visualMap组件会自动按数值映射颜色。
3.3 HTTP 协议爬取农业新闻:Jsoup 的实用边界
新闻爬虫模块是这套系统里“看起来不难但对细节抠得比较狠”的部分。用 HTTP 协议抓取农业新闻,实际工具是 Jsoup,核心就三步:连接目标页面、解析 HTML 提取标题和链接、保存到数据库。要注意的是,爬虫不是写一次就完事,目标网站换一次版式,你的选择器就失效了,所以必须把失败日志记录下来。
核心爬虫代码:
public void crawlNews() { String url = "http://www.agri.com.cn/news"; try { Document doc = Jsoup.connect(url) .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64)") .timeout(5000) .get(); Elements items = doc.select("div.news-list li a"); for (Element item : items) { String title = item.text(); String link = item.attr("href"); if (title.length() < 10) { continue; } News news = new News(); news.setTitle(title); news.setUrl(link); news.setSource("农业信息网"); newsService.saveIfNotExists(news); } } catch (Exception e) { log.error("抓取农业新闻失败", e); } }参数说明:timeout(5000)是连接和读取超时,这个必须显式指定,否则目标网站响应慢的时候,Tomcat 的线程会一直被阻塞。userAgent设置成标准浏览器头,能避开一部分基础反爬。title.length() < 10这个过滤条件是我加上去的,因为很多网站的新闻列表会把“更多”这种短链接混进去。存储时用saveIfNotExists根据 URL 唯一键判断,避免重复入库。
还有一点,抓新闻的频次要控制,我一般设定为每天两次,早上八点和下午五点,用 Spring Task 的@Scheduled(cron = "0 0 8 * * ?")触发。不要每分钟抓一次,会对目标站点造成压力,也会让你的服务器 IP 被封。另外,只抓标题和摘要,不抓全文,版权风险小。
3.4 温湿度数据分析:从 SQL 聚合到前端曲线
数据分析模块对应项目里的DataAnalysisServiceImpl。需求是把某个站点一段时间的温湿度按小时或天聚合,计算平均值和极值,然后画曲线。这一步最考验 SQL 能力,而不是 Java 代码。
Mapper 里的统计 SQL:
SELECT DATE_FORMAT(collect_time, '%Y-%m-%d %H:00') AS hour_point, AVG(temperature) AS avg_temp, AVG(humidity) AS avg_hum, MAX(temperature) AS max_temp, MIN(temperature) AS min_temp FROM t_temp_humidity WHERE site_id = #{siteId} AND collect_time BETWEEN #{start} AND #{end} GROUP BY DATE_FORMAT(collect_time, '%Y-%m-%d %H:00') ORDER BY hour_point逻辑说明:DATE_FORMAT把collect_time截整到小时,GROUP BY hour_point把同一个小时的记录聚成一行。这里没有用DISTINCT,因为GROUP BY本身就能去重。AVG函数对 DECIMAL 字段计算会保留两位小数。查询条件collect_time BETWEEN #{start} AND #{end}里,start和end传字符串,格式必须是yyyy-MM-dd HH:mm:ss,否则 MySQL 会隐式转换,虽然能用但容易有不必要的性能损耗。
Service 类可以这样写:
@Service public class DataAnalysisServiceImpl implements DataAnalysisService { @Autowired private DataAnalysisMapper dataAnalysisMapper; @Override public List<AnalysisResult> analyzeHourly(Long siteId, Date start, Date end) { List<AnalysisResult> list = dataAnalysisMapper.selectHourly(siteId, start, end); // 对空值做兜底,防止前端拿到 null list.forEach(item -> { if (item.getAvgTemp() == null) { item.setAvgTemp(BigDecimal.ZERO); } }); return list; } }这种聚合统计如果用 Java 内存算,就要把所有原始记录 load 出来,一个月可能上万条,逐条循环累加平均,复杂度高且容易出错。数据库聚合是压在最底层的数据操作,效率远高于业务层硬算。给数据分析接口加一个@ResponseBody,返回 JSON 数组,前端用 ECharts 的 line 图消费。区域选择器建议做成双日期选择框,默认最近 24 小时,避免用户一次查太长时间导致响应慢。
4. 权限模型与站点管理:SSM 中的角色控制和站点生命周期
4.1 RBAC 权限模型:从数据到拦截器
很多课程设计里的权限管理只做到“登录才能访问”,但“不同角色能访问的菜单不同”才是完整的权限控制。这套系统的数据库里维护了用户、角色、用户角色关联表,角色用编码区分,管理员ADMIN、普通用户USER、访客VISITOR。登录的时候把当前用户的角色列表放进 session,然后由 SpringMVC 拦截器统一判断请求 URL。
权限拦截器的核心代码:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String uri = request.getRequestURI(); if (uri.startsWith("/login") || uri.startsWith("/static")) { return true; } User user = (User) request.getSession().getAttribute("loginUser"); if (user == null) { response.sendRedirect("/login"); return false; } // 管理员请求 admin 前缀接口,普通用户请求 user 前缀接口 if (uri.startsWith("/admin") && !isAdmin(user)) { response.setStatus(403); return false; } return true; } private boolean isAdmin(User user) { return user.getRoles().stream() .anyMatch(role -> "ADMIN".equals(role.getRoleCode())); } }逻辑说明:拦截器preHandle返回 false 时,请求直接终止,不会进入 Controller。放行的路径有登录页和静态资源,其他请求必须先判断 session 里有没有loginUser。如果访问/admin开头接口而角色不是管理员,直接返回 403,前端拿到 403 后跳转到“无权限”页面。这个方案没有细粒度到按钮级权限,但对作物监控系统足够了。
注册拦截器要在springmvc.xml里配置:
<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/static/**"/> <bean class="com.example.crop.monitor.interceptor.AuthInterceptor"/> </mvc:interceptor> </mvc:interceptors>配置exclude-mapping是必要的,不然登录接口自己会被拦截,形成死循环。我当时第一次配忘写排除,结果启动后访问登录页直接 302 重定向到自己,浏览器疯狂刷新,最后查控制台日志才发现是拦截器把自己拦了。
4.2 站点创建:表单校验和事务边界
管理员创建站点,这个操作涉及两张表:先插入t_site拿到自增 id,再批量给传感器表写记录。这个跨表操作必须放在一个事务里,否则站点建好了,传感器绑定失败,就会留下一个没有传感器数据的死站点。
Controller 里先做参数校验:
@ResponseBody @RequestMapping(value = "/admin/site/add", method = RequestMethod.POST) public Result addSite(@RequestBody SiteAddRequest req) { if (StringUtils.isBlank(req.getSiteName())) { return Result.fail("站点名称不能为空"); } if (req.getLongitude() < -180 || req.getLongitude() > 180) { return Result.fail("经度超出范围"); } siteService.createSiteWithSensor(req); return Result.success(); }Service 层的创建方法:
@Transactional(rollbackFor = Exception.class) public Long createSiteWithSensor(SiteAddRequest req) { Site site = new Site(); site.setSiteName(req.getSiteName()); site.setLongitude(req.getLongitude()); site.setLatitude(req.getLatitude()); site.setStatus(1); siteMapper.insert(site); Long siteId = site.getId(); List<String> sensorCodes = req.getSensorCodes(); if (sensorCodes != null && !sensorCodes.isEmpty()) { for (String code : sensorCodes) { sensorMapper.bindSite(code, siteId); } } return siteId; }@Transactional(rollbackFor = Exception.class)这里要特别注意:Spring 默认只回滚RuntimeException,如果你的事务方法抛的是受检异常,比如IOException,它不会回滚。写成rollbackFor = Exception.class更稳妥。批量绑定传感器时,bindSite的 SQL 里要加上WHERE site_id = 0 AND sensor_code = #{code}这样的条件,防止把别人站点上已经激活的传感器抢过来。
站点删除我建议用逻辑删除,也就是把status置为 0,而不是物理DELETE。因为温湿度记录表里有外键引用传感器,传感器又挂在站点下,物理删除很容易碰到外键约束报错。逻辑删除后,地图模块只显示状态为正常的站点,数据留档,后续如果要审计也有迹可循。
4.3 站点状态与地图联动
站点的状态枚举可以定义成正常/停用/故障,状态除了前端地图气泡颜色不同,还会影响实时推送的数据源。停用的站点在MonitorService.getLatestRecord查询时要先过滤状态,不然停用的站点还在推数据,管理员会觉得逻辑混乱。前端拿到的站点列表里包含 status 字段,展示时用三元表达式映射颜色:
const statusColor = { 1: 'green', // 正常 0: 'gray', // 停用 2: 'red' // 故障 };这个状态字段在站点表中维护,故障状态一般由后台定时任务检测采集心跳自动更新,停用状态由管理员手动操作。站点创建时默认状态为 1,只有经人工确认硬件连接正常后才算真正启用。
5. 避坑与排查:SSM+MySQL 监控系统最常见的五个翻车现场
5.1 报错:Invalid bound statement (not found)
现象:项目启动正常,一调用某个 Mapper 方法,直接抛BindingException: Invalid bound statement (not found)。
原因:三个点,第一个是 MyBatis 的 Mapper XML 里namespace和接口的全限定名不一致;第二个是 XML 文件没有被 Maven 打包进target/classes;第三个是mybatis-config.xml里没有绑定这个 mapper。
解决:先看Mapper接口.java是从哪个包来的,比如com.example.crop.monitor.mapper.DataAnalysisMapper,XML 的namespace必须是这个全限定名。然后打开target/classes检查是否存在 XML,不存在就在pom.xml加资源配置:
<build> <resources> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> </includes> </resource> </resources> </build>上面的配置把src/main/java下所有 XML 文件复制到 classpath,这样 Maven 在清理和编译后 XML 不会被丢掉。我自己当时就是漏了这一条,在 IDEA 里能跑,mvn package后部署到 Linux 上就报错,排查半天才发现是资源打包策略问题。
5.2 中文乱码:页面、数据库、连接串三个位置
现象:前端页面上看到“???”,数据库表里的中文也成了乱码。
原因:三处不统一,第一页面没有声明 UTF-8,第二 MySQL 连接串缺少characterEncoding=utf8,第三表本身的字符集不是 utf8mb4。
解决:第一,在web.xml加一个全局字符编码过滤器:
<filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> <init-param> <param-name>forceEncoding</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>第二,jdbc.properties的 URL 后面加上useUnicode=true&characterEncoding=utf8。第三,建表时显式写DEFAULT CHARSET=utf8mb4,不要依赖数据库默认字符集。如果你用的是 MySQL 5.6 以下版本,utf8mb4 的索引长度可能会超限,建议直接上 MySQL 5.7 以上的版本,这也是为什么我每次看到安装教程都建议默认选 5.7 或 8.0。
5.3 实时推送页面不刷新
现象:数据库里温湿度数据已经变了,前端页面一直停在上一次的值,必须手动刷新浏览器才更新。
原因:Ajax GET 请求被浏览器或者本地代理缓存了,同一个 URL 不带任何变化参数时,浏览器会直接取缓存,根本不会发新请求。
解决:最简单是在请求 URL 后加一个当前时间戳,也就是前面 3.1 节代码里的&t=Date.now()。更彻底的做法是在响应头里关闭缓存,在后端 Controller 里设置:
response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate");如果你用了 Spring 的@ControllerAdvice,可以统一处理。还有一种做法是把请求改成 POST,POST 默认不会被缓存,但语义上不匹配,我一般不会为了避开缓存强行改 GET 为 POST。排查时可以用 DevTools 的 Network 面板看请求是否发出,这样一秒就知道是缓存还是后端数据问题。
5.4 爬虫把线程池撑爆
现象:爬虫定时任务运行到一半,Tomcat 的所有线程被占满,整个 Web 应用假死,error.log里全是ConnectTimeoutException。
原因:爬虫没有设置连接和读取超时,目标网站响应慢的时候,一个线程就阻塞 30 秒以上,几十个请求同时堆积,Tomcat 的线程池被耗尽。
解决:第一,Jsoup 设置.timeout(5000),固定 5 秒,超过就丢弃。第二,给爬虫单独配置一个线程池,最大线程数控制在 2,不要和 Web 请求抢线程:
ExecutorService crawlExecutor = Executors.newFixedThreadPool(2);第三,抓取失败必须写error.log,并且下一次定时任务开始前先检查上一次是否还在执行,用AtomicBoolean避免任务重入:
private AtomicBoolean running = new AtomicBoolean(false); @Scheduled(cron = "0 0 8 * * ?") public void scheduledCrawl() { if (!running.compareAndSet(false, true)) { log.warn("上一次抓取未结束,跳过本次任务"); return; } try { crawlNews(); } finally { running.set(false); } }这样即使目标网站上线很慢,也只会占用爬虫线程池里的两个线程,不会拖垮处理真实业务请求的 Tomcat 线程池。项目资源里的log.log.2019-04-17和error.log.2019-04-17这类按天滚动的日志,遇到这个问题时就是救命稻草,能直接看到异常堆栈发生的时间点。
5.5 数据库连接被耗尽
现象:系统运行几个小时后,开始随机报Connection is not available, request timed out,重启后恢复正常,过一段时间又出现。
原因:连接池配置太小,加上某些 DAO 方法在长事务里打开了连接没及时释放,比如@Transactional加在了getxxx方法上,事务迟迟不提交,连接一直被占用。
解决:第一,调大连接池上限,给到 50,同时设置maxLifetime为 30 分钟,让 MySQL 主动断开空闲连接后能及时回收。第二,检查 Service 层的@Transactional位置,查询方法不需要事务的就不要加,只需要在写入或更新方法上加。第三,排查代码里是否有手动关闭资源的遗漏,尤其在使用原生JdbcTemplate或者SqlSession时,要用try-with-resources保证释放。
我一般会在压测阶段直接用 JMeter 模拟 200 个并发请求打数据分析接口,看看连接池是否稳定。如果测 10 分钟就报连接耗尽,先把连接池调大,再逐段看日志定位是哪个 Service 的哪条 SQL 慢。很多时候不是连接不够,而是某条查询没加索引,导致 MySQL 扫描全表,每条请求都占着连接 2 秒以上。
6. 进阶技巧与验证:从“能跑”到“好用”的六个检查点
项目跑通不代表真的能交付,我每次接手这类 SSM 监控系统,都会至少做一遍下面的验证和调优:
第一,接口冒烟验证。用 Postman 或者 curl 逐个测这几个接口:登录、获取站点列表、获取单站点最新数据、查询一段时间温湿度统计、新增站点。每个接口都看返回码和 JSON 结构,不能只看页面能打开就完事。重点确认未登录时请求受保护接口返回 302 或 403,而不是放行。
第二,实时推送的“双浏览器验证”。开两个浏览器窗口,一个在站点管理页手动修改某站点的温湿度模拟值,另一个看首页实时值是否在 3 秒内变化。如果超过 10 秒没变,检查轮询 URL 的缓存问题和后端查询是否真的查到了新数据。
第三,数据分析准确性验证。用 SQL 手查一遍某天某个站点的平均温度和最大温度,和前端曲线图对比,误差要控制在两位小数以内。这个检查能发现DATE_FORMAT时区问题,也能发现数据库时间字段是否被错误地存成字符串。
第四,数据库索引检查。t_temp_humidity表数据量到 10 万条之后,执行EXPLAIN SELECT ... WHERE site_id = 1 AND collect_time BETWEEN ...,确认走的是idx_th_site_time联合索引。如果走了全表扫描,查询时间会从毫秒级上升到秒级,这时候要在采集软件侧同步加索引,或者对表做按月分区。
第五,日志按天滚动验证。项目里的日志文件名是log.log.2019-04-17这种格式,说明日志框架配置了按天滚动。部署时确认logback.xml的rollingPolicy是TimeBasedRollingPolicy,并且maxHistory设置为 30,否则磁盘会被日志塞满。error.log 单独一个文件方便排错,我习惯每天看一眼 error 级别的记录,确认没有连续重复的数据库异常。
第六,JVM 参数与重启策略。Tomcat 内存分配一般给-Xms256m -Xmx512m,服务器是 2G 内存的话足够。不要盲目加大堆内存,因为监控系统主要瓶颈在数据库,不在 JVM 堆。用jstat -gcutil <pid> 1000看 GC 频率,如果 YGC 间隔太短,优先检查是否在循环里创建了过多对象。
最后说个我自己的教训:以前做课程设计,从来没验证过权限“拦不拦得住”,结果答辩时老师用普通账号直接访问了管理员接口,页面还真的返回了数据,当场翻车。从那以后,我每次部署这套系统,都会把管理员、普通用户、访客三个账号分别跑一遍,确认不该进的页面进不去。这套基于 SSM 和 MySQL 的作物生长监控系统,资源包里带的源码、SQL 脚本和按天日志样本都很完整,照着上面的步骤把数据库密码改成你自己的,很快就能跑起来,希望帮到你。
本文还有配套的精品资源,点击获取