JavaWeb购物商城项目实战:MySQL表设计、JDBC连接池与下单事务解析
2026/9/23 1:56:32 网站建设 项目流程

简介:一款面向 JavaWeb 初学者及课程设计/毕业设计需求的完整购物商城项目源码,基于 Java 与 MySQL 开发,核心采用 MVC 设计模式和动态代理模式。项目从前台购物到后台管理形成完整闭环:主页展示热销商品,支持商品搜索与详情查看,详情页显示库存并可立即购买或加入购物车,购物车支持数量增减、手动输入与选购删除,提交订单时具备重复提交拦截、库存不足和商品下架提示;后台涵盖会员管理、商品批量添加与上下架、库存维护、订单发货与删除等实用功能。资源共 613 个文件,包含 66 个 Java 源码、50 个 JSP 页面、68 个 class 文件、37 个 jar 依赖包,以及图片、CSS、JavaScript 等前端资源,还有 SQL 数据库脚本和启动脚本,可快速导入运行;压缩包整体约 16.85MB,目录按 MVC 分层组织,便于针对控制器、业务逻辑、数据访问层逐个研读。目前已有 21946 人浏览学习,是一份能覆盖电商常见业务场景的高性价比实战资料。

1. 一个能跑起来的JavaWeb购物商城,卡点常在数据层

在本地把一套JavaWeb购物商城源码从压缩包变成能登录、能下单、能查订单的系统,我见过太多人卡在同一个位置:不是看不懂Servlet的分发逻辑,也不是JSP标签不会写,而是数据库连不上、页面乱码、下单时库存没扣住。这个项目是典型的Servlet + JSP + MySQL组合,代码按MVC分层拆开,附带建表脚本和初始化数据,和教学案例里常用的路径一致。它适合正在准备Java课程设计、需要一个完整参考项目的学生,也适合刚学完Servlet想看看真实业务里请求、服务、数据访问如何配合的开发者。跑通它,比看十遍概念都管用。

2. 数据库表结构与MySQL连接层的三个要点

后端项目拿到手,第一件事不是看代码,而是把数据模型读明白。购物商城的表结构决定了订单、库存、购物车这些核心逻辑怎么写。这一章讲清楚三件事:表怎么拆、连接串为什么那样写、连接池参数怎么定。

2.1 商城六张核心表的字段规划

这个项目的数据库拆成六张表:用户表、分类表、商品表、购物车表、订单表、订单项表。用户和商品之间没有直接外键,而是通过订单、购物车间接关联。对于课程设计级别的JavaWeb项目来说,这个模型既没有复杂到看不懂,又覆盖了商城最关键的数据关系。

表名关键字段说明
t_userid, username, password, phone, address, create_time用户基础信息,密码存MD5摘要
t_categoryid, name, parent_id分类,支持两级,parent_id为0表示顶级
t_goodsid, category_id, name, price, stock, sales, img_url, description商品库存与销量
t_cartid, user_id, goods_id, quantity, add_time购物车明细,不存商品快照
t_orderid, order_no, user_id, total_price, status, create_time订单主表,total_price是下单时快照
t_order_itemid, order_id, goods_id, price, quantity订单项,price是成交价快照

在电商场景里,订单里保存的是下单那一刻的商品单价快照。这个设计很关键:t_goods.price是当前售价,t_order_item.price是成交价,商品后来调价不影响已经生成的订单。同理t_order.total_price也不应该在下单后重新计算,否则订单详情会跟着商品表一起变。

外键的使用上,我的习惯是订单相关字段用逻辑外键,购物车表不建强外键。用户购物车加了商品,管理员再把商品下架,如果购物车建了物理外键,删商品会直接报错或者连带删除购物车记录;不建外键顶多是用户点进去发现失效,由应用层提示,体验反而更好。

