简介一套基于 JSP 与 MVC 模式、使用 MySQL 作为后台数据库的手机销售网站源码适合 Java Web 初学者、课程设计及毕业设计人群可帮助读者理解分层开发思想并掌握动态网站构建流程。资源共 58 个文件以 13 个 Java 类、9 个 JSP 页面、2 个 SQL 数据库脚本和 XML 配置为主另含编译后的 class 文件、图片素材和 jar 包压缩后仅 1.74MB目录结构兼顾源码与发布目录便于直接导入开发工具学习。项目实现了手机商品展示、购物车管理、订单提交等典型电商功能通过源码能够学习控制器层、业务层与数据访问层之间的协作方式并了解 JSP 中表达式语言和标签库的渲染技巧数据库脚本清晰定义了手机表、用户表、订单表等核心结构对理解表间关联设计很有帮助。目前该资源已有 382 人学习下载作为轻量级实战案例适合用来自上而下串联 Java Web 开发中的关键环节也可作为课程项目或毕业设计的参考蓝本。1. 从“能卖手机”到“扛住并发”这套技术栈到底解决什么问题手机销售网站这类项目在面试题和课程设计里出现频率极高但多数实现还停留在“用JSP连MySQL查个表页面里塞满Java代码”的阶段。JSPMVCMySQL三个词拆开看都不陌生合在一起却经常被误解——很多人以为“用了JSP就是MVC”实际是Servlet把请求转发到JSPJSP里又new了DAOModel和View早就搅成糊。这套组合的成熟落地方式是用Servlet充当控制器JSP只做视图渲染JavaBean或独立Service层处理业务MySQL负责持久化。按这个边界写出来的代码后期加一个手机型号筛选、改一次分页逻辑不需要重写页面。这篇文章适合两类人一类是正在做课程设计或毕业设计的学生需要一套能跑通、能讲清楚的设计思路另一类是刚转Java Web开发、想弄明白“正规项目为什么不用JSP写业务”的从业者。文章会围绕一个可复现的手机销售网站骨架展开把MVC分层、MySQL表设计、会话管理、分页搜索这些高频需求逐个落地最后给出几个不翻车的小技巧。2. JSPMVC别把Servlet写成工具人2.1 三层职责边界Model管数据View管展示Controller管流转MVC在Java Web里的典型映射是JSP对应ViewServlet对应ControllerJavaBean/Service/DAO对应Model。很多项目失败的起点是Controller里写了一大坨业务判断或者JSP里直接用%标签查询数据库。要判断自己写的是不是合格的MVC最简单的方法是看请求流转路径——浏览器发请求Servlet接收并解析参数调用Service层处理业务把结果放进request或session作用域最后forward到JSP渲染页面。任何一步跳出这个顺序都说明分层出了问题。// 以用户登录为例Controller层只做三件事收参、调Service、转发 WebServlet(/login) public class LoginServlet extends HttpServlet { private UserService userService new UserService(); protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username req.getParameter(username); String password req.getParameter(password); // 业务判断全部在Service层Controller不写if (password.equals(...)) User user userService.login(username, password); if (user ! null) { req.getSession().setAttribute(loginUser, user); resp.sendRedirect(req.getContextPath() /phone/list); } else { req.setAttribute(errorMsg, 用户名或密码错误); req.getRequestDispatcher(/login.jsp).forward(req, resp); } } }这段代码的核心逻辑是“Servlet只做路由不做判断”。UserService.login()返回User对象或nullServlet根据结果决定重定向还是转发。sendRedirect是重新发起一次请求地址栏会变化适合登录成功后的跳转forward是服务端转发request作用域里的errorMsg还能带到JSP里——这个区别在面试常被追问实际项目中乱用会导致刷新页面重复提交表单。View层要遵守一条纪律JSP里不允许出现import java.sql.*不允许直接new DAO对象。页面需要展示的数据都是在Controller里通过req.setAttribute(phoneList, list)塞进去的。JSP拿到数据后用JSTL的c:forEach循环渲染商品卡片或者用EL表达式${phone.price}输出单个字段。% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % c:forEach varphone items${phoneList} div classphone-card h3${phone.name}/h3 p价格${phone.price} 元/p p库存${phone.stock}/p a href${pageContext.request.contextPath}/phone/detail?id${phone.id}查看详情/a /div /c:forEach2.2 自己写MVC框架前端控制器加反射比Struts2轻得多一些项目被要求“不能使用Spring MVC”此时可以自己封装一个极简MVC核心核心思想是前端控制器模式——所有请求先进入一个Servlet通过URL映射找到对应的Action类和方法省略掉每个业务写一个Servlet的重复工作。// 一个统一的DispatcherServlet拦截 *.do 请求 WebServlet(*.do) public class DispatcherServlet extends HttpServlet { private MapString, Object actionMap new HashMap(); public void init() { // 正常项目里这里会扫描包路径下的Action类并初始化到map中 actionMap.put(/user/login, new UserAction()); actionMap.put(/phone/list, new PhoneAction()); } protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String uri req.getRequestURI(); String actionPath uri.substring(req.getContextPath().length(), uri.lastIndexOf(.do)); Object action actionMap.get(actionPath); if (action null) { resp.sendError(404); return; } // 用反射调用统一的方法名例如 execute(HttpServletRequest, HttpServletResponse) try { Method m action.getClass().getMethod(execute, HttpServletRequest.class, HttpServletResponse.class); String result (String) m.invoke(action, req, resp); // result形如 forward:/phone/list.jsp 或 redirect:/phone/list.do if (result.startsWith(forward:)) { req.getRequestDispatcher(result.substring(8)).forward(req, resp); } else if (result.startsWith(redirect:)) { resp.sendRedirect(req.getContextPath() result.substring(9)); } } catch (Exception e) { throw new ServletException(Action执行失败, e); } } }DispatcherServlet只做三件事解析URL、查Map拿Action实例、反射调用统一方法。每个业务Action不再需要继承HttpServlet只需提供execute方法并返回一个字符串控制跳转方向。这种设计的优点是新增一个页面只要加一个Action类并往actionMap里注册一行缺点是需要自己处理事务和参数封装不如用Spring MVC时一个RequestMapping注解来得快。如果项目没有“禁止使用框架”的硬性要求直接上Spring MVC会更划算——Controller、RequestMapping、RequestParam三个注解就能省掉上面30行代码。但理解这个自定义Dispatcher的原理对于面试回答“Spring MVC的DispatcherServlet是怎么工作的”会有很大帮助。2.3 拦截器和过滤器登录校验到底该放哪手机销售网站必然有用户中心和购物车这就需要在访问受保护资源前判断用户是否登录。很多新手把校验代码写在每个Servlet开头复制粘贴一旦校验逻辑分了三份后面加“强制改密”功能就痛苦了。正确的做法是写一个Filter用WebFilter(/*)注解注册在doFilter里统一检查。WebFilter(/*) public class LoginFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; String uri request.getRequestURI(); // 白名单不需要登录就能访问的资源 if (uri.endsWith(.css) || uri.endsWith(.js) || uri.endsWith(.jpg)) { chain.doFilter(req, resp); return; } if (uri.endsWith(/login.jsp) || uri.endsWith(/login.do) || uri.endsWith(/register.do)) { chain.doFilter(req, resp); return; } // 检查session中是否有登录标记 Object loginUser request.getSession().getAttribute(loginUser); if (loginUser null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, resp); } }需要注意Filter的执行时机——它拦截的是Servlet容器层面的所有请求包括静态资源。上面代码用白名单方式放过了CSS、JS和图片但这只是简易做法正规项目通常把静态资源放在/static/目录下在web.xml或Spring配置里单独放行而不是在Filter里拼后缀。另外chain.doFilter(req, resp)放行时要传ServletRequest和ServletResponse类型如果你的后续代码需要HttpServletRequest里的方法可以在放行前用HttpServletRequest引用操作session也可以放行后在Servlet里强转两种做法都常见。3. MySQL数据库设计手机SKU表加库存连接池参数是个坑3.1 表结构设计SKU和SPU为什么要分开手机销售网站上架的商品有品牌、型号、颜色、存储容量、价格、库存等属性。如果把所有信息塞进一张tb_phone表颜色和容量是“规格参数”每个规格组合产生的价格和库存不同。正确做法是拆成SPU表和SKU表——SPU是商品抽象iPhone 15SKU是具体可下单的规格组合iPhone 15 蓝色 256G。CREATE TABLE tb_phone_spu ( id INT PRIMARY KEY AUTO_INCREMENT, brand VARCHAR(50) NOT NULL COMMENT 品牌如 Apple、Huawei, model VARCHAR(100) NOT NULL COMMENT 机型如 iPhone 15, release_date DATE DEFAULT NULL, description TEXT COMMENT 商品详情, main_image VARCHAR(255) DEFAULT NULL, status TINYINT DEFAULT 1 COMMENT 1上架 0下架, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE tb_phone_sku ( id INT PRIMARY KEY AUTO_INCREMENT, spu_id INT NOT NULL, color VARCHAR(30) NOT NULL, storage VARCHAR(20) NOT NULL COMMENT 如 128G、256G, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, sku_code VARCHAR(50) UNIQUE COMMENT 如 APL-IP15-BLUE-256, FOREIGN KEY (spu_id) REFERENCES tb_phone_spu(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;DECIMAL(10,2)是价格字段的标准选择用FLOAT存价格会出现0.10.2不等于0.3的精度问题。stock字段有并发隐患——用户下单时要先判断stock 0再扣减但两个请求同时读到库存为1时都可能执行扣减导致超卖。自己实现MVCMySQL时最简单的防超卖方案是使用乐观锁UPDATE tb_phone_sku SET stock stock - 1 WHERE id ? AND stock 0;UPDATE语句在InnoDB默认的REPEATABLE READ隔离级别下会对命中行加锁stock 0条件确保扣减前库存为正执行后通过int rows preparedStatement.executeUpdate()判断是否等于1为0表示库存不足或并发冲突回滚订单。3.2 连接池选型阿里巴巴Druid的配置与监控JSP页面直连MySQL最不该犯的错误是用DriverManager.getConnection()在每次请求时新建连接。数据库连接的创建和销毁开销远大于SQL执行本身高并发下直接打满数据库线程数。这里建议用阿里Druid连接池除了性能好还自带监控页面排查慢SQL时特别方便。# db.properties jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/phone_shop?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue jdbc.usernameroot jdbc.passwordyour_password # Druid初始连接数 druid.initialSize5 druid.minIdle5 druid.maxActive20 druid.maxWait60000 druid.validationQuerySELECT 1 druid.testWhileIdletrue druid.timeBetweenEvictionRunsMillis3000com.mysql.cj.jdbc.Driver是MySQL 8.0以上版本驱动的类名老项目里的com.mysql.jdbc.Driver在8.0已废弃。serverTimezoneAsia/Shanghai指定时区不设置会报The server time zone value й׼ʱ is unrecognized的错误。allowPublicKeyRetrievaltrue是MySQL 8.0使用caching_sha2_password认证时客户端首次连接需要的参数。Druid连接池的核心参数里maxWait是获取连接的最大等待毫秒数超时抛异常避免线程无限阻塞。validationQuery是验证连接是否有效的测试SQLtestWhileIdle控制空闲连接是否定期检测搭配timeBetweenEvictionRunsMillis能避免MySQL的wait_timeout默认8小时回收空闲连接后连接池仍拿着失效连接给用户使用。3.3 MySQL存储过程处理订单事务订单表数据多了以后会涉及主表和子表事务。通常做法是在Service层方法上加Transactional注解让Spring管理事务边界但不用框架时就要自己控制Connection的提交与回滚。存储过程把多条SQL打包放到数据库端执行适合像下单这种既有订单表写入、又有SKU表扣减、还有用户积分变动的场景。DELIMITER $$ CREATE PROCEDURE sp_create_order( IN p_user_id INT, IN p_sku_id INT, IN p_quantity INT, OUT p_order_id INT ) BEGIN DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN ROLLBACK; RESIGNAL; END; START TRANSACTION; -- 生成订单主表记录 INSERT INTO tb_order (user_id, total_amount, status, create_time) VALUES (p_user_id, 0, PENDING, NOW()); SET p_order_id LAST_INSERT_ID(); -- 插入订单明细金额在业务代码中算好后传入这里简化处理 INSERT INTO tb_order_item (order_id, sku_id, quantity, price) SELECT p_order_id, id, p_quantity, price FROM tb_phone_sku WHERE id p_sku_id; -- 更新SKU库存利用乐观锁条件防止超卖 UPDATE tb_phone_sku SET stock stock - p_quantity WHERE id p_sku_id AND stock p_quantity; -- 检查受影响行数如果为0说明库存不足 IF ROW_COUNT() 0 THEN SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT 库存不足; END IF; COMMIT; END $$ DELIMITER ;存储过程里的DECLARE EXIT HANDLER FOR SQLEXCEPTION类似Java里的try-catch发生异常会自动回滚整个事务并将异常上抛。SIGNAL SQLSTATE 45000 SET MESSAGE_TEXT 库存不足是主动抛出业务异常。使用存储过程有一个代价——业务逻辑分散到了SQL和Java两层数据库升级迁移时会多一份脚本需要维护。一般项目里简单的单表更新用Java层事务控制就好存储过程更适用于订单、库存这种跨表强一致性的核心链路。4. 核心模块实战商品列表页的分页、搜索与购物车会话4.1 分页与搜索的SQL拼接排序字段不能拼字符串手机列表页必然有“按品牌筛选”“按价格排序”和分页。先给出DAO层的分页查询代码再指出其中的安全陷阱。public ListPhoneSku queryPhoneList(String brand, String keyword, String orderBy, int page, int pageSize) { StringBuilder sql new StringBuilder( SELECT s.id, s.color, s.storage, s.price, s.stock, p.brand, p.model, p.main_image FROM tb_phone_sku s JOIN tb_phone_spu p ON s.spu_id p.id WHERE 11 ); ListObject params new ArrayList(); if (brand ! null !brand.isEmpty()) { sql.append(AND p.brand ? ); params.add(brand); } if (keyword ! null !keyword.isEmpty()) { sql.append(AND (p.model LIKE ? OR p.description LIKE ?) ); params.add(% keyword %); params.add(% keyword %); } String safeOrder; if (price_asc.equals(orderBy)) { safeOrder s.price ASC; } else if (price_desc.equals(orderBy)) { safeOrder s.price DESC; } else { safeOrder s.id DESC; } sql.append(ORDER BY ).append(safeOrder).append( LIMIT ? OFFSET ?); params.add(pageSize); params.add((page - 1) * pageSize); // 执行PreparedStatement并封装结果... }WHERE 11是动态拼接的惯用技巧它让后续每一个AND子句都可以无脑追加不用额外判断是否是第一个条件。?占位符配合PreparedStatement能防止SQL注入LIKE关键字本身不做转义所以用户输入里带%会把所有数据查出来——这个问题可以接受不需要额外处理。关键点是排序字段。orderBy参数如果直接拼字符串用户传入price_desc; DROP TABLE tb_phone_sku;--就直接注入了。这里用白名单方式把orderBy限定为三个固定值映射到安全的SQL片段至少能保证不会出现语法错误和注入风险。需要扩展排序维度时在if分支里继续加即可。4.2 商品详情页的URL设计REST风格与JSP路径的匹配商品详情页的URL如果写成/phone/detail?id123是传统的查询字符串风格如果写成/phone/detail/123是REST风格路径。Tomcat 8及以上版本支持Servlet 3.1的路径参数但JSP里输出链接时要保持一致。a href${pageContext.request.contextPath}/phone/detail/${sku.id}查看详情/a对应Servlet的映射处理方式是使用WebServlet(/phone/detail/*)然后从request.getPathInfo()中截取商品IDWebServlet(/phone/detail/*) public class PhoneDetailServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String pathInfo req.getPathInfo(); // 形如 /123 if (pathInfo null || pathInfo.length() 1) { resp.sendError(400); return; } int skuId; try { skuId Integer.parseInt(pathInfo.substring(1)); } catch (NumberFormatException e) { resp.sendError(400); return; } PhoneSku sku skuService.queryBySkuId(skuId); if (sku null) { resp.sendError(404); return; } req.setAttribute(sku, sku); req.getRequestDispatcher(/phone_detail.jsp).forward(req, resp); } }getPathInfo()在Tomcat下的表现是/detail/后面的部分以/开头。这里截取时用substring(1)去掉前导斜杠。如果项目里同时存在/phone/detail?id123风格的旧链接兼容做法是既读取pathInfo又读取getParameter(id)优先用pathInfo为空时改用getParameter。这种兼容写法在系统升级时很常见部署时不需要让用户改收藏夹里的旧链接。4.3 购物车会话购物车放Session还是数据库手机销售网站的购物车是典型的不同用户状态场景。登录用户的购物车建议存数据库存tb_cart_item表字段包含user_id、sku_id、quantity这样用户换电脑登录购物车还在也方便后台分析用户偏好。未登录用户的购物车可以先放Session登录后把Session中的临时条目合并进数据库。public void addToCart(HttpServletRequest req, int skuId, int quantity) { User user (User) req.getSession().getAttribute(loginUser); CartService cartService new CartService(); if (user ! null) { cartService.addItemToDb(user.getId(), skuId, quantity); } else { Cart cart (Cart) req.getSession().getAttribute(cart); if (cart null) { cart new Cart(); req.getSession().setAttribute(cart, cart); } cart.addItem(skuId, quantity); } }Cart类内部实现为一个MapInteger, Integer键是skuId值是数量。放在Session的优点是响应快、不占数据库资源缺点是一旦session.invalidate()或者服务器重启数据就丢了。所以登录后的第二次请求需要把Session里的临时购物车数据批量搬到数据库。常见做法是写一个mergeCart方法在用户登录成功的一刻调用。4.4 JSP个人信息展示页面与密码修改个人信息页在JSP中的展示要特别注意XSS漏洞——用户名、昵称这类用户可控字段在JSP页面必须用fn:escapeXml()或${fn:escapeXml(user.nickname)}转义。JSTL的c:out标签默认也会转义比${}表达式更安全% taglib prefixfn urihttp://java.sun.com/jsp/jstl/functions % div classuser-info span用户昵称/span spanc:out value${user.nickname} //span /div修改密码功能要重点处理“新密码与旧密码是否相同”“新密码与确认密码是否一致”“密码加密方式”三件事。密码存储禁止明文也不要用MD5——MD5撞库速度极快至少用BCrypt或PBKDF2。自己实现可以用JDK自带的MessageDigest做带盐的SHA-256盐值用SecureRandom生成并和密码一起存储。5. 部署与排错MySQL 8.0的坑、JSP编译class文件和Tomcat配置5.1 本地环境搭建MySQL免安装版的初始化步骤手机销售网站要跑起来需要JDK 8、Tomcat 8.5、MySQL 5.7或8.0以及IDEA。MySQL从官网下载的是压缩版解压后不能直接使用需要手动初始化和创建数据库# 1. 进入MySQL解压目录下的bin文件夹 mysqld --initialize-insecure # 执行完毕后data目录生成root用户密码为空 # 2. 安装Windows服务 mysqld --install MySQL8 --defaults-fileD:\mysql-8.0.46-winx64\my.ini # 3. 启动服务 net start MySQL8 # 4. 进入命令行客户端 mysql -u root -p # 5. 设置root密码并创建项目数据库 ALTER USER rootlocalhost IDENTIFIED BY your_password; CREATE DATABASE phone_shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;--initialize-insecure会生成一个密码为空的root账户方便首次登录。my.ini是MySQL的配置文件解压版默认没有这个文件需要自己创建至少包含[mysqld]、basedir和datadir三项。注意basedir和datadir的路径分隔符建议用正斜杠或双反斜杠。MySQL 8.0还要求character-set-serverutf8mb4否则从连接池写入中文可能会乱码。查询当前字符集配置的命令是SHOW VARIABLES LIKE character_set%;。5.2 IDEA配置JSP编译输出位置jsp编译class文件保存路径JSP文件在Tomcat运行时才被编译成Servlet类编译后的class文件并不是固定保存在项目的target目录。在IDEA里部署Web项目后JSP编译生成的Java和class文件保存在Tomcat庄配置的Work目录针对IDEA内嵌Tomcat工作目录默认是%USERPROFILE%\.IntelliJIdea2019.1\system\tomcat\项目名。出现“查看jsp编译后的java类”的需求通常是为了排查JSP模板中的语法错误或变量作用域问题。定位方式是在IDEA的Run Configuration中找到Tomcat Server配置查看Working directory字段那里标注了C:\Users\xxx\.IntelliJIdea...\Tomcat\...路径。进入该目录后找到work\Catalina\localhost\项目名\org\apache\jsp能看到login_005fpage_jsp.java这类文件打开即可看到JSP被翻译成的Servlet源码。如果IDE打开的是Eclipse路径则位于E:\apache-tomcat-9.0.xx\work\Catalina\localhost\下。排查JSP编译错误除了看这个文件更快的办法是直接看Tomcat日志catalina.out或IDEA的Console窗口。JSP编译错误会抛出org.apache.jasper.JasperException错误信息会具体到哪一行使用了未定义的变量或标签。5.3 MySQL驱动版本与ClassNotFoundException的对应关系手机销售网站部署到自购的腾讯云轻量服务器或阿里云ECS时拷好war包放到Tomcat的webapps下启动后访问JSP页面报java.lang.ClassNotFoundException: com.mysql.jdbc.Driver。这通常意味着mysql-connector-java的jar包没有放到WEB-INF/lib目录下。IDEA里Add as Library只会把jar引入编译环境不会自动打包进war。正确的做法是在Project Structure的Artifacts里把mysql-connector-java-8.0.x.jar拖进“WEB-INF/lib”区域。部署到外部Tomcat后也可以直接把jar拷到Tomcat的lib目录但这个全局jar会影响该Tomcat下的所有应用不推荐。MySQL 8.x的驱动jar需要和JDK版本匹配mysql-connector-java 8.0.x要求JDK 8个别老项目的JDK 1.6只能改用5.1.49版本驱动但5.1.49连接MySQL 8.0又可能遇到认证插件不兼容问题。如果服务器上的MySQL是5.7使用MySQL 5.1.49连接无需配置com.mysql.cj.jdbc.Driver用com.mysql.jdbc.Driver即可。5.4 MySQL排序的常见陷阱和索引优化手机列表页价格排序慢、品牌筛选慢是高频问题。如果表中数据量到了几十万行ORDER BY s.price LIMIT 10 OFFSET 0这种查询如果只有主键索引MySQL需要进行全表扫描和文件排序filesort。解决办法是在tb_phone_sku表上为spu_id加索引避免每次JOIN tb_phone_spu时发生全表扫描ALTER TABLE tb_phone_sku ADD INDEX idx_spu_id (spu_id);如果是按品牌进商品筛选WHERE p.brand Apple ORDER BY s.price这种涉及两表关联则需要在tb_phone_spu表的brand字段建立二级索引。但要注意索引不是越多越好——每个索引都会拖慢INSERT和UPDATE速度做项目时先看执行计划再决定EXPLAIN SELECT s.id, s.price, p.brand FROM tb_phone_sku s JOIN tb_phone_spu p ON s.spu_id p.id WHERE p.brand Apple ORDER BY s.price LIMIT 10;EXPLAIN输出结果中的type列是关键指标ALL表示全表扫描数据量大时需要优化eq_ref或ref表示走索引且效率较高。Extra列出现Using filesort时说明ORDER BY字段没有索引支撑性能会受影响。6. 三个能立竿见影的优化技巧连接复用、AJAX异步加载和SQL拦截6.1 JSP底部统一输出执行时间给页面加上“查询耗时xx毫秒”这个细节既能在答辩时展示你对性能有感知也方便在页面上直观验证数据库索引是否生效。实现方式是在最外层的index.jsp或公共布局里使用过滤器统计处理时间并通过request.setAttribute传给JSP。在DispatcherServlet的service方法开始前记录long start System.currentTimeMillis();在finally块里把耗时塞进request作用域JSP页面的底部Footer.jsp中用spanPage Load: ${requestScope.costTime} ms/span输出。注意这个时间包含Servlet处理和JSP渲染两部分单独看数据库查询耗时还需要在DAO层再埋一个计时点。6.2 AJAX异步加载商品库存手机详情页的“查看剩余库存”功能如果用同步刷新每次点击都重新加载整个页面体验糟糕还浪费带宽。正确做法是用AJAX调用独立的Servlet接口返回JSON格式的库存数据前端用JavaScript局部更新。function checkStock(skuId) { fetch(contextPath /phone/stock/ skuId) .then(resp resp.json()) .then(data { document.getElementById(stock-info).innerText 剩余库存 data.stock 件; }) .catch(() { document.getElementById(stock-info).innerText 库存查询失败请刷新重试; }); }对应的Servlet返回JSON需要引入fastjson或Gson库注意处理中文乱码时要在resp.setContentType(application/json;charsetutf-8)后才能getWriter().write(jsonString)charsetutf-8不可省略。JSP页面里要引用JSON对象时注意引入fastjson后JSON.toJSONString(map)对日期字段默认输出时间戳格式要格式化得为Date字段配JSONField(formatyyyy-MM-dd HH:mm:ss)注解。6.3 拦截SQL打印的隐藏方法自己写的JDBC框架可以动态代理PreparedStatement来打印执行SQL和参数快速定位慢查询和参数错误。常见做法是在DAO层统一封装一个JdbcTemplate工具类在该类中重写createStatement和prepareStatement方法把最终执行的SQL和绑定参数拼出来打印到控制台// 拿到的PreparedStatement包装对象invoke时截获execute*方法日志 public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { Object result method.invoke(realStatement, args); if (method.getName().startsWith(execute)) { System.out.println([SQL] sql — params: Arrays.toString(params)); } return result; }需要注意代理PreparedStatement后getGeneratedKeys、getResultSet这类方法会频繁被调用日志打印条件只需要判断方法名是executeQuery、executeUpdate或execute即可。线上环境建议加一个开关控制是否打印SQL否则日志文件会膨胀得很快——通常用DEBUG级别日志输出SQLINFO级别只打印慢查询执行时间超过200ms的SQL。6.4 JSP禁止离开页面提示的关闭方式开发JSP页面时如果总弹出“系统检测到您有未保存的修改”之类的提示通常是在body标签上加了onbeforeunload事件。反查方法是不好使的要在浏览器开发者工具的Console中执行window.onbeforeunload null并刷新页面。如果项目里全局引入了某个JS文件并注册了beforeunload监听器需要在初始化JS里找到对应代码注释掉这段逻辑。这个提示的本意是防止用户意外丢失表单内容但调试时会造成极大的干扰一般在非生产环境直接屏蔽。本文还有配套的精品资源点击获取