1. 项目缘起与整体设计思路这个项目说起来挺有意思最早是我在帮一个做企业内训的朋友整理学员档案时碰到的需求。他们之前用的是一个单机版的小工具数据存在本地文件里几个人同时用就各种冲突导出个名单还得手动复制粘贴。后来找到我说能不能搞一个简单的Web端用户信息列表展示系统要求不高能录入、能查看、能搜索、能分页最好还能导出。预算有限服务器就是一台普通的云主机环境是现成的Tomcat。我当时第一反应是用Spring Boot一把梭但仔细想了想这个场景其实没必要上那么重的框架。一来服务器配置一般二来维护的人对Spring生态不熟出了问题排查成本高。最后我选了ServletJSPMySQL这套经典组合但做了一个关键调整——前后端分离。注意这里的“前后端分离”不是指现在流行的Vue/React那一套而是指JSP只负责页面骨架渲染数据交互全部走Servlet提供的接口通过AJAX异步获取。这样既保留了JSP上手快的优势又避免了传统JSP里嵌一堆Java代码的混乱局面。为什么这么设计我解释一下背后的考量。传统JSP开发模式里页面里经常混着% %脚本片段、JDBC查询、HTML标签一个文件几百行改一个字段要翻半天。而纯前后端分离比如后端只出JSON前端用Vue对这个项目来说又太重了引入Node构建工具链、跨域配置、打包部署维护成本反而上去了。折中方案就是JSP作为视图模板只输出HTML结构和少量初始化数据所有业务数据通过fetch或XMLHttpRequest调用Servlet接口获取Servlet返回JSON。这样职责清晰调试也方便——打开浏览器控制台就能看到每个请求的返回。技术选型上我列一下具体的版本和理由技术组件版本选择选型理由JDK1.8服务器上已有环境兼容性最好Tomcat 8.5对1.8支持稳定Tomcat8.5轻量启动快对Servlet 3.1规范支持完整MySQL5.7朋友服务器上已安装InnoDB引擎支持事务utf8mb4字符集Servlet3.1支持注解配置WebServlet省去web.xml繁琐配置JSP2.3配合JSTL标签库做页面渲染前端库原生JS Bootstrap 4不引入构建工具CDN直接引用降低维护门槛JSON库Jackson序列化方便性能稳定这个组合的好处是零构建、零额外依赖把项目打成war包扔进Tomcat的webapps目录就能跑。对于中小型内部系统来说这种“轻量级分离”方案在开发效率和可维护性之间找到了一个不错的平衡点。数据库设计方面用户信息表我设计了这些字段id自增主键、username用户名、real_name真实姓名、email邮箱、phone手机号、department部门、status状态1启用/0禁用、create_time创建时间、update_time更新时间。索引方面在username和phone上建了唯一索引department和status上建了普通索引用于筛选。字符集统一用utf8mb4避免emoji存储报错——这个坑我后面会细说。2. 核心细节解析与实操要点2.1 项目目录结构与职责划分目录结构这件事看似简单但规划不好后期维护很痛苦。我采用的是标准Maven Web项目结构但做了一些微调userlist/ ├── src/main/java/ │ ├── com/example/userlist/ │ │ ├── entity/ # 实体类与数据库表对应 │ │ ├── dao/ # 数据访问层纯JDBC操作 │ │ ├── service/ # 业务逻辑层 │ │ ├── servlet/ # 控制器层处理HTTP请求 │ │ └── util/ # 工具类数据库连接、JSON封装 ├── src/main/webapp/ │ ├── static/ │ │ ├── css/ # 自定义样式 │ │ ├── js/ # 页面交互脚本 │ │ └── lib/ # 第三方库本地备份 │ ├── WEB-INF/ │ │ ├── web.xml # 仅配置欢迎页和编码过滤器 │ │ └── views/ # JSP页面存放外部无法直接访问 │ └── index.jsp # 入口页重定向到列表页 └── pom.xml # Maven依赖管理这里有个关键点JSP页面放在WEB-INF/views/下面而不是直接放在webapp根目录。为什么因为放在WEB-INF下的JSP无法通过浏览器地址栏直接访问必须经过Servlet的RequestDispatcher.forward()转发。这样做的好处是所有页面访问都经过控制器可以在转发前做权限校验、数据预处理避免用户直接敲URL绕过业务逻辑。比如用户列表页必须先经过UserListServlet查询数据、设置请求属性再转发到list.jsp这样JSP里拿到的就是已经准备好的数据。2.2 数据库连接池的取舍数据库连接这块我纠结过要不要上连接池。理论上每次请求都DriverManager.getConnection()创建新连接在高并发下会直接把数据库连接数打满。但这个项目是内部使用同时在线人数撑死几十个用连接池反而增加了配置复杂度。不过最后我还是用了Druid连接池原因有两个一是Druid自带监控页面能直观看到SQL执行情况和连接池状态排查问题方便二是配置简单一个druid.properties文件加几行初始化代码就搞定。具体配置如下driverClassNamecom.mysql.jdbc.Driver urljdbc:mysql://localhost:3306/userlist?useUnicodetruecharacterEncodingutf8mb4useSSLfalse usernameroot passwordyour_password initialSize5 minIdle5 maxActive20 maxWait30000 validationQuerySELECT 1 testWhileIdletrue testOnBorrowfalse testOnReturnfalse这里有几个参数值得说明。maxActive20是根据Tomcat默认最大线程数200来定的一般每个请求持有连接的时间很短20个连接足够周转。maxWait30000表示获取连接的超时时间是30秒超过就抛异常避免请求无限等待。validationQuerySELECT 1是连接有效性检测语句testWhileIdletrue表示空闲时检测这样能及时发现被数据库服务器断开的死连接。注意MySQL 8.0以上版本驱动类名是com.mysql.cj.jdbc.DriverURL里还需要加serverTimezoneAsia/Shanghai否则会报时区错误。我用的MySQL 5.7所以没这个问题但如果你环境不同记得调整。2.3 统一JSON响应格式设计前后端分离的核心是接口约定。我设计了一个统一的响应格式所有Servlet接口都返回这个结构{ code: 200, message: 操作成功, data: { ... }, total: 100 }code用HTTP状态码风格200成功、400参数错误、500服务器异常。message是给前端提示用的。data是实际业务数据可能是对象、数组或null。total专门用于分页场景表示总记录数。为什么要统一格式因为前端处理响应时可以写一个通用函数根据code判断成功失败成功就取data失败就弹message。不用每个接口写一套解析逻辑。我在util包里封装了一个Result类public class ResultT { private int code; private String message; private T data; private Long total; public static T ResultT success(T data) { ResultT r new Result(); r.code 200; r.message 操作成功; r.data data; return r; } public static T ResultT success(T data, Long total) { ResultT r success(data); r.total total; return r; } public static T ResultT error(int code, String message) { ResultT r new Result(); r.code code; r.message message; return r; } // getter/setter省略 }Servlet里这样用WebServlet(/api/user/list) public class UserListServlet extends HttpServlet { private UserService userService new UserService(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { resp.setContentType(application/json;charsetUTF-8); PrintWriter out resp.getWriter(); try { int page Integer.parseInt(req.getParameter(page) null ? 1 : req.getParameter(page)); int limit Integer.parseInt(req.getParameter(limit) null ? 10 : req.getParameter(limit)); String keyword req.getParameter(keyword); String department req.getParameter(department); PageResultUser pageResult userService.queryUsers(page, limit, keyword, department); out.print(new ObjectMapper().writeValueAsString( Result.success(pageResult.getList(), pageResult.getTotal()))); } catch (Exception e) { out.print(new ObjectMapper().writeValueAsString( Result.error(500, 查询失败 e.getMessage()))); } } }这段代码里有个细节resp.setContentType(application/json;charsetUTF-8)必须在getWriter()之前调用否则编码设置不生效返回的中文会变成乱码。这个坑我踩过不止一次。3. 实操过程与核心环节实现3.1 数据库表创建与初始化数据先把表建好这是所有操作的基础。SQL脚本如下CREATE DATABASE IF NOT EXISTS userlist DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE userlist; CREATE TABLE sys_user ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, username varchar(50) NOT NULL COMMENT 登录账号, real_name varchar(50) NOT NULL COMMENT 真实姓名, email varchar(100) DEFAULT NULL COMMENT 电子邮箱, phone varchar(20) DEFAULT NULL COMMENT 手机号码, department varchar(50) DEFAULT NULL COMMENT 所属部门, status tinyint(1) NOT NULL DEFAULT 1 COMMENT 状态1启用 0禁用, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_username (username), UNIQUE KEY uk_phone (phone), KEY idx_department (department), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户信息表;插入一些测试数据方便调试INSERT INTO sys_user (username, real_name, email, phone, department, status) VALUES (zhangsan, 张三, zhangsanexample.com, 13800000001, 技术部, 1), (lisi, 李四, lisiexample.com, 13800000002, 市场部, 1), (wangwu, 王五, wangwuexample.com, 13800000003, 技术部, 1), (zhaoliu, 赵六, zhaoliuexample.com, 13800000004, 人事部, 0), (sunqi, 孙七, sunqiexample.com, 13800000005, 财务部, 1);提示create_time和update_time用了MySQL的默认值特性插入时不用手动传更新时update_time自动刷新。这样能减少代码里的时间处理逻辑也避免时区转换问题。3.2 分页查询的SQL实现与参数计算分页是列表展示的核心功能。我采用的是物理分页即用LIMIT offset, size在数据库层面截取数据而不是查全量再内存分页。为什么因为用户表数据量虽然不大但养成好习惯很重要万一以后数据涨到几万条内存分页会直接把JVM堆撑爆。分页SQL的拼接逻辑在DAO层public PageResultUser queryUsers(int page, int limit, String keyword, String department) { StringBuilder sql new StringBuilder(SELECT * FROM sys_user WHERE 11); ListObject params new ArrayList(); if (keyword ! null !keyword.trim().isEmpty()) { sql.append( AND (username LIKE ? OR real_name LIKE ? OR phone LIKE ?)); String likeKeyword % keyword.trim() %; params.add(likeKeyword); params.add(likeKeyword); params.add(likeKeyword); } if (department ! null !department.trim().isEmpty()) { sql.append( AND department ?); params.add(department.trim()); } // 先查总数 String countSql SELECT COUNT(*) FROM ( sql.toString() ) AS t; long total queryCount(countSql, params.toArray()); // 再查当前页数据 sql.append( ORDER BY id DESC LIMIT ?, ?); params.add((page - 1) * limit); params.add(limit); ListUser list queryList(sql.toString(), params.toArray()); return new PageResult(list, total); }这里有个性能优化点先查总数再查数据而不是用SQL_CALC_FOUND_ROWS。因为SQL_CALC_FOUND_ROWS在MySQL 8.0已经废弃而且它需要扫描所有匹配行性能不如单独执行COUNT(*)。另外COUNT(*)查询我包了一层子查询这样能复用WHERE条件避免重复拼接。参数计算方面offset (page - 1) * limit。比如第1页每页10条offset0第2页offset10。前端传参时page从1开始limit默认10最大限制100防止恶意请求拉全表。3.3 前端AJAX请求与表格渲染前端这块我用原生fetchAPI不引入jQuery。虽然jQuery的$.ajax写起来更短但原生fetch是标准而且能强制自己理解Promise和异步流程。列表页的核心逻辑在static/js/user-list.js里const API_BASE /userlist/api/user; let currentPage 1; const pageSize 10; async function loadUserList() { const keyword document.getElementById(searchKeyword).value.trim(); const department document.getElementById(searchDepartment).value; const params new URLSearchParams({ page: currentPage, limit: pageSize, keyword: keyword, department: department }); try { const response await fetch(${API_BASE}/list?${params}); const result await response.json(); if (result.code 200) { renderTable(result.data); renderPagination(result.total); } else { alert(加载失败 result.message); } } catch (error) { console.error(请求异常, error); alert(网络异常请稍后重试); } } function renderTable(users) { const tbody document.getElementById(userTableBody); if (!users || users.length 0) { tbody.innerHTML trtd colspan7 classtext-center暂无数据/td/tr; return; } tbody.innerHTML users.map(user tr td${user.id}/td td${escapeHtml(user.username)}/td td${escapeHtml(user.realName)}/td td${escapeHtml(user.email || -)}/td td${escapeHtml(user.phone || -)}/td td${escapeHtml(user.department || -)}/td td span classbadge ${user.status 1 ? badge-success : badge-secondary} ${user.status 1 ? 启用 : 禁用} /span /td /tr ).join(); } function escapeHtml(str) { if (!str) return ; return str.replace(/[]/g, function(match) { const escapeMap { : amp;, : lt;, : gt;, : quot;, : #39; }; return escapeMap[match]; }); }这里有个安全细节所有从后端返回的字符串都要经过escapeHtml转义再插入DOM。虽然这个系统是内部使用但万一有人把用户名改成scriptalert(1)/script直接innerHTML就会触发XSS。养成转义习惯比事后补救强。分页组件我用Bootstrap的pagination样式动态生成页码function renderPagination(total) { const totalPages Math.ceil(total / pageSize); const pagination document.getElementById(pagination); if (totalPages 1) { pagination.innerHTML ; return; } let html ; // 上一页 html li classpage-item ${currentPage 1 ? disabled : } a classpage-link href#>let searchTimer null; document.getElementById(searchKeyword).addEventListener(input, function() { clearTimeout(searchTimer); searchTimer setTimeout(() { currentPage 1; // 搜索时重置到第一页 loadUserList(); }, 300); });300毫秒的延迟是实测比较舒服的值。太短了请求太频繁太长了用户觉得卡顿。部门筛选用change事件立即触发查询因为下拉选择是明确操作不需要防抖。搜索和筛选条件可以叠加比如同时按“技术部”和关键词“张”查询。后端SQL里用AND连接条件前端把参数拼在URL里一起传。这里要注意每次改变搜索条件都要把currentPage重置为1否则可能出现当前在第5页搜索后结果只有2页页面显示空白的情况。4. 常见问题与排查技巧实录4.1 中文乱码问题的三层排查中文乱码是Java Web开发的老大难问题我遇到过至少三种不同的乱码场景排查思路如下乱码现象可能原因排查方法解决方案表单提交后数据库存的是问号请求编码未设置检查request.getCharacterEncoding()在Filter里统一设置request.setCharacterEncoding(UTF-8)页面显示中文正常但接口返回乱码响应编码未设置浏览器控制台看Response Headers的Content-Typeresp.setContentType(application/json;charsetUTF-8)数据库查询出来是乱码连接URL或表字符集不对执行SHOW VARIABLES LIKE character%URL加characterEncodingutf8mb4表用utf8mb4我写了一个编码过滤器在web.xml里配置对所有请求生效WebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding(UTF-8); response.setCharacterEncoding(UTF-8); chain.doFilter(request, response); } }注意response.setContentType()和response.setCharacterEncoding()的区别。前者设置MIME类型和编码后者只设置编码。对于JSON接口必须用setContentType(application/json;charsetUTF-8)否则浏览器可能按默认编码解析。4.2 分页查询总数与数据不一致这个问题比较隐蔽用户列表显示总共100条翻到第10页却只有5条数据。原因通常是查询总数和查询数据的条件不一致。比如总数查询用了keyword条件但数据查询忘了加或者反过来。我的解决办法是把条件拼接逻辑抽成一个方法总数查询和数据查询共用同一套WHERE子句private String buildWhereClause(String keyword, String department, ListObject params) { StringBuilder where new StringBuilder( WHERE 11); if (keyword ! null !keyword.trim().isEmpty()) { where.append( AND (username LIKE ? OR real_name LIKE ? OR phone LIKE ?)); String likeKeyword % keyword.trim() %; params.add(likeKeyword); params.add(likeKeyword); params.add(likeKeyword); } if (department ! null !department.trim().isEmpty()) { where.append( AND department ?); params.add(department.trim()); } return where.toString(); }这样总数SQL就是SELECT COUNT(*) FROM sys_user whereClause数据SQL就是SELECT * FROM sys_user whereClause LIMIT ?,?。条件永远一致不会出现对不上的情况。4.3 数据库连接泄漏的排查连接泄漏的表现是系统运行一段时间后所有请求都卡住日志里出现wait millis 30000, active 20这样的错误。原因是某个地方获取了连接但没关闭。排查方法在Druid监控页面看活跃连接数如果持续增长不下降就是泄漏了。或者用lsof -i:3306看服务器上的连接数。我的经验是所有JDBC操作必须放在try-with-resources里public ListUser queryList(String sql, Object... params) { ListUser list new ArrayList(); try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i params.length; i) { ps.setObject(i 1, params[i]); } try (ResultSet rs ps.executeQuery()) { while (rs.next()) { list.add(mapRow(rs)); } } } catch (SQLException e) { throw new RuntimeException(查询失败, e); } return list; }try-with-resources会自动关闭Connection、PreparedStatement和ResultSet即使中间抛异常也不影响。这比手动在finally里close靠谱得多。4.4 前端跨域与404问题虽然这个项目前后端在同一个Tomcat里不存在跨域但如果你把前端页面单独部署比如用Nginx托管静态页就会遇到CORS问题。解决方案是在Servlet里加响应头resp.setHeader(Access-Control-Allow-Origin, *); resp.setHeader(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS); resp.setHeader(Access-Control-Allow-Headers, Content-Type, Authorization);另外OPTIONS预检请求要直接返回200不执行业务逻辑if (OPTIONS.equalsIgnoreCase(req.getMethod())) { resp.setStatus(HttpServletResponse.SC_OK); return; }404问题通常是路径写错了。我的建议是Servlet的WebServlet路径和前端请求路径保持完全一致并且注意context path。比如项目部署在/userlist下前端请求要写/userlist/api/user/list而不是/api/user/list。可以在JSP里用${pageContext.request.contextPath}动态获取const API_BASE ${pageContext.request.contextPath}/api/user;这样无论项目部署在什么路径下前端都能正确请求。4.5 常见问题速查表问题现象可能原因快速解决页面空白控制台报404请求路径错误或Servlet未注册检查WebServlet路径和前端请求URL接口返回500日志报ClassNotFound缺少Jackson或MySQL驱动依赖检查pom.xml重新mvn clean package分页点击无反应事件绑定失败或页码计算错误控制台看是否有JS报错检查>mvn clean package -DskipTests生成的war包在target/userlist.war复制到Tomcat的webapps目录下启动Tomcat会自动解压部署。访问地址是http://服务器IP:8080/userlist/。Tomcat配置方面我调整了server.xml里的连接器参数Connector port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 maxThreads200 minSpareThreads20 acceptCount100 URIEncodingUTF-8/URIEncodingUTF-8很重要否则GET请求里的中文参数会乱码。maxThreads200是默认值对于内部系统够用了。acceptCount100表示等待队列长度超过就拒绝连接。5.2 JSP预编译与静态资源缓存JSP第一次访问时会编译成Servlet这个过程比较慢。可以在Tomcat的conf/web.xml里开启JSP预编译servlet servlet-namejsp/servlet-name servlet-classorg.apache.jasper.servlet.JspServlet/servlet-class init-param param-namedevelopment/param-name param-valuefalse/param-value /init-param init-param param-namegenStringAsCharArray/param-name param-valuetrue/param-value /init-param /servletdevelopmentfalse会关闭JSP的运行时编译检查提升性能。静态资源CSS/JS/图片可以配置缓存头减少重复请求WebFilter(/static/*) public class CacheFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletResponse resp (HttpServletResponse) response; resp.setHeader(Cache-Control, max-age86400); chain.doFilter(request, response); } }max-age86400表示缓存一天。如果更新了静态文件改一下文件名或加版本号参数即可。5.3 数据库查询优化用户列表查询主要慢在LIKE %keyword%上因为前置通配符无法走索引。优化方案有两个一是限制搜索字段只对username和phone做前缀匹配LIKE keyword%这样能走索引二是如果数据量大引入全文索引或搜索引擎。对于这个项目数据量预计在几千条以内LIKE %keyword%的全表扫描在毫秒级完成没必要过度优化。但我还是加了一个查询超时设置防止意外慢查询拖垮系统ps.setQueryTimeout(5); // 5秒超时另外ORDER BY id DESC如果id是主键MySQL会直接按主键倒序扫描效率很高。如果按create_time排序记得给create_time建索引。5.4 日志配置与问题追踪日志是排查问题的眼睛。我用Logback替换了默认的JUL配置如下configuration appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender file/var/log/userlist/app.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePattern/var/log/userlist/app.%d{yyyy-MM-dd}.log/fileNamePattern maxHistory30/maxHistory /rollingPolicy encoder pattern%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n/pattern /encoder /appender logger namecom.example.userlist levelDEBUG/ root levelINFO appender-ref refFILE/ /root /configuration关键日志点请求进入Servlet时记录参数SQL执行前记录语句和参数异常时记录完整堆栈。这样出问题时看日志就能还原整个请求链路。6. 项目扩展方向与个人体会这个项目上线后运行了半年多中间迭代了几次。后来加的功能包括用户信息导出Excel用POI、批量启用/禁用、操作日志记录。导出功能特别受欢迎因为之前他们手动复制粘贴要花半小时现在点一下按钮几秒钟搞定。如果让我重新设计我会在几个地方做改进。第一引入MyBatis或JPA替代手写JDBC。虽然手写JDBC让我对SQL有完全控制但重复的mapRow和参数绑定代码太多用ORM能省不少事。第二前端用轻量级框架比如Alpine.js或Petite-Vue比原生JS操作DOM更优雅又不像Vue那么重。第三加一层Redis缓存把部门列表、状态字典这些不常变的数据缓存起来减少数据库查询。不过话说回来技术选型没有绝对的对错关键看场景。这个项目的核心诉求是“简单、稳定、好维护”ServletJSPMySQL这套组合虽然老但胜在成熟、资料多、出问题好查。对于内部工具类项目快速交付和低维护成本比技术先进性更重要。最后分享一个我踩过的坑Tomcat的work目录缓存。有次改了JSP页面重启Tomcat后还是显示旧内容折腾了半天才发现是work/Catalina/localhost/userlist下的编译缓存没清。后来我养成了习惯部署前先删work目录或者直接在Tomcat的context.xml里加Context reloadabletrue开启热加载。这个细节文档里很少提但实际开发中经常遇到。