☰
JSP+Servlet+MySQL水果商城系统:从数据库设计到踩坑实战
2026/10/14 21:53:02 网站建设 项目流程

最近又把这套“基于 JSP+Servlet+MySQL 的在线水果商城管理系统”从头到尾梳理了一遍。技术栈看起来传统,但真要把商品、购物车、订单、后台管理这些模块串起来,工程量和细节一点都不少。JSP+Servlet 这套组合虽然已经“上了年纪”,但做课程设计、毕设或者想彻底搞懂 JavaWeb 底层运行逻辑,它依然是非常好用的入门载体。这篇文章我会从需求拆解、数据库设计、核心功能实现到常见踩坑排查,把完整思路和可复用代码片段整理出来,给正在啃原生 JavaWeb 的同学一些参考。

1. 先想清楚系统要做什么:需求与模块拆解

1.1 两类使用者的功能边界

水果商城和其他电商系统在核心逻辑上没什么区别,无非是“用户浏览、加购、下单、支付、收货”,以及“管理员上架、改价、发货、统计”。但正因为业务常见,很多人在开发时容易贪多求全,一上来就加秒杀、优惠券、评价、积分,最后代码越写越乱。

做这个项目的第一步,是先把两类人的边界画清楚。我把功能拆成了前台和后台两条线,中间用同一套数据库串起来。

使用者核心功能
前台顾客注册登录、浏览商品、按分类筛选、关键词搜索、查看详情、加入购物车、修改购物车、下单、查看订单、取消订单、修改个人信息
后台管理员管理员登录、商品分类管理、商品上下架与编辑、库存修改、订单发货/完成操作、用户列表查看、基础销售统计

这里有个容易忽略的点:前台顾客不需要看到后台入口,管理员也不需要走顾客的购物流程。所以我用角色字段区分用户,前台和后台使用独立的 Servlet 路径前缀,配合过滤器做访问控制。项目规模不大,但“角色分离”这个意识从一开始就要有,后面加权限或者其他功能都会省力很多。

1.2 数据库表设计:能支撑业务的最小闭环

在线商城最怕订单数据对不上账,所以表结构设计要严谨。我设计的最小闭环包含 7 张表:用户表、分类表、商品表、购物车表、订单主表、订单明细表,另外再加一张评论表作为扩展。这里说几个关键点。

商品表的核心字段长这样:

CREATE TABLE fruit ( id INT AUTO_INCREMENT PRIMARY KEY, category_id INT NOT NULL, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL DEFAULT 0.00, stock INT NOT NULL DEFAULT 0, image VARCHAR(255), status TINYINT DEFAULT 1 COMMENT '1上架 0下架', sales_count INT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );

价格为什么用 DECIMAL(10,2) 而不是 DOUBLE?因为浮点数在金额计算上容易产生精度误差,比如 0.1+0.2 不等于 0.3。库存字段用 INT 就够,但要在代码层保证“库存不能减成负数”。

订单这边我分成 orders 和 order_item 两张表。orders 存订单主信息:订单号、用户ID、总金额、状态、收货人、电话、地址、下单时间;order_item 存订单里的每一件水果:商品ID、商品名称、单价、数量、小计。为什么要冗余商品名称和单价?因为下单之后商品价格可能会变,但历史订单里的成交价必须保留。

购物车表的设计也很有讲究:

CREATE TABLE cart ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, fruit_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, UNIQUE KEY uk_user_fruit (user_id, fruit_id) );

给 user_id 和 fruit_id 加上唯一索引后,同一个用户对同一件水果只保留一条记录,重复加入购物车时用增量更新而不是重复插入,这样能避免购物车出现一模一样的重复行。关于购物车用 Session 还是用数据库,后面核心功能部分我再展开讲。

2. 技术选型背后的逻辑:为什么还是 JSP+Servlet+Bootstrap

2.1 原生 JavaWeb 能帮你搞懂 Web 应用的本质

现在很多人一上来就用 Spring Boot + MyBatis Plus,熟练之后反而不知道一次 HTTP 请求到底是怎么走的。JSP+Servlet 这个组合最大的价值,就是让你被迫去理解底层过程:Servlet 容器接收请求、根据 URL 映射找到对应 Servlet、Servlet 处理完业务后将结果放入 request、再转发到 JSP 渲染页面。这些概念学明白后,再迁移到 Spring MVC 会非常快,因为 Spring MVC 的核心 DispatcherServlet 本身也是一个 Servlet。

