基于MVC模式的原生Servlet+JDBC点餐系统项目实战解析

发布时间:2026/10/8 8:28:10
基于MVC模式的原生Servlet+JDBC点餐系统项目实战解析 简介这是一份基于MVC开发模式构建的原生ServletJDBC点餐系统项目适合正在准备毕业设计、课程设计的计算机专业学生也适合希望夯实Java Web基础的开发者。项目完整覆盖Servlet生命周期、JDBC数据库操作、用户认证与会话管理、点餐流程处理等核心环节通过Model-View-Controller分层展示前后端协作方式。压缩包共139个文件包含81张图片素材、20个JSP视图页面、6个Java源码及对应class文件、7个Jar依赖库另有SQL数据库脚本和需求文档其中图片多用于界面展示JSP负责页面渲染Java源码对应MVC各层核心逻辑SQL脚本用于初始化用户、菜品、订单等数据表便于对照部署与二次开发。压缩包体积3.76MB轻量易下载目前已有61人学习使用适合作为入门级完整项目参考。借助源码、数据库脚本和文档使用者可以快速搭建Tomcat运行环境梳理从登录、选餐到下单的完整链路理解Servlet与JDBC在实际项目中的落地写法为后续独立开发类似管理系统积累经验。1. 拿到这个“基于MVC开发模式开发原生Servletjdbc服务器项目-点餐系统”的包先看它值不值得用来当课设或练手很多人在学完 Java Web 基础之后都会面临一个尴尬期Servlet 会写了JDBC 也敲过但一说到“把两者组织成一个完整项目”脑子里只有往 JSP 里塞 Java 代码的冲动。这个点餐系统项目本质上就是把你零散学过的 Servlet、JSP、JDBC 用 MVC 分层串成一个完整服务器项目能注册登录、能浏览菜品、能加入购物车、能下单。它的价值不在“点餐”这个业务本身而在于它演示了在不引入 Spring 全家桶的前提下Controller、Service、DAO、JSP 这四层代码到底怎么分、怎么协作、事务该放在哪一层处理。如果你的课设要求是“使用 Java Web 原生技术栈不使用框架”或者你想在学 Spring MVC 之前把 Servlet 的底层机制彻底跑通这个方向非常值得做。有一点先说清楚这项目能帮你建立“Controller 只管路由、Service 只管业务、DAO 只管数据”的分层直觉这份直觉会在你以后看 Spring MVC 源码时反复被验证。本篇文章会顺着这个项目把技术点拆开讲每一步都给出能抄的代码和参数保证你照着能跑起来。2. 拆解 MVC 分层这个点餐系统的三个包、两张作用域以及为什么 Servlet 里不许直接写 JDBC2.1 Servlet 只当“分发器”Controller 层该干什么、不该干什么MVC 里的 CController在这个项目中就是那一个个 Servlet 类。常见做法是给每个业务入口建一个 Servlet比如LoginServlet、RegisterServlet、DishListServlet、OrderServlet。Servlet 的核心职责是拿到 HTTP 请求里的参数把它们转成 Java 对象交给 Service 层处理再根据处理结果决定转发还是重定向到某个 JSP。它不应该做的事有两件第一不直接写DriverManager.getConnection()第二不在 Servlet 里用out.println()拼 HTML。第一点是“原生 ServletJDBC”项目最容易走歪的地方。很多人写课设的时候图省事直接在doPost里写SELECT * FROM dish把ResultSet倒出来塞进request转发给 JSP。这在小项目里运行没问题但它会让Connection的生命周期和 HTTP 请求的生命周期绑在一起后续想加连接池、想统一事务都得把所有 Servlet 翻出来重改。我一般会用一个BaseServlet做顶层封装把“接收参数、调 service、转发”这个套路固定下来。WebServlet(/dish/list) public class DishListServlet extends HttpServlet { private DishService dishService new DishService(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); resp.setContentType(text/html;charsetUTF-8); // 参数解析页码从请求里拿缺省就取第 1 页 int pageNum 1; String pageNumStr req.getParameter(pageNum); if (pageNumStr ! null !pageNumStr.isEmpty()) { pageNum Integer.parseInt(pageNumStr); } // 业务处理交给 ServiceServlet 不碰数据库 PageResultDish pageResult dishService.findDishPage(pageNum, 3); // 把数据放进 request 域JSP 用 EL 表达式取 req.setAttribute(pageResult, pageResult); req.getRequestDispatcher(/WEB-INF/jsp/dishList.jsp).forward(req, resp); } }这段代码里有三个值得注意的约定WebServlet注解用于声明路由项目里不需要再在web.xml里配置servlet-mapping/dish/list这种路径一看就知道是查菜品列表比起/dishListServlet更接近 REST 风格/WEB-INF/jsp/这个目录不是随便定的放在WEB-INF下的 JSP 页面无法被浏览器直接访问强制请求必须经过 Servlet这对维护 MVC 流程是很有用的约束。至于pageSize为什么要定成 3是为了在项目截图里更容易看出分页效果实际开发中一般取 10。看到这里你应该发现了Servlet 的代码量很小真正的业务逻辑都在DishService里。所谓的 MVC 分层第一步就是先让 Controller 瘦下来。2.2 Model 层的两个名字DAO 负责取数Service 负责业务别让点餐逻辑裸奔Model 层在 MVC 里是最容易被误解的很多人把它等同于“实体类”。实体类比如Dish、Order、User确实是 Model 的一部分但 Model 层的完整形态应该包含实体类、DAOData Access Object和 Service。DAO 层的职责非常纯粹封装对数据库的增删改查。比如DishDAO里有findAll()、findById()、updateStock()每个方法内部都只做“数据库操作”这一件事不判断“库存够不够”不计算“总价是多少”。Service 层才是处理点餐业务规则的地方判断用户是否登录、购物车是否为空、库存是否充足、下单后要同时扣库存和记录订单明细。如果把这些判断写在 DAO 里DAO 就会被点餐业务污染如果写在 Servlet 里以后想加一个“App 端下单”的入口业务逻辑就得复制一份。拿“用户点餐”这个场景举例Service 层的典型代码结构如下public class OrderService { private OrderDAO orderDAO new OrderDAO(); /** * 创建订单这里抛出异常时整个事务必须回滚 */ public boolean createOrder(int userId, MapInteger, Integer cart, double totalPrice) throws SQLException { Connection conn JdbcUtils.getConnection(); try { conn.setAutoCommit(false); // 往 orders 表插入主记录 int orderId orderDAO.insertOrder(conn, userId, totalPrice); // 往 order_detail 表插入明细 for (Map.EntryInteger, Integer entry : cart.entrySet()) { orderDAO.insertOrderItem(conn, orderId, entry.getKey(), entry.getValue()); } // 扣减库存 for (Integer dishId : cart.keySet()) { orderDAO.deductStock(conn, dishId, cart.get(dishId)); } conn.commit(); return true; } catch (SQLException e) { conn.rollback(); throw e; } finally { JdbcUtils.closeConnection(conn); } } }注意createOrder方法的三个参数userId表示谁点的餐cart是购物车里菜品 id 和数量的映射totalPrice是前端算好传过来的总金额。这里没有直接在 Service 里写SELECT和UPDATE而是各自封装在OrderDAO里这就是分层带来的好处——你可以在不改变 Service 方法签名的情况下把OrderDAO的实现从 JDBC 换成 MyBatis这恰好就是以后学习持久层框架时最熟悉的切换方式。还有一个容易被忽略的细节conn这个Connection对象是从JdbcUtils里拿出来的并且被作为参数传给了 DAO 方法。为什么要传参因为同一笔订单的插入、明细、扣库存必须发生在同一个数据库连接里才能保证它们共享同一个事务。如果每个 DAO 方法内部都自己调一次JdbcUtils.getConnection()那这三个操作就分属三个独立连接一旦中途出错根本无法统一回滚。这一点在第四章还会再展开。2.3 View 层的 JSP 做到什么程度算合格禁止 Scriptlet用 EL 和 JSTL 收尾View 层的文件在项目中集中在webapp/WEB-INF/jsp目录下。很多老教程的 JSP 页面长这样页面上方导入java.util.*然后用% for(Dish dish : list) { %这种 Scriptlet 遍历数据。这种做法有三个非常实际的痛点第一页面里一旦出现 Java 代码前端想调整样式就很容易乱动业务代码第二% ... %里的变量如果为 null整个页面运行时报错时Tomcat 报出来的错行号对不上任何人的编辑器第三团队协作时前端和后端改同一个文件Git 冲突概率极高。合理的做法是 JSP 里只写 HTML 和标签。展示菜品列表的页面结构如下% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html head title菜品列表/title /head body h2点餐系统 - 菜品列表/h2 table border1 tr th菜名/th th价格/th th操作/th /tr c:forEach items${pageResult.list} vardish tr td${dish.name}/td td${dish.price}/td td a hrefcart?actionadddishId${dish.id}加入购物车/a /td /tr /c:forEach /table /body /html${pageResult.list}是 EL 表达式它对应 Servlet 里req.setAttribute(pageResult, pageResult)中的pageResult对象${dish.name}调用的是Dish类的getName()方法。注意 JSP 顶部没有写% page importjava.util.* %也没有任何 Java 块的痕迹。项目里如果要在 JSP 中做条件判断、循环、格式化金额统一靠 JSTL 标签。想做到这个程度需要在WEB-INF/lib里放jstl-1.2.jar这是唯一的外部依赖之一。还有一个小细节是 JSP 页面里隐藏表单的用法。提交订单时不要用a href跳转 GET 请求来修改数据否则刷新页面会重复下单。应该用表单 POSTform actionorder/create methodpost input typehidden namecartJson value${cartJson} button typesubmit确认下单/button /form购物车数据以 JSON 字符串的形式放在隐藏域里提交Servlet 拿到后用jackson-databind把它反序列化成MapInteger, Integer。这比把整个购物车对象放进 Session 再在另一个页面上取更符合“请求之间尽量无状态”的原则也方便以后对接微信小程序。不过这里有个细节如果菜品数量很多JSON 串会很长这时候就老老实实把购物车放 Session 里别塞进表单。3. 把环境与骨架搭起来Servlet 注册方式、JDBC 连接 MySQL、连接池 Druid 的配置3.1 用 WebServlet 还是 web.xml 注册路由一个点餐系统最少需要几张“表”起步的时候先在 MySQL 里建库建表。点餐系统最典型的表结构是四张用户表、菜品表、订单主表、订单明细表。菜品和订单之间是多对多关系所以必须拆出明细表。建表 SQL 如下CREATE DATABASE IF NOT EXISTS ordering_system DEFAULT CHARACTER SET utf8mb4; USE ordering_system; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL ); CREATE TABLE t_dish ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0 ); CREATE TABLE t_order ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, total_price DECIMAL(10,2) NOT NULL, create_time DATETIME NOT NULL, FOREIGN KEY (user_id) REFERENCES t_user(id) ); CREATE TABLE t_order_detail ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, dish_id INT NOT NULL, count INT NOT NULL, price DECIMAL(10,2) NOT NULL, FOREIGN KEY (order_id) REFERENCES t_order(id), FOREIGN KEY (dish_id) REFERENCES t_dish(id) );表设计里有几个具体的决策点价格字段用DECIMAL(10,2)而不是DOUBLE因为浮点数在小数运算时会有精度损失点餐系统的金额必须精确到分库存字段默认值写成 0 而不是 NULL是为了避免扣库存时stock - count计算出 NULLcreate_time用DATETIME让数据库来处理时间戳应用层只传new Date()给PreparedStatement。如果你的实验环境是 MySQL 8.0 以上需要注意utf8mb4字符集它能完整支持中文和 emoji虽然点餐系统用不到 emoji但这个是当下建库的默认习惯。路由注册选哪种方式直接影响后续代码的书写习惯。web.xml是传统的 Servlet 3.0 之前的做法现在主流 IDE 新建动态 Web 项目时只会生成空的web.xml而WebServlet注解是 Servlet 3.0 之后推荐的声明式路由方式。这个项目采用WebServlet它更直观而且能连loadOnStartup属性一起配置比如登录校验过滤器就可以设置loadOnStartup 1来保证过滤器比 Servlet 先初始化。这里需要提前说明一下WebServlet的常见坑注解的urlPatterns值必须写成数组形式或者单个字符串同时不能有空格。写成WebServlet(/order/create )这种带尾部空格的路径Tomcat 启动时会直接报Unable to map servlet异常而且报错信息里不会明确提示空格问题这是新手经常翻车的地方。3.2 JDBC 连接 MySQL 的 4 个参数和一个最容易翻车的 driver 类名JDBC 连接数据库在所有 Java Web 项目里都绕不开。点餐系统的JdbcUtils工具类负责提供Connection和关闭资源代码如下public class JdbcUtils { private static final String URL jdbc:mysql://localhost:3306/ordering_system?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai; private static final String USERNAME root; private static final String PASSWORD 123456; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new RuntimeException(MySQL 驱动类加载失败, e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USERNAME, PASSWORD); } public static void closeResources(Connection conn, Statement stmt, ResultSet rs) { if (rs ! null) { try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } } if (stmt ! null) { try { stmt.close(); } catch (SQLException e) { e.printStackTrace(); } } if (conn ! null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }这四个参数里最有说法的是driver类名。如果项目用的mysql-connector-java是 5.x 版本驱动类名必须写com.mysql.jdbc.Driver如果是 8.x 版本驱动类名就得写com.mysql.cj.jdbc.Driver注意中间多了.cj.。同时 8.x 的连接 URL 里还必须加serverTimezoneAsia/Shanghai不然PreparedStatement写入DATETIME字段时会报时区相关异常。这个“版本号决定类名和 URL 参数”的差异可以说困扰了所有过来人。关资源的顺序还有个讲究应该先关ResultSet再关Statement最后关Connection。最保险的写法是像上面代码那样在方法里接收这三个对象依次关闭。很多人只关Connection觉得连接关了其他都无所谓但数据库游标的释放时机和结果集有关不关ResultSet在连接池复用的场景下会慢慢泄漏内存。从 Java 7 开始也可以把Connection、Statement、ResultSet放进try-with-resources但要注意ResultSet被关闭时相关联的Statement是否也被关闭取决于具体 JDBC 驱动实现。3.3 引入 Druid 连接池为什么连接池是点餐系统的“刚需”不是可选项从上面的JdbcUtils来看连接是每次调用都新建、用完就关闭。这种方式的致命问题在于每个Connection从创建到销毁TCP 握手、MySQL 认证、权限检查全都走一遍在本地开发时感觉不到但只要点餐系统被多个人同时访问比如答辩时老师、同学、你自己三台电脑同时打开页面连接建立的开销会直接拖垮接口响应时间。更严重的是MySQL 默认最大连接数只有 151如果每个请求都新建连接且因为代码异常没关闭很快会报Too many connections。解决这个问题的最常见方案是引入 Druid 连接池。在pom.xml或手动管理的lib目录中加入druid-1.2.x.jar然后改JdbcUtilspublic class JdbcUtils { private static DruidDataSource dataSource; static { try { Class.forName(com.mysql.cj.jdbc.Driver); dataSource new DruidDataSource(); dataSource.setUrl(jdbc:mysql://localhost:3306/ordering_system?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai); dataSource.setUsername(root); dataSource.setPassword(123456); dataSource.setInitialSize(5); dataSource.setMaxActive(20); dataSource.setMinIdle(5); dataSource.setMaxWait(3000); } catch (Exception e) { throw new RuntimeException(e); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }上面这段代码的关键参数我解释一下setInitialSize(5)表示连接池启动时就预先创建 5 个连接避免第一个请求慢吞吞setMaxActive(20)是连接池最多同时给出 20 个连接超过这个数且setMaxWait(3000)设定了等待 3 秒3 秒内没有空闲连接就直接抛异常而不是无限阻塞setMinIdle(5)确定连接池最少保持 5 个空闲连接。引入连接池后有一个以前裸连时不会遇到的问题从连接池拿出的连接调用conn.close()并不是真的关闭 TCP 连接而是把连接还给池子。所以如果你的代码里没有正确调用close()连接被“借走”而不还池子很快就空了。顺带提一句用 Druid 之后连接 URL 上的useSSLfalse建议保留。MySQL 8.x 默认开启 SSL 校验如果不显式关闭本地开发环境很容易在建立连接时因为证书问题报Communications link failure这种错误往往不是你代码有 bug而是环境参数不对。4. 点餐业务的核心流程从菜品分页到购物车再到下单的事务控制4.1 菜品分页查询JDBC 的 LIMIT 语法和 Servlet 的响应页面搭在一起点餐系统里菜品数量少则几十多则上百全查出来塞给 JSP 会让页面首屏渲染变慢。分页查询在 JDBC 里是用LIMIT ? OFFSET ?来实现的MySQL 特有语法PostgreSQL 相同SQL Server 用OFFSET ... FETCH。DAO 层的分页方法如下public ListDish findPage(int pageNum, int pageSize) throws SQLException { ListDish list new ArrayList(); String sql SELECT id, name, price, stock FROM t_dish ORDER BY id LIMIT ? OFFSET ?; try (Connection conn JdbcUtils.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, pageSize); ps.setInt(2, (pageNum - 1) * pageSize); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Dish dish new Dish(); dish.setId(rs.getInt(id)); dish.setName(rs.getString(name)); dish.setPrice(rs.getBigDecimal(price)); dish.setStock(rs.getInt(stock)); list.add(dish); } } } return list; }注意LIMIT ? OFFSET ?里的参数顺序LIMIT后面的是每页条数OFFSET后面的是跳过的行数。第 1 页跳过 0 行第 2 页跳过(2-1) * 3 3行。这段代码用了try-with-resources所以Connection、PreparedStatement、ResultSet都会自动关闭不需要在finally里手写三段关闭代码。getBigDecimal对应数据库中DECIMAL类型等价于用rs.getBigDecimal(price)把0.00这类值完整取出不会像getDouble那样出现精度漂移。分页后端返回时还需要把“总页数”“当前页”“上一页/下一页链接”这些信息打包成一个PageResult对象。这个对象里通常只放四个字段list当前页数据、pageNum当前页码、totalPage总页数、totalCount总记录数。总记录数用SELECT COUNT(*) FROM t_dish查出分页控件在 JSP 端就可以用 JSTL 渲染成上一页、下一页的链接链到dish/list?pageNum2这种 URL。4.2 把菜加进 Session 购物车购物车该放 Session 还是数据库选购物车的存放位置是这个点餐系统里最体现开发者经验的一个设计决策。很多教程让把购物车直接建一张数据库表用cart_id关联用户做法没错但对一个展示型课设来说属于过度设计。这个项目的合理做法是把购物车对象放进HttpSession由Session的setAttribute(cart, cartMap)持有。cart的类型可以简单定义为MapInteger, Integerkey 是菜品 idvalue 是加购数量。加购的 Servlet 代码如下WebServlet(/cart/add) public class CartAddServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String dishIdParam req.getParameter(dishId); if (dishIdParam null || dishIdParam.isEmpty()) { resp.sendRedirect(dish/list); return; } int dishId Integer.parseInt(dishIdParam); HttpSession session req.getSession(); SuppressWarnings(unchecked) MapInteger, Integer cart (MapInteger, Integer) session.getAttribute(cart); if (cart null) { cart new HashMap(); session.setAttribute(cart, cart); } cart.put(dishId, cart.getOrDefault(dishId, 0) 1); // 重定向到购物车页面避免刷新重复提交 resp.sendRedirect(req.getContextPath() /cart/view); } }这段逻辑里有两处值得细化。第一处是SuppressWarnings(unchecked)把session中的Object强转为Map类型会有运行期类型安全警告点餐系统规模小可以加注解忽略如果做更大型项目建议用专门的Cart类来替代Map类内部持有MapInteger, CartItem。第二处是为什么用了sendRedirect而不是forward这里是一个典型的“会话数据变更后要立即刷新页面”的操作如果forward给cart/view.jsp用户按 F5 刷新时浏览器会重新提交上一次的加购请求购物车数量翻倍sendRedirect会让浏览器重新发一个 GET 请求到cart/view这样才是安全的。购物车存在 Session 里的另一个天然好处是天然地按用户隔离每个浏览器会话都有自己的购物车不用做任何关联查询。但要注意HttpSession默认空闲超时是 30 分钟Tomcat 默认值用户加购后逛了超过 30 分钟购物车就无声无息地没了。如果点餐系统里想避免这个现象可以在 web.xml 里调整session-configsession-timeout60/session-timeout/session-config给用户留出更长操作时间。4.3 下单扣库存的事务边界setAutoCommit(false) 到底在防什么这是整个点餐系统里最核心的一段代码。下单动作包含三件事插入订单主记录、插入订单明细记录、扣减菜品库存。这三件事必须同时成功或同时失败。如果订单插入成功但库存扣减失败会出现“超卖”——用户下了 3 份菜库存只减少 1 份第二天商家对不上账。第三章里OrderService.createOrder方法已经通过conn.setAutoCommit(false)将这个操作包成了事务。这里要补充说明的是setAutoCommit(false)之后连接状态的变化从这一刻起当前连接上的所有 SQL 都不会真正落盘直到调用conn.commit()。执行过程中任何一条 SQL 抛出SQLException程序进入catch块调用conn.rollback()把之前所有未提交的操作全部撤销。所以DAO方法必须接收外部传入的Connection并且内部不能再调用JdbcUtils.getConnection()否则就拿不到同一个连接事务自然失效。一个更隐蔽的问题是库存扣减的并发控制。如果两个人同时点了最后一份菜两个请求同时读到stock 1各自计算出stock - 1 0再写回最终库存变成 0但有两个订单都认为自己购买成功。解决这个问题的方式是在UPDATE语句里加上stock ?条件UPDATE t_dish SET stock stock - ? WHERE id ? AND stock ?UPDATE影响行数可以通过PreparedStatement.executeUpdate()的返回值拿到的。如果返回 0说明库存已不足DAO可以抛出自定义业务异常Service 层捕获后回滚整个事务。这样可以不加SELECT FOR UPDATE也能避免超卖因为 MySQL 的UPDATE本身会对命中的行加锁stock ?条件在锁内判断不会出现“读到旧库存再覆盖写”的竞态。看起来简单但这实际是订单系统里最常用的一种乐观锁写法。事务写完之后还要考虑create_time字段的填充。t_order表里create_time DATETIME NOT NULL如果insertOrder的 SQL 没写这个字段数据库会直接报字段不能为 NULL 的异常。正确写法是 SQL 中显式写入create_time并使用new java.sql.Timestamp(System.currentTimeMillis())传入参数或者直接在 SQL 里写NOW()。5. 避坑专场这 5 个点餐系统开发中最常见的问题按“现象→原因→解决”排查5.1 页面全是问号Tomcat 和 JSP 两处编码必须一起调现象访问dish/list时页面上所有中文菜名显示为一串???数据库查出来的中文本身是正常的。原因这个问题通常有两个层次。第一MySQL 连接 URL 里没有characterEncodingutf8驱动和数据库之间传输二进制时就乱码了第二即使数据库连接正常ServletdoGet里没有调用req.setCharacterEncoding(UTF-8)而 JSP 页面顶部也漏掉% page contentTypetext/html;charsetUTF-8 %Tomcat 默认按ISO-8859-1解码请求参数、按ISO-8859-1输出响应体中文字符自然全变问号。解决统一三处设置。第一处是连接 URL 加useUnicodetruecharacterEncodingutf8第二处是 Servlet 里每个doGet/doPost开头写req.setCharacterEncoding(UTF-8)和resp.setContentType(text/html;charsetUTF-8)或者更省事的做法是写一个CharacterEncodingFilter用WebFilter(/*)把编码逻辑统一处理掉第三处是 JSP 文件page指令里的contentType属性写对。做完这三步再遇到???就是查询条件或表结构本身的问题。5.2 连接池的“Too many connections”连接没归还还是池子不够大现象系统运行几小时后页面突然报Cannot get a connection, pool exhausted重启 Tomcat 又能恢复过几小时再次复现。原因最常见的原因是代码里使用DriverManager.getConnection()裸连时忘记在finally中关闭Connection改用连接池后虽然也调了close()但调用的位置不对。连接池的close()是把连接归还到池子里不是真的物理关闭如果代码中从池子里借了连接后因为某段逻辑提前returnclose()被跳过这条连接就从“已借出”变成“永久丢失”。另一个原因是setMaxActive设置得太小点餐系统虽然本地开发时只有你一个人访问但浏览器可能会同时发起多个请求加上 TCP 保持连接20 个连接如果每个请求都持有 3 秒并发一高也容易打满。解决检查所有getConnection()的调用代码确保finally块里调用JdbcUtils.closeResources()或者conn.close()同时把setMaxActive调整为 50setMaxWait调整为 5000加大容量。如果问题只在压力测试时出现那就是正常的连接池排队现象不需要在意。这里给一个排查技巧Druid 的dataSource.getActiveCount()可以打印活跃连接数在怀疑泄漏时用System.out.println输出到控制台观察一段时间的曲线。5.3 MySQL 驱动类找不到lib 目录没拷进去不是代码写错了现象Tomcat 启动时Class.forName(com.mysql.cj.jdbc.Driver)抛ClassNotFoundException虽然 IDE 里External Libraries看得到驱动包。原因在 IDEA 或 Eclipse 里直接把mysql-connector-java-8.0.x.jar以Add as Library的方式加入项目依赖只会进入编译期 classpath但 Tomcat 运行时根本不会读 IDEA 的模块依赖它只读WEB-INF/lib目录。如果项目用的是 Maven 管理pom.xml里声明了依赖IDE 会把 jar 自动复制进target的 lib 目录如果用普通动态 Web 项目jar 必须手动放进webapp/WEB-INF/lib或者通过Artifacts → Available Elements右键移动到WEB-INF/lib下。解决先检查webapp/WEB-INF/lib里有没有驱动 jar没有就手动复制进去。如果是 Maven 项目且没有自动同步执行mvn clean package重新构建再看target/xxx/WEB-INF/lib。驱动问题还有个隐蔽点如果同时导入mysql-connector-java5.x 和 8.x 两个版本Tomcat 加载哪个类取决于 classpath 顺序容易造成Server returns invalid timezone之类的不明错误。项目里只留一个版本号。5.4 购物车里的菜品信息改价后Session 里还是旧价格现象管理员后台改了某个菜品的价格用户购物车页面显示的还是修改钱的价格。退出重新登录或者重启浏览器价格才正确显示。原因购物车对象存在HttpSession里Session 的生命周期从用户第一次访问开始到 Session 超时或被销毁结束。购物车里的CartItem对象在加入购物车时就已经把当时的单价复制了一份后续所有地方读取的都是这个副本数据库里的新价格自然影响不到它。这不是 bug而是“快照”机制。点餐系统里这样设计是有意为之用户下单时应该按“加入购物车时的价格”结算而不是按结算时的最新价格。解决如果想做到“结算时以最新价格为准”需要改购物车结构cart里只放dishId和count每次展示购物车页面时通过SELECT * FROM t_dish WHERE id IN (...)实时查询一遍菜品最新价格再计算小计和总价。代价是每次打开购物车页面多一次数据库查询但换来的一致性是合理的。这个需求没有标准答案作为开发者你要想清楚这个系统是给谁用的如果是给食堂用的价格一般不会频繁变动快照没问题如果是给生鲜电商场景单价每天波动必须实时查询。5.5 订单明细金额和订单总价对不上前端传裸价格后端必须重新计算现象用户把购物车里的 3 份菜都下了单t_order的total_price却和order_detail里的明细价格相乘之和不相等差了 0.01 或者几块钱。原因系统把前端提交的totalPrice当作可信数据直接落库没有在后端重新计算一次。这种问题在点餐系统里尤其常见因为前端页面用 JavaScript 直接算总额Double精度问题加上购物车里有折扣规则、免运费规则时前后端很容易各算各的。更严重的是用户可以通过修改请求参数把总价改成负数——安全审计时这就是明显的越权漏洞。解决下单接口里后端拿到购物车后自己遍历菜品表查询最新单价重新计算总价忽略前端传的totalPrice字段。这样既保证金额正确也堵住了接口被手工调用然后刷单的漏洞。计算总价时用BigDecimal而不是double累加是total total.add(price.multiply(BigDecimal.valueOf(count)))。这个习惯可以从点餐系统一直带到后面的所有支付类项目里。6. 进阶验证试试这两招让点餐系统从“能跑”变成“能答辩”6.1 用 JUnit 给 DAO 层写三个“冒烟测试”验证最核心的增删改查这个项目做完后建议顺手写几个 JUnit 测试专门测最容易出错的OrderDAO.deductStock和分页查询的逻辑。测试类一般长这样public class DishDAOTest { private DishDAO dishDAO new DishDAO(); Test public void testFindPage() throws Exception { ListDish page dishDAO.findPage(1, 3); Assertions.assertTrue(page.size() 3); } Test public void testDeductStockSuccess() throws Exception { boolean result dishDAO.deductStock(JdbcUtils.getConnection(), 1, 1); Assertions.assertTrue(result); } Test public void testDeductStockExceed() throws Exception { boolean result dishDAO.deductStock(JdbcUtils.getConnection(), 1, 99999); Assertions.assertFalse(result); } }这三个测试覆盖了分页查询、正常扣库存、库存不足三个场景能帮你快速验证数据库表和 SQL 的正确性。写测试有个好处即使将来代码改成 MyBatis这几个方法签名保持不动老测试依然能回归。顺便说一句deductStock的返回值是executeUpdate()返回的记录数大于 0 的布尔值这是分层设计中“让 DAO 把执行结果语义化”的一个表现。6.2 给订单打一张小票用响应输出模拟一个简单的“下单成功”页答辩时最容易加分的点是验证“事务真的生效了”。可以手动做一个测试先在数据库里把某个菜品的stock改成 1然后在购物车里加入 2 份这种菜点击下单。观察结果——要么下单失败页面给出“库存不足”的提示要么订单连同明细都写入成功但库存不能变成负数。如果库存变成了负数说明事务或者stock ?条件没有生效这是最直接的验收标准。下单成功页还有一个提体验的小技巧在order/create的doPost方法里确保下单成功后清空 Session 里的购物车属性代码是session.removeAttribute(cart)。这个动作别看只有一行如果忘了写用户下单成功后回到菜品列表购物车还是满的再次点击结算就会重复下单。很多新手项目栽在这个细节上我当年第一次做商城系统时也在这翻过车后来养成的习惯是凡是涉及“订单创建成功”的请求必须把购物车清干净希望这条经验能帮到你。本文还有配套的精品资源点击获取