简介基于Java的银行排号系统完整资料包面向学习Java Web开发、数据库设计与软件工程流程的高校学生和自学者可用于课程设计、毕业设计或实战项目复盘。压缩包约1.69MB整合了项目报告、答辩PPT、源代码和数据库形成从需求分析、架构设计到编码实现与答辩展示的完整链路便于按模块对照学习。内容基于Java面向对象思想与MVC设计模式将业务层、展示层、数据层分离涉及Servlet或Spring Boot、Swing或JavaFX、关系数据库及用户认证等关键点可作为理解银行排队场景下号码生成、窗口分配、队列状态更新等核心逻辑的典型实例。项目报告详细包含需求分析、系统设计、测试过程与问题解决方案答辩PPT带出项目背景、系统架构与关键技术源码和数据库配合使用可直接运行和二次开发。目前已有151人浏览学习适合希望掌握从零搭建信息管理系统同时提升排错、文档撰写和演示表达能力的初学者到中级开发者。1. 银行排号系统到底在做什么一个课程设计题目背后的真实业务每年毕业季和Java课程设计结束时都能看到大量“基于java的银行排号系统系统设计与实现”这样的项目包里面装着项目报告、答辩PPT、源代码和数据库文件。第一次拿到的人常以为它只是个普通桌面程序但真正复现一遍才会发现这个题目把Java基础、集合框架、JDBC、多线程、Swing事件模型全串在了一起是练手价值极高的综合项目。银行排号系统的核心业务一句话就能说清客户取号、排队等待、窗口叫号、办理完成。但把它做成一个不重号、界面不卡死、多窗口并发不冲突的程序需要解决的细节远比看上去多。这篇文章适合拿到代码但看不懂、想自己重写一遍的在校生也适合想把队列模型和并发控制捋清楚的一线Java从业者。下面我会按“需求设计 → 核心代码 → 并发 → 踩坑 → 答辩”这条路径展开每一块都给出可以直接复制的代码和参数。2. 需求拆解与系统设计从取号到叫号的模块划分与表结构2.1 排号业务有哪些角色和流程银行排号系统的业务角色并不复杂一共三类客户、柜员、管理员。客户在取号机上选择业务类型比如个人业务、对公业务、理财业务取号后等待叫号。柜员登录自己的窗口点击“呼叫下一个”系统就从对应类型的等待队列里取最早进入的客户然后把窗口号和票号推送到叫号屏。管理员负责查看窗口状态、排队人数、业务办理时长等统计信息。状态流转是这个系统的核心。我一般把票号状态定义为六种WAITING等待中、CALLED已叫号、PROCESSING办理中、FINISHED已完成、SKIPPED过号未办理、CANCELED客户取消。叫号之后客户如果在规定时间内没到窗口柜员应该把这张票标记为SKIPPED然后继续呼叫下一张。这个设计在数据库里就是一个状态字段但它的正确性决定了整个系统的可用性。除了流程还有一个比较隐蔽的需求是“按业务类型分流”。不同窗口可能服务不同业务比如窗口3、4专门办理对公窗口5办理理财。所以叫号不能全队列统一排必须按service_type过滤。如果不做这个过滤会出现对公窗口叫到个人业务票的尴尬情况。我建议在动手写代码之前先把这些流程画成状态图即使报告里不要求也一定要在脑子里有数。项目报告里的功能模块图通常就是根据这个流程拆的答辩时讲解也会顺畅很多。2.2 数据库表设计票号表、窗口表、日志表数据库是这个项目最值得花时间的部分。我见过太多人把全部数据塞在一张表里最后做统计查询时越写越乱。按常见做法至少需要三张表ticket票号表、window_info窗口表、queue_log排队日志表。CREATE TABLE ticket ( id BIGINT PRIMARY KEY AUTO_INCREMENT, ticket_no VARCHAR(32) NOT NULL, ticket_type VARCHAR(20) NOT NULL, status VARCHAR(20) NOT NULL DEFAULT WAITING, window_id INT DEFAULT NULL, create_time DATETIME NOT NULL, call_time DATETIME DEFAULT NULL, finish_time DATETIME DEFAULT NULL, UNIQUE KEY uk_ticket_no (ticket_no), KEY idx_status_type (status, ticket_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE window_info ( id INT PRIMARY KEY, window_name VARCHAR(20) NOT NULL, service_type VARCHAR(20) NOT NULL, operator_name VARCHAR(20) DEFAULT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE queue_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, ticket_id BIGINT NOT NULL, action VARCHAR(20) NOT NULL, oper_time DATETIME NOT NULL, operator_id INT DEFAULT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;先解释这两条索引。uk_ticket_no是唯一索引保证票号不重复idx_status_type是联合索引让“查询某业务类型下最早等待票”的语句直接走索引避免取号时锁表。票号生成方式不依赖数据库自增id可以先用自增id生成业务票号也可以直接用一个计数器但唯一索引是底线。queue_log里记录每一次action流转比如CALLED、FINISHED、SKIPPED答辩时可以直接用日志表的数据证明系统没有漏叫和重叫。这里有个参数细节需要特别注意status字段要使用VARCHAR还是TINYINT。我建议VARCHAR因为取到的值可以直接映射到JComboBox和前端展示省去一层枚举转换。虽然存储空间略大但这个量级的系统完全不需要考虑压缩。charset用utf8mb4避免生僻字或特殊符号写入时报错。2.3 技术选型为什么是Java Swing JDBC MySQL课程设计里的银行排号系统最常见也最稳妥的组合是Java Swing做界面、JDBC连接MySQL、MySQL存数据。为什么不用JSP或Spring Boot从演示效果看桌面端程序启动即用不依赖Tomcat答辩现场不容易出环境事故从代码结构看Swing天然把事件监听、界面刷新、业务逻辑拆开适合体现面向对象编程java的基本功。很多人在简历上写“熟悉JavaWeb”但如果你想练java基础桌面项目比Web项目更容易看到每一行代码的执行路径。我也看到过用JavaFX或Spring Boot Vue重构排号系统的案例但那是给有一定工作经验的人准备的。对于课程设计和毕业设计Swing MySQL的容量已经足够覆盖所有功能点还能突出数据库事务、多线程、集合框架这三个核心评分点。数据库连接驱动推荐mysql-connector-java 8.0.33JDBC URL里需要显式写上时区和编码参数这一点后面避坑章节会具体展开。3. 核心代码实现把“取号—叫号—过号”用队列模型跑通3.1 取号逻辑生成连续票号并防止重号取号模块是最先要写的因为后面所有功能都依赖票号是否唯一。常见做法是使用数据库自增id来生成业务票号这样天然连续且不会重复。代码我一般写成下面这样public String createTicket(Connection conn, String type) throws SQLException { String insertSql INSERT INTO ticket(ticket_type, status, create_time) VALUES(?, WAITING, NOW()); try (PreparedStatement ps conn.prepareStatement(insertSql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, type); ps.executeUpdate(); ResultSet rs ps.getGeneratedKeys(); long id 0; if (rs.next()) { id rs.getLong(1); } String dateStr LocalDate.now().format(DateTimeFormatter.ofPattern(yyyyMMdd)); String ticketNo T dateStr String.format(%05d, id); updateTicketNo(conn, id, ticketNo); return ticketNo; } }生成票号的逻辑说明先插入一条状态为WAITING的记录拿到自增id后把id拼进票号。比如第60001号记录生成的就是“T2025030500001”这样的编号。String.format里的%05d表示id至少占5位不足前补0如果id超过99999格式化会自动扩位不会产生错乱。之后再执行UPDATE把ticket_no写回配合表里的唯一索引即使并发插入数据库层也会拦截重复值。这里有个参数选择问题为什么不用“当天序号1”的方式因为当天最大值的查询在并发下非常容易出问题。多个请求同时读到最大值100然后同时插入101就会重复。虽然可以用SELECT MAX(ticket_no) FOR UPDATE锁行但性能不高而且需要额外处理空表的情况。直接用自增id是性价比最高的方案代码简单还不会漏号。3.2 窗口叫号从待叫队列弹出队首并修改状态叫号是系统的核心动作要求“一次只能叫出一张票且必须是最早等待的那张”。如果两个柜员同时点击“呼叫下一个”系统必须保证同一张票不会被叫两次。这里必须使用事务和行锁public boolean callNext(Connection conn, int windowId, String serviceType) throws SQLException { conn.setAutoCommit(false); try { String selectSql SELECT id FROM ticket WHERE status WAITING AND ticket_type ? ORDER BY create_time ASC LIMIT 1 FOR UPDATE; try (PreparedStatement ps conn.prepareStatement(selectSql)) { ps.setString(1, serviceType); ResultSet rs ps.executeQuery(); if (!rs.next()) { conn.commit(); return false; // 没有排队客户 } long ticketId rs.getLong(1); String updateSql UPDATE ticket SET status CALLED, window_id ?, call_time NOW() WHERE id ?; try (PreparedStatement pu conn.prepareStatement(updateSql)) { pu.setInt(1, windowId); pu.setLong(2, ticketId); pu.executeUpdate(); } insertLog(conn, ticketId, CALLED, windowId); } conn.commit(); return true; } catch (SQLException e) { conn.rollback(); throw e; } }这段代码有几个关键参数。ORDER BY create_time ASC决定了队列的公平性LIMIT 1 FOR UPDATE把查询到的行锁住直到事务提交。这样窗口A锁定票号1后窗口B再执行同样的查询只能等待直到窗口A提交commit后才看到票号2。注意ticket_type条件必须走联合索引idx_status_type否则FOR UPDATE可能升级成锁表性能骤降。调用这个函数前还需要在视图层判断当前窗口是否已经处于PROCESSING状态。如果窗口正在办理某张票就不能再取新票。这个状态判断可以在内存里维护一个Map窗口ID, 当前票ID也可以在window_info表里加一个current_ticket_id字段。3.3 界面刷新JTable实时显示排队队列界面上一般用JTable展示当前排队列表并在叫号后自动刷新。很多初学者会在后台线程里循环执行Thread.sleep(1000)然后直接更新表格结果界面卡顿甚至白屏。正确做法是使用Swing的Timer定时器配合SwingWorker做数据库查询Timer timer new Timer(2000, e - refreshQueuingTable()); timer.start(); private void refreshQueuingTable() { SwingWorkerListTicket, Void worker new SwingWorker() { Override protected ListTicket doInBackground() throws Exception { return ticketDao.listWaitingTickets(); // JDBC查询 } Override protected void done() { try { DefaultTableModel model (DefaultTableModel) table.getModel(); model.setRowCount(0); // 清空旧数据 for (Ticket t : get()) { model.addRow(new Object[]{t.getTicketNo(), t.getTicketType(), t.getStatus()}); } labelCount.setText(当前等待人数 model.getRowCount()); } catch (Exception e) { JOptionPane.showMessageDialog(frame, 刷新失败 e.getMessage()); } } }; worker.execute(); }Timer的两个参数要注意第一个2000是刷新间隔毫秒数对于本地MySQL场景2秒足够不要把间隔设成100反而会增加数据库压力第二个e是监听事件里面调用刷新方法。SwingWorker的doInBackground在后台线程执行done方法自动切回事件分发线程EDT更新组件时不会再出现线程冲突。如果希望让界面更贴近真实银行柜台可以再加一个JComboBox下拉框选项是“全部业务、个人业务、对公业务、理财业务”。监听该下拉框的selectedItem变化刷新时把业务类型作为参数传给ticketDao.listWaitingTickets()即可实现对单个队列的过滤展示。这个功能虽然简单但在答辩时非常加分。4. 多线程与并发排号系统最容易翻车的三个并发点4.1 多个窗口同时叫号锁与事务的边界银行排号系统天然是多用户并发系统。即使单机版只有两三个窗口同时点击“呼叫下一个”也足以触发并发问题。最典型的翻车场景是窗口A和窗口B同时查询都查到当前最早等待的是票号5然后各自更新为CALLED结果同一个客户被两个窗口叫走。要打破这个问题靠synchronized只是权宜之计因为它只对单进程内的线程有效只要系统部署成客户端连服务器的结构就一定要靠数据库锁。前面3.2小节给出的SELECT ... FOR UPDATE方案是MySQL InnoDB下确保“先查后改”原子性的标准做法。FOR UPDATE会锁住选中的那一行事务未提交前其他事务只能等。但这里有一个必须检查的细节查询条件里的status和ticket_type必须能命中联合索引。如果MySQL选择不全可能锁住索引范围里的多行极端情况下锁表导致其他窗口完全卡死。排查方式是用EXPLAIN执行计划确认key字段为idx_status_type。另一个常见做法是乐观锁给ticket表加一个version字段更新时校验version是否匹配。但排队场景用乐观锁会很别扭窗口叫号时拿到version1另一个窗口抢先更新成version2当前窗口更新失败需要重新查询可能叫到别人已经叫过的票交互上很混乱。所以银行排号系统用悲观锁是更靠谱的选择这和电商秒杀场景不同不需要追求高并发吞吐。4.2 界面线程与业务线程Swing的单线程规则Swing的界面组件只能在事件分发线程EDT里操作。很多人写Swing程序时会直接在按钮的ActionListener里执行数据库查询导致点击按钮后界面卡成“未响应”。原因很简单数据库查询是耗时操作如果放在EDT里执行整个界面包括鼠标移动都会被阻塞。解决办法是把耗时的数据库操作丢到后台线程。btnCallNext.addActionListener(e - { btnCallNext.setEnabled(false); // 防重复点击 int windowId currentWindowId; String serviceType currentServiceType; SwingWorkerBoolean, Void worker new SwingWorker() { Override protected Boolean doInBackground() throws Exception { return windowService.callNext(conn, windowId, serviceType); } Override protected void done() { btnCallNext.setEnabled(true); if (Boolean.TRUE.equals(get())) { refreshQueuingTable(); labelWindow.setText(窗口 windowId 正在办理...); } else { JOptionPane.showMessageDialog(frame, 当前队列没有等待客户); } } }; worker.execute(); });这段代码里的btnCallNext.setEnabled(false)非常关键。它可以在后台线程执行期间把按钮置灰防止用户连点。如果连点就会发线程池里堆积多个叫号任务可能连续叫出好几张票。等done里再重新置回enabled确保每次点击只触发一次叫号。使用SwingWorker的好处是doInBackground的返回值可以安全地在done里使用不需要自己维护线程之间的复杂通信。4.3 并发测试用两个线程模拟叫号验证不重号写好事务和锁之后必须验证并发正确性。我习惯写一个简单的并发测试类用CountDownLatch让多个线程同时释放模拟多个窗口同时叫号。ExecutorService executor Executors.newFixedThreadPool(5); CountDownLatch latch new CountDownLatch(1); // 同时放行 int callCountPerWindow 20; AtomicInteger calledCount new AtomicInteger(0); for (int w 1; w 5; w) { int windowId w; executor.submit(() - { try { latch.await(); // 等统一信号 for (int i 0; i callCountPerWindow; i) { if (windowService.callNext(conn, windowId, 个人业务)) { calledCount.incrementAndGet(); } } } catch (Exception e) { e.printStackTrace(); } }); } latch.countDown(); executor.shutdown(); executor.awaitTermination(1, TimeUnit.MINUTES); System.out.println(实际叫号次数 calledCount.get()); System.out.println(数据库CALLED状态票数 ticketDao.countByStatus(CALLED));执行后两个值必须相等。如果实际叫号次数明显大于数据库CALLED状态票数说明有重复叫号如果小于说明有叫号事务回滚或锁等待超时。我还建议在ticket表里加一个联合唯一索引UNIQUE(window_id, call_time)但这个只是一个辅助测试手段正常业务下不是必须的。这个测试跑通后再验证“过号”场景。手动把某张票设成SKIPPED然后再次调用callNext应该能取到下一张等待票而不是永远卡在过号那张上。这一步常被人忽略但在答辩演示时经常被评委追问。5. 避坑与常见问题排查复现排号系统时最值得记住的5个踩坑记录5.1 MySQL连接失败时区与驱动版本不匹配现象代码刚启动连接数据库就抛CommunicationsException或者连接超时异常。有些同学会以为是MySQL没启动其实问题往往出在JDBC URL参数上。原因MySQL 8.0以上默认时区使用UTC而本机时区是Asia/Shanghaiplus服务器默认开启SSL握手旧版驱动不认识新的加密协议如果驱动版本和数据库版本差距太大还会出现“Public Key Retrieval is not allowed”这个经典报错。解决JDBC URL里显式加上useSSLfalse、serverTimezoneAsia/Shanghai、characterEncodingutf8并使用较新的驱动包。这是我常用的配置jdbc:mysql://localhost:3306/bank_queue?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8rewriteBatchedStatementstrue补充一个参数说明rewriteBatchedStatementstrue对批量插入有效如果你需要造测试数据这个参数能带来成倍的速度提升。但如果你只是单条插入开不开没有太大影响。出现“Public Key Retrieval is not allowed”时可以在URL里加allowPublicKeyRetrievaltrue但更推荐把驱动升级到8.0.33以上。5.2 票号重复没有给ticket_no加唯一索引现象并发测试时发现某两张票的ticket_no完全相同或者报表统计时票号总数对不上记录数。原因代码里用“查询当前最大值再加1”的方式生成票号没有给表加唯一约束加上并发时两个线程同时读到最大值各自加1自然重复。解决第一给ticket_no字段加UNIQUE索引第二程序里改用自增id拼业务票号。双保险才能保证万无一失。即使代码逻辑写错了数据库也能在最后一道防线拦住重复数据。一个另类的好习惯是在插入后捕获DuplicateKeyException如果发生不要直接抛给前端而是重试一次这样体验更好。5.3 叫号叫到同一张票FOR UPDATE没生效现象两个窗口同时点“呼叫下一个”结果叫号屏上出现同一张票被两个窗口显示。原因一种是查询条件里的status和ticket_type组合没走索引导致锁住更大范围另一种是事务里执行完SELECT后在UPDATE之前代码抛了异常但没有rollback导致锁一直没有释放后续操作全被阻塞。还有更隐蔽的因为连接池配置不当多个业务共用同一个Connection导致几个窗口的代码跑到同一个事务里互相干扰。解决用EXPLAIN检查执行计划是否命中idx_status_type确保callNext方法在事务内如果使用连接池将连接池的最小空闲连接数设为5左右避免窗口线程共用连接。下面是HikariCP的一个经典配置property namemaximumPoolSize value10/ property nameminimumIdle value5/ property nameconnectionTimeout value30000/ property nameidleTimeout value600000/注意maximumPoolSize不需要调得很大。这个系统最多5个窗口并发10个连接足够让每个窗口都拿到独立连接。如果调成100MySQL默认连接数配置可能扛不住。5.4 界面不刷新或一直转圈刷新线程写错了现象点击叫号后JTable里的数据迟迟不变或者表格偶尔闪一下表头内容完全空白。原因很多人习惯直接new Thread(() - { while(true) { model.addRow(...); Thread.sleep(1000); }}).start()一边在后台线程里不断往DefaultTableModel里塞数据一边又在EDT里刷新两边的数据互相打架。更常见的场景是把数据库查询放到了后台线程但在后台线程里直接调用了table.updateUI()违反了Swing单线程规则。解决所有界面组件的读写都放SwingWorker的done方法或Timer的监听事件里。后台线程只负责返回数据不接触任何组件。如果你在刷新时发现表格一直显示“加载中”优先检查是不是refreshTable方法里的model.setRowCount(0)写在了查询之前导致每次清空后还没填充数据界面就被重绘了。5.5 数据库连接泄漏运行十分钟后彻底无响应现象程序刚启动一切正常运行一段时间后点击任何按钮都没反应控制台出现“Too many connections”或者连接池耗尽异常。原因每取一次号、每刷一次列表都打开一个Connection用完后没有关闭。Java中Connection、Statement、ResultSet这三个对象都实现了AutoCloseable但很多人只关了Connection没有关Statement和ResultSet或者只是写了conn.close()而中间try里抛了异常finally没有妥善处理导致连接泄漏。解决统一使用try-with-resources彻底解决资源关闭问题。示例如下try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { // 处理结果 } } catch (SQLException e) { e.printStackTrace(); }上面这个写法会自动按逆序关闭ResultSet、Statement、Connection不需要手动写finally。如果项目里还没有引入连接池只用DriverManager.getConnection那么程序结束前必须显式关闭连接。时间一长连接池就会把数据库的连接数占满整个系统就像黑匣子一样卡死重启才能恢复。6. 从课程设计到答辩PPT讲稿与验证数据怎么准备6.1 造数脚本一次性生成5个窗口和50张排队票答辩演示最忌讳现场只有两三条数据看起来不像真实系统。我一般会准备一个造数方法输入窗口数和票数自动生成随机业务类型和时间。-- 窗口数据 INSERT INTO window_info (id, window_name, service_type, operator_name) VALUES (1, 1号窗口, 个人业务, 柜员A), (2, 2号窗口, 个人业务, 柜员B), (3, 3号窗口, 对公业务, 柜员C), (4, 4号窗口, 理财业务, 柜员D); -- 批量造票 INSERT INTO ticket (ticket_no, ticket_type, status, create_time) SELECT CONCAT(T, DATE_FORMAT(NOW(), %Y%m%d), LPAD(id, 5, 0)), ELT(1 FLOOR(RAND()*3), 个人业务,对公业务,理财业务), WAITING, NOW() - INTERVAL FLOOR(RAND()*60) MINUTE FROM ( SELECT rownum : rownum 1 AS id FROM (SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5) a, (SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5) b, (SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION SELECT 5) c, (SELECT rownum : 0) r ) t LIMIT 50;这段SQL里用了ELT和RAND来生成随机业务类型LPAD补零生成票号。注意冷知识这条语句执行完后ticket表需要事先清空或者把id的初始值调整好否则会造出重复的ticket_no。6.2 答辩演示路径先跑流程再讲并发最后讲数据答辩时间通常只有八到十分钟不用把界面每个按钮都演示一遍。我建议按三条线来演示。第一条取号与叫号主流程现场取三张不同业务类型的票点击叫号再办理完成让状态从WAITING走到FINISHED。第二条并发功能开着两个窗口同时点叫号展示数据库里没有重复叫号这个环节可以直接用4.3小节的并发测试类输出日志。第三条统计展示打开queue_log表展示SKIPPED票被正确跳过FINISHED票平均办理时间。能讲清楚这三条评分不会低。6.3 被问住时的补救把“两个窗口同时叫号”当成加分项最后说一个我的血泪经验。当年答辩演示时评委问了一句“如果两个窗口恰好同时点到同一个客户你的系统怎么处理”我当场愣住因为没做过并发测试。后来我把FOR UPDATE的事务方案彻底弄懂再被问同样问题时反而变成最自信的环节。你可以直接在PPT里放一张EXPLAIN的截图展示FOR UPDATE命中索引顺便讲清楚为什么事务隔离级别要选默认的REPEATABLE_READ而不是READ_COMMITTED。这种“把踩坑转化为亮点”的做法远比背概念更有说服力。希望帮到你。本文还有配套的精品资源点击获取