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

文章详情

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

Java银行排号系统设计与实现:从数据库到并发控制全解析

Java银行排号系统设计与实现:从数据库到并发控制全解析 简介基于Java实现的银行排号系统是一套完整的设计与开发实战案例面向计算机专业学生、Java学习者及需要课程设计与毕业设计的开发者可完整还原银行排队取号服务的需求分析、系统设计、编码实现与答辩展示全过程。压缩包约1.69MB包含项目报告、答辩PPT、源代码和数据库四大部分其中源码以Java为主配合SQL脚本与界面构建文件报告与PPT分别记录实施细节和关键技术便于对照学习与二次开发。系统采用MVC设计模式分离业务逻辑、数据处理与用户界面使用Swing/JavaFX搭建前端后台通过Servlet或Spring Boot处理请求数据库设计遵循关系范式并存储用户信息、预约记录和队列状态。项目报告中包含需求分析、系统架构、测试结果及遇到的问题与解决方案答辩PPT则对应项目背景、主要功能、性能评估及未来展望两者与源码结合可完整复盘项目流程。此外还覆盖用户认证、权限控制、异常处理与单元测试等工程化细节并体现窗口分配、排队状态更新等核心业务逻辑目前已有152人学习浏览适合希望通过完整项目掌握Java Web开发、数据库建模与系统设计能力的人群。1. 银行排号系统解决什么问题一个等待队列背后的 Java 工程你走进银行大厅见到的第一台机器大概率是一台叫号机。客户按业务类型取号坐到等候区抬头看大屏听到柜员叫号去窗口办理完成时屏幕同步更新。这套流程背后就是银行排号系统在运转。基于 Java 的银行排号系统设计与实现是毕业设计里一个非常经典的题目它不像电商项目那样堆功能也不像管理系统那样只写增删改查它的核心是业务逻辑和状态管理——取号、排队、叫号、过号、转号每一步都涉及数据库的写操作和状态约束还要应付多窗口并发的场景。做完这个题目你会把 Java 服务端开发中几个真正重要的基础点串起来怎么设计一张能支撑业务流转的数据表、怎么在处理并发取号时避免号码重复、怎么让状态之间不能乱跳。对正在做课程设计或准备毕业答辩的人来说这套方案比写一个“XX 管理系统”能学到更多东西也更有演示效果。接下来的内容就按我从需求到数据库、从业务逻辑到接口实现、再到调试和答辩的顺序把整个系统怎么落地讲清楚看完你就能照着这套思路把代码跑起来。2. 把需求拆成五张核心表数据库设计与 MyBatis 映射细节2.1 先分清角色和动作再谈建表银行排号系统的参与者不多但每个角色的动作边界必须清晰。管理员负责配置服务类型、维护窗口信息、管理柜员账号柜员是核心操作者她要做的是空闲时点击“叫号”、客户入座后点击“开始办理”、办理完毕点击“完成”遇到客户没在听号时点击“过号”客户不做任何系统操作只通过取号机或者柜员代取拿到一个号大屏显示器也不是人它是一个只读客户端持续刷新当前叫号状态。基于这些动作我把数据模型分成两块基础资料和动态流水。基础资料包含系统用户表、服务类型表、窗口表动态流水是排队记录表和叫号日志表。排队记录表是整个系统的核心它记录每一张号当前到了哪个状态、被哪个窗口叫走、什么时候叫的、什么时候办完的。叫号日志表则是操作留痕答辩时被问到“怎么证明系统有据可查”这张表就是答案。2.2 建表 SQL字段边界和索引配置一次到位我没有把窗口和柜员合并成一张表因为一个窗口在一天内可能被多个柜员轮班使用合并会让数据变得不可追溯。服务类型和窗口之间也不是一对一一个全能窗口可以受理多种业务所以我在窗口表里只放一个主服务类型字段实际过滤时允许为空表示全能窗口。以下是我整理的五张核心表建表脚本你可以直接复制到 MySQL 5.7 以上版本执行CREATE TABLE sys_user ( id int(11) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password varchar(64) NOT NULL COMMENT BCrypt密文, role tinyint(1) NOT NULL DEFAULT 1 COMMENT 0-管理员 1-柜员, window_id int(11) DEFAULT NULL COMMENT 柜员绑定的窗口ID, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT系统用户表; CREATE TABLE service_type ( id int(11) NOT NULL AUTO_INCREMENT, type_name varchar(50) NOT NULL COMMENT 个人业务/对公业务/理财等, queue_prefix varchar(5) NOT NULL COMMENT 号牌前缀A/B/C, status tinyint(1) NOT NULL DEFAULT 1, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT服务类型表; CREATE TABLE window_info ( id int(11) NOT NULL AUTO_INCREMENT, window_no varchar(10) NOT NULL COMMENT 如 1号窗口, service_type_id int(11) DEFAULT NULL COMMENT NULL表示全能窗口, status tinyint(1) NOT NULL DEFAULT 1 COMMENT 1-空闲 0-繁忙 2-暂停, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT窗口表; CREATE TABLE queue_record ( id int(11) NOT NULL AUTO_INCREMENT, queue_no varchar(20) NOT NULL COMMENT 号牌如A001, service_type_id int(11) NOT NULL, status tinyint(1) NOT NULL DEFAULT 0 COMMENT 0-等待 1-叫号 2-办理中 3-已完成 4-过号 5-转号, window_id int(11) DEFAULT NULL COMMENT 叫号窗口, create_time datetime DEFAULT CURRENT_TIMESTAMP, call_time datetime DEFAULT NULL, finish_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_queue_no (queue_no), KEY idx_service_type_status (service_type_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT排队记录表; CREATE TABLE call_log ( id int(11) NOT NULL AUTO_INCREMENT, queue_record_id int(11) NOT NULL, queue_no varchar(20) NOT NULL, action varchar(10) NOT NULL COMMENT call/transfer/expire/finish, operator_id int(11) NOT NULL, window_id int(11) NOT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_queue_record_id (queue_record_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT叫号操作日志表;这里有三个容易忽略的点。第一queue_no加了唯一约束这是并发取号时防重复的最后一道兜底防线即使代码层面出了 bug数据库也会把重复的号拒掉。第二queue_record上建了(service_type_id, status)联合索引因为“按业务类型找下一个等待号”是系统最频繁的查询语句没有这个索引当表里有几万条历史记录时叫号接口会明显变慢。第三所有时间字段我统一用datetime而不是timestamp避免 2038 年问题和时区转换的隐性坑。2.3 MyBatis 映射细节ResultMap 和驼峰命名的边界五张表定义好了接下来是 MyBatis 映射这里有个常见的翻车点。如果直接在application.yml里配置map-underscore-to-camel-case: true那么queue_no可以自动映射到queueNo但call_time和finish_time这类字段也能映射成功。问题出在当你的实体类属性名和表字段不是简单对应时比如operatorId在call_log表里是operator_id这个没问题但如果你打算做关联查询把柜员姓名也查出来放到实体的operatorName字段里驼峰映射就帮不上忙了。我再补一张 ResultMapresultMap idQueueRecordMap typecom.bank.queue.entity.QueueRecord id propertyid columnid/ result propertyqueueNo columnqueue_no/ result propertyserviceTypeId columnservice_type_id/ result propertystatus columnstatus/ result propertywindowId columnwindow_id/ result propertycreateTime columncreate_time/ result propertycallTime columncall_time/ result propertyfinishTime columnfinish_time/ /resultMap注意resultMap里必须声明所有列哪怕名字能自动对应你漏掉的字段不会自动补上。报名是在 mapper XML 里编写带条件的update语句不要用 MyBatis 的updateByPrimaryKeySelective去更新整行因为它会把没传入的字段置空。银行排号系统里的状态变更一定要用“带旧状态条件”的更新语句这在下一章会重点展开。3. 取号、叫号与过号的状态机核心业务逻辑怎么落地3.1 取号动作背后的并发控制不能用 MAX1 硬闯取号是系统的起点客户点击取号后端要做的是在queue_record表里插入一张新号牌。但“插入前先查当前最大号”这个动作在高并发下会出事。设想业务高峰期 20 个人同时取号两个请求都查到了当前最大号 A010都加 1 变成 A011数据库的唯一约束会发现其中一条插入失败客户看到的报错是“取号失败”这在真实场景里是无法接受的。常见做法是引入一张计数器表把当天的叫号序列单独存储每次取号事务里执行UPDATE ... SET current_no current_no 1拿返回值当序号。数据库行锁会保证同一时刻只有一个人能拿到序列号不需要 Java 层做额外同步。我给出的核心实现如下Override Transactional public QueueRecord takeNumber(Integer serviceTypeId) { String today LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); QueueCounter counter counterMapper.selectForUpdate(serviceTypeId, today); if (counter null) { counter counterMapper.initCounter(serviceTypeId, today); counter.setCurrentNo(1); } else { counter.setCurrentNo(counter.getCurrentNo() 1); counterMapper.updateCurrentNo(counter.getId(), counter.getCurrentNo()); } ServiceType type serviceTypeMapper.selectById(serviceTypeId); String queueNo type.getQueuePrefix() String.format(%03d, counter.getCurrentNo()); QueueRecord record new QueueRecord(); record.setQueueNo(queueNo); record.setServiceTypeId(serviceTypeId); record.setStatus(0); queueRecordMapper.insert(record); return record; }这里selectForUpdate对应的 SQL 是SELECT * FROM queue_counter WHERE service_type_id ? AND count_date ? FOR UPDATEFOR UPDATE是 InnoDB 的行级排他锁事务不提交其他线程的同条件查询会阻塞等待。updateCurrentNo则是把自增后的数字写回去。如果你把这段代码拿去跑记得先建一张queue_counter(service_type_id, count_date, current_no)表并在(service_type_id, count_date)上做唯一索引否则初始化时会出现重复行。这个方案的好处是号牌序号按“天 服务类型”独立递增过了凌晨自动重置不需要手动清表。3.2 叫号流程从等待队列里抢到那一条记录柜员点击“叫号”系统需要找到当前窗口可以处理的最早一位等待客户。这里最直接的 SQL 是SELECT * FROM queue_record WHERE service_type_id ? AND status 0 ORDER BY id ASC LIMIT 1但这段查询在多个窗口并发叫号时会出问题。设想 A 窗口和 B 窗口同时执行这条语句两个窗口都拿到同一张号牌 A001都去更新它的状态结果就是同一个客户被两个窗口同时叫走。解决方式是给查询加排他锁把“查出来”和“标记已叫号”放进同一个事务。调用的是下面这段逻辑Override Transactional public QueueRecord callNext(Integer windowId) { WindowInfo window windowMapper.selectById(windowId); QueueRecord record queueRecordMapper.selectFirstWaitingForUpdate( window.getServiceTypeId()); if (record null) { throw new BusinessException(当前队列暂无等待客户); } record.setStatus(1); record.setWindowId(windowId); record.setCallTime(LocalDateTime.now()); queueRecordMapper.updateStatus(record); callLogMapper.insertLog(record.getId(), record.getQueueNo(), call, SecurityUtils.getUserId(), windowId); return record; }对应 mapper 里的 SQL 是SELECT * FROM queue_record WHERE service_type_id #{serviceTypeId} AND status 0 ORDER BY id ASC LIMIT 1 FOR UPDATE。FOR UPDATE配合事务会让第一个执行这条语句的窗口锁住那行第二个窗口的查询阻塞等第一个窗口的事务提交后它再执行时会发现这条记录状态已经变成 1 了这里有个坑MySQL 默认隔离级别是可重复读REPEATABLE READ第二个事务可能读不到提交后的最新状态依然拿到旧快照。我的做法是把查询和更新组装成一条原子 SQLUPDATE queue_record SET status 1, window_id ?, call_time NOW() WHERE id (SELECT id FROM (SELECT id FROM queue_record WHERE service_type_id ? AND status 0 ORDER BY id ASC LIMIT 1) t) AND status 0再取影响行数。这个写法有点绕但逻辑严密内层子查询先选目标 id外层更新带status 0条件只有拿到锁并且状态正确的那个窗口才能更新成功影响行数为 1其余窗口拿到 0 就知道没抢到。3.3 过号和转号状态约束比业务动作更关键过号不能由柜员随意操作。客户没有听到叫号柜员要连续提醒三次才允许标记过期这个“三次”通常在前端控制但后端也要防住手动调接口绕过检查。更关键的是过号操作必须保证只有状态为“叫号中”的记录才能变成“已过号”否则两个操作同时请求一个标记办理中一个标记过号状态就乱了。转号的逻辑类似柜员把当前排队中的某张号从一个业务队列挪到另一个队列或者让全能窗口接手。本质上就是修改这条记录的service_type_id和window_id但必须校验目标服务类型是否在窗口的可受理范围内同时在日志表里留一条transfer记录。如果答辩时被问到业务边界你可以补充说明转号只允许发生在等待状态正在办理中的号码不能转号那两个操作我统一封装了状态比较函数。下面是更新方法public void markStatus(QueueRecord record, int expectStatus, int targetStatus) { int rows queueRecordMapper.updateStatusWithExpect( record.getId(), expectStatus, targetStatus, record.getWindowId(), LocalDateTime.now()); if (rows 0) { throw new BusinessException(操作失败当前号码状态已改变请刷新重试); } }updateStatusWithExpect的 SQL 是UPDATE queue_record SET status #{targetStatus}, window_id #{windowId}, call_time #{callTime} WHERE id #{id} AND status #{expectStatus}。你想让系统严谨可靠所有状态变更都走这个统一模式这比在 Java 里先查一遍再更新要稳妥得多因为 Java 代码从查询到更新之间永远有缝隙。4. 从 Service 到 Controller 到前端大屏Spring Boot 全链路实现4.1 Service 层划分别把所有逻辑塞进一个类前两章的数据表和核心方法定下来后Service 层怎么组织决定了你的代码好不好答辩。我一般分成三个服务QueueService管取号、叫号、过号、转号这些核心流程WindowService管窗口状态变更和窗口查询ReportService管排队统计比如今日取号总数、平均等待时长、各窗口业务量。答辩时老师最容易追问“你做了哪些统计”只做个排队列表是不够的。举个例子统计模块里一个简单的查询今日各时段取号峰值。用一条 SQL 就能实现不需要额外存数据SELECT HOUR(create_time) AS hour, COUNT(*) AS total FROM queue_record WHERE create_time CURDATE() GROUP BY HOUR(create_time) ORDER BY total DESC;这个结果可以直接展示在管理端页面作为答辩时“数据可视化”的素材。不要小看这些统计接口它们能让你的项目从“能用”变成“有深度”。4.2 Controller 接口契约统一 Result 包装和时间格式Controller 层我建议所有接口都返回统一的Result对象结构包括code、message、data三个字段。成功时code 200业务异常时code 500参数错误时code 400。前端拿到code先判断再渲染数据不用每次在页面里解析奇怪的错误结构。时间字段格式化也是必须提前处理的。LocalDateTime默认序列化成数组或 ISO 格式前端显示会非常难看。我在项目里配置了 Jackson 全局日期格式统一输出yyyy-MM-dd HH:mm:ssConfiguration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer customizer() { return builder - { builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer( DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); builder.deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer( DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss))); }; } }Controller 接口本身很简单下面这段是核心接口的最小集合RestController RequestMapping(/api/queue) public class QueueController { Resource private QueueService queueService; PostMapping(/take) public Result takeNumber(RequestBody TakeRequest req) { return Result.success(queueService.takeNumber(req.getServiceTypeId())); } PostMapping(/call) public Result callNext(RequestBody WindowRequest req) { // 实际开发中 windowId 从登录用户的 Session 里取不用客户端传 return Result.success(queueService.callNext(req.getWindowId())); } PostMapping(/expire) public Result expire(RequestBody ExpireRequest req) { queueService.markExpired(req.getQueueRecordId(), req.getWindowId()); return Result.success(null); } GetMapping(/current) public Result current(Integer windowId) { return Result.success(queueService.getCurrentCalling(windowId)); } GetMapping(/today/stats) public Result todayStats() { return Result.success(reportService.todayStats()); } }4.3 大屏页面用轮询实现实时显示不用 WebSocket 也够大屏显示端不需要太复杂的技术前端轮询完全够用。银行排队场景的大屏刷新频率是秒级人眼能接受的延迟在 3 秒以内与其引入 WebSocket 或者 SSE 增加答辩复杂度不如用一个定时器去拉当前叫号接口。jQuery 就能实现。我在项目里的大屏页面是这么写的function refreshCurrent() { $.get(/api/queue/current, { windowId: 1 }, function (res) { if (res.code 200 res.data) { $(#windowNo).text(res.data.windowNo); $(#queueNo).text(res.data.queueNo); $(#serviceType).text(res.data.serviceTypeName); } }); } $(function () { refreshCurrent(); setInterval(refreshCurrent, 3000); });设置 3000 毫秒一次轮询接口返回的数据量很小不会给后端造成压力。不过有两点要注意页面窗口切到后台后浏览器的定时器会被降频大屏通常需要把页面全屏常驻或者让运维把电脑电源策略改成永不睡眠另外如果 AJAX 请求本身超时了setInterval不会等它完成就会发起下一次请求可能堆积连接所以建议用setTimeout递归替代setInterval。前端展示页面的数据从哪里来我给大屏单独定义了一个视图对象QueueDisplayVO把窗口号、当前叫号、待办人数、最近五个已叫号放在一起一个接口返回避免大屏端一次次拼接口。5. 并发重复号、时区偏晚、大屏卡顿四条实测排查记录5.1 并发取号同时生成两张 A001有一次我在本地用两个浏览器窗口同时点“取号”数据库里出现了两张A001这标志着我第一版的号码发生器翻车了。当时用的是SELECT MAX(queue_no)再1两个完全独立的 HTTP 请求几乎同时到达查询阶段都看到了当前最大值 A001于是各自生成了 A002 和 A002而事务提交后唯一约束直接报错。原因就是查询和插入之间没有原子性。解决方式我在 3.1 节已经给出用queue_counter表的行锁自增加上uk_queue_no唯一约束兜底。验证步骤如下清空数据用 JMeter 模拟 50 个并发线程同时调用取号接口接口返回后检查号码列表是否连续且无重复。连续不重复就算通过。5.2 记录的时间和本地时间差了 8 小时数据库里插入的create_time比北京时间慢 8 个小时这是一个典型的时区配置问题。MySQL 驱动在连接字符串里如果没指定serverTimezone会依赖 JVM 默认时区和数据库会话时区的一套组合推断只要一端是 UTC 一端是东八区就会错位。解决方式是在application.yml的 JDBC URL 里显式加上serverTimezoneAsia/Shanghai并且把 MySQL 服务端的time_zone也设置为08:00。如果项目跑在 Linux 服务器的 docker 里还要检查容器本身的TZ环境变量。总之时区问题排查时按“连接参数、数据库时区、系统时区”三层逐层检查基本能定位。5.3 大屏一直显示旧号码点击叫号后不刷新大屏页面上叫号后号码没变化但用手工刷新页面又变新号了这是前端定时器被浏览器节流了。浏览器为了省电对后台标签页的setInterval做了自动降频严重时几秒一次变成几十秒一次。解决方式是要求大屏页面的浏览器窗口必须置顶且处于焦点状态同时把后端接口响应时间压缩到 100 毫秒以内前端用document.visibilityState监听页面是否可见不可见时主动暂停轮询可见时立刻恢复。另一个隐藏坑是后端接口偶发 500前端没做失败处理再次刷新又好了这种“玄学”问题往往来自接口返回异常时页面把旧数据当新数据渲染了。5.4 导入数据库脚本时报 utf8mb4 相关错误用 Navicat 导入建表脚本失败提示字符集不兼容这多半是 SQL 文件本身带了DEFAULT CHARSETutf8mb4但数据库连接或表格本身是 utf8 导致的。新版 MySQL 默认是 utf8mb4老库可能是错误指定的 utf8 或 latin1。做法是导入前先执行ALTER DATABASE bank_queue DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci然后重新导入脚本导入后查一下实际表结构确认字符集生效。需要注意的是如果你原来的数据已经有中文字段字符集改完后要重新检查是否有乱码最好在建库的 SQL 文件里就固定死utf8mb4不要依赖环境默认值。6. 演示与答辩把系统效果讲成加分项6.1 演示脚本十分钟跑通核心业务闭环答辩现场最怕的是演示到一半数据出错或者页面卡死。我的做法是准备一个固定顺序的演示脚本不临场发挥。开场先说系统背景银行网点排队体验差需要一套能分流客户、叫号有序、数据可追溯的排号系统。然后用三个角色登录管理员、柜员、大屏访客页面。第一步管理员新增一个服务类型“对公业务”分配前缀 B设置对应窗口第二步用大屏的取号入口模拟三个客户分别取个人业务 A001、A002、A003第三步柜员窗口点击叫号大屏显示 A001柜员点击办理中片刻后点击完成第四步演示过号叫到 A002 时故意不响应柜员连续提醒三次后标记过号大屏显示跳号第五步回到管理页面展示今日取号统计说明系统具备数据沉淀能力。整个演示控制在六到八分钟剩两分钟讲解设计亮点。演示步骤角色预期结果新增服务类型管理员下拉框出现“对公业务”前缀 B连取三个号客户生成 A001、A002、A003叫号并办理柜员大屏显示 A001状态流转过号操作柜员大屏显示跳过 A002统计报表管理员今日取号数、平均等待时长6.2 测试用例和性能预演让数据替你说话答辩老师喜欢问“你测过并发吗”。在答辩前一天用 JMeter 或者不用工具直接写一段 Java 多线程代码模拟 100 个线程同时取号容量规划不要做太大能证明你的方案没有明显的并发缺陷就够了。把测试结果截图放在 PPT 里例如“100 个并发请求全部成功号码无重复平均响应耗时 60ms”。这个数据比任何口头描述都有说服力。数据量上也要多造一些提前往queue_record表插入几千条历史记录不然你写“大数据量下查询性能”显得空泛。造数可以用存储过程循环插入日期分布在最近几天这样统计接口有图可展。6.3 答辩时常遇到的三个问题准备两种深度的回答问题一“为什么不用 Redis 做排队号”回答思路分两步先承认 Redis 的原子自增性能更好再说本项目选择和数据库事务统一管理的原因——排号系统不是高吞吐场景数据库方案保证数据一致性和审计能力避免引入额外中间件带来的运维成本。这个回答体现了你做技术选型时会权衡。问题二“过号的人能不能重新排队”标准答案是能但要走重新取号流程旧号码保持过号状态日志里会记录完整操作链。把这块逻辑说清楚老师就知道你理解状态机。问题三“窗口和大屏的数据怎么实时同步”本项目的答案是前端轮询因为数据量极小且实时性要求是秒级轮询协议简单、可靠性高。如果你有余力可以提一句“如需毫秒级推送可以升级为 SSE”不要主动给自己加彩头。我在多次实践里养成的一个习惯是答辩演示前一定先在纯浏览器无痕模式下完整跑一遍取号、叫号、完成的流程清掉缓存再跑一遍确保不是靠本地缓存“表演”出来的结果。这一点希望帮到你细节做踏实了评委的问题就都是展示你思考的机会。本文还有配套的精品资源点击获取
返回列表