简介:一份完整可运行的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_number:
CREATE 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;唯一键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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;call_log相当于审计日志。窗口每次操作都会写一条,后期做“平均等待时长”“每个窗口叫号量”这类统计,直接对这张表做 GROUP BY 就行。我在做类似项目时习惯把action字段值固定为英文小写,避免中文编码问题,也方便程序里枚举转换。
2.3 核心状态流转:取号、叫号、重呼、过号、销号
queue_number.status一共五个值,整套代码的核心就是把这个状态按规则改来改去。先看正常流程:取号端插入一条记录,status=0;窗口点击叫号,status 变成 1,大屏显示“请到X号窗口”;用户开始办理时,窗口点“办理中”,status=2;办完点“完成”,status=4。
过号和重呼是两个容易被绕晕的分支。窗口点“过号”,status 变成 3,号码不会立刻回到等待队列,而是等管理端一个定时任务把“过号超过 N 分钟的号码”重新置为 0,并且call_count加 1。这个 N 在管理端配置,默认 10 分钟。这样设计是为了避免过号用户刚走开就被叫第二次,同时也给预约取号留了窗口期。如果你在代码里看到某个定时器在查status=3 AND called_at < NOW() - INTERVAL 10 MINUTE,那就是这个逻辑。
状态流转里最容易踩的坑是“重呼”。用户没听到叫号,窗口再点一次“呼叫”,此时call_count会增加,但 status 如果已经是 1,就不要再走一遍“从等待变成呼叫”的 update 条件。项目里叫号端区分了两个按钮:一个是“呼叫下一个”,只对 status=0 的号码生效;另一个是“重呼”,对当前已呼叫号码重复播报,不改变状态。写代码的时候如果直接把两个按钮指向同一个方法,就会出现“同一个号码被反复从等待改成呼叫”的问题,这会打乱后面的过号时间计算。
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_type=@bt " + "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 status=1, window_id=@wid, called_at=NOW(), call_count=call_count+1 WHERE id = (SELECT id FROM ( SELECT id FROM queue_number WHERE business_type=@bt AND status=0 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 status=1 AND window_id=@wid ORDER BY called_at DESC LIMIT 1再补一条查询,我建议改成SELECT ... FOR UPDATE或者用UPDATE的返回值配合临时变量,避免在多线程下日志串号。严格说这是项目里一个不起眼的隐患,但实际使用中窗口叫号频率不高,问题还没暴露出来。
3.3 大屏显示端:用两秒轮询刷新当前呼叫号码
大屏端不写库,只读取状态为“呼叫中”的最新记录。项目里用的是 WinForms 的Timer,设置Interval=2000,每两秒查一次。
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_id=w.id WHERE q.status=1 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.2
- MySQL 8.0 社区版
- MySQL 连接组件 MySql.Data,通过 NuGet 安装(版本不低于 8.0)
- Windows 10/11,建议中文环境
如果你本机已经装了 MySQL 5.7,代码基本兼容,但建表脚本里用了utf8mb4字符集,5.7 也支持。需要注意SslMode设置,MySQL 8.0 默认开启 SSL 要求,连接字符串里如果没写SslMode=none,C# 里可能报“无法连接,SSL 连接失败”。项目源码里App.config的默认连接串是:
<connectionStrings> <add name="QueueDB" connectionString="Server=localhost;Database=queue_db;Uid=root;Pwd=123456;Charset=utf8mb4;SslMode=none;Connection Timeout=5;Max Pool Size=50;" /> </connectionStrings>这个连接串里的每一项都有意义:Charset=utf8mb4是为了防止中文乱码;Connection Timeout=5是连接超时,避免数据库不可达时界面卡死;Max Pool Size=50是连接池上限,防止频繁开关窗口导致连接数爆掉。如果你用的是 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,结果重新进入等待队列的号码马上被叫号端叫起,但用户已经离开了,导致这个号码不停“叫号→过号→重排”,后面的号码全部卡住。
原因:过号重排逻辑没有加时间延迟,或者只判断了 status=3 就立刻重置。一旦用户不在现场,重新排队后又被叫,形成死循环。
解决:在管理端定时任务里,把查询条件从WHERE status=3改为WHERE status=3 AND called_at <= NOW() - INTERVAL 10 MINUTE,也就是过号至少 10 分钟后才允许重新进入等待。同时,叫号端在挑选下一个等待号码时,只选那些status=0且called_at为空或者大于等于当前时间的记录。如果过号码重新排队,它的called_at保留的是本次重新进入的时间,也推荐在表中增加一个ready_at字段专门记录“可被再次叫号的时间”,这样查询条件更清晰。
5.4 坑四:运行一段时间后,叫号端点击无响应,卡死在数据库操作
现象:窗口端运行半天后,点击“呼叫下一号”无反应,过几分钟又恢复,数据库连接池报错。
原因:连接没有及时关闭,事务没有 commit 或 rollback,导致 MySQL 连接被占满。项目里早期版本的代码在CallNext的catch里只Rollback()没有关闭连接,而using块又只包了部分代码,久而久之池里的连接都被耗尽。
解决:把MySqlConnection和MySqlCommand都放入using块,保证方法结束后连接一定释放;同时在连接字符串里设置Max Pool Size=50,并按需调低,比如 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 status=1, window_id=@wid, called_at=NOW(), call_count=call_count+1 WHERE id = ( SELECT id FROM ( SELECT id FROM queue_number WHERE business_type=@bt AND status=0 ORDER BY is_reserved DESC, id ASC LIMIT 1 ) tmp )这里把原来的ORDER BY id改成ORDER BY is_reserved DESC, id ASC,预约号排在普通号前面;同优先级内依然按取号顺序。这是一个很典型的最小改动,不影响取号端和大屏端逻辑。
6.3 叫号后触发短信通知
用call_log里的action='call'记录,可以做到“叫号后自动发短信提醒”:
if (affected > 0) { // 从queue_number查手机号 string phone = GetPhoneByQueueId(queueId); string message = $"您的号已到{windowNo}窗口,请尽快前往"; SmsSender.Send(phone, message); }实际项目中手机号字段会放在用户表,这里可以按queue_id关联查询。注意短信接口要异步调用,不能阻塞叫号事务,我给这种操作加了一个ThreadPool.QueueUserWorkItem,避免短信通道慢拖垮叫号响应。做完这步,整个叫号系统就不再是本地单机排队,而是“预约-取号-优先叫号-短信提醒”的完整闭环。通过这一次改造,你会更清楚源码里哪些方法该拆、哪些查询该优化。现在我拿到旧项目,一定先画一张状态流转图,再把每个状态变更核对一遍,确认无重复无死循环后才敢加新功能,希望帮到你。
本文还有配套的精品资源,点击获取