简介:基于JSP+Servlet+MySQL实现的客户管理系统,采用B/S架构,适合Java Web初学者、课程设计或毕业设计参考。系统涵盖登录、客户管理、线索管理、交易管理、联系人管理、市场管理、数据统计与系统管理等模块,前后端分别使用Layui与JSP/Servlet/MyBatis技术,能够帮助读者理解经典MVC分层开发与数据库交互流程。资源包共697个文件,压缩后23.06MB,主要包括Java源文件、JSP页面、XML配置、CSS/JS前端资源以及SQL脚本等,其中gif演示图、class编译文件与jar依赖库数量较多,便于直接部署运行和对照学习。已有169人学习下载,适合希望快速搭建完整业务系统的开发者。通过该项目可掌握客户关系管理模块的设计思路、表单交互流程及数据统计实现方法,同时项目附带的目录结构与部署配置也能为二次开发提供良好基础。
1. 客户管理系统还在用 JSP+Servlet+Mysql?先搞清楚它到底解决什么问题
培训班的结业项目、小公司的内部 CRM、老牌政企系统的某个模块——基于 JSP+Servlet+Mysql 的客户管理系统,直到今天还在大量运行。这套技术栈解决的需求很固定:维护客户档案,包括名称、联系人、电话、来源、等级、备注,外加登录权限和简单的分页搜索。它最大的价值在于链路短,从浏览器到 Mysql 全程可见,请求怎么进的、参数怎么绑的、SQL 怎么查的,出了事不用翻框架源码。这篇文章按我自己的落地习惯,把表结构、Servlet 请求流转、连接池和部署踩坑拆开讲,新手能照着跑通,熟手可以拿排查清单去维护老项目。
2. 先把地基打牢:Mysql 建表与 JSP+Servlet 项目骨架
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)) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 搭一个 JSP+Servlet 的 webapp:依赖与目录结构
常见做法是 Maven 打包成 war 部署到 Tomcat。我用 IntelliJ 新建项目时选 Maven Archetype 的 webapp 模板,然后手动整理 pom.xml。需要注意 servlet-api 和 jsp-api 必须设 provided,否则打 war 包后和 Tomcat 自带的类冲突,典型现象是启动时重复加载类。
<dependencies> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>jsp-api</artifactId> <version>2.3.3</version> <scope>provided</scope> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.49</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.8</version> </dependency> <dependency> <groupId>jstl</groupId> <artifactId>jstl</artifactId> <version>1.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 xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0"> <display-name>CustomerCRM</display-name> <welcome-file-list> <welcome-file>index.jsp</welcome-file> </welcome-file-list> <servlet> <servlet-name>LoginServlet</servlet-name> <servlet-class>com.lms.crm.servlet.LoginServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>LoginServlet</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.properties:
driverClassName=com.mysql.jdbc.Driver url=jdbc:mysql://localhost:3306/crm?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username=root password=你的密码 initialSize=5 maxActive=20 minIdle=5 maxWait=60000 validationQuery=SELECT 1 testWhileIdle=true testOnBorrow=false removeAbandonedTimeout=60 removeAbandoned=true逻辑说明:maxActive 决定连接数峰值,客户管理系统 20 个够用。maxWait 设 60 秒,超过就抛异常,避免请求无限等下去。removeAbandoned 是保命配置——代码里漏了 close 的连接,超过 60 秒会被 Druid 主动回收,不配这条,第二天早上系统大概率卡死。
参数说明:url 里的 characterEncoding 决定 JDBC 驱动读写字符串时用的字符集。useSSL=false 去掉 SSL 握手开销,内网环境用不上证书。driverClassName 写 com.mysql.jdbc.Driver 对应 5.x 驱动,如果用 8.x 驱动要改成 com.mysql.cj.jdbc.Driver,两者不能混。
DBUtil 工具类的常见写法是把连接池做成静态单例,整个应用只有一个 DruidDataSource:
public 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 contentType="text/html;charset=UTF-8" %> <form action="${pageContext.request.contextPath}/login" method="post"> <input type="text" name="username" placeholder="用户名" required> <input type="password" name="password" placeholder="密码" required> <button type="submit">登录</button> </form> <p style="color: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"); PageResult<Customer> 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 工具方法把 pageNum=abc 这种恶意参数拦下来,默认回第 1 页。pageSize 建议加个上限,有人会把 pageSize 传到 99999 把整表拉出来,虽然没有注入风险,但流量上很容易把应用打满。
参数说明:getRequestDispatcher().forward() 转发时浏览器地址不变,刷新会重新执行这个 Servlet 拿到同样的列表数据,这是列表页的标准姿势。req.setAttribute("keyword", keyword) 是为了搜索框回显用户输入的关键词。
分页查询的 Dao 实现,注意 count 查询必须和列表查询共用同一个 keyword 拼装逻辑:
public PageResult<Customer> pageQuery(String keyword, int pageNum, int pageSize) { StringBuilder sql = new StringBuilder("SELECT * FROM customer WHERE 1=1 "); List<Object> 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 1=1 "); // 这里要重复上面的 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()) { List<Customer> 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)*pageSize,pageNum 从 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 prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table> <tr><th>客户名称</th><th>联系人</th><th>电话</th><th>等级</th><th>操作</th></tr> <c:forEach items="${page.list}" var="c"> <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}" onclick="return 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 处理 POST:
@WebServlet("/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 status=0 WHERE id=?,列表查询必须默认带上 WHERE status=1。真要恢复某条数据,数据库里 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 为 null,EL 表达式输出空串不会报错。这个设计让新增和编辑只维护同一个表单页面,字段多了不重复。
4. 客户管理系统避坑手册:Mysql 乱码、连接池失效与路径 404
4.1 Mysql 连接失败:驱动选择、时区参数与 SSL 的连环坑
现象:项目本地跑得好好的,把 war 包部署到服务器,启动时报 Communications link failure,或者干脆 ClassNotFoundException: com.mysql.cj.jdbc.Driver。
原因:最常见的两种。一是 pom 里装的是 5.1.x 驱动,代码里却写了 com.mysql.cj.jdbc.Driver,8.0 驱动类名带了 cj,5.1 的类是 com.mysql.jdbc.Driver,两者互换必报错。二是 url 没带 serverTimezone,Mysql 8.0 默认时区跟 JVM 时区不一致,驱动握手直接抛异常。
解决:统一用 mysql-connector-java 5.1.49,Driver 写 com.mysql.jdbc.Driver,对 5.7 和 8.0 都能连。要用 8.0 驱动的话,url 必须写完整:jdbc:mysql://localhost:3306/crm?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai。useSSL=false 别省,8.0 驱动默认开 SSL,连不上时 80% 是它。不要为了省事去改 Mysql 配置跳过 SSL 校验,那会引入更大的安全漏洞。
4.2 插入中文变问号:三层字符集必须同时设
现象:客户名称带"广东某某科技有限公司",页面显示正常,提交后数据库里存的是"????科技有限公司"。
原因:字符集问题最隐蔽。实际排错按三层确认:连接层、存储层、页面层。绝大多数项目只改了数据库表 DEFAULT CHARSET=utf8mb4,但连接 url 没带 characterEncoding=utf8,请求里的中文在 Servlet 这层就丢了。
解决:按三层补。第一层,连接 url 加 useUnicode=true&characterEncoding=utf8。第二层,web.xml 加一个 EncodingFilter,request.setCharacterEncoding("UTF-8")。第三层,每个 JSP 顶部加 page contentType="text/html;charset=UTF-8",HTML 里也写 meta charset="UTF-8"。三层都设置后还乱码,就去查 Mysql 系统变量:
SHOW VARIABLES LIKE 'character_set%';只要 character_set_server 不是 utf8mb4,就改配置文件。my.ini 的 [mysqld] 段加 character-set-server=utf8mb4,注意是 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 里加 removeAbandoned=true 和 removeAbandonedTimeout=60,即使有分支漏关,一分钟后 Druid 也会强制回收。但这只是兜底,根治是靠 try-with-resources。还要注意 maxActive 打满时先查代码而不是盲目调大连接数,客户管理系统常规并发几十人,active 长期在 2 到 5 之间,经常打满一定是代码问题。
4.4 点击编辑按钮 404:JSP 里 href 不带 contextPath
现象:客户列表页正常,点编辑,浏览器地址变成 http://localhost:8080/customer/toEdit?id=3,提示 404。表面看地址没错,但项目部署路径是 /crm,真实地址应该是 /crm/customer/toEdit。
原因:JSP 里写的是 href="/customer/toEdit?id=${c.id}",少了 ${pageContext.request.contextPath}。浏览器看到 / 开头就从根路径解析,项目上下文被丢弃。
解决:页面所有链接和表单 action 统一加上下文。最省事的是页面顶部加 base 标签:
<base href="${pageContext.request.contextPath}/">之后页面里的相对链接直接写 href="customer/toEdit?id=${c.id}",浏览器自动拼上 /crm/。注意 base 标签的作用范围是所有相对 URL,包括 CSS 和 JS,所以 href 属性值结尾的 / 一定要写全。
4.5 两个 Mysql 版本之间的认证插件冲突
现象:本机用 Mysql 8.0 开发完,部署到客户内网 Mysql 5.7 服务器,JDBC 报 Access denied for user 'root'@'localhost',密码明明是对的。
原因:Mysql 8.0 默认认证插件是 caching_sha2_password,5.7 是 mysql_native_password。如果 5.7 服务器用户是 native 插件,而驱动按 8.0 的方式握手,认证就失败。
解决:要么驱动用 5.1.49,对两种插件都能握手;要么在 5.7 服务器上把用户插件改回:
ALTER USER 'root'@'localhost' 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 方法内手动管理 Connection,setAutoCommit(false) 之后,后面的 SQL 全部用同一个连接,最后 commit,异常就 rollback,finally 里恢复 autoCommit 再 close 归还连接池。最需要注意的是事务方法内部不能再调 DBUtil.getConnection() 另拿连接,否则那个连接不在事务里,数据就对不上。代码评审时我会专门查这一点。
5.3 并发验证三步走
上线前我会做三步验证:用 JMeter 开 20 个并发线程跑登录和列表查询,观察 Druid 的 active 连接数是否异常打满;故意写错一条 SQL,验证回滚后旧数据还在;重启 Tomcat,确认未登录访问业务 URL 会被 Filter 拦回。做完这三步,系统才敢交给用户。
这些年接手的老系统,出过大事故的,最后都能在代码里找到"没设事务""漏 close""不带 contextPath"这三类问题之一。一个基于 JSP+Servlet+Mysql 的客户管理系统做得好不好,不取决于用了多新的框架,而取决于这些基础问题处理得干不干净。我把这三件事列为代码评审的必查项,希望这一套从建表到上线的路径能帮到你。
本文还有配套的精品资源,点击获取