news 2026/10/28 6:09:13

排队叫号系统源码拆解:Socket长连接与SQL Server事务实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
排队叫号系统源码拆解:Socket长连接与SQL Server事务实战

1. 从一次大厅现场的尴尬说起:为什么我要拆解这套排队叫号系统

几年前我第一次接触排队叫号类的项目,是在一个业务大厅里做现场支持。当时的情况现在想起来还挺尴尬:窗口工作人员点了"下一位",喇叭没响,屏幕没跳,排队的人坐在椅子上互相看着,谁也不知道该谁上。重启程序之后数据又对不上,前面已经叫过的号重新冒出来,后面的人开始抱怨插队。那次问题折腾了大半天,最后定位下来,一半是通信层的心跳没做,一半是叫号写库的时候事务边界切错了地方。

这件事之后我对"排队叫号系统"这类看着简单的业务系统就多了一层敬畏。它的业务逻辑确实不复杂:取号、排队、叫号、办理、过号、评价,就这么几个动作。但它把几个最容易出问题的技术点全凑齐了——长连接通信、并发抢号、事务一致性、多端状态同步。任何一环没处理好,用户感受到的就是"乱"。

所以这篇内容我打算基于一套典型的排队叫号系统源码,把里面两个最核心的技术支柱掰开讲:一个是Socket 通信,负责大厅屏幕、窗口终端、取号机、后台管理之间的实时消息传递;另一个是SQL Server 事务,负责号码发放、状态流转、数据一致性。中间会穿插大量我在实际项目里踩过的坑和具体处理办法。

适合谁看?如果你手上有类似的叫号、排队、窗口调度类项目,或者你正在学 Socket 长连接与数据库事务怎么在真实业务里配合,这篇应该能省你不少时间。如果你只是想搞清楚"为什么叫号系统有时候会乱",那更值得往下看,因为乱的原因基本都在这两块里。代码我尽量给到能直接对照改的程度,原理部分也会说透,不搞只贴代码不讲道理那一套。

2. Socket 通信层:叫号系统里真正的"神经系统"

2.1 为什么这套系统必须用长连接,而不是轮询

先讲一个选型问题,因为很多人第一反应是"我用 HTTP 轮询不就行了"。我实测过,一个中等规模的大厅,假设有 30 个窗口终端、4 块综合屏、2 台取号机,如果每个终端每 1 秒轮询一次服务端,那就是每秒 36 次请求,一次请求哪怕只花 20 毫秒,累积起来也是不小的开销,更麻烦的是延迟不可控。叫号这个动作对实时性是有硬要求的:工作人员按下"呼叫下一位",综合屏最好在半秒内刷新,语音最好立刻播报,超过 1 秒用户就会觉得"系统卡了",超过 3 秒就会有人跑去问工作人员。

长连接的价值就在这:服务端一旦产生"叫号事件",可以立刻推给所有相关终端,不需要等下一次轮询周期。这个差异在大厅忙的时候特别明显。所以我在这套系统里采用的是常驻 Socket 连接,服务端维护一张"在线终端表",事件产生后按角色定向广播。

具体到实现,源码里用的是一套基于 TCP 的自定义协议,而不是直接上 WebSocket。这里面有个取舍值得说清楚:

方案实时性实现复杂度适用场景
HTTP 短轮询差,取决于周期最低状态变化极少的展示类页面
WebSocket好中等,需处理握手与代理兼容浏览器端为主的大厅屏
原生 TCP Socket最好较高,需自定义粘包处理与心跳窗口客户端、叫号器、语音播报端

如果终端是浏览器(比如用大屏网页做综合屏),WebSocket 是更省事的选择,因为它天然穿透大部分中间层。但如果终端是桌面客户端、一体机、语音播报盒子,原生 TCP 更稳,可控性也更强。这套源码面向的是后一种场景,所以走的是自定义 TCP 协议。

2.2 自定义协议怎么定:一个长度头解决粘包问题

TCP 是字节流,没有消息边界。你发两条消息,对面可能一次收到,也可能分三次收到。这个特性在叫号系统里会造成非常诡异的 bug:屏幕上偶尔显示半条号码,或者一次冒出两条重复消息。这就是典型的粘包与半包。

源码里的处理办法很朴素但有效:固定 8 字节消息头,前 4 字节是消息总长度,后 4 字节是消息类型,后面跟消息体。接收端先读 8 字节头,解析出长度,再循环读到够长度为止,不够就等下一批数据。

消息结构: [4字节 总长度][4字节 消息类型][N字节 消息体(JSON)]

我按这个思路给出关键接收逻辑,用的 C# 写法,因为这套源码是 .NET 技术栈:

