ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

排队叫号系统源码解析: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预约签到(扩展用) );

这张表的状态流转可以直观理解为:取号插入一条Status=0的等待记录,窗口叫号把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 叫号、过号与重呼:状态机是怎么转起来的

窗口端按下"呼叫下一个",服务端干的活本质上是把队列里最早一条Status=0的记录改成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

重呼的逻辑则简单得多:窗口端对一条Status=2的过号记录点"重呼",服务端把它从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 name="QueueDB" connectionString="Server=127.0.0.1;Database=QueueSys;User Id=sa;Password=123456;Pooling=true;Min Pool Size=5"/> </connectionStrings> <appSettings> <!-- TCP 监听端口,所有客户端都通过这个端口连接服务端 --> <add key="ListenPort" value="9100" /> <!-- 过号超时时间,单位秒,超过这个时间未办理自动置为过号 --> <add key="OverTime" value="180" /> </appSettings> </configuration>

大屏端、窗口端、取号端的配置则简单很多,只需要告诉程序服务端在哪:

<appSettings> <!-- 服务端所在机器的 IP,局域网部署时填服务端电脑的内网地址 --> <add key="ServerIP" value="127.0.0.1" /> <add key="ServerPort" value="9100" /> <!-- 窗口端专属配置,标识当前程序是几号窗口 --> <add key="WindowId" value="1" /> <add key="WindowName" 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 name=QueueServer dir=in action=allow protocol=TCP localport=9100

另外一种隐蔽的原因是服务端监听了 IPV6 地址而客户端用 IPv4 解析,两端没有配到同一个协议栈上。处理办法是确保服务端监听时IPAddress.Any,而不是显式指定具体 IP。

5.2 叫号后大屏要等两秒才刷新,像卡住了一样

如果你接手时大屏端还是轮询数据库的实现,那这个现象几乎是必现的。大屏端每 2 秒查一次T_QueueInfo里有没有新叫号的记录,查到才刷新界面,体验就是"叫号慢半拍"。其实窗口端点了叫号,数据一毫秒内就写进数据库了,慢的是轮询周期。

解决思路是把大屏端改成 Socket 推送模式。服务端在CallNext和重呼完成之后,直接把SVR_CALL_NOTIFY主动推送给所有已注册的 Screen 端,大屏收到消息立刻刷新,延迟从轮询周期缩短到网络时延。这个改动需要动服务端的ScreenBus.Broadcast方法和客户端的消息接收处理,如果手头这份源码已经是大屏主动推送的版本,就直接跳过这条。

5.3 两个顾客同时取号,拿到了同一个号码

这在数据库开发里属于并发经典的翻车现场。取号逻辑如果用"先查询MAX(Seq),再在代码里 +1,最后 INSERT"的老写法,两个请求并发执行时可能同时读到maxSeq=5,各自插一条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_name='sa'看会话数是不是只增不减,是的话就是代码里没用using释放连接。建议重构数据访问层,统一由一个DBHelper管理连接创建和关闭,每次取连接都走using (var conn = DB.CreateConnection())的写法,确保任何异常路径都不会泄漏连接。

5.5 服务端升级重启后,队列里"已叫号"的号码全部卡死

服务端重启一小会儿,重新连上的大屏上那些叫到一半的号码状态全是"已叫号",但顾客早就被晾在一边了。原因是队列状态存在数据库里,但叫号通知只存在于大屏的内存中,服务端一重启,没有哪块逻辑去接管这些半途状态。

解决办法是给服务端写一个启动恢复逻辑:进程起来后,先把所有Status=1且CallTime停留在 30 分钟之前未办结的记录重置为0或直接置为过号2,然后向所有大屏端广播一次SVR_SCREEN_SNAP队列快照,让屏幕恢复到正确的队列状态。这段逻辑要放在服务端开始接收新请求之前执行,否则恢复的同时又来新叫号,状态又会乱掉。

6. 二次开发:给叫号系统加预约取号和微信提醒

如果让我在这个项目上做第一个改造,我大概率会先做"预约签到"这个功能。现在银行网点和医院分诊台普遍支持线上预约后到现场取号,核心诉求是让预约过的顾客到了现场能优先叫号。落在代码上需要改三个点:T_QueueInfo表增加SourceType字段区分预约和现场,取号端加一个"输入预约流水号签到"的入口,以及改叫号调度算法,让预约号优先出队。

/// <summary>预约优先的叫号调度:先取预约等待号,再取现场号</summary> private string PickNextQueueNo(int businessTypeId) { // 优先把 SourceType=1 且状态为等待中的预约号叫出去 string next = GetNextBySource(businessTypeId, sourceType: 1); // 预约队列取空了才轮到现场号 if (next == null) next = GetNextBySource(businessTypeId, sourceType: 0); return next; }

微信提醒的改造相对独立,可以在服务端加一个后台定时器,扫描Status=1的已叫号记录,超过一分钟就调一次第三方 HTTP 接口把"您的号码已叫号,请前往 3 号窗口"推给公众号模板消息。这一块完全不碰现有业务链路,适合作为练习读源码的切入点。

这套源码我断断续续拆过两遍,第一遍只顾着把取号逻辑跑通,直到现场被"两个窗口同时叫号互相卡死"的问题折磨了一下午,才回头把UPDLOCK, READPAST的语义吃透。从那以后,我每接到一个消息分发类项目,都会强制自己先画出状态流转图和消息时序图再动手,把"哪个事务该锁住哪一行、哪条广播该放在提交之后"先想明白。看似不起眼的排队叫号,里头的并发和数据一致性考量一点不比高并发秒杀系统少,希望这份拆解能帮你在跑通和改造的全程少踩几个坑。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进