CREATE TABLE t_goods ( id INT AUTO_INCREMENT PRIMARY KEY, category_id INT NOT NULL COMMENT '所属分类', name VARCHAR(128) NOT NULL COMMENT '商品名称', price DECIMAL(10,2) NOT NULL COMMENT '单价', stock INT NOT NULL DEFAULT 0 COMMENT '库存', sales INT NOT NULL DEFAULT 0 COMMENT '销量', img_url VARCHAR(255) DEFAULT NULL, description TEXT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_category (category_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

价格字段用DECIMAL(10,2)而不是 FLOAT 或 DOUBLE,是因为浮点类型在累计运算时会出现精度丢失。库存和销量用 INT,配合 MySQL 的严格模式,可以避免写入负数库存。建表脚本里我已经按这个方式写好,直接执行即可。

2.2 JDBC连接串:总是被忽略的三个参数

项目里db.properties配的 MySQL 连接串是下面这样,凡是出现中文乱码、连接报错的情况,先对着这段检查:

driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/shop?useSSL=false&useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username=root password=你的密码

useSSL=false是因为本地开发环境一般没配置SSL证书,不关掉会在连接时多一次证书握手的警告甚至报错。useUnicode=truecharacterEncoding=utf8必须成对出现,告诉MySQL驱动用UTF-8编码传输字符串,这个参数丢了,页面显示的中文十有八九是问号。serverTimezone=Asia/Shanghai是MySQL 8.x 驱动必须有的参数,驱动初始化时拿不到服务器时区会直接抛异常。allowPublicKeyRetrieval=true是配合useSSL=false用的,本地开发打开后可以避免公钥检索失败。

2.3 Druid连接池参数怎么定

项目里没有用传统的DriverManager.getConnection(),而是用了Druid连接池。原因很简单:每次请求都新建物理连接意味着一次完整的TCP握手加MySQL认证,一个商城首页可能同时触发好几条SQL,高频创建销毁连接会让MySQL的线程处理不过来。

public class JdbcUtils { private static DataSource ds; static { try { InputStream in = JdbcUtils.class.getClassLoader() .getResourceAsStream("db.properties"); Properties p = new Properties(); p.load(in); // 注册驱动,MySQL 8.x 的驱动类是 com.mysql.cj.jdbc.Driver Class.forName("com.mysql.cj.jdbc.Driver"); // 用连接池替换DriverManager ds = DruidDataSourceFactory.createDataSource(p); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return ds.getConnection(); } }

静态块在类加载时执行一次,整个应用只有一个连接池实例。即使去掉Class.forName,MySQL 8的驱动在JDBC 4.0下也会自动注册,但保留这一步可以让代码在低版本Tomcat上更稳。

Druid参数按下面的参考值调,这个量级在小商城场景下够用:

参数这个项目里的值为什么这么定
initialSize5启动时预创建连接,第一个请求不用等连接建立
maxActive20单机开发或小型部署,20个并发连接足够
minIdle2空闲时至少保留2个连接,避免频繁创建
maxWait3000连接池占满时最多等3秒,超时就快速失败
timeBetweenEvictionRunsMillis60000每60秒检测一次空闲连接,及时回收

maxWait是很容易忽略的一项。默认值是-1,意思是永远等待,一旦连接池耗尽,请求会一直挂着,表现就是每个页面都转圈很久才报错。配成3000毫秒后,超时直接抛SQLException,日志里立刻能看到"Get connection timeout"。

3. MVC分层与下单事务的代码拆解

这个项目的后端没有用Spring,完全基于Servlet原生API。看懂了它,往后看SpringMVC会快很多,因为MVC的思想是同一个。

3.1 一次请求的完整链路:从Servlet到DAO

项目的包结构按MVC做了清晰分割:

src/main/java/com/example/shop ├── entity/ 数据库实体类,字段和表一一对应 ├── dao/ JDBC数据访问层,只做增删改查 ├── service/ 业务层,事务和业务规则在这里 ├── servlet/ 控制器,接收参数、调用service、转发视图 ├── filter/ 过滤器,统一编码、登录校验 └── vo/ 视图对象,比如PageBean分页对象

一个典型的请求链路是:浏览器提交/goods?action=list&pageNum=2,Tomcat根据@WebServlet找到GoodsServlet,Servlet调用GoodsService,Service调用GoodsDao,DAO执行JDBC查询返回List<Goods>,Servlet把结果set进request域,forward到goods_list.jsp

项目里统一编码是在Filter里做的:

@WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; // 1. 请求体统一UTF-8,解决POST提交的中文乱码 request.setCharacterEncoding("UTF-8"); // 2. 响应统一UTF-8,解决页面输出乱码 response.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); // 3. 放行到后续Servlet或JSP chain.doFilter(request, response); } }

Filter只管POST请求体的编码。GET请求里URL带的中文参数编码,受Tomcat连接器的URIEncoding影响,Tomcat 8.5及以前的版本需要在conf/server.xml的Connector上配置URIEncoding="UTF-8",Tomcat 9之后默认就是UTF-8,不用再改。

3.2 分页查询与PageBean的边界处理

商品列表是本项目里访问量最大的接口,分页必须做好。前端传页码pageNum,后端配合pageSize计算偏移量。PageBean里有一个容易被抄错的公式:

public class PageBean<T> { private int pageNum; // 当前页码 private int pageSize; // 每页条数 private long total; // 总记录数 private int totalPages; // 总页数 private List<T> list; // 当前页的数据 private int start; // SQL里LIMIT的起始下标 public void calculateTotalPages() { // 向上取整,比如 total=11, pageSize=5,总页数 = (11+4)/5 = 3 this.totalPages = (int) ((total + pageSize - 1) / pageSize); } }

向上取整的公式用(total + pageSize - 1) / pageSize而不是Math.ceil,是因为整数相除没有小数,直接在分子上加 pageSize-1 就能让有余数时多出一页。有些新手会写(int)Math.ceil(total / pageSize),但total和pageSize都是int,total / pageSize已经先截断成了整数,结果永远是下取整,页数会少一页。

DAO里分页查询的SQL:

public List<Goods> findByPage(int categoryId, int pageNum, int pageSize) { String sql = "SELECT id, name, price, stock, sales, img_url " + "FROM t_goods WHERE status = 1 "; // 分类筛选是可选条件,要动态拼SQL if (categoryId > 0) { sql += "AND category_id = ? "; } // LIMIT的偏移量 = (当前页 - 1) * 每页条数 sql += "ORDER BY id DESC LIMIT ?, ?"; int offset = (pageNum - 1) * pageSize; return jdbcTemplate.query(sql, new BeanPropertyRowMapper<>(Goods.class), categoryId > 0 ? categoryId : null, offset, pageSize); }

ORDER BYLIMIT的顺序不能写反,这两个关键字的位置是固定的。如果业务上要按销量排序,建议写成ORDER BY sales DESC, id DESC,原因是销量可能并列,只按销量排的话翻页时两页之间会出现重复数据,加id DESC做第二排序键保证顺序稳定。

3.3 下单扣库存:事务必须放在Service层

购物商城里最容易出bug的是下单流程:同时插入订单、插入订单项、扣减库存,任何一个环节失败,前面成功的数据都应该回滚。这个事务边界如果放在DAO层,每个DAO方法都各自提交,就失去了原子性。

public boolean createOrder(Order order, List<CartItem> items) { Connection conn = null; try { conn = JdbcUtils.getConnection(); // 1. 关闭自动提交,由当前方法统一控制事务 conn.setAutoCommit(false); OrderDao orderDao = new OrderDao(); // 2. 插入订单主表,返回自增主键 int orderId = orderDao.insert(conn, order); // 3. 遍历购物车项,插入订单明细 for (CartItem item : items) { orderDao.insertItem(conn, orderId, item); } // 4. 减库存:UPDATE t_goods SET stock = stock - ? WHERE id = ? AND stock >= ? GoodsDao goodsDao = new GoodsDao(); boolean enough = goodsDao.updateStock(conn, items); if (!enough) { // 5. 库存不足直接回滚 conn.rollback(); return false; } conn.commit(); return true; } catch (Exception e) { // 6. 任何一步异常,订单、明细、库存一起回滚 try { conn.rollback(); } catch (SQLException ex) { log.error("回滚失败", ex); } log.error("下单失败", e); return false; } finally { // 7. 还原自动提交并归还连接 try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { log.error("归还连接失败", e); } } }

updateStock里用UPDATE t_goods SET stock = stock - ? WHERE id = ? AND stock >= ?这种带条件的更新语句,比先SELECT stock再在Java里判断负数要可靠。两个请求同时读到库存5,各自判断够用再各自扣减,最终库存可能变成负数。把判断条件放进UPDATE,数据库的行锁保证只有一个请求能更新成功,另一个请求affected rows为0,自然回滚。

finally里先setAutoCommit(true)close(),是因为Druid连接归还后会被复用,如果带着false回池,下个请求拿到的连接自动提交状态是关闭的,会导致数据莫名不落库。这个顺序经常被忽略,但出问题后极难排查。

4. 前端JSP渲染与购物车会话管理

前端的核心是JSP + EL + JSTL,购物车用Session保存。这一章讲清楚列表怎么渲染、购物车为什么用Map、防重复提交怎么落地。

4.1 JSP中用JSTL渲染商品列表

Servlet把PageBean<Goods>set进request域后,JSP通过EL表达式读取:

<c:forEach items="${page.list}" var="g"> <div class="goods-card"> <img src="${g.imgUrl}" alt="${g.name}" /> <p class="name">${g.name}</p> <p class="price">¥${g.price}</p> <a href="cart?action=add&goodsId=${g.id}">加入购物车</a> </div> </c:forEach>

顶部记得加<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>,漏掉这个,浏览器里会直接报一个JasperException提示找不到标签库。EL表达式自带HTML转义,用户提交的商品名里如果带<script>,渲染时会被转成普通文本,不需要额外处理XSS。需要注意${g.price}输出的是DECIMAL原值,如果不想看到199.00的数据库原始格式,要在JSP里用<fmt:formatNumber value="${g.price}" pattern="#.##"/>格式化。

4.2 购物车放入Session的Map结构

购物车在这个项目里没有频繁读写数据库,而是存在用户Session里,用一个Map<Integer, CartItem>承载:

Map<Integer, CartItem> cart = (Map<Integer, CartItem>) session.getAttribute("cart"); if (cart == null) { cart = new HashMap<>(); session.setAttribute("cart", cart); } // 加购:key是goodsId,value是购物车行项 CartItem item = cart.get(goodsId); if (item == null) { cart.put(goodsId, new CartItem(goods, 1)); } else { item.setQuantity(item.getQuantity() + 1); }

用Map不用List的原因很直接:Map的key是goodsId,加购同一件商品时直接 get 后改数量,天然去重。如果换成List,每次加购都要遍历一遍判断商品存不存在,数据量大了之后每次请求都会有一层无意义的循环。购物车本质上是用户在一次会话里的临时状态,存Session有两个明显后果:用户换浏览器购物车就空;Session超时(默认30分钟)后购物车也不在了。课程设计层面这样做没问题,但你要清楚它的边界。

购物车的操作动作统一走一个Servlet,按action参数分发:

action处理逻辑返回页面
add已存在则数量加1,不存在则put新行项重定向到商品列表
update接收前端传入的quantity,覆盖原值重定向到购物车页
remove从Map中remove(goodsId)重定向到购物车页
clear移除session中的cart属性重定向到购物车页

重定向用response.sendRedirect(),而不是forward()。如果加了购物车后直接转发回列表页,用户刷新浏览器会再次提交上一次的请求,购物车里加出一堆重复商品。重定向后浏览器地址栏变成了列表页URL,刷新动作就安全了。

4.3 下单防重复提交的落地方案

下单按钮防重复是页面必写的逻辑,这个商城采用前后端结合的方式。前端最简单直接的是提交后立即禁用按钮:

<button onclick="this.disabled=true;this.form.submit()">提交订单</button>

按钮一旦被点击就禁用,鼠标连点两次也只会触发一次提交。后端可以再补一层:用户进入下单页时在Session里生成一个随机的orderToken,随表单一起提交,下单成功后立刻清掉该token,第二次提交时发现token不存在直接拒绝。前者拦截手抖,后者拦截刷新重放,两个加起来才能保证订单不会因为一次操作产生多份。

5. 部署验证与查询性能的一个实用技巧

最后一章不讲虚的,直接给三个能立刻上手的操作:IDEA里把项目跑起来的顺序、乱码排查的检查表、以及一个能让列表页查询变快的复合索引。

5.1 IDEA里运行JavaWeb项目的确认顺序

拿到源码后按这个顺序推进:先执行shop.sql把六张表建出来,确认t_goods里有初始化商品数据;再检查db.properties里的用户名和密码是否与本机一致;接着在IDEA里配置Tomcat,Deployment选项卡把项目Artifact加进去,Application context填/shop;最后启动前打开Project Structure -> Artifacts,确认Output Layout里有mysql-connector.jardruid.jar。启动即报ClassNotFoundException排查时优先看Artifact中lib目录下的jar是否完整,这是IDEA里最典型的依赖遗漏。

5.2 中文乱码按顺序查三个位置

页面出现乱码,按下面的顺序逐一排查:第一看数据库连接URL是否带characterEncoding=utf8;第二看JSP第一行pageEncoding是否写了UTF-8,并且contentType里的charset一致;第三看表的字段字符集,建议建表时统一CHARSET=utf8mb4,因为utf8在MySQL里是utf8mb3的别名,无法完整存储四字节的Unicode字符。

5.3 一个复合索引让销量排序不再全表扫描

商品列表里的按销量排序,在数据量增长后很容易触发Using filesort。解决方式不是给sales单列加索引,而是要结合查询条件建复合索引:

ALTER TABLE t_goods ADD INDEX idx_category_sales (category_id, sales DESC, id DESC);

联合索引让WHERE category_id = ?先定位到分类内的数据,然后直接按索引中的sales顺序读取,避免了额外的排序操作。验证是否生效只需要在SQL前加EXPLAIN

EXPLAIN SELECT id, name, price FROM t_goods WHERE category_id = 3 ORDER BY sales DESC, id DESC LIMIT 10;

执行后typeALL变成refExtra里不再出现Using filesort,说明这条查询已经完全命中索引。如果在实际项目中还加了价格区间筛选,可以再按(category_id, price, id)的方向扩展,但注意不要把排序字段和范围字段混在同一个索引的前缀位置,否则后面的字段又退化为全表排序。

本文还有配套的精品资源,点击获取

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

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

立即咨询