简介:在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 type='A',然后+1。但在高并发下,两个请求可能同时读到同一个MAX值,导致生成重复号码。
我的解决方案是利用数据库的自增特性与业务规则结合。具体来说:
- 我为
ticket表设计了一个数据库自增的主键id,但这不对外显示。 - 对外显示的“票号”,是由“业务类型前缀” + “日期” + “当日顺序号”组成,例如“A20240520015”。
- “当日顺序号”的生成,是核心难点。我创建了一张
sequence_table表,记录每个业务类型当日的当前序号。每次取号时,执行类似下面的SQL:
这条SQL利用MySQL的UPDATE sequence_table SET current_value = LAST_INSERT_ID(current_value + 1) WHERE type = ‘A’ AND date = CURDATE(); SELECT LAST_INSERT_ID();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对象,并定期向其中发送数据即可。 - 资源消耗:相比WebSocket,SSE的连接更轻量。
在实现时,我创建了一个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() { List<QueueData> 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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. 获取当前柜员有权限办理的业务类型列表 List<Integer> 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 SQL(MyBatis):
<select id="selectOldestWaitingByTypes" resultMap="BaseResultMap"> SELECT * FROM ticket WHERE status = 1 AND business_type_id IN <foreach collection="typeList" item="typeId" open="(" separator="," close=")"> #{typeId} </foreach> ORDER BY create_time ASC LIMIT 1 FOR UPDATE </select>这里使用了SELECT ... FOR UPDATE,这是一个悲观锁。在叫号这个关键操作上,为了防止两个柜员同时叫到同一个号,用行锁来保证绝对的安全。虽然会带来一点性能损耗,但对于银行这种对准确性要求极高的场景,是值得的。如果并发量极大,可以考虑更轻量级的乐观锁(版本号控制),但业务逻辑会变复杂。
4.3 管理端报表统计的实现
管理员需要查看数据报表,例如“今日各业务类型办理量”、“柜员工作效率排行”。这类统计查询通常会关联多张表,并且有分组聚合操作。
public List<BizTypeStatDTO> getTodayStatistics() { // 使用MyBatis的注解或XML编写复杂SQL return ticketMapper.countTodayGroupByBizType(); }<!-- TicketMapper.xml --> <select id="countTodayGroupByBizType" resultType="com.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.active=prod > app.log 2>&1 &命令后台启动。这里--spring.profiles.active=prod会激活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连接是保存在应用内存(如一个
List<SseEmitter>)中的,应用重启,内存清空,所有连接自然失效。 - 解决:这是一个设计上的取舍。对于这个场景,最简单的解决方案就是让显示端具备重连能力(如问题1的代码)。更复杂的方案是将连接信息持久化到Redis等外部存储,应用重启后从Redis恢复连接,但这实现成本较高,对于银行网点通常应用不会频繁重启,因此客户端重连是性价比最高的方案。
这个银行排号系统项目,虽然业务逻辑不复杂,但它像是一个微型的“企业级应用”样板,涵盖了用户权限、实时通信、事务控制、数据库设计、并发处理等多个核心知识点。在实现过程中,每一个技术选型和细节优化,都需要结合具体的业务场景和约束条件来权衡。希望这份详细的复盘,能为你带来一些实实在在的参考价值。代码和文档我已经整理好了,如果你在复现过程中遇到任何问题,或者有更好的实现思路,欢迎随时交流。
本文还有配套的精品资源,点击获取