资讯动态

用Servlet手写在线票务系统:从三层架构到并发防超卖

发布时间:2026/10/16 6:52:01 来源:尧图企业网站定制
1. 为什么我最终选择用 Servlet 手写一套在线票务系统而不是直接上 Spring Boot接到这个题目的时候我的第一反应和大多数同学一样都什么年代了还在用 Servlet 写 Web 项目Spring Boot 不香吗但真的动手把基于 Java WebServlet 框架的在线票务系统完整做下来之后我才发现这个选择其实相当有讲究。这套技术栈虽然看起来复古但在教学评估、底层原理理解和答辩表达三个维度上都有框架封装替代不了的价值。1.1 项目约束与选型理由先说说这个项目的来由。在线票务系统是一个非常典型的课设/毕设题目需求覆盖用户注册登录、场次票档展示、下单购票、订单管理、后台数据维护等完整闭环数据模型和业务流程都足够丰满。而题目明确要求使用 Servlet 框架这其实不是限制反而帮我们划定了技术边界不需要引入 Spring、不需要微服务所有 HTTP 请求处理、会话管理、数据库访问都由我们自己手写掌控。我当时的完整技术栈是JDK 8 Tomcat 9 MySQL 8.0 JSP/JSTL JDBC前端用 Bootstrap 做了简单样式JSON 序列化用的 Gson构建工具用的 Maven。这套组合里没有一件是框架级的重量级选手每一层都看得见、摸得着。Tomcat 接收到一个 HTTP 请求后从 Servlet 的 doGet/doPost 进入到我们自己写的 DAO 查询数据库再原路返回渲染到 JSP 页面上整条链路没有任何黑盒。从一个过来人的角度看这种裸写项目带来的最大收获是你被迫去理解 HTTP 协议、Servlet 生命周期、Session 机制、JDBC 事务这些最底层的东西。比如你手写过一个 Connection 手动 commit/rollback 的 Service 方法之后再去看 Spring 的 Transactional 注解你会知道它背后替你干了什么你手动配置过 Filter 做登录拦截之后再去看 Spring MVC 的拦截器你会一眼理解它的定位。所以如果你现在正在被这个题目折磨别急着抱怨这套东西做完你的 Java Web 基础会非常扎实。1.2 Servlet JSP 这条技术栈到底够不够用很多同学担心的问题其实是用 Servlet 写的在线票务系统功能上会不会很简陋我的实测结论是完全够用前提是你规划好分层。在线票务系统的真实业务复杂度并不高它不像电商平台那样需要海量 SKU 和复杂的促销规则核心就集中在场次—票档—库存—订单这条线上。Servlet 负责接收请求、调用 Service、转发页面JSP 负责渲染视图JDBC 负责数据持久化三层各司其职之后系统可以非常清爽地跑起来。我最终完成的系统支持用户注册登录、首页场次列表、场次详情与票档余票展示、提交订单带库存扣减、我的订单查询、取消订单、后台场次与票档管理演示和答辩都完全拿得出手。真正的瓶颈在于并发场景。Servlet 默认是单实例多线程的也就是说多个用户同时抢票时你的业务代码会并发执行这里一旦处理不当就会出现超卖。但这恰恰是这个项目最有价值的部分——你得亲手解决并发问题而不能像用 Spring Boot MyBatis 那样抄一个乐观锁注解就完事。按我的经验只要把数据库并发控制和事务处理写对了这套系统承载课设和演示级别的并发量绰绰有余。2. 表结构设计从用户到订单五个关键表怎么支撑一次完整购票数据库是整个在线票务系统的地基。我见过很多同学一上来就建了十几张表看起来很高大上结果字段重复、状态冗余、查询逻辑绕来绕去。票务系统不是 ERP它的核心数据关系其实非常收敛。我最终只用了五张核心业务表就完整跑通了购票流程。2.1 用户、场次、票档三张基础表第一张是用户表 user字段包括 user_id、username、password、phone、email、create_time。这里的 password 绝对不能存明文我用的是加盐后的 SHA-256 哈希值虽然现在业界更推荐 BCrypt但在课程设计阶段自己实现一个加盐哈希也能讲清楚安全思路。username 要加唯一索引这是登录的天然业务主键。第二张是场次表 event字段包括 event_id、event_name、venue、start_time、end_time、description、cover_url、status。这里的 status 我建议用 0/1 表示未开始/已结束不要用字符串排序和查询都方便。start_time 和 end_time 用 DATETIME 类型注意 JDBC 读取时要用 rs.getTimestamp()如果只用 getDate() 会丢掉时分秒这会导致场次时间显示不完整我一开始就踩过这个坑。第三张是票档表 ticket_type这是整个设计的核心亮点。它把一场演出有多少种票建模成了一张子表字段包括 ticket_type_id、event_id、type_name比如普通票VIP票早鸟票、price、total_stock、sold_count。这里我特意没有做成一张座位表原因后面会讲但对于票务系统的通用模型来说按票档卖票、每档有总库存和已售数是最经典也最稳定的方案。2.2 订单表与支付记录表状态机这样流转第四张是订单表 orders字段包括 order_id、order_no、user_id、event_id、ticket_type_id、quantity、amount、status、create_time、pay_time、cancel_time。order_no 是给用户看的订单号我生成规则是时间戳 用户ID后四位 四位随机数这样每个订单号都唯一且可读性强。status 字段是整个订单状态机的核心我用 TINYINT 存了四个值0 待支付、1 已支付、2 已取消、3 已完成。订单流转路径是下单时必然是 0 待支付用户点击支付后变 1用户主动取消或者超时未支付变 2活动结束后把已支付订单置为 3 已完成。第五张是支付记录表 payment字段包括 payment_id、order_id、pay_no、amount、status、callback_time。这张表的定位是记录每次支付行为当用户对同一笔订单发起多次支付请求时payment 表会产生多条记录但只有成功的那个状态是 1。把订单表和支付记录表拆开的好处是订单状态只管业务流向支付是否到账由支付流水负责两者不互相污染。数据表之间用外键关联但在实际项目里我建议仅仅在逻辑上关联不要真的在 MySQL 层面加外键约束。因为加了物理外键之后管理员删除一个场次时会被订单、票档等子表挡住删除流程变得极其麻烦。我的做法是建表时只加普通索引根本不在 DDL 里写 CONSTRAINT FOREIGN KEY所有表间一致性的维护交给 Service 层事务。这个取舍在答辩时完全可以讲清楚面试官反而会认可你对数据库设计的理解。3. 三层架构落地DAO、Service、Servlet 的职责划分与代码拆解表结构设计好之后最怕的就是把代码全部堆在 Servlet 里。我见过太多同学在一个 doPost 方法里写几百行连着数据库、拼 SQL、执行查询、手动组装 HTML 输出那根本不叫 Web 项目那是披着 Servlet 外壳的脚本。这个系统想要拿高分三层架构的层次感必须清晰。3.1 分包结构与数据访问层设计我最终的项目包结构是这样的entity 放实体类dao 放数据访问层service 放业务逻辑层servlet 放控制层filter 放过滤器util 放工具类。实体类严格对应数据库表属性名和字段名做驼峰映射比如数据库里是 create_timeJava 里就是 createTime。要注意的是实体类里我偶尔会加一些不属于表结构的字段比如 Event 中加一个 ticketTypes 列表方便页面一次展示场次下的所有票档这个叫视图组装字段不算破坏分层。DAO 层我只写了一个 BaseDAO 和各个实体的 XxxDAO。BaseDAO 里封装了通用的查询方法和更新方法查询方法接收 SQL 和参数返回 List 更新方法接收 SQL 和参数返回受影响行数。这里必须使用 PreparedStatement绝对不能字符串拼接 SQL。我见过有人为了省事直接WHERE id userId结果被 SQL 注入登录接口被人用万能密码怼穿这是整个项目中最低级的错误。PreparedStatement 的 ? 占位符不仅能防注入还能让 MySQL 走预编译缓存性能也更好。3.2 Service 层事务边界在哪里Service 层是整个系统的业务心脏也是最容易讲清楚为什么的地方。我遵循的原则是一个对外方法对应一个事务边界方法开始时获取 Connection 并设置 setAutoCommit(false)方法成功结束后 commit任何异常 catch 到之后 rollback并且把 Connection 通过参数或 ThreadLocal 传递给 DAO 层。最典型的场景就是下单购票。一次完整的下单操作包括三步扣减票档库存、创建订单记录、如果有优惠则扣减优惠券。这三步必须处于同一个事务中任何一步失败都要整体回滚。我特别强调这一点是因为很多同学会在 Servlet 里逐条调用 DAO每条 DAO 各自开一个连接执行一旦第三步失败前面的库存已经扣了数据就永远对不上。后来我在 Service 层用一个 TransactionTemplate 手动管理事务并在 finally 里关闭连接问题彻底解决。我这里给出一段我实际写过的核心下单伪代码框架虽然做了简化但事务的骨架是完整的public boolean createOrder(Order order, int ticketTypeId, int quantity) { Connection conn DBUtil.getConnection(); try { conn.setAutoCommit(false); // 1. 乐观锁扣减库存受影响行数为0说明余票不足或版本冲突 int rows ticketTypeDao.deductStock(conn, ticketTypeId, quantity); if (rows 0) { conn.rollback(); return false; } // 2. 插入订单记录 orderDao.insert(conn, order); // 3. 记录支付流水待支付状态 paymentDao.insert(conn, order.getOrderId(), PENDING); conn.commit(); return true; } catch (Exception e) { conn.rollback(); log.error(下单失败, e); return false; } finally { DBUtil.close(conn); } }3.3 Servlet 控制层的请求分发与参数校验控制层我再三提醒自己Servlet 只做三件事——接收参数、调用 Service、决定返回哪个视图或 JSON。所有的参数类型转换、空值校验、业务异常处理要么在 Servlet 里做轻量检查要么丢给 Service 层抛业务异常但绝对不允许出现 SQL 语句。我做了一个 BaseServlet 继承 HttpServlet把 doGet 和 doPost 统一转发到同一个 handle 方法再根据 request 里的 action 参数分发到具体业务方法。这样避免了每一个 Servlet 都要重写一遍 doGet/doPost 的样板代码。参数校验是我在这个项目里做得比较细的地方。比如购买数量必须大于 0、不能超过单笔订单上限、手机号要满足正则、日期字符串要能解析成功。这些校验如果放在 JSP 页面做用户体验好但不可靠如果放在数据库层做错误信息太粗暴。最合适的层级是 Servlet 层入口做格式校验Service 层做业务校验比如库存是否充足、场次是否开始JSP 里只做前端提示。三层校验各有侧重这个设计在答辩时能成为一个加分讲解点。4. 抢票场景下的并发控制彻底解决库存被多卖在线票务系统最刺激的业务场景就是抢票。热门演唱会开票的瞬间可能同时有几十上百个请求打到服务器。如果代码逻辑是先查询余票判断足够再扣减库存那么两个线程可能同时读到余票为 1然后都执行扣减最终 sold_count 变成 2超过 total_stock也就是超卖。这是我在测试阶段用并发工具反复验证过的问题也是最值得写进博客的硬核内容。4.1 经典超卖问题是怎么产生的我们先看一段最容易写出 Bug 的伪代码第一个步骤 SELECT sold_count FROM ticket_type WHERE ticket_type_id ?第二个步骤在 Java 里判断 sold_count quantity total_stock第三个步骤执行 UPDATE ticket_type SET sold_count sold_count quantity WHERE ticket_type_id ?。这段逻辑在单线程下完全正确一旦并发两个请求同时执行完第一步读到的都是同一个 sold_count于是都通过了 Java 层的数量判断第三步都执行成功数据就错了。这个问题的根源在于检查和更新不是原子的。Java 层的 if 判断和数据库层的 UPDATE 之间存在一个时间窗口并发请求可以全部挤进这个窗口。解决方案的核心思路只有一个把检查余票是否充足和扣减库存合并成一个数据库层面的原子操作。4.2 三种落地方案对比与最终选择我在项目里对比了三种方案最终选择了最适合 Servlet JDBC 场景的那一种。第一种是悲观锁方案在查询票档时加上 SELECT ... FOR UPDATE强制锁住这一行其他事务必须等待。这个方案在并发量非常大时会形成行锁等待而且需要事务包裹整个操作锁的持有时间越长吞吐量越低。课程设计阶段用它可以但我没有选它。第二种是乐观锁版本号方案在 ticket_type 表加一个 version 字段更新时要求版本号匹配并且同时递增。这个方案思路优雅但是需要额外的字段维护并在更新失败后做重试或提示代码复杂度略高。第三种是我最终采用的条件更新方案核心 SQL 是UPDATE ticket_type SET sold_count sold_count ?, version version 1 WHERE ticket_type_id ? AND sold_count ? total_stock这条 SQL 用一条 UPDATE 完成了判断和扣减两个操作数据库引擎在行锁的保护下保证原子性。如果影响行数为 0说明余票不足直接返回票已售罄。为了和乐观锁方案的思想结合我也保留了 version 字段并把 AND version ? 加进 WHERE 条件这样就同时做到了业务判断原子化和并发更新冲突检测。实测用并发模拟工具开 50 个线程同时抢同一场次的最后 10 张票最终成功创建的订单正好是 10 条sold_count 也从没错过。4.3 订单超时未支付怎么处理解决了超卖下一个问题是用户下单后不支付怎么办总不能让库存一直锁着。我采用的方案是下单锁库存 定时任务扫描释放。下单时把订单状态置为待支付并记录 create_time后台用 ScheduledExecutorService 启动一个每 30 秒执行一次的定时任务扫描所有状态为待支付且创建时间超过 15 分钟的订单将其置为已取消同时回补对应票档的库存也就是反向执行 sold_count sold_count - quantity。这里有一个很重要的细节回补库存的 UPDATE 必须也要带条件防止订单已经被用户支付了但定时任务还没执行到的情况。我加了一个前置条件UPDATE orders SET status 2 WHERE order_id ? AND status 0如果这行更新影响数为 0说明订单状态已经不是待支付就不能回补库存。这个状态变更先比较旧状态的写法是防止脏数据覆盖的经典手段也体现了数据库层面对并发操作的兜底意识。5. 登录态、权限拦截与密码安全Session 和 Filter 的日常用法在线票务系统里有用户角色和后台管理角色权限控制是躲不开的需求。用 Servlet 做这个核心就两个工具Session 和 Filter。但用不好就会出现登录了还能直接访问下单接口密码明文存储退出登录后 Session 没销毁之类的低级问题。5.1 注册登录与密码存储注册功能里密码绝对不能存明文。我实现的方式是在 util 包里写一个 PasswordUtil用固定盐 用户名字 密码拼成一个字符串再做 SHA-256 哈希最终把哈希值和盐一起存到数据库。虽然这个方案和专业的 BCrypt/Argon2 相比强度偏弱但在教学项目里你能讲清楚为什么不能存明文加盐的作用是什么就已经达到目的了。登录时取出数据库里的盐对用户输入的密码做同样的哈希再比较结果。登录成功之后我把 user 对象和角色信息放进了 Session关键代码是session.setAttribute(loginUser, user); session.setAttribute(role, user.getRole());Session 的底层其实就是 JSESSIONID 这个 CookieTomcat 会为每个新会话生成一个不重复的 ID客户端之后每次请求都会带上它服务端据此找到对应的 HttpSession 对象。只要你不主动调用 session.invalidate()这个会话默认有 30 分钟的有效期。5.2 用 Filter 做统一登录拦截权限拦截如果用每个 Servlet 里 if 判断实现会重复且容易漏。正确做法是写一个 LoginFilter 实现 javax.servlet.Filter在 doFilter 里先判断请求路径如果是登录页、注册接口、静态资源css/js/images直接放行否则从 Session 里取 loginUser如果取不到就重定向到登录页。后台管理相关的路径再额外加一个角色判断不是 admin 角色就返回 403 页面。Filter 的过滤顺序由 web.xml 中的 filter-mapping 顺序决定我配置了三个 Filter第一个是 CharacterEncodingFilter强制所有请求和响应使用 UTF-8 编码第二个是 LoginFilter做登录拦截第三个是 AdminFilter只拦截 /admin/* 路径。顺序很重要如果登录拦截放在编码过滤器前面POST 请求的中文参数就会乱码。这个顺序问题我排了很久才定位到后来验证发现 Filter 的执行顺序和 J2EE 规范一致先声明的先执行编码过滤器必须先于业务过滤器。5.3 会话安全要防的几个点除了基础登录拦截我还加了两个安全细节。第一个是退出登录时不能只 removeAttribute(loginUser)必须调用 session.invalidate()这样 JSESSIONID 直接失效别人拿着旧 Cookie 也无法继续访问。第二个是针对下单接口的 CSRF 简单防护在用户访问订单确认页时生成一个一次性的随机 token 放入 Session前端提交订单时把 token 一起提交Service 层校验 Session 中的 token 是否一致校验通过后立即清除。因为跨站请求无法读取到我们页面里的 token这个方案可以抵挡绝大多数简单的跨站伪造请求。这些安全措施不需要做到银行级别但明文密码登录态可伪造未登录可下单这三个雷绝对不能踩。答辩时老师最爱问的也是这里你能讲出 Session 失效、Filter 拦截范围、CSRF Token 这三个点安全设计这一关基本就稳了。6. 页面交互JSP AJAX 做出票档选择、下单与倒计时体验票务系统的前端不需要花里胡哨但交互链路要顺。我用的是 JSP 渲染主要页面结构配合 jQuery AJAX 做局部刷新。这套方案的好处是整个渲染过程还在服务端掌控中符合题目对 JSP 的要求同时 AJAX 能提供接近现代 Web 应用的体验。6.1 JSP 页面里的 Java 代码该怎么控制刚上手 JSP 的人最容易犯的错是把 Java 代码像 JS 一样随意写在 % % 脚本里导致 JSP 页面几百行 scriptlet维护成本爆炸。我做了一个硬性约束任何业务逻辑、数据库查询、if/for 循环全部在 Servlet 里完成Servlet 把结果封装成 request 属性或转发参数JSP 页面里只用 EL 表达式和 JSTL 标签渲染。典型的详情页写法是这样的Servlet 查询场次信息和对应票档列表塞进 request 后 forward 到 event_detail.jsp。JSP 里用 c:forEach 遍历票档列表每一个票档渲染成一张卡片显示名称、原价、余票和购买按钮。这种写法看着干净答辩时也容易解释MVC 的分层体现在哪里——JSP 只是 View不承担任何业务职责。6.2 AJAX 实时刷新余票与下单流程余票数量是动态变化的如果页面写死一个数字用户看到有票下单时可能已经没了。我在票档卡片上加了一个查余票的 AJAX 请求用户进入详情页时和点击购买时都会调用一个 GetTicketStockServlet它返回 JSON 数据{ code: 200, stock: 23 }。前端拿到这个数字后实时更新按钮文案。为了不让用户频繁点击造成压力我在前端做了一次简单的节流两次请求之间的间隔不小于 2 秒。下单交互我设计成确认弹层 倒计时用户选好票档和数量点击购买后弹出一个确认层里面展示订单金额同时启动 15 分钟倒计时。前端把这个倒计时展示出来实际上最终是否超时以后端定时任务为准前端只是给用户一个直观的预期。点击确认后AJAX 发起 POST 请求到 CreateOrderServlet携带票档 ID、数量、CSRF token。Servlet 返回 JSONcode 为 200 就跳转到我的订单页面code 为 500 就弹出错误提示比如余票不足请重新选择。这个链路现在看起来不复杂但它把服务端渲染列表 局部刷新数据 卢API式下单三种模式都覆盖了说实话已经能代表大多数中小型 Web 应用的交互形态。做完这一套再去看前后端分离方案的 AJAX 交互你会发现核心思想完全一致。7. 部署实测与折腾记录Tomcat、MySQL、乱码与并发测试功能写完之后真正让人头大的反而是环境、部署和测试。我把这一路折腾中遇到的高频问题整理成清单每一个都是我亲手修过的你可能也会碰到。7.1 环境搭建里最容易踩的坑第一坑是 MySQL 8.0 的 JDBC 连接串。必须带上时区参数否则驱动会报 CST 时区不识别错误。我的完整连接串是 jdbc:mysql://localhost:3306/ticket_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8。useSSL 不加在本地没问题但加上更规范characterEncodingutf8 是保证写入中文不乱码的关键。第二坑是 Tomcat 版本和 Servlet API 包名的对应。Tomcat 10 及以上的实现包名从 javax.servlet 改成了 jakarta.servlet很多网上教程还在用 javax直接导入项目会编译报错。我的建议是课程设计选 Tomcat 9和绝大多数教材、博客一致遇到问题好查资料。第三坑是数据库连接不关闭导致连接耗尽。这个一般发生在 DAO 层某个方法里 conn、ps、rs 没用 try-with-resources异常时没有关闭。我在测试阶段亲眼看着系统从正常变成响应 10 秒以上查看 MySQL 的 max_connections 直接打满。后来把所有连接资源全部改成 try-with-resources 或 finally 关闭问题消失。这个习惯比任何框架配置都重要。第四坑是中文乱码三件套JSP 页面顶部设置 pageEncodingUTF-8、CharacterEncodingFilter 强制 request 和 response 编码、MySQL 连接串带 characterEncodingutf8。三处全对齐之后从页面输入到数据库存储再到页面回显中文全程无乱码。7.2 并发测试验证不超卖功能测试通过后我用国产开源压测工具模拟了并发抢票场景。测试步骤是把某场次某票档的总库存设为 10然后用 20 个并发线程同时发送下单请求。跑完之后我做了三件事一是查 orders 表里该票档对应的成功订单总数结果正好 10二是查 ticket_type 表的 sold_count结果还是 10没有超过 total_stock三是查支付记录表发现第 11 个及之后的下单请求返回的都是票已售罄。这个结果说明条件更新 SQL 生效了。为了看得更直观我还在 Service 层加了一条临时日志把每次扣减库存后的受影响行数都打印出来调试时可以清楚地看到哪些请求更新失败、什么原因失败。如果你们在做类似项目时发现偶尔还是超卖我建议先检查两点扣减库存的 UPDATE 是否确实带了余票判断条件以及 Service 方法是否在同一个事务中。只要这两点没问题超卖基本不会发生。7.3 演示答辩前我梳理的请求走查图最后分享一个我认为性价比极高的准备动作画一张一个请求从浏览器到数据库再返回的完整走查图然后对着它练习讲解。不需要什么绘图工具一张白纸一支笔就能做。我的走查图涵盖了三个场景用户注册登录、用户购买票务、后台管理修改场次。比如用户购买票务我会这样讲浏览器发起 POST 请求Tomcat 按 URL 映射找到 CreateOrderServletCharacterEncodingFilter 先统一编码然后框架判断 Session 中是否有登录用户进入 Servlet 的 doPost 后取出参数调用 OrderServiceOrderService 开启事务先执行条件更新扣减库存再插入订单成功后提交Servlet 把结果封装成 JSON 字符串返回前端 JS 收到 code200 后跳转到订单页面。这一套讲下来既体现了你对 HTTP 协议的理解也体现出你对事务和并发控制的认识比直接展示代码效果要好得多。这套系统做完我最大的体会是用 Servlet 手写在线票务系统本质上是一次拆解黑盒的过程。你亲手实现了框架帮你做过的每一件事——请求映射、会话管理、事务控制、JSON 序列化然后再回头看任何 Java Web 框架都不会再觉得它神秘。如果你正在做类似的课设别急着追求炫技把业务走通、把并发控制做对、把每一层为什么这么设计讲清楚就已经是一份相当有分量的作品了。最后再补一句真心话项目里那个 15 分钟未支付自动关单的定时任务建议一定要做上答辩演示时现场展示下单不付款、库存自动回补这个亮点比任何花哨页面都管用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