我当时不是没用过 Spring Boot,而是在做这个商城项目时特意“降级”回原生 JavaWeb。一方面是为了巩固基础,另一方面也是因为这类教学项目对运行环境和依赖要求很低,装一个 JDK、一个 Servlet 容器、一个 MySQL 就可以跑,不需要拉一堆 Maven 依赖。等你把这个项目走通,再回头用框架重写,基本就是“换骨架不换业务”的过程。

2.2 Servlet 负责业务、JSP 负责展示的协作方式

核心请求流程其实就这几步:浏览器发送请求,Servlet 接收并解析参数,调用 Service 层完成业务,再通过 DAO 层访问 MySQL,拿到结果后放到 request 域中,最后 forward 给 JSP 展示。下面这段代码是典型的 Servlet 写法:

@WebServlet("/fruit/list") public class FruitListServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { FruitService fruitService = new FruitService(); List<Fruit> fruitList = fruitService.listOnSale(); request.setAttribute("fruitList", fruitList); request.getRequestDispatcher("/fruit_list.jsp").forward(request, response); } }

JSP 页面中用 JSTL 来遍历数据,而不建议在 JSP 里写大量 Java 代码:

<c:forEach items="${fruitList}" var="fruit"> <div class="col-md-3"> <div class="card"> <img src="${ctx}/${fruit.image}" class="card-img-top" alt="${fruit.name}"> <div class="card-body"> <h5>${fruit.name}</h5> <p>¥${fruit.price}</p> <a href="${ctx}/cart/add?fruitId=${fruit.id}" class="btn btn-primary">加入购物车</a> </div> </div> </div> </c:forEach>

这里有个很容易踩的坑:Servlet 负责“控制跳转”时,转发和重定向千万别混着用。转发是服务器内部跳转,地址栏不变,request 中的数据能带到 JSP;重定向是浏览器再次发起请求,可以防止表单重复提交,但数据必须手动拼到 URL 或者放在 Session 里。我在做订单提交时就是先重定向到“订单成功页”,避免用户刷新页面导致重复下单。

2.3 Bootstrap 在前端落地中的实际意义

Bootstrap 在这个项目里的作用不是“花架子”,而是让前端开发效率提高很多。它的栅格系统可以直接做响应式商品列表,在小屏幕设备上自动堆叠;导航栏、卡片、表单、模态框都有现成组件,不需要手写一套 CSS。最省心的是表格,后台管理订单列表直接套一个table table-striped table-hover,整体观感立刻专业很多。

需要提醒的是,Bootstrap 只是静态资源,不会影响 JSP 的动态渲染。你把 bootstrap.css 放在 webapp 的 css 目录下,然后在 JSP 页面用<link rel="stylesheet" href="${ctx}/css/bootstrap.min.css">引入。模板渲染和 CSS 样式互相独立,逻辑层归 Servlet,页面展示归 JSP,样式美化归 Bootstrap,分层很清晰。

3. 核心功能逐个落地:从登录到下单一气呵成

3.1 登录注册、加密与访问控制

用户密码绝对不能明文存储。虽然这是一个学习项目,但养成好习惯很重要。我用的是 SHA-256 加盐存储,实际开发中如果安全要求高,可以考虑 BCrypt。加盐的意思是给原密码拼上一段随机字符串,再做哈希,避免两个用户密码相同导致加密结果也一样。工具类写法大同小异:

public static String sha256WithSalt(String password, String salt) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] bytes = md.digest((salt + password).getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(bytes); } catch (NoSuchAlgorithmException e) { throw new RuntimeException("加密失败", e); } }

登录成功的标志是把用户对象放进 Session 中,比如session.setAttribute("loginUser", user)。后续所有需要登录的操作,都通过过滤器统一判断。我这里写了一个粗粒度的登录过滤器,拦截除登录、注册、商品浏览、静态资源以外的请求:

@WebFilter("/*") public class LoginFilter implements Filter { @Override 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.contains("/login.jsp") || uri.contains("/user/login") || uri.contains("/register.jsp") || uri.contains("/user/register") || uri.contains("/fruit/") || uri.contains("/css/") || uri.contains("/js/") || uri.contains("/images/")) { chain.doFilter(request, response); return; } Object loginUser = request.getSession().getAttribute("loginUser"); if (loginUser == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } chain.doFilter(request, response); } }

