☰
基于JSP+Servlet的拍卖管理系统:数据库设计与并发出价实战
2026/10/1 17:47:07 网站建设 项目流程

简介:这是一套面向高校计算机专业毕业设计场景的拍卖管理系统完整源码包,采用JSP+Servlet技术栈,前端使用jQuery,后端基于Servlet与JDBC实现,角色划分为管理员与普通用户。系统集成商品竞拍、分类管理、商品管理、订单管理、在线留言等核心模块,用户端支持注册登录、商品分类检索与名称搜索、竞拍操作、竞拍记录与订单查询,管理端则覆盖账号管理、分类与商品增删改查、竞拍与订单管理、友情链接及轮播图等系统配置,功能链路较为完整。资源包共438个文件,包含79个jsp页面、126个png与66个gif图像资源、42个css与42个js脚本,以及17个jar依赖、8个java源文件、1个sql数据库脚本等,压缩包约23.19MB,目录结构清晰,便于按模块查阅与二次开发。目前已有26人浏览学习,适合作为毕业设计选题参考或课程设计实践素材,读者可据此快速理解拍卖类系统的业务逻辑与前后端协作方式,并在此基础上完成功能扩展与论文撰写。

1. 拍卖管理系统为什么还在用 JSP+Servlet:一次把选型逻辑讲透

如果你在 GitHub 或各类源码站搜「拍卖管理系统」,跳出来的结果十有八九是 SpringBoot + Vue 那一套。但如果你翻的是高校课程设计、JavaWeb 实训、或者一些中小企业的内部老系统,JSP + Servlet 依然是主力。这不是技术落后,而是场景决定的:部署简单、依赖少、一个 Tomcat 加一个 MySQL 就能跑起来,不需要 Node 环境、不需要前后端分离的联调成本。

这套「基于 JSP+Servlet 的拍卖管理系统」要解决的核心问题很明确:用户注册登录、发布拍卖商品、浏览竞价、出价、倒计时结束自动成交、后台管理商品和用户。听起来像电商,但拍卖的业务逻辑比普通商城多了一层——价格是动态的,状态是随时间变化的。这就决定了数据库设计和 Servlet 的职责划分不能照搬普通 CRUD 项目。

适合谁看?如果你是 JavaWeb 入门阶段,想找一个比「图书管理系统」更有业务厚度的项目练手;或者你手头有一个传统 JSP 项目要维护、要改;再或者你在准备课程设计、毕设,需要一个能讲清楚业务逻辑而不是纯增删改查的选题——那这套东西值得你花时间吃透。下面我从数据库设计一路讲到 Servlet 的并发出价处理,把能踩的坑都标出来。

2. 数据库表设计与拍卖状态机:先把地基打对

2.1 五张核心表怎么拆

拍卖管理系统的数据库设计,最容易翻车的地方是把商品信息和拍卖信息混在一张表里。我见过不少课程设计项目,一张goods表里塞了start_price、current_price、end_time、winner_id,结果商品下架后历史拍卖记录全丢了。正确的做法是拆开:

表名作用关键字段
user用户信息id, username, password, role, phone
auction_item拍卖商品id, name, description, image, start_price, seller_id, create_time
auction_session拍卖场次id, item_id, start_time, end_time, current_price, status, winner_id
bid_record出价记录id, session_id, user_id, bid_price, bid_time
category商品分类id, name, sort_order

auction_item存的是商品的静态属性,auction_session存的是这次拍卖的动态状态。同一个商品可以多次上拍(比如流拍后重新上架),每次生成一条新的 session。status字段用整数枚举:0=未开始,1=进行中,2=已结束,3=已流拍。

建表 SQL 我一般这样写:

