
简介面向C#初学者以及正在设计排队机、叫号系统的开发者这套源代码提供了一套经过多年客户实际使用验证的完整方案覆盖叫号逻辑、数据库访问、界面展示与报表打印等环节可帮助理解真实项目中的模块划分与数据流转方式。压缩包共249个文件以172个cs源码文件为主体辅以44个resx界面资源、9个ico图标、6个user自定义控件、5个settings配置以及2个rdlc报表定义整体仅1.29MB结构紧凑便于直接阅读与二次开发。已有1617人浏览学习。代码中包含DbHelperSQL等数据库辅助类、frmCaller与frmScreen等核心窗体以及MyCommon公共方法模块覆盖从数据库操作到多窗体协作的典型实现适合初学者拆解学习也可为实际项目提供可复用的设计思路与代码骨架节省从零搭建系统的时间。1. 排队机叫号源代码本质是一台队列状态机「排队机叫号源代码」这几个字听起来像是一个「能叫号的程序」但真正拿到这种项目的人会发现叫号只是最表面的动作。取号、分配、叫号、过号、重呼、结单每一步都是一个状态在数据库里的流转而最常见的翻车现场——两个窗口抢同一个号、客户没到窗口号就过了、日报表数字对不上——全都出在状态没有守住而不是出在界面按钮上。这套源代码的价值在于把整条队列的状态管理从头到尾做完了数据库表怎么建、并发时怎么锁号、过号之后怎么拉回来逻辑是一脉贯通的。它适合两类人一类是接手维护网点排队机系统的从业者想快速看懂老工程另一类是在做数据库课程设计、需要真实业务场景源码来改造的学生。2. 取号到结单的完整流转四个角色与一套状态码任何一套排队机源码不管是银行大堂、政务大厅还是医院药房用的拆开看都是四个角色在操作同一张号码流水表取号端负责产生号码窗口端负责消费号码显示端负责把当前状态广播出去管理端负责配置和统计。这套代码里四个角色没有各自建表而是共享一张流水表靠状态码区分「这个号码现在走到哪一步」。2.1 四个角色的职责划分取号端一般是触摸屏或者窗口人员代取动作只有一个调用取号逻辑生成一个当天唯一的业务号码插入流水表并置为待叫状态。窗口端是叫号人员手里的操作界面包含叫下一个、过号、重呼、开始办理、结单这五个动作是最复杂的一端。显示端不写数据库只做一件事情把「当前正在叫的号码」和「排队人数」拉出来推送到 LED、电视屏或语音盒子。管理端负责维护业务类型表、窗口表以及查看当日流水和统计报表。把这四个角色的调用关系画出来这套源码至少能看懂八成。剩下两成是细节过号以后号码怎么重新进入队列、两个窗口同时按叫号时数据库怎么保证不重号这两处是判断一套排队机代码水平的分水岭。2.2 号码生命周期状态位是队列的命脉排队机的核心表里一定会有一个状态字段这套源码用的是整数状态位设计很干净只有六个值状态值含义触发动作1待叫已取号还在排队取号成功时写入2已叫正在窗口办理或正在等待客户窗口叫号时写入3过号叫了没来窗口点过号时写入4保留位部分版本用作「办理中」视项目而定5完成业务办结窗口结单时写入6取消/作废取号后离开管理端或自动清理时写入为什么用整数而不是用字符串因为叫号逻辑里要频繁按状态过滤整数可以直接进索引还能用StatusId IN (1, 2)这类写法一次捞出「还在队列里」的所有号码。我以前见过一个版本用中文状态字段报表统计时WHERE Status已叫 AND Status!过号这种条件写得到处都是后来改状态值差点把线上数据改崩。这套状态机里唯一需要严格并发控制的是「1→2」这一步待叫的号码可能被多个窗口同时竞争。其余的状态迁移比如过号、重呼、结单都是操作者针对自己窗口已经持有的号码做更新天然不会冲突。2.3 一条完整的指令序列把一次完整业务串起来看流水表的变化是这样的步骤操作者动作数据变更1客户取号机出票插入流水StatusId1记录业务类型和取号时间2窗口人员点「叫下一个」原子锁号StatusId 1→2写入 WinId、CallTimeCallCount13a窗口人员客户到窗点「开始办理」StatusId 2→5写 FinishTime3b窗口人员客户未到点「过号」StatusId 2→3保留原 WinId3c窗口人员客户回来点「重呼」StatusId 3→2CallCount1重写 CallTime4客户取号后离开管理端或日终清理时置 StatusId6这套序列是排查问题的总线索。比如报表里完成数偏少就去查是不是有大量记录卡在状态 2 或状态 3比如两个窗口叫出同一个号就去查步骤 2 的锁号逻辑是不是原子的比如重呼后号码跑到别的窗口去了就去查重呼时是不是错误地把 WinId 清空了。提示重呼时不应该清空 WinId。客户听到叫号后已经在走向原窗口重呼只是把号码拉回「已叫」状态窗口还是原来那个窗口。如果重呼时把 WinId 置空这个号码就会被其他窗口当作待叫号抢走窗口之间必然起冲突。3. 数据库是这套源码的心脏三张核心表与关键 SQL数据库开发类项目最怕看到一坨没有主键、没有索引、全靠程序里SELECT *硬撑的表。这套排队机源码的表结构走的是经典的中型业务系统路线三张核心表职责分明索引和约束都建在点子上。3.1 业务类型表控制号码前缀与当日序号业务类型表的作用是给每种业务一个独立队列。银行网点里个人业务、对公业务、理财业务是三条队政务大厅里社保、税务、不动产是三条队每一条队有独立的号码前缀和当日序号。CREATE TABLE dbo.t_BizType ( BizTypeId INT IDENTITY(1,1) PRIMARY KEY, -- 业务类型ID BizName NVARCHAR(20) NOT NULL, -- 业务名显示在取号机上 Prefix NVARCHAR(4) NOT NULL, -- 号码前缀如 A/B/C CurrentSeq INT NOT NULL, -- 当日已发到的序号 StartNo INT NOT NULL DEFAULT 1, -- 当日起始序号 Status TINYINT NOT NULL DEFAULT 1 -- 1启用 0停用 );这里最关键的是CurrentSeq字段。取号逻辑就是把它加一然后拼上前缀生成号码比如前缀是 A、当前序号是 5号码就是 A0005。这个字段有一个隐含问题跨天之后要归零。常见做法是每天凌晨跑一个定时任务执行UPDATE dbo.t_BizType SET CurrentSeq StartNo - 1或者在程序启动时检测系统日期变化后统一重置。粗放一点的项目直接在日终结算时重置也能跑但遇到跨天不关机的取号机就有概率发重号这个坑后面细说。3.2 窗口表绑定业务类型窗口表负责定义「哪个窗口办哪种业务」字段不多。CREATE TABLE dbo.t_Window ( WinId INT IDENTITY(1,1) PRIMARY KEY, WinName NVARCHAR(20) NOT NULL, -- 显示名如 1号窗口 BizTypeId INT NOT NULL, -- 绑定的业务类型 DevStatus TINYINT NOT NULL DEFAULT 1, -- 1正常 0暂停 2午休 LastCallNo NVARCHAR(16) NULL -- 最近一次叫的号便于恢复现场 );窗口跟业务类型是「多对一」还是「一对一」实际项目里经常是一个窗口在下午切换业务比如上午办个人业务、下午办对公业务。所以窗口端界面上要有「切换业务类型」的功能切换时业务类型表不变只改窗口当前绑定的 BizTypeId。看到源码里直接在t_Window上做这个绑定而不是做成中间映射表说明作者是按实际营业场景设计的不是按教科书设计的。3.3 号码流水表队列本身流水表是这套系统里数据量最大的表一天几千到几万条一个月后就是几十万条。没有索引的流水表撑不过三个月查询会明显变慢。CREATE TABLE dbo.t_TicketFlow ( FlowId BIGINT IDENTITY(1,1) PRIMARY KEY, TicketNo NVARCHAR(16) NOT NULL, -- 业务号码如 A0005 BizTypeId INT NOT NULL, -- 指向业务类型表 StatusId TINYINT NOT NULL, -- 1待叫 2已叫 3过号 5完成 6取消 WinId INT NULL, -- 处理该号的窗口叫号后写入 CreateTime DATETIME NOT NULL, -- 取号时间 CallTime DATETIME NULL, -- 叫号时间 FinishTime DATETIME NULL, -- 结单时间 CallCount TINYINT NOT NULL DEFAULT 0, -- 叫号/重呼次数 Source NVARCHAR(10) NOT NULL DEFAULT TOUCH ); CREATE INDEX IX_Flow_Queue ON dbo.t_TicketFlow(StatusId, BizTypeId, CreateTime) INCLUDE(TicketNo, WinId);IX_Flow_Queue这个索引是给取号逻辑专门建的按状态、业务类型、时间排序取最老的待叫号。因为查询只要回读TicketNo和WinId用 INCLUDE 直接把这些列塞进索引叶子节点避免每次回表读整行。日终统计时流水表按CreateTime过滤这个索引也能顶一部分查询。需要注意INCLUDE 语法是 SQL Server 的写法。如果把这套表结构迁移到 MySQL 8要改成普通联合索引(StatusId, BizTypeId, CreateTime, TicketNo, WinId)迁移到 PostgreSQL 则可以直接建INCLUDE两者的索引机制类似。关键的统计 SQL 有两句。一句是看每个窗口的待办积压SELECT w.WinName, SUM(CASE WHEN f.StatusId 1 THEN 1 ELSE 0 END) AS WaitingCount FROM dbo.t_Window w LEFT JOIN dbo.t_TicketFlow f ON f.BizTypeId w.BizTypeId AND f.WinId IS NULL AND CONVERT(DATE, f.CreateTime) CONVERT(DATE, GETDATE()) WHERE w.DevStatus 1 GROUP BY w.WinName;另一句是看全天的平均等待时长这是营业厅管理最看重的指标SELECT AVG(DATEDIFF(SECOND, CreateTime, CallTime)) AS AvgWaitSec, COUNT(*) AS CalledCount FROM dbo.t_TicketFlow WHERE CONVERT(DATE, CreateTime) CONVERT(DATE, GETDATE()) AND CallTime IS NOT NULL;注意CONVERT(DATE, CreateTime)这类写法在流水表数据量大时会放弃索引扫描全表。几十万条以内没感觉上了百万条就要改成CreateTime 2025-06-01 AND CreateTime 2025-06-02的区间写法。调试时如果发现报表越跑越慢先看执行计划是不是走了索引。4. 多窗口并发与调度策略怎么保证两个窗口不叫到同一个号排队机的并发问题集中在两个窗口同时按「叫下一个」的瞬间。两个窗口读取到同一个待叫号码、同时更新成功屏幕上两个窗口都在喊同一个号客户一脸懵。这个问题不是玄学是典型的读写竞态解决思路只有一个把「查询待叫号码」和「把号码标记为己叫」合并成一个原子操作。4.1 重号是怎么发生的最容易出问题的写法是「先查再更」窗口端先执行SELECT TOP 1 ... WHERE StatusId 1拿到一个号码显示在界面上用户点确认后再执行UPDATE ... SET StatusId 2。问题出在两步之间有空窗窗口 A 查到了 A0005界面弹出确认框窗口 B 在确认框弹出的这一瞬间也执行查询同样拿到 A0005两边都点确认两个 Update 都成功重号就发生了。我见过某个版本试图用「程序里加锁变量」来解决在单机进程里定义一个静态变量窗口端先抢到这个变量才能查号。这在单机演示时没问题但排队机是局域网多终端架构取号机、多个窗口工控机连同一个数据库进程内的锁根本锁不住其他机器的请求。所以这套源码把锁挪到了数据库层用存储过程解决方向是对的。4.2 用 UPDLOCK READPAST 实现原子取号这套源码里的取号存储过程是核心结构大致如下CREATE PROCEDURE dbo.usp_CallNext WinId INT, -- 发起叫号的窗口ID BizTypeId INT -- 该窗口当前受理的业务类型 AS BEGIN SET NOCOUNT ON; DECLARE TicketNo NVARCHAR(16); DECLARE FlowId BIGINT; BEGIN TRAN; -- 带 UPDLOCK 锁定扫描到的行带 READPAST 跳过已被其他事务锁住的行 SELECT TOP 1 FlowId FlowId, TicketNo TicketNo FROM dbo.t_TicketFlow WITH (UPDLOCK, READPAST) WHERE BizTypeId BizTypeId AND StatusId 1 AND WinId IS NULL ORDER BY CreateTime ASC, FlowId ASC; IF FlowId IS NULL BEGIN ROLLBACK; RETURN -1; -- 队列为空 END; UPDATE dbo.t_TicketFlow SET StatusId 2, WinId WinId, CallTime GETDATE(), CallCount CallCount 1 WHERE FlowId FlowId; COMMIT; SELECT TicketNo AS TicketNo; END;这段逻辑的关键在WITH (UPDLOCK, READPAST)两个提示的组合。UPDLOCK让查询在读到行的时候就对该行加更新锁后续其他事务再来读同一行会被阻塞READPAST则保证当前事务跳过已经被别人锁住的行不会傻等。两者配合的效果是窗口 A 先执行锁住 A0005 并改成已叫窗口 B 同时执行读的时候直接跳过 A0005拿到下一条 A0006。这就是「既不重号、又不互相阻塞」的数据库层实现。如果是 MySQL 8 环境等价写法是SELECT ... FOR UPDATE SKIP LOCKEDPostgreSQL 也支持SKIP LOCKED。SQL Server 这套 Hint 语法在老项目里最常见所以排障时遇到同类源码先找有没有这两个关键词。4.3 窗口端调用与广播解耦存储过程只负责「把号码从队列里取出来并更新状态」窗口端拿到号码之后还要推送给 LED 屏幕和语音盒子。这里有一个重要的边界数据库状态更新是主事务推送屏幕是附属动作附属动作失败不能回滚主事务。// 窗口端“叫下一个”按钮的完整处理 private void btnCallNext_Click(object sender, EventArgs e) { string ticketNo; // 第一步调存储过程原子取号 using (var conn new SqlConnection(CONN_STR)) using (var cmd new SqlCommand(dbo.usp_CallNext, conn)) { cmd.CommandType CommandType.StoredProcedure; cmd.Parameters.AddWithValue(WinId, winId); cmd.Parameters.AddWithValue(BizTypeId, bizTypeId); conn.Open(); // 队列为空时存储过程返回 -1这里表现为取不到值 ticketNo (string)cmd.ExecuteScalar(); } if (string.IsNullOrEmpty(ticketNo)) { MessageBox.Show(当前队列为空请先取号, 提示, MessageBoxButtons.OK, MessageBoxIcon.Information); return; } // 第二步广播。推送失败不撤销叫号只记录日志 try { DisplayProxy.ShowNow(ticketNo, winName); VoiceProxy.Speak($请{ticketNo}号到{winName}窗口办理); } catch (Exception ex) { Log.Error(广播失败但号码已生效请人工喊号, ex); } }winId和bizTypeId是窗口端登录后从配置表读取的参数。广播失败时的处理是很多项目忽略的如果推送成功后 LED 屏幕断电客户看不到号码窗口人员只能人工喊号此时如果代码把数据库状态回滚成待叫那其他窗口就可能把这个号再叫一次彻底乱套。所以广播失败的正确做法是记日志、提示窗口人员、但不回滚。调度策略上这套代码用的是「窗口主动抢占」模式窗口空闲时点按钮去队列里抢最老的号。另一种模式是「系统自动派号」取号时直接分配指定窗口客户凭票去对应窗口适合流水线式作业但银行政务类场景用户体验差客户不知道被分到哪个窗口实际项目里很少用。提示如果某天线上出现了重号第一件事是查数据库日志里这两个 Update 的执行顺序和事务隔离级别而不是先改窗口端代码。绝大多数重号问题根源在数据库层界面端代码改一百遍也没用。5. 排队机避坑指南五个真实翻车现场与修复方法这套源码整体逻辑是完整的但实际部署中坑点不少。下面五条都是我见过或经历过的典型问题按「现象 → 原因 → 解决」整理出来照着排查能省半天时间。5.1 两个窗口同时叫出同一个号现象早高峰两个窗口几乎同时点叫号LED 屏和语音同时播同一个号码客户不知道去哪个窗口窗口人员互相推诿。原因这不是数据库并发问题而是流程设计问题。旧版本窗口端做了「预取号」程序先把待叫号查出来显示在界面上等窗口人员确认后再更新状态。预取号和确认之间的空窗期另一个窗口也能查到同一个号。窗口人员手速越快重号概率越高。解决严格走usp_CallNext存储过程把查号和锁号合并为一次数据库操作。如果业务上必须保留确认弹窗就把弹窗放在「调存储过程之后」而不是之前先落库拿到号码弹窗只是展示结果不是确认项。这一条改动就能根治重号。5.2 客户还没走到窗口号码就变成过号了现象老年客户从取号机拿完号走到窗口路上用了两分钟到窗口时系统已经显示「已过号」窗口人员重新叫号时客户一脸委屈现场吵起来。原因过号等待时间写死在代码里默认值只有 30 秒。代码里用的是Thread.Sleep(30000)这类硬编码部署时没人去改。大厅越大、窗口越远、客户群体年龄越大这个值就越不合理。解决把过号等待间隔做成配置表参数过号判定逻辑统一读配置。配置表就两列一列键一列值INSERT INTO dbo.t_Config ([Key], [Value]) VALUES (WaitTimeoutSec, 180);窗口端判定逻辑改成SELECT Value FROM dbo.t_Config WHERE [Key] WaitTimeoutSec取不到值时默认 120 秒。另外窗口端要保留「撤销过号」按钮客户到了就点一下把状态从 3 拉回 2同时记录一条操作日志避免被投诉时说不清楚。5.3 LED 屏幕显示乱码现象号码数字正常汉字和字母变成「口」或菱形某些批次屏幕正常、某些批次乱码。原因LED 控制器和程序约定的编码不一致。常见组合是控制卡默认 GB2312程序按 UTF-8 拼报文或者反过来。同类源码复制到不同网点显示屏厂家不一样编码约定就不一样看起来像系统「时好时坏」其实是换屏的时候没同步改编码。解决把编码参数做成每个网点的配置项不要在代码里写死。调试时直接用串口工具抓报文看 Hex 字节流对照 GB2312 或 UTF-8 的码表一看就知道哪一端错了。这个坑最好在部署阶段就统一确认等上线后再改要同时改程序和屏参来回折腾。5.4 日报表统计数量比实际少了 20%现象晚上打印日报表系统统计当天办理 80 笔营业厅手写台账记了 90 笔差了 10 笔。查流水表发现有一部分记录状态停在了「已叫」。原因窗口人员点「结单」按钮时界面上的客户记录消失了但数据库状态没有更新。旧代码里「开始办理」只改了前端界面状态对应数据库根本没有任何更新操作真正把状态改成完成的那段代码只存在于另一个没人调用的模块里。报表按 StatusId5 统计自然偏少。解决强制约定结单必须走统一的更新语句把StatusId从 2 改成 5 并写入FinishTime。排查时用一个分组查询看状态分布哪里异常一眼可见SELECT StatusId, COUNT(*) AS Cnt FROM dbo.t_TicketFlow WHERE CONVERT(DATE, CreateTime) CONVERT(DATE, GETDATE()) GROUP BY StatusId;正常一天跑下来状态 5完成应该占绝大多数状态 1 和状态 2 是营业结束时还残留在队列里的少量号码状态 3 是过号。如果状态 2 占比异常高说明「结单不落库」的问题还在。5.5 取号机间歇性卡死重启后恢复现象客户按取号后纸质票打印不出来或者触摸屏转圈十几秒重启软件恢复过半天又犯。原因打印控件同步调用打印机打印机走网络共享时响应慢超时期间取号事务一致没提交数据库连接被占满后面所有取号请求都在等连接表现为整个取号端卡死。这是典型的「外部设备拖垮主流程」。解决取号写库和打印彻底解耦。按钮点击后先执行取号存储过程拿到号码立刻提示「取号成功」打印丢到后台线程队列慢慢处理失败只记日志不弹窗。同时给所有SqlConnection设置短连接超时比如Connect Timeout 3避免单台设备异常拖垮整个系统。6. 部署后怎么验证造数、并发测试与参数配置化拿到这套源码不要急着改业务流程先做三轮验证。第一轮是造数据验证统计逻辑。写一段脚本往流水表灌一天的量比如 3000 条记录时间从早上 9 点均匀分布到下午 5 点状态随机落在 1、2、3、5然后跑前面那两句统计 SQL确认报表口径和你预期一致。这个步骤能提前发现状态位含义和报表逻辑的错位。第二轮是并发验证。打开五个数据库查询窗口模拟五个窗口同时执行usp_CallNext连续跑几十次观察返回的号码有没有重复有没有返回空值。正常情况每次返回的号码都应该严格递增且不重复。如果出现重复回到第 4 章的锁机制排查如果出现大量空值说明锁粒度太粗事务互相阻塞严重要检查是不是漏了READPAST。第三轮是把行为参数全部配置化。WaitTimeoutSec过号等待秒数、MaxCallCount号码最大重呼次数、DailyResetTime每日序号归零时间这些值全部挪到t_Config表里改参数不用重新编译程序。我之前接手一个项目对方说系统「时好时坏很玄学」排查到最后发现是某台窗口电脑的系统时间快了八分钟叫号记录和过号判断全部错位。从那以后我拿到任何排队机源码第一件事就是检查所有终端的时间同步再做并发压测最后才动业务逻辑。希望你拿到的这套代码能少踩几个类似的坑祝顺利。本文还有配套的精品资源点击获取