注意一点:管理员的接口要单独用角色判断,不能只判断“是否登录”。我会在后台路径/admin/*的请求上额外检查loginUser.role == 1,防止普通用户手动访问后台地址。

3.2 商品列表、搜索与分页

商品列表页涉及三个常见操作:分类筛选、关键字搜索、分页。三者要同时生效,所以 SQL 要用动态拼接。拼接时强烈推荐 PreparedStatement,既能防止 SQL 注入,也方便设置参数。我的查询逻辑大致是:

StringBuilder sql = new StringBuilder("SELECT * FROM fruit WHERE status = 1 "); List<Object> params = new ArrayList<>(); if (categoryId != null && categoryId > 0) { sql.append(" AND category_id = ?"); params.add(categoryId); } if (keyword != null && !keyword.trim().isEmpty()) { sql.append(" AND name LIKE ?"); params.add("%" + keyword.trim() + "%"); } // 分页:page从1开始,pageSize固定 int offset = (page - 1) * pageSize; sql.append(" ORDER BY sales_count DESC, create_time DESC LIMIT ?, ?"); params.add(offset); params.add(pageSize);

分页查询除了查列表,还要查符合条件的总记录数。如果只对全表做 count,搜索关键词后就会出现“一共 3 页但第一页搜出来只有 1 条”的尴尬情况。正确做法是复用上面的 where 条件,把SELECT *换成SELECT COUNT(*)。这一步看起来很基础,但很多人第一次写分页都栽在这里。

3.3 购物车的两种实现与选择

购物车有两套方案:存 Session 还是存数据库。Session 方案实现简单,用户未登录也能加入购物车,但劣势很明显——浏览器一关数据就没了,换设备更不能同步。数据库方案是把购物车记录落库,用户登录后任何设备都能取到,订单结算时也能直接关联,我最终选择的是数据库方案。

加入购物车的核心 SQL,可以用 PostgreSQL 的INSERT ... ON DUPLICATE KEY UPDATE这种写法,MySQL 同样支持。不过为了兼容性和可读性,我更喜欢先查询是否存在,再决定更新还是插入。具体业务逻辑可以写在 Service 层:

Cart cart = cartDao.findByUserIdAndFruitId(userId, fruitId); if (cart == null) { cartDao.insert(userId, fruitId, quantity); } else { cartDao.updateQuantity(cart.getId(), cart.getQuantity() + quantity); }

购物车列表页应该 JOIN 商品表,实时获取最新价格和图片。这里有个业务决策:购物车里的价格只作为展示参考,真正计较的金额以下单时重新查询商品表为准,不能直接信任购物车里的冗余价格。防超卖和恶意篡改价格这种事,在服务端一定要把关。

3.4 提交订单时如何用好事务

下单是整站业务最复杂的一步,它涉及到插入订单主表、插入订单明细表、扣减商品库存、更新商品销量、清空购物车这多个写操作。任何一个步骤失败,都会造成数据不一致。例如订单生成了但库存没扣,后面就会超卖;库存扣了但订单没生成,用户就白付款了。所以必须用数据库事务。手动管理 Connection 的简化逻辑如下:

Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 1. 生成订单主表 Order order = new Order(orderNo, userId, totalPrice, "待支付", receiver, phone, address); orderDao.insert(conn, order); // 2. 批量插入订单明细 for (CartItem item : cartItems) { orderItemDao.insert(conn, order.getId(), item.getFruitId(), item.getFruitName(), item.getPrice(), item.getQuantity()); } // 3. 扣减库存,防止超卖 int affectedRows = fruitDao.reduceStock(conn, fruitId, quantity); if (affectedRows == 0) { throw new RuntimeException("库存不足"); } // 4. 清空用户购物车 cartDao.clearByUserId(conn, userId); conn.commit(); } catch (Exception e) { if (conn != null) { conn.rollback(); } throw e; } finally { if (conn != null) { conn.close(); } }

扣减库存建议使用乐观更新:UPDATE fruit SET stock = stock - ? WHERE id = ? AND stock >= ?。这行 SQL 本身就能保证库存不会被扣成负数,再通过受影响行数判断是否扣减成功。在学习项目里,这种做法已经足以应对大多数并发场景。

下单成功之后,订单状态默认是“待支付”。由于真实支付网关接入比较复杂,我在项目里做了一个模拟支付按钮,点击后把订单状态从 1 改成 2。这个模拟思路对学习很有帮助,后续要接真实支付时,只需要把“支付回调更新状态”的逻辑挂上去即可。

3.5 后台管理:商品与订单的状态流转

后台管理可以复用前台的 Servlet 分层,但 URL 上统一加上/admin前缀,比如/admin/order/list、/admin/fruit/edit。然后在管理端的 JSP 页面里用 Bootstrap 的卡片和表格做数据展示。

商品管理这里,最常见的坑是“删除”。如果商品被用户下过单,删除商品会把历史订单明细对应的商品关联弄丢。所以我不建议做物理删除,而是用 status 字段做逻辑删除,也就是下架。这样商品不展示在前台,但历史订单数据依然完整。

订单管理主要做状态流转:待支付 -> 待发货 -> 已发货 -> 已完成。管理员点击“发货”按钮时,要判断当前订单是否处于“待发货”状态,避免重复发货或状态乱跳。我在 DAO 层写的是:

UPDATE orders SET status = 3 WHERE id = ? AND status = 2

只有以前一个状态为前提去更新,事务才能保证状态流转是按顺序进行的。后台还可以加一个简单的统计页,比如按天统计订单数量、按商品统计销量排行,直接用聚合 SQL 就能实现,不用引入额外图表库。

4. 踩坑实录:常见的六个问题与排查方法

4.1 中文乱码:从源头到数据库的一次性解决

中文乱码是 JavaWeb 里出现频率最高的问题。它可能发生在页面显示、表单提交、SQL 查询、数据库存储四个环节。我踩过最典型的坑是:页面能显示中文,但表单提交到数据库后就变成问号。

解决乱码要“三管齐下”:第一,在 JSP 页面顶部写<%@ page contentType="text/html;charset=UTF-8" language="java" %>,HTML 里也要写<meta charset="UTF-8">。第二,在 Servlet 接收请求和输出响应时设置编码:

request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8");

第三,JDBC 连接地址后面必须加上编码参数:

jdbc:mysql://localhost:3306/fruit_shop?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai

MySQL 数据库和表本身也要用 utf8mb4。如果建库时忘了,可以用ALTER DATABASE fruit_shop CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;补救。

4.2 路径问题:转发重定向和上下文路径

路径问题最常见的是部署后 404。本地直接访问/login.jsp没问题,但部署到带上下文路径的容器里就找不到了。比如项目上下文叫fruit_shop,直接写/login.jsp实际访问的是根路径下的 login.jsp,肯定 404。

统一解决方案是在 JSP 页面用${pageContext.request.contextPath},在 Servlet 中用request.getContextPath()。比如重定向登录页:

response.sendRedirect(request.getContextPath() + "/login.jsp");

转发和重定向的路径也有区别:转发路径是服务器内部路径,不需要带上下文路径,因为它不重新发请求;重定向路径是浏览器直接访问的 URL,必须带上下文路径。这个点我调试了快一个小时才反应过来,分享出来希望大家少走弯路。

4.3 数据库连接没释放:应用卡死的前兆

原生 JDBC 最容易犯的错误是只打开了 Connection,却忘在 finally 中关闭。开发环境数据量小,问题不明显;跑一段时间后就会出现“连接池耗尽”“too many connections”或者应用卡死。排查起来其实很简单,先把代码里所有conn.createStatement()、ps.executeQuery()的位置过一遍,确保 ResultSet、Statement、Connection 分别关闭。

我现在习惯用 try-with-resources 来写 JDBC 访问:

try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, keyword); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { // 处理结果 } } }

这种方式会自动关闭资源,不用手写繁琐的 finally。另外,正式项目一定要用连接池,Druid 和 C3P0 都行。连接池的核心是复用连接,避免频繁创建销毁,性能会好很多。

4.4 登录状态丢失:Session 和 Cookie 的边界

很多同学在开发时发现,登录成功后关掉浏览器再打开,又要重新登录,可能觉得是 bug。其实这是 Session 的正常表现。Session 默认依赖 Cookie 保存 JSESSIONID,浏览器关闭后 Cookie 消失,服务器上的 Session 对象就“对不上人”了。

如果你希望用户在一段时间内保持登录,可以在 web.xml 中配置 Session 超时时间:

<session-config> <session-timeout>30</session-timeout> </session-config>

单位是分钟,30 表示 30 分钟无操作后 Session 失效。另一种做法是使用自定义登录态,把 token 存到数据库或缓存中,再通过浏览器本地存储保持登录,学习项目一般不需要做到这一步。

4.5 分页数据错乱:条件和总数的坑

我在 3.2 中提到过分页总数问题,这里再补充一个实际场景:用户在搜索框输入“苹果”,点击第 2 页时,搜索条件丢了,页面突然展示所有水果。这是因为分页链接没有携带 keyword 参数。解决方式是在生成分页标签时,把 keyword、categoryId、sort 这些查询条件一并拼到 URL 上:

<a href="${ctx}/fruit/list?page=2&keyword=${keyword}&categoryId=${categoryId}">2</a>

关键字最好经过 URL 编码,否则遇到中文参数可能乱码。在 Servlet 端再统一用request.getParameter("keyword")获取,配合 filters 做编码处理即可。

4.6 商品图片上传与回显

图片上传用 Servlet 3.0 提供的request.getPart("file")可以拿到文件流,保存时文件名最好用 UUID 重新生成,避免用户上传的a.jpg覆盖已经存在的a.jpg。保存目录我放在了 webapp/upload 下,JSP 中通过${ctx}/upload/文件名直接访问。

有一个 IDE 相关的坑:如果用集成开发环境的内置 Servlet 容器运行项目,重启后临时目录可能会被清空,导致图片丢失。建议把图片输出目录配到容器外,或者使用一个固定的项目内目录,并在部署文档中注明。学习项目图个简单,放在 webapp 下也能接受,但心里要清楚这种方式的局限性。

5. 项目结构分享与还能怎么扩展

5.1 推荐的包结构与核心配置

项目做到最后,包结构一定要清晰。我自己的习惯是这样分层:

com.xxx.fruit ├── filter // 编码过滤、登录过滤、后台权限过滤 ├── servlet // 前台 Servlet、后台 AdminServlet ├── service // 业务接口和实现,比如 OrderService ├── dao // 数据库访问,用 JDBC 写具体 SQL ├── model // 实体 Bean:User、Fruit、Cart、Order └── util // DBUtil、MD5Util、分页工具

这样的分层虽然比直接用 Servlet 写 JDBC 多几层,但每个包职责明确。后期要接 MyBatis 或者 MyBatis Plus,只需要改 DAO 层,Servlet 和 Service 基本不动。

配置文件方面,数据库连接信息建议单独放在db.properties里,不要硬编码在类中。然后用一个静态代码块加载配置并初始化连接池,这样切换环境时只需要改配置文件。

5.2 从课程设计到实际部署的几个建议

很多同学做完项目,“能在开发工具里跑通”就结束了,但实际部署还有几个坑:第一,Servlet 容器版本和 JDK 版本必须匹配,比如比较老的容器可能不支持 Servlet 3.0 的注解扫描,需要改成 web.xml 配置;第二,MySQL 驱动 jar 必须放到WEB-INF/lib目录下,否则运行时明明项目里引了驱动却报找不到类;第三,项目打成 WAR 包后,注意数据库账号密码不能是开发环境的本机账号,要改成部署环境可用的账号。

功能扩展的方向我也简单列一下:购物车可以换成 Redis 存储,订单状态可以接入真实支付回调,商品图片可以保存到对象存储服务,前端可以改成 Vue 分离前后端,后台统计可以引入 ECharts。不过这些扩展的前提,都是把现有的原生实现吃透。我个人的建议是,先把这个项目完整跑通,再考虑用更现代的方式重写,那样你会对每一项框架特性背后的意义体会更深。

6. 最后再分享一点实操体会

做完这个项目,我最大的感觉是:原生 JSP+Servlet 虽然写起来繁琐,但它把 Web 开发的核心问题都暴露出来了——请求编码、会话状态、事务边界、路径跳转、资源释放。这些问题在框架里可能一个注解就解决了,但如果你没经历过它们,出了问题根本不知道从哪里查。

我个人现在写代码有个习惯:所有 SQL 先放到数据库客户端里跑一遍,确认结果没问题,再复制到 DAO 层;所有跳转链接只要涉及项目部署,都强制拼接上下文路径;所有连数据库的操作,写完就检查资源释放。这几个小习惯帮我避开了这个项目里 80% 的常见问题。如果你正卡在某个报错里,不妨先从路径、编码、连接三件事入手,大概率能找到原因。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询