简介:这份资源面向计算机专业学生与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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 排队号表:核心表,唯一索引防重号 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 窗口表 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 叫号记录表:用于追溯和统计 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完成' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明: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 以内。
最后说个习惯:这套系统我每次部署前都会先跑一遍「取号 → 叫号 → 过号 → 重呼 → 完成」的全链路手工测试,再跑压测。状态机的东西,自动化测试覆盖不到的分支,手工点一遍比什么都靠谱。希望帮到你。
本文还有配套的精品资源,点击获取