手写JSP+Servlet+MySQL客户管理系统,彻底搞懂Java Web底层原理
2026/9/15 10:55:56 网站建设 项目流程

简介:基于JSP+Servlet+MySQL的客户管理系统是一份完整的Java Web项目源码,面向Java Web初学者、课程设计与毕业设计人群。系统实现了登录、客户管理、系统管理、市场管理、数据统计、线索管理、交易管理、联系人等核心功能模块,包含线索分配、交易阶段推进、联系人维护等典型业务场景,完整展示B/S架构下Layui前端与JSP+Servlet+MyBatis后端的整合方式。压缩包共697个文件,JSP与Java文件构成核心业务代码,JS/CSS负责页面交互,XML配置持久层映射,SQL脚本用于初始化数据库,另有演示动图与操作录屏辅助上手,整体大小23.06MB。已有169人学习下载。项目可直接导入Eclipse或IDEA运行,参照SQL脚本初始化数据库即可使用;通过阅读源码可掌握分层设计、DAO封装、请求转发等关键技巧,也可了解客户管理系统的数据表设计、权限校验和统计报表生成思路,适合作为课程设计或毕业设计的基础版本二次开发。

1. JSP + Servlet + MySQL 的客户管理系统,为什么在 SpringBoot 时代还值得亲手搭一遍

客户信息散落在销售各自的 Excel 里,换一个人跟单就丢一段上下文,老板临时要按区域汇总客户分布,得花一晚上人工合并——这是大多数没上 CRM 的小团队最真实的痛点。而“基于 JSP + Servlet + MySQL 的客户管理系统”正是解决这类问题的标准小系统:登录、客户台账、跟进记录、条件搜索和分页,五张页面就能跑通一条完整业务闭环。

这套技术栈常被贴上“过时”的标签,但反直觉的结论是:它的线程模型、请求流转和分层方式是 Spring MVC 的直系前身。Servlet 容器把请求交给哪个类处理、session 怎么跨请求保持状态、JDBC 参数怎么防注入,这些机制在 SpringBoot 里被注解和自动配置盖住了。5 年以上的工程师回头看这套手写方案,反而能把过去“背下来的用法”重新理解成原理。适合的人群有两类:一是拿它做课设、毕设或内部小工具,需要一份能跑通、能讲清的设计思路;二是想彻底搞懂 Java Web 底层、再看框架时不发怵的人。下文会落到表结构、登录链路、CRUD 分页和排错,每一步都能照抄。

2. 先立数据模型再写代码:客户管理系统的三张表与 MVC 分工

2.1 用户、客户、跟进记录:三张核心表的字段与关系设计

客户管理系统的数据量不大,但关系模型要立得住。常见做法是三张表:t_user 存登录账号、t_customer 存客户主数据、t_follow_up 存每次跟进记录。建表脚本如下:

CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID', username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(128) NOT NULL COMMENT '密码散列,算法见3.3节', salt VARCHAR(32) NOT NULL COMMENT '每个用户独立的随机盐', real_name VARCHAR(50) DEFAULT NULL COMMENT '姓名,列表页展示用', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_customer ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT '客户名称,搜索的主要字段', contact VARCHAR(50) COMMENT '联系人姓名', phone VARCHAR(20) COMMENT '联系电话', level TINYINT DEFAULT 1 COMMENT '客户级别:1普通 2潜在 3重点', address VARCHAR(200) COMMENT '地址', remark VARCHAR(500) COMMENT '备注', created_by INT COMMENT '录入人,关联t_user.id', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_name (name), KEY idx_level (level) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_follow_up ( id INT PRIMARY KEY AUTO_INCREMENT, customer_id INT NOT NULL COMMENT '关联t_customer.id', content VARCHAR(500) NOT NULL COMMENT '跟进内容', next_time DATE DEFAULT NULL COMMENT '下次跟进日期', created_by INT COMMENT '跟进人', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_customer_id (customer_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

三张表的字段设计有几个关键取舍。字符集必须用 utf8mb4 而不是 utf8,前者才能存 emoji 和生僻字,MySQL 8.0 默认就是 utf8mb4,建表时显式写出来能避免旧库迁移时的隐式转换坑。客户级别用 TINYINT 而不是 VARCHAR,因为级别是有限枚举,用数字存、在 JSP 里用标签映射中文展示,后续要扩展“VIP”级别只改映射不改表结构。跟进记录表不加逻辑外键约束,而用普通索引,是因为这套系统删除客户时要走应用层事务(第 5.3 节会讲),数据库外键会绑死删除顺序,排查问题时也多一层干扰。

