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然后都插入 max1重号就产生了。这在测试环境几乎测不出来一上生产就偶发。源码里用的是三种方案的组合我按推荐程度排一下方案原理优点缺点事务 唯一索引 重试数据库兜底冲突即回滚重试绝对可靠实现简单高并发下重试次数上升序列表 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而不是等待。如果没有READPASTB 会被阻塞窗口端就会卡一下操作员会觉得点了没反应。但如果加上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 COMMITTEDSQL Server 默认隔离级别是READ COMMITTED它能防止脏读但允许不可重复读和幻读。在叫号场景里幻读会造成一个具体问题窗口端先查待办队列列表用于显示用户看着列表上有 8 个人点下一位时实际只剩 5 个中间有人办了转移。这个体验不算致命但会让人觉得数字对不上。真正需要关注的是阻塞。默认的READ COMMITTED在读到被修改的行时会等待锁释放高并发时容易出现锁等待链。所以源码里在查询类操作上加了READ COMMITTED SNAPSHOT需要在数据库级别开启ALLOW_SNAPSHOT_ISOLATION和READ_COMMITTED_SNAPSHOTALTER 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[namexml_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 个环节每个环节的责任和失败处理都列清楚这样出问题时能快速定位在哪一环。窗口端捕获下一位点击本地先做防抖300 毫秒内的重复点击直接丢弃。窗口端通过 Socket 发送CallNext请求带上窗口号和本地请求序号用于幂等。服务端收到请求先查在线表确认该窗口连接合法再进入事务处理。事务内取号、改状态、处理过号提交。提交成功后服务端组装叫号事件推给该窗口、所有展示屏、语音端。各终端收到事件后更新显示语音端播报展示屏做高亮动画。窗口端收到服务端的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 秒业务报错友好恢复后能自愈这份清单我基本上每个项目都会精简后保留最后两项最容易被漏掉但恰恰是生产环境最常见的故障源。我个人的体会是叫号系统的技术难度不在于某个点有多深而在于每个环节都要考虑失败情况。它跑得好的时候存在感很低一旦出问题用户是站在大厅里当场感知的没有缓冲余地。所以宁可把协议写啰嗦一点、把事务切小一点、把重试和兜底做足一点也别指望应该不会出问题。上面这些处理方案基本都是被现场问题逼出来的直接拿去用能省不少返工。
阅读完成 · 觉得有帮助?