ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

排队叫号系统源码解析:Socket通信与SQL Server事务实践

排队叫号系统源码解析:Socket通信与SQL Server事务实践 简介面向C#初学者和排队机系统设计者这份源代码完整呈现了一套可实际投产的排队叫号系统。系统覆盖取号、呼叫、窗口分配、候诊屏同步显示等核心业务流程并封装了数据库操作辅助类与常见硬件设备对接逻辑可帮助读者理解工业级叫号项目的整体架构与实现思路。项目已在真实客户现场稳定运行多年整体代码几乎无保留界面与业务规则均可自行修改扩展尤其适合正在为排队机方案发愁的开发者做参考。压缩包为rar格式共249个文件大小仅1.29MB其中以172个C#源码文件为主另有44个resx资源文件、9个项目工程文件、2个rdlc报表文件以及设置、图标、用户控件等辅助文件模块划分清晰便于按功能分段研读与二次开发。目前已有1617人学习下载对希望快速上手WinForm数据库开发、或需要完整叫号系统设计样例的读者而言这份稳定可靠的实战代码能节省大量从零搭建的时间。1. 排队机叫号一个把大厅广播、小票打印和数据库串起来的实时队列系统在医院分诊台取号小票打出来消化内科 A012 前面还有 5 人到银行网点拿身份证一刷柜台呼叫下一位大屏同步闪出号码和窗口号。这套熟悉得不能再熟悉的流程背后就是一个独立的排队机叫号系统。这份源码不是某个大厂中间件而是一套完整的 C/S 架构工程覆盖了取号、叫号、过号、重呼、窗口分配、大屏展示、语音播报、业务量统计这些叫号系统该有的全部环节。适合三类人接政企外包需要做叫号交付的开发者、想在自己的业务系统里集成排队模块的技术负责人、以及正在学 Socket 通信和 SQL Server 事务开发的初学者。技术选型偏传统但架构干净核心就一条 TCP 消息链路加一张不断变状态的排队表值得拆开看一遍。2. 系统拆解四端一中心一个 TCP 消息把大厅串起来2.1 整体架构排队叫号本质上是一个状态机被消息推着走很多人第一次接触叫号系统会以为难点在大屏 UI 或者语音播报其实拆开源码看核心就是三个角色之间的消息流转取号端负责发号窗口端负责叫号大屏端负责展示和播报而服务端是唯一能碰数据库的角色。整个系统采用 Socket 长连接组网服务端作为消息中心四个程序通过网络连接在一起。顾客在取号机上按下个人业务取号端把请求发给服务端服务端写库生成号码再返回给取号端打印小票柜员在窗口端点呼叫下一个窗口端把指令发给服务端服务端更新数据库里的排队状态然后主动把叫号通知推给大屏端和语音端。数据库在这一整套流程里只跟服务端打交道其他三个端都不直接读写数据库。这种服务端收口数据库的设计好处很明显排队状态的任何变化都有唯一的数据入口不会出现窗口端和大屏端各改各的导致数据不一致。缺点是服务端一旦挂了整个大厅叫号就瘫掉所以源码里服务端也做了一些断线重连和恢复处理后面的避坑章节会细说。2.2 工程目录一次下载下来你会看到的项目结构源码解决方案是标准的 Visual Studio 结构五个工程按职责切分得比较清楚QueueSystem/ ├── QueueServer.Svc/ 服务端程序含状态机核心逻辑 │ ├── Core/ 队列调度、过号扫描、恢复逻辑 │ ├── DB/ SQL Server 数据访问层 │ └── SocketService/ TCP 监听、消息解析、客户端会话管理 ├── QueueClient.Terminal/ 取号端程序触摸屏取号机界面 ├── QueueClient.Window/ 窗口端程序柜员叫号操作界面 ├── QueueClient.Screen/ 大屏端程序LCD/LED 展示与语音播报 ├── Queue.Common/ 公共类库消息协议封装、实体模型、日志组件 ├── DbScript/ │ └── QueueDB.sql 数据库初始化脚本建库建表基础数据 └── QueueSystem.sln服务端工程里最重要的目录是Core和SocketService一个管队列怎么排队一个管消息怎么收发。Queue.Common是所有工程都引用的公共类库消息帧的封包拆包、实体对象的序列化都在这。四个端共享同一套协议类这是通信不跑偏的基础。每个客户端工程里都有对应的app.config或ApplicationConfig.config大屏端和窗口端配置服务端 IP 和端口取号端还可以额外配置小票打印机串口号。这套命名和结构对做过 C/S 项目的人来说基本零门槛按图索骥能找到所有入口。2.3 消息协议设计一条命令字 长度 JSON的帧协议四端之间传的不是 XML 也不是对象流而是一种非常朴素的自定义文本协议消息帧格式是命令字|消息体长度|消息体消息体是一段 JSON 字符串。比如服务端向大屏推一条叫号通知实际在网络上传输的内容是这样SVR_CALL_NOTIFY|112|{QueueNO:A012,WindowNO:03,CallTime:2024-06-25 09:30:12}帧里的三个字段用竖线分割命令字标识消息类型长度告诉接收方要读满多少字节才算一条完整消息JSON 承载具体业务数据。长度字段很关键因为 TCP 是流式传输接收方如果不按长度去凑够一条消息就会出现一次 Read 拿到半条消息或者两条消息粘在一起的情况。Queue.Common里对应的封包拆包逻辑是这个样子/// summary把一个命令字和消息体打包成一帧网络消息/summary public static byte[] Pack(string cmd, object body) { // 把消息体序列化为 JSON 字符串 string json JsonConvert.SerializeObject(body); // 拼接成 命令字|长度|消息体 的帧格式 string frame ${cmd}|{json.Length}|{json}; // 转成 UTF-8 字节返回给 Socket 发送 return Encoding.UTF8.GetBytes(frame); } /// summary从接收缓冲区里拆出一条完整的消息返回命令字和反序列化后的原始 JSON/summary public static bool TryParse(byte[] buffer, ref int offset, out string cmd, out string json) { cmd null; json null; // 先把缓冲区转成字符串找第一个竖线分隔符截出命令字 string text Encoding.UTF8.GetString(buffer, offset, buffer.Length - offset); int firstSep text.IndexOf(|); if (firstSep 0) return false; cmd text.Substring(0, firstSep); // 再截出长度字段用 int.TryParse 解析出消息体应该有多少个字符 int secondSep text.IndexOf(|, firstSep 1); int length int.Parse(text.Substring(firstSep 1, secondSep - firstSep - 1)); // 如果缓冲区里剩余字节数比消息体长度还少说明消息还没收全返回 false if (text.Length - secondSep - 1 length) return false; // 按长度字段截出完整 JSON 消息体并更新偏移量 json text.Substring(secondSep 1, length); offset secondSep 1 length; return true; }这里的逻辑说明要展开两点第一Pack里把 JSON 序列化和帧拼接做了合并调用方只传命令字和业务对象不用关心转义问题第二TryParse是增量解析服务端每个 Socket 会话都持有一个缓冲区收到新数据就丢进这个解析器能拆出完整消息才进入业务分发。判断消息没收全的那个分支最容易写错长度字段必须按 JSON 字符串的字符数计算如果按Encoding.UTF8.GetBytes(json).Length算字节数遇到中文就全乱套了这也是这份源码里一个隐藏的易错点。命令字在Queue.Common里定义成了常量类稍大一点的系统建议改成枚举但源码用字符串常量也没问题。常用的命令字有TERM_TAKE_NUMBER取号、WIN_CALL_NEXT叫下一位、WIN_FINISH办结、SVR_CALL_NOTIFY叫号广播、SVR_SCREEN_SNAP大屏队列快照和HBT_HEARTBEAT心跳。3. 数据库设计排队表里的每一条记录都在等状态变化3.1 四张核心表队列、窗口、业务类型和操作日志打开DbScript/QueueDB.sql往后面翻会发现整个排队的业务数据落在四张表上T_BusinessType存业务类型个人业务、对公业务、挂失销户T_WindowInfo存窗口基础信息T_QueueInfo是排队明细主表T_QueueLog记录每一次操作轨迹。其中T_QueueInfo.Status字段是整个系统的灵魂它用四个数字表示一条排队记录的完整生命周期-- 排队明细主表一条记录代表一张排队小票 CREATE TABLE T_QueueInfo ( Id INT IDENTITY(1,1) PRIMARY KEY, -- 自增主键 QueueNO VARCHAR(10) NOT NULL, -- 叫号号码例如 A012 TokenNO VARCHAR(20) NOT NULL, -- 小票流水号用于取票凭证和追溯 BusinessTypeId INT NOT NULL, -- 关联业务类型表 WindowId INT NULL, -- 当前服务的窗口ID未叫号时为NULL Status TINYINT NOT NULL DEFAULT 0, -- 0等待中 1已叫号 2已过号 3已完成 4已取消 CreateTime DATETIME NOT NULL DEFAULT GETDATE(), -- 取号时间 CallTime DATETIME NULL, -- 首次叫号时间过号判断依赖这个字段 FinishTime DATETIME NULL, -- 办结或取消时间 SourceType TINYINT NOT NULL DEFAULT 0 -- 0现场取号 1预约签到扩展用 );这张表的状态流转可以直观理解为取号插入一条Status0的等待记录窗口叫号把0改成1并写入CallTime顾客超时未到则变成2办结改成3。QueueNO里的字母前缀来自业务类型表的Prefix字段比如 A 开头的个人业务和 B 开头的对公业务同类业务内部按三位流水号递增。T_BusinessType除了存业务名称和前缀还有一个IsPriority字段表示这个业务类型是否允许优先叫号。T_QueueLog则简单记录操作的流水谁在什么时间对哪条队列做了什么操作用于事后排查这个号到底被谁叫过。数据模型整体说不上精巧但非常符合叫号业务的实际运营逻辑。3.2 取号一个串行化事务完成号码生成取号动作的最终落点其实就是一段查询最大序号 1再插一条记录的代码。但就是这么简单的逻辑并发做不好就会出现两个顾客取到相同号码。服务端Core/TakeNumberHandler.cs里的实现用了Serializable隔离级别的事务把取最大值和插入记录两件事锁在同一条链路上执行/// summary取号核心逻辑生成排队号码并写入数据库/summary public TicketResult TakeNumber(int businessTypeId) { using (SqlConnection conn DB.CreateConnection()) using (SqlTransaction tx conn.BeginTransaction(IsolationLevel.Serializable)) { // 在事务内查询该业务类型当天已存在的最大序号 // 加上 UPDLOCK让两个并发取号请求在这里排队等待而不是同时读到同一个值 SqlCommand cmd new SqlCommand( SELECT ISNULL(MAX(Seq), 0) AS MaxSeq FROM T_QueueInfo WITH (UPDLOCK) WHERE BusinessTypeId bt AND CreateTime today, conn, tx); cmd.Parameters.AddWithValue(bt, businessTypeId); cmd.Parameters.AddWithValue(today, DateTime.Today); int maxSeq (int)cmd.ExecuteScalar(); int newSeq maxSeq 1; // 组合叫号号码业务前缀 三位流水号例如 A012 string prefix GetPrefix(businessTypeId); string queueNo ${prefix}{newSeq:D3}; // 小票流水号用时间戳生成精确到毫秒用于后续追溯和预约核销 string tokenNo DateTime.Now.ToString(yyyyMMddHHmmssfff); // 插入排队记录Status 默认 0 表示等待中 SqlCommand ins new SqlCommand( INSERT INTO T_QueueInfo(QueueNO, TokenNO, BusinessTypeId, Status, CreateTime) VALUES(qno, tno, bt, 0, now), conn, tx); ins.Parameters.AddWithValue(qno, queueNo); ins.Parameters.AddWithValue(tno, tokenNo); ins.Parameters.AddWithValue(bt, businessTypeId); ins.Parameters.AddWithValue(now, DateTime.Now); ins.ExecuteNonQuery(); tx.Commit(); // 返回号码和当前等待人数给取号端小票打印使用 return new TicketResult(queueNo, GetWaitCount(businessTypeId)); } }这段代码里有两个值得抄走的细节。第一个是WITH (UPDLOCK)它让并发取号的多个事务在MAX(Seq)查询处排队避免两个请求同时读到同一个maxSeq后各自 1 导致号码重复。第二个是CreateTime today这个条件它保证了流水号只按当天累计第二天取号会重新从 1 开始编号这是叫号业务里默认的业务规则。GetWaitCount在事务内部执行也很重要它查询当前还在等待中的记录数这个数字会打印在小票上告诉顾客前面还有 X 人。如果把它放到事务外面取号刚结束还没提交事务时等待人数就会少算自己这一个虽然差别不大但总归是数据一致性问题。3.3 叫号、过号与重呼状态机是怎么转起来的窗口端按下呼叫下一个服务端干的活本质上是把队列里最早一条Status0的记录改成1然后广播给大屏。源码里CallNext的实现没有用复杂的存储过程而是靠SELECT UPDLOCK先把候选行锁住再更新/// summary窗口叫号从等待队列里取出最早的一条记录更新状态并推送给大屏/summary public CallResult CallNext(int windowId, int businessTypeId) { string queueNo null; using (SqlConnection conn DB.CreateConnection()) using (SqlTransaction tx conn.BeginTransaction()) { // 锁定最早一条等待中的记录READPAST 让并发的叫号请求直接跳过被锁的行避免互相阻塞 SqlCommand select new SqlCommand( SELECT TOP 1 QueueNO FROM T_QueueInfo WITH (UPDLOCK, READPAST) WHERE Status 0 AND BusinessTypeId bt ORDER BY CreateTime, conn, tx); select.Parameters.AddWithValue(bt, businessTypeId); queueNo select.ExecuteScalar() as string; if (queueNo null) { tx.Rollback(); return new CallResult { Success false, Message 当前无等待号码 }; } // 把这条记录状态置为“已叫号”并登记窗口和叫号时间 SqlCommand update new SqlCommand( UPDATE T_QueueInfo SET Status 1, WindowId win, CallTime now WHERE QueueNO qno AND Status 0, conn, tx); update.Parameters.AddWithValue(win, windowId); update.Parameters.AddWithValue(qno, queueNo); update.Parameters.AddWithValue(now, DateTime.Now); update.ExecuteNonQuery(); tx.Commit(); } // 通知大屏端和语音端显示叫号信息这一步必须放在事务提交之后 ScreenBus.Broadcast(SVR_CALL_NOTIFY, new { QueueNO queueNo, WindowNO GetWindowNo(windowId) }); return new CallResult { Success true, QueueNo queueNo }; }对READPAST这个提示需要多做一句说明多个窗口同时按呼叫下一个时第一个事务锁住了TOP 1的记录第二个叫号事务如果不加READPAST就会一直等第一个事务提交才能继续扫描导致窗口 2 的叫号按钮转圈。加了这个提示后第二个事务直接跳过被锁住的行去取下一条等待记录两个窗口可以同时各叫各的号互不干扰。这是多窗口叫号场景下最容易忽略的一个病根。过号逻辑则是挂在服务端的一个后台定时器上每隔 30 秒扫一次把已经叫号但超过 3 分钟没来柜台办理的号码置为2已过号并同步推一条消息给大屏让它从正在叫号区域移除-- 过号扫描把叫号后超过 OverTime 秒仍未办理的号码置为过号状态 UPDATE T_QueueInfo SET Status 2 WHERE Status 1 AND DATEDIFF(SECOND, CallTime, GETDATE()) 180重呼的逻辑则简单得多窗口端对一条Status2的过号记录点重呼服务端把它从2改回1并重新广播一次叫号通知。状态机总共就四条边等待到叫号、叫号到完成、叫号到过号、过号到重呼回叫号理解这四条边就能看懂整个系统的流转逻辑。4. 部署运行从建库到听到第一声叫号4.1 初始化数据库一条命令把库建好把源码拉到本地后第一步不是开 Visual Studio 编译而是先把数据库准备好。打开DbScript目录下的QueueDB.sql里面已经写好了建库、建表、插入基础业务类型和窗口数据的完整脚本。用 SQL Server Management Studio 连上本地数据库实例直接执行这个脚本即可脚本末尾会插入常见的业务类型数据和 1 到 8 号窗口数据。需要注意的是脚本使用的 SQL 语法只依赖基础功能2008 R2 及以上版本的 SQL Server 都能跑。执行完成后可以验证一下四张表是否都建出来了-- 检查核心表是否创建成功 SELECT name FROM sys.tables WHERE name IN (T_BusinessType,T_WindowInfo,T_QueueInfo,T_QueueLog);成功后T_BusinessType里应该能看到个人业务 A、对公业务 B两条初始数据。如果想改业务名称或加前缀直接在这里改就行但注意前缀长度最好保持 1 个字母否则QueueNO的字段长度需要相应调整。4.2 配置文件与启动顺序数据库就绪后按顺序配置四个程序的连接信息。服务端的app.config里有两个关键配置段connectionStrings里的QueueDB连接字符串以及appSettings里的监听端口和过号时间configuration connectionStrings !-- 换成实际部署环境的数据库地址、账号密码 -- add nameQueueDB connectionStringServer127.0.0.1;DatabaseQueueSys;User Idsa;Password123456;Poolingtrue;Min Pool Size5/ /connectionStrings appSettings !-- TCP 监听端口所有客户端都通过这个端口连接服务端 -- add keyListenPort value9100 / !-- 过号超时时间单位秒超过这个时间未办理自动置为过号 -- add keyOverTime value180 / /appSettings /configuration大屏端、窗口端、取号端的配置则简单很多只需要告诉程序服务端在哪appSettings !-- 服务端所在机器的 IP局域网部署时填服务端电脑的内网地址 -- add keyServerIP value127.0.0.1 / add keyServerPort value9100 / !-- 窗口端专属配置标识当前程序是几号窗口 -- add keyWindowId value1 / add keyWindowName value综合窗口1号 / /appSettings启动顺序有讲究先启动服务端再启动大屏端和窗口端最后启动取号端。服务端启动后监听 9100 端口各客户端启动时会发起 Socket 连接并注册自己的身份。窗口端启动时会自动把窗口状态置为空闲大屏端启动后会向服务端索要一次当前队列快照把已经叫号但还没办结的号码恢复到屏幕上显示出来。如果反过来先启动客户端再启动服务端客户端会进入断线重试状态每 3 秒尝试重连一次倒也能自动恢复。4.3 验收流程模拟一次完整的取号叫号全部启动完成后可以用一个最简单的方式验证整个系统是否跑通手动造一条取号请求。如果没有实体取号机和小票打印机可以直接打开取号端程序界面上会显示业务类型按钮点击个人业务按钮程序会向服务端发送TERM_TAKE_NUMBER请求服务端返回新生成的号码。此时观察两点取号端界面应显示您的号码是 A001前面还有 0 人服务端的日志窗口或日志文件里应打印一条插入排队记录的操作记录。然后在窗口端点击呼叫下一个服务端日志会打印A001 分配至窗口 1大屏端界面应随即显示请 A001 到 1 号窗口语音模块同时播报一遍。最后在窗口端点击办结服务端把记录状态置为3大屏端把这条叫号信息从显示区移除。这四步走通整个系统的核心链路就验证完成了。如果在大屏端听不到语音播报先检查系统是否安装了 TTS 语音包源码里语音模块用的是 Windows 自带的语音合成接口精简版系统可能缺失语音引擎但不影响业务链路。这一步很多人容易误判为代码问题其实环境问题占多数。5. 避坑指南排队机项目部署里最常见的五个翻车现场5.1 窗口端连不上服务端提示远程主机强迫关闭了一个现有的连接这个现象多发生在服务端程序跑得好好的、窗口端却反复连接失败的时候。最常见的原因就两个一是服务端只监听了本机回环地址而不是0.0.0.0导致局域网内其他机器根本连不进来二是 Windows 防火墙默认拦截了 9100 端口的入站连接。排查时先在窗口端所在机器用telnet 服务端IP 9100测一下端口通不通不通就去服务端执行一条放行命令netsh advfirewall firewall add rule nameQueueServer dirin actionallow protocolTCP localport9100另外一种隐蔽的原因是服务端监听了 IPV6 地址而客户端用 IPv4 解析两端没有配到同一个协议栈上。处理办法是确保服务端监听时IPAddress.Any而不是显式指定具体 IP。5.2 叫号后大屏要等两秒才刷新像卡住了一样如果你接手时大屏端还是轮询数据库的实现那这个现象几乎是必现的。大屏端每 2 秒查一次T_QueueInfo里有没有新叫号的记录查到才刷新界面体验就是叫号慢半拍。其实窗口端点了叫号数据一毫秒内就写进数据库了慢的是轮询周期。解决思路是把大屏端改成 Socket 推送模式。服务端在CallNext和重呼完成之后直接把SVR_CALL_NOTIFY主动推送给所有已注册的 Screen 端大屏收到消息立刻刷新延迟从轮询周期缩短到网络时延。这个改动需要动服务端的ScreenBus.Broadcast方法和客户端的消息接收处理如果手头这份源码已经是大屏主动推送的版本就直接跳过这条。5.3 两个顾客同时取号拿到了同一个号码这在数据库开发里属于并发经典的翻车现场。取号逻辑如果用先查询MAX(Seq)再在代码里 1最后 INSERT的老写法两个请求并发执行时可能同时读到maxSeq5各自插一条A006进库。事务内查询不加锁或者隔离级别设成READ COMMITTED都挡不住这个问题。正确的修法是给取号逻辑的事务加上IsolationLevel.Serializable或者可重复读并且在查询语句里带上WITH (UPDLOCK)。另一种方案是给T_QueueInfo的(BusinessTypeId, QueueNO)建唯一索引插入时捕获重复键冲突然后重试一次。两者可以共存但事务加锁是更符合叫号业务的做法重试逻辑只能算后悔药。5.4 系统跑了两天数据库连接池爆满服务端开始报连接池已达到最大大小这个坑大多不是源码本身的问题而是二次开发时到处new SqlConnection忘关导致的。叫号这类服务端程序长时间运行任何一个请求路径上泄漏一条连接半天就能把默认 100 条连接池耗尽。排查方法很直接在 SQL Server 里执行SELECT * FROM sys.dm_exec_sessions WHERE login_namesa看会话数是不是只增不减是的话就是代码里没用using释放连接。建议重构数据访问层统一由一个DBHelper管理连接创建和关闭每次取连接都走using (var conn DB.CreateConnection())的写法确保任何异常路径都不会泄漏连接。5.5 服务端升级重启后队列里已叫号的号码全部卡死服务端重启一小会儿重新连上的大屏上那些叫到一半的号码状态全是已叫号但顾客早就被晾在一边了。原因是队列状态存在数据库里但叫号通知只存在于大屏的内存中服务端一重启没有哪块逻辑去接管这些半途状态。解决办法是给服务端写一个启动恢复逻辑进程起来后先把所有Status1且CallTime停留在 30 分钟之前未办结的记录重置为0或直接置为过号2然后向所有大屏端广播一次SVR_SCREEN_SNAP队列快照让屏幕恢复到正确的队列状态。这段逻辑要放在服务端开始接收新请求之前执行否则恢复的同时又来新叫号状态又会乱掉。6. 二次开发给叫号系统加预约取号和微信提醒如果让我在这个项目上做第一个改造我大概率会先做预约签到这个功能。现在银行网点和医院分诊台普遍支持线上预约后到现场取号核心诉求是让预约过的顾客到了现场能优先叫号。落在代码上需要改三个点T_QueueInfo表增加SourceType字段区分预约和现场取号端加一个输入预约流水号签到的入口以及改叫号调度算法让预约号优先出队。/// summary预约优先的叫号调度先取预约等待号再取现场号/summary private string PickNextQueueNo(int businessTypeId) { // 优先把 SourceType1 且状态为等待中的预约号叫出去 string next GetNextBySource(businessTypeId, sourceType: 1); // 预约队列取空了才轮到现场号 if (next null) next GetNextBySource(businessTypeId, sourceType: 0); return next; }微信提醒的改造相对独立可以在服务端加一个后台定时器扫描Status1的已叫号记录超过一分钟就调一次第三方 HTTP 接口把您的号码已叫号请前往 3 号窗口推给公众号模板消息。这一块完全不碰现有业务链路适合作为练习读源码的切入点。这套源码我断断续续拆过两遍第一遍只顾着把取号逻辑跑通直到现场被两个窗口同时叫号互相卡死的问题折磨了一下午才回头把UPDLOCK, READPAST的语义吃透。从那以后我每接到一个消息分发类项目都会强制自己先画出状态流转图和消息时序图再动手把哪个事务该锁住哪一行、哪条广播该放在提交之后先想明白。看似不起眼的排队叫号里头的并发和数据一致性考量一点不比高并发秒杀系统少希望这份拆解能帮你在跑通和改造的全程少踩几个坑。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表