private byte[] _buffer = new byte[8192]; private int _buffered = 0; private void OnReceive(byte[] data, int length) { // 追加到缓冲区 Buffer.BlockCopy(data, 0, _buffer, _buffered, length); _buffered += length; while (_buffered >= 8) { int total = BitConverter.ToInt32(_buffer, 0); if (total <= 8 || total > 1024 * 64) { // 长度非法,说明流已经错位,直接断开重连 CloseAndReconnect(); return; } if (_buffered < total) break; // 半包,等后续数据 int type = BitConverter.ToInt32(_buffer, 4); string body = Encoding.UTF8.GetString(_buffer, 8, total - 8); Dispatch(type, body); // 把处理过的数据移出缓冲区 int remain = _buffered - total; Buffer.BlockCopy(_buffer, total, _buffer, 0, remain); _buffered = remain; } }

这段代码有三个细节值得单独拎出来说。第一,长度上限一定要校验(我写的是 64KB),否则一旦流错位,读到的"长度"可能是个天文数字,程序会直接去分配超大内存,最终把进程拖死。第二,遇到非法长度不要试图"纠正",直接断开重连是最稳的做法,因为流一旦错位,后面所有消息都不可信。第三,Buffer.BlockCopy做缓冲区搬移在小消息量下没问题,如果消息非常频繁,可以换成环形缓冲区减少内存拷贝,不过叫号系统的消息量其实很有限,一个大厅一分钟也就几十条,没必要提前优化。

提示:调试阶段一定要专门测一次"消息分段到达"。可以用工具手动把一个包拆成两次发送,验证接收端不会把半条消息当完整消息处理。这个测试能提前暴露 80% 的通信类线上问题。

2.3 心跳、断线重连与身份注册:长连接的三个必备件

长连接不是连上就完事了。实际部署里,大厅的网络环境经常有波动,交换机重启、网线松动、终端休眠,都会导致连接静默断开。TCP 有个很讨厌的特性:对端掉电或者网线拔了,本端可能长时间感知不到,连接看起来还"活着",但发出去的消息石沉大海。

所以心跳是必须的。源码里的设计是:客户端每 15 秒发一次心跳,服务端记录每个连接的"最后活跃时间",后台有一个扫描线程每 10 秒检查一次,超过 45 秒没活跃的连接直接关闭。这个参数不是拍脑袋定的,45 秒意味着允许连续丢 3 次心跳,能容忍短时的网络抖动,又不至于让死连接堆积太久。如果你们大厅网络特别差,可以把心跳间隔调到 10 秒、超时调到 30 秒;反之如果是在内网千兆环境,15/45 完全够用。

重连这块,我踩过的最大的坑是重连风暴。早期版本我是断线后立即重连,结果有一次服务端重启,几十个终端在同一毫秒发起重连,服务端瞬间被打满,accept 队列溢出,反而全都连不上。后来改成指数退避加随机抖动:

第1次:1s + 随机0~500ms 第2次:2s + 随机0~500ms 第3次:4s + 随机0~500ms …… 上限:30s + 随机0~500ms

这个改动的效果非常明显,服务端重启后终端会在一分钟内陆续恢复,不会再雪崩。

第三个必备件是身份注册。连接建立后,终端必须先发送一条注册消息,告诉服务端"我是谁、我是什么类型"。类型大致分三类:取号机、窗口终端、展示屏。服务端把连接与终端信息绑定到在线表里,后续广播时才能定向推送。比如叫号事件只需要推给"对应窗口终端 + 所有展示屏 + 语音端",不需要推给取号机。

{ "type": 1001, "body": { "clientId": "WIN-A-03", "clientType": "window", "windowNo": "A03", "version": "2.1.4" } }

注册消息里带上版本号是我后来加的,因为现场升级经常漏掉某一台终端,导致协议不兼容的诡异问题。服务端收到低版本客户端时会在管理后台标红提示,比现场排查省事太多。

3. 叫号业务的并发核心:号码发放与状态流转怎么保证不乱

3.1 取号环节的并发风险:同一秒两个人取到同一个号

取号看起来简单——插一条记录,返回号码。但真实场景里,一个大厅往往有 2 到 4 台取号机,早高峰时段几个人同时点击"取号"完全正常。如果实现得草率,就会出现两个人拿到同一个号码,或者号码跳号(明明第 5 个人取号,却给出 0007)。

原始写法常见的是这种:

-- 先查当前最大号 SELECT MAX(NumberNo) FROM QueueTicket WHERE QueueDate = @today AND BusinessType = @type; -- 再加上 1 插进去 INSERT INTO QueueTicket (...) VALUES (@max + 1, ...);

这两句之间有一个时间窗口,两个请求同时执行,都读到同一个 MAX,然后都插入 max+1,重号就产生了。这在测试环境几乎测不出来,一上生产就偶发。

源码里用的是三种方案的组合,我按推荐程度排一下:

方案原理优点缺点
事务 + 唯一索引 + 重试数据库兜底,冲突即回滚重试绝对可靠,实现简单高并发下重试次数上升
序列表 + UPDLOCK 行锁单独一张号段表,取号时加锁自增无冲突,性能好需额外维护按日重置逻辑
应用层排队队列单线程串行处理取号请求逻辑最清晰单点,需考虑进程崩溃恢复

我实际项目里最常用的是第二种,也就是号段表 + UPDLOCK。核心思路是把"当天某业务类型的当前号码"单独存一行,取号时用WITH (UPDLOCK, ROWLOCK)锁住这一行,读出来加一,再写回,然后在同一事务里插入取号记录。因为锁的粒度是一行,不同业务类型之间互不影响,并发性能很好。

BEGIN TRANSACTION; DECLARE @nextNo INT; UPDATE QueueCounter WITH (UPDLOCK, ROWLOCK) SET @nextNo = CurrentNo = CurrentNo + 1, LastUpdate = GETDATE() WHERE CounterDate = @today AND BusinessType = @type; IF @@ROWCOUNT = 0 BEGIN -- 当天首次取号,初始化计数器 INSERT INTO QueueCounter (CounterDate, BusinessType, CurrentNo, LastUpdate) VALUES (@today, @type, 1, GETDATE()); SET @nextNo = 1; END INSERT INTO QueueTicket (TicketNo, NumberNo, BusinessType, Status, CreateTime) VALUES (@ticketNo, @nextNo, @type, 'Waiting', GETDATE()); COMMIT TRANSACTION;

这里有个技巧值得说:UPDATE ... SET @nextNo = CurrentNo = CurrentNo + 1这一句同时完成了"赋值给变量"和"更新行"两个动作,比先 SELECT 再 UPDATE 少一次往返,也避免了中间被插入其他语句的可能。另外IF @@ROWCOUNT = 0处理当天首次取号的情况,因为计数器行还不存在。这段逻辑一定要放进同一个事务,否则初始化阶段仍会有竞争。

注意:UPDLOCK必须配合显式事务使用,如果没有BEGIN TRANSACTION,锁在语句结束就释放了,等于没加。这是我在代码评审里见过最多的错误。

3.2 号码格式与业务类型的解耦设计

很多人把号码格式写死在代码里,比如直接拼"A" + number.ToString("D3")。这在业务稳定时没问题,一旦大厅要加新业务、改前缀、换位数,就得改代码重新发版。源码里的做法是把"号码规则"抽成配置:业务类型表里存前缀、位数、是否按日重置、是否允许跨业务优先。

CREATE TABLE BusinessType ( TypeCode VARCHAR(10) PRIMARY KEY, -- 业务编码,如 A、B、C TypeName NVARCHAR(50), -- 业务名称 Prefix VARCHAR(5), -- 号码前缀 NumberDigits INT DEFAULT 3, -- 号码位数 DailyReset BIT DEFAULT 1, -- 是否按日重置 Priority INT DEFAULT 0 -- 优先级,用于叫号排序 );

号码生成时读取配置动态拼接,这样新增业务类型只需要插一行数据,不用动代码。这个设计我在后续好几个项目里都沿用了,运维友好度提升非常明显——现场管理员自己就能在后台加业务,不用等开发排期。

另外Priority字段解决了"老年人优先""预约优先"这类需求。叫号时的排序不是单纯按号码先后,而是按Priority DESC, NumberNo ASC。这个设计要说清楚一点:优先业务会插到普通业务前面,但不能无限制插队,否则普通号永远叫不到。所以我在实际实现里加了一个约束——每个优先号最多只能超越 N 个普通号,N 可配置,默认 5。这个细节源码里没写,是我后来补的,因为现场真出现过优先号太多导致普通队列停滞的投诉。

3.3 叫号动作的完整事务链:一次"下一位"背后发生了什么

按下"下一位",看起来只是一个动作,实际上服务端要做一串事:找到该窗口对应业务的下一个待办号、把它的状态从"等待"改成"呼叫中"、记录呼叫时间和窗口号、生成一条叫号事件推给屏幕和语音、如果之前有"呼叫中"的号要处理过号逻辑。这一串必须要么全成功,要么全失败。

源码里的实现是典型的事务包裹加状态机校验:

BEGIN TRANSACTION; -- 1. 取出下一个待办号(加锁防止两个窗口抢到同一个号) SELECT TOP 1 @ticketId = Id, @numberNo = NumberNo FROM QueueTicket WITH (UPDLOCK, READPAST) WHERE Status = 'Waiting' AND BusinessType IN (@allowedTypes) -- 该窗口可办理的业务 AND QueueDate = @today ORDER BY Priority DESC, NumberNo ASC; IF @ticketId IS NULL BEGIN ROLLBACK TRANSACTION; -- 返回"当前无排队"给窗口端 RETURN; END -- 2. 把上一个"呼叫中"的号转为过号 UPDATE QueueTicket SET Status = 'Missed', UpdateTime = GETDATE() WHERE WindowNo = @windowNo AND Status = 'Calling'; -- 3. 更新当前号状态 UPDATE QueueTicket SET Status = 'Calling', WindowNo = @windowNo, CallTime = GETDATE(), UpdateTime = GETDATE(), CallCount = CallCount + 1 WHERE Id = @ticketId; COMMIT TRANSACTION; -- 4. 事务提交成功后再推消息(重要!) BroadcastCallEvent(@ticketId, @numberNo, @windowNo);

这里有三个关键点,每一个都对应过我实际遇到的 bug:

第一,READPAST这个提示词很关键。它的意思是"跳过被锁定的行"。假设 A 窗口和 B 窗口同时叫号,A 锁住了 0005 这一行,B 来查询时会跳过 0005 直接看 0006,而不是等待。如果没有READPAST,B 会被阻塞,窗口端就会卡一下,操作员会觉得"点了没反应"。但如果加上READPAST又配合了UPDLOCK,那么即使两个窗口同时查询,也不会拿到同一条记录,安全性是有的。这个组合是行级并发取号的经典用法。

第二,过号逻辑必须在同一事务里。我见过一种写法是先提交叫号,再单独一个事务处理过号,结果中间服务重启,出现一个窗口同时有两个"呼叫中"的记录,屏幕上就乱了。凡是状态互斥的更新,一定要放在一个事务里。

第三,消息推送必须在事务提交之后。这一点踩坑最深。早期我把BroadcastCallEvent写在事务里,结果有一次数据库提交失败回滚了,但消息已经推出去,屏幕显示"请 A005 到 3 号窗口",可数据库里 A005 还是 Waiting 状态。用户跑到窗口,工作人员一查系统查不到,现场就尴尬了。事务内只做数据操作,事务外才做副作用,这条原则我后来在所有项目里都严格执行。

3.4 乐观并发与状态机:防止重复点击造成的双叫号

操作员手快,连着点两下"下一位",在某些实现里会叫出两个号。这不是操作员的问题,是系统没做好幂等。

源码里用了两重保险。第一重是状态机,每个号的状态只能在合法路径之间流转:

Waiting → Calling → Processing → Completed ↓ ↓ Missed Transferred

任何不在这个表里的转换一律拒绝。比如一个已经是Calling的号,不能再被叫一次,因为Calling → Calling不合法。

第二重是版本号乐观锁。QueueTicket表里加一个RowVersion字段(SQL Server 里用ROWVERSION类型,自动维护),更新时带上版本条件:

UPDATE QueueTicket SET Status = 'Processing', RowVersion = RowVersion -- 触发版本变化 WHERE Id = @ticketId AND Status = 'Calling'; -- 检查 @@ROWCOUNT,为 0 说明状态已被别的请求改过

在应用层判断受影响行数,如果是 0 就直接返回"操作已失效,请刷新",不报错也不重复处理。这个处理方式比抛异常友好,用户端只会看到一次操作的反馈。

我个人的经验是,所有"按钮类"的业务操作都应该默认考虑重复提交,因为网络慢的时候用户一定会多点几下。与其事后解释,不如在设计阶段就把幂等做进去。

4. SQL Server 事务边界与隔离级别的实战取舍

4.1 隔离级别的选择:为什么不用默认的 READ COMMITTED

SQL Server 默认隔离级别是READ COMMITTED,它能防止脏读,但允许不可重复读和幻读。在叫号场景里,幻读会造成一个具体问题:窗口端先查"待办队列列表"用于显示,用户看着列表上有 8 个人,点下一位时实际只剩 5 个,中间有人办了转移。这个体验不算致命,但会让人觉得"数字对不上"。

真正需要关注的是阻塞。默认的READ COMMITTED在读到被修改的行时会等待锁释放,高并发时容易出现锁等待链。所以源码里在查询类操作上加了READ COMMITTED SNAPSHOT(需要在数据库级别开启ALLOW_SNAPSHOT_ISOLATION和READ_COMMITTED_SNAPSHOT):

ALTER DATABASE QueueSystem SET ALLOW_SNAPSHOT_ISOLATION ON; ALTER DATABASE QueueSystem SET READ_COMMITTED_SNAPSHOT ON;

开启之后,读操作走行版本,不阻塞写操作,写也不阻塞读。这对展示屏频繁刷新队列数据特别友好。代价是 tempdb 里的版本存储会增长,需要定期关注,不过叫号系统的数据量很小,一天几万条记录,压力可以忽略。

但要注意,关键的状态更新不能依赖快照隔离。发号、叫号这类需要严格互斥的操作,我依然用显式UPDLOCK,因为快照隔离是乐观思路,冲突时靠回滚重来,而叫号这种操作重试的语义不清晰(重试一次可能叫出下一个号)。所以我的实践是:读用快照,写用锁,两条路分开走。

4.2 事务范围的控制:别把消息推送和日志塞进事务

前面提过一条,这里再展开,因为它是这套源码里我认为最有价值的实践之一。

事务的范围应该是"一个业务原子操作所需要的最小数据集"。叫号的事务里只应该包含:查下一个号、改状态、记录时间窗口。至于推送消息、写操作日志、更新统计缓存,全都应该在事务外做。

原因很实在:事务里做的事越多,持锁时间越长,并发能力越差,而且一旦某个非核心操作失败(比如日志表满了),整个叫号都会回滚,用户看到的是"叫号失败",但实际上业务本该成功。这就是典型的把非核心操作拉进核心事务导致故障放大。

我整理了一个对照表,是我在多个项目里总结的"进不进事务"判断依据:

操作是否进事务理由
号码自增进必须与发号记录原子
状态流转更新进状态互斥,不容中间态
写操作日志不进失败不应影响业务,可异步补偿
推送 Socket 消息不进外部副作用不可回滚
更新统计缓存不进可重算,最终一致即可
调用语音播报接口不进外部依赖,失败可重试

这个表贴在我工位上好几年,每次评审代码都拿它对照一遍,能挡掉大部分事务设计问题。

4.3 死锁的真实案例与排查思路

死锁在叫号系统里不是理论问题,我在一个多窗口大厅的项目里真实遇到过,现象是高峰期偶发"叫号超时",日志里是 SQL Server 的错误 1205。

排查过程值得完整说一下,因为这个思路可以复用到很多场景。

第一步,先确认是不是死锁。在 SQL Server 里开一个跟踪,或者直接查系统视图:

-- 查看最近的死锁信息(需要开启 system_health 扩展事件,默认是开的) SELECT XEvent.value('(event/@timestamp)[1]', 'datetime2') AS DeadlockTime, XEvent.query('.') AS DeadlockGraph FROM ( SELECT CAST(target_data AS XML) AS TargetData FROM sys.dm_xe_session_targets st JOIN sys.dm_xe_sessions s ON s.address = st.event_session_address WHERE s.name = 'system_health' AND st.target_name = 'ring_buffer' ) AS Data CROSS APPLY TargetData.nodes('RingBufferTarget/event[@name="xml_deadlock_report"]') AS XT(XEvent) ORDER BY DeadlockTime DESC;

拿到死锁图之后,我看的是两个关键信息:哪两个资源在争,各自的锁模式和执行顺序。

那次的情况是:叫号事务先更新QueueTicket(拿行锁),再更新QueueCounter(拿行锁);而另一个"过号批量处理"的后台任务先更新QueueCounter,再更新QueueTicket。两个事务的加锁顺序相反,形成环路,死锁就来了。

解决办法很朴素:统一加锁顺序。所有涉及这两张表的事务,一律先操作QueueCounter,再操作QueueTicket。改完之后死锁完全消失。

这件事给我的教训是,只要有多个事务操作多张表,就必须约定一个全局的加锁顺序,并且写进开发规范。这个约定不需要多聪明,只要一致就行。顺序可以按表名字母排,也可以按"主表先、从表后",关键是全项目统一。

另外补充一个降低死锁概率的实用手段:把长事务拆短。我见过有人在叫号事务里顺手做了一次全表统计查询,那个查询要扫几十万行,持锁时间被拉长到秒级,死锁概率直线上升。凡是能在事务外做的查询,一律挪出去。

4.4 按日重置与历史数据:分区思路比 DELETE 更靠谱

叫号系统有个绕不开的需求:号码每天从 1 开始。同时历史数据不能丢,要能查"上个月某天的排队情况"。

我见过的最差实现是每天凌晨跑一个DELETE FROM QueueTicket WHERE QueueDate = 昨天,后果是历史全没了,而且大批量 DELETE 会产生大量日志、锁表、拖慢整个库。稍好一点的实现是定时归档到历史表再删,但要写两套逻辑,还得处理归档失败的情况。

源码里的做法是用QueueDate字段做逻辑分区,配合索引和按日统计表:

CREATE TABLE QueueTicket ( Id BIGINT IDENTITY PRIMARY KEY, QueueDate DATE NOT NULL, BusinessType VARCHAR(10) NOT NULL, NumberNo INT NOT NULL, TicketNo VARCHAR(20) NOT NULL, Status VARCHAR(20) NOT NULL, WindowNo VARCHAR(10) NULL, CallTime DATETIME2 NULL, CreateTime DATETIME2 DEFAULT GETDATE(), RowVersion ROWVERSION, CONSTRAINT UQ_Ticket UNIQUE (QueueDate, BusinessType, NumberNo) ); CREATE INDEX IX_Queue_Waiting ON QueueTicket (QueueDate, Status, BusinessType, Priority DESC, NumberNo ASC) INCLUDE (NumberNo, TicketNo);

这里有两个设计要点。第一,唯一约束(QueueDate, BusinessType, NumberNo)是最后一道防线,即使应用层并发处理有漏,数据库也不会允许重号,这是我最看重的保底。第二,那个覆盖索引直接对应叫号时的查询条件,INCLUDE把需要的字段带上,查询可以完全走索引,不用回表。实测在几十万行数据下,取下一个号的查询依然在毫秒级。

至于清理,我的做法是保留最近 3 个月在主表,更早的用后台任务按天分批归档到QueueTicketHistory,每批 2000 行,间隔执行。这样既不影响在线业务,也不会丢历史。归档任务的幂等性靠"先插历史、校验条数、再删主表"三步保证,中途失败重跑也不会重复。

提示:QueueDate一定要用DATE类型而不是DATETIME,否则查询时到处要写范围条件,索引也用不好。这个细节我在接手别人代码时改过不止一次,改完查询性能提升非常直观。

5. 通信层与数据层怎么配合:一个完整叫号链路的时序拆解

5.1 从按键到喇叭响:链路每一环的责任划分

单独看 Socket 和事务,各自都不难,难在配合。我把一次完整叫号拆成 7 个环节,每个环节的责任和失败处理都列清楚,这样出问题时能快速定位在哪一环。

  1. 窗口端捕获"下一位"点击,本地先做防抖,300 毫秒内的重复点击直接丢弃。
  2. 窗口端通过 Socket 发送CallNext请求,带上窗口号和本地请求序号(用于幂等)。
  3. 服务端收到请求,先查在线表确认该窗口连接合法,再进入事务处理。
  4. 事务内取号、改状态、处理过号,提交。
  5. 提交成功后,服务端组装叫号事件,推给该窗口、所有展示屏、语音端。
  6. 各终端收到事件后更新显示,语音端播报,展示屏做高亮动画。
  7. 窗口端收到服务端的CallNextAck响应,更新本地界面状态。

这里第 2 步的"请求序号"和第 7 步的"响应"是配套的。窗口端维护一个自增序号,服务端处理完成后回传同样的序号,窗口端据此知道这次点击是否真的成功。如果 3 秒内没收到响应,窗口端提示"网络异常,请稍后确认",而不是直接显示成功——宁可让操作员等一下,也不能显示假成功,这是所有窗口类系统的基本原则。

5.2 消息丢失与重复:不能靠 TCP 保证业务正确

有个误区要澄清:TCP 保证的是字节流可靠传输,它不保证你的业务消息"恰好被处理一次"。断线重连的瞬间,服务端可能已经处理了请求但响应没送达,窗口端重发,服务端就会处理两次。

解决办法是在服务端做请求去重。维护一个短期缓存(内存或者 Redis,保留 5 分钟),key 是"窗口号 + 请求序号",处理前先查,处理过就直接返回上次的结果,不再走事务。这个机制叫幂等键,实现成本很低,但能挡掉绝大多数重复叫号。

string idemKey = $"{windowNo}:{requestSeq}"; if (_idemCache.TryGet(idemKey, out var cached)) { SendAck(windowNo, requestSeq, cached); return; // 已处理过,直接返回 } var result = ProcessCallNext(windowNo); _idemCache.Set(idemKey, result, TimeSpan.FromMinutes(5)); SendAck(windowNo, requestSeq, result); BroadcastCallEvent(result);

需要注意顺序:_idemCache.Set要在事务提交之后、广播之前。如果放在事务之前,事务失败了缓存却有记录,重试会被误判成"已处理",反而更糟。

5.3 展示屏的数据自愈:终端重启后怎么恢复正确状态

大厅的综合屏有时候会被人误操作重启,或者断电恢复。这时候屏幕是空的,但队列里还有人在等。如果只依赖推送,重启后就永远收不到之前的叫号信息了。

所以每个终端连上之后的第一步,除了注册,还要发一个SyncState请求,服务端返回当前大厅的完整快照:正在呼叫的号、等待队列前 N 个、各窗口状态。终端拿到快照先渲染,然后才开始接收增量推送。

这个"先快照后增量"的模式是长连接系统的通用做法,顺序不能反。如果先开始接收推送再拉快照,中间产生的变更可能丢失。我的实现是:连接建立 → 注册 → 拉快照 → 服务端标记该连接为"可推送"。在标记之前产生的推送对该连接直接丢弃,因为它马上会从快照里拿到最新状态。

{ "type": 2001, "body": { "snapshotTime": "2024-06-12T09:31:22", "calling": [ { "windowNo": "A03", "numberNo": "A012", "businessType": "A" } ], "waiting": [ { "numberNo": "A013", "businessType": "A", "waitCount": 1 }, { "numberNo": "B005", "businessType": "B", "waitCount": 1 } ], "windows": [ { "windowNo": "A03", "status": "Busy", "currentNo": "A012" }, { "windowNo": "A04", "status": "Idle", "currentNo": null } ] } }

快照里除了号码,我还加了waitCount,表示该号前面还有几个人。这个信息在大屏上显示"前面还有 3 位"特别有用,能显著降低排队者的焦虑感,也能减少去窗口问询的次数。这是纯产品层面的收益,但实现成本极低,值得加。

5.4 限流与背压:大厅忙时别把服务端压垮

早高峰时,展示屏刷新很频繁(每次叫号都要刷),如果再加上语音、窗口端、管理后台,消息量会短时上升。虽然绝对量不大,但如果每台终端在收到消息后又触发一次回查数据库,就会形成放大效应。

源码里的处理是在服务端做推送合并:同一个展示屏,500 毫秒内的多次刷新请求合并成一次推送,只推最新状态。实现方式很简单,给每个连接挂一个"待推送标记 + 最新数据",定时器每 500 毫秒扫一次,有标记才真正发。

// 合并推送:只保留最新快照 connection.PendingSnapshot = BuildSnapshot(); connection.PendingDirty = true; // 后台定时器 foreach (var conn in _connections.Where(c => c.PendingDirty)) { conn.Send(Serialize(conn.PendingSnapshot)); conn.PendingDirty = false; }

这个改动让高峰期的推送量下降了大约七成,服务端的 CPU 曲线平滑了很多。而且对用户没有感知,因为 500 毫秒内合并的几次刷新,屏幕看到的还是最新结果。

背压的另一面是服务端要防止单个慢终端拖累全局。发送队列要有上限,超过就断开该终端并让它重连拉快照。这个策略看着粗暴,但比让整个服务端的发送队列堆积到内存爆掉要好得多。叫号系统的终端数量少,重连成本很低,这个取舍是划算的。

6. 部署上线后最容易翻车的几个地方

6.1 语音播报的节奏控制:不是每次叫号都该立刻响

技术上把消息推给语音端很简单,但实际使用里如果连续叫号,喇叭会叠在一起,谁也听不清。源码里在语音端做了队列:收到播报任务先进队列,按顺序播,播完一个再播下一个。同时做了去重,如果 2 秒内收到同一号码的播报请求,只播一次。

更关键的是优先级:过号提醒、优先号的播报要插队,普通叫号排队。这个策略是我在某个大厅现场被投诉后加上的——当时一位老人家的号被叫了没听到,过号了才有提醒,家属意见很大。加了优先播报之后,重要提醒会打断当前播报,效果明显好转。

播报内容也要注意,别把号码念成连读。比如 "A012",直接念字母加数字容易被听成 "A零一二" 或者 "A十二"。我的处理是转成中文数字逐位播报,"A 零 一 二",虽然听起来慢一点,但辨识度高得多。这个细节没有技术含量,但对现场体验影响很大。

6.2 时间不同步导致的"过号判定"错乱

过号判定依赖时间:呼叫后超过 N 分钟未办理,自动转过号。如果服务器和终端的时间不同步,判定就会错。我遇到过一台终端时间慢了 20 分钟,导致它上面的号永远不会被判过号,队列越积越长。

解决办法是把时间判定全部放到服务端,终端只负责显示。服务端用一个后台任务每分钟扫一次,找出Status = 'Calling' AND CallTime < DATEADD(MINUTE, -@missMinutes, GETDATE())的记录,批量转Missed。这样判定标准只有一个,不受终端时间影响。

另外终止一下:这个扫描任务要控制批次,一次最多处理 200 条,避免大批量更新造成锁表。如果积压很多(比如系统停机几小时后恢复),让它慢慢追。

6.3 数据库连接池与超时参数的调优

叫号系统是典型的高频短事务场景,连接池配置不当会很容易出问题。默认连接池最大 100 个连接,如果每条请求都新开连接用不完就关,池子会频繁创建销毁,性能很差。正确的做法是连接用using包住、尽快归还、不要跨事务长时间持有。

超时参数我一般这么设:连接超时 15 秒,命令超时 30 秒。命令超时不要设太短,因为凌晨归档任务会跑得比较久,设成 5 秒会大面积超时。但如果叫号接口的命令超时超过 30 秒,说明有问题需要查,不该靠超时掩盖。

还有一个容易忽略的点:批量归档和在线业务要分开。归档任务用独立的连接字符串,限制最大连接数为 5,避免它把连接池占满导致叫号请求排队。这个隔离措施我在两个项目里都加过,效果立竿见影。

6.4 上线前的必测清单

按我这套源码的经验,上线前有几项一定要测,不测基本会在现场暴露:

测试项方法关注点
消息分段到达手动拆包发送接收端不把半包当完整消息
断线重连拔网线 30 秒后插回自动恢复且不重复叫号
并发取号4 台取号机脚本同时请求 100 次无重号、无跳号
双窗口同时叫号两个窗口脚本同时点击不叫出同一个号
服务端重启重启服务观察终端指数退避重连,1 分钟内全部恢复
终端重启中途重启展示屏拉快照后显示正确
过号判定修改服务器时间或等待到点转过号,屏幕更新
数据库断连停库 30 秒业务报错友好,恢复后能自愈

这份清单我基本上每个项目都会精简后保留,最后两项最容易被漏掉,但恰恰是生产环境最常见的故障源。

我个人的体会是,叫号系统的技术难度不在于某个点有多深,而在于每个环节都要考虑失败情况。它跑得好的时候存在感很低,一旦出问题,用户是站在大厅里当场感知的,没有缓冲余地。所以宁可把协议写啰嗦一点、把事务切小一点、把重试和兜底做足一点,也别指望"应该不会出问题"。上面这些处理方案,基本都是被现场问题逼出来的,直接拿去用能省不少返工。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/28 6:09:10

KingbaseES安全审计策略失效排查与优化实践

1. KingbaseES安全审计策略失效问题概述作为国产数据库领域的核心产品&#xff0c;KingbaseES的安全审计功能是企业级应用中不可或缺的组成部分。但在实际运维中&#xff0c;我们经常会遇到审计策略看似配置正确却无法正常记录日志的情况。这种情况往往发生在数据库版本升级、参…

作者头像 李华
网站建设 2026/10/28 6:09:10

凝形即风化:重读李晨阳《与时俱进的人类原则——提倡一种研究儒学的新进路》51737

凝形即风化&#xff1a;重读李晨阳《与时俱进的人类原则——提倡一种研究儒学的新进路》51737——思想史意义分析一、问题意识层面&#xff1a;重新问题化“儒学与民主”的关系这篇文章的思想史位置&#xff0c;首先取决于它把什么问题重新变成了问题。自牟宗三“良知坎陷”说以…

作者头像 李华
网站建设 2026/10/28 6:08:22

基于YOLOv5的交通标志识别优化与边缘部署实践

1. 项目背景与核心价值交通标志识别作为智能驾驶系统的"眼睛"&#xff0c;直接影响着车辆对道路环境的理解能力。TT100K数据集作为目前亚洲地区规模最大的交通标志基准库&#xff0c;包含10万张真实道路场景图像和221类标志。但在实际工程应用中&#xff0c;我们发现…

作者头像 李华
网站建设 2026/10/28 6:06:54

FileZilla Server 0.9.60安装配置与被动模式排错指南

简介&#xff1a;面向需要快速搭建文件传输服务的个人开发者、小型团队与运维初学者&#xff0c;这份资源提供了FileZilla Server v0.9.60的Windows安装程序及一份中文说明文档&#xff0c;用于解决在自有机器或内网环境中架设FTP服务器、实现文件上传下载与共享管理的问题。压…

作者头像 李华
网站建设 2026/10/28 6:06:00

WebAPI:前端开发的核心技术与实践指南

1. 为什么WebAPI是前端开发的基石2005年&#xff0c;当Google Maps首次实现无刷新拖拽地图时&#xff0c;整个Web开发领域为之震动。这背后正是WebAPI的魔力——它让JavaScript突破了玩具语言的局限&#xff0c;成为能与浏览器深度对话的利器。作为现代前端开发的三大支柱之一&…

作者头像 李华
网站建设 2026/10/28 6:04:16

贝叶斯优化在柔性钙钛矿光伏器件光子固化中的应用

1. 柔性钙钛矿光伏器件光子固化过程的贝叶斯优化实践在光伏材料研究领域&#xff0c;柔性钙钛矿太阳能电池因其轻质、可弯曲的特性备受关注。其中光子固化工艺作为制备过程中的关键环节&#xff0c;直接影响器件的最终性能表现。传统试错法优化固化参数不仅耗时耗力&#xff0c…

作者头像 李华