☰
Java Socket + GUI + Oracle:银行排号系统实战全解析
2026/10/7 5:53:01 网站建设 项目流程

简介:一套基于Java Socket与Java GUI的银行排号系统项目资料,面向高校学生的课程设计、毕业设计以及Java网络编程实践者,重点解决客户端与服务器端实时通信、排队叫号、窗口调度等典型场景需求。压缩包整体大小约292.61MB,虽然未单独列出文件数量,但内部以项目完整源码和配套设计文档为主:源码经过测试校正,能够百分百运行;文档覆盖需求分析、系统设计、核心代码说明、数据库设计等内容,便于按模块阅读或二次开发。目前已有475人学习浏览,适合需要完整实战方案的开发者参考。通过该项目可以掌握Socket多线程通信机制、Java GUI事件处理与界面布局方法、业务数据流转与线程安全处理等关键技能;配套文档还能作为课程设计或毕业答辩时的设计说明书蓝本,帮助快速理清排号系统整体架构与实现思路,节省从零搭建的时间。

1. 银行排号系统为什么绕不开 Java + Socket + Java GUI + Oracle 这一套

银行大厅那台取号机吐出来的小票,背后并不是什么玄学,而是一个相当典型的 Java CS 架构:服务端要么用 ServerSocket 挂着一个端口等连接,要不用 NIO 搞事件轮询;客户端用 Swing 或 JavaFX 画出取号按钮、叫号大屏和柜台工作台;Oracle 负责把每一笔取号、叫号、办结记录都落进数据库。这个基于 java+Socket+Java GUI 的银行排号系统,把 Socket 网络编程、Java GUI 事件模型、JDBC 数据库访问这几块基础串在了一个真实场景里,复杂度刚好卡在“能讲清楚”和“值得琢磨”之间。它不是一个高并发的互联网项目,但如果你是刚走到 Java 基础后半程的开发者、要做课程设计或者接了个小 demo 项目的人,把这套代码从头到尾跑通、改库里的一张表、调一次端口,比刷十道 java 面试题更能把 Socket 和 GUI 线程这块儿变成自己的东西。

2. 排号系统的 Socket 通信骨架:三端职责、线程池和自定义协议怎么定

排号系统本质上就是一个多客户端连同一个服务端的 CS 模型:厅里有多台取号机、多个柜台窗口,还有挂在墙上的叫号屏。取号机往服务端发“我要排队”,柜台往服务端发“我叫下一个”,服务端把叫号消息广播给所有叫号屏。这类业务用 Socket 长连接是更稳妥的选择,因为每个客户端需要实时收到广播,如果每次取号都临时建连接、拿到结果就断开,叫号屏上的“请 A012 到 3 号窗口”就没法主动推过去。先把这个骨架看清楚,后面写 GUI 和 Oracle 才不会翻车。

2.1 排号系统的三端角色与数据流向

我一般会把客户端拆成三种角色,而不是做一个“万能”客户端:排号机、柜台端、叫号屏各连各的。排号机是取号入口,界面上有几个按钮,分别是现金业务、对公业务、VIP 业务,点一下就向服务端发送取号请求;服务端分配一个新号码并返回给排号机,同时把这笔记录写进 Oracle。柜台端是窗口工作人员用的,界面上显示当前窗口号、正在办理的号码以及“呼叫下一个”按钮;点击后服务端把对应号码改成已叫状态,并向所有叫号屏广播。叫号屏只做展示,没有操作按钮。

数据流是一个环:取号机发送 GET_NUM 后,服务端写库、分配号码、响应给取号机;柜台端发送 CALL_NUM 后,服务端改状态并广播 NOTICE;最后柜台端发送 DONE_NUM,服务端把状态改成已完成。这里最容易被忽略的是广播环节,服务端必须维护一个“当前所有在线客户端”的集合,并且要区分这个客户端是排号机、柜台还是叫号屏,否则广播就会把消息发给排号机,叫号屏反而收不到。我习惯在客户端连接后先发一条注册消息,格式是REG|DEVICE_TYPE|CLIENT_ID,服务端据此把 Socket 放进不同的组里。

