
简介采用Java语言并结合Servlet、JSP、MySQL和JDBC技术实现的Web新闻发布系统项目资料包面向Java Web初学者以及课程设计、毕业设计人群主要解决新闻内容展示、后台管理、数据持久化等实际问题。资源按照MVC设计思路组织覆盖用户登录认证、新闻发布、编辑删除、图片上传等典型功能模块可以清晰看到页面、控制器、业务模型之间的协作关系。压缩包为ZIP格式共269个文件大小6.13MB。其中56个Java源文件与112个编译后的class文件构成后端处理逻辑50个JSP页面负责动态视图展示同时包含PNG、GIF、JPG等图片素材以及CSS样式、SQL数据库脚本、XML配置、jar依赖库等导入开发工具后即可构建运行或二次开发。目前已有3433人学习/下载。资料提供完整项目源码、数据库建表脚本、公共DAO封装、新闻处理Servlet、操作控制器以及页面样式素材。读者可以据此理解JDBC连接MySQL数据库、使用预编译语句防止SQL注入、通过会话机制跟踪登录状态并体会从浏览器请求到Servlet处理、再到JSP响应的完整MVC流程便于在此基础上扩展新功能或进行重构。1. JavaServletJSPMySQL 写新闻发布系统入门简单但坑一个不少JavaServletJSPMySQL 新闻发布系统在很多人眼里是课程设计但它恰恰是 Java Web 世界里最不加掩饰的一条链路请求怎么进来、Servlet 在哪一步接手、JSP 怎么把数据渲染成页面、MySQL 怎么落库每一步都摆在明面上。做一遍这个组合比背十道 java面试题 都有用。新闻发布系统是这个组合最典型的载体。前台有列表、详情、分页后台有登录、发布、编辑CRUD、表单、会话管理这些考点一个不落。它不依赖 Spring Boot 帮你把细节藏起来每一个环节都得自己写、自己查、自己调。这篇笔记按我实际做新闻站点的顺序写先定选型和表结构再配环境然后从 DAO 写到 Servlet 再到 JSP最后交代常见翻车现场和上线验证。新手能照着跑通熟手能少踩几个坑。2. 技术选型与架构分工Servlet/JSP 不是老古董是理解 Java Web 的最短路径2.1 一次新闻请求的完整旅程Servlet、JSP、MySQL 各自的位置我先用一条实际的请求把角色讲清楚。用户在浏览器地址栏输入/news/list回车Tomcat 收到这个 HTTP 请求后根据项目的部署描述或者注解里声明的映射关系找到对应的 NewsListServlet。这是 Servlet 容器在替你完成的工作你在代码里只负责在WebServlet注解上把路径写对。Servlet 是单例的。Tomcat 第一次访问到 NewsListServlet 时创建实例并调用 init之后所有请求都复用同一个实例由容器分配线程去执行 service 方法service 再按请求方法分发给 doGet 或 doPost。这就是 java面试题 里反复问的 Servlet 生命周期init 一次、service 每次请求一次、destroy 在容器关闭时一次。因为这个模型Servlet 里的成员变量是所有请求共享的我一般只放无状态的 DAO 对象绝对不把某个用户的数据存成成员变量否则高并发下数据就是串的。Servlet 拿到请求后调用 DAO 层查 MySQL得到 ListNews把这个列表塞进 request 属性再 forward 给 JSP。JSP 在 Tomcat 里本质上也是一个 Servlet它第一次被访问时编译成 Java 类之后直接执行。JSP 的作用就是把 Java 拿到的数据渲染成 HTML 字符串返回浏览器。到这里一次新闻列表请求的完整路径是浏览器 → Tomcat → NewsListServlet → NewsDao → MySQL → 返回 List → JSP 渲染 → 浏览器。这个链路里Servlet 是控制层JSP 是视图层DAO 是数据访问层MySQL 是存储层。没有框架帮你自动装参数、自动映射实体、自动管理事务每一步手动写。看起来啰嗦但你能看到每一环的输入输出这也是为什么我不建议一上来就用 Spring Boot MyBatis——框架封装的便利恰恰掩盖了这些本该理解的东西。等你自己把 Servlet 这条链路走通了再去看 MyBatis 的映射和 Spring MVC 的 DispatcherServlet会瞬间明白它们到底替你干了什么。2.2 为什么新闻列表用 JSP 渲染而不是前端框架做技术选型的时候有人提议新闻列表和详情页用 Vue 做前后端分离。新闻发布系统的前台页面有一个硬需求是 SEOJSP 在服务端把数据和 HTML 拼好再返回浏览器拿到的直接是完整文档搜索引擎的爬虫不需要执行 JavaScript 就能收录。Vue 这种客户端渲染方案初始 HTML 是空壳内容要等 JS 跑起来才有爬虫如果不渲染 JS收录就是零。这是新闻站和后台管理系统最本质的区别后台用 Vue 没问题前台公开页面我坚持用 JSP。JSP 的代价也很明确它把 HTML 和 Java 逻辑放在同一个文件里一旦写成满屏 scriptlet维护就是灾难。我的约束是 JSP 里不写% %脚本块只用 EL 表达式和 JSTL 标签所有数据在 Servlet 里准备好JSP 只负责读。这样 JSP 退化成一个模板文件职责清楚前端同事改样式也不会碰坏逻辑。还有一点容易被忽略新闻详情页是典型的读多写少场景服务端渲染比客户端渲染省一次接口请求对服务器压力也更友好。真要上 Vue 也不是不行但在这个项目体量下引入构建工具、跨域配置、前端路由这些复杂度性价比很低。等技术栈里已经有前后端分离的基础设施时再迁不迟。2.3 数据库设计新闻表、分类表、用户表的建表 SQL 与字段取舍数据库是新闻系统最早要定下来的东西改表结构的成本远高于改代码。我给一个最小但完整的建表 SQLCREATE DATABASE IF NOT EXISTS news_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE news_db; CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL UNIQUE, sort_order INT DEFAULT 0 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE news ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, summary VARCHAR(500), content TEXT, category_id INT NOT NULL, author VARCHAR(50), publish_time DATETIME, views INT DEFAULT 0, INDEX idx_category (category_id), INDEX idx_publish_time (publish_time), CONSTRAINT fk_news_category FOREIGN KEY (category_id) REFERENCES category(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(50) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;三张表的分工很清晰category 管分类news 管新闻主体user 管后台账号。它们的核心字段和索引对应关系如下表关键字段索引说明categoryid, name, sort_ordername 唯一分类表后台维护newsid, title, summary, content, category_id, author, publish_time, viewscategory_id, publish_time新闻主体userid, username, password, real_nameusername 唯一后台账号news 表里有几个字段是特意这样设计的。summary 单独存是为了列表页只查 title 和 summary 这两三百个字符不用把 content 这种大文本字段也拖出来否则列表查询的 IO 和网络传输都会明显变慢。publish_time 用 DATETIME 而不是 TIMESTAMP因为 TIMESTAMP 有 2038 年的上限新闻系统这种要长期维护的表没必要赌这个边界。views 字段给一个 INT 默认 0详情页每次访问做一次 UPDATE views views 1这就是一个新闻热度的雏形。索引和关联上idx_publish_time 专门服务列表页的ORDER BY publish_time DESC排序这是 mysql排序 最常见的热点查询没有这个索引数据一多就会走 filesort。分类和新闻之间我加了外键这个体量下外键的检查开销可以忽略但它能防止后端代码逻辑出 bug 时把新闻挂到一个不存在的分类上。真到了需要分库分表的规模再去掉外键不迟。user 表里 password 字段我留了 100 长度是因为不能直接存明文。最小可行做法是 MD5(username password) 再入库别只哈希密码本身否则相同密码的用户哈希值一样一看就知道这是一批弱口令账号。真正生产环境用 bcrypt 这类带盐的算法这里不展开。登录逻辑用 Session 保存 loginUser后台发布接口每次先取 Session 校验身份这个动作在第 4 章的代码里会看到完整写法。3. 环境与项目骨架一次配好 JDK、Tomcat、MySQL 与 Maven3.1 版本兼容矩阵JDK 8、Tomcat 9、MySQL 5.7 是最省心的组合每个初学者看 mysql安装教程 装完数据库、又按 java环境配置 配好 JDK然后兴冲冲写第一行 JDBC结果被版本组合教育。版本选择是新闻系统第一个真正的坑。我推荐保守组合JDK 1.8 Tomcat 9.0.x MySQL 5.7比如 5.7.44 Maven 3.6 以上。组件保守组合替代组合注意点JDK1.817JDK 17 编译的 class 要配合支持它的 TomcatTomcat9.0.x10.1.xTomcat 10 包名换成 jakarta.servlet老代码全编译不过MySQL5.78.0MySQL 8 必须驱动 8.x且连接串要加 serverTimezoneMaven3.63.9只有 mvn 命令能打 war 包IDE 的 Build 不算为什么保守组合最省心Tomcat 9 用的还是 javax.servlet 包名网上绝大多数 servlet demo 和教材都是基于这个包名写的你复制过来的代码直接能编译。JDK 1.8 虽然老了但 Tomcat 9 官方支持列表里它依然被完整支持而且编译出来的字节码性能对这个项目完全够用。如果你的机器上已经装了 JDK 17 不想动那配套要选 Tomcat 10.1同时所有 import javax.servlet.* 要改成 import jakarta.servlet.*JSTL 也要换成 Jakarta 版本等于给所有参考代码做一次批量替换新手很容易在这一步放弃。MySQL 5.7 和 8.0 的选择上我倾向于 5.7 的原因只有一个驱动 mysql-connector-java 5.1.49 和它配合多年稳定到几乎没有意外。MySQL 8.0 功能更多但第一次见面会让你处理 caching_sha2_password 认证插件和时区参数这些细节留给第二轮优化再接触。3.2 用 Maven 建 war 包项目pom.xml 最小配置与目录结构Maven 的作用是把编译、依赖、打包固化成一条命令。我不建议新手在这个项目里用 IDE 自带的 Artifact 打包war 包的手工配置在每台机器上都不一样Maven 的 pom.xml 写一次到处跑。先看最小配置project xmlnshttp://maven.apache.org/POM/4.0.0 modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdnews-web/artifactId version1.0.0/version packagingwar/packaging properties maven.compiler.source1.8/maven.compiler.source maven.compiler.target1.8/maven.compiler.target project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties dependencies dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version5.1.49/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.20/version /dependency /dependencies build finalNamenews-web/finalName /build /projectpom.xml 里有三个容易理解错的地方。servlet-api 的 scope 是 provided意思是编译需要它但打包时不要带进 war因为 Tomcat 自己带了一份一旦不带 providedwar 里就会塞进 servlet-api.jar运行时和 Tomcat 自带的冲突报出各种诡异的 ClassCastException。jstl 1.2 则相反必须打进 warTomcat 默认不带 JSTL 实现。mysql 驱动和 druid 是运行时依赖正常打进 war 的 WEB-INF/lib。依赖配好后目录结构固定成 Maven 的标准布局news-web/ ├── pom.xml └── src/main/ ├── java/com/example/news/ │ ├── entity/ News.java, Category.java, User.java │ ├── dao/ NewsDao.java, CategoryDao.java, UserDao.java │ ├── web/ NewsListServlet.java, NewsDetailServlet.java, │ │ NewsAddServlet.java, LoginServlet.java │ └── util/ DBUtil.java └── webapp/ ├── WEB-INF/web.xml ├── WEB-INF/jsp/ news_list.jsp, news_detail.jsp └── login.jsp这个 web项目 的目录里有一个特意安排公开的 JSP 页面放在 WEB-INF 下。WEB-INF 里的资源客户端不能通过 URL 直接访问只能由 Servlet forward 进去这样用户想看哪个新闻必须先经过 NewsDetailServlet参数校验和访问计数都挡在入口处。web.xml 在 Servlet 3.0 时代可以做到很薄因为 Servlet 用注解声明路径。我通常只保留一个空壳 web.xml 声明版本号web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 display-namenews-web/display-name /web-app注意这个文件里不能出现metadata-completetrue否则 Tomcat 认为部署描述是全量的会跳过注解扫描你所有 WebServlet 全部失效页面全 404。这个细节在第 5 章排查里还能再见一次。3.3 数据库连接池选型与参数Druid 的四个必调参数直接在每个 DAO 里 Class.forName DriverManager.getConnection 的方式在课程设计里能跑但新闻系统一上线就暴雷每次请求都新建物理连接MySQL 默认同时只放 151 个连接请求一多直接报 Too many connections。连接池的本质是连接复用把建连这件事从每次请求里挪出来。我一般用 Druid自带监控和慢 SQL 统计对 ServletJSP 这种轻量项目用不着上 HikariCP 的极限性能。最小的 DBUtil 写法package com.example.news.util; import com.alibaba.druid.pool.DruidDataSource; import java.sql.Connection; import java.sql.SQLException; public class DBUtil { private static final DruidDataSource dataSource new DruidDataSource(); static { dataSource.setUrl(jdbc:mysql://localhost:3306/news_db ?useUnicodetruecharacterEncodingutf8 useSSLfalseserverTimezoneAsia/Shanghai); dataSource.setUsername(root); dataSource.setPassword(123456); dataSource.setInitialSize(3); dataSource.setMinIdle(3); dataSource.setMaxActive(20); dataSource.setMaxWait(3000); dataSource.setValidationQuery(SELECT 1); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }四个参数按新闻系统的访问量调就可以。initialSize 是启动时预建的连接数设 3 避免第一个请求慢minIdle 是最小空闲连接一般和 initialSize 一致maxActive 是上限20 对这个项目足够设太大会让 MySQL 连接数被占满maxWait 是拿不到连接时最多等 3 秒超过就抛异常避免请求无限挂起。validationQuery 配 SELECT 1让连接池在把连接交给业务前探测一下防止取到一条已经被数据库踢掉的死连接。连接串里 useUnicodetruecharacterEncodingutf8 解决中文写入乱码useSSLfalse 关掉 MySQL 8 默认的 SSL 握手serverTimezoneAsia/Shanghai 解决 MySQL 8 的时区报错。这三个参数在 MySQL 5.7 下可有可无但写上以后换 MySQL 8 不用改代码属于一次性投资。4. 从 DAO 到页面新闻列表、详情与后台发布的完整代码4.1 实体类与 DAO 层JDBC 查询与连接释放的正确姿势实体类的字段和 news 表对齐再加一个不属于表的 categoryName用于列表页直接显示分类名避免每行新闻都再查一次分类表。实体类只放数据不写业务package com.example.news.entity; import java.util.Date; public class News { private int id; private String title; private String summary; private String content; private int categoryId; private String categoryName; private String author; private Date publishTime; private int views; // 省略 getter/setterIDE 一键生成即可 }DAO 层的职责是拿数据不掺业务判断。列表查询用一条 LEFT JOIN 把分类名带出来而不是查出 news 后再循环查 category这是新手最容易写出 N1 查询的地方package com.example.news.dao; import com.example.news.entity.News; import com.example.news.util.DBUtil; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.List; public class NewsDao { public ListNews findLatest(int offset, int size) { String sql SELECT n.id, n.title, n.summary, n.category_id, c.name AS category_name, n.author, n.publish_time, n.views FROM news n LEFT JOIN category c ON n.category_id c.id ORDER BY n.publish_time DESC LIMIT ?, ?; ListNews list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, offset); ps.setInt(2, size); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { News news new News(); news.setId(rs.getInt(id)); news.setTitle(rs.getString(title)); news.setSummary(rs.getString(summary)); news.setCategoryId(rs.getInt(category_id)); news.setCategoryName(rs.getString(category_name)); news.setAuthor(rs.getString(author)); news.setPublishTime(rs.getTimestamp(publish_time)); news.setViews(rs.getInt(views)); list.add(news); } } } catch (SQLException e) { throw new RuntimeException(查询新闻列表失败, e); } return list; } }这里用 Java 7 的 try-with-resourcesConnection、PreparedStatement、ResultSet 全部在 try 块结束后自动关闭。手动写 finally 去 close 不是不行但忘关 ResultSet 的概率很高连接池的连接一旦被业务代码占用不还池子耗尽只是时间问题。SQL 里ORDER BY publish_time DESC对应第 2 章建的 idx_publish_time 索引LIMIT ?, ? 的两个参数由 Servlet 层算好传进来offset 是起始行size 是本页条数。实体类字段只映射查询需要的列content 这种大字段在列表查询里被故意排除这是第 2 章字段取舍在代码层面的落实。rs.getTimestamp(publish_time)取 DATETIME 列返回 java.sql.Timestamp赋给实体里 java.util.Date 属性没有问题Timestamp 是 Date 的子类多态赋值天然成立。4.2 Servlet 控制层接收请求、转发视图的固定套路Servlet 在新闻系统里的模式非常固定取参数 → 调 DAO → 塞数据 → 转发。以新闻列表为例package com.example.news.web; import com.example.news.dao.NewsDao; import com.example.news.entity.News; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.List; WebServlet(/news/list) public class NewsListServlet extends HttpServlet { private final NewsDao newsDao new NewsDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String pageStr req.getParameter(page); int page 1; if (pageStr ! null !pageStr.isEmpty()) { try { page Integer.parseInt(pageStr); } catch (NumberFormatException e) { page 1; } } int pageSize 10; int offset (page - 1) * pageSize; ListNews newsList newsDao.findLatest(offset, pageSize); int total newsDao.countAll(); int totalPages (total pageSize - 1) / pageSize; req.setAttribute(newsList, newsList); req.setAttribute(currentPage, page); req.setAttribute(totalPages, totalPages); req.getRequestDispatcher(/WEB-INF/jsp/news_list.jsp).forward(req, resp); } }这个 servlet demo 虽然短但有两个值得记住的细节。getParameter 返回的一律是 String转 int 必须处理 NumberFormatException用户传?pageabc时不能直接 500兜底回第一页是成本最低的处理。(total pageSize - 1) / pageSize是向上取整的经典写法避免 total 正好被 pageSize 整除时多算一页。forward 和 sendRedirect 的区别是这个项目的分水岭forward 是服务器内部转跳浏览器 URL 不变适合列表和详情这种 GET 请求增删改操作成功后必须用 sendRedirect否则用户一刷新浏览器把刚才的 POST 请求再发一次新闻就重复入库了。注意forward 之后浏览器地址栏不变用户刷新会重新执行 doGet这是无害的但 POST 后如果 forward 而不是 sendRedirect刷新就会重复提交表单务必用重定向收尾。后台发布对应 doPost处理顺序有讲究setCharacterEncoding 必须在第一行然后取参数、校验会话再落库WebServlet(/admin/news/add) public class NewsAddServlet extends HttpServlet { private final NewsDao newsDao new NewsDao(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); // 校验登录状态 if (req.getSession().getAttribute(loginUser) null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } String title req.getParameter(title); String content req.getParameter(content); int categoryId Integer.parseInt(req.getParameter(categoryId)); String author ((User) req.getSession().getAttribute(loginUser)).getUsername(); newsDao.insert(title, content, categoryId, author); resp.sendRedirect(req.getContextPath() /news/list); } }登录校验放在参数读取之前是为了让未登录请求尽早返回不白做参数解析和落库。author 不从表单里取从 Session 里取防止前台伪造一个 author 参数覆盖真实作者这类越权问题在新闻后台很常见。req.getContextPath()返回部署上下文名 /news-web重定向带上它项目换名字时链接不会断。4.3 JSP 视图层JSTL 遍历列表与详情页的内容输出JSP 页面不写 java 代码只做渲染。列表页用 JSTL 的 c:forEach 遍历 Servlet 塞进来的 newsList% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % html headtitle新闻列表/title/head body c:forEach varnews items${newsList} div classnews-item h2 a href${pageContext.request.contextPath}/news/detail?id${news.id} ${news.title} /a /h2 p${news.summary}/p span${news.categoryName}/span span${news.author}/span spanfmt:formatDate value${news.publishTime} patternyyyy-MM-dd HH:mm//span /div /c:forEach div classpagination c:forEach vari begin1 end${totalPages} a href${pageContext.request.contextPath}/news/list?page${i}${i}/a /c:forEach /div /body /html两个 taglib 前缀是 JSTL 1.2 的标准写法。fmt 标签负责把 java.util.Date 格式化成用户看得懂的时间字符串${news.views}这个点击量可以放在标题行右侧体现新闻热度。链接里用pageContext.request.contextPath拼上下文路径部署到任何路径下链接都不会写死。页脚的 c:forEach 从 1 循环到 totalPages生成数字页码这是最简分页。详情页有一个容易踩的渲染点新闻正文存的是富文本里面带 HTML 标签。用${news.content}输出会原样渲染出 HTML这是富文本功能需要的但如果是用户投稿后台没有清洗 HTML就存在 XSS 注入风险。我的习惯是后台发布时用白名单过滤 script 标签前台用一个工具方法把正文里的script标签转义后再输出。新闻系统这种公开页面安全不是可选项等被扫出漏洞再补就被动了。5. 避坑手册新闻系统从 404 到中文乱码的五个排查记录下面五条都是我做这个项目时真实翻过车的记录按现象、原因、解决三步写可以直接当排查手册用。5.1 部署后访问 /news/list 一直 404现象Tomcat 启动日志干净访问部署的上下文根路径能看到欢迎页但访问 /news/list 就是 404。原因代码里明明写了WebServlet(/news/list)注解却没生效。最常见的有两种web.xml 里写了metadata-completetrue告诉 Tomcat 部署描述是全量的别去扫描注解另一种是 JSP 里写的链接是 /newsList和注解路径差一个斜杠。解决打开 web.xml确认没有 metadata-complete 属性或者干脆删掉 web.xml 只靠注解。排查链接问题时用浏览器开发者工具看 Network 面板里实际请求的 URL和注解的 value 逐一比对大小写和分隔符都要一致路径里多一个少一个斜杠都不行。这是低成本但高回报的习惯能帮你把注解和映射这两个概念彻底分清。5.2 新闻标题在页面显示成 ??现象数据库里 title 字段用 Navicat 看是正常中文浏览器页面上却是一排问号。原因编码不一致而且这个不一致可能发生在三个地方。JDBC 连接串没带 characterEncodingutf8数据从 MySQL 出来已经乱码JSP 页面顶部没写 UTF-8 的 page 指令Tomcat 按默认编码输出最隐蔽的是建表时用了 latin1数据本身入库就是错的页面再怎么调都是白费。解决检查顺序是表字符集 → 连接串 → JSP page 指令。表和库统一 utf8mb4连接串带 useUnicodetruecharacterEncodingutf8JSP 页面第一行写 contentTypetext/html;charsetUTF-8。建库建表时字符集这种决定一旦落地数据进去之后再想改就是血泪工程没有后悔药所以建库建表时宁可多写一句 DEFAULT CHARSETutf8mb4也不要依赖默认值。5.3 数据库连接失败Access denied 或时区报错现象项目启动时 Druid 初始化连接失败控制台报 java.sql.SQLException: Access denied for user rootlocalhost或者报 The server time zone value ... is unrecognized。原因Access denied 有一种特殊情况是 MySQL 8 默认创建的用户用的是 caching_sha2_password 认证插件旧版驱动不支持时区报错是 MySQL 8 要求连接串显式声明时区系统默认时区名带中文驱动解析不了。这两个问题都和密码本身无关容易被误判成账号密码输错。解决两个问题都在连接串上补齐参数。useSSLfalse 关掉 SSL 握手serverTimezoneAsia/Shanghai 指定时区。驱动版本和 MySQL 大版本必须对应MySQL 5.7 用 5.1.49MySQL 8 用 8.0.x版本错配的表现往往是启动正常、第一次请求才报错这种延迟暴露最消磨耐心。排查时先确认驱动 jar 的版本再用命令行客户端直连数据库排除账号问题一层层缩小范围。5.4 发布新闻后 content 丢失或乱码现象后台发布一篇带中文正文的新闻数据库里 title 正常content 却是空串或者乱码。原因req.setCharacterEncoding(UTF-8) 放错了位置。Tomcat 8 对 POST 表单默认按 ISO-8859-1 解析setCharacterEncoding 必须在第一次调用 getParameter 之前执行一旦参数被读过一次编码就固定了之后再调用没有任何作用。这是编码问题里最典型的顺序坑代码看起来一模一样位置差一行结果天差地别。解决doPost 方法体第一行就写 req.setCharacterEncoding(UTF-8)不给任何参数读取的机会。同时响应侧在 JSP 页面指令里声明 UTF-8两步走才能保证请求和响应都是 UTF-8。这条规则我后来用到所有项目里不管是 Servlet 还是 Spring MVC字符编码过滤器永远是最早执行的顺序错了一切白搭。5.5 JSP 报错 The absolute uri cannot be resolved for tag library现象页面打开是 500catalina.out 里报 org.apache.jasper.JasperException: The absolute uri cannot be resolved for tag library with uri http://java.sun.com/jsp/jstl/core。原因Tomcat 不自带 JSTL 实现必须把 jstl 依赖打进 war。常见两个翻车点pom 里给 jstl 加了 scope provided导致打包后 WEB-INF/lib 里没有 jstl 的 jar或者换了 Tomcat 10 用 jakarta 包名却还在引用 javax 版 JSTL两边的 taglib URI 对不上。解决回到 pom.xml把 jstl 1.2 的 scope 去掉重新打包部署。如果坚持用 Tomcat 10改用 org.glassfish.web:jakarta.servlet.jsp.jstl 并把 taglib uri 换成对应的 jakarta 命名空间然后 JSP 里所有引用一起改。最省事的路径还是回到 Tomcat 9这个项目的技术栈本身就不需要 Tomcat 10 的新特性不要在版本迁移上浪费工时。6. 上线验证分页、SQL 注入防护与部署检查清单6.1 分页参数计算LIMIT offset, size 与页脚按钮的对应关系分页逻辑就一个公式offset (page - 1) * pageSize。page 从 1 开始pageSize 固定 10则第 1 页查 LIMIT 0,10第 2 页查 LIMIT 10,10页码offsetMySQL LIMIT10LIMIT 0, 10210LIMIT 10, 10320LIMIT 20, 10页脚除了数字页码要加“上一页”“下一页”上一页在 currentPage 为 1 时禁用下一页在等于 totalPages 时禁用这个边界条件漏一个翻页体验就很别扭。6.2 PreparedStatement 防注入拼接 SQL 的代价不止是安全新闻后台的搜索框是安全扫描工具最爱光顾的入口。字符串拼 SQL 的错误写法会把用户输入直接当成 SQL 执行// 错误示范直接把参数拼进 SQL String sql SELECT * FROM news WHERE title LIKE % keyword %;用户传一个 OR 11 --进去SQL 就变成了条件恒真的查全表这是 SQL 注入的经典原理。PreparedStatement 把 SQL 模板和参数分两次发给 MySQL参数永远作为数据解析// 正确做法占位符 ? 绑定参数 String sql SELECT * FROM news WHERE title LIKE ?; PreparedStatement ps conn.prepareStatement(sql); ps.setString(1, % keyword %);用占位符不只是防注入。SQL 模板文本固定不变MySQL 服务端可以直接复用上次编译的执行计划而拼接写法每次 SQL 文本都不同缓存全部失效。新闻系统投稿量上来之后搜索和列表都是热点这一条能省不少 CPU。6.3 部署 Tomcat 的验证清单与一条重启习惯war 包mvn clean package之后放到 webapps 目录启动 Tomcat 按清单过一遍访问 /news-web/news/list 看列表点进详情页看正文渲染和浏览量 1发布一篇标题、正文都带中文和 emoji 的测试新闻确认入库不乱码翻到第二页确认分页链接带 page2最后手工往数据库插一条旧数据验证排序还是按 publish_time 倒序。我的个人习惯是每次改完 JSP重启前把 Tomcat 的 work/Catalina/localhost/news-web 目录清掉。JSP 是编译成 class 再执行的服务器经常没来得及重新编译就对外服务页面表现是“我改了怎么不生效”。这其实不是玄学是 JSP 编译缓存和浏览器缓存叠出来的假象清一次 work 目录能省半小时排查时间。项目跑通之后再回去看 Servlet 生命周期、会话过期策略、Druid 监控面板你会比直接学 Spring Boot 的同学多一层底气。希望帮到你。本文还有配套的精品资源点击获取