created_at 和 updated_at 的默认值写法值得直接复制:DEFAULT CURRENT_TIMESTAMP 和 ON UPDATE CURRENT_TIMESTAMP 能让插入、更新自动维护时间戳,少写两行 Java 代码。索引只建在 name 和 level 上,是因为列表页的搜索条件就是“按名称模糊查 + 按级别过滤”,phone 和 contact 在数据量过万前不需要索引,索引太多反而拖慢写入。

2.2 MVC 在 JSP + Servlet 里的真实分工:dao / service / servlet 三层怎么切

客户管理系统最常见的包结构是 com.example.crm 下分四层:servlet 层接收 HTTP 请求、解析参数、跳转页面;service 层放业务规则,比如“删除客户前必须删掉它的跟进记录”;dao 层只写 JDBC,一个方法对应一条 SQL;model 包放 User、Customer、FollowUp 三个 JavaBean。

这套分层不是装样子。Servlet 里如果直接写 JDBC 代码,列表页逻辑超过 50 行后就没法调试;dao 层如果混入“判断客户级别再决定是否允许删除”这类业务判断,将来要加权限规则就得改 SQL 方法。切分标准是:dao 方法只回答“数据怎么查”,service 方法只回答“业务允不允许”,servlet 只回答“请求来了怎么响应”。

JSP 在 MVC 里只做视图渲染,也就是说 JSP 页面里不应出现 <% ... %> 脚本片段去查数据库。所有数据通过 request.setAttribute 从 Servlet 传进来,页面用 JSTL 的 <c:forEach> 遍历展示。这正好对应热词里“jsp个人信息展示页面”的玩法——展示页只负责把后台传来的对象渲染成 HTML,不碰业务。对照 SpringMVC 再看这套结构会非常顺眼:DispatcherServlet 对应这里的 CustomerServlet,@RequestMapping 对应 doGet/doPost 方法,ModelAndView 对应 setAttribute + forward。理解了手写版,框架的封装就好理解了。

2.3 环境选型:JDK、Tomcat、MySQL 8.0 与 Navicat 的搭配建议

开发环境我一般这样选:JDK 8 或 11,Tomcat 9.x,MySQL 8.0 社区版,Navicat for MySQL 或 MySQL Workbench 做客户端。Tomcat 9 对应 javax.servlet 命名空间,国内多数教材和课设都是这个组合;如果装了 Tomcat 10 以上,包名变成 jakarta.servlet,网上的老代码基本不能直接跑,新手很容易在这上面卡半天。

MySQL 8.0 的驱动类名是 com.mysql.cj.jdbc.Driver,这是最容易踩的版本坑。旧教程里写的 com.mysql.jdbc.Driver 在 8.0 驱动中已移除。用 Navicat 建库时,字符集选 utf8mb4,排序规则选 utf8mb4_general_ci 即可。JDBC 连接串建议写成 jdbc:mysql://localhost:3306/crm_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai,其中 serverTimezone 必须加,否则 8.0 驱动会直接报时区错误。IDE 方面,Eclipse 和 IDEA 都行,重点是记得给项目添加 Tomcat 运行时和 MySQL Connector/J 驱动 jar,这一步漏了,后面运行时必现 ClassNotFoundException。

3. 登录链路完整落地:DBUtil 连接池、UserDao 查询到 LoginServlet 分发

3.1 DBUtil 封装 DBCP2 连接池:为什么不直接 DriverManager.getConnection

登录功能是所有客户管理系统页面过滤的总闸门。在写 LoginServlet 前,先把数据访问底座搭好。初学者最容易写成“每次查询都 DriverManager.getConnection”,这个做法在并发量起来后会暴露问题:每次连接都要完成 TCP 握手、MySQL 鉴权和会话创建,一次查询多出几十毫秒开销。更实际的是,连接不关闭会导致数据库连接数被占满,系统直接假死。这里用 Apache DBCP2 连接池,DBUtil 代码如下:

