简介基于JSPServletMySQL实现的客户管理系统采用B/S架构适合Java Web初学者、课程设计或毕业设计参考。系统涵盖登录、客户管理、线索管理、交易管理、联系人管理、市场管理、数据统计与系统管理等模块前后端分别使用Layui与JSP/Servlet/MyBatis技术能够帮助读者理解经典MVC分层开发与数据库交互流程。资源包共697个文件压缩后23.06MB主要包括Java源文件、JSP页面、XML配置、CSS/JS前端资源以及SQL脚本等其中gif演示图、class编译文件与jar依赖库数量较多便于直接部署运行和对照学习。已有169人学习下载适合希望快速搭建完整业务系统的开发者。通过该项目可掌握客户关系管理模块的设计思路、表单交互流程及数据统计实现方法同时项目附带的目录结构与部署配置也能为二次开发提供良好基础。1. 客户管理系统还在用 JSPServletMysql先搞清楚它到底解决什么问题培训班的结业项目、小公司的内部 CRM、老牌政企系统的某个模块——基于 JSPServletMysql 的客户管理系统直到今天还在大量运行。这套技术栈解决的需求很固定维护客户档案包括名称、联系人、电话、来源、等级、备注外加登录权限和简单的分页搜索。它最大的价值在于链路短从浏览器到 Mysql 全程可见请求怎么进的、参数怎么绑的、SQL 怎么查的出了事不用翻框架源码。这篇文章按我自己的落地习惯把表结构、Servlet 请求流转、连接池和部署踩坑拆开讲新手能照着跑通熟手可以拿排查清单去维护老项目。2. 先把地基打牢Mysql 建表与 JSPServlet 项目骨架2.1 客户表结构字段、索引和状态位怎么定客户管理系统最核心的是这张客户表。新手容易一上来就建十几个字段没过一个月又要改结构客户数据全要手工迁。把精力花在刀刃上客户表一开始只需要 14 个字段左右。我习惯把状态位、来源、等级这些枚举值用 TINYINT 存而不是用字符串这样 Java 侧映射 Byte 和 Integer 都不容易出错查询条件写起来也简单。CREATE TABLE customer ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY COMMENT 客户ID, customer_name VARCHAR(64) NOT NULL COMMENT 客户名称/公司名, contact_person VARCHAR(32) DEFAULT NULL COMMENT 联系人, phone VARCHAR(20) DEFAULT NULL COMMENT 联系电话, email VARCHAR(64) DEFAULT NULL COMMENT 邮箱, province VARCHAR(32) DEFAULT NULL COMMENT 省份, city VARCHAR(32) DEFAULT NULL COMMENT 城市, address VARCHAR(128) DEFAULT NULL COMMENT 详细地址, source VARCHAR(16) DEFAULT manual COMMENT 来源manual/import/web, level TINYINT NOT NULL DEFAULT 1 COMMENT 等级1普通 2重要 3核心, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1正常 0停用, remark VARCHAR(255) DEFAULT NULL COMMENT 备注, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, UNIQUE KEY uk_phone (phone), KEY idx_customer_name (customer_name(32)) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT客户信息表;逻辑说明phone 加唯一键有讲究——同一个手机号重复录入是客户管理里最常见的脏数据来源这个唯一键能在入库前就拦住。customer_name 是列表页最高频的查询条件但前缀匹配用不到整列索引所以只给前 32 个字符建索引节省索引空间。status 作为软删除标记位尽量不要物理删行后续做数据恢复还有后悔药。参数说明level 和 status 用 TINYINT 而不是 INT是因为 Java 侧用 Integer 接收完全没有问题但 Mysql 在存储层能省不少空间。create_time 和 update_time 交给 Mysql 维护应用层不要手动传时间否则服务器时钟和数据库时钟不一致时数据对不上。如果以后要按省份统计客户分布再补 KEY idx_province (province)但别在这张表上建超过 5 个索引写多读少的客户表用不上那么复杂的索引策略。2.2 用 Maven 搭一个 JSPServlet 的 webapp依赖与目录结构常见做法是 Maven 打包成 war 部署到 Tomcat。我用 IntelliJ 新建项目时选 Maven Archetype 的 webapp 模板然后手动整理 pom.xml。需要注意 servlet-api 和 jsp-api 必须设 provided否则打 war 包后和 Tomcat 自带的类冲突典型现象是启动时重复加载类。dependencies dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdjavax.servlet.jsp/groupId artifactIdjsp-api/artifactId version2.3.3/version scopeprovided/scope /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version5.1.49/version /dependency dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.8/version /dependency dependency groupIdjstl/groupId artifactIdjstl/artifactId version1.2/version /dependency /dependencies逻辑说明mysql-connector-java 我惯用 5.1.49它对 Mysql 5.7 和 8.0 都能连踩坑最少后面避坑章节会详细说为什么。Druid 1.2.8 是老配置兼容性较好的版本。jstl 1.2 只有一个 jar不需要像 1.1 那样拆成 api 和 impl 两个依赖省去一对麻烦。参数说明Tomcat 9 对应 Servlet 4.0.1如果你的服务器是 Tomcat 8.5就把 javax.servlet-api 降到 3.1.0。JSP 的版本号和 Servlet 要匹配Tomcat 9 用 2.3.3 没问题。部署时可以把连接池、驱动这些 runtime 依赖打进去provided 的两个不会进 war避免和容器冲突。项目目录建议这样组织views 目录放 WEB-INF 下是血泪经验直接放在 webapp 根目录下的 JSP用户只要猜到文件名就能用 URL 直接打开放在 WEB-INF 下则必须经 Servlet 转发才能访问天然少一类安全问题。src/main/java/com/lms/crm/ ├── servlet/ # Servlet 层接收请求、转发 JSP ├── dao/ # 数据访问层SQL 和 JDBC 操作 ├── entity/ # 实体类Customer、User ├── util/ # DBUtil、PageResult 工具 └── filter/ # 编码、登录校验 Filter src/main/webapp/ ├── WEB-INF/ │ ├── web.xml │ └── views/ # JSP 页面统一放这里 ├── css/ ├── js/ └── index.jspweb.xml 里的 Servlet 映射用全路径不用 *.do 那种通配。这样每个 URL 对应一个明确的方法加权限时清楚知道要拦谁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-nameCustomerCRM/display-name welcome-file-list welcome-fileindex.jsp/welcome-file /welcome-file-list servlet servlet-nameLoginServlet/servlet-name servlet-classcom.lms.crm.servlet.LoginServlet/servlet-class /servlet servlet-mapping servlet-nameLoginServlet/servlet-name url-pattern/login/url-pattern /servlet-mapping /web-app参数说明welcome-file 指到 index.jsp而 index.jsp 只做一件事转发到 LoginServlet。有些网络上的 servlet demo 喜欢把登录页直接作为 welcome-file那样用户不登录也能打开页面不符合客户管理系统的权限要求。2.3 数据库连接池为什么不用 DriverManager 直连新手教程喜欢用 DriverManager.getConnection()原型阶段没问题但上线后每个请求都新建和销毁 Mysql 连接并发一高数据库很快报 Too many connections。我直接从 Druid 连接池起步配置放在 src/main/resources/druid.propertiesdriverClassNamecom.mysql.jdbc.Driver urljdbc:mysql://localhost:3306/crm?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai usernameroot password你的密码 initialSize5 maxActive20 minIdle5 maxWait60000 validationQuerySELECT 1 testWhileIdletrue testOnBorrowfalse removeAbandonedTimeout60 removeAbandonedtrue逻辑说明maxActive 决定连接数峰值客户管理系统 20 个够用。maxWait 设 60 秒超过就抛异常避免请求无限等下去。removeAbandoned 是保命配置——代码里漏了 close 的连接超过 60 秒会被 Druid 主动回收不配这条第二天早上系统大概率卡死。参数说明url 里的 characterEncoding 决定 JDBC 驱动读写字符串时用的字符集。useSSLfalse 去掉 SSL 握手开销内网环境用不上证书。driverClassName 写 com.mysql.jdbc.Driver 对应 5.x 驱动如果用 8.x 驱动要改成 com.mysql.cj.jdbc.Driver两者不能混。DBUtil 工具类的常见写法是把连接池做成静态单例整个应用只有一个 DruidDataSourcepublic class DBUtil { private static final DruidDataSource dataSource new DruidDataSource(); static { Properties props new Properties(); try (InputStream in DBUtil.class.getClassLoader() .getResourceAsStream(druid.properties)) { props.load(in); dataSource.setDriverClassName(props.getProperty(driverClassName)); dataSource.setUrl(props.getProperty(url)); dataSource.setUsername(props.getProperty(username)); dataSource.setPassword(props.getProperty(password)); dataSource.setInitialSize(Integer.parseInt(props.getProperty(initialSize, 5))); dataSource.setMaxActive(Integer.parseInt(props.getProperty(maxActive, 20))); dataSource.setMaxWait(Long.parseLong(props.getProperty(maxWait, 60000))); } catch (IOException e) { throw new ExceptionInInitializerError(加载 druid.properties 失败); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }逻辑说明静态代码块在类加载时执行一次连接池不会每次请求都重新初始化。getConnection 拿的是池里复用的连接调用方调 close 不是真断开而是把连接归还池里——新手最容易误解这一点以为调 close 就关了数据库连接其实 Druid 包装过的连接 close 只是归还连接池。参数说明initialSize 是预热连接数系统启动后第一次查询不需要等建立连接。getResourceAsStream 从 classpath 读配置war 包部署后配置在 classes 目录里改配置不用重新编译直接改部署目录里的 properties 文件再重启。3. 增删改查全链路跑通Servlet 接收请求、Dao 操作 Mysql、JSP 回显3.1 登录与 Session 校验第一个 Servlet 完整代码登录是客户管理系统第一个要写的功能它确立了整个项目的代码组织方式。LoginServlet 负责接收用户名密码、验证、写 Session、跳转。doGet 和 doPost 分工直接访问 /login 是 GET展示登录页表单提交是 POST走验证逻辑。WebServlet(/login) public class LoginServlet extends HttpServlet { private final UserDao userDao new UserDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.getRequestDispatcher(/WEB-INF/views/login.jsp) .forward(req, resp); } Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username req.getParameter(username); String password req.getParameter(password); User user userDao.findByUsernameAndPassword(username, password); if (user null) { req.setAttribute(msg, 用户名或密码错误); req.getRequestDispatcher(/WEB-INF/views/login.jsp) .forward(req, resp); return; } req.getSession().setAttribute(loginUser, user); resp.sendRedirect(req.getContextPath() /customer/list); } }逻辑说明登录失败用 forward 而非 redirect是为了让 request 里的 msg 属性在 JSP 上能直接用 EL 取到。登录成功把 User 对象塞进 Session后续每个页面都能从 Session 拿到当前操作员。redirect 到 /customer/list 而不是直接 forward是为了让浏览器地址变成列表页用户按 F5 刷新时不会重复提交登录表单。参数说明加密这里只做演示md5 是不合格的真实项目至少加盐或者用 BCrypt。password 参数在 UserDao 里再加密LoginServlet 里不要做二次加密保持一层业务只做一件事。WEB-INF/views 下的 JSP 浏览器无法直接访问必须经 Servlet 转发。登录页表单要注意 action 拼 contextPath直接写 action/login 的话项目部署成 /crm 时提交路径就 404 了% page contentTypetext/html;charsetUTF-8 % form action${pageContext.request.contextPath}/login methodpost input typetext nameusername placeholder用户名 required input typepassword namepassword placeholder密码 required button typesubmit登录/button /form p stylecolor:red${msg}/p逻辑说明required 在前端挡住空提交后端仍要判断 null因为绕过页面直接 POST 是测试人员的常规动作。${pageContext.request.contextPath} 在 EL 里拼出项目部署路径这是老项目最常见的防 404 手段。UserDao 里用 PreparedStatement 占位符这是防 SQL 注入的基本功public User findByUsernameAndPassword(String username, String password) { String sql SELECT id, username, real_name, role FROM user WHERE username ? AND password ? LIMIT 1; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, md5(password)); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { User u new User(); u.setId(rs.getInt(id)); u.setUsername(rs.getString(username)); u.setRealName(rs.getString(real_name)); u.setRole(rs.getString(role)); return u; } } } catch (SQLException e) { throw new RuntimeException(查询用户失败, e); } return null; }逻辑说明try-with-resources 写法让 Connection、PreparedStatement、ResultSet 在方法结束时自动关闭不需要 finally 里手动繁琐处理。LIMIT 1 保证查询结果只有一行即使 user 表出现重复数据也不会报多结果集错误。参数说明md5 仅为演示生产环境建议引入 spring-security-crypto 换成 BCryptPasswordEncoder或者至少加盐。注意 PreparedStatement 的占位符从 1 开始计数setString(1, username) 对应第一个 ?。3.2 客户列表查询分页、搜索和结果集封装列表是客户管理系统打开率最高的页面必须同时支持分页和按客户名、电话模糊搜索。CustomerServlet 用路径映射统一处理 /customer 下的动作WebServlet(/customer/*) public class CustomerServlet extends HttpServlet { private final CustomerDao customerDao new CustomerDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String path req.getPathInfo(); if (/list.equals(path)) { list(req, resp); } else if (/toEdit.equals(path)) { toEdit(req, resp); } else { resp.sendError(404); } } private void list(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int pageNum parseInt(req.getParameter(pageNum), 1); int pageSize parseInt(req.getParameter(pageSize), 10); String keyword req.getParameter(keyword); PageResultCustomer page customerDao.pageQuery(keyword, pageNum, pageSize); req.setAttribute(page, page); req.setAttribute(keyword, keyword); req.getRequestDispatcher(/WEB-INF/views/customer_list.jsp) .forward(req, resp); } private int parseInt(String s, int def) { if (s null || s.trim().isEmpty()) return def; try { return Integer.parseInt(s.trim()); } catch (NumberFormatException e) { return def; } } }逻辑说明把列表和编辑回显放到同一个 Servlet 里按 getPathInfo() 分发比每个动作新建一个 Servlet 文件结构更紧凑。parseInt 工具方法把 pageNumabc 这种恶意参数拦下来默认回第 1 页。pageSize 建议加个上限有人会把 pageSize 传到 99999 把整表拉出来虽然没有注入风险但流量上很容易把应用打满。参数说明getRequestDispatcher().forward() 转发时浏览器地址不变刷新会重新执行这个 Servlet 拿到同样的列表数据这是列表页的标准姿势。req.setAttribute(keyword, keyword) 是为了搜索框回显用户输入的关键词。分页查询的 Dao 实现注意 count 查询必须和列表查询共用同一个 keyword 拼装逻辑public PageResultCustomer pageQuery(String keyword, int pageNum, int pageSize) { StringBuilder sql new StringBuilder(SELECT * FROM customer WHERE 11 ); ListObject params new ArrayList(); if (keyword ! null !keyword.isBlank()) { sql.append(AND (customer_name LIKE ? OR contact_person LIKE ? OR phone LIKE ?) ); String like % keyword %; params.add(like); params.add(like); params.add(like); } sql.append(ORDER BY update_time DESC LIMIT ?, ?); params.add((pageNum - 1) * pageSize); params.add(pageSize); // count 语句条件与列表查询保持完全一致 StringBuilder countSql new StringBuilder(SELECT COUNT(*) FROM customer WHERE 11 ); // 这里要重复上面的 keyword 拼接逻辑 try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql.toString())) { for (int i 0; i params.size(); i) { ps.setObject(i 1, params.get(i)); } try (ResultSet rs ps.executeQuery()) { ListCustomer list new ArrayList(); while (rs.next()) { list.add(mapToCustomer(rs)); } return new PageResult(list, totalCount, pageNum, pageSize); } } catch (SQLException e) { throw new RuntimeException(分页查询客户失败, e); } }逻辑说明LIMIT 的偏移量是 (pageNum-1)*pageSizepageNum 从 1 开始这个公式写错最常见的结果是第一页和第二页数据重叠。totalCount 必须单独 COUNT 查询不能拿 list.size() 当总数否则点击第 2 页后总数变成 10分页组件全乱。count 语句的条件和列表查询条件不一致时搜索后的总页数会对不上。参数说明setObject 可以自动匹配类型比 setString/setInt 逐个写省很多行。LIKE 通配符 % 拼在参数里而不是直接拼进 SQL用户输入的 % 和 _ 不会被当作通配符解析。ORDER BY update_time DESC 让最新维护的客户排前面这个排序规则在客户管理里比按 id DESC 更符合使用习惯。列表页 JSP 只做展示用 JSTL 循环和 EL 表达式不要在页面里写 scriptlet% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % table trth客户名称/thth联系人/thth电话/thth等级/thth操作/th/tr c:forEach items${page.list} varc tr td${c.customerName}/td td${c.contactPerson}/td td${c.phone}/td td${c.level 3 ? 核心 : (c.level 2 ? 重要 : 普通)}/td td a href${pageContext.request.contextPath}/customer/toEdit?id${c.id}编辑/a a href${pageContext.request.contextPath}/customer/delete?id${c.id} onclickreturn confirm(确认删除该客户)删除/a /td /tr /c:forEach /table逻辑说明EL 三元表达式可以把 level 数字映射成语义化文案比 JSP 里写 if/else scriptlet 干净得多。confirm 拦截是交互提示不是安全机制真正删除动作必须放 POST 请求。参数说明href 带 contextPath 是第一个要注意的点。不带前缀的链接在浏览器地址栏是 /customer/list 时恰好能打开但到了 /customer/detail/5 这种深层路径就 404。从第一天就统一带上下文后面能省很多事。3.3 新增、修改、删除客户POST 参数绑定、表单回显与重定向新增和修改共用一个 customer_form.jsp靠 URL 带不带 id 区分模式。CustomerSaveServlet 处理 POSTWebServlet(/customer/save) public class CustomerSaveServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String id req.getParameter(id); Customer customer new Customer(); // 这里是 15 行左右的字段绑定 customer.setCustomerName(req.getParameter(customerName)); customer.setContactPerson(req.getParameter(contactPerson)); customer.setPhone(req.getParameter(phone)); customer.setEmail(req.getParameter(email)); customer.setProvince(req.getParameter(province)); customer.setCity(req.getParameter(city)); customer.setAddress(req.getParameter(address)); customer.setSource(req.getParameter(source)); if (id null || id.isBlank()) { customerDao.insert(customer); } else { customer.setId(Integer.parseInt(id)); customerDao.update(customer); } resp.sendRedirect(req.getContextPath() /customer/list); } }逻辑说明新增和修改共用一个 Servlet靠 id 是否存在区分 insert 和 update。req.setCharacterEncoding 必须在第一个 getParameter 之前调用否则中文参数在绑定阶段就乱码了。POST 成功后重定向到列表页用户按 F5 刷新时浏览器重新 GET 列表不会重复提交表单——这是客户管理系统表单提交的标准防重姿势。参数说明字段绑定可以借助 BeanUtils 工具自动完成但老项目字段不多时手写更直观。id 参数是字符串Integer.parseInt 之前最好先做 null 判断和数字校验我一般在 Servlet 基类里放一个 parseInt 工具方法复用。删除用 POST 而不是 GET这是一个重要的安全习惯。用 GET 的话爬虫和浏览器预加载可能把删除链接当普通页面访问客户数据被动一次全删了。softDelete 只把 status 置 0不物理删行WebServlet(/customer/delete) public class CustomerDeleteServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int id Integer.parseInt(req.getParameter(id)); customerDao.softDelete(id); // 逻辑删除status 置 0 resp.sendRedirect(req.getContextPath() /customer/list); } }逻辑说明softDelete 执行 UPDATE customer SET status0 WHERE id?列表查询必须默认带上 WHERE status1。真要恢复某条数据数据库里 UPDATE 一下状态就行。物理删除一旦误操作恢复的成本就是整表还原甚至丢失。编辑回显的 toEdit 方法从数据库查出客户后在表单里回显private void toEdit(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int id parseInt(req.getParameter(id), 0); Customer c customerDao.findById(id); req.setAttribute(customer, c); req.getRequestDispatcher(/WEB-INF/views/customer_form.jsp) .forward(req, resp); }逻辑说明customer_form.jsp 里用${customer.customerName}回显新增模式下 customer 为 nullEL 表达式输出空串不会报错。这个设计让新增和编辑只维护同一个表单页面字段多了不重复。4. 客户管理系统避坑手册Mysql 乱码、连接池失效与路径 4044.1 Mysql 连接失败驱动选择、时区参数与 SSL 的连环坑现象项目本地跑得好好的把 war 包部署到服务器启动时报 Communications link failure或者干脆 ClassNotFoundException: com.mysql.cj.jdbc.Driver。原因最常见的两种。一是 pom 里装的是 5.1.x 驱动代码里却写了 com.mysql.cj.jdbc.Driver8.0 驱动类名带了 cj5.1 的类是 com.mysql.jdbc.Driver两者互换必报错。二是 url 没带 serverTimezoneMysql 8.0 默认时区跟 JVM 时区不一致驱动握手直接抛异常。解决统一用 mysql-connector-java 5.1.49Driver 写 com.mysql.jdbc.Driver对 5.7 和 8.0 都能连。要用 8.0 驱动的话url 必须写完整jdbc:mysql://localhost:3306/crm?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai。useSSLfalse 别省8.0 驱动默认开 SSL连不上时 80% 是它。不要为了省事去改 Mysql 配置跳过 SSL 校验那会引入更大的安全漏洞。4.2 插入中文变问号三层字符集必须同时设现象客户名称带广东某某科技有限公司页面显示正常提交后数据库里存的是????科技有限公司。原因字符集问题最隐蔽。实际排错按三层确认连接层、存储层、页面层。绝大多数项目只改了数据库表 DEFAULT CHARSETutf8mb4但连接 url 没带 characterEncodingutf8请求里的中文在 Servlet 这层就丢了。解决按三层补。第一层连接 url 加 useUnicodetruecharacterEncodingutf8。第二层web.xml 加一个 EncodingFilterrequest.setCharacterEncoding(UTF-8)。第三层每个 JSP 顶部加 page contentTypetext/html;charsetUTF-8HTML 里也写 meta charsetUTF-8。三层都设置后还乱码就去查 Mysql 系统变量SHOW VARIABLES LIKE character_set%;只要 character_set_server 不是 utf8mb4就改配置文件。my.ini 的 [mysqld] 段加 character-set-serverutf8mb4注意是 utf8mb4 不是 utf8。Mysql 8.0 默认就是 utf8mb4老版本 5.7 很多还是 latin1。4.3 连接池报 Connection is not available事务未提交与连接未释放现象系统跑了一个多月都正常某天早上用户说点客户列表一直转圈日志里刷 druid wait millis 10000、active 20、maxActive 20页面一直卡到超时。原因连接池里的 20 个连接全被占住不放。最大嫌疑是某个代码分支里 Connection 开了没有 close。我排查过一次真实事故新增客户时先查手机号是否重复这段查询在 try-catch 外面开了 connection分支 return 时忘了关。Druid 默认不回收这种泄漏连接除非开了 removeAbandoned。解决druid.properties 里加 removeAbandonedtrue 和 removeAbandonedTimeout60即使有分支漏关一分钟后 Druid 也会强制回收。但这只是兜底根治是靠 try-with-resources。还要注意 maxActive 打满时先查代码而不是盲目调大连接数客户管理系统常规并发几十人active 长期在 2 到 5 之间经常打满一定是代码问题。4.4 点击编辑按钮 404JSP 里 href 不带 contextPath现象客户列表页正常点编辑浏览器地址变成 http://localhost:8080/customer/toEdit?id3提示 404。表面看地址没错但项目部署路径是 /crm真实地址应该是 /crm/customer/toEdit。原因JSP 里写的是 href/customer/toEdit?id${c.id}少了 ${pageContext.request.contextPath}。浏览器看到 / 开头就从根路径解析项目上下文被丢弃。解决页面所有链接和表单 action 统一加上下文。最省事的是页面顶部加 base 标签base href${pageContext.request.contextPath}/之后页面里的相对链接直接写 hrefcustomer/toEdit?id${c.id}浏览器自动拼上 /crm/。注意 base 标签的作用范围是所有相对 URL包括 CSS 和 JS所以 href 属性值结尾的 / 一定要写全。4.5 两个 Mysql 版本之间的认证插件冲突现象本机用 Mysql 8.0 开发完部署到客户内网 Mysql 5.7 服务器JDBC 报 Access denied for user rootlocalhost密码明明是对的。原因Mysql 8.0 默认认证插件是 caching_sha2_password5.7 是 mysql_native_password。如果 5.7 服务器用户是 native 插件而驱动按 8.0 的方式握手认证就失败。解决要么驱动用 5.1.49对两种插件都能握手要么在 5.7 服务器上把用户插件改回ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 你的密码; FLUSH PRIVILEGES;另外 Mysql 5.7 安装时库表字符集很可能默认 latin1建库要显式指定。建库语句写成 CREATE DATABASE crm DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci。用 utf8mb4_unicode_ci 做排序规则中文 LIKE 查询行为更符合预期别用 utf8_general_ci它在偏旁字符上有排序兼容问题。5. 上线前最后一步用 Filter 收口权限、给批量操作上事务业务代码跑通之后上线前还有三件事必须做Filter 收口、事务补漏、并发验证。这一步不做好出了事故再改就狼狈了。5.1 Filter 统一编码与登录校验Filter 在 Servlet 之前执行编码设置放这里所有 Servlet 都不用重复写 setCharacterEncoding。登录校验也放这里未登录直接拦回登录页。WebFilter(urlPatterns /*) public class AuthFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; request.setCharacterEncoding(UTF-8); String uri request.getRequestURI(); if (uri.endsWith(/login) || uri.contains(/css/)) { chain.doFilter(req, resp); return; } if (request.getSession().getAttribute(loginUser) null) { response.sendRedirect(request.getContextPath() /login); return; } chain.doFilter(req, resp); } }逻辑说明先设置编码再判断路径。uri.endsWith(/login) 对带上下文路径的部署也能匹配静态资源必须放行否则样式全丢。Session 里没有 loginUser 就重定向到登录页这是客户管理系统最简单的权限收口方式。5.2 给批量操作补上事务连接要共用一个批量导入客户、客户合并这类操作必须让多条 SQL 在一个事务里。常见做法是在 Dao 方法内手动管理 ConnectionsetAutoCommit(false) 之后后面的 SQL 全部用同一个连接最后 commit异常就 rollbackfinally 里恢复 autoCommit 再 close 归还连接池。最需要注意的是事务方法内部不能再调 DBUtil.getConnection() 另拿连接否则那个连接不在事务里数据就对不上。代码评审时我会专门查这一点。5.3 并发验证三步走上线前我会做三步验证用 JMeter 开 20 个并发线程跑登录和列表查询观察 Druid 的 active 连接数是否异常打满故意写错一条 SQL验证回滚后旧数据还在重启 Tomcat确认未登录访问业务 URL 会被 Filter 拦回。做完这三步系统才敢交给用户。这些年接手的老系统出过大事故的最后都能在代码里找到没设事务漏 close不带 contextPath这三类问题之一。一个基于 JSPServletMysql 的客户管理系统做得好不好不取决于用了多新的框架而取决于这些基础问题处理得干不干净。我把这三件事列为代码评审的必查项希望这一套从建表到上线的路径能帮到你。本文还有配套的精品资源点击获取