ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

网上书店系统设计代码:JSP+Servlet+MySQL下单事务与防超卖解析

网上书店系统设计代码:JSP+Servlet+MySQL下单事务与防超卖解析 简介这是一份基于JSP技术的网上书店系统毕业设计源码包面向计算机相关专业学生及需要快速上手B/S架构项目的开发者覆盖图书查询、购物车、订单处理、后台管理等核心业务可直接用于课程设计或二次开发。压缩包共60个文件约2.19MB以20个asp动态页面为主辅以JSP常用的图片素材jpg/gif、说明文档doc、数据库文件mdb/db及站点配置文件asa能够完整体现页面展示、业务逻辑与数据存储三个层次。资源已吸引54人学习浏览。内含说明文档、数据库备份、页面设计图与主要功能模块代码目录中images、pic等素材分明便于按图索骥通过阅读ASP页面与数据库表结构可厘清登录验证、图书信息维护、订单管理及用户注册等功能的实现思路对理解JSP数据库的经典Web应用非常有帮助。1. 网上书店系统设计代码一套能跑通也能讲清楚的课设方案网上书店系统设计代码说到底就是把“卖书”这件事翻译成数据库表、接口和一组事务逻辑。我帮不少人改过这类项目发现最常见的翻车点不是代码写不出来而是表结构拍脑袋、订单状态没有生命周期、下单时库存能扣成负数——演示一提交订单后台就一片红。这篇文章按我实际做过的 JSP Servlet MySQL 方案来拆从表设计和订单状态机定下来到购物车、下单、库存扣减的代码怎么写再到并发超卖和部署阶段的常见坑最后用一个小测试用例把整条下单链路验证一遍。适合正在赶课程设计或毕业设计、想拿到一套能跑通也能答辩讲清楚的参考实现的人。JSP 这套技术栈虽然老但网上书店题目里最常见的就是它轻量、好部署、也好解释。2. 网上书店系统设计表结构、订单状态机与分层架构动手前先定这三件事很多网上书店项目写到一半返工根因都是在写代码之前没把设计定下来。写代码之前我一般先花半天时间做三件事定表结构、定订单状态、定分层。这三件事定了后面所有代码都是填空。2.1 六张核心表的字段设计把“卖书”拆成可查询的关系网上书店的表设计网上有很多版本但核心逃不出这六张表用户表、图书表、库存表、购物车表、订单表、订单明细表。这里给出一套可以直接落库的 MySQL 建表脚本字段做了精简保留课设答辩时需要讲的部分。CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE COMMENT 登录名, password VARCHAR(64) NOT NULL COMMENT MD5摘要不存明文, nickname VARCHAR(32) DEFAULT , created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_book ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(128) NOT NULL COMMENT 书名, author VARCHAR(32) DEFAULT , price DECIMAL(10,2) NOT NULL, category VARCHAR(32) DEFAULT COMMENT 分类用于首页筛选, cover_url VARCHAR(255) DEFAULT COMMENT 封面图路径, sales INT DEFAULT 0 COMMENT 销量用于排序展示, status TINYINT DEFAULT 1 COMMENT 1上架 0下架 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_stock ( book_id INT PRIMARY KEY COMMENT 与t_book.id一一对应, quantity INT NOT NULL DEFAULT 0 COMMENT 当前可售库存, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号防止超卖 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_cart ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, book_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, UNIQUE KEY uk_user_book (user_id, book_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 业务订单号对外展示, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已发货 3已签收 4已取消, address VARCHAR(255) DEFAULT , created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, book_id INT NOT NULL, book_title VARCHAR(128) NOT NULL COMMENT 冗余书名防止图书被删后订单看不到, price DECIMAL(10,2) NOT NULL COMMENT 下单时的快照价, quantity INT NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有两个设计决定值得展开。第一库存单独拆成t_stock而不是放在t_book里是为了让“扣库存”只锁库存那一行不阻塞图书信息的普通查询同时留着version字段给第四章节的并发控制用。第二t_order_item里冗余了book_title和price这是故意的——订单一旦生成就必须和商品未来的改价、下架无关冗余字段保证了历史订单永远可读。2.2 订单状态机为什么“已支付”和“已发货”之间必须拆两步订单状态是网上书店系统设计里最容易被忽略的部分。很多初版设计只有“待支付”“已完成”两个状态结果一演示就露怯用户点了支付订单直接跳到完成中间缺了发货、收货这些环节答辩老师一问就答不上来。我常用的状态流转如下表。核心原则是状态只能按箭头方向迁移不能跳级也不能回退到已经经过的状态。当前状态触发动作下一状态待支付用户支付成功已支付已支付后台管理员发货已发货已发货用户确认收货已签收已支付/已发货用户申请退款且管理员同意已取消“已支付”到“已发货”之间必须拆开是因为这两个动作的发起人不同时间也可能相隔几天。支付是用户操作发货是管理员操作。如果你把两个状态合成一个“处理中”那一旦中间出了岔子比如支付回调成功但库存没扣成你连问题出在哪一步都定位不到。状态多一个代码里无非是多几个if判断但排查问题和答辩讲设计时的收益是实打实的。2.3 三层架构与包结构Servlet、Service、DAO 各管什么网上书店这类课设最常见的分层是 Servlet控制层→ Service业务层 → DAO数据访问层。JSP 只负责展示不写业务逻辑Servlet 只做参数接收、调用 Service、跳转页面事务边界放在 Service 层。包结构是这样组织的com.bookstore ├── controller // Servlet接收HTTP请求 │ ├── LoginServlet.java │ ├── CartServlet.java │ └── OrderServlet.java ├── service // 业务逻辑事务边界在这里 │ ├── OrderService.java │ └── impl │ └── OrderServiceImpl.java ├── dao // 数据访问一个表对应一个DAO │ ├── UserDao.java │ ├── BookDao.java │ ├── StockDao.java │ └── OrderDao.java ├── entity // 实体类字段与表一一对应 ├── util // DB连接池、MD5工具等 └── filter // 编码过滤器、登录过滤器这个分层不是装饰。网上书店系统设计中最容易出问题的“下单”操作恰好需要跨t_order、t_order_item、t_stock三张表写数据如果把这些 SQL 散落在各个 Servlet 里事务就没法统一管理。把 DAO 只留“单表操作”的接口把“多表组合”的逻辑收口到 Service事务才控制得住。这也是后面下单代码能在一个事务里跑完的结构前提。3. 网上书店核心代码购物车、下单事务与库存扣减的最小可运行实现设计定了代码就是按图索骥。网上书店最核心的代码有三段购物车怎么存、下单事务怎么写、订单号怎么生成。这三段是演示和答辩时必然被问到的我逐个给出示例代码和选型理由。3.1 购物车方案Session 存储还是数据库表购物车有两种常见做法存 Session 或存数据库表。对课设来说我一般选 Session。理由是购物车是强临时性数据用户关掉浏览器就清空没必要在库里留一堆脏数据而且 Session 方案的代码短讲起来也直观。下面是一个典型的加入购物车 Servlet 片段。WebServlet(/cart/add) public class CartServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { HttpSession session req.getSession(); Integer bookId Integer.valueOf(req.getParameter(bookId)); Integer quantity Integer.valueOf(req.getParameter(quantity)); // 从Session里取购物车MapbookId, 数量第一次访问时创建 MapInteger, Integer cart (MapInteger, Integer) session.getAttribute(cart); if (cart null) { cart new HashMap(); session.setAttribute(cart, cart); } cart.put(bookId, cart.getOrDefault(bookId, 0) quantity); resp.sendRedirect(req.getContextPath() /cart/list.jsp); } }这段代码的核心是MapInteger, Integer这个结构它只记录“哪本书买几本”不冗余图书信息。显示购物车页面时再由BookDao根据 bookId 批量查书名和价格这样 Session 里永远只存最小必要数据。注意两个参数一是quantity必须做上限校验防止用户直接构造请求把数量改成 99999下面章节会提到这个坑二是购物车只适合单机 Session如果项目要求多台服务器部署就老老实实换成数据库表。3.2 下单事务一个 Connection 完成订单插入与库存扣减下单是网上书店系统设计代码里最值得写对的一段。它的业务要求是订单主表插一条、订单明细插多条、库存减掉对应数量三个操作要么全成功要么全失败。JDBC 默认每条 SQL 独立提交所以必须在 Service 层手动开启事务。这是核心代码public boolean createOrder(OrderVO vo, MapInteger, Integer items) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交 // 1. 插入订单主表拿到自增id long orderId OrderDao.insertOrder(conn, vo); // 2. 批量插入订单明细 for (Map.EntryInteger, Integer entry : items.entrySet()) { Book book BookDao.getById(conn, entry.getKey()); OrderItemDao.insert(conn, orderId, book, entry.getValue()); } // 3. 条件扣减库存返回影响行数 int rows StockDao.deduct(conn, vo.getBookId(), vo.getQuantity()); if (rows 0) { throw new RuntimeException(库存不足); } conn.commit(); // 全部成功才提交 return true; } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { log.error(回滚失败, ex); } } log.error(下单失败, e); return false; } finally { DBUtil.close(conn); // 归还到连接池而不是物理关闭 } }这段代码有三个关键点。第一conn必须在整个 Service 方法里创建并传递给所有 DAO 方法绝不能在每个 DAO 里各自getConnection()那样事务就断了。第二deduct用的是条件更新而不是先查后改WHERE quantity ?这个条件本身就是防超卖的第一道防线后面章节会展开。第三finally块里关闭连接不是真的断开而是归还给连接池这个习惯能保证连接不泄漏。网上书店这类课设项目里很多“运行一会儿就卡死”的诡异问题都是忘记close导致连接池耗尽。3.3 订单编号生成时间戳加用户ID为什么不够用订单号是网上书店系统设计代码里最容易“显外行”的地方。直观想法是用System.currentTimeMillis()拼上用户 ID 当作订单号但高并发下同一毫秒内两次下单会生成完全相同的订单号数据库唯一索引直接报错。我常用的做法是时间戳加随机后缀yyyyMMddHHmmss拼上 6 位随机数再拼上用户 ID 的末四位。虽然不保证绝对不重复但实际量级下重复概率已经足够低。如果要更严谨可以直接用数据库自增主键当订单号对外展示——如果你不在乎订单号暴露销量这是最简单且绝对不会重复的方案。个人建议课设项目不要引入雪花 ID 那种重量级方案答辩时解释成本高收益却有限。4. 并发下单的库存超卖网上书店系统也必须直面的一致性问题网上书店系统看起来是课设题但“库存扣成负数”这个问题哪怕放到生产环境也是经典案例。原因在于多人同时下单时两个请求可能同时读到相同的库存余量然后各自扣减最后实际扣的数量超过库存。这一章把超卖的时间线拆开再给出代码级别的解法。4.1 超卖是怎么发生的一次经典的时间线拆解假设图书 A 库存还剩 1 本。用户甲和用户乙同时点击“购买”两个请求在服务端几乎同时到达。请求甲: SELECT quantity FROM t_stock WHERE book_id 1; -- 读到1 请求乙: SELECT quantity FROM t_stock WHERE book_id 1; -- 也读到1 请求甲: UPDATE t_stock SET quantity 0 WHERE book_id 1; -- 扣减成功 请求乙: UPDATE t_stock SET quantity -1 WHERE book_id 1; -- 也执行成功问题出在哪出在“先查后改”这两步之间有个时间窗口。请求乙的 SELECT 发生在请求甲的 UPDATE 生效之前所以它看到的是旧值。如果两个请求是严格串行的——第一个完全结束后第二个才开始——就不会有这个问题。但 Web 应用天然是并发的时间窗口无法消除只能从 SQL 层面消除。4.2 乐观锁与条件更新JSP 课设里最够用的方案解决超卖不需要引入复杂的分布式锁。网上书店这个量级一条带条件的 UPDATE 就能解决。这就是第三章代码里StockDao.deduct的具体实现public static int deduct(Connection conn, int bookId, int quantity) throws SQLException { String sql UPDATE t_stock SET quantity quantity - ? WHERE book_id ? AND quantity ?; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, quantity); ps.setInt(2, bookId); ps.setInt(3, quantity); return ps.executeUpdate(); // 返回影响行数 } }这段 SQL 的核心在于AND quantity ?。数据库在执行 UPDATE 时会对命中的行加锁第二个请求到达时会等待第一个请求提交或回滚然后重新检查条件。当库存只剩 1 本时第二个请求的quantity 1条件不再成立影响行数为 0Service 层就会抛出“库存不足”并回滚整个下单事务。这就是典型的乐观锁思路——不锁读只在写的时候通过条件校验版本。代码里还有一个细节try-with-resources只关闭了PreparedStatementConnection是外面传进来的不能在这里关。初学者常见的错误是在 DAO 方法里把连接也关了导致 Service 层第二次使用同一个连接时直接报错。记住一条规矩谁创建连接谁负责关闭。4.3 事务隔离级别与升级路径什么时候才需要更重的方案用条件更新解决了超卖紧接着会有人问要不要把事务隔离级别调到 Serializable我的答案是不要。Serializable 会让并发性能断崖式下降网上书店课设项目根本没有这个必要。条件更新配合行锁已经达到效果。还有一条常见升级路线值得了解但课设阶段不建议用把库存扣减从数据库挪到 Redis用DECR原子操作。这种方案能扛住秒杀级别的流量但代价是引入 Redis 依赖、库存数据双写一致性、缓存与数据库对账等一系列新问题。网上书店系统设计代码的核心目标是流程完整、逻辑自洽不是追求高性能架构。答辩时如果能主动说出“当前实现适用于什么量级再往上应该怎么演进”反而比堆砌一堆中间件更得分。5. 网上书店系统常见问题排查5个部署与运行时踩坑记录代码写完只是开始真正花时间的往往是部署和联调阶段的怪问题。下面这 5 个坑是我觉得网上书店项目里出现频率最高、且网上资料经常讲得不清不楚的每个都按现象、原因、解决来写。5.1 中文乱码明明设置了 setCharacterEncoding 还是乱码现象页面能打开但书名、用户名显示为问号或乱码。原因三层都可能有编码问题——JSP 页面本身、Servlet 接收参数、数据库连接。最常见的原因是数据库连接 URL 没指定字符集或者建表时用了latin1。解决三层各查一遍。JSP 页面顶部加% page contentTypetext/html;charsetUTF-8 %Servlet 里在读取任何参数前先调用request.setCharacterEncoding(UTF-8)注意必须在第一个getParameter之前JDBC URL 统一写成jdbc:mysql://localhost:3306/bookstore?useUnicodetruecharacterEncodingUTF-8。如果是已存在的库还要检查表字符集SHOW CREATE TABLE t_book看CHARSET是不是utf8mb4。5.2 Tomcat 启动即报错访问 JSP 出现 404 或 500现象项目部署到 Tomcat 后首页 HTML 能打开但一访问 JSP 页面就 500后台报ClassNotFoundException: org.apache.jasper.servlet.JspServlet。原因Tomcat 版本和项目依赖的 Servlet API 版本冲突或者导入项目时把 Tomcat 自带的库覆盖到了项目里。还有一种是 JDK 版本太高老 Tomcat 不兼容。解决先确认 Tomcat 版本和 JDK 版本的匹配关系。Tomcat 8.5 对应 JDK 1.7 以上Tomcat 9 对应 JDK 1.8 以上。然后检查项目的 Build Path 里是否引用了 Tomcat 自带的servlet-api.jar如果有删掉改用 Tomcat 运行时提供。千万不要把servlet-api.jar塞进WEB-INF/lib这是启动冲突的重灾区。5.3 运行半小时后卡死数据库连接池耗尽现象项目刚启动一切正常操作几次后页面加载越来越慢最后直接卡死后台报Connection is not available。原因DAO 层获取了连接但没有释放连接池被耗尽。很多课设项目用DriverManager.getConnection()每次新建连接用完不关虽然没有池子但同样会耗尽数据库的最大连接数。解决把代码里所有conn DBUtil.getConnection()的调用检查一遍确认每个try块都有对应的finally { conn.close(); }。如果用连接池close()是归还连接不是真正断开所以放心调用。可以用一个简单方法验证启动项目后连续下单 20 次每次成功后检查数据库SHOW PROCESSLIST如果连接数只增不减说明有泄漏。5.4 图片正常上传却不显示绝对路径与相对路径的坑现象图书封面上传成功文件也确实存在服务器目录里但页面上img标签就是显示不出来控制台报 404。原因上传的图片保存到了真实磁盘路径比如D:/upload但 JSP 里写的 URL 是相对路径浏览器访问的是当前项目的上下文路径根本够不到磁盘上的文件。解决在 Tomcat 的server.xml里给上传目录配置一个虚拟映射Context docBaseD:/upload path/upload /这样浏览器访问/upload/cover.jpg时实际读取的是D:/upload/cover.jpg。JSP 里的图片路径统一写成c:url value/upload/${book.coverUrl} /。注意coverUrl数据库里只存文件名不存完整路径这样以后换目录只要改虚拟映射不用改数据库。5.5 控制台测试都正常多线程一压就库存变负现象一个人下单测试没问题库存也对用多线程工具模拟 100 个并发请求后库存出现负数。原因代码里先SELECT quantity判断大于 0再UPDATE扣减是典型的先查后改存在竞态窗口。单人操作时请求串行永远发现不了问题一旦并发立刻暴露。解决把“判断库存足够”和“扣减库存”合并成一条条件 UPDATE即第四章的UPDATE t_stock SET quantity quantity - ? WHERE book_id ? AND quantity ?。这是网上书店系统设计代码里最重要的一行 SQL务必让所有成员都理解它为什么能防超卖。改完后用同样的并发场景压一遍库存应该恰好归零或停止在非负值。6. 进阶验证技巧用一个最小测试用例把下单链路验证跑通项目跑起来了怎么证明它是对的我习惯写一个不依赖浏览器的下单测试把“正常下单、库存不足、并发扣减”三个场景一次性覆盖掉。这里用 JUnit 4 加多线程模拟Test public void testConcurrentOrder() throws InterruptedException { ExecutorService pool Executors.newFixedThreadPool(20); CountDownLatch start new CountDownLatch(1); CountDownLatch done new CountDownLatch(100); AtomicInteger successCount new AtomicInteger(0); for (int i 0; i 100; i) { pool.submit(() - { start.await(); boolean ok orderService.createOrder(...); if (ok) successCount.incrementAndGet(); done.countDown(); }); } start.countDown(); done.await(); // 断言1成功数不能超过初始库存10 assertTrue(successCount.get() 10); // 断言2数据库里剩余库存不少于0 int remain stockDao.getQuantity(bookId); assertTrue(remain 0); }这个测试的价值在于它把“超卖”变成一个可自动回归的检查项。以后任何人改了OrderService的代码跑一次测试就知道有没有打破库存约束。以前我图省事只测“单次下单成功”这一个路径结果并发一压就翻车。现在不管多急我都会把这个用例留在测试集里改任何和订单、库存相关的代码都要跑一遍才敢提交。项目验收时主动说“我有并发下单的回归测试”比单纯展示页面更能说明你真正理解了系统设计。希望这个思路帮到你。本文还有配套的精品资源点击获取
返回列表