import org.apache.commons.dbcp2.BasicDataSource; import java.sql.Connection; import java.sql.SQLException; public class DBUtil { private static final BasicDataSource dataSource = new BasicDataSource(); static { dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver"); dataSource.setUrl("jdbc:mysql://localhost:3306/crm_db?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai"); dataSource.setUsername("root"); dataSource.setPassword("123456"); dataSource.setInitialSize(3); dataSource.setMaxTotal(10); dataSource.setMaxWaitMillis(3000); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }

关键在于三个参数:setInitialSize(3) 表示应用启动时预创建 3 个连接,避免第一个请求背负建连开销;setMaxTotal(10) 限制最大连接数,防止连接被耗尽;setMaxWaitMillis(3000) 让获取连接的超时时间是 3 秒而不是无限等待。实际并发不大的内部系统,10 个连接足够。获取连接的代码写在静态方法里,所有 dao 类统一调用,将来要换成 C3P0 或 HikariCP,只需要改 DBUtil 这一个类。依赖方面,DBCP2 需要 commons-dbcp2 和 commons-pool2 两个 jar 包,放到 WEB-INF/lib 下即可。

3.2 UserDao 查询方法与 PreparedStatement 的防注入机制

登录校验本质是一条按用户名查用户的 SQL,密码是否匹配在 Java 端比对,而不是把密码拼进 SQL 里让数据库去比。UserDao 的写法:

public class UserDao { public User findByUsername(String username) { String sql = "SELECT id, username, password, salt, real_name FROM t_user WHERE username = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { User u = new User(); u.setId(rs.getInt("id")); u.setUsername(rs.getString("username")); u.setPassword(rs.getString("password")); u.setSalt(rs.getString("salt")); u.setRealName(rs.getString("real_name")); return u; } } } catch (SQLException e) { throw new RuntimeException("查询用户失败", e); } return null; } }

这里有几个细节要说明。SQL 里的 ? 是占位符,ps.setString(1, username) 会把参数转义后传入,从根上堵住 SQL 注入——即使用户名输入 ' OR '1'='1 也不会被拼接进 SQL。try-with-resources 写法保证 Connection、PreparedStatement、ResultSet 在方法结束后自动关闭,顺序是 resultset 先关、statement 再关、connection 最后回到连接池。查询条件是 username 而不是 username AND password,密码比对放在 Java 端,这样才能实现每用户独立盐的散列校验。真实项目里登录名唯一,查询结果要么 0 条要么 1 条,不需要循环遍历。

3.3 LoginServlet 处理 doPost:session 会话、密码散列与 sendRedirect 的选择

登录请求是 POST 表单,LoginServlet 只重写 doPost 即可。处理逻辑分四步:取参数、验密码、写 session、跳转。核心代码如下:

@WebServlet("/login") public class LoginServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); UserDao userDao = new UserDao(); User user = userDao.findByUsername(username); if (user != null && checkPassword(password, user)) { req.getSession().setAttribute("loginUser", user); resp.sendRedirect(req.getContextPath() + "/customer/list"); } else { req.setAttribute("error", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); } } }

doPost 里不写 super.doPost 是 405 报错的常见来源,这里没有继承父类逻辑,直接覆盖是标准写法。密码校验用 checkPassword(String input, User user) 方法:取 user.getSalt() 作为盐,对 input 做同样的散列运算,比对结果和 user.getPassword() 是否相等。盐是注册时生成的 32 位随机串,存入 t_user.salt 字段,散列算法用 MD5 或 SHA-256 均可——MD5 加随机盐后,彩虹表基本失效,这是课设和内部系统最平衡的密码方案。生产环境建议直接上 BCrypt,那套思路是在散列里内嵌盐,代码更少但需要额外依赖。

登录成功用 sendRedirect 而不是 forward,原因是防表单重复提交:如果用 forward,用户在列表页按 F5 刷新,浏览器会重发之前的 POST 请求,导致重复登录动作。sendRedirect 会先返回 302 让浏览器重新 GET 列表页,地址栏变成 /customer/list,刷新安全。session 默认超时是 30 分钟,在 web.xml 里可以改:

<session-config> <session-timeout>60</session-timeout> </session-config>

60 表示 60 分钟,客户管理系统这种后台工具,60 分钟比较合适,太短销售填个表就会被踢下线。

3.4 LoginFilter 拦截未登录请求并统一处理中文编码

客户管理系统不可能每个 Servlet 都写一遍“检查是否登录”的代码,用 Filter 在请求进入 Servlet 前统一拦截。两个 Filter 组合用:CharacterEncodingFilter 解决中文乱码,LoginFilter 拦截 /customer/* 下的页面:

@WebFilter("/customer/*") public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; HttpSession session = request.getSession(false); if (session == null || session.getAttribute("loginUser") == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } chain.doFilter(req, resp); } }

request.getSession(false) 的 false 是关键:如果用户未登录,这个方法返回 null 而不是创建一个新 session,避免给每个爬虫和未登录请求都发一个 JSESSIONID。Filter 的 URL 模式是 /customer/,登录页、静态资源 css/js 都不在拦截范围内,注意不要写成 /,否则会把登录页自己拦住形成死循环。

中文乱码问题的根源是浏览器默认用 UTF-8 发送请求,Tomcat 默认用 ISO-8859-1 解码。在 CharacterEncodingFilter 里调用 request.setCharacterEncoding("UTF-8") 就能让 POST 参数正确解码,但这个方法必须在 getParameter 之前调用。对于 GET 请求的查询参数,setCharacterEncoding 不生效,处理方式是修改 Tomcat 的 server.xml 中 Connector 的 URLEncoding="UTF-8",或者统一把搜索关键词用 POST 表单提交。课设阶段用 Filter + POST 组合最省事,升级到 Tomcat 8.0 以上后 GET 的查询字符串默认就是 UTF-8,这个坑少了很多。

4. 客户管理 CRUD 完整实现:分页查询、条件搜索与表单校验

4.1 一个 CustomerServlet 分发多个操作:action 参数的路由设计

客户管理的功能点比登录多:列表、新增、进入编辑页、更新、删除,共 5 个动作。常见做法是用一个 CustomerServlet 加 action 参数分发,而不是建 5 个 Servlet 类。路由设计如下:

@WebServlet("/customer") public class CustomerServlet extends HttpServlet { @Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if (action == null || action.equals("list")) { doList(req, resp); } else if (action.equals("add")) { doAdd(req, resp); } else if (action.equals("edit")) { doEdit(req, resp); } else if (action.equals("update")) { doUpdate(req, resp); } else if (action.equals("delete")) { doDelete(req, resp); } else { resp.sendError(HttpServletResponse.SC_NOT_FOUND); } } }

重写 service 方法而不是分别重写 doGet 和 doPost,可以让列表页的链接(GET 请求)和表单提交(POST 请求)共用一套路由入口。比如删除操作,用 标签发 GET 请求带 action=delete,新增操作用表单 POST 带 action=add,service 方法统一收口。action 为 null 时默认走 list,用户直接访问 /customer 也能看到列表页而不是报 404。每个方法内部只做三件事:调 dao 查数据、setAttribute 放数据、forward/redirect 跳转。

4.2 LIMIT ? OFFSET ? 分页查询与 PageResult 封装

分页是客户管理列表页的核心逻辑,SQL 用 LIMIT 加 OFFSET。注意 MySQL 8.0 支持在 PreparedStatement 中直接给 LIMIT 参数占位。DAO 层方法如下:

public List<Customer> findPage(String keyword, Integer level, int page, int pageSize) { StringBuilder sql = new StringBuilder( "SELECT id, name, contact, phone, level, address, created_at FROM t_customer WHERE 1 = 1"); List<Object> args = new ArrayList<>(); if (keyword != null && !keyword.trim().isEmpty()) { sql.append(" AND name LIKE ?"); args.add("%" + keyword.trim() + "%"); } if (level != null) { sql.append(" AND level = ?"); args.add(level); } sql.append(" ORDER BY created_at DESC LIMIT ? OFFSET ?"); args.add(pageSize); args.add((page - 1) * pageSize); // 执行查询,循环封装 Customer 对象后返回 List }

同样需要一条 count 查询得到总数,注意 count 的条件拼接要和 findPage 完全一致,否则页码会算错。总页数计算不在 SQL 里做,而是在 Servlet 中:

int totalPages = (totalCount + pageSize - 1) / pageSize;

这里用 + pageSize - 1 再整除,比 Math.ceil((double) total / pageSize) 更简洁,也避免了浮点精度导致的临界错误。页码参数来自 request.getParameter("page"),一定要做非法值兜底:小于 1 就当成 1,超过总页数就重置为总页数。PageResult 类把 list、currentPage、pageSize、totalCount、totalPages 五个字段封装起来,一次 setAttribute("pageResult", ...) 传给 JSP,页面取数据只碰一个对象,比散放五个 request 属性清爽得多。

4.3 列表页与搜索表单:JSTL 渲染、参数回显和空指针防御

列表页用 JSTL 遍历 PageResult 中的客户列表,级别字段用 c:choose 映射成中文。搜索表单做成 GET 提交,action 指向 /customer?action=list,表单包含关键词输入框和级别下拉框。JSP 片段如下:

<form method="get" action="${pageContext.request.contextPath}/customer"> <input type="hidden" name="action" value="list"> <input type="text" name="keyword" value="${param.keyword}" placeholder="客户名称"> <select name="level"> <option value="">全部级别</option> <option value="1" ${param.level == '1' ? 'selected' : ''}>普通</option> <option value="2" ${param.level == '2' ? 'selected' : ''}>潜在</option> <option value="3" ${param.level == '3' ? 'selected' : ''}>重点</option> </select> <button type="submit">搜索</button> </form> <c:forEach items="${pageResult.list}" var="c"> <tr> <td>${c.name}</td> <td>${c.contact}</td> <td> <c:choose> <c:when test="${c.level == 3}">重点</c:when> <c:when test="${c.level == 2}">潜在</c:when> <c:otherwise>普通</c:otherwise> </c:choose> </td> <td> <a href="${pageContext.request.contextPath}/customer?action=edit&id=${c.id}">编辑</a> <a href="${pageContext.request.contextPath}/customer?action=delete&id=${c.id}" onclick="return confirm('确认删除该客户?')">删除</a> </td> </tr> </c:forEach>

param.keyword 是 EL 内置的请求参数映射,刷新页面后搜索词不会丢,这是参数回显最简单的做法。所有链接里的路径都写成 ${pageContext.request.contextPath} 拼接,而不是写死 /crm,项目改名或调整部署名时页面不用跟着改。删除操作用 confirm() 做前端二次确认,比直接提交可靠,但真正的防线是后端——try-catch 兜住外键和清理逻辑。空列表时表格没有数据,至少要在下方加一行“暂无数据”的提示,c:if test="${empty pageResult.list}",否则用户以为页面坏了。

4.4 新增和更新共用表单:编辑回显与 POST-Redirect-GET 模式

新增和编辑共用同一个 customer_form.jsp,区别只在是否带 id 参数。表单页面里用 EL 默认值回显:

<input type="text" name="name" value="${customer.name}" required> <input type="hidden" name="id" value="${customer.id}">

新增时 Servlet 往 request 里放一个空的 Customer 对象,编辑时放查出来的对象,页面不需要区分场景。表单提交后 doUpdate 的执行路径:先按 id 查库确认客户存在,再逐个 set 表单传上来的字段,最后调 dao 更新。更新完成后必须用 sendRedirect 跳回列表页,这个模式叫 POST-Redirect-GET(PRG):

resp.sendRedirect(req.getContextPath() + "/customer?action=list");

它的价值在于,用户按 F5 时浏览器重复的是 GET 列表请求而不是 POST 更新请求,不会出现“刷新一次页面就重复提交一条数据”的事故。这个模式在登录、新增、更新、删除四个操作里都适用。新增和编辑的字段校验放在 Servlet 里做:name 必填、phone 用正则 ^1[3-9]\d{9}$ 校验,校验失败时 setAttribute("error", ...) 并 forward 回表单页,而不是返回 400 让用户重新输入全部内容。

5. 排错与性能收尾:连接池之外的三个坑和最小事务示例

5.1 三个运行时高频异常:404、500、ClassNotFoundException 各自怎么看

客户管理系统写完之后跑不起来,九成是下面三类问题。404 表示请求路径到了 Tomcat 但找不到对应资源,排查顺序固定:先看浏览器地址栏的项目上下文路径是否正确——部署名是 crm 就要访问 http://localhost:8080/crm/customer,漏掉上下文路径直接 404;再看 web.xml 或注解里映射的 URL 是否和链接一致,登出常见的坑是 /logout 和 @WebServlet("/logout/") 不一致;最后确认 WEB-INF/web.xml 是否存在,纯注解项目的 web.xml 可以只保留 version 声明。500 错误看两个地方:页面顶部输出的异常堆栈,或者 %TOMCAT_HOME%/logs/catalina.out 里最后的异常块。

ClassNotFoundException 和 NoClassDefFoundError 是同一个根因的不同阶段:前者是类加载时找不到,后者是类加载成功但初始化依赖的另一个类缺失。客户管理系统里最经典的就是 com.mysql.cj.jdbc.Driver 找不到——MySQL 驱动 jar 没有放到 WEB-INF/lib 下,或者 IDEA / Eclipse 里 jar 没被标记为“发布”。Eclipse 创建基于 Maven 的 Servlet 项目时,Maven 依赖默认不会进 WEB-INF/lib,需要在 Deployment Assembly 里把 Maven Dependencies 加入发布列表。java.lang.NoClassDefFoundError 开头的报错,先说一句:这是类路径问题,不是代码问题,按依赖发布检查即可。

5.2 JSP 编译后的 class 文件在哪里:用 Tomcat work 目录反查页面错误

JSP 和 Servlet 不同,它不是一个预编译的类。第一次访问某个 JSP 时,Tomcat 的 Jasper 引擎会把它翻译成 Java 文件再编译成 class。这些文件的位置是 %TOMCAT_HOME%/work/Catalina/localhost/项目名/org/apache/jsp/,展开后能看到 login_005fjsp.java、customer_005flist_005fjsp.java 这样的文件,_005f 对应下划线。

这个目录的价值在排错:EL 表达式取不到值、JSTL 标签报错时,打开对应的 .java 文件看 Jasper 翻译后的代码,能确认到底是 ${customer.name} 拼错了,还是 Customer 类里没有 getName() 方法。热词里经常有人问“jsp编译class文件保存在哪里”,其实还有一层意思:修改 JSP 后要不要重启 Tomcat。答案是开发模式下不需要,Jasper 会对比编译时间和文件修改时间,发现 JSP 变了自动重新翻译编译——所以经常出现的情况是,改完了刷新页面还是旧样式,多等一两秒或者再刷新一次即可。但修改 Java 类和 web.xml 必须重启。

5.3 删除客户同时清理跟进记录:一个完整的最小事务示例

第 2.1 节刻意没在 t_follow_up 上建 ON DELETE CASCADE 外键,就是为了让删除动作在应用层用事务控制。删除客户时如果只删了 t_customer,跟进记录表会残留悬空数据,列表页一 join 就报错;如果两条 delete 之间出了异常,可能出现客户没了跟进还在。事务保证两条删除要么都成功要么都回滚:

public void deleteCustomerWithFollowUps(int customerId) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); try (PreparedStatement ps1 = conn.prepareStatement("DELETE FROM t_customer WHERE id = ?")) { ps1.setInt(1, customerId); ps1.executeUpdate(); } try (PreparedStatement ps2 = conn.prepareStatement("DELETE FROM t_follow_up WHERE customer_id = ?")) { ps2.setInt(1, customerId); ps2.executeUpdate(); } conn.commit(); } catch (SQLException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { throw new RuntimeException("回滚失败", ex); } } throw new RuntimeException("删除客户失败", e); } finally { if (conn != null) { conn.setAutoCommit(true); conn.close(); } } }

finally 里恢复 setAutoCommit(true) 这行不能省。连接池复用的是物理连接,这个连接回池时如果 autoCommit 还是 false,下一个请求拿到它执行查询后不提交,事务一直挂着,MySQL 端会看到连接被锁定,表现是别的页面突然全部卡住。事务范围只覆盖两条 delete,不要扩大到事务外的远程调用、文件操作之类。客户管理这种量级不需要存储过程,应用层事务的语义更清晰,也方便将来加“删除前把客户归档到备份表”的逻辑。

5.4 MySQL 索引在什么情况下真的有效:给 name 和 level 建了索引但搜索还是很慢

2.1 节建索引时给了 KEY idx_name (name) 和 KEY idx_level (level),但索引并不总是生效。name 列的索引在 WHERE name LIKE '%关键词%' 这种以 % 开头的模糊查询里是没用的,MySQL 没法用 B+ 树的顺序性去匹配“中间包含”模式,只能全表扫。业务上非要模糊搜索不可,数据量到十万级别可以换 FULLTEXT 索引走 MATCH AGAINST,但这个规模下普通索引足够。真正让索引生效的写法是前缀模糊:LIKE '关键词%'。level 是低区分度字段,只有 1、2、3 三个值,优化器很可能弃用索引直接全表扫,所以它的价值更多在于联合索引的左侧前缀。

客户管理系统里更值得建的索引是复合索引 (created_at, id),列表页默认 ORDER BY created_at DESC 时,InnoDB 按主键聚簇,回表排序的成本不高,但如果要按创建时间倒序加条件过滤,联合索引能避免 filesort。判断索引是否生效,用 EXPLAIN SELECT ... 看 type 列和 key 列:type 是 ALL 说明全表扫,是 range 或 ref 说明索引生效;possible_keys 有但 key 为 NULL,说明优化器评估后放弃了。这一步能确认到底是不是 SQL 写法问题,而不是索引数量不够。

6. 部署后的验证清单与一个减少重复 Servlet 代码的小技巧

系统部署到 Tomcat 后,按顺序过一遍验收清单,能覆盖 90% 的功能缺陷和配置问题。下面的验证表可以作为课设演示和自测脚本:

步骤操作预期结果
1浏览器访问 /crm/login.jsp页面正常显示,无中文乱码,CSS 生效
2输入错误密码登录页面顶部出现“用户名或密码错误”,URL 不变
3输入正确账号登录跳转到客户列表页,地址栏为 /crm/customer?action=list
4翻页、搜索关键词、按级别过滤URL 带 page/keyword/level 参数,参数回显正常
5新增一条客户后提交跳回列表页,新数据出现在第一行,时间戳正确
6编辑该客户后提交跳回列表页,字段刷新
7点击删除并确认数据消失,刷新后不出现,跟进记录一并被清掉
8退出登录后直接访问 /crm/customer被重定向到登录页

第 1 步的 URL 要带项目上下文路径。第 2 步和第 3 步分别验证了 forward 和 sendRedirect 的行为差异,地址栏的变化是最直观判断依据。第 7 步需要到 MySQL 里执行 SELECT * FROM t_follow_up 确认事务删除生效。验证过程中如果某一步卡住,按第 5 章的排错顺序从后往前倒查:先看日志,确认 SQL 是否正确执行,再确认页面跳转是否有 forward/replace 混淆。

最后分享一个能显著减少重复代码的结构:把 CustomerServlet 里的 action 分发逻辑抽成 BaseServlet,子类只需写业务方法,路由通过方法名自动匹配。思路是用反射调用子类方法,相当于手写一个极简 DispatcherServlet:

public class BaseServlet extends HttpServlet { @Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if (action == null || action.isEmpty()) { action = "list"; } try { Method method = this.getClass().getMethod(action, HttpServletRequest.class, HttpServletResponse.class); method.invoke(this, req, resp); } catch (NoSuchMethodException e) { resp.sendError(HttpServletResponse.SC_NOT_FOUND); } catch (Exception e) { throw new ServletException("方法执行失败", e); } } }

子类 CustomerServlet 继承 BaseServlet,直接写 public void list(...)、public void add(...)、public void edit(...),方法名变成 URL 参数。这个模式有两个好处:新增一个操作只加一个方法,不再动 if-else 链;再配合一个 ValidateFilter 做统一参数校验,Servlet 代码量能少三分之一。但反射调用必须注意安全性——action 参数是用户可控的,不能让任意方法名都能被调。稳妥的做法是加一个白名单数组,在调用前校验方法名在集合内。回头看 SpringMVC 的 @RequestMapping,本质是把这里的反射分发优化成了前期注册映射,理解了这个演化过程,再读 Spring 的源码就会顺畅得多。这套由 JSP、Servlet、MySQL 组成的客户管理系统虽然技术栈老,但它把请求、会话、事务、索引这些 web 开发的底层骨架完整铺了一遍,之后无论是转 SpringBoot 还是写网关,都会发现核心概念并没有变。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询