基于Servlet+JSP+JDBC+MySQL的火车票预订系统设计与实现
讲师、导师、某同学、某高校等代称。我前阵子带了一个课程设计小组做的就是这套基于JavaWeb和MySQL的火车票预订系统。技术栈是JSPServletJDBCHTMLMySQL没有动Spring家族。很多同学第一反应是“这都什么年代了还用Servlet”但真把这个项目从头到尾啃下来你会发现它就是JavaWeb最经典的骨架HTTP请求怎么进TomcatServlet怎么处理逻辑JDBC怎么连MySQLJSP怎么渲染动态页面全给你摆得明明白白。这篇文章我把当时踩过的坑、优化的思路、核心代码怎么写的都拆开讲一遍给准备做类似JavaWeb课设或者想拿Servlet练手的朋友做个参考。1. 项目整体设计与技术选型思路1.1 为什么是JSPServlet而不是Spring Boot这个问题几乎是每届学生都会问的。站在课程设计角度这套技术栈有两个不可替代的优势一是没有Spring Boot那种自动配置的“黑盒”你能看到一次请求从浏览器到数据库再返回的全部过程对理解HTTP协议、Servlet生命周期、状态管理这些底层概念非常有帮助二是部署环境轻量只要一个Tomcat加一个MySQL就能跑起来不需要搞Maven依赖传递、也不用担心Spring版本兼容问题。更重要的是火车票预订系统本身的功能复杂度不高核心就是“查车次-下单-生成订单-管理订单”用Servlet手动控制请求转发和重定向代码量完全可控。如果直接上Spring Boot你会发现自己大部分时间在写注解、配repository反而忽略了最本质的连接处理。我建议每位做课设的同学至少亲手把ServletJSP完整走一遍再去学框架基础会扎实很多。1.2 核心需求解析与模块划分火车票预订系统按角色分成两种视图普通用户端和管理员端。用户端要能注册登录、按出发地/目的地/出发日期查询车次、选中车次提交预订、查看本人订单、取消未支付的订单管理员端要能维护车次信息、查询所有订单、处理余票变更。这六个功能听起来不多但把它们拆到MVC三层里就能自然分出视图层ViewJSP页面负责展示数据和收集用户输入包括登录页、注册页、车次列表页、订票确认页、订单列表页、后台管理页。控制层ControllerServlet负责接收请求、调用业务逻辑、决定跳转到哪个视图。每个功能对应一个或多个Servlet。模型层ModelJavaBean JDBC操作JavaBean承载车次、用户、订单对象JDBC负责与MySQL交换数据。这个划分是当时我们小组讨论后定下来的。虽然JSP里也能直接写JDBC代码但那样页面会变得一团乱。坚持三层分离后面排查问题会轻松很多也方便分工并行开发。1.3 原理解读一次完整请求是怎么走通的以“查询车次”这个最小用例来解释。用户在首页选择“北京-上海”和日期点击查询后浏览器会发送一个GET请求地址类似http://localhost:8080/train/queryServlet?start北京end上海date2025-05-20。这个请求会先到Tomcat的web.xml或注解里注册的queryServlet它的doGet方法拿到request里的三个参数然后调用业务层的findTrainByRoute方法。业务方法再往下走就是JDBC创建一个连接执行预编译SQL把结果集封装成Train对象列表返回给Servlet。Servlet最后把这个列表塞到request域里用请求转发RequestDispatcher.forward交给trainList.jsp去渲染。JSP用EL表达式和JSTL标签把列表循环输出成HTML表格最终这个HTML响应通过HTTP协议传回浏览器。你按下F12看到的Network面板里那条记录就是这一串协作的最终结果。1.4 技术栈版本选择与配套工具我们当时用的组合是JDK 8、Tomcat 8.5、MySQL 5.7、MySQL Connector/J 5.1.49IDE选的是Eclipse JEE版。这里有个小提醒MySQL 8.0以上版本和JDBC 5.1的驱动在连接字符串上有个坑com.mysql.jdbc.Driver在MySQL 8下会报警告建议直接用com.mysql.cj.jdbc.Driver并且连接URL里要显式加上serverTimezoneAsia/Shanghai否则时间字段会报时区异常。我们因为用了老版本当时被这个时区问题折腾了一晚上后来统一换成5.7才消停。如果你本地装了更高版本MySQL也没关系只要记得JDBC驱动版本和数据库版本要匹配连接URL写对就可以。另外Tomcat 9配JDK 8也完全没问题只是注意servlet-api的包名和版本差异。2. 数据库设计火车票系统的数据地基2.1 表结构规划与字段取舍火车票预订系统的核心数据就三类用户、车次、订单。我们设计了三张表外加一张管理员表一共四张。先看用户表CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(50), phone VARCHAR(20), create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;密码字段我坚持用VARCHAR(100)而不是50原因很简单正常我们不会存明文密码而是用MD5或SHA-256哈希后存储哈希字符串长度往往超过50位。当初有同学直接存明文测试时图省事结果后来做预防SQL注入和安全演示时又推翻重来。一次到位密码哈希为妙。车次表比较关键字段要覆盖“一班车能卖多少票”这个核心问题CREATE TABLE train ( id INT PRIMARY KEY AUTO_INCREMENT, train_no VARCHAR(20) NOT NULL UNIQUE COMMENT 车次号如G1234, start_city VARCHAR(50) NOT NULL, end_city VARCHAR(50) NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, seat_count INT NOT NULL DEFAULT 100 COMMENT 总座位数, price DECIMAL(10,2) NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里train_no唯一start_time和end_time用DATETIME而不是字符串是为了排序和区间查询方便。seat_count是总库存但真正有多少余票不能只靠这一个字段算因为每个车次在不同日期发售的总票数不同。如果你只有一个总座位数那用户今天买了5张、明天买了3张你没法扣同一个数。所以还需要一张“每日期车次余票”表但这个设计我们放到下一小节说。订单表是核心业务表CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(50) NOT NULL UNIQUE, user_id INT NOT NULL, train_id INT NOT NULL, travel_date DATE NOT NULL, order_time DATETIME DEFAULT CURRENT_TIMESTAMP, ticket_count INT NOT NULL DEFAULT 1, total_price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-未支付, 1-已支付, 2-已取消, KEY idx_user_id (user_id), KEY idx_train_id (train_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里order_time的DEFAULT CURRENT_TIMESTAMP很方便不需要在Java代码里额外set。status用TINYINT而不是字符串是出于存储效率和索引扫描的考虑查询时用一个枚举映射就行。order_no是业务单号最好在Java里生成唯一值我后面会讲生成规则。2.2 余票扣减的经典设计思路很多初学者会把“余票”直接设计成seat_count每次下单减一但真实系统里这样会出大问题。因为一个车次并不只有一天运行你要卖的是“2025-05-20那天的G1234”的票而不是把365天的票全捆在一起。我们采用的方法是加一张车次每日票表CREATE TABLE train_daily ( id INT PRIMARY KEY AUTO_INCREMENT, train_id INT NOT NULL, travel_date DATE NOT NULL, left_seat INT NOT NULL DEFAULT 100, UNIQUE KEY uk_train_date (train_id, travel_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;用户在购票时先按车次号和旅行日期查询train_daily拿到当天的left_seat如果大于等于购买张数就扣减并在订单表插入记录。这里有一个并发问题两个用户同时买最后一张票Java代码里先select再update会出现超卖。最简单有效的做法是把“检查余票和扣减”合并成一条SQLUPDATE train_daily SET left_seat left_seat - #{count} WHERE train_id #{trainId} AND travel_date #{date} AND left_seat #{count};影响行数为1表示扣票成功为0表示余票不足。这个Update就是原子操作不需要在业务代码里加锁。课程设计阶段用这种方式完全够用如果你还想再稳妥一点可以在事务里再查一次但那会引入额外开销。这张train_daily表也需要在后台管理里增加“初始化某天车次余票”的功能。2.3 SQL注入防御与预编译用户登录和查车次时会输入大量字符串。如果你用拼接SQL的方式写比如WHERE usernameuserName遇到用户名输入 OR 11 --就能把整个表拖出来。我们小组做代码审查时专门拿了这个例子给大家演示从那以后所有操作的SQL全部改写成了PreparedStatementPreparedStatement ps conn.prepareStatement( SELECT * FROM user WHERE username ? AND password ? ); ps.setString(1, username); ps.setString(2, encryptedPassword);参数用?占位由JDBC驱动自己处理特殊字符转义从根上堵住注入。这个习惯早养成早受益别学别人图省事直接拼字符串。3. 核心功能模块拆解与Servlet实现3.1 注册登录模块与密码加密注册页提交字段后RegisterServlet先检查用户名是否重复、两次密码是否一致再加密存库。密码加密我当时用的是JDK自带的MD5加盐String salt UUID.randomUUID().toString().substring(0, 6); String encryptPassword DigestUtils.md5Hex(salt password);保存时把salt也存进用户表记得加一列。登录时先用用户名查到salt再拼上明文密码做MD5跟库里存的比较。这样即使库泄露也无法直接反推密码。虽然MD5不算强哈希但课设层面比明文强得多。真要严谨就换SHA-256或者BCrypt但那个需要在项目里引扩展包既然没引入第三方安全框架用MD5加盐做个演示效果足够。登录成功后把用户ID和用户名塞进Session设置失效时间为30分钟httpSession.setAttribute(userId, user.getId()); httpSession.setAttribute(username, user.getUsername()); httpSession.setMaxInactiveInterval(30 * 60);后续所有需要登录才能访问的Servlet都在开头先判断Session里有没有userId没有就重定向到登录页。为了不重复写这段代码可以抽一个BaseServlet把检查逻辑放进去子类只需要继承它再实现自己的业务方法。3.2 车次查询与省市级联下拉框查询页面用最简单的方式两个下拉框出发地/目的地。你可以把城市列表放在常量数组里也可以从数据库里distinct出来。我们当时是放在一个单独的cityServlet里从车次表SELECT DISTINCT start_city加载下拉框选项。好处是以后增加新城市无需改代码坏处是每次页面加载多一次请求但数据量很小几乎没影响。用户点击查询后QueryTrainServlet接收参数并执行条件查询。这里有一个小细节不能只查start_city和end_city相等还必须把日期条件传过去。但车次表train本身没有日期字段实际卖的是某一天的某趟车所以真正查的是train_daily和train的联表SELECT t.id, t.train_no, t.start_city, t.end_city, t.start_time, t.end_time, td.left_seat, t.price FROM train t JOIN train_daily td ON t.id td.train_id WHERE t.start_city ? AND t.end_city ? AND td.travel_date ? AND td.left_seat 0;这里如果只想筛选“有票”的车次条件必须用td.left_seat 0。我见过同学忘了加这个条件结果用户看到无票车次还能点预订到提交订单时才报错体验非常差。3.3 订票提交与订单号生成订票流程分两步先在车次列表点“预订”进入确认页显示车次信息和用户要填的张数提交后由BookTicketServlet处理。订票模块的难点之一是订单号生成。不能依赖数据库自增ID当订单号因为那样暴露了销量而且用户容易猜到别人的单。我们采用规则时间戳精确到毫秒 用户ID 随机小数字段组成20位以内的字符串每台机器生成都不冲突。String orderNo System.currentTimeMillis() userId String.format(%03d, new Random().nextInt(1000));如果要追求绝对唯一可以在数据库加唯一索引插入失败就重试。但课设场景下这个规则已经够用。下单时还需要做两件事把订单状态设为0未支付把train_daily的余票减掉。这里一定要使用事务否则订单插入成功而余票扣减失败或者反过来都会造成数据不一致。事务的标准写法是conn.setAutoCommit(false); try { // 执行扣余票SQL // 执行插入订单SQL conn.commit(); } catch (SQLException e) { conn.rollback(); throw new RuntimeException(订票失败库存不足或系统异常); } finally { conn.setAutoCommit(true); // 释放连接 }我们测试时故意把插入订单SQL的表名改错验证了扣减余票会跟着回滚这正是事务的威力。忘掉事务是课设扣分的重灾区一定记住多表写入必须提交或回滚。3.4 订单列表与取消订单用户查看订单时OrderListServlet根据Session里的用户ID去订单表联查车次信息SELECT o.order_no, o.travel_date, t.train_no, t.start_city, t.end_city, t.start_time, t.end_time, o.ticket_count, o.total_price, o.status FROM orders o JOIN train t ON o.train_id t.id WHERE o.user_id ? ORDER BY o.order_time DESC;取消订单是课设中很容易忽略的细节。只把订单状态改成2是不够的必须同时把余票加回去。这个逻辑同样要用事务包裹否则你取消单了余票却被吞掉。另外要注意取消动作有条件限制只允许取消未支付的订单已经支付或取消的订单不能重复操作。在SQL里可以加上AND status 0来保证状态机正确。3.5 后台管理模块管理员端我们做了两个功能车次管理和订单总览。车次管理支持新增、修改、删除车次删除时要把关联的train_daily也一起删掉。新增车次的同时要给未来某段日期范围内的每一天初始化一条train_daily记录。比如管理员创建一个30天后运行的G1234那么从今天到第30天每天都生成一条left_seat100的记录。这个循环是在后台写的比较简单。订单总览则是一张分页表格管理员能看到所有用户的订单号和状态。这里最好加上分页不然订单一多页面就卡。分页的SQL用LIMIT offset, pageSize同时查询总数算总页数。4. 实操过程与核心环节实现4.1 项目目录结构与运行环境搭建不管你用Eclipse还是IDEA一个标准的JavaWeb项目结构大致如下TrainTicketSystem/ ├── src/ │ └── com/example/ │ ├── servlet/ │ │ ├── LoginServlet.java │ │ ├── RegisterServlet.java │ │ ├── QueryTrainServlet.java │ │ ├── BookTicketServlet.java │ │ ├── OrderListServlet.java │ │ ├── CancelOrderServlet.java │ │ └── admin/ │ │ ├── AdminTrainServlet.java │ │ └── AdminOrderServlet.java │ ├── dao/ │ │ ├── UserDao.java │ │ ├── TrainDao.java │ │ └── OrderDao.java │ ├── model/ │ │ ├── User.java │ │ ├── Train.java │ │ ├── TrainDaily.java │ │ └── Order.java │ └── util/ │ ├── DBUtil.java │ └── Md5Util.java ├── web/ │ ├── WEB-INF/ │ │ └── web.xml │ ├── css/ │ ├── js/ │ └── jsp/ │ ├── login.jsp │ ├── register.jsp │ ├── index.jsp │ ├── trainList.jsp │ ├── bookConfirm.jsp │ └── orderList.jsp └── lib/ └── mysql-connector-java-5.1.49.jar数据库连接工具类要写成单例的避免每来一个请求就新建一个连接。我们用的是最朴素的JDBC连接池手写版不我们直接用了连接池说实话Tomcat课设项目里很多同学是每次Servlet里DriverManager.getConnection性能很差。至少要封装一个DBUtil用静态代码块注册驱动再用一个Queue来复用连接。但更省心的方案是引入Apache的DBCP连接池或者直接用Tomcat自带的JNDI数据源。为了减少jar包依赖我当时选择在DBUtil里用BasicDataSource手动配置连接池这样连接复用、并发处理都稳很多。public class DBUtil { private static BasicDataSource dataSource; static { dataSource new BasicDataSource(); dataSource.setDriverClassName(com.mysql.jdbc.Driver); dataSource.setUrl(jdbc:mysql://localhost:3306/train_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai); dataSource.setUsername(root); dataSource.setPassword(yourpassword); dataSource.setInitialSize(5); dataSource.setMaxTotal(10); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }注意连接URL里的characterEncodingutf8这是解决中文乱码的第一道关。后面还会在JSP页面和Servlet里做二次保证。4.2 Servlet核心代码示例查询车次WebServlet(/queryTrain) public class QueryTrainServlet extends HttpServlet { private TrainDao trainDao new TrainDao(); Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String start request.getParameter(startCity); String end request.getParameter(endCity); String dateStr request.getParameter(travelDate); ListTrain trainList trainDao.findTrainByRoute( start, end, Date.valueOf(dateStr)); request.setAttribute(trainList, trainList); request.setAttribute(startCity, start); request.setAttribute(endCity, end); request.getRequestDispatcher(/jsp/trainList.jsp).forward(request, response); } }一定要在Servlet读取参数之前调用request.setCharacterEncoding(UTF-8)。这是老生常谈的坑如果你先getParameter再设置编码参数已经用默认ISO-8859-1解码过一次中文就变成了乱码。写代码时要记住这个顺序。TrainDao里用PreparedStatement写完SQL记得把结果集的列名跟JavaBean属性一一对应。我建议封装一个mapRow(ResultSet rs)方法不要到处写rs.getString。4.3 JSP页面的写法与EL表达式JSP页面我不推荐在% %里写Java代码那样页面会非常难维护。用EL表达式加JSTL标签把遍历、判断都放到标签里。比如车次列表的核心片段c:forEach items${trainList} vart tr td${t.trainNo}/td td${t.startCity} - ${t.endCity}/td td${t.startTime}/td td${t.endTime}/td td${t.leftSeat}/td td${t.price}/td tda hrefbookPage?trainId${t.id}date${travelDate}预订/a/td /tr /c:forEach注意${t.startTime}默认会调用Train类里getStartTime()的返回值需要你提前把java.util.Date转换成String存到对象里或者直接在getter里格式化。我们是在JavaBean里增加一个getStartTimeStr()方法用SimpleDateFormat格式化后返回JSP直接调它。页面里还要用一个c:if判断列表是否为空不然查询不到数据时只显示一个空表格的壳子交互体验很差。可以在空列表时显示一句“该时间段暂无可售票车次”这个细节很加分。4.4 web.xml配置与URL映射如果Servlet都用WebServlet注解web.xml里只需要配置欢迎页和过滤器。我记得当时项目里配置了一个全局字符编码过滤器处理所有请求和响应时的编码这样比在每个Servlet里写一行要统一WebFilter(urlPatterns /*) public class CharacterEncodingFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding(UTF-8); response.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); chain.doFilter(request, response); } }有了这个过滤器前面每个Servlet里的编码设置甚至可以省略。但要注意如果某个Servlet里还想单独设置响应内容类型别覆盖成ISO-8859-1就行。4.5 前端页面与静态资源路径首页我做了三个入口普通用户的登录注册、车次查询、管理员登录。整个前端没有使用任何框架就是HTML加CSS加一点原生JavaScript。因为选的是ServletJSP没必要把前端栈搞得太复杂。静态资源放在web目录下的css/、js/、images/JSP里引入时用绝对路径link relstylesheet href${pageContext.request.contextPath}/css/style.css这一点很多人会踩坑如果你写hrefcss/style.css当前URL路径不同会导致找不到资源。加${pageContext.request.contextPath}就是动态获取项目根路径无论哪个Servlet转发过来都稳定。4.6 本地部署与运行步骤整个系统跑起来的完整流程我整理成一份速查方便你照着操作启动MySQL执行项目里的train_db.sql脚本创建库表和初始数据。把项目打包成WAR或者直接部署到Tomcat的webapps目录。确保MySQL用户名密码和DBUtil里一致。启动Tomcat控制台不报错后访问http://localhost:8080/项目名/。用预置的管理员账号比如admin / admin123密码记得是加密存储的登录后台或者注册一个普通用户测试前端流程。测试一遍完整流程注册用户-登录-查询车次-预订-查看订单-取消订单-重新预订。如果访问时看到404多半是项目部署名不对Tomcat里访问路径不是项目根名你要在浏览器里带上项目名。如果是数据库连接失败优先检查MySQL服务有没有启动、URL里的库名和密码是否正确。5. 常见问题与排查技巧实录5.1 中文乱码问题汇总中文乱码是JavaWeb课设遇到频率最高的一个。我们当时把乱码成因分类整理过请求参数乱码、数据库写入乱码、响应页面乱码。请求参数乱码的两种场景POST请求需要request.setCharacterEncoding(UTF-8)放在读取参数之前GET请求因为Tomcat 8.0以上版本对URI默认采用UTF-8问题不大但Tomcat 7年代需要改server.xml里的URIEncodingUTF-8。数据库写入乱码需要在JDBC连接URL里加useUnicodetruecharacterEncodingutf8并且建库时表、字段都显式指定CHARACTER SET utf8mb4三处缺一不可。响应页面乱码用过滤器统一设置response.setCharacterEncoding(UTF-8)和setContentType。经验判断法如果服务器返回的HTML显示“锟斤拷”这类字符多半是编码在写入和读取过程中多次转换出了问题。用上面三件套基本能根治。5.2 数据库连接失败的六类检查点数据库连接解决不了时按这个顺序排查看MySQL服务是否启动命令行执行mysql -uroot -p能不能连上。看URL里库名是否写错拼写不一致会报Unknown database。看用户名密码是否匹配。看端口默认3306如果本机有多个MySQL实例改过端口就要在连接URL里带端口号。看JDBC驱动包有没有放在WEB-INF/lib下IDEA和Eclipse里放置路径不同特别是IDEA需要把jar包Add as Library。看依赖冲突如果项目里同时存在两个不同版本的驱动可能报NoClassDefFoundError。有个小技巧单独写一个Java类在main方法里DBUtil.getConnection()测试能排除Servlet容器的影响快速定位是不是数据库配置本身的问题。5.3 订单超卖与数据一致性问题很多同学做完订票功能测试时会发现明明余票只剩一张两个人几乎同时下单却都成功了。根本原因是代码是“先select再update”两个请求都查到剩余1然后都去update各减一次变成-1了。解决这个问题我在前面已经介绍了原子Update方案。但还有另一个隐患事务隔离级别。MySQL默认是REPEATABLE READ你在同一个事务里如果先SELECT再UPDATE而另一个事务已经提交了扣减可能因为快照读导致判断不准。所以最稳妥的落法就是把“带条件扣减”作为唯一判断依据不再单独查余票。如果你还想做“防止重复提交”的效果可以在前端把“提交”按钮在点击后置灰后端再对同一用户同一车次同一日期做一个唯一约束限制比如在orders表增加uk_user_train_date防止恶意刷票。课设做到前端置灰原子Update已经完全够用不需要再上分布式锁。5.4 JSP编译错误与页面无法访问遇到JSP文件报错时先分两种一种是Tomcat编译时提示Java语法错误说明JSP里有问题表达式或者标签库引用不对另一种是运行时显示500空指针那基本是Servlet转发时没有setAttribute就访问了某属性。用JSTL前要确认web.xml或者页面头部引入了标签库% taglib prefixc urihttp://java.sun.com/jsp/jstl/core %并且jstl.jar和standard.jar已经放到WEB-INF/lib。如果缺失页面会直接报“Unknown tag (c:forEach)”错。另一个常见问题是javax.servlet.ServletException: Servlet.service() threw exception堆栈信息里如果看到ClassNotFoundException多半是DAO类没有编译进输出目录。这时在IDE里执行Clean Project再重新部署能解决大部分灵异问题。5.5 部署到云服务器时的注意事项课设答辩时不少同学喜欢把系统部署到云服务器演示给老师看。这里有几个坑要先踩平云服务器的安全组必须放行Tomcat端口默认8080MySQL端口别暴露给公网尽量只允许本机回环连接。我见过直接在服务器上跑MySQL并把3306端口开放的这是非常危险的做法容易被暴力破解。Tomcat启动后如果云服务器上访问不了先在服务器本机执行curl http://localhost:8080通了说明Tomcat正常再检查云控制台防火墙。如果本机都访问不通八成是Tomcat端口根本没有监听成功。日志在logs/catalina.out里找线索。另外线上环境不要用开发者代码里写死的明文数据库密码。可以用环境变量读或者至少把数据库密码放到一个配置文件里并从svn/git里忽略掉。课设虽然是教学演示但安全习惯要从第一次跑通养成。6. 项目扩展与进阶方向6.1 基于这个系统平滑迁移到Spring Boot如果你课设答辩完还想继续打磨我建议用这个项目做一次“框架迁移”。你会发现ServletJSP的业务逻辑几乎可以平移DAO可以改成Spring Data JPA的Repository但查询写法差别不大BookTicketServlet里的逻辑可以改成Service层方法加个Transactional注解JSP页面则可以逐渐换成Thymeleaf或Vue前端分离。迁移时最有价值的是把原来的JDBC代码改成Spring的JdbcTemplate。连接池从手写的DBCP换成HikariCP。你也能直观体会到框架带来的便利代码量至少减少三成事务管理从手动settAutoCommit变成一行注解请求参数接收不再每次getParameter。这个过程不需要删除原项目的任何文件新建一个Spring Boot工程把原项目的model和业务逻辑代码拷过去逐步替换控制层即可。做完这一步你相当于同时掌握了传统JavaWeb和现代开发框架两套技能。6.2 增加接口化改造前后端分离现在的系统是JSP服务端渲染如果要扩展到小程序或手机App端就要把后端改造成RESTful API返回JSON而不是HTML页面。原项目的Servlet其实可以继续用只需要改成响应JSON格式用response.getWriter().write(json)并借助现成的JSON库比如Jackson把JavaBean序列化。原来所有业务逻辑和DAO层代码可以完全复用这就是分层设计带来的好处。如果有余力可以试着把车次查询、订单提交这两个接口抽成POST请求设计一下参数和返回码。但要注意接口化之后需要引入Token鉴权机制不能用Session里塞userId的方案了。这也是一个很值得研究的方向。6.3 性能优化与日志监控课设作品一般没人谈性能但如果你想在答辩时展示更深一层的思考可以从两个维度优化为orders表的order_time和status加联合索引查询车次时避免在Java里筛选把条件全部下推到SQL里为热门车次列表加Redis缓存但课设环境通常不装Redis你可以写一个简单的LRU本地缓存Demo说明思路。日志方面原来的Servlet里如果想打日志直接System.out.println就算了。要更规范一点可以引入日志库打印请求耗时、入参出参。这是企业项目很重要的习惯。我见过很多同学平时不打日志遇到bug两眼一抹黑。从课设开始每写一个Servlet都加一行“请求开始”和“请求结束耗时”的输出自己排查问题会轻松很多。6.4 从课设到面试的转化思路这个项目的亮点不是用了多新的技术而是逻辑完整、边界处理细致、有自己思考。在面试聊项目时如果你能主动讲出“余票扣减如何防止超卖”“订单状态机如何设计”“为什么用预编译防止SQL注入”会比说“我用了Spring Boot全家桶”更有说服力。因为这些问题直接考察数据库并发、安全、系统设计的理解。建议把项目代码上传到git仓库每次提交都写清提交信息把README写好记录你遇到过的坑和解决方案。在面试官看来这是很好的工程习惯。不要觉得课设项目水平低优秀和普通的分水岭永远在于你在项目里沉淀了多少自己真正消化过的知识点。我个人在带完这个项目之后最大的体会就是老技术栈并不等于落后它反而是最清晰的教材。它逼着你亲手去处理连接、事务、编码、路径、并发这些无数次被框架隐藏掉的琐事。等有一天你用Spring Boot时才能理解为什么自动配置能节省时间以及自动配置背后到底做了什么。最后再分享一个小技巧把数据库脚本单独放到项目的 environment/sql 目录里每次测试前一键执行重建表。这样就算你在开发时把数据搞乱了也能快速回到干净状态。我在做这个火车票系统时因为频繁测试订单取消和余票扣减这个脚本重置方式帮我省了无数重复造数据的时间。