CREATE TABLE auction_session ( id INT PRIMARY KEY AUTO_INCREMENT, item_id INT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, current_price DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT '0未开始 1进行中 2已结束 3流拍', winner_id INT DEFAULT NULL, FOREIGN KEY (item_id) REFERENCES auction_item(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意current_price用DECIMAL而不是FLOAT,金额计算用浮点数迟早出精度问题。utf8mb4是为了支持商品名里的 emoji 和生僻字,用utf8在某些 MySQL 版本下会截断。

2.2 拍卖状态流转:谁在什么时候改 status

状态机是这套系统的灵魂。很多同学写完发现「拍卖结束了但状态还是进行中」,就是因为没有统一的入口去改状态。我的做法是:状态变更只在一个地方发生——一个AuctionStatusService类,提供startSession()、endSession()、cancelSession()三个方法,所有涉及状态修改的操作都走它。

流转规则:

  • 创建 session 时 status=0,到达 start_time 后由定时任务或首次访问时惰性触发改为 1
  • 到达 end_time 后改为 2,同时把最高出价者写入 winner_id
  • 如果 end_time 到达时没有任何出价记录,改为 3(流拍)

这里有个细节:不要依赖数据库的定时事件。MySQL 的 EVENT 虽然能定时执行,但调试困难、权限要求高,而且项目迁移时容易忘。我一般用一个ServletContextListener在应用启动时开一个ScheduledExecutorService,每分钟扫一次需要变更状态的 session。

@WebListener public class AuctionScheduler implements ServletContextListener { private ScheduledExecutorService executor; @Override public void contextInitialized(ServletContextEvent sce) { executor = Executors.newSingleThreadScheduledExecutor(); executor.scheduleAtFixedRate(() -> { // 每分钟检查一次,把到期的 session 状态推进 auctionService.refreshExpiredSessions(); }, 0, 1, TimeUnit.MINUTES); } @Override public void contextDestroyed(ServletContextEvent sce) { if (executor != null) executor.shutdownNow(); } }

refreshExpiredSessions()里做两件事:把end_time < NOW()且 status=1 的记录改为 2 并计算 winner;把start_time < NOW()且 status=0 的改为 1。用单线程是因为状态变更不需要并发,反而并发容易导致重复处理。

注意:contextDestroyed里必须调用shutdownNow(),否则热部署时旧线程不会退出,Tomcat 会报内存泄漏警告。

3. Servlet 层怎么写:从登录到出价的完整链路

3.1 项目结构与 web.xml 还是注解

传统 JSP 项目有两种 Servlet 注册方式:web.xml配置和@WebServlet注解。2024 年了,除非你维护的是 Servlet 2.5 的老项目,否则一律用注解。项目结构我习惯这样分:

src/main/java/ com.auction.servlet/ -- 所有 Servlet com.auction.service/ -- 业务逻辑 com.auction.dao/ -- 数据库操作 com.auction.entity/ -- 实体类 com.auction.util/ -- 工具类(DBUtil, MD5Util 等) src/main/webapp/ WEB-INF/views/ -- JSP 页面,外部不能直接访问 static/ -- css/js/图片 index.jsp

JSP 放在WEB-INF/views/下面是为了强制走 Servlet 转发,避免用户直接访问 JSP 绕过权限检查。这是很多入门项目忽略的点——JSP 直接放 webapp 根目录,用户输个 URL 就能看到管理页面。

3.2 登录 Servlet:Session 管理和密码存储

登录逻辑本身不复杂,但有两个坑:密码明文存储和 Session 固定攻击。密码必须哈希,我一般用 SHA-256 加盐:

public class PasswordUtil { public static String hash(String password, String salt) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); md.update(salt.getBytes(StandardCharsets.UTF_8)); byte[] digest = md.digest(password.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(digest); } catch (NoSuchAlgorithmException e) { throw new RuntimeException("SHA-256 not available", e); } } }

salt 存在 user 表里,每个用户注册时随机生成。登录时用同样的 salt 哈希后比对。不要用 MD5,不要不加盐。

Session 管理方面,登录成功后先request.getSession().invalidate()再创建新 Session,防止 Session 固定攻击:

protected void doPost(HttpServletRequest request, HttpServletResponse response) { String username = request.getParameter("username"); String password = request.getParameter("password"); User user = userService.login(username, password); if (user != null) { request.getSession().invalidate(); HttpSession session = request.getSession(true); session.setAttribute("currentUser", user); session.setMaxInactiveInterval(30 * 60); // 30分钟 response.sendRedirect(request.getContextPath() + "/auction/list"); } else { request.setAttribute("error", "用户名或密码错误"); request.getRequestDispatcher("/WEB-INF/views/login.jsp").forward(request, response); } }

setMaxInactiveInterval设 30 分钟是拍卖场景的合理值——用户可能在看商品详情时停留较久,太短会频繁掉登录。

3.3 出价 Servlet:并发下的价格覆盖问题

出价是整套系统里唯一需要认真考虑并发的地方。两个用户同时出价,如果不加控制,可能出现「后出价的人价格更低却覆盖了高价」的玄学问题。核心在于:检查当前价和更新当前价必须是原子操作。

方案一:数据库行锁。在事务里用SELECT ... FOR UPDATE:

public boolean placeBid(int sessionId, int userId, BigDecimal bidPrice) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 锁定这一行,其他事务必须等待 String lockSql = "SELECT current_price, status, end_time FROM auction_session WHERE id = ? FOR UPDATE"; PreparedStatement ps = conn.prepareStatement(lockSql); ps.setInt(1, sessionId); ResultSet rs = ps.executeQuery(); if (!rs.next()) { conn.rollback(); return false; } BigDecimal currentPrice = rs.getBigDecimal("current_price"); int status = rs.getInt("status"); Timestamp endTime = rs.getTimestamp("end_time"); // 校验:拍卖进行中、未结束、出价必须高于当前价 if (status != 1 || endTime.before(new Timestamp(System.currentTimeMillis()))) { conn.rollback(); return false; } if (bidPrice.compareTo(currentPrice) <= 0) { conn.rollback(); return false; } // 更新当前价 String updateSql = "UPDATE auction_session SET current_price = ? WHERE id = ?"; PreparedStatement ups = conn.prepareStatement(updateSql); ups.setBigDecimal(1, bidPrice); ups.setInt(2, sessionId); ups.executeUpdate(); // 插入出价记录 String insertSql = "INSERT INTO bid_record(session_id, user_id, bid_price, bid_time) VALUES(?,?,?,NOW())"; PreparedStatement ips = conn.prepareStatement(insertSql); ips.setInt(1, sessionId); ips.setInt(2, userId); ips.setBigDecimal(3, bidPrice); ips.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { if (conn != null) try { conn.rollback(); } catch (SQLException ignored) {} return false; } finally { if (conn != null) try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ignored) {} } }

FOR UPDATE会在 sessionId 这一行上加排他锁,第二个请求必须等第一个事务提交后才能读到最新的 current_price。这样就不会出现价格覆盖。

方案二:用UPDATE ... WHERE current_price < ?的乐观锁写法,不需要显式事务:

UPDATE auction_session SET current_price = ? WHERE id = ? AND status = 1 AND current_price < ? AND end_time > NOW()

然后检查executeUpdate()返回的影响行数,如果是 0 说明出价失败(被别人抢先或价格不够)。这种写法更轻量,但插入 bid_record 需要单独处理,且失败时无法区分是价格不够还是拍卖已结束。

我一般用方案一,因为拍卖出价频率不高,行锁的等待时间可以接受,而且逻辑清晰、错误可区分。

提示:DBUtil.getConnection()不要每次新建连接,用连接池。最简单的方案是在contextInitialized里初始化一个HikariCP或DBCP数据源,存到ServletContext里。手写DriverManager.getConnection在并发下会迅速耗尽数据库连接数。

4. 避坑与排查:那些让我加班到凌晨的问题

4.1 中文乱码:POST 和 GET 要分开处理

现象:表单提交的商品名在数据库里变成???,或者页面显示乱码。

原因:Tomcat 8 以后 GET 请求默认 URI 编码是 UTF-8,但 POST 请求体默认是 ISO-8859-1。很多人只加了request.setCharacterEncoding("UTF-8"),这只对 POST 有效。

解决:POST 用request.setCharacterEncoding("UTF-8"),GET 参数如果乱码,需要在 Tomcat 的server.xml里给 Connector 加URIEncoding="UTF-8"。另外 JSP 页面头部必须写<%@ page contentType="text/html;charset=UTF-8" language="java" %>,HTML 的<meta charset="UTF-8">也要加。数据库连接 URL 加?useUnicode=true&characterEncoding=utf8mb4。

4.2 出价成功但页面价格没变

现象:用户出价后提示成功,但刷新页面看到的还是旧价格。

原因:JSP 页面被浏览器缓存了,或者 Servlet 转发到的 JSP 读取的是 request 里的旧数据。

解决:在出价成功后用response.sendRedirect()重定向到列表页,而不是forward。重定向会发起新的 GET 请求,拿到最新数据。同时在 JSP 里加<meta http-equiv="Cache-Control" content="no-cache">。如果用了 AJAX 出价,确保返回的是最新价格而不是固定字符串。

4.3 定时任务在 Tomcat 热部署时重复启动

现象:改了代码重新部署,发现每分钟执行的任务跑了两次,出价记录被重复处理。

原因:contextDestroyed里没有正确关闭线程池,旧的应用实例还在运行。

解决:确保executor.shutdownNow()被调用,并且在contextInitialized里先检查是否已有线程池在运行。更稳妥的做法是用ServletContext的setAttribute存一个标志位,启动前先清理。另外开发阶段建议关闭 Tomcat 的自动热部署,手动重启更可控。

4.4 数据库连接泄漏导致 Tomcat 卡死

现象:系统运行一段时间后所有请求都超时,重启 Tomcat 恢复正常。

原因:某个 Servlet 里Connection没有在finally里关闭,或者ResultSet、PreparedStatement没关。连接池耗尽后新请求全部阻塞。

解决:用 try-with-resources 改写所有 DAO 方法:

try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { // ... } catch (SQLException e) { // ... }

如果已经上线无法快速改代码,临时方案是在连接池配置里加leakDetectionThreshold=60000(HikariCP),超过 60 秒未归还的连接会打印堆栈,帮你定位泄漏点。

4.5 JSP 里写 Java 代码导致页面报错难排查

现象:JSP 页面 500 错误,但堆栈指向_jspService行号,根本看不出是哪段逻辑出错。

原因:在 JSP 里写了大量<% %>脚本片段,业务逻辑和页面渲染混在一起。

解决:JSP 只负责展示,数据由 Servlet 通过request.setAttribute传入,页面用 EL 表达式${}和 JSTL 标签输出。如果必须在 JSP 里做判断,用<c:if>而不是<% if %>。这样出错时堆栈会指向 Servlet 或 Service 类,定位快很多。

5. 进阶技巧:用过滤器统一权限和编码,少写重复代码

写到后面你会发现,每个 Servlet 开头都在做两件事:设置编码、检查登录。这两件事完全可以用 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; request.setCharacterEncoding("UTF-8"); response.setContentType("text/html;charset=UTF-8"); chain.doFilter(req, resp); } }

权限过滤器,拦截需要登录的路径:

@WebFilter(urlPatterns = {"/auction/bid", "/auction/publish", "/admin/*"}) public class AuthFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; HttpSession session = request.getSession(false); if (session == null || session.getAttribute("currentUser") == null) { response.sendRedirect(request.getContextPath() + "/login"); return; } chain.doFilter(req, resp); } }

urlPatterns里列出所有需要登录的路径。注意/admin/*只拦截管理员路径,但还需要在 Filter 里进一步判断role是否为 admin。我一般把管理员判断单独写一个AdminFilter,避免逻辑混在一起。

还有一个实用技巧:用监听器统计在线人数。实现HttpSessionListener,在sessionCreated里给一个AtomicInteger加一,sessionDestroyed里减一,把值存到ServletContext。JSP 页面直接${applicationScope.onlineCount}就能显示。拍卖系统里这个功能很加分,用户能看到「当前 XX 人在线竞拍」,有氛围感。

最后说一个我自己的习惯:每次改完 DAO 层的 SQL,先在 MySQL 客户端里手动跑一遍,确认查询结果正确再写进 Java 代码。JSP+Servlet 项目没有 MyBatis 那样的 SQL 日志,出了错只能靠堆栈猜,提前在客户端验证能省很多时间。另外,拍卖结束的定时任务,我会在本地把系统时间调到结束前 1 分钟,盯着它跑完整个流程,确认 winner_id 写入正确、状态变为 2、页面显示「已成交」。这个手动验证步骤看起来笨,但比出了线上问题再回头查强得多。

希望帮到你。

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

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

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

立即咨询