2.2 ServerSocket 线程池:一个连接开一个线程为什么不行

最原始的写法是主线程死循环调用accept(),每拿到一个 Socket 就new Thread去处理。在小 demo 里没问题,三个用户同时连进来线程数还不至于失控;但营业厅的排号系统高峰期可能有几十个连接,加上系统里还有数据库操作和广播推送,连接线程只增不减,最终会把 JVM 的线程栈空间吃掉。另一个问题是多客户端同时连进来时,频繁创建线程会有调度开销,界面也会感觉卡。

我通常用固定大小线程池,连接读取逻辑放到ExecutorService里:

public class TicketServer { private static final int PORT = 9090; private static final int BACKLOG = 50; private static final int THREAD_COUNT = 16; public static void main(String[] args) throws IOException { ExecutorService pool = Executors.newFixedThreadPool(THREAD_COUNT); // 第二个参数是 backlog,表示操作系统内核对连接请求的排队长度 try (ServerSocket server = new ServerSocket(PORT, BACKLOG)) { System.out.println("排号服务端已启动,端口=" + PORT); while (true) { Socket socket = server.accept(); // 每来一个连接就提交一个任务,线程池内执行 pool.submit(() -> handleClient(socket)); } } } private static void handleClient(Socket socket) { // 读取客户端请求、处理业务、返回响应 } }

BACKLOG=50是允许挂起等待的连接数上限,超过这个数后再来的连接会被操作系统拒绝。16 个线程处理排号业务是够的:按一个普通网点 30 个窗口、4 台取号机、2 块叫号屏算,并发峰值同时操作的客户端也就 36 个左右,线程池满员时的多余请求会在等待队列里排队,不会阻塞主线程的 accept。如果你后续要在同一个 JVM 里跑多个服务端实例,就要注意热词里常出现的那种错误——“failed to create server shutdown socket on address [localhost] and port [802]”,这不是业务端口被占,而是 JVM 注册的 shutdown 端口冲突,通常是你在一个 JVM 里启动了多个 Spring 容器或重复初始化了服务端,解决方案是不要重复创建上下文对象。

这段代码需要配合一个读线程才能真正把数据读上来。Socket 的getInputStream()是阻塞的,主线程没法一边 accept 一边读数据,所以我在handleClient里会再给每个客户端挂一个独立的读取循环,用BufferedReader.readLine()一行一行地读,这样可以避免自己处理字节缓冲拼接的问题。

2.3 通信协议:自定义文本协议比 Java 对象序列化更省心

客户端和服务端之间传什么格式,是排号系统里最容易随手乱写的地方。不少人直接用一个Socket流把ObjectOutputStream写上,传 Java 对象进去,好处是省事,坏处是调试困难、序列化版本号一旦不匹配就报InvalidClassException,而且排号系统的中间结果没法用 telnet 直接观测。我倾向于定义一个简单的文本协议,每行一条指令,字段用竖线分隔。

指令分三类。请求类:GET_NUM|BIZ_TYPE取号;CALL_NUM|WINDOW_NO呼叫下一个;DONE_NUM|TICKET_NO|WINDOW_NO办结。响应类:ACK|TICKET_NO|WAIT_COUNT返回给取号机,NOTICE|TICKET_NO|WINDOW_NO由服务端广播给所有叫号屏。错误类:ERR|CODE|MESSAGE。这里有一个容易被忽略的约定:文本协议必须选一个不会出现在数据里的分隔符,取号号码是 A 加三位数字、窗口号是纯数字,竖线分隔足够安全。

按行读取文本协议还有个隐藏的好处:它天然解决了 TCP 粘包问题。BufferedReader.readLine()读到换行符才算一条完整指令,不会发生半条消息被业务代码处理的情况。服务端读取的循环大概是:

private static void handleClient(Socket socket) { try (BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); PrintWriter out = new PrintWriter(new OutputStreamWriter( socket.getOutputStream(), StandardCharsets.UTF_8), true)) { String line; while ((line = in.readLine()) != null) { String[] parts = line.split("\\|"); switch (parts[0]) { case "GET_NUM" -> handleGetNum(parts, socket); case "CALL_NUM" -> handleCallNum(parts); case "HEART_BEAT" -> System.out.println("心跳: " + parts[1]); default -> out.println("ERR|100|unsupported command"); } } } catch (IOException e) { System.out.println("客户端断开: " + socket.getRemoteSocketAddress()); } }

这里PrintWriter的自动刷新参数要写成 true,否则println的内容可能还卡在缓冲区里。每行结尾都是\n,服务端读完一行后按split("\\|")切字段。整套协议没有多余描述,上手成本低,这也是排号系统这类内部 demo 项目适合用自定义文本协议的原因——它不像开放平台那样需要严格鉴权和加密,只要内部几个端约定一致就行。

3. Java GUI 实现:排号机、叫号屏和 Swing 线程安全的落地写法

Java GUI 在这个项目里承担的是“看得见、点得动”的界面层。Swing 是 Java GUI 里最老牌也最稳的选择,虽然界面观感朴实,但胜在 JDK 自带、无需额外依赖、事件模型清晰。排号机和叫号屏看起来是两个独立的程序,实际上代码结构可以共用一套 Socket 客户端类,只是界面上展示的内容不同。这一章我会把排号机取号、叫号屏收广播以及最容易出问题的 Swing 线程安全都过一遍,并告诉你哪些 java 基础会在这一步被真正用上。

3.1 排号机界面:JFrame、按钮与排队人数刷新

排号机的界面不需要复杂布局,一个业务类型按钮区、一个显示当前票号的标签、一个显示前方等待人数的标签就够。取号按钮的点击事件不能直接在事件回调里做 Socket 通信,否则服务端响应慢一点,整个界面就卡住不动,这在 javagui 里是最典型的翻车现场。正确做法是把请求交给单独的线程池,收到响应后再切回界面线程更新标签。

public class TicketClientUI extends JFrame { private final JLabel ticketLabel = new JLabel("请点击下方业务按钮取号"); private final JLabel waitLabel = new JLabel("等待人数:-"); private final ExecutorService exec = Executors.newFixedThreadPool(2); private final SocketClient client; public TicketClientUI(SocketClient client) { this.client = client; setTitle("银行取号机"); setSize(420, 280); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setLayout(new BorderLayout(8, 8)); JPanel btnPanel = new JPanel(new FlowLayout(FlowLayout.CENTER, 12, 12)); btnPanel.add(buildBizButton("现金业务", "CASH")); btnPanel.add(buildBizButton("对公业务", "CORP")); btnPanel.add(buildBizButton("VIP业务", "VIP")); add(btnPanel, BorderLayout.NORTH); add(ticketLabel, BorderLayout.CENTER); add(waitLabel, BorderLayout.SOUTH); } private JButton buildBizButton(String text, String bizType) { JButton btn = new JButton(text); btn.addActionListener(e -> exec.submit(() -> doGetNum(bizType))); return btn; } private void doGetNum(String bizType) { try { String resp = client.sendCommand("GET_NUM|" + bizType); String[] parts = resp.split("\\|"); // 网络线程拿到结果后,必须用 invokeLater 回到 Swing 事件线程更新界面 SwingUtilities.invokeLater(() -> { if (parts[0].equals("ACK")) { ticketLabel.setText("您的号码:" + parts[1]); waitLabel.setText("等待人数:" + parts[2]); } else { ticketLabel.setText("取号失败:" + parts[2]); } }); } catch (IOException ex) { SwingUtilities.invokeLater(() -> ticketLabel.setText("连接服务端失败")); } } }

这段代码里的sendCommand是同步阻塞方法,会一直等到服务端返回。线程池只开了两个线程,因为同一时刻最多就是两三个取号点击在并发,不会把资源耗尽。SwingUtilities.invokeLater是关键,它把界面更新动作放进事件派发线程(EDT)的队列里,保证 JLabel 的修改不会和界面重绘争抢同一把锁。如果你偷懒直接在这个方法里改标签,多半会出现界面偶尔闪一下或者文字不刷新的诡异问题。

3.2 叫号屏界面:服务端广播怎么接才能不掉消息

叫号屏和取号机最大的不同是:取号机是“发请求等响应”,叫号屏是“被动收广播”。服务端一旦广播NOTICE|A012|3,叫号屏客户端必须有一个线程始终阻塞在读 Socket 上,每读到一条广播就更新屏幕上的号码和窗口号。这个读线程和 Swing 的关系要格外小心,因为读线程不是事件线程,直接改 JLabel 会引发偶发的不显示或文字错乱。

public class NoticeScreenUI extends JFrame { private final JLabel numLabel = new JLabel("----"); private final JLabel winLabel = new JLabel("请到对应窗口办理"); public NoticeScreenUI(Socket socket) throws IOException { setTitle("叫号大屏"); setSize(500, 300); setLayout(new GridLayout(2, 1)); numLabel.setFont(new Font("Dialog", Font.BOLD, 48)); winLabel.setFont(new Font("Dialog", Font.PLAIN, 28)); add(numLabel); add(winLabel); // 独立读线程:阻塞等待服务端推送 new Thread(() -> { try (BufferedReader in = new BufferedReader( new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) { String line; while ((line = in.readLine()) != null) { if (line.startsWith("NOTICE|")) { String[] parts = line.split("\\|"); SwingUtilities.invokeLater(() -> { numLabel.setText(parts[1]); winLabel.setText("请到 " + parts[2] + " 号窗口"); }); } } } catch (IOException e) { SwingUtilities.invokeLater(() -> numLabel.setText("连接断开")); } }, "socket-read-thread").start(); } }

这个设计里读线程的生命周期和 JFrame 一样长,窗口关闭时如果没有把线程置为 daemon,程序会退出不了。我一般把线程名写成socket-read-thread,这样用 jstack 排查问题的时候一眼就能看出来是哪条线程在读 Socket。这里的“读一行、更新一次”很有用,服务端广播频率不高,不会出现消息风暴。如果将来要做几百条消息秒发,就得用消息队列做缓冲,但排号场景完全用不上。

3.3 Swing 与 Socket 线程协作:为什么 UI 更新必须切回 EDT

Swing 的组件不是线程安全的,所有对组件状态的修改都必须在事件派发线程中完成。排号系统的网络层天然是多线程的,服务端推送、客户端读线程、按钮回调都可能在任意线程里触发界面更新。如果不做切换,你大概率会看到界面偶发闪烁、文字不刷新,严重时直接抛java.awt.AccessControlException或者卡死。

用一段最简单的心智模型来记:Socket 线程负责“拿数据”,EDT 负责“画界面”,两者之间只通过SwingUtilities.invokeLater传递 UI 更新动作。任何网络耗时操作、数据库操作、Thread.sleep都不允许出现在 EDT 里。我见过不少同行把client.sendCommand()直接写在addActionListener里面,然后抱怨“为什么我一取号,界面就白屏转圈”——不是 Socket 本身慢,而是你让画画的人去门口搬砖,画布当然就停了。

这条规则同样适用于 javax.swing.Timer 和 SwingWorker。如果你的排号机界面需要定时刷新等待人数,不要用while(true)循环加sleep,可以用一个javax.swing.Timer每两秒触发一次,里面再去发起查询。Timer 本身运行在 EDT 上,所以回调里不要做阻塞操作,只负责把任务丢给线程池。

4. Oracle 建模与 JDBC 取号:表结构、序列和并发单调号的几种做法

排号系统把数据落进 Oracle,看起来只是多了一个数据库,实际上它决定了“号码会不会重复”和“报表能不能查”。Oracle 做这张表并不复杂,但有几个和 MySQL 习惯不同的地方:没有自增主键,需要 sequence;字符串类型有空串和 NULL 混用的问题;JDBC 驱动要匹配 JDK 版本。这一章按我习惯的设计顺序来讲,先建表,再写 JDBC 访问层,最后讨论并发取号时怎么保证号码唯一。

4.1 排号表结构设计:一张主表加一组业务类型

排号记录用一张表就够,核心字段是票号、业务类型、窗口号、状态和时间。这里不需要过度拆分订单表,营业厅一天也就几千笔取号记录,一张表加索引性能足够。下面是我常用的建表语句:

CREATE TABLE queue_ticket ( id NUMBER(12) PRIMARY KEY, ticket_no VARCHAR2(10) NOT NULL, biz_type VARCHAR2(4) NOT NULL, window_no NUMBER(3), status NUMBER(1) DEFAULT 0, create_time TIMESTAMP DEFAULT SYSDATE, call_time TIMESTAMP, finish_time TIMESTAMP ); CREATE INDEX idx_ticket_status ON queue_ticket(status, create_time); CREATE SEQUENCE seq_ticket_id START WITH 1000 INCREMENT BY 1 NOCACHE;

ticket_no存的是展示给客户的号码,比如 A012,前半段是业务类型标识,后半段是递序号。biz_type用CASH、CORP、VIP这样的短码,避免在界面显示时报错。status字段用数字表示状态:0 等待中,1 已叫号,2 已完成,90 已取消,用数字的好处是排序和统计方便,坏处是要在代码里维护一套常量定义。create_time用TIMESTAMP类型而不是DATE,这样能记录毫秒时间,方便统计高峰时段的排队时长。

Oracle 的NUMBER(12)做主键对比 MySQL 的BIGINT AUTO_INCREMENT更直白,但要注意NOCACHE是特意加的。排号系统一天就几千个号,sequence 缓存没有意义,反而会让重启后出现号段空洞。如果你遇到“今天的第一个号是 1050”这种怪事,往往就是 sequence 缓存了 20 个号但数据库异常重启导致的。这不是故障,但会给柜员解释起来很麻烦。

4.2 JDBC 连接 Oracle:驱动、URL 格式和连接管理

JDBC 连 Oracle 的第一个坑是驱动版本选择。JDK 8 用ojdbc8,JDK 17 或 11 那一档就必须换ojdbc11,因为两者编译时的 class 版本不同,低版本的驱动在高版本 JDK 上能加载,但某些内部方法会抛UnsupportedClassVersionError。URL 格式也要分清,旧的 SID 写法是jdbc:oracle:thin:@localhost:1521:orcl,服务名写法是jdbc:oracle:thin:@//localhost:1521/orcl。两种写法连的其实是同一个监听,但实例名和服务名不一样,写错就会报ORA-12505,这个后面避坑章节再展开。

public class TicketDao { private final String url = "jdbc:oracle:thin:@//localhost:1521/XEPDB1"; private final String username = "bank_user"; private final String password = "bank_pass"; // 取号:调用存储过程/序列生成票号并插入记录 public TicketRecord insertTicket(String bizType) throws SQLException { String sql = """ INSERT INTO queue_ticket(id, ticket_no, biz_type, status) VALUES (seq_ticket_id.NEXTVAL, ? || seq_ticket_id.CURRVAL, ?, 0) """; try (Connection conn = DriverManager.getConnection(url, username, password); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, bizType); ps.setString(2, bizType); ps.executeUpdate(); } } }

这里直接用了seq_ticket_id.NEXTVAL取号,然后拼出票号。这种写法简洁,但是把“生成票号”和“插入记录”拆成了两次取序列值,如果业务要求票号必须连续,就不能这么拼。更稳的做法是在应用层先查一次SELECT seq_ticket_id.NEXTVAL FROM dual拿到号,再拼完整票号做插入。关于dual表,它其实是一个只有一行一列的系统表,任何不带FROM 子句的 Oracle SQL 都要用dual补全,这也是 Oracle 入门时最容易困惑的地方,但它在这里只承担“取序列值”的作用。

连接管理上,小项目直接用DriverManager.getConnection没问题,但生产环境至少要用一个简单的连接池,比如 HikariCP。排号系统的并发连接数不高,用不用连接池主要影响的是每次取号时的建连开销。Oracle 数据库新建连接一般要几十毫秒到上百毫秒,如果高峰一分钟有几十笔取号,连接池能让数据库层面的耗时可预测。

4.3 高并发取号:Oracle sequence 和应用层锁怎么配合

同一个客户不会在同一秒按两次取号键,但不同取号机是并发的。如果两个线程同时插入,主键 id 绝对不会冲突,因为 sequence 是数据库侧原子自增;但ticket_no如果手工拼“业务类型+数字”,就可能出现两个线程算出同一个号。最常见的原因是应用层先查询当前最大号再 +1,这就不是原子操作,并发时必出重复。

用一个 Oracle 存储过程把整个取号动作包起来是最省心的做法,因为存储过程里可以保证取号、更新计数器、插入记录这三步在一个事务里完成。排号号的序号本身不需要全局连续,只需要全局不重复。做法是每次取号时只用 sequence 的 NEXTVAL 做基数,业务类型是 VIP 就拼V + 数字,这样不同业务类型的号段天然分开了。

CREATE OR REPLACE PROCEDURE p_get_ticket_num ( p_biz_type IN VARCHAR2, p_ticket_no OUT VARCHAR2, p_wait_cnt OUT NUMBER ) AS v_seq NUMBER; BEGIN SELECT seq_ticket_id.NEXTVAL INTO v_seq FROM dual; p_ticket_no := SUBSTR(p_biz_type, 1, 1) || TO_CHAR(v_seq); INSERT INTO queue_ticket(id, ticket_no, biz_type, status) VALUES (v_seq, p_ticket_no, p_biz_type, 0); SELECT COUNT(*) INTO p_wait_cnt FROM queue_ticket WHERE status = 0; COMMIT; END;

注意SUBSTR(p_biz_type, 1, 1)和TO_CHAR(v_seq)。这个存储过程把所有写库操作塞进一个事务,取号机拿到p_ticket_no和p_wait_cnt后,界面立刻就能展示“A105 前方有 3 人等待”。p_wait_cnt是排号系统特有的字段,它读的是表里 status=0 的总数,不需要额外维护队列表,查询量也不算大,营业厅一天几千笔完全可以承受。

有人会问,为什么不在 Java 层直接用synchronized锁住取号段?在小项目里这也能跑通,但它只对单 JVM 进程生效。将来如果拆成两个服务端实例做负载均衡,synchronized就会放行两个线程同时取号。从这个角度说,把并发的终点放在 Oracle 的 sequence 上是更面向演进的选择。

5. 避坑排查:端口占用、GUI 卡死、Oracle 监听和中文乱码的现场修复

这个项目里踩过的坑,大多不是 Java 语法问题,而是 Socket、GUI、Oracle 三者之间的协作问题。这些问题单独看你都很眼熟,但凑在一起时,错误信息往往极具迷惑性。比如启动失败了,你以为是端口被封了,其实是一个 JVM 里跑了两个服务端;界面卡死了,你以为是电脑死机,其实是读线程在 EDT 里等待 Socket 数据。这一章我按现象、原因、解决的顺序写实战中排队最高的四条。

5.1 端口占用:错误信息明明说“Address already in use”,却不是业务端口的事

现象:启动服务端时报java.net.BindException: Address already in use: JVM_Bind,Windows 上还可能提示“windows socket error: 通常每个套接字地址(协议/网络地址/端口)只允许使用一次。”第一次看到这个错的人都会觉得,肯定是 9090 端口被占用了。

原因:确实有可能是上一个服务端进程没关干净,但更隐蔽的情况是服务端主类被加载了两次,比如在同一个 JVM 里同时初始化了两个 Spring 容器、或者 IDE 的“Rerun”功能没有停掉上一个进程。还有一个常见源头是 JVM 的 shutdown 端口,也就是热词里提到的failed to create server shutdown socket on address [localhost] and port [802],这是 JMX 或内部通信端口冲突,报错信息经常混在业务日志里。

解决:先用netstat -ano | findstr 9090找到占用进程 PID,再用taskkill /F /PID <pid>杀掉。如果端口确实没有被占用,检查是否有两个服务端实例在同一个 JVM 里启动;如果是 Spring Boot 环境,检查 management 端口的配置是否重复绑定。排号系统是普通 Java 进程,不涉及这类管理端口,遇到这个错误基本就是重复启动,关掉所有控制台运行实例再重启。

5.2 GUI 卡死:界面不动,到底是死锁还是阻塞读占用了事件线程

现象:点击排号机的“取号”按钮后,整个窗口立刻变白、标题栏显示“未响应”,过了几秒甚至几十秒才恢复。或者点完按钮后,排队人数永远不刷新。

原因:最常见的是 Socket 的readLine()被写到了 ActionListener 回调里。ActionListener 本来就在 EDT 上执行,readLine()是阻塞操作,服务端如果不响应,EDT 就停在这里,所有按钮重绘、标签刷新全部排队等待。我调试时用jstack <jpid>导出线程栈,能看到一条名为main或AWT-EventQueue-0的线程停在socketRead0上,这就是 UI 线程被网络 IO 卡住的最好证据。

解决:把 Socket 的读写全部丢到独立线程池,UI 更新用SwingUtilities.invokeLater切回 EDT。还有一个小细节,JFrame 默认关闭时只是隐藏窗口,如果不调用System.exit(0),后台读线程和线程池会留在内存里,重复启动会产生一堆孤儿连接。要在关闭事件里把线程池shutdownNow(),把 Socket 关闭。

5.3 ORA-12505 和 ORA-12541:监听服务没启动或实例名写错是两码事

现象:JDBC 连接时报ORA-12541: TNS:no listener或者ORA-12505: TNS:listener does not currently know of SID given in connect descriptor。

原因:ORA-12541是 Oracle 监听服务根本不在线,最常见的是 Windows 服务列表里 OracleOraDb11g_home1TNSListener 没启动,或 Linux 上lsnrctl没跑起来。ORA-12505则是监听在线但没这个实例,监听器配的服务名和你的 URL 对不上。Oracle 11g 的默认实例名通常是orcl,但如果你装的是 XE 版本,实例名是xe;如果用的是多租户架构,连接的是 PDB 的XEPDB1,不是 CDB 的XE。

解决:先看监听器状态,在命令行执行lsnrctl status,输出里会列出当前监听的服务名。然后对照 URL:SID 格式jdbc:oracle:thin:@127.0.0.1:1521:xe,服务名格式jdbc:oracle:thin:@//127.0.0.1:1521/XEPDB1,不要混用。还有一种情况是监听器日志积压导致服务异常,网上很多“oracle 10 清理监听日志”的帖子就是这么来的,排查时如果监听状态时好时坏,去监听目录看listener.log文件是不是已经几个 GB 了,清掉之后重启监听即可。

5.4 中文乱码:Socket 流里的“请到3号窗口”变成问号

现象:取号机上显示“您的号码:A012”正常,但叫号屏上显示“???? 3 号窗口”。或者服务端日志里显示的中文变成乱码。

原因:Swing 的 JLabel 显示乱码通常是字体支持问题,但这套系统里的乱码九成是编码不一致。Windows 默认字符集是 GBK,而 Socket 流在 Java 里默认按平台默认字符集生成字节;如果客户端用new InputStreamReader(socket.getInputStream()),服务端用StandardCharsets.UTF_8,两边编码就对不上。取号机到服务端的那段指令如果只传 ASCII 字符还看不出来,一旦广播NOTICE|请到 3 号窗口这种带中文的字段,问题立刻暴露。

解决:在项目里把编码约定锁死为 UTF-8。服务端和客户端的InputStreamReader、OutputStreamWriter都显式传StandardCharsets.UTF_8,不要用不传参数的默认构造。如果流程里走过 Oracle,还要确认数据库字符集是 AL32UTF8,连接 URL 里可以加?useUnicode=true&characterEncoding=UTF-8类似的参数。Oracle 的VARCHAR2在 AL32UTF8 下存中文一点问题没有,怕的只是应用层往数据库写的时候就已经是乱码了。

6. 联调验证与断线重连:让排号系统从 demo 变成能放营业厅跑

代码都写完,接下来要解决的是“怎么证明它真的能用”以及“营业厅里网络不稳怎么办”。这部分我习惯先做一次完整的本地联调,再给客户端加上心跳保活和断线重连,否则排号机在角落里放了十几天,打开却发现连不上服务端,场面会很难看。

验证步骤不复杂:先启动服务端,再启动一个排号机客户端和一个叫号屏客户端。在排号机上按“现金业务”,看叫号屏有没有弹出“请到对应窗口办理”的号码;再到柜台端发送 CALL_NUM,确认叫号屏号码变化、Oracle 里对应行状态从 0 改成 1。这一步能同时验证 Socket 通信、GUI 刷新和 JDBC 读写三条链路。我在本地一般开三个 JVM 实例,服务端默认 9090 端口,两个客户端不传端口参数时也指向 9090,这样一台机器就能模拟多端。

断线重连是排号系统上线前必须处理的点。营业厅的取号机不一定会重启,但交换机和网络可能重启,Socket 连接在 TCP 层要很久才能感知断开,这时候服务端还认为客户端在线,广播发不过去。我通常给客户端加一个心跳线程,每 5 秒发一条HEART_BEAT|CLIENT_ID,服务端记录最后心跳时间,超过 15 秒没收到就主动关闭该连接。客户端读线程捕获到 IOException 后,进入重连循环,每 3 秒尝试重新连接一次,重连成功后重新注册设备类型。

重连代码里有个细节值得注意:重连成功后旧的 Socket 要彻底关闭,否则控制台上会堆满关闭时报错。客户端这边由重连线程负责建立新连接,然后重新启动读线程,不要让读线程自己负责重连,职责分离能少很多隐蔽竞态。服务端的广播推送也要加一个防御:向某个 Socket 写数据时如果抛异常,先把该连接移出在线集合,避免下一次广播重复对失效连接执行写操作。

最后分享一下我的习惯做法:做这类基于 Java + Socket + Java GUI 的系统,我按“服务端主流程 → 协议Agent → 两个GUI端 → 数据库 → 断线重连”的顺序实现,每一层都有独立可测的入口,出问题时能快速定位是网络层、界面层还是数据层。你在复现这套排号系统时,建议先砍掉 Oracle,把数据和广播逻辑放在服务端内存里跑通,再一步步接数据库加心跳,这样可以避免一开始就被 ORA-12505 这类问题挡住。希望这篇笔记里的参数和踩坑记录能帮你把系统一次跑起来,以后再做 Socket 加 GUI 的东西,也能少几件“明明按照教程写的但就是不行”的事。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询