JavaWeb购物商城项目实战:MySQL表设计、JDBC连接池与下单事务解析
简介一款面向 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;价格字段用DECIMAL(10,2)而不是 FLOAT 或 DOUBLE是因为浮点类型在累计运算时会出现精度丢失。库存和销量用 INT配合 MySQL 的严格模式可以避免写入负数库存。建表脚本里我已经按这个方式写好直接执行即可。2.2 JDBC连接串总是被忽略的三个参数项目里db.properties配的 MySQL 连接串是下面这样凡是出现中文乱码、连接报错的情况先对着这段检查driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/shop?useSSLfalseuseUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue usernameroot password你的密码useSSLfalse是因为本地开发环境一般没配置SSL证书不关掉会在连接时多一次证书握手的警告甚至报错。useUnicodetrue和characterEncodingutf8必须成对出现告诉MySQL驱动用UTF-8编码传输字符串这个参数丢了页面显示的中文十有八九是问号。serverTimezoneAsia/Shanghai是MySQL 8.x 驱动必须有的参数驱动初始化时拿不到服务器时区会直接抛异常。allowPublicKeyRetrievaltrue是配合useSSLfalse用的本地开发打开后可以避免公钥检索失败。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.forNameMySQL 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?actionlistpageNum2Tomcat根据WebServlet找到GoodsServletServlet调用GoodsServiceService调用GoodsDaoDAO执行JDBC查询返回ListGoodsServlet把结果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;charsetUTF-8); // 3. 放行到后续Servlet或JSP chain.doFilter(request, response); } }Filter只管POST请求体的编码。GET请求里URL带的中文参数编码受Tomcat连接器的URIEncoding影响Tomcat 8.5及以前的版本需要在conf/server.xml的Connector上配置URIEncodingUTF-8Tomcat 9之后默认就是UTF-8不用再改。3.2 分页查询与PageBean的边界处理商品列表是本项目里访问量最大的接口分页必须做好。前端传页码pageNum后端配合pageSize计算偏移量。PageBean里有一个容易被抄错的公式public class PageBeanT { private int pageNum; // 当前页码 private int pageSize; // 每页条数 private long total; // 总记录数 private int totalPages; // 总页数 private ListT list; // 当前页的数据 private int start; // SQL里LIMIT的起始下标 public void calculateTotalPages() { // 向上取整比如 total11, pageSize5总页数 (114)/5 3 this.totalPages (int) ((total pageSize - 1) / pageSize); } }向上取整的公式用(total pageSize - 1) / pageSize而不是Math.ceil是因为整数相除没有小数直接在分子上加 pageSize-1 就能让有余数时多出一页。有些新手会写(int)Math.ceil(total / pageSize)但total和pageSize都是inttotal / pageSize已经先截断成了整数结果永远是下取整页数会少一页。DAO里分页查询的SQLpublic ListGoods 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 BY和LIMIT的顺序不能写反这两个关键字的位置是固定的。如果业务上要按销量排序建议写成ORDER BY sales DESC, id DESC原因是销量可能并列只按销量排的话翻页时两页之间会出现重复数据加id DESC做第二排序键保证顺序稳定。3.3 下单扣库存事务必须放在Service层购物商城里最容易出bug的是下单流程同时插入订单、插入订单项、扣减库存任何一个环节失败前面成功的数据都应该回滚。这个事务边界如果放在DAO层每个DAO方法都各自提交就失去了原子性。public boolean createOrder(Order order, ListCartItem 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把PageBeanGoodsset进request域后JSP通过EL表达式读取c:forEach items${page.list} varg div classgoods-card img src${g.imgUrl} alt${g.name} / p classname${g.name}/p p classprice${g.price}/p a hrefcart?actionaddgoodsId${g.id}加入购物车/a /div /c:forEach顶部记得加% taglib prefixc urihttp://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里用一个MapInteger, CartItem承载MapInteger, CartItem cart (MapInteger, CartItem) session.getAttribute(cart); if (cart null) { cart new HashMap(); session.setAttribute(cart, cart); } // 加购key是goodsIdvalue是购物车行项 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 onclickthis.disabledtrue;this.form.submit()提交订单/button按钮一旦被点击就禁用鼠标连点两次也只会触发一次提交。后端可以再补一层用户进入下单页时在Session里生成一个随机的orderToken随表单一起提交下单成功后立刻清掉该token第二次提交时发现token不存在直接拒绝。前者拦截手抖后者拦截刷新重放两个加起来才能保证订单不会因为一次操作产生多份。5. 部署验证与查询性能的一个实用技巧最后一章不讲虚的直接给三个能立刻上手的操作IDEA里把项目跑起来的顺序、乱码排查的检查表、以及一个能让列表页查询变快的复合索引。5.1 IDEA里运行JavaWeb项目的确认顺序拿到源码后按这个顺序推进先执行shop.sql把六张表建出来确认t_goods里有初始化商品数据再检查db.properties里的用户名和密码是否与本机一致接着在IDEA里配置TomcatDeployment选项卡把项目Artifact加进去Application context填/shop最后启动前打开Project Structure - Artifacts确认Output Layout里有mysql-connector.jar和druid.jar。启动即报ClassNotFoundException排查时优先看Artifact中lib目录下的jar是否完整这是IDEA里最典型的依赖遗漏。5.2 中文乱码按顺序查三个位置页面出现乱码按下面的顺序逐一排查第一看数据库连接URL是否带characterEncodingutf8第二看JSP第一行pageEncoding是否写了UTF-8并且contentType里的charset一致第三看表的字段字符集建议建表时统一CHARSETutf8mb4因为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前加EXPLAINEXPLAIN SELECT id, name, price FROM t_goods WHERE category_id 3 ORDER BY sales DESC, id DESC LIMIT 10;执行后type从ALL变成refExtra里不再出现Using filesort说明这条查询已经完全命中索引。如果在实际项目中还加了价格区间筛选可以再按(category_id, price, id)的方向扩展但注意不要把排序字段和范围字段混在同一个索引的前缀位置否则后面的字段又退化为全表排序。本文还有配套的精品资源点击获取