ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Java实现银行排号系统:并发取号与叫号调度实战

基于Java实现银行排号系统:并发取号与叫号调度实战 简介这份资源面向计算机专业学生与Java初学者提供一套基于C/S架构的银行排号系统完整实现方案用于解决服务大厅排队效率低、顾客等待无序的问题。包内整合源码、LW论文文档、PPT、数据库脚本与讲解视频覆盖Socket网络编程、Java多线程、TCP/IP通信等核心知识点适合作为课程设计、毕业设计或Java网络编程练手项目。资源包为rar格式整体约69.98MB文件类型以源码、文档、演示文稿与数据库文件为主分别对应系统实现、论文撰写、答辩展示与数据存储等用途。目前已有151人学习下载。读者可借助论文中的业务流程图、用例图、功能结构图、数据流程图与E-R图理解系统设计思路结合源码与讲解视频掌握客户端与服务器实时通信、取号叫号等业务逻辑并参考数据库设计完成环境搭建与二次开发快速形成可运行、可讲解的完整项目成果。1. 银行排号系统到底在解决什么问题从柜台排队到并发取号的真实场景很多人第一次接触「基于Java实现银行排号系统」是在课程设计或毕业设计里觉得不就是取个号、叫个号吗真到银行网点蹲半天就会发现核心难点根本不是界面而是并发取号不重号、多窗口叫号不冲突、VIP插队策略可控、断电重启数据不丢。一个能跑通的排号系统本质是一个带业务规则的队列调度服务客户到店取号进入等待队列柜员点叫号从队列取号大屏和语音同步播报业务办完释放窗口。它适合两类人一是想用 Java 把「多线程 数据库 前后端」串起来练手的开发者二是要给小型网点做轻量排队方案的实施人员。下面我按能复现的路子把选型、建表、并发取号、叫号调度、避坑和进阶验证一层层拆开讲。2. 技术选型与数据库设计为什么用 Spring Boot MySQL 而不是纯内存队列2.1 选型理由内存队列跑得欢断电全白干排号系统最容易被低估的是数据持久化。用ConcurrentLinkedQueue或BlockingQueue在单机内存里做队列取号叫号确实快代码也短但网点机器一断电、服务一重启当天所有号码全丢客户手里的纸质号和大屏对不上这是血泪经验。所以常见做法是队列状态落库内存只做缓存和锁。技术栈我一般这样定层次选型理由后端框架Spring Boot起步快内置 Tomcat适合课程设计和轻量部署持久层MyBatis-Plus单表 CRUD 少写 SQL分页和条件构造方便数据库MySQL 8事务和行锁成熟网点级数据量完全够用并发控制数据库唯一索引 乐观锁比纯 Java 锁更可靠重启不丢状态前端Vue 或 Thymeleaf取号机用页面即可大屏单独轮询接口这里要强调一点不要用自增主键当排队号。自增 ID 会因删除、回滚产生空洞客户看到 001 直接跳到 005 会投诉。排队号应该是业务号按「业务类型 日期 当日序号」生成。2.2 建表四张表撑起整个排号系统数据库设计是这套系统的地基表结构没设计好后面并发问题会成倍放大。核心四张表-- 业务类型表个人业务、对公业务、VIP业务 CREATE TABLE biz_type ( id BIGINT PRIMARY KEY AUTO_INCREMENT, type_code VARCHAR(16) NOT NULL COMMENT 业务编码, type_name VARCHAR(32) NOT NULL COMMENT 业务名称, prefix CHAR(1) NOT NULL COMMENT 号码前缀如 A/B/V, priority INT NOT NULL DEFAULT 0 COMMENT 优先级越大越优先, UNIQUE KEY uk_type_code (type_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 排队号表核心表唯一索引防重号 CREATE TABLE queue_ticket ( id BIGINT PRIMARY KEY AUTO_INCREMENT, ticket_no VARCHAR(16) NOT NULL COMMENT 完整号码如 A023, biz_type_id BIGINT NOT NULL, seq_no INT NOT NULL COMMENT 当日序号, status TINYINT NOT NULL DEFAULT 0 COMMENT 0等待 1叫号中 2已完成 3过号, window_id BIGINT NULL COMMENT 受理窗口, create_time DATETIME NOT NULL, call_time DATETIME NULL, finish_time DATETIME NULL, UNIQUE KEY uk_ticket_no (ticket_no), UNIQUE KEY uk_biz_seq (biz_type_id, seq_no), KEY idx_status_priority (status, biz_type_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 窗口表 CREATE TABLE service_window ( id BIGINT PRIMARY KEY AUTO_INCREMENT, window_no VARCHAR(8) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0空闲 1服务中 2暂停, UNIQUE KEY uk_window_no (window_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 叫号记录表用于追溯和统计 CREATE TABLE call_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, ticket_id BIGINT NOT NULL, window_id BIGINT NOT NULL, call_time DATETIME NOT NULL, result TINYINT NOT NULL DEFAULT 0 COMMENT 0已叫 1过号 2完成 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明queue_ticket上的uk_biz_seq唯一索引是关键它保证同一业务类型同一天不会出现两个相同序号即使并发请求同时进来数据库也会拦下重复插入。status字段配合idx_status_priority索引让叫号时能快速捞出「等待中且优先级最高」的号。参数上priority建议个人业务设 0、对公设 1、VIP 设 10具体数值按网点规则调不要写死在代码里。提示MySQL 建表时字符集统一用 utf8mb4避免号码前缀里出现特殊字符时乱码。3. 并发取号与叫号调度把重号率压到零的三个关键动作3.1 取号接口唯一索引兜底 重试别只靠 synchronized取号的核心矛盾是多个取号机同时请求怎么保证序号不重复。有人上来就加synchronized单机确实能挡住但一旦部署两个实例就失效。我一般用「数据库唯一索引 有限重试」的组合。Service public class TicketService { Autowired private QueueTicketMapper ticketMapper; Autowired private BizTypeMapper bizTypeMapper; private static final int MAX_RETRY 3; /** * 取号生成当日序号并落库 * param typeCode 业务编码 * return 完整号码如 A023 */ public String takeTicket(String typeCode) { BizType biz bizTypeMapper.selectByCode(typeCode); if (biz null) { throw new BizException(业务类型不存在); } for (int i 0; i MAX_RETRY; i) { // 查当日该业务最大序号1 作为新序号 Integer maxSeq ticketMapper.selectMaxSeqToday(biz.getId()); int nextSeq (maxSeq null ? 0 : maxSeq) 1; String ticketNo biz.getPrefix() String.format(%03d, nextSeq); QueueTicket ticket new QueueTicket(); ticket.setTicketNo(ticketNo); ticket.setBizTypeId(biz.getId()); ticket.setSeqNo(nextSeq); ticket.setStatus(0); ticket.setCreateTime(new Date()); try { ticketMapper.insert(ticket); return ticketNo; } catch (DuplicateKeyException e) { // 唯一索引冲突说明并发下被别人抢先重试 continue; } } throw new BizException(取号繁忙请重试); } }逻辑说明先查最大序号再插入两步之间有空窗并发下必然有人撞车。撞车时数据库抛DuplicateKeyException捕获后重试最多三次。参数MAX_RETRY设 3 是经验值网点取号并发通常不高三次足够如果做大型医院排号可以提到 5 次并加退避。注意selectMaxSeqToday要按biz_type_id和当天日期过滤SQL 里用create_time CURDATE()即可。3.2 叫号接口按优先级取号 窗口状态原子更新叫号要解决两个问题一是按 VIP 优先级取号二是防止两个窗口同时叫到同一个号。做法是「先更新窗口状态再取号最后回写号码状态」用数据库行锁保证原子性。Transactional(rollbackFor Exception.class) public CallResult callNext(Long windowId) { // 1. 锁定窗口防止两个柜员同时操作同一窗口 ServiceWindow window windowMapper.selectForUpdate(windowId); if (window null || window.getStatus() 2) { throw new BizException(窗口不可用); } // 2. 取等待队列中优先级最高的号FOR UPDATE 锁行 QueueTicket next ticketMapper.selectNextWaitingForUpdate(); if (next null) { return CallResult.empty(); } // 3. 更新号码状态为叫号中 next.setStatus(1); next.setWindowId(windowId); next.setCallTime(new Date()); ticketMapper.updateById(next); // 4. 更新窗口为服务中 window.setStatus(1); windowMapper.updateById(window); // 5. 写叫号记录 callRecordMapper.insert(buildRecord(next, windowId)); return CallResult.of(next.getTicketNo(), window.getWindowNo()); }逻辑说明selectForUpdate对应 SQL 的SELECT ... FOR UPDATE会给选中的行加排他锁另一个窗口的事务必须等锁释放才能读到同一行从而避免重复叫号。取号 SQL 建议写成SELECT * FROM queue_ticket WHERE status 0 ORDER BY (SELECT priority FROM biz_type WHERE id biz_type_id) DESC, seq_no ASC LIMIT 1 FOR UPDATE;参数上ORDER BY先按业务优先级降序再按序号升序保证 VIP 优先但同优先级先到先服务。注意FOR UPDATE必须在事务里用否则锁立即释放等于没加。3.3 过号与重呼状态机要闭环客户被叫到但没来柜员点「过号」号码状态从 1 改成 3。过号后是否允许重呼取决于网点规则。常见做法是过号后插入队尾或直接作废。我一般做成可配置queue_ticket加一个retry_count字段过号后retry_count 1小于 2 次则重新置为等待并排到当前队列末尾超过则作废。这样状态流转是0 等待 → 1 叫号中 → 2 完成 / 3 过号 →可选0 等待。状态机不闭环统计报表就会对不上账。4. 避坑与排查排号系统上线后最容易翻车的五个点4.1 号码重复现象是客户拿到两张一样的号现象两个取号机几乎同时出票号码相同。原因只用了「查最大值 1」而没有唯一索引兜底或者唯一索引建在了自增 ID 上而不是业务号上。解决queue_ticket必须建uk_biz_seq唯一索引代码里捕获DuplicateKeyException重试两者缺一不可。4.2 叫号重复两个窗口叫到同一个号现象大屏同时显示两个窗口叫同一个号码。原因叫号查询没用FOR UPDATE或者事务隔离级别是读已提交但没加锁。解决叫号方法加Transactional查询 SQL 带FOR UPDATE窗口状态更新和号码状态更新放在同一事务里。4.3 序号跨天不重置第二天从 A100 开始现象昨天最后一个号是 A099今天第一个号变成 A100。原因selectMaxSeqToday没按日期过滤或者过滤条件用了create_time 昨天这种模糊写法。解决SQL 里明确DATE(create_time) CURDATE()并且每天凌晨可以用定时任务把未完成的号批量置为过号避免跨天残留。4.4 大屏刷新延迟客户以为没叫到自己现象柜员已经叫号大屏过了十几秒才更新。原因大屏用长轮询但间隔设太长或者接口没加缓存控制。解决大屏轮询间隔设 2 到 3 秒接口返回加Cache-Control: no-store有条件的用 WebSocket 推送。注意 WebSocket 断线重连要做否则网络抖动后大屏就成黑匣子了。4.5 数据库连接耗尽高峰期取号转圈现象上午十点高峰期取号接口响应超过 5 秒。原因每次取号都开事务且重试次数过多连接池被占满。解决取号本身不需要长事务把「查最大值」和「插入」拆开插入单独短事务连接池maximumPoolSize按取号机数量乘以 2 配置别照搬默认 10。5. 进阶验证用压测和状态核对确认系统真的扛得住5.1 并发取号压测看重号率和响应时间系统能不能用压测说了算。用 JMeter 或wrk对取号接口打 200 并发跑 1 分钟然后核对数据库-- 检查是否有重复号码 SELECT ticket_no, COUNT(*) AS cnt FROM queue_ticket WHERE DATE(create_time) CURDATE() GROUP BY ticket_no HAVING cnt 1; -- 检查序号是否连续允许因重试产生的空洞但不允许重复 SELECT biz_type_id, COUNT(*) AS total, MAX(seq_no) AS max_seq FROM queue_ticket WHERE DATE(create_time) CURDATE() GROUP BY biz_type_id;如果第一条 SQL 返回空说明唯一索引和重试机制生效。第二条里total和max_seq的差值就是重试造成的空洞网点场景下可以接受如果要求严格连续就得改成号段预分配方案但复杂度会上升。5.2 叫号一致性核对状态机不能有孤儿号跑完压测后核对状态-- 叫号中但没有窗口的号属于异常 SELECT * FROM queue_ticket WHERE status 1 AND window_id IS NULL; -- 已完成但没有完成时间的号 SELECT * FROM queue_ticket WHERE status 2 AND finish_time IS NULL;这两条查询应该都返回空。如果有数据说明事务边界没控制好或者代码里有分支漏了状态更新。我一般把这两条 SQL 做成定时巡检任务每十分钟跑一次发现异常就告警。5.3 一个具体技巧用号段预分配替代实时查最大值如果网点取号并发真的很高实时查最大值再插入会成为瓶颈。进阶做法是号段预分配内存里缓存一段号码比如一次取 50 个用AtomicInteger发号发完再向数据库申请下一段。这样数据库压力从「每次取号一次查询」降到「每 50 次取号一次查询」。代价是服务重启会浪费当前号段里未使用的号码所以要在启动时把未使用的号段标记作废避免和后续号段冲突。这个方案我在高并发取号场景用过重号率零响应时间从 80ms 降到 5ms 以内。最后说个习惯这套系统我每次部署前都会先跑一遍「取号 → 叫号 → 过号 → 重呼 → 完成」的全链路手工测试再跑压测。状态机的东西自动化测试覆盖不到的分支手工点一遍比什么都靠谱。希望帮到你。本文还有配套的精品资源点击获取
返回列表