ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java银行排号系统源码解析:并发取号与数据库设计

Java银行排号系统源码解析:并发取号与数据库设计 简介这份资源面向计算机专业学生与Java初学者提供一套基于C/S架构的银行排号系统完整实现方案可用于课程设计、毕业设计或Java网络编程练手。压缩包约69.98MB内含源码、LW论文文档、PPT演示、数据库脚本及讲解视频覆盖从需求分析到编码落地的全流程。系统采用Swing构建客户端界面借助Socket网络编程与Java多线程实现客户端与服务器端的实时通信支持取号、叫号与业务互动论文部分还包含业务流程图、系统用例图、功能结构图、数据流程图及数据库E-R图等设计资料。已有151人学习适合希望理解TCP/IP通信连接、线程调度与数据库设计的中级学习者可对照源码与视频快速掌握排号系统的整体架构与实现细节。1. 银行排号系统到底在解决什么问题从柜台排队到 Java 并发建模去银行办业务最烦的不是等而是不知道要等多久。银行排号系统要解决的核心就一件事把「谁先来、谁优先、哪个窗口空」这套调度逻辑用代码固化下来让客户拿到号就知道前面还有几个人让柜员按顺序叫号不扯皮。这个标题里的关键词是 Java、银行排号系统、源码、数据库——它本质上是一个典型的 Java 课程设计/毕业设计级项目技术栈通常落在 Spring Boot MyBatis-Plus MySQL 这一套前端可能是 JSP、Thymeleaf 或者 Vue。适合谁看正在做 Java 课程设计、想找一个能讲清楚并发和状态机的练手项目、或者需要一套带数据库和文档的完整交付物的同学。它不复杂但麻雀虽小排队调度、号码生成、窗口状态、并发取号这几个点都能踩到拿来练手比增删改查有意义得多。2. 排号系统的核心模型号码、队列与窗口状态怎么设计2.1 先想清楚三个实体和它们的关系排号系统看着简单建模错了后面全是坑。核心就三个实体号码Ticket、队列Queue、窗口Counter。号码是客户取到的凭证包含号码值、业务类型、状态等待中/已叫号/已完成/已过号、取号时间。队列是按业务类型分的等待序列普通业务一个队列VIP 或者对公业务另一个队列。窗口是服务资源有编号、当前服务的号码、状态空闲/忙碌/暂停。关系上一个窗口同一时刻只能服务一个号码一个号码只能被一个窗口叫走。这个「一对一占用」就是并发控制的重点。很多人一开始把队列直接塞进窗口里结果窗口一多就乱套。正确做法是队列独立于窗口窗口从队列里「抢」号码抢的动作要加锁或者用原子操作。业务类型决定了优先级。常见做法是 VIP 队列优先于普通队列但 VIP 不能无限插队否则普通客户永远排不到。我一般会设一个阈值比如普通队列每叫 3 个VIP 队列叫 1 个用权重轮询来平衡。这个逻辑写在调度层不要散落在各个窗口的代码里。2.2 号码生成为什么不能用简单的自增号码生成最容易翻车的地方是并发。如果用数据库自增主键当号码多个客户同时取号号码是唯一的但业务上你希望号码是按业务类型分别递增的比如普通号 A001、A002VIP 号 V001、V002。这时候就不能靠一张表的自增了。常见做法是每个业务类型维护一个当前最大号码取号时在事务里先查再更新。但这样在并发下会锁行性能差。更好的做法是用 Redis 的 INCR或者用数据库的乐观锁版本号。课程设计级别用 synchronized 或者 ReentrantLock 在单机内保证也行但要讲清楚这是单机方案分布式下要换。下面是一个基于数据库乐观锁的号码生成示例用 MyBatis-Plus 的 update 带版本条件来实现// 号码生成服务基于乐观锁的按业务类型递增 Service public class TicketNumberService { Autowired private BizTypeMapper bizTypeMapper; /** * 生成下一个号码格式如 A001 * param bizType 业务类型编码如 A、V * return 格式化后的号码 */ public String nextNumber(String bizType) { while (true) { // 1. 查出当前业务类型的号码配置 BizTypeConfig config bizTypeMapper.selectByCode(bizType); if (config null) { throw new BizException(业务类型不存在: bizType); } int current config.getCurrentSeq(); int next current 1; // 2. 带版本号更新版本不匹配说明被别人改过重试 int updated bizTypeMapper.updateSeqWithVersion( bizType, next, config.getVersion()); if (updated 1) { // 3. 格式化前缀 三位补零 return String.format(%s%03d, bizType, next); } // 更新失败循环重试实际项目可加最大重试次数 } } }逻辑说明先读当前序号和版本号计算下一个序号然后用update ... set current_seq ?, version version 1 where biz_type ? and version ?去更新。如果返回影响行数为 1说明没人抢成功返回 0 说明期间有别人改了重试。参数上bizType是业务类型编码currentSeq是当前最大序号version是乐观锁版本。这个方案在并发不高时够用高并发下重试次数会上升可以换成 Redis INCR 或者号段模式。提示号码格式化时前缀和位数要固定否则 A1 和 A001 混在一起排序会乱。数据库里存纯数字序号展示时再格式化。2.3 队列与窗口的调度叫号逻辑怎么写叫号是排号系统的心脏。柜员点「叫号」系统要从队列里取出优先级最高的等待号码分配给这个窗口同时把号码状态改成「已叫号」窗口状态改成「忙碌」。这里有两个并发点多个窗口同时叫号不能叫到同一个号码客户取号和柜员叫号同时发生队列不能丢数据。我一般用阻塞队列LinkedBlockingQueue来存等待号码天然线程安全。但阻塞队列有个问题它不支持按优先级取。所以要么用PriorityBlockingQueue要么自己维护多个队列按权重轮询。课程设计里用PriorityBlockingQueue加一个优先级字段就够了。// 叫号服务从队列取号并绑定窗口 Service public class CallNumberService { // 按业务类型分队列实际可用 MapString, BlockingQueueTicket private final BlockingQueueTicket normalQueue new LinkedBlockingQueue(); private final BlockingQueueTicket vipQueue new LinkedBlockingQueue(); Autowired private CounterMapper counterMapper; Autowired private TicketMapper ticketMapper; /** * 窗口叫号 * param counterId 窗口ID * return 被叫的号码无号码时返回 null */ public Ticket call(Long counterId) { // 1. 先尝试 VIP 队列再尝试普通队列简化版优先级 Ticket ticket vipQueue.poll(); if (ticket null) { ticket normalQueue.poll(); } if (ticket null) { return null; // 队列空无人可叫 } // 2. 更新号码状态为已叫号 ticket.setStatus(TicketStatus.CALLED); ticket.setCounterId(counterId); ticket.setCallTime(new Date()); ticketMapper.updateById(ticket); // 3. 更新窗口状态为忙碌 Counter counter new Counter(); counter.setId(counterId); counter.setStatus(CounterStatus.BUSY); counter.setCurrentTicketId(ticket.getId()); counterMapper.updateById(counter); return ticket; } }逻辑说明poll()是非阻塞取取不到返回 null避免柜员点叫号时线程卡住。先 VIP 后普通是简化优先级真实场景要按权重轮询否则普通客户体验差。更新号码和窗口状态最好放在同一个事务里避免号码叫了但窗口没更新。参数上counterId是窗口主键TicketStatus和CounterStatus是枚举建议用 MyBatis-Plus 的EnumValue或者 TypeHandler 映射。注意队列放在内存里服务重启就丢了。生产环境要把等待队列持久化到数据库或者 Redis启动时重建。课程设计里如果只要求演示内存队列可以接受但答辩时老师大概率会问这个问题。3. 数据库表设计与 MyBatis-Plus 落地从建表到增删改查3.1 五张核心表怎么拆排号系统的数据库不用太复杂五张表基本够用业务类型表biz_type、号码表ticket、窗口表counter、柜员表staff、叫号记录表call_record。业务类型表存业务编码、名称、当前序号、版本号。号码表存号码值、业务类型、状态、取号时间、叫号时间、完成时间、窗口ID。窗口表存窗口编号、状态、当前号码ID。柜员表存柜员和窗口的绑定关系。叫号记录表存每次叫号的历史方便统计。建表时几个字段类型要注意状态字段用 tinyint 而不是 varchar省空间且索引快时间字段用 datetime别用 timestamptimestamp 有 2038 问题号码值用 varchar因为带前缀。索引上ticket表的biz_type status建联合索引叫号时按业务类型查等待号码会快很多。-- 业务类型表 CREATE TABLE biz_type ( id BIGINT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(16) NOT NULL COMMENT 业务编码如A、V, name VARCHAR(64) NOT NULL COMMENT 业务名称, current_seq INT NOT NULL DEFAULT 0 COMMENT 当前最大序号, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本, UNIQUE KEY uk_code (code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT业务类型; -- 号码表 CREATE TABLE ticket ( id BIGINT PRIMARY KEY AUTO_INCREMENT, ticket_no VARCHAR(16) NOT NULL COMMENT 号码如A001, biz_type VARCHAR(16) NOT NULL COMMENT 业务编码, status TINYINT NOT NULL DEFAULT 0 COMMENT 0等待 1已叫 2完成 3过号, counter_id BIGINT DEFAULT NULL COMMENT 窗口ID, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, call_time DATETIME DEFAULT NULL, finish_time DATETIME DEFAULT NULL, KEY idx_biz_status (biz_type, status), KEY idx_create_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT号码;逻辑说明biz_type表的current_seq和version配合乐观锁生成号码。ticket表的idx_biz_status联合索引让「查某业务类型下所有等待号码」走索引。status用 tinyint0 到 3 四个状态够用。字符集用 utf8mb4避免中文乱码。3.2 MyBatis-Plus 的实体映射和常用查询MyBatis-Plus 的好处是单表增删改查不用写 XML但排号系统里有些查询还是要手写 SQL比如统计某窗口当天叫号数量、查队列中排在第几位。实体类上用TableName指定表名主键用TableId(type IdType.AUTO)状态字段用枚举加EnumValue。// 号码实体 Data TableName(ticket) public class Ticket { TableId(type IdType.AUTO) private Long id; private String ticketNo; private String bizType; private TicketStatus status; private Long counterId; private Date createTime; private Date callTime; private Date finishTime; } // 状态枚举 public enum TicketStatus { WAITING(0, 等待中), CALLED(1, 已叫号), FINISHED(2, 已完成), PASSED(3, 已过号); EnumValue private final int code; private final String desc; // 构造和 getter 省略 }逻辑说明EnumValue告诉 MyBatis-Plus 存库时用code字段而不是枚举名。这样数据库里是 0/1/2/3Java 里是枚举两边都舒服。查询等待号码用LambdaQueryWrapper// 查某业务类型下所有等待号码按取号时间升序 ListTicket waiting ticketMapper.selectList( new LambdaQueryWrapperTicket() .eq(Ticket::getBizType, A) .eq(Ticket::getStatus, TicketStatus.WAITING) .orderByAsc(Ticket::getCreateTime) );参数说明eq是等值条件orderByAsc按取号时间升序保证先来先服务。如果数据量大这个查询要分页用PageTicket对象。3.3 取号接口的完整流程取号接口要做四件事校验业务类型、生成号码、写号码表、返回号码。这四步要在一个事务里否则生成号码成功但写表失败号码就丢了。用Transactional注解注意异常要抛RuntimeException才会回滚。RestController RequestMapping(/api/ticket) public class TicketController { Autowired private TicketNumberService numberService; Autowired private TicketMapper ticketMapper; PostMapping(/take) Transactional(rollbackFor Exception.class) public ResultTicketVO take(RequestParam String bizType) { // 1. 生成号码 String ticketNo numberService.nextNumber(bizType); // 2. 写号码表 Ticket ticket new Ticket(); ticket.setTicketNo(ticketNo); ticket.setBizType(bizType); ticket.setStatus(TicketStatus.WAITING); ticket.setCreateTime(new Date()); ticketMapper.insert(ticket); // 3. 返回给前端 TicketVO vo new TicketVO(); vo.setTicketNo(ticketNo); vo.setAheadCount(countAhead(bizType, ticket.getId())); return Result.ok(vo); } // 统计前面还有几个人 private int countAhead(String bizType, Long ticketId) { return ticketMapper.selectCount( new LambdaQueryWrapperTicket() .eq(Ticket::getBizType, bizType) .eq(Ticket::getStatus, TicketStatus.WAITING) .lt(Ticket::getId, ticketId) ).intValue(); } }逻辑说明nextNumber生成号码insert写库countAhead统计前面等待人数。Transactional保证生成号码和写库要么都成功要么都失败。参数上bizType从请求参数拿实际项目要做白名单校验防止乱传。countAhead用lt比较 id因为 id 自增id 小的先来。提示countAhead在高并发下可能不准因为取号瞬间又有新人进来。前端展示「前面约 X 人」就行别承诺精确数字。4. 避坑与排查排号系统上线前必须过的五道坎4.1 号码重复并发取号时两个客户拿到同一个号现象压测时发现两个线程取到相同号码数据库里出现重复ticket_no。原因号码生成用了「先查后改」但没加锁两个线程同时查到 current_seq 是 5都算出 6。解决用乐观锁版本号更新时带version条件失败重试或者直接给biz_type表的行加select ... for update悲观锁。课程设计里乐观锁更优雅但重试次数要设上限避免死循环。4.2 叫号叫到已完成的号码现象柜员点叫号叫出来的号码是之前已经办完的。原因队列里存的是号码对象号码完成后没有从队列移除或者移除失败。解决号码状态变更时同步操作队列用remove(Object)按 id 移除更稳妥的做法是叫号时先查数据库确认状态是 WAITING不是就跳过继续取下一个。内存队列和数据库状态要双向一致任何一边变了都要同步。4.3 窗口状态卡在「忙碌」现象柜员办完业务点「完成」窗口状态没变回空闲下一个客户叫不了号。原因完成接口更新号码状态成功但更新窗口状态时抛异常事务回滚了号码状态但窗口状态没回滚或者两个更新不在同一事务。解决把号码完成和窗口释放放在同一个Transactional方法里异常统一抛RuntimeException。另外加一个定时任务扫描超过 30 分钟还是「忙碌」的窗口强制释放并记录日志。4.4 队列重启后丢失现象服务重启所有等待客户消失客户拿着号来问前面还有几个人系统说没有。原因等待队列存在 JVM 内存里重启即丢。解决号码表本身就是持久化的等待队列启动时从ticket表查所有status WAITING的记录按业务类型重建内存队列。重建时按create_time排序保证顺序不变。这个逻辑写在PostConstruct或者ApplicationRunner里。4.5 过号处理没做客户回来要重新取号现象客户取号后去上厕所回来发现号被叫过了柜员说重新取。原因系统没有「过号」状态和「召回」机制。解决叫号后设一个超时时间比如 3 分钟超时未到窗口则状态改为「已过号」窗口自动叫下一个。客户回来可以点「召回」把过号重新插入队列头部。过号次数要限制比如同一号码最多召回一次防止恶意占号。5. 进阶技巧用状态机 定时任务把排号系统做扎实前面四章把主流程跑通了但要让系统经得起答辩和实际使用还得补两块状态机管理和定时补偿。状态机是把号码的生命周期固化下来禁止非法流转。比如 WAITING 只能到 CALLEDCALLED 只能到 FINISHED 或 PASSEDFINISHED 和 PASSED 是终态不能再变。用枚举加一个canTransferTo方法就能实现每次更新状态前校验非法流转直接抛异常。这样能挡住大部分脏数据。public enum TicketStatus { WAITING(0), CALLED(1), FINISHED(2), PASSED(3); private final int code; // 定义合法流转 public boolean canTransferTo(TicketStatus target) { switch (this) { case WAITING: return target CALLED; case CALLED: return target FINISHED || target PASSED; default: return false; // 终态不可流转 } } }定时任务用 Spring 的Scheduled就够了。两个任务一个是过号检测每分钟扫一次status CALLED且call_time超过 3 分钟的号码改成 PASSED 并释放窗口另一个是窗口心跳检测每 5 分钟扫一次status BUSY但update_time超过 30 分钟的窗口强制置为空闲。这两个任务能把大部分「卡死」场景兜住。Component public class ScheduleTask { Autowired private TicketMapper ticketMapper; Autowired private CounterMapper counterMapper; // 每分钟检测过号 Scheduled(fixedRate 60000) public void checkPassed() { Date threshold new Date(System.currentTimeMillis() - 3 * 60 * 1000); ListTicket called ticketMapper.selectList( new LambdaQueryWrapperTicket() .eq(Ticket::getStatus, TicketStatus.CALLED) .lt(Ticket::getCallTime, threshold) ); for (Ticket t : called) { t.setStatus(TicketStatus.PASSED); ticketMapper.updateById(t); // 释放窗口 counterMapper.releaseByTicketId(t.getId()); } } }参数说明fixedRate 60000表示每 60 秒执行一次单位毫秒。threshold是 3 分钟前的时间点lt是小于。实际项目里过号时间要做成配置项不同业务类型可以不一样比如对公业务给 10 分钟。验证方法上我一般用 JMeter 或者写个简单的多线程测试模拟 50 个客户同时取号看号码有没有重复再模拟 10 个窗口同时叫号看有没有叫到同一个号。数据库层面加唯一索引uk_ticket_no兜底就算代码有 bug数据库也能挡住重复号码。这个唯一索引是后悔药一定要加。最后说个血泪经验排号系统的难点不在增删改查而在并发和状态一致性。我见过太多课程设计把重点放在界面上结果一压测就露馅。把状态机、乐观锁、定时补偿这三样做扎实答辩时老师问什么都能接住。希望帮到你。本文还有配套的精品资源点击获取
返回列表