
简介一份完整可运行的C#排队机叫号系统项目源码面向C#初学者、计算机专业学生以及需要设计窗口叫号系统的开发者典型应用于银行、政务大厅、医院、餐饮等排队取号场景。压缩包只有1.29MB共含249个文件其中172个cs源代码文件组成核心逻辑另有44个resx资源文件、9个csproj项目文件、rdlc报表、settings配置和ico图标界面资源与业务代码分开存放整体工程结构清晰。源码覆盖呼叫操作、大屏显示、公共工具类、数据库操作封装等模块可系统学习WinForms界面设计、控件编排、数据库连接与增删改查实现以及多模块协作方式。这是一套长期在客户现场稳定运行的商用级项目作者未做删减直接放出具备较强借鉴意义可根据业务需要自行修改界面和功能。目前已有1618人学习/下载对希望获得真实完整项目练手的初学者尤其友好。1. 排队机叫号源代码一个把“号码递增”变成“数据库状态机”的真实项目在某三甲医院的分诊台取号机吐出“A012”大屏显示“请到3号窗口”窗口医生点击叫号、过号、重呼这一切背后并不是一个计数器那么简单。这套排队机叫号源代码是一个完整的 C# WinForms MySQL 应用包含取号端、叫号端、管理后台和大屏显示四个独立程序。它把“排队叫号”这件事拆成了号码生成、状态流转、窗口并发控制、过号重排、业务统计等多个模块尤其适合正在做 C# 数据库开发项目练手、毕业设计或想拿现成源码做医院/银行场景二次开发的开发者。第一次独立写这种系统的人十有八九会在“并发取两个相同号码”或“两个窗口叫到同一个人”这些地方翻车而这份源码全是用事务和锁解决这些问题的值得拆开讲讲。2. 总体架构与数据库设计四个程序共享一张MySQL表的协同逻辑拿到源码先不要急着跑建议先从前面的 README 和数据库脚本看起。这个项目最反直觉的地方是四个程序之间没有任何 Socket 或 HTTP 通信它们靠数据库表“说话”。所有叫号状态都写进数据库谁都可以读但“写”必须遵守同一套事务和锁规则。理解了这张数据总线后面所有代码读起来都通顺。2.1 模块划分取号端、叫号端、管理端、大屏显示端工程里一共四个可启动的 WinForms 项目共用同一个 queue_db 数据库。它们之间没有直连而是通过数据库表协作。模块可执行程序核心职责主要表取号端TakeTicket.exe用户按键取号生成号码并写库queue_number叫号端WindowClient.exe窗口叫号、重呼、过号、办理queue_number, call_log管理端AdminPanel.exe业务类型配置、窗口配置、过号后重排定时任务business_type, window_info大屏显示端LcdScreen.exe轮询当前呼叫号码显示到LED屏并语音播报queue_number取号端把号写进queue_number叫号端把状态从“等待”改成“呼叫中”大屏端去查“呼叫中”的最新号码整个链路是单向通吃的。这种设计的好处是每个程序都能独立启停窗口端崩了重启也不影响取号端坏处是并发写入全靠数据库兜底如果取号时不加锁不做唯一约束两个取号机同时按下去就会拿到重复号。项目里的做法是用事务锁住同一业务类型的最后一条记录再插入新号码。2.2 数据库表结构设计queue_number、window_info、call_log数据库脚本在sql/queue_db.sql里核心三张表是排队号表、窗口表和叫号日志表。先看最关键的queue_numberCREATE TABLE queue_number ( id INT NOT NULL AUTO_INCREMENT, business_type CHAR(1) NOT NULL COMMENT 业务类型A普通B优先, queue_no VARCHAR(10) NOT NULL COMMENT 展示号码如A012, status TINYINT NOT NULL DEFAULT 0 COMMENT 0等待 1呼叫中 2办理中 3过号 4完成 5注销, window_id INT DEFAULT NULL COMMENT 被哪个窗口叫起, call_count INT DEFAULT 0 COMMENT 被呼叫次数用于重呼/过号判断, created_at DATETIME NOT NULL, called_at DATETIME DEFAULT NULL, finished_at DATETIME DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_queue_no (business_type, queue_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;唯一键uk_queue_no是必须保留的它从数据库层面保证同一个业务类型里不会出现两个“A012”。但只有唯一键还不够取号时如果两个事务都先把自己查询到的最后号码加 1插入时唯一的那个会报重复另一个也会失败用户看到的就是“取号失败”。所以项目里在取号时对对应业务类型的记录做SELECT ... FOR UPDATE把并发降到串行这个细节后面章节专门说。窗口表和叫号日志表相对简单CREATE TABLE window_info ( id INT NOT NULL AUTO_INCREMENT, window_no VARCHAR(10) NOT NULL, window_name VARCHAR(50) DEFAULT NULL, is_active TINYINT DEFAULT 1, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE call_log ( id INT NOT NULL AUTO_INCREMENT, queue_id INT NOT NULL, window_id INT NOT NULL, action VARCHAR(20) NOT NULL COMMENT call/repeat/pass/finish, created_at DATETIME NOT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;call_log相当于审计日志。窗口每次操作都会写一条后期做“平均等待时长”“每个窗口叫号量”这类统计直接对这张表做 GROUP BY 就行。我在做类似项目时习惯把action字段值固定为英文小写避免中文编码问题也方便程序里枚举转换。2.3 核心状态流转取号、叫号、重呼、过号、销号queue_number.status一共五个值整套代码的核心就是把这个状态按规则改来改去。先看正常流程取号端插入一条记录status0窗口点击叫号status 变成 1大屏显示“请到X号窗口”用户开始办理时窗口点“办理中”status2办完点“完成”status4。过号和重呼是两个容易被绕晕的分支。窗口点“过号”status 变成 3号码不会立刻回到等待队列而是等管理端一个定时任务把“过号超过 N 分钟的号码”重新置为 0并且call_count加 1。这个 N 在管理端配置默认 10 分钟。这样设计是为了避免过号用户刚走开就被叫第二次同时也给预约取号留了窗口期。如果你在代码里看到某个定时器在查status3 AND called_at NOW() - INTERVAL 10 MINUTE那就是这个逻辑。状态流转里最容易踩的坑是“重呼”。用户没听到叫号窗口再点一次“呼叫”此时call_count会增加但 status 如果已经是 1就不要再走一遍“从等待变成呼叫”的 update 条件。项目里叫号端区分了两个按钮一个是“呼叫下一个”只对 status0 的号码生效另一个是“重呼”对当前已呼叫号码重复播报不改变状态。写代码的时候如果直接把两个按钮指向同一个方法就会出现“同一个号码被反复从等待改成呼叫”的问题这会打乱后面的过号时间计算。3. 核心代码拆解从取号事务到叫号原子更新的完整链路这一章是整套源码里最值得细读的部分。取号端和叫号端都涉及并发写库项目里用了事务和比较冷门的原子更新写法。我会把关键方法的逻辑抽出来讲附带参数说明和为什么这样写。3.1 取号端用事务和行锁生成不重复的号码取号端的核心方法是GenerateQueueNumber(string businessType)。它要做的事情是根据传入的业务类型查出当前最大号码加 1 生成新号码再插入队列。如果不加控制两个线程同时执行这段逻辑就会拿到同一个号码。public string GenerateQueueNumber(string businessType) { using (var conn new MySqlConnection(connStr)) { conn.Open(); MySqlTransaction tx conn.BeginTransaction(); try { // 锁定该业务类型的最后一条记录让并发取号变成串行 var lockCmd new MySqlCommand( SELECT queue_no FROM queue_number WHERE business_typebt ORDER BY id DESC LIMIT 1 FOR UPDATE, conn, tx); lockCmd.Parameters.AddWithValue(bt, businessType); int newNo 1; var result lockCmd.ExecuteScalar(); if (result ! null) { string noStr result.ToString(); newNo int.Parse(noStr.Substring(1)) 1; } string queueNo ${businessType}{newNo:D3}; var insertCmd new MySqlCommand( INSERT INTO queue_number(business_type, queue_no, status, created_at) VALUES(bt, qn, 0, NOW()), conn, tx); insertCmd.Parameters.AddWithValue(bt, businessType); insertCmd.Parameters.AddWithValue(qn, queueNo); insertCmd.ExecuteNonQuery(); tx.Commit(); return queueNo; } catch { tx.Rollback(); throw; } } }这个写法用了SELECT ... FOR UPDATE事务提交或回滚前其他事务对同一队列的相同查询会被阻塞。参数businessType决定了号码前缀比如传 A 生成 A012传 B 生成 B003。这里有两点要注意一是FOR UPDATE只能锁到已经存在的记录如果表是空的锁不住任何行第一次并发取号仍可能有重复二是 MySQL 默认隔离级别是REPEATABLE READ两个事务同时执行这个查询时都可能读到同一个旧值所以还需要依赖唯一键uk_queue_no兜底。项目里在catch中捕获了重复键异常并重试代码里没有很完整地写我一般在二次开发时会补一个for循环重试三次保证极端情况下用户体验不受影响。3.2 叫号端用原子更新避免两个窗口叫到同一个号窗口叫号最容易犯的错误是“先 SELECT 一个等待号码再 UPDATE 它的状态”。两个窗口同时执行 SELECT可能读到同一个 id然后都去 UPDATE导致两个窗口显示同一个号码。正确做法是把“查号”和“改状态”合成一条原子 SQL。public bool CallNext(string windowNo, string businessType) { using (var conn new MySqlConnection(connStr)) { conn.Open(); MySqlTransaction tx conn.BeginTransaction(); try { var cmd new MySqlCommand( UPDATE queue_number SET status1, window_idwid, called_atNOW(), call_countcall_count1 WHERE id (SELECT id FROM ( SELECT id FROM queue_number WHERE business_typebt AND status0 ORDER BY id LIMIT 1 ) tmp), conn, tx); cmd.Parameters.AddWithValue(wid, windowNo); cmd.Parameters.AddWithValue(bt, businessType); int affected cmd.ExecuteNonQuery(); if (affected 0) { tx.Rollback(); return false; // 当前队列没有等待号码 } // 写一条 from 还是 to 叫号日志 var logCmd new MySqlCommand( INSERT INTO call_log(queue_id, window_id, action, created_at) VALUES(LAST_INSERT_ID(), wid, call, NOW()), conn, tx); logCmd.Parameters.AddWithValue(wid, windowNo); logCmd.ExecuteNonQuery(); tx.Commit(); return true; } catch { tx.Rollback(); throw; } } }关键点在UPDATE ... WHERE id (SELECT id FROM (SELECT ... ) tmp)。MySQL 不允许直接在同一张表的子查询里更新自身所以多套了一层tmp别名。这个UPDATE会原子地选中最小的等待中 id并立刻把它改成呼叫中因为同时只能有一个事务对该行加锁第二个窗口执行到这条语句时要么等到第一个窗口提交后重新选下一行要么返回失败。affected等于 0 说明没有可叫的号码前端会提示“暂无可叫号”。这里windowNo是窗口编号比如 W1、W2在窗口端启动时输入或从配置文件读取。另外日志用的是LAST_INSERT_ID()获取刚更新行的 id。这里有个小陷阱UPDATE不会自动更新LAST_INSERT_ID()所以项目里先执行了 UPDATE然后又立刻执行 INSERT 时取到的可能是上一个 INSERT 的值。正确做法是先SELECT id INTO id再用它更新。源码里在这个版本是用SELECT id FROM queue_number WHERE status1 AND window_idwid ORDER BY called_at DESC LIMIT 1再补一条查询我建议改成SELECT ... FOR UPDATE或者用UPDATE的返回值配合临时变量避免在多线程下日志串号。严格说这是项目里一个不起眼的隐患但实际使用中窗口叫号频率不高问题还没暴露出来。3.3 大屏显示端用两秒轮询刷新当前呼叫号码大屏端不写库只读取状态为“呼叫中”的最新记录。项目里用的是 WinForms 的Timer设置Interval2000每两秒查一次。private void timerRefresh_Tick(object sender, EventArgs e) { string sql SELECT q.queue_no, w.window_no FROM queue_number q LEFT JOIN window_info w ON q.window_idw.id WHERE q.status1 ORDER BY q.called_at DESC LIMIT 1; using (var conn new MySqlConnection(connStr)) using (var cmd new MySqlCommand(sql, conn)) { conn.Open(); using (var reader cmd.ExecuteReader()) { if (reader.Read()) { string queueNo reader.GetString(queue_no); string windowNo reader.GetString(window_no); lblCurrent.Text ${queueNo} 请到 {windowNo} 号窗口; if (queueNo ! lastQueueNo) { lastQueueNo queueNo; // 调用本机TTS语音播报 SpeechSynthesizer sp new SpeechSynthesizer(); sp.SpeakAsync(${queueNo}请到{windowNo}号窗口); } } } } }轮询间隔设 2 秒是折中方案太短数据库压力大太长用户体验差。如果你要接到真正的大屏上我一般会改成长轮询或 WebSocket但在这个项目里用Timer配合数据库轮询已经够用。lastQueueNo用来对比号码是否变化只有变化时才触发语音播报避免每两秒重复播报同一个号。这里用了SpeechSynthesizer是 Windows 自带语音库不需要额外安装。4. 从源码到跑通环境准备、脚本导入和四个实例的启动顺序这一章直接照着操作十分钟能把系统跑起来。我会按环境、数据库、启动三步说清楚期间会遇到的一些版本匹配问题也会讲。4.1 环境准备与版本选择项目使用的开发环境是Visual Studio 2019 或 2022需要支持 C# WinForms.NET Framework 4.7.2MySQL 8.0 社区版MySQL 连接组件 MySql.Data通过 NuGet 安装版本不低于 8.0Windows 10/11建议中文环境如果你本机已经装了 MySQL 5.7代码基本兼容但建表脚本里用了utf8mb4字符集5.7 也支持。需要注意SslMode设置MySQL 8.0 默认开启 SSL 要求连接字符串里如果没写SslModenoneC# 里可能报“无法连接SSL 连接失败”。项目源码里App.config的默认连接串是connectionStrings add nameQueueDB connectionStringServerlocalhost;Databasequeue_db;Uidroot;Pwd123456;Charsetutf8mb4;SslModenone;Connection Timeout5;Max Pool Size50; / /connectionStrings这个连接串里的每一项都有意义Charsetutf8mb4是为了防止中文乱码Connection Timeout5是连接超时避免数据库不可达时界面卡死Max Pool Size50是连接池上限防止频繁开关窗口导致连接数爆掉。如果你用的是 MySQL 8.0 默认的caching_sha2_password认证而 MySql.Data 版本比较老会提示“Authentication method not supported”升级 MySql.Data 到 8.0.33 以上即可。4.2 初始化数据库执行建表脚本并导入测试数据源码包里sql目录下有两个文件queue_db.sql是建表脚本seed_data.sql是测试数据。打开命令行在项目根目录执行mysql -u root -p sql/queue_db.sqlqueue_db.sql里除了三张表还插入了business_type的初始数据。执行完建议用 Navicat 或 MySQL Workbench 打开看一下确认有队列表、窗口表、日志表。接着导入测试数据mysql -u root -p sql/seed_data.sqlseed_data.sql会插入三个窗口记录和几条不同状态的测试号码方便你启动大屏端时立刻看到效果。这里有个容易踩的小坑直接执行queue_db.sql时脚本里没有CREATE DATABASE需要先手动创建数据库CREATE DATABASE IF NOT EXISTS queue_db DEFAULT CHARACTER SET utf8mb4;如果跳过这一步mysql 会提示Unknown database。我在第一次跑这个项目时就因为漏建库卡了两分钟所以特意提一下。4.3 修改连接串并启动四个实例打开解决方案QueueSolution.sln用 Visual Studio 还原 NuGet 包。然后右键解决方案把四个启动项目全部设为“启动”或者编译生成后手动运行bin\Debug下的四个 exe。运行顺序建议是先启动AdminPanel.exe确认连接串正确能看到窗口列表再启动TakeTicket.exe点取号按钮观察数据库新增记录接着启动WindowClient.exe输入窗口号 W1点“呼叫下一号”最后启动LcdScreen.exe大屏上会出现刚呼叫的号码。如果窗口端调试需要多窗口并发测试可以在同一台机器上复制多个WindowClient.exe分别输入 W1、W2。因为它们用的是同一个数据库天然就是多客户端状态。测试时先在两个窗口都点击“呼叫下一号”正常情况下不会出现重复号码这正是后面避坑章里要重点验证的场景。整个跑起来之后你会看到大屏端每两秒刷新一次配合 Windows 语音朗读基本就是医院门口那种体验。5. 避坑指南并发取号、重复叫号和过号重排的踩坑记录这一章我把自己在这个项目里实际踩过的几个坑整理出来每个都按“现象 → 原因 → 解决”来写全部是真实会遇到的场景。5.1 坑一两台取号机同时取号生成了同一个号码现象取号端在两个线程同时执行取号数据库里出现两条A013大屏和窗口都展示出同一个号码用户现场投诉。原因取号方法里先查最大号码再插入查询和插入之间没有加锁。两个线程同时读到最大号码A012分别生成A013然后都插入成功。如果项目里补上了唯一键uk_queue_no第二个插入会直接抛异常但如果没有唯一键脏数据就进去了。解决给queue_number表加唯一键uk_queue_no(business_type, queue_no)同时在取号事务里使用SELECT ... FOR UPDATE锁定该业务类型的最后一行。再在捕获到重复键异常时重试一次就能把并发问题彻底堵住。我当时是把GenerateQueueNumber方法改成循环三次重试每次重试前重新查询第三次还失败就提示用户稍后再试。5.2 坑二两个窗口同时点击“叫下一号”窗口上显示同一个号码现象W1 和 W2 窗口几乎同时点击“呼叫下一号”两边都显示A020请到对应窗口数据库里只有一个A020但两个窗口都认为自己叫到了。原因叫号端最初用的是“先 SELECT 等待号码再 UPDATE 状态”两段式写法。W1 和 W2 同时 SELECT都拿到了A020紧接着都去 UPDATE两条更新都成功了最终只有一个窗口的window_id被更新但另一个窗口界面还是用查询到的号码显示造成了重复叫号的假象。解决把两段式合并成一条原子更新语句也就是 3.2 里的UPDATE ... WHERE id (SELECT ...)写法。这样同步只有一个事务能成功把A020的状态改成呼叫中另一个事务会被阻塞或返回影响行数为 0窗口端据此提示“没有可叫的号码”。从那以后我每次改叫号逻辑都特别注意任何“查出来再改”的代码在这个项目里都必须换成“条件更新”或“更新返回”。5.3 坑三过号号码重排后直接插到最前面把所有新号都堵住了现象用户点过号后脚本把 status 从 3 改成 0结果重新进入等待队列的号码马上被叫号端叫起但用户已经离开了导致这个号码不停“叫号→过号→重排”后面的号码全部卡住。原因过号重排逻辑没有加时间延迟或者只判断了 status3 就立刻重置。一旦用户不在现场重新排队后又被叫形成死循环。解决在管理端定时任务里把查询条件从WHERE status3改为WHERE status3 AND called_at NOW() - INTERVAL 10 MINUTE也就是过号至少 10 分钟后才允许重新进入等待。同时叫号端在挑选下一个等待号码时只选那些status0且called_at为空或者大于等于当前时间的记录。如果过号码重新排队它的called_at保留的是本次重新进入的时间也推荐在表中增加一个ready_at字段专门记录“可被再次叫号的时间”这样查询条件更清晰。5.4 坑四运行一段时间后叫号端点击无响应卡死在数据库操作现象窗口端运行半天后点击“呼叫下一号”无反应过几分钟又恢复数据库连接池报错。原因连接没有及时关闭事务没有 commit 或 rollback导致 MySQL 连接被占满。项目里早期版本的代码在CallNext的catch里只Rollback()没有关闭连接而using块又只包了部分代码久而久之池里的连接都被耗尽。解决把MySqlConnection和MySqlCommand都放入using块保证方法结束后连接一定释放同时在连接字符串里设置Max Pool Size50并按需调低比如 20让异常更容易暴露。排查时看数据库侧SHOW PROCESSLIST如果连接一直出现 Sleep 状态且没有关闭基本就是代码里漏了using或事务未结束。我养成的习惯是每次写完数据库方法都检查是否所有分支包括异常分支都会释放连接。6. 二次开发实战给叫号系统加上“预约优先”规则如果你已经跑通了原始源码下一步可以试着把系统改成“预约用户优先叫号”。这是一个真实需求很多医院和银行希望预约过的用户到现场取号后能插到普通号前面。6.1 从需求到扩展点在队列表中增加预约标记不用大改架构只需要在queue_number里加一个字段ALTER TABLE queue_number ADD COLUMN is_reserved TINYINT DEFAULT 0 COMMENT 1预约 0普通;同时增加一个预约取号入口调用现有的GenerateQueueNumber生成号后把这个字段更新为 1。也就是说取号端可以增加一个按钮“预约取号”。6.2 修改取号策略预约号和普通号共用队列取号端的GenerateQueueNumber不需要动因为号码本身还是会按 A/B 顺序生成。要改的是叫号端CallNext的查询排序UPDATE queue_number SET status1, window_idwid, called_atNOW(), call_countcall_count1 WHERE id ( SELECT id FROM ( SELECT id FROM queue_number WHERE business_typebt AND status0 ORDER BY is_reserved DESC, id ASC LIMIT 1 ) tmp )这里把原来的ORDER BY id改成ORDER BY is_reserved DESC, id ASC预约号排在普通号前面同优先级内依然按取号顺序。这是一个很典型的最小改动不影响取号端和大屏端逻辑。6.3 叫号后触发短信通知用call_log里的actioncall记录可以做到“叫号后自动发短信提醒”if (affected 0) { // 从queue_number查手机号 string phone GetPhoneByQueueId(queueId); string message $您的号已到{windowNo}窗口请尽快前往; SmsSender.Send(phone, message); }实际项目中手机号字段会放在用户表这里可以按queue_id关联查询。注意短信接口要异步调用不能阻塞叫号事务我给这种操作加了一个ThreadPool.QueueUserWorkItem避免短信通道慢拖垮叫号响应。做完这步整个叫号系统就不再是本地单机排队而是“预约-取号-优先叫号-短信提醒”的完整闭环。通过这一次改造你会更清楚源码里哪些方法该拆、哪些查询该优化。现在我拿到旧项目一定先画一张状态流转图再把每个状态变更核对一遍确认无重复无死循环后才敢加新功能希望帮到你。本文还有配套的精品资源点击获取