1. 为什么到了2025年我还在用JSPServlet做旅游商城先说结论这个项目选型不是跟不上时代而是刻意为之。最近在整理自己两年多JavaWeb开发里攒下的项目代码翻到了大二做的那个在线旅游路线商城。技术栈写在标题里了——javaweb mysql jsp servlet javascript一套标准的JavaWeb课设级组合。很多人一看JSP就皱眉觉得这玩意儿早就该进博物馆了但实际上如果你想把Servlet生命周期、HTTP协议交互、Session状态管理、JDBC连接池这些底子吃透JSPServlet反而是最不留情面、最能暴露你真水平的组合。你想想Spring Boot帮你把Tomcat内嵌了、把JSON序列化自动配好了、把事务封装得明明白白你写一个GetMapping就完事底层发生了什么你其实一无所知。而这个旅游商城不一样从前端页面的表单提交到Servlet里doGet/doPost的分流再到JDBC手动获取连接、预编译SQL、处理ResultSet每一步都是你自己写出来的。这个过程走一遍你再去学Spring MVC会发现它不过是把这个流程规范化、模块化了而已。这个系统解决的核心问题很具体旅游路线不是标准商品它有出发地、目的地、行程天数、价格梯度、成团人数、出发日期还牵扯到下单后要记录出行人数、联系人信息。拿通用电商那套商品表结构硬套会非常别扭。所以这个项目的数据库设计、订单流程和页面交互都有区别于普通商城的地方这也是我觉得它值得拿出来单独写一篇的原因。适合看这篇文章的人我总结了一下基本是这三类正在做JavaWeb课设或毕设题目恰好是旅游类网站的可以整套参考想从零手写一套ServletJSP完整项目来巩固基础的这篇的代码思路和排查路径能帮你少走很多弯路面试前想找个自己真正写过的项目来梳理技术栈的这个商城的业务复杂度刚好够你讲清楚事务、会话、连接池这些考点。下面我按自己实际做这个项目的顺序从数据库设计、环境搭建、核心功能实现到部署踩坑一路讲下来。代码不追求炫技但保证每一行都是跑得通的。2. 旅游路线商城的数据库设计为什么不能照搬电商表结构2.1 先理清业务实体之间的关系做任何系统我习惯先画清楚实体关系再碰代码。旅游路线商城涉及的核心实体理下来就是这些用户表user前台登录、下单、查看订单都用它路线分类category比如周边游、国内长线、出境游前台筛选要用旅游路线route商品主表但这个表比普通商品复杂路线图片route_image一条路线通常有多张宣传图轮播图用订单主表order一笔订单对应一条路线记录总价、状态订单联系人表order_contact出行人姓名、电话、证件号这是旅游业务特有的不能丢。我当时最开始的设计错误是把路线图片直接塞在route表里一个字段存多个URL用逗号分隔。后来前台做轮播图时发现取出来还得split图片多了性能还差最终老老实实拆成了独立的route_image表主外键关联。这也是做JavaWeb数据库设计的第一个经验能用外键关联拆开的就别用逗号拼接不然你写SQL的时候每一条都要想着怎么解析字符串活儿全白干。数据库我选的是MySQL 5.7。没上8.0是因为当时学校机房和老项目的兼容性考虑。如果你是自己从零做用8.0也没问题但注意驱动要配mysql-connector-java对应版本JDBC的URL里useSSLfalse最好显式写出来不然本地连接时经常报警告。2.2 核心表结构手写版下面直接给route表和order表的核心字段。route表CREATE TABLE route ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 路线名称, category_id INT COMMENT 分类ID, departure_city VARCHAR(50) COMMENT 出发城市, destination VARCHAR(100) COMMENT 目的地, days INT COMMENT 行程天数, price DECIMAL(10,2) COMMENT 成人价格, children_price DECIMAL(10,2) DEFAULT 0 COMMENT 儿童价格, start_date DATE COMMENT 出发日期, min_people INT DEFAULT 1 COMMENT 成团最低人数, stock INT DEFAULT 0 COMMENT 剩余名额, summary VARCHAR(500) COMMENT 路线简介, detail TEXT COMMENT 详细行程, status TINYINT DEFAULT 1 COMMENT 1上架 0下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );注意DECIMAL(10,2)而不是FLOAT这是价格字段的铁律。用FLOAT存价格一旦有小数运算就可能出现0.10.2不等于0.3这种问题旅游路线价格动不动几百上千一分钱都不能差。order表的几个关键字段CREATE TABLE order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) UNIQUE COMMENT 订单编号, user_id INT COMMENT 下单用户, route_id INT COMMENT 路线ID, adult_count INT DEFAULT 1 COMMENT 成人数, child_count INT DEFAULT 0 COMMENT 儿童数, total_price DECIMAL(10,2) COMMENT 总价, contact_name VARCHAR(50) COMMENT 联系人姓名, contact_phone VARCHAR(20) COMMENT 联系人电话, status TINYINT DEFAULT 0 COMMENT 0待支付 1已支付 2已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );order_no我用的生成规则是时间戳用户ID三位随机数比如20250607153012001023。为什么不用数据库自增ID当订单号因为订单号要给人看的自增ID一眼能看出你一天有多少订单而且不方便做唯一约束排查。这个生成规则还有一个隐藏好处——字符串里直接带了时间你在后台做订单列表排序时按order_no倒序排列就等价于按下单时间倒序。2.3 一条路线会有多张图片轮播图怎么存再多说一嘴图片表因为这是很多JSPServlet新手容易卡住的地方CREATE TABLE route_image ( id INT PRIMARY KEY AUTO_INCREMENT, route_id INT NOT NULL, img_url VARCHAR(255) NOT NULL, img_type TINYINT DEFAULT 0 COMMENT 0轮播图 1详情图 );后端把图片上传到服务器某个目录数据库只存相对路径。前台展示时${pageContext.request.contextPath}拼上路径就能访问。这个相对路径的设计决定了你项目部署到不同上下文路径时图片还能正常显示不会因为项目名改了图片全挂。3. 环境搭建与项目骨架Tomcat Servlet 3.0 JDBC怎么搭配最省心3.1 开发工具选型与配置清单我这次搭建用的组合是这样JDK 1.8最稳妥的版本JSP和Servlet在这上面跑了十几年Tomcat 8.5Servlet 3.1规范支持异步处理但咱也用不上那些高级特性稳定才是关键Maven 3.6管理依赖省得手动往WEB-INF/lib里塞jar包MySQL 5.7 Navicat建表和可视化排查数据IDEA 2023写JavaWebIDEA的Run配置里直接选Tomcat Server断点调试Servlet不要太方便。pom.xml里核心依赖就四个不需要引入任何重量级框架dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version3.1.0/version scopeprovided/scope /dependency dependency groupIdjavax.servlet/groupId artifactIdjsp-api/artifactId version2.3.3/version scopeprovided/scope /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version5.1.49/version /dependency dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency注意两个provided。如果你不小心把servlet-api和jsp-api打成war包丢进Tomcat启动时会报重复类定义的错甚至直接ClassNotFoundException。这个问题遇到过不下三次每次都是新手把依赖带进去了。3.2 JDBC工具类的封装思路JSPServlet项目里数据库连接不能每次都DriverManager.getConnection那样连接反复创建销毁性能太差。我写了一个DBUtil工具类用ThreadLocal持有Connection保证一次请求内多个DAO操作共用同一个数据库连接为后面的事务控制打基础。public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/travel_db?useSSLfalsecharacterEncodingutf8; private static final String USER root; private static final String PASSWORD 123456; private static ThreadLocalConnection connHolder new ThreadLocal(); static { try { Class.forName(com.mysql.jdbc.Driver); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { Connection conn connHolder.get(); if (conn null) { conn DriverManager.getConnection(URL, USER, PASSWORD); connHolder.set(conn); } return conn; } public static void closeAll(ResultSet rs, Statement stmt, Connection conn) { try { if (rs ! null) rs.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (stmt ! null) stmt.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (conn ! null) { conn.close(); connHolder.remove(); } } catch (SQLException e) { e.printStackTrace(); } } }这里connHolder.remove()特别关键。ThreadLocal不手动移除在高并发复用的线程池场景下线程复用时连接还挂在上面直接导致连接泄漏。虽然没有用连接池但用ThreadLocal这套写法已经把连接的管理方式往正规方向靠了。等以后换成Druid或C3P0只需要改这个工具类DAO层的代码完全不用动。3.3 Web.xml配置和分层包结构Servlet 3.0之后虽然支持注解配置但我建议web.xml里还是保留一个空壳定义写清楚欢迎页面和session超时时间web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee version3.1 display-nameTravelMall/display-name welcome-file-list welcome-fileindex.jsp/welcome-file /welcome-file-list session-config session-timeout30/session-timeout /session-config /web-app包结构我用的标准分层com.travel.dao -- 数据库访问层 com.travel.entity -- 实体类对应表结构 com.travel.service -- 业务逻辑层事务控制放在这 com.travel.web -- Servlet控制层 com.travel.util -- 工具类为什么多此一举加一层Service因为如果直接在Servlet里写业务逻辑页面跳转时你要复制一大段代码而且下单这个动作涉及扣库存创建订单生成联系人三个步骤必须放在同一个事务里。Service层就是干这个的。4. Servlet核心代码实战路线列表、详情、下单这三大金刚4.1 路线列表的分页与条件筛选前台首页展示的是所有上架状态的路线。但是旅游路线一般就十到二十条我一开始没有做分页后来发现一旦加入搜索条件比如按目的地筛选或者按价格排序整个页面的数据交互就乱了。老老实实补了一个分页功能。分页的核心原理其实特别简单LIMIT ? OFFSET ?。但要注意的是MySQL的LIMIT ?参数不能直接拼在SQL里必须用PreparedStatement占位符public ListRoute findPage(int pageNum, int pageSize, String keyword) { String sql SELECT * FROM route WHERE status 1 AND name LIKE ? LIMIT ? OFFSET ?; ListRoute list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, % keyword %); ps.setInt(2, pageSize); ps.setInt(3, (pageNum - 1) * pageSize); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { list.add(mapRoute(rs)); } } } catch (SQLException e) { e.printStackTrace(); } return list; }OFFSET头一页是0所以页数要减1乘pageSize。这个逻辑初学的时候特别容易写反我建议你写的时候先口算一遍第1页offset(1-1)*60取第0到5条对第2页offset(2-1)*66取第6到11条也对。前端页面我封装了一个PageBean对象包含当前页、总页数、总记录数、当前页数据列表。JSP里用c:forEach循环渲染路线卡片每个卡片跳转到详情页route/detail?id${route.id}。4.2 详情页的图片轮播与库存判断详情页是游客决定买不买的关键页面。我用的是JSP JavaScript配合的方式路线主图切换用原生的JS实现不引jQuery因为这个轮播场景太简单了——点击缩略图把大图的src替换成对应图片地址。function changeMainImage(imgUrl) { document.getElementById(mainImage).src imgUrl; }库存判断这里有个实际业务的细节旅游路线的库存跟普通商品的库存不太一样。普通商品库存就一个数字卖完即止。旅游路线得考虑两个层面——一个是总名额比如这条线最多收50人另一个是成团最低人数比如不满8人就不发团。我在详情页把这两个概念分开显示如果stock 0按钮置灰显示已报满;如果stock minPeople显示成团中剩余X个名额按钮可以用但提示可能不成团;如果stock minPeople正常显示立即预订。这个逻辑虽然简单但写出来之后用户体验差别很大。很多旅游网站直接一刀切显示库存不足其实会把一些其实还能拼团的订单吓跑。4.3 下单流程购物车不旅游业直接下单普通电商必须要有购物车因为用户一次可能买多件商品。但旅游路线不一样——一个人出去旅游只需要一条路线预定的时候填的是出行人数不是商品数量。所以这个项目里我砍掉了购物车模块这个决定复盘下来非常关键它让整个订单流程短了一半用户下单转化率反而更高。下单的Servlet处理逻辑我现在手写出来这是整个项目里最值得看的一段WebServlet(/order/create) public class OrderCreateServlet extends HttpServlet { Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { HttpSession session request.getSession(); User user (User) session.getAttribute(loginUser); if (user null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } int routeId Integer.parseInt(request.getParameter(routeId)); int adultCount Integer.parseInt(request.getParameter(adultCount)); int childCount Integer.parseInt(request.getParameter(childCount)); String contactName request.getParameter(contactName); String contactPhone request.getParameter(contactPhone); // 生成订单号时间戳 用户ID 随机三位 String orderNo System.currentTimeMillis() user.getId() String.format(%03d, new Random().nextInt(1000)); OrderService service new OrderService(); boolean success service.createOrder(user.getId(), routeId, adultCount, childCount, contactName, contactPhone, orderNo); if (success) { response.sendRedirect(request.getContextPath() /order/success.jsp?orderNo orderNo); } else { request.setAttribute(errorMsg, 下单失败请检查库存是否充足); request.getRequestDispatcher(/route/detail?id routeId).forward(request, response); } } }注意这段里的三个关键处理第一登录拦截。下单前必须判断Session里的loginUser没登录就重定向到登录页。这个逻辑虽然简单但放到每个Servlet里你容易漏掉后面我会讲用Filter统一拦截的改造方案。第二字符串数字转换。Integer.parseInt在参数异常时会直接抛NumberFormatException页面就会报500。我这边用了try-catch没有贴出来真实项目建议用工具类封装或者前端直接限制输入框只能填数字。第三下单失败的跳转方式。用的是forward而不是sendRedirect这样request.setAttribute里的错误信息才能在详情页拿到。如果是重定向request没了errorMsg也就没了。这个细节很多人第一次写都踩过。4.4 Service层的事务管理扣库存和下订单不能一个成功一个失败这是我这个项目里最引以为豪的一段逻辑虽然只有二十行不到但它把事务这个概念讲透了。public boolean createOrder(int userId, int routeId, int adultCount, int childCount, String contactName, String contactPhone, String orderNo) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 RouteDao routeDao new RouteDao(); OrderDao orderDao new OrderDao(); // 第一步查询当前路线库存是否充足 Route route routeDao.findById(routeId); if (route.getStock() adultCount childCount) { conn.rollback(); return false; } // 第二步扣减库存 routeDao.decreaseStock(routeId, adultCount childCount); // 第三步插入订单记录 BigDecimal totalPrice route.getPrice() .multiply(BigDecimal.valueOf(adultCount)) .add(route.getChildrenPrice().multiply(BigDecimal.valueOf(childCount))); orderDao.insert(userId, routeId, adultCount, childCount, totalPrice, contactName, contactPhone, orderNo); conn.commit(); return true; } catch (Exception e) { try { if (conn ! null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } e.printStackTrace(); return false; } finally { try { if (conn ! null) { conn.setAutoCommit(true); // 恢复自动提交 conn.close(); DBUtil.connHolder.remove(); } } catch (SQLException e) { e.printStackTrace(); } } }这里必须强调三件事一是setAutoCommit(false)之后如果出了异常没有rollback这个连接上的未提交事务会一直占着行锁别人想改同一行库存就堵住了严重时直接死锁。所以catch里第一个动作就是rollback。二是finally里要setAutoCommit(true)恢复默认设置。因为连接是要复用放进连接池的如果你把它关了重开没事但如果是池化连接autoCommit状态会残留。我当时用自带连接没有池化还是坚持了这个好习惯后来换Druid一点没改就接上了。三是价格计算用BigDecimal。有小数乘法的场景double和float都会产生精度丢失。multiply方法参数必须是BigDecimal所以adultCount这个int要先BigDecimal.valueOf()转换。这一段如果你面试时能默写出来并讲清楚rollback和setAutoCommit的语义面试官基本就信你是真写过项目的。5. JSP前端交互与JavaScript细节轮播、异步校验、以及那些让你怀疑人生的坑5.1 登录注册的前端实时校验登录注册页面我用的是纯JavaScript原生校验不引框架。注册页的表单有用户名、密码、确认密码、手机号在表单提交函数里做判断function validateForm() { var username document.getElementById(username).value.trim(); var password document.getElementById(password).value; var confirmPwd document.getElementById(confirmPwd).value; var phone document.getElementById(phone).value.trim(); if (username.length 3) { alert(用户名至少3个字符); return false; } if (password.length 6) { alert(密码至少6位); return false; } if (password ! confirmPwd) { alert(两次密码不一致); return false; } if (!/^1[3-9]\d{9}$/.test(phone)) { alert(请输入正确的手机号); return false; } return true; }正则这个/^1[3-9]\d{9}$/是校验国内手机号的经典写法第一位是1第二位是3到9后面9位数字。合法不合法一键判断不用再写一堆逻辑。提交方式是onsubmitreturn validateForm()这个return一定不能省。如果只写onsubmitvalidateForm()校验失败返回false也会被忽略表单照样提交。这个细节我在课上见过不下五个同学犯。5.2 用户名是否已存在的Ajax异步查询注册页用户名是否已被占用这个用JSPServlet做异步查询是练习Ajax的最佳案例。不用任何框架原生XMLHttpRequest就够了function checkUsername() { var username document.getElementById(username).value.trim(); if (username.length 3) { document.getElementById(usernameTip).innerText ; return; } var xhr new XMLHttpRequest(); xhr.open(GET, contextPath /user/checkUsername?username encodeURIComponent(username), true); xhr.onreadystatechange function () { if (xhr.readyState 4 xhr.status 200) { var result xhr.responseText; // true 或 false var tip document.getElementById(usernameTip); if (result true) { tip.innerText 用户名已存在; tip.style.color red; } else { tip.innerText 用户名可用; tip.style.color green; } } }; xhr.send(); }注意两点URL地址我用了contextPath这个变量它是在JSP页面里用${pageContext.request.contextPath}赋值的。如果不加这个前缀页面部署到非ROOT路径下Ajax请求会404。这是部署环境差异化导致的经典坑。后端处理这个请求的Servlet返回的就是一个true字符串不是JSON。因为只有一个布尔值没必要引JSON库直接用response.getWriter().write(String.valueOf(exists))就行。等你以后要返回对象再换成fastjson或Gson也不迟。5.3 旅游路线图片的坐标定位问题有一个热搜词是jsp图片如何对坐标定位。这个在旅游商城里确实会用到——比如地图展示某条路线的途经景点或者首页的景区导览图。我当时的做法是在图片上叠一层透明div通过CSS绝对定位把可点击区域框出来同时存储每个可点击区域的坐标。思路是这样的假设有一张景点导览图要求用户点击某个景点时弹出该景点的介绍。在后端管理里维护每个景点的坐标(x1, y1, x2, y2)前端渲染时生成对应矩形的可点击区域。不用map标签直接用div透明度方式更好控制样式div styleposition: relative; display: inline-block; img src${pageContext.request.contextPath}/images/map.jpg width800 height600 / div styleposition: absolute; left: 120px; top: 80px; width: 100px; height: 60px; cursor: pointer; onclickshowSpotInfo(spot_1)/div div styleposition: absolute; left: 400px; top: 300px; width: 80px; height: 50px; cursor: pointer; onclickshowSpotInfo(spot_2)/div /div这种方案的优点是定位直观、改坐标方便缺点是不够语义化没有自适应能力。后来我做了个优化——把坐标换成百分比。left用(x/800)*100%计算图片缩放时热区跟着等比例变化不会点偏。5.4 JSP页面加载后自动刷新一次热搜词里有一条jsp页面让加载完后刷新一次。这个在我们系统里还真有一个实际场景下单成功跳转到成功页后需要自动刷新一次让后端把订单状态从处理中更新为已完成。因为订单创建是异步提交的页面第一次渲染时状态还没更新。实现方式是在head里写一行meta http-equivrefresh content0.50.5秒后刷新页面。这个写法简单粗暴但有副作用——如果页面里有表单数据会丢。旅游商城的订单确认页我用了这个是因为这个页面只需要展示动态的订单状态不需要用户输入。你要是做后台管理页面千万别这么干还是用Ajax轮询或者WebSocket推送靠谱。5.5 JavaScript保留两位小数价格在页面上展示经常遇到totalPrice是1980.5但用户要看到1980.50。JavaScript里不能直接用toFixed(2)了事因为(1.005).toFixed(2)返回的是1.00而不是1.01这是浮点数精度问题。我封装了一个方法function formatPrice(price) { return (Math.round(parseFloat(price) * 100) / 100).toFixed(2); }先把价格乘以100取整再除以100绕开浮点运算误差最后toFixed(2)补零。这个方法在整个项目的前端到处用订单金额、路线价格、合计价格统一走它没有一个金额显示是错的。6. Filter统一登录拦截与乱码处理项目上线前必须做的两个全局配置6.1 登录过滤器别在每一个Servlet里写重复代码我在第四部分讲了下单Servlet里的登录判断。但项目里有下订单、查看订单、个人中心好几个Servlet都需要这个逻辑如果每个都写一遍你维护起来就是想哭的节奏。用Filter统一拦截是最优雅的解法WebFilter(/*) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; HttpSession session req.getSession(); User user (User) session.getAttribute(loginUser); String uri req.getRequestURI(); // 放行登录、注册、静态资源、首页、路线详情等公开资源 if (user ! null || uri.endsWith(/login.jsp) || uri.endsWith(/register.jsp) || uri.endsWith(/index.jsp) || uri.contains(/user/login) || uri.contains(/user/register) || uri.contains(/route/list) || uri.contains(/route/detail) || uri.contains(/css/) || uri.contains(/js/) || uri.contains(/images/)) { chain.doFilter(request, response); } else { resp.sendRedirect(req.getContextPath() /login.jsp); } } Override public void init(FilterConfig filterConfig) throws ServletException {} Override public void destroy() {} }白名单的思路是核心。你不可能把每个路径都列全但把公开资源列全是可以做到的。这个方法有一个隐含坑/*会拦截JSP页面但JSP在访问时也会经过Filter所以/login.jsp这类页面要记得放行才能正常显示。6.2 编码过滤器一个乱码问题折磨了我一个下午JSPServlet的乱码问题堪称新手杀手。具体表现是页面显示正常但往MySQL里存入的中文变成??或者Ajax返回的中文变成乱码。乱码的根源是编码链路上某一环不一致。我整理出一套四处统一的排查原则照着设置基本不会乱第一处数据库连接URL里必须带characterEncodingutf8jdbc:mysql://localhost:3306/travel_db?useSSLfalsecharacterEncodingutf8第二处MySQL表结构建表时要指定字符集。如果你建表时没指定默认是latin1中文必乱。补救办法是ALTER TABLE route CONVERT TO CHARACTER SET utf8mb4;第三处Servlet接收请求参数时要手动设置编码。GET请求的参数编码在Tomcat里默认是ISO-8859-1POST请求默认也是。最好写一个编码FilterWebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); chain.doFilter(request, response); } }注意这个response.setContentType一定要写在chain.doFilter之前不然响应头已经发出去了再设置编码就晚了。第四处JSP页面头部必须声明% page contentTypetext/html;charsetUTF-8 languagejava %这四处统一成UTF-8之后乱码问题基本绝迹。如果还有漏网的检查一下IDE的文件编码设置IDEA右下角可以改文件保存时不是UTF-8也会出问题。6.3 MySQL的SSL连接错误热搜词里有一条mysql ssl连接错误我在用JDBC直连MySQL 5.7时也遇到了一模一样的报错Communications link failureThe server selected protocol version 10原因是MySQL 5.7默认开启了SSLJDBC连接时驱动尝试使用SSL握手失败。解决方式就是在JDBC URL里显式关闭SSLprivate static final String URL jdbc:mysql://localhost:3306/travel_db?useSSLfalsecharacterEncodingutf8;useSSLfalse这一行建议你直接养成肌肉记忆写进去。本地开发根本不需要SSL加密连接不开还能省握手开销到了生产环境如果真要开SSL再单独配置证书也不迟。7. 部署上线实录从IDEA本地跑通到Tomcat服务器发布7.1 War包打包与部署的完整命令流程项目在IDEA里右键Build → Build Artifacts → Rebuild生成war包但这个操作如果遇到依赖问题还是Maven打包更靠谱。我当时的命令是mvn clean package -DskipTests打包完成后war包会在target/目录下。我把它命名为travel_mall.war丢到Tomcat的webapps/目录。Tomcat启动时自动解压部署# 进入tomcat bin目录 ./startup.sh # 查看日志确认部署成功 tail -f ../logs/catalina.out访问地址是http://localhost:8080/travel_mall/。域名加项目名的路径可能不太好看但这是标准做法。如果你想直接通过根路径访问把war包改名为ROOT.war替换掉Tomcat自带的ROOT目录即可。7.2 部署后最容易发生的三个翻车现场翻车现场一图片路径404。本地IDEA里图片的绝对路径指向的是你的本地磁盘目录但部署到Linux服务器后目录根本不存在。解决方式是web.xml里配置虚拟路径映射servlet-mapping servlet-namedefault/servlet-name url-pattern/upload/*/url-pattern /servlet-mapping然后把图片上传目录建在Tomcat外的绝对路径下再把这行配置加进去请求/upload/xx.jpg时Tomcat会映射到你指定的磁盘目录。翻车现场二MySQL连接失败。服务器上的MySQL默认只监听localhostJDBC URL里如果写的是127.0.0.1没问题但如果数据库在另一台机器上需要给MySQL开远程访问权限GRANT ALL PRIVILEGES ON travel_db.* TO root% IDENTIFIED BY 123456; FLUSH PRIVILEGES;另外Linux服务器防火墙没开放3306端口也会连接失败检查一下防火墙规则。翻车现场三JSP页面刷新一次后Session失效。部署后发现用户刚登录点两下又回到登录页。后来排查发现是Session Cookie的路径问题。Tomcat默认Session Cookie的路径是/但你部署的上下文是/travel_mall浏览器可能把Cookie路径处理错了。检查conf/context.xml里Session Cookie的配置与部署上下文是否匹配。多数情况下不手动改不会出事但如果出现登录后跳转就丢失Session的诡异问题优先检查这里。7.3 生产环境MySQL配置的几个关键项部署到生产后MySQL默认配置比较保守我根据实际运行情况调整了三处[mysqld] max_connections 200 character-set-server utf8mb4 default-time-zone 8:00max_connections默认151并发稍高就会报Too many connectionsdefault-time-zone不设置CURRENT_TIMESTAMP创建时间会比北京时间晚8小时订单时间显示混乱字符集默认可能不是utf8mb4会影响特殊符号存储。这三项你可以在my.cnf里加好重启MySQL生效。8. 性能与体验优化我做的四个关键升级8.1 首页数据缓存手动实现SimpleCacheJSPServlet没有现成的缓存方案但首页路线列表是高频访问、低频更新的数据完全可以在应用启动时加载一次放内存里。我当时用了一个简单的HashMap实现了缓存public class RouteCache { private static MapInteger, ListRoute cache new ConcurrentHashMap(); private static final long EXPIRE_TIME 10 * 60 * 1000; // 10分钟 public static ListRoute getList(int categoryId) { ListRoute list cache.get(categoryId); if (list null) { list new RouteDao().findByCategory(categoryId); cache.put(categoryId, list); } return list; } public static void clear() { cache.clear(); } }后台修改路线上下架时调用RouteCache.clear()清空缓存。这个方案虽然简单但性能提升立竿见影——首页数据库查询从每次请求都执行变成了十分钟才执行一次。需要注意缓存数据的一致性。如果后台高频修改路线信息10分钟缓存会造成用户看到旧数据。我的策略是价格、库存这类敏感信息不做缓存只缓存路线名称、简介等列表展示信息下单时实时查库不走缓存。8.2 SQL语句的LIKE模糊查询优化路线搜索用LIKE %关键字%在数据量小的时候没问题但如果路线表有上万条数据全表扫描就很慢了。我给name字段加了索引并调整了查询逻辑SELECT * FROM route WHERE status 1 AND name LIKE ? LIMIT ? OFFSET ?LIKE %keyword%这种写法即使有索引也用不上因为前导通配符导致无法走索引。优化方案有两个方向一是改用全文索引MATCH...AGAINST二是分词后拼接成多个LIKE条件。我实测后选择了方案二把关键字按空格拆成多个词拼成LIKE %词1% AND name LIKE %词2%可读性好且能利用部分索引。如果你测试的数据量不大这个优化看不出来明显效果。但面试时你能说出索引失效这几个字比闷头写crud要值钱很多。8.3 订单列表的分页懒加载后台管理端订单列表数据增长快每次全量查询拉几百条数据渲染在表格里页面会明显卡顿。我加了一个基于页码的懒加载——初始只加载第一页20条点击下一页通过Ajax请求获取下一批数据用JavaScript动态渲染表格行。这个功能逻辑不复杂但有个细节用户的查询状态要保存。用户输入了订单号搜索点第二页时Ajax请求必须带上当前的搜索条件不然翻页后搜索条件丢了数据就不对了。8.4 JSP页面静态资源合并与压缩这个优化不改变功能但直接影响首屏加载速度。旅游商城首页引了多个CSS和JS文件浏览器每个文件都要发起一次HTTP请求。我把多个CSS用%include filecommon_css.jsp%合并到一个JSP片段里输出减少了请求次数。同时用Maven插件对JS和CSS做了压缩文件体积大概减少了40%左右。说实话这两个操作在本地开发时感受不到差别但在电信3G网络环境下测试我模拟过首屏加载时间从4.2秒降到了2.6秒。对旅游网站来说首页加载速度直接决定跳出率这部分优化我觉得是值得的。9. 项目复盘这套技术栈还能怎么演进做完整套项目之后我发现一个很明显的感受JSPServlet这套玩法虽然繁琐但它把Web开发的底层链路完整暴露在你面前。你在里面踩过的每一个坑——乱码、Session丢失、连接泄漏、转发表单数据丢失——最后都会变成你理解Spring Boot原理的钥匙。如果这个项目要继续演进我的建议按优先级排序第一引入MVC框架。保留现有的实体类和DAO层把Servlet换成Spring MVC的ControllerJSP作为视图层不动。这样做的好处是业务代码绝大部分可以复用但请求参数绑定、数据校验、JSON序列化这些重复劳动被框架接管了。第二引入MyBatis。把DAO层手写的JDBC代码换成Mapper接口加XMLSQL还可以继续用你调优过的那些只是不用再手动写PreparedStatement和ResultSet转换了。第三前后端分离。把JSP页面全部换成Vue或React前端项目后端提供纯JSON接口。这样做的前提是你要先补上鉴权方案——JWT或Session以及跨域处理。旅游商城这种业务前后端分离后移动端H5可以无缝复用同一套API。第四业务扩展。这个项目目前只有旅游路线的展示和预订可以加的功能其实很多路线评论和评分、优惠券系统、支付回调对接、后台数据统计报表。随便挑一个做深都能成为你面试时讲项目的亮点。回顾整个开发过程我最想分享的心得其实是一条很朴素的教训做JavaWeb项目别急着上框架先想清楚业务里的每个动作要操作哪些表、改哪些字段、在什么情况下要回滚。把这些问题想清楚了用ServletJSP写一遍比你上来就Spring Boot写CRUD得到的成长要多得多。这个旅游商城项目我在写博客时重新跑了一遍当年写的核心逻辑一行没改也能正常跑。技术会过时但对业务的理解、对事务的敬畏、对细节的执着这些永远不会过时。