多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

基于Java的银行排号系统:从数据库设计到并发取号实现

基于Java的银行排号系统:从数据库设计到并发取号实现 简介一套基于Java技术的银行排号系统完整实战项目面向计算机相关专业学生及Java初学者以银行排队取号业务为场景完整覆盖系统设计、编码实现、项目汇报与答辩各环节。系统采用MVC设计模式将业务逻辑、数据处理与用户界面分层解耦数据库遵循关系型设计原则存储用户信息、预约记录与队列状态客户端利用Swing或JavaFX构建操作界面后台服务负责号码生成、窗口分配与队列更新同时包含用户认证、权限控制及异常处理等稳定性措施。压缩包内含项目报告、答辩PPT、Java源代码及数据库脚本整包约1.69MB可对照查看从需求分析、架构设计到代码落地、方案陈述的完整流程。已有152人学习浏览适合用于课程设计参考、毕业设计选题或Java Web开发综合练习。1. 银行排号系统不只是“取个号”Java课设背后的一整套设计与实现如果你在高峰期去过银行网点一定见过大堂经理手里那叠号码纸喊号靠嗓子过号重排靠记忆。所谓“基于java的银行排号系统”就是把这一整套流程搬进程序里取号、排队、叫号、过号、窗口管理、数据统计。很多同学做这个课设时以为核心是界面好不好看实际上真正难住人的是并发取号不会重复、叫号状态不乱、系统重启后队列还能恢复。这套系统适合正在做课程设计或毕业设计的Java初学者也适合想搞懂队列、JDBC、Swing和MySQL怎么凑成一套完整项目的开发者。代码量不大但五脏俱全做完它你能把数据库增删改查、多线程、状态机这些面试常考的东西一次打通。2. 先把设计与数据库立住模块拆分、四张核心表与BaseDao拿到“银行排号系统”这类标题我习惯先打开一个空白文档画模块而不是急着建类。因为排号系统的本质是号码的生命周期取号是创建等待是入队叫号是出队过号是状态异常完成是状态终止。只要把这几个状态想清楚后面代码不过是围绕状态做流转。2.1 为什么先拆“取号、叫号、窗口、记录”四个模块常见做法是把系统拆成四个职责独立的模块取号模块负责生成号码并写入数据库叫号模块负责从等待队列取人窗口模块负责维护窗口状态、绑定当前服务号码记录模块负责把每一次操作写入日志作为统计和审计的依据。四个模块各管一段后续加“VIP优先”“过号重呼”这类功能时改动范围会被限制在某一个模块里不会牵一发动全身。取号模块最重要的约束是“绝不重复”所以在设计上不能依赖SELECT MAX然后加一这种先查后改的思路。叫号模块要考虑的不是快而是公平普通业务按到达顺序VIP业务可以插队。窗口模块看似简单却要和叫号模块保持状态一致否则会出现号码已经叫了窗口却不知道在叫哪一号的情况。记录模块是大部分课设最容易忽略的但它恰恰是答辩时证明系统可靠性的证据。2.2 创建 MySQL 数据库与四张表完整的 DDL 脚本我一般会把数据放在四张表里业务类型表、窗口表、号码表、操作日志表。业务类型表决定取号前缀和当前序号窗口表保存窗口状态号码表是整套流程的核心操作日志表把“取号、叫号、过号、完成”这些动作全部留下痕迹。下面是直接可执行的 MySQL 建表脚本。CREATE DATABASE bank_queue DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE bank_queue; CREATE TABLE t_biz_type ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, biz_code VARCHAR(20) NOT NULL COMMENT 业务编码如 PERSONAL、VIP, biz_name VARCHAR(50) NOT NULL COMMENT 业务名称如个人现金业务, prefix VARCHAR(10) NOT NULL COMMENT 号码前缀如 A、B、V, current_no INT NOT NULL DEFAULT 0 COMMENT 当前已取到的序号, is_active TINYINT NOT NULL DEFAULT 1 COMMENT 1启用0停用, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_biz_code (biz_code) ) ENGINEInnoDB; CREATE TABLE t_window ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, win_no VARCHAR(10) NOT NULL COMMENT 窗口编号如 01, win_name VARCHAR(50) NOT NULL COMMENT 窗口名称如综合窗口, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用0停用, current_ticket_id BIGINT UNSIGNED NULL COMMENT 当前正在服务的号码id, called_times INT NOT NULL DEFAULT 0 COMMENT 累计叫号次数, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_win_no (win_no) ) ENGINEInnoDB; CREATE TABLE t_ticket ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, ticket_no VARCHAR(20) NOT NULL COMMENT 完整号码如 A0001, biz_type_id BIGINT UNSIGNED NOT NULL, window_id BIGINT UNSIGNED NULL COMMENT 最后受理窗口, status TINYINT NOT NULL DEFAULT 0 COMMENT 0等待1叫号2过号3完成4取消, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, call_time DATETIME NULL, finish_time DATETIME NULL, KEY idx_ticket_status (status), KEY idx_ticket_create (create_time), CONSTRAINT fk_ticket_biz FOREIGN KEY (biz_type_id) REFERENCES t_biz_type(id), CONSTRAINT fk_ticket_window FOREIGN KEY (window_id) REFERENCES t_window(id) ) ENGINEInnoDB; CREATE TABLE t_operation_log ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, ticket_id BIGINT UNSIGNED NOT NULL, action VARCHAR(20) NOT NULL COMMENT CREATE/CALL/CANCEL/FINISH, operator VARCHAR(50) NOT NULL DEFAULT system, action_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, remark VARCHAR(200) NULL, KEY idx_op_ticket (ticket_id), CONSTRAINT fk_op_ticket FOREIGN KEY (ticket_id) REFERENCES t_ticket(id) ) ENGINEInnoDB;这段 DDL 有几个关键设计整个库使用 utf8mb4避免中文乱码current_no 放在 t_biz_type 而不是 t_ticket 里是为了取号时能通过 UPDATE 原子自增而不是全表扫描 MAXt_ticket.status 用 TINYINT 保存状态比字符串更省空间也方便扩展。外键在课设里经常被省略但我建议保留因为号码表关联业务类型和窗口外键能防止日后测试数据出现“窗口不存在却绑定了号码”的脏数据。2.3 用 JDBC 写一个通用 BaseDao让增删改查不再重复数据库增删改查是这套系统最频繁的操作。我一般先写一个通用的 BaseDao把连接、更新、查询、释放全部收拢起来之后的 TicketDao、WindowDao 只需要传 SQL 和参数不需要每个方法都重复写数据库连接。package com.bank.dao; import java.io.InputStream; import java.sql.*; import java.util.*; public class BaseDao { private String url; private String username; private String password; public BaseDao() { try (InputStream in getClass().getClassLoader().getResourceAsStream(db.properties)) { Properties props new Properties(); props.load(in); this.url props.getProperty(jdbc.url); this.username props.getProperty(jdbc.username); this.password props.getProperty(jdbc.password); Class.forName(com.mysql.cj.jdbc.Driver); } catch (Exception e) { throw new RuntimeException(数据库配置初始化失败, e); } } protected Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } protected int executeUpdate(String sql, Object... params) { try (Connection conn getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i params.length; i) { ps.setObject(i 1, params[i]); } return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(数据库更新失败: sql, e); } } protected ListMapString, Object query(String sql, Object... params) { ListMapString, Object list new ArrayList(); try (Connection conn getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i params.length; i) { ps.setObject(i 1, params[i]); } try (ResultSet rs ps.executeQuery()) { ResultSetMetaData meta rs.getMetaData(); int colCount meta.getColumnCount(); while (rs.next()) { MapString, Object row new LinkedHashMap(); for (int i 1; i colCount; i) { row.put(meta.getColumnLabel(i), rs.getObject(i)); } list.add(row); } } } catch (SQLException e) { throw new RuntimeException(数据库查询失败: sql, e); } return list; } }参数说明executeUpdate 负责增删改query 负责查询第二个参数是可变参数PreparedStatement 可以有效防止 SQL 注入。try-with-resources 保证资源一定关闭用 Java 经验来看数据库连接泄漏多半是 ResultSet 或 Statement 忘关造成的。连接串建议写成jdbc:mysql://localhost:3306/bank_queue?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/ShanghaiuseSSLfalse少了 characterEncoding 就会出现中文乱码。如果换成 MyBatis-Plus你甚至可以根据 Java 实体类生成创建表的 SQL 语句但课设阶段手写 JDBC 更能把每一步讲清楚答辩时不至于被问“配置文件里每个参数是什么意思”就卡壳。3. 取号与叫号的 Java 核心逻辑从“当前号1”到线程安全排号系统真正的技术含量集中在两个动作取号和叫号。这两个动作如果单机单线程跑任何写法都能工作但只要两个窗口同时操作就会暴露出一堆并发问题。这一章我把最常见的实现思路和参数讲清楚。3.1 取号算法别用 SELECT MAX 然后 1很多 Java 初学者写取号时会自然想到先查当前最大号码再加一然后插入。这个思路在一个窗口点击时没问题但两个请求同时执行 SELECT会读到同一个最大值于是生成两个相同号码。解决这个问题有两种常见做法一种是在 Java 方法上加 synchronized另一种是利用数据库 UPDATE 的原子性。我更推荐第二种因为它天然跨进程安全系统重启后也不容易乱号。下面这段代码模拟了基于 t_biz_type 表原子自增的取号逻辑。先 UPDATE current_no 让它加一再查最新序号整个过程放在一个事务里数据库的行锁会保证任何时刻只有一个请求能成功更新同一行。private static final int NUMBER_WIDTH 4; public String nextNumber(long bizTypeId) { String updateSql UPDATE t_biz_type SET current_no current_no 1 WHERE id ?; String selectSql SELECT prefix, current_no FROM t_biz_type WHERE id ?; try (Connection conn getConnection()) { conn.setAutoCommit(false); try (PreparedStatement ps conn.prepareStatement(updateSql)) { ps.setLong(1, bizTypeId); int rows ps.executeUpdate(); if (rows 0) { throw new IllegalStateException(业务类型不存在); } } String prefix ; int currentNo 0; try (PreparedStatement ps conn.prepareStatement(selectSql)) { ps.setLong(1, bizTypeId); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { prefix rs.getString(prefix); currentNo rs.getInt(current_no); } } } conn.commit(); return prefix String.format(%0 NUMBER_WIDTH d, currentNo); } catch (SQLException e) { throw new RuntimeException(取号失败, e); } }这里 NUMBER_WIDTH 决定了号码长度A0001 就是宽度 4如果单日取号量超过一万可以考虑把宽度改成 5注意 String.format 的占位符要一起变。把 setAutoCommit(false) 和 commit 拆出来是为了保证 UPDATE 和 SELECT 在同一个事务里否则更新完还没查时另一线程插进来仍可能读到旧值。实际课设里我还见过有人在 Java 里用 AtomicInteger 生成号但如果系统重启AtomicInteger 会从 0 重新开始除非启动时从数据库回填当前值。3.2 叫号队列用 Queue 还是 PriorityQueue过号怎么处理叫号模块的本质是“从队列头部取一个等待中的号码”。常见的做法是在内存里维护MapLong, QueueTicketkey 是业务类型 idvalue 是该业务类型的等待队列。用ArrayDeque做普通队列就够不要在这个场景里写自定义排序有人喜欢把冒泡排序 java 实现拿来给号码排序其实号码本来就是按取号顺序插入的排序反而是多余操作。对于 VIP 优先场景可以改用PriorityBlockingQueue按业务类型设置优先级。这里有一个容易犯的错误如果用普通队列取出的元素需要手动把状态从“等待”改成“叫号”同时把窗口 current_ticket_id 更新到这张号码。这个状态更新必须在队列 poll 之后立刻执行否则两个窗口可能同时 poll 到同一个号码。public void callNext(long windowId) { Long bizTypeId windowService.getBizTypeId(windowId); QueueTicket queue queues.get(bizTypeId); if (queue null || queue.isEmpty()) { showMessage(当前没有等待的号码); return; } Ticket ticket queue.poll(); ticketDao.updateStatus(ticket.getId(), 1, windowId); windowDao.bindTicket(windowId, ticket.getId()); ticketDao.insertLog(ticket.getId(), CALL, user); }这段逻辑里最关键的一行是ticketDao.updateStatus(ticket.getId(), 1, windowId)它把号码状态从 0 变成 1。如果漏掉这一步界面上看起来号已经叫了但数据库里还在等待列表过号重呼时就会出现同一号码被叫两次的问题。过号的处理方式是给状态字段预留一个“过号”值比如 2用户没到窗口时点击“过号”按钮把状态改成 2如果用户又来了可以点“重呼”把状态改回 1或者把它重新放回队列尾部。这一套状态流转最好写成一个独立方法避免在按钮事件里反复粘贴业务代码。3.3 Swing 界面与逻辑分离一个最小可运行的呼叫面板做桌面版银行排号系统最常见的坑是把数据库查询直接写在按钮事件里。Swing 的按钮事件默认跑在事件分发线程上一旦执行慢 SQL界面就卡住不动看起来很玄学其实是线程阻塞。正确做法是后台线程执行耗时操作再回到事件线程更新界面。下面是一个极简的“叫号”按钮示例。JButton nextBtn new JButton(叫号); nextBtn.addActionListener(e - { SwingWorkerVoid, Void worker new SwingWorker() { Override protected Void doInBackground() { ticketService.callNext(1L); return null; } Override protected void done() { numberLabel.setText(A0001请到1号窗口); } }; worker.execute(); });doInBackground 里跑的是数据库操作done 里更新界面这样点击按钮后窗口不会卡住。如果你用的是 Java 8SwingWorker 的写法稍微有点繁琐但思路不变。实际上不少 java 面试题会问“Swing 是线程安全的吗”答案是不安全所以界面更新必须回到事件分发线程。这部分看起来简单但我在帮人排查课设代码时遇到最多的就是按钮卡死和号码重复。4. 让系统能看能讲统计 SQL、测试用例与答辩素材准备代码能跑只是第一步课设验收还要交项目报告和答辩 PPT。我发现很多人在这一步翻车因为系统只能演示拿不出让评委信服的数据。与其临时编数据不如用好数据库里积累的 real 数据把它变成报表和测试用例。4.1 统计模块等待人数、平均等待时间、叫号次数的 SQL 与代码排号系统最典型的三个统计指标各业务类型等待人数、每个窗口的叫号次数、平均等待时间。这三个指标可以直接用 SQL 算出来在 Java 里执行后展示到“统计面板”。等待人数能体现系统是否积压平均等待时间是服务质量的直接证据叫号次数则能说明窗口忙闲程度。下面是三条常用的统计查询。SELECT b.biz_name, COUNT(t.id) AS waiting_count FROM t_ticket t JOIN t_biz_type b ON t.biz_type_id b.id WHERE t.status 0 GROUP BY b.biz_name; SELECT w.win_no, w.win_name, COUNT(t.id) AS call_count FROM t_window w LEFT JOIN t_ticket t ON t.window_id w.id AND t.call_time IS NOT NULL GROUP BY w.win_no, w.win_name; SELECT AVG(TIMESTAMPDIFF(MINUTE, t.create_time, t.call_time)) AS avg_wait_minutes FROM t_ticket t WHERE t.call_time IS NOT NULL;在 Java 里调用 BaseDao.query 就能拿到 List再拼到 JTable 或 JTextArea 上。注意第一个查询只统计 status0因为 status1 的号码已经被叫到窗口不再处于排队状态如果查询条件没带状态会把正在办理的号码也算进去导致等待人数虚高一倍。第三个查询里的 TIMESTAMPDIFF 是 MySQL 函数第一个参数 MINUTE 表示分钟粒度如果你希望更精确可以改成 SECOND。不少同学在这里手动用 Java 计算时间差其实 SQL 函数更简单也避免跨时区问题。4.2 用数据支撑项目报告和答辩 PPT项目报告里最需要的不是代码片段而是设计证据。我一般会在报告里放置四张图系统功能模块图、业务流程图、数据库 E-R 图、状态转换图。E-R 图可以直接从四张表的关系画出来状态转换图则体现 t_ticket.status 的六种流转。这些图不需要工具用 draw.io 就能完成答辩 PPT 里截取关键部分即可。要避免整页贴代码评委更想看到的是“你如何思考”。答辩前一定要准备一组测试用例最好是手工执行后记录的表格字段包括测试编号、操作步骤、预期结果、实际结果、是否通过。举几个例子先取号 A0001再取号 A0002然后窗口叫号确认屏幕显示 A0001窗口叫号后不处理点击过号再点击重呼确认状态从 2 变回 1同时打开两个窗口模拟两个请求取号确认号码没有重复。这些测试用例既是验收依据也是“项目报告”里最有说服力的章节比从网上抄一堆可行性分析实在得多。源代码管理也是一个容易被忽略的加分项。用 Git 在项目开始时建仓库每完成一个模块就提交一次报告里附上 commit 记录能明显看出项目是逐步迭代出来的。答辩被问到“你这个项目做了多久”时Git 历史比任何解释都直观。当然源码包里的数据库脚本也要纳入版本控制否则换一台电脑运行整个系统就变得异常困难。4.3 数据库备份与恢复答辩前别让数据“蒸发”课设答辩现场最尴尬的情况是打开系统取号页面正常但之前测试产生的排队数据全部没了。这通常是因为没有备份数据库甚至有人把数据存在内存里程序一关就清空。银行排号系统是数据敏感型项目验证和演示都依赖历史数据所以备份必须做。常见做法是使用 mysqldump 定时备份命令很简单mysqldump -uroot -p bank_queue backup_$(date %Y%m%d).sql恢复时执行mysql -uroot -p bank_queue backup_20260101.sql这个命令在项目报告里作为“系统维护”一节出现非常合适。即使没有任何数据库同步软件只要每天备份一次答辩前的手工测试数据就不会丢。注意备份文件里包含了 t_biz_type.current_no恢复后取号序号也能继续从备份时的值递增不会出现取号号码倒退的诡异问题。5. 避坑银行排号系统高频翻车现场的 5 个排查清单这里整理了我帮人排查这类项目时最常遇到的五个问题每条都按“现象 → 原因 → 解决”来写希望能帮你跳过那些让人挠头的坑。5.1 取号重复两个窗口同时取到 A0001现象快速点击取号或者用两个客户端同时取号数据库里出现两条 A0001。原因取号逻辑写成了 SELECT MAX(ticket_no) 1。两个请求同时查询时都查到当前最大号是 A0000于是各自插入 A0001。这是典型的读-改-写竞态Java 方法不加锁也没用因为两个请求可能跑在不同的连接里。解决使用 UPDATE t_biz_type SET current_no current_no 1 原子自增同一行数据的更新在数据库层是串行的或者给取号方法加 synchronized但应用层锁只在单进程内有效。建议采用数据库更新方案既简单又稳。5.2 叫号后状态一直“等待”过号无法重呼现象窗口点了“叫号”屏幕也显示号码了但用户没到场点击“过号”时系统提示“没有等待号码”点击“重呼”也没有反应。原因叫号时只把号码从内存队列取出来没有更新 t_ticket.status。数据库里号码仍然是 0 等待状态窗口模块自然认为它不是当前号码过号和重呼都找不到数据。解决在队列 poll 之后立刻执行状态更新把 status 改成 1并写入窗口 id。定义常量WAIT0, CALL1, NO_SHOW2, FINISH3, CANCEL4过号操作把 1 改成 2重呼操作把 2 改成 1。这样每次操作都有明确的状态依据。5.3 重启系统后排队队列全部丢失现象Java 程序重启后之前取号的客户全部不在列表中取号序号也从 1 重新开始。原因等待队列只放在内存Queue里数据库的 t_ticket 虽然有记录但启动时没有把它们重新加载进来。解决启动时执行查询SELECT * FROM t_ticket WHERE status 0 ORDER BY create_time把未完成号码按时间顺序重新放回内存队列同时从 t_biz_type.current_no 恢复取号自增位置。这一步在项目报告的“系统初始化”小节里写清楚能明显提高答辩印象分。5.4 Swing 界面点击“叫号”后窗口无响应现象点击按钮后整个界面卡住几十秒后才恢复甚至直接显示未响应。原因在事件分发线程里执行了 JDBC 查询数据库操作阻塞了界面刷新。数据库查询本身不快时这个卡顿会非常明显。解决把耗时操作放到 SwingWorker 里后台执行数据库更新完成后在 done 方法里更新界面。如果项目里已经用了 JavaFX则改用 Task原理相同。记住一条经验任何可能访问网络或磁盘的代码都不要直接在按钮监听器里写。5.5 中文乱码数据库里显示“???”现象插入“个人现金业务”后数据库里显示“???”或者 Java 界面显示乱码。原因建库时用了默认字符集 latin1或者 JDBC 连接串缺了 characterEncoding导致客户端和数据库之间字符集不一致。解决建库时指定 utf8mb4连接串加useUnicodetruecharacterEncodingutf8mb4。MySQL 8 还需要加serverTimezoneAsia/Shanghai避免时区报错。字符集问题看起来是小问题但实际影响整个系统的可用性建议在项目一开始就统一。6. 让系统落地更稳的一步模拟并发取号验证与日志检查项目交付前我最常做的一件事是模拟并发取号而不是手动点界面。手动点击永远不会发现并发问题只有同时发起几十个请求才能验证取号模块是否真的靠谱。验证方法很简单写一个只调用取号逻辑的多线程程序用线程池模拟 20 个客户同时取号。ExecutorService pool Executors.newFixedThreadPool(20); for (int i 0; i 200; i) { pool.execute(() - { String no ticketService.nextNumber(1L); System.out.println(no); }); } pool.shutdown();运行后去数据库检查 t_ticket 是否有重复 ticket_no如果一条 SQL 就能查出重复说明取号逻辑还有问题。这个方法比任何代码评审都直观。我还会同时把日志级别调到 DEBUG打印每个号码生成前后的 current_no方便定位是并发问题还是数据库连接问题。这套“先并发验证再查状态流转”的习惯帮我在答辩时避开了“现场突然号码重复”的致命翻车。这类系统做到最后你会发现真正的难点不是界面而是对状态的敬畏。我第一次做排号系统时觉得一个队列加几张表就能交差结果模拟并发时看到重复号码的一瞬间才意识到课设里最值钱的部分正是那些被忽略的事务边界和并发控制。项目报告与答辩 PPT 只是表达工具源代码和数据库才是底气。把这些细节理顺无论是答辩还是后续照着做类似的生产系统你都会比大多数人稳得多。希望帮到你。本文还有配套的精品资源点击获取
返回列表