
简介在Java Web开发领域数据库事务与并发控制是保障系统数据一致性的核心基础。其原理在于通过ACID特性确保在高并发场景下多个操作仍能正确执行。这项技术对于构建如银行、政务等对数据准确性要求极高的业务系统具有关键价值。典型的应用场景包括订单处理、库存扣减以及排队叫号系统。本文聚焦于银行排号系统深入探讨了如何利用MySQL的SELECT ... FOR UPDATE悲观锁与LAST_INSERT_ID()函数结合SSE技术实现实时数据推送从而解决取号时的并发冲突与大厅显示屏的即时更新问题确保系统在高并发下的稳定与高效。1. 项目概述一个银行排号系统的诞生最近在整理过往的项目资料翻到了几年前做的一个银行排号系统。这个项目虽然听起来传统但麻雀虽小五脏俱全从需求分析、技术选型、数据库设计到前后端实现再到最后的部署和讲解完整地走了一遍软件开发的流程。今天我就把这个项目的核心思路、实现细节以及踩过的坑系统地梳理出来分享给正在学习Java Web开发或者需要做一个类似管理系统的朋友。无论你是想参考源码还是想了解一个真实项目从零到一的构建过程相信这篇分享都能给你带来一些启发。这个系统的核心目标很简单模拟银行网点大厅的排队叫号流程。客户来了可以在终端机上选择业务类型比如个人业务、对公业务、VIP业务取号柜员在后台登录后可以叫号、办理业务并标记完成大厅的显示屏则实时展示当前正在办理的号码和等待人数。后台还需要有管理员角色用于管理柜员信息、查看业务统计报表等。整个系统需要稳定、高效并且界面清晰易用。技术栈上我选择了经典的Java Web组合Spring Boot MyBatis MySQL Thymeleaf前端用了一点Bootstrap让页面看起来不那么“原始”。接下来我会分几个部分详细拆解这个系统的设计与实现。2. 系统整体设计与核心思路拆解2.1 为什么选择这个技术栈在做技术选型时我主要考虑了这几个因素成熟度、开发效率、学习成本和项目实际需求。Spring Boot是当时现在依然是Java领域快速构建Web应用的首选框架它“约定大于配置”的理念让我能快速搭建起项目骨架摆脱繁琐的XML配置。内嵌的Tomcat服务器也让部署变得极其简单打成一个Jar包就能随处运行非常适合演示和教学场景。数据持久层我选择了MyBatis而不是JPA。这里有个小心思对于排号系统这种业务逻辑相对直接但SQL操作需要一定灵活性的场景比如复杂的多表关联查询用于报表MyBatis的半自动化特性更得心应手。我可以直接编写和优化SQL语句同时又享受到对象关系映射的便利。对于初学者来说理解SQL与Java对象之间的映射关系也比理解JPA的抽象层要更直观一些。数据库毫无疑问是MySQL轻量、免费、社区活跃对于这种并发量不会特别高的内部管理系统完全够用。前端模板选用Thymeleaf因为它能与Spring Boot无缝集成并且语法自然直接在HTML里写表达式前后端分离不那么彻底但对于一个以展示和管理为主、交互不算极度复杂的后台系统来说开发速度更快。注意这个技术栈是几年前的选择。如果现在来做前后端分离可能更主流后端提供RESTful API前端用Vue或React。但对于想快速理解一个完整MVC流程、或者项目周期紧张的情况Thymeleaf这种服务端渲染的方案依然有它的价值至少部署简单没有跨域问题。2.2 核心业务流程与模块划分系统的核心业务流围绕“号”的生命周期展开生成 - 等待 - 呼叫 - 办理 - 完成。基于此我划分了以下几个核心模块取号模块面向客户。提供触摸屏界面实际上就是一个Web页面客户选择业务类型后系统根据该类型的当前最大号码1生成一个新号并记录取号时间、业务类型、状态初始为“等待”存入数据库。同时这个新号码需要实时反馈到大厅的显示屏上。叫号与办理模块面向柜员。柜员登录后进入自己的工作台。他可以点击“叫号”按钮系统会从等待队列中按取号时间先后分配一个最老的、符合其权限比如普通柜员只能办理个人业务的号码给他并将该号码状态改为“办理中”。办理完成后柜员点击“完成”号码状态变为“已办结”并记录办结时间。显示模块面向大厅。这是一个独立的显示页面需要以大字体、高刷新率的方式循环展示当前各业务类型正在办理的号码、等待人数等信息。这里的关键是实时性不能等用户刷新页面。管理模块面向管理员。负责用户柜员的增删改查、业务类型的配置、以及查看历史数据报表如各柜员办理量、各时段业务量高峰等。数据库设计上核心表也就四五张ticket号票表存储每一个号码的详细信息如票号ID、业务类型、状态、取号时间、开始办理时间、办结时间、办理柜员ID等。这是最核心的表。business_type业务类型表定义业务类型如个人现金、对公转账、VIP服务等包含类型名称、前缀如“A”、“B”、描述等。user用户表存储柜员和管理员信息包含登录名、密码加密存储、角色、所属窗口等。counter窗口表可选项用于管理物理窗口与用户关联。display_config显示配置表可选项用于配置显示屏要显示的内容和样式。3. 核心细节解析与实操要点3.1 号码生成的策略与并发控制号码的生成看似简单但在并发取号时却是个坑。最直观的想法是SELECT MAX(number) FROM ticket WHERE typeA然后1。但在高并发下两个请求可能同时读到同一个MAX值导致生成重复号码。我的解决方案是利用数据库的自增特性与业务规则结合。具体来说我为ticket表设计了一个数据库自增的主键id但这不对外显示。对外显示的“票号”是由“业务类型前缀” “日期” “当日顺序号”组成例如“A20240520015”。“当日顺序号”的生成是核心难点。我创建了一张sequence_table表记录每个业务类型当日的当前序号。每次取号时执行类似下面的SQLUPDATE sequence_table SET current_value LAST_INSERT_ID(current_value 1) WHERE type ‘A’ AND date CURDATE(); SELECT LAST_INSERT_ID();这条SQL利用MySQL的LAST_INSERT_ID()函数在更新序列值的同时原子性地获取到更新后的值完美避免了并发冲突。然后再结合前缀和日期拼接成最终的票号。这个方案比在应用层用synchronized锁或Redis分布式锁更简单可靠依赖数据库的事务特性即可。实操心得对于这种全局唯一序号的生成一定要在数据库层面考虑原子性。应用层的锁在单机时有效但一旦应用水平扩展多实例部署就会失效。上述UPDATE...SELECT LAST_INSERT_ID()是一个经典且高效的做法。如果业务量极大还可以考虑使用号段分配模式比如一次从数据库申请一个号段如1000个号缓存在应用内存中发完再取减少数据库访问压力。3.2 实时显示功能的实现SSE与WebSocket的抉择大厅显示屏需要实时更新这是一个典型的服务器向浏览器主动推送数据的场景。当时有两个主流选择SSE和WebSocket。SSE服务器发送事件。它是基于HTTP的单向通道只能由服务器向客户端推送。实现简单天然支持断线重连。WebSocket全双工通信。功能更强大可以双向实时通信。我最终选择了SSE。理由如下需求匹配显示模块只需要接收服务器推送的最新排队信息不需要向服务器发送复杂指令最多发个心跳包SSE的单向性完全够用。实现简单Spring Boot中对SSE的支持很友好。客户端只需要一个EventSource对象连接指定的端点服务器端控制器的方法返回SseEmitter对象并定期向其中发送数据即可。资源消耗相比WebSocketSSE的连接更轻量。在实现时我创建了一个DisplayController其中有一个/display/stream的端点。当显示屏页面加载时JavaScript会建立到这个端点的SSE连接。后端有一个定时任务或是在每次号票状态变更时将最新的排队数据封装成JSON通过所有活跃的SseEmitter发送给所有连接的客户端。// 简化的后端代码示例 GetMapping(/stream) public SseEmitter stream() { SseEmitter emitter new SseEmitter(0L); // 0表示不超时 // 将这个emitter存入一个全局的List或Map中管理 displayService.addEmitter(emitter); // 发送初始数据 sendInitialData(emitter); return emitter; } // 在业务逻辑中当排队数据变化时 public void onQueueChanged() { ListQueueData latestData getLatestQueueData(); for (SseEmitter emitter : allEmitters) { try { emitter.send(SseEmitter.event().data(latestData)); } catch (IOException e) { // 处理连接已关闭的情况移除emitter allEmitters.remove(emitter); } } }3.3 数据库表设计与关键索引良好的数据库设计是性能的基石。除了之前提到的核心表这里重点讲一下ticket表的设计和索引策略。CREATE TABLE ticket ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, ticket_number varchar(50) NOT NULL COMMENT 票号如A20240520001, business_type_id int(11) NOT NULL COMMENT 业务类型ID, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态1-等待2-办理中3-已办结4-过号, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 取号时间, call_time datetime DEFAULT NULL COMMENT 叫号时间, finish_time datetime DEFAULT NULL COMMENT 办结时间, user_id bigint(20) DEFAULT NULL COMMENT 办理柜员ID, window_num varchar(20) DEFAULT NULL COMMENT 办理窗口号, PRIMARY KEY (id), UNIQUE KEY uk_ticket_number (ticket_number), KEY idx_status_type (status,business_type_id), KEY idx_create_time (create_time), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT号票表;关键索引解析uk_ticket_number唯一索引确保票号唯一这是业务关键约束。idx_status_type这是一个联合索引。这是最重要的索引之一。柜员叫号时最常见的查询就是SELECT * FROM ticket WHERE status 1 AND business_type_id ? ORDER BY create_time ASC LIMIT 1。这个索引能直接覆盖where条件并且由于create_time是主键的一部分InnoDB二级索引会包含主键排序效率也很高。如果分开建两个单列索引数据库可能只会用到一个效率大打折扣。idx_create_time用于按时间排序查询和历史数据分页。idx_user_id用于查询某个柜员办理的所有业务生成报表时常用。踩坑记录最初我没有建立idx_status_type这个联合索引而是单独建立了status和business_type_id的索引。在模拟并发叫号测试时发现ORDER BY create_time的排序操作非常慢因为需要大量的临时表排序。加上联合索引后查询速度提升了几十倍。这让我深刻理解到索引一定要为最核心的查询场景服务考虑查询条件的组合和排序字段。4. 核心功能实现与代码剖析4.1 取号接口的实现取号接口是系统的入口要求快速、准确、并发安全。控制器层接收前端传来的业务类型ID服务层负责核心逻辑。Service public class TicketServiceImpl implements TicketService { Autowired private TicketMapper ticketMapper; Autowired private SequenceService sequenceService; Autowired private BusinessTypeMapper bizTypeMapper; Transactional(rollbackFor Exception.class) // 开启事务 Override public Ticket generateTicket(Integer businessTypeId) { // 1. 验证业务类型是否存在且有效 BusinessType bizType bizTypeMapper.selectById(businessTypeId); if (bizType null || !bizType.getIsActive()) { throw new BusinessException(无效的业务类型); } // 2. 生成当日顺序号核心防并发 Long sequence sequenceService.getNextSequence(bizType.getCode(), LocalDate.now()); // 3. 拼接完整票号 String ticketNumber String.format(%s%tY%tm%td%03d, bizType.getPrefix(), LocalDate.now(), sequence); // 4. 创建票号对象并入库 Ticket ticket new Ticket(); ticket.setTicketNumber(ticketNumber); ticket.setBusinessTypeId(businessTypeId); ticket.setStatus(TicketStatus.WAITING.getCode()); // ... 设置其他字段 ticketMapper.insert(ticket); // 5. 可选触发事件通知显示模块更新 applicationContext.publishEvent(new TicketCreatedEvent(this, ticket)); return ticket; } }代码要点Transactional确保生成序列号和插入票号这两个操作在一个事务里原子性提交。SequenceService.getNextSequence内部就是调用前面提到的UPDATE...SELECT LAST_INSERT_ID()SQL。票号拼接使用了String.format和LocalDate格式清晰。%03d保证顺序号至少三位前面补零。事件发布使用Spring的事件机制解耦取号逻辑和显示更新逻辑。TicketCreatedEvent事件会被监听器捕获然后触发向所有SSE连接发送更新数据的操作。4.2 柜员叫号与业务办理流程柜员登录后其工作台主要两个功能叫号、办结。叫号逻辑需要找到最适合当前柜员的那个“等待”中的号码。Service public class CounterServiceImpl implements CounterService { Override public Ticket callNextTicket(Long userId, String windowNum) { // 1. 获取当前柜员有权限办理的业务类型列表 ListInteger allowedBizTypes getUserService().getAllowedBusinessTypeIds(userId); if (allowedBizTypes.isEmpty()) { throw new BusinessException(该柜员未分配可办理的业务类型); } // 2. 查询最早的一个等待号码使用联合索引 Ticket waitingTicket ticketMapper.selectOldestWaitingByTypes(allowedBizTypes); if (waitingTicket null) { throw new BusinessException(当前无等待办理的客户); } // 3. 更新票号状态为“办理中”并关联柜员和窗口 waitingTicket.setStatus(TicketStatus.PROCESSING.getCode()); waitingTicket.setUserId(userId); waitingTicket.setWindowNum(windowNum); waitingTicket.setCallTime(new Date()); ticketMapper.updateById(waitingTicket); // 4. 再次触发事件更新显示屏当前办理号码变化 applicationContext.publishEvent(new TicketCalledEvent(this, waitingTicket)); return waitingTicket; } Override public void finishTicket(Long ticketId, Long userId) { Ticket ticket ticketMapper.selectById(ticketId); // 校验票号是否存在、状态是否为办理中、办理人是否是当前柜员 if (ticket null || !TicketStatus.PROCESSING.getCode().equals(ticket.getStatus()) || !userId.equals(ticket.getUserId())) { throw new BusinessException(无法完成此业务办理); } ticket.setStatus(TicketStatus.FINISHED.getCode()); ticket.setFinishTime(new Date()); ticketMapper.updateById(ticket); // 触发办结事件更新显示等待人数减少 applicationContext.publishEvent(new TicketFinishedEvent(this, ticket)); } }对应的Mapper SQLMyBatisselect idselectOldestWaitingByTypes resultMapBaseResultMap SELECT * FROM ticket WHERE status 1 AND business_type_id IN foreach collectiontypeList itemtypeId open( separator, close) #{typeId} /foreach ORDER BY create_time ASC LIMIT 1 FOR UPDATE /select这里使用了SELECT ... FOR UPDATE这是一个悲观锁。在叫号这个关键操作上为了防止两个柜员同时叫到同一个号用行锁来保证绝对的安全。虽然会带来一点性能损耗但对于银行这种对准确性要求极高的场景是值得的。如果并发量极大可以考虑更轻量级的乐观锁版本号控制但业务逻辑会变复杂。4.3 管理端报表统计的实现管理员需要查看数据报表例如“今日各业务类型办理量”、“柜员工作效率排行”。这类统计查询通常会关联多张表并且有分组聚合操作。public ListBizTypeStatDTO getTodayStatistics() { // 使用MyBatis的注解或XML编写复杂SQL return ticketMapper.countTodayGroupByBizType(); }!-- TicketMapper.xml -- select idcountTodayGroupByBizType resultTypecom.bank.dto.BizTypeStatDTO SELECT bt.name as businessTypeName, COUNT(*) as totalCount, SUM(CASE WHEN t.status 3 THEN 1 ELSE 0 END) as finishedCount, AVG(TIMESTAMPDIFF(SECOND, t.call_time, t.finish_time)) as avgProcessSeconds FROM ticket t LEFT JOIN business_type bt ON t.business_type_id bt.id WHERE DATE(t.create_time) CURDATE() GROUP BY t.business_type_id, bt.name ORDER BY totalCount DESC /select优化建议对于这种固定的日报表如果数据量很大比如运行了好几年每次查询都全表扫描ticket会很慢。可以考虑以下策略归档历史数据将办结时间超过一年的数据迁移到历史表主表只保留近期热数据。建立汇总表每天凌晨跑一个定时任务将前一天的统计结果计算好存入一张daily_stat表。管理端查询时直接查汇总表速度极快。这就是典型的“空间换时间”。对create_time加索引本例中的WHERE DATE(t.create_time) CURDATE()条件如果create_time有索引但使用了DATE()函数会导致索引失效。更好的做法是写为t.create_time CURDATE() AND t.create_time CURDATE() INTERVAL 1 DAY这样就能利用上索引。5. 部署、测试与常见问题排查5.1 本地开发与生产部署要点开发环境我使用IDEA进行开发利用Spring Boot DevTools实现热部署修改代码后自动重启提升效率。数据库连接池使用默认的HikariCP配置合理的连接数通常10-20个连接对于开发测试足够。生产部署打包使用mvn clean package打成一个可执行的Jar文件spring-boot-maven-plugin已配置好。数据库在生产环境的MySQL中需要调整参数如max_connections根据应用服务器数量和预估并发调整、innodb_buffer_pool_size设置为机器物理内存的50%-70%。应用启动在服务器上使用nohup java -jar bank-queue-system.jar --spring.profiles.activeprod app.log 21 命令后台启动。这里--spring.profiles.activeprod会激活application-prod.properties配置文件其中配置了生产环境的数据库地址、日志级别等。反向代理使用Nginx作为反向代理将80/443端口的请求转发到Spring Boot应用的内嵌Tomcat端口如8080。Nginx负责处理静态文件、SSL加密、负载均衡如果多实例部署等。显示终端大厅的显示终端实际上就是一台连接了网络的电脑或大屏设备全屏打开浏览器访问显示页面的URL即可。为了确保稳定可以写一个简单的脚本在页面崩溃或网络断开时自动刷新。5.2 功能测试与压力测试模拟测试是保证系统稳定的关键。单元测试对核心服务类如TicketService、SequenceService使用JUnit和Mockito编写单元测试模拟各种边界情况如业务类型无效、序列号生成冲突等。集成测试使用SpringBootTest启动整个应用上下文测试完整的API接口确保控制器、服务、数据库链路通畅。压力测试使用JMeter模拟高并发场景。取号压力测试模拟100个用户在10秒内同时发起取号请求观察是否出现重复号、错误率、响应时间。叫号压力测试模拟多个柜员循环叫号、办结测试在并发办理下数据状态是否正确有无超时或死锁。SSE连接测试模拟上百个客户端同时连接显示流观察服务器内存和CPU占用。在我的测试中最初没有加FOR UPDATE和联合索引时在高并发叫号下出现了少量重复分配和响应缓慢的问题。通过添加锁和优化索引后系统在模拟环境下4核8G服务器MySQL同机部署能够稳定支持每秒数百次的取号/叫号操作完全满足一个中型网点的需求。5.3 常见问题与排查技巧实录在实际开发和后期维护中会遇到一些典型问题这里记录一下问题1显示屏数据更新延迟或不同步。排查首先检查浏览器开发者工具的Network标签看SSE连接/display/stream是否正常建立状态码200类型为eventsource。如果连接断开查看服务器日志是否有异常。解决确保服务器端SseEmitter的发送逻辑没有阻塞。不要在发送循环里做耗时的操作。客户端需要监听onerror事件实现断线重连机制。// 客户端重连示例 function connectSSE() { const eventSource new EventSource(/display/stream); eventSource.onmessage (event) { /* 更新界面 */ }; eventSource.onerror (err) { console.error(SSE连接错误5秒后重连..., err); eventSource.close(); setTimeout(connectSSE, 5000); }; }问题2柜员叫号时偶尔提示“当前无等待客户”但实际等待队列明明有人。排查这很可能是因为SELECT ... FOR UPDATE锁住了某一行而另一个柜员的查询被阻塞超时后返回了空。检查数据库的innodb_lock_wait_timeout设置默认50秒以及应用是否有设置查询超时。解决确保叫号业务逻辑执行要快不要在事务里进行不必要的耗时操作。如果业务允许可以尝试使用乐观锁机制先查询出票号和版本号更新时用版本号作为条件如果更新失败版本号不对则让柜员重试叫号。问题3运行一段时间后系统变慢查询报表特别卡。排查登录MySQL执行SHOW PROCESSLIST;查看当前是否有慢查询。开启MySQL的慢查询日志slow_query_log定位具体的慢SQL。解决通常是缺少索引或SQL写法问题。使用EXPLAIN分析慢SQL的执行计划。对于报表查询考虑引入前面提到的汇总表或定时任务预计算策略。定期对表进行优化OPTIMIZE TABLE ticket注意此操作会锁表需在业务低峰期进行。问题4应用重启后之前连接的显示屏不更新了。原因SSE连接是保存在应用内存如一个ListSseEmitter中的应用重启内存清空所有连接自然失效。解决这是一个设计上的取舍。对于这个场景最简单的解决方案就是让显示端具备重连能力如问题1的代码。更复杂的方案是将连接信息持久化到Redis等外部存储应用重启后从Redis恢复连接但这实现成本较高对于银行网点通常应用不会频繁重启因此客户端重连是性价比最高的方案。这个银行排号系统项目虽然业务逻辑不复杂但它像是一个微型的“企业级应用”样板涵盖了用户权限、实时通信、事务控制、数据库设计、并发处理等多个核心知识点。在实现过程中每一个技术选型和细节优化都需要结合具体的业务场景和约束条件来权衡。希望这份详细的复盘能为你带来一些实实在在的参考价值。代码和文档我已经整理好了如果你在复现过程中遇到任何问题或者有更好的实现思路欢迎随时交流。本文还有配套的精品资源点击获取