简介一份C#排队机叫号系统源代码面向C#初学者以及正在为排队机、叫号机设计而苦恼的开发者提供一套可整体参考的商用级项目实现。代码经过客户多年实际使用未出故障其稳定性足可作为学习蓝本系统模块划分较完整涵盖取号排队、窗口呼叫、屏幕展示、数据存取等主要环节能帮助读者快速看懂排队业务的流转过程与WinForm界面交互方式。包体为RAR压缩包共249个文件、1.29MB体量精简但结构丰富。172个.cs源文件承载核心业务逻辑44个.resx文件保存窗体布局、控件属性与本地化资源9个.csproj工程文件、9个.ico图标以及2个.rdlc报表定义一并补齐编译、显示和打印所需的模块目录清晰便于逐段查阅。目前已有1617人学习下载。和入门级示例不同它保留了数据库操作封装、公共工具类、屏幕显示窗体等多类可直接改动的源码模块适合用来自学项目分层与数据访问默认界面可自行替换作为排队叫号系统二次开发或课程设计起点都很实用。1. 排队机叫号到底难在哪一张小票背后的状态流转很多人以为排队机叫号就是把号打出来、叫下一个真正动手写过源码的人才知道难点从来不在打印小票而在状态管理。营业厅、政务大厅、医院药房里的那套取号→窗口叫号→过号重叫→办结流程落到代码里就是一张票从生到死的状态流转。这个方向适合要给内部做轻量叫号系统的开发者也适合想抄一套可靠源码的初学者。反直觉的结论是别急着上分布式先把状态机画对。否则并发一上来不是重复叫号就是丢号线上翻车时你连问题都复现不出来。下面的实现是我接过几个类似小需求后沉淀下来的最小可靠方案全部代码围绕 SQLite 和状态字段展开换 MySQL 也只改一行连接串。2. 叫号系统数据模型为什么不能用内存队列硬扛2.1 一张 queue_ticket 表把取号、叫号、过号全装下常见做法是一上来就用 Redis List 或者 Python 的 deque认为叫号就是从队头弹一个。这个思路在演示 Demo 里跑得通业务一复杂就露馅过号的人要重叫窗口要指定叫某个号领导要查今天的排队记录服务一重启队列全丢。队列里存的不该是一个人而是一张票的完整状态。我一般用一张关系表做主存储选 SQLite 是因为单文件、零运维一个营业厅十几个窗口的并发量完全扛得住将来要上 MySQLSQL 基本不用改只换连接层。核心字段见下表字段类型说明idINTEGER 主键自增全局有序取号顺序靠它ticket_noTEXT 唯一索引展示给客户的号码如 A048statusINTEGER0 待叫 / 1 已叫 / 2 过号 / 3 完成 / 4 作废window_idTEXT当前呼叫的窗口编号created_atTEXT取号时间ISO 格式called_atTEXT最近一次叫号时间finished_atTEXT办结时间status 用整数不用字符串是为了让状态跳转能用一条 UPDATE ... WHERE status? 完成天然带并发保护。ticket_no 必须加唯一索引这是后面防重复取号的最后一道保险。时间存 ISO 字符串而不是时间戳是为了调试时人眼能直接读排序在 SQLite 里字符串和数字效果一致。建表语句如下注意唯一索引和 WAL 模式CREATE TABLE IF NOT EXISTS queue_ticket ( id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_no TEXT NOT NULL, status INTEGER NOT NULL DEFAULT 0, window_id TEXT, created_at TEXT NOT NULL, called_at TEXT, finished_at TEXT ); CREATE UNIQUE INDEX IF NOT EXISTS idx_ticket_no ON queue_ticket(ticket_no); PRAGMA journal_modeWAL;WAL 模式值得多说一句它让读操作不阻塞写操作多线程取号和查状态同时进行时不会报 database is locked。代价是会产生 -wal 和 -shm 两个附属文件备份时要把它们一起带上否则数据可能不是最新的。2.2 取号接口的最小实现编号生成与并发兜底取号的核心是生成当天序号。最常见的写法是 SELECT COUNT(*) 1但并发取号时两个请求同时查到同一个 COUNT就会打出两张一样的票。所以我的做法是编号生成照做但数据库唯一索引在底下兜底撞号就重试。import sqlite3 from datetime import datetime DB_PATH queue.db def get_conn(): conn sqlite3.connect(DB_PATH, timeout5) conn.execute(PRAGMA journal_modeWAL) return conn def take_number(prefixA): 取号生成并插入一张新票返回票号 conn get_conn() try: today datetime.now().strftime(%Y-%m-%d) with conn: cur conn.execute( SELECT COUNT(*) FROM queue_ticket WHERE ticket_no LIKE ? AND created_at LIKE ?, (prefix %, today %) ) seq cur.fetchone()[0] 1 ticket_no f{prefix}{seq:03d} conn.execute( INSERT INTO queue_ticket (ticket_no, status, created_at) VALUES (?, 0, ?), (ticket_no, datetime.now().isoformat()) ) return ticket_no except sqlite3.IntegrityError: # 并发撞号重新生成 return take_number(prefix) finally: conn.close()这段代码的逻辑是先数当天同前缀有多少张票再加一得到新序号插入成功后把票号返回给前端打印。第一遍 SELECT 不是线程安全的但 INSERT 撞了唯一索引会抛 IntegrityError递归重试就能拿到下一个可用号。两个参数需要按现场调prefix 是号别前缀常见场景里有 A 普通号、B 老年号、C 预约号不同队列互不干扰seq 的位数决定了号面宽度03d 会补成 A048超过 999 后会自动变四位数不用你管。失控点是递归重试——极端情况下可能栈溢出生产环境请把递归改成 for 循环最多重试三次三次还撞说明表里数据异常要告警而不是闷头重试。还有一条血泪经验小票打印必须放在事务提交之后。曾经有个项目把打印机调用放在事务内打印机卡纸导致事务一直不提交后面所有取号请求全部堵住。正确顺序是先 INSERT 提交拿到票号再驱动打印机打印失败就让窗口手动补打数据库记录不能回滚。2.3 窗口叫号接口用条件 UPDATE 避免两个窗口抢同一单窗口叫号最怕两个窗口同时按下呼叫下一个把同一个号叫走。新手写法是先 SELECT 取最早的待叫号再 UPDATE 改成已叫这两步之间有间隙并发时就会抢到同一行。解法是把取出锁定合并成一条带条件的 UPDATE。SQLite 的 UPDATE 是串行执行的同一个时刻只有一个请求能把 status 从 0 改成 1另一个请求的 rowcount 会是 0说明没抢到重试即可。def call_next(window_id, prefixA): 窗口叫号取最早待叫号并标记为已叫返回票号 conn get_conn() try: for _ in range(3): with conn: cur conn.execute( UPDATE queue_ticket SET status 1, window_id ?, called_at ? WHERE id ( SELECT id FROM queue_ticket WHERE status 0 AND ticket_no LIKE ? ORDER BY id ASC LIMIT 1 ) AND status 0, (window_id, datetime.now().isoformat(), prefix %) ) if cur.rowcount 0: cur conn.execute( SELECT ticket_no FROM queue_ticket WHERE id (SELECT id FROM queue_ticket WHERE status 1 AND window_id ? ORDER BY called_at DESC LIMIT 1), (window_id,) ) return cur.fetchone()[0] return None finally: conn.close()这条 SQL 的关键在子查询和 WHERE status 0 的叠加子查询选出最早的待叫号 ID外层 UPDATE 再确认这一行确实还是待叫状态。两个窗口并发时只有一个 UPDATE 的 rowcount 是 1另一个是 0循环重试就会叫到下一个号。窗口只能用自己所里配置的 prefix避免 A 窗口把 B 队列的号叫走这个过滤条件放在 ticket_no LIKE 里。如果你的现场所有窗口都能叫所有队列去掉 LIKE 条件即可。叫号动作完成后大厅屏那边通过查询 status 1 且 called_at 最新的记录刷新正在呼叫。3. 过号与重叫的完整状态机源码里最容易写崩的两个动作3.1 状态跳转表先定规矩再写代码服务端所有接口本质上都是对 status 字段的受控修改。我每次动手前会先把跳转表画出来贴在代码注释里所有函数照着表写不要自由发挥。这个表也是评审代码时第一件要核对的东西。当前状态允许的动作目标状态触发场景0 待叫窗口叫号1 已叫正常呼叫1 已叫标记过号2 过号叫了三遍没人来1 已叫办理完成3 完成业务办结2 过号窗口重叫1 已叫客户回来了2 过号办理完成3 完成过号后直接办0/1/2作废4 作废系统误操作、票面损坏为什么一定要这张表因为没有它你会自然写出过号就是把这条记录删掉的代码。删掉一时爽查账时发现号不连续领导问你 A023 去哪了你只能瞪眼。正确做法是过号只改状态记录永远留在表里今天的业务数据随时能回溯。3.2 过号标记与重叫绑定窗口防止二次抢号过号和重叫是这套源码里最容易写崩的两个动作。很多人把重叫实现成把这张票重新插回队列尾这是错的客户人是回来了但他的号一旦回到队尾就会排到所有新取号的人后面实体的队伍规则被代码改掉了现场会吵起来。正确做法是把状态从 2 改回 1让原窗口再播报一次号码不挪位置。def mark_no_show(ticket_no): 过号仅允许已叫状态的票变成过号 conn get_conn() try: with conn: cur conn.execute( UPDATE queue_ticket SET status 2 WHERE ticket_no ? AND status 1, (ticket_no,) ) return cur.rowcount 0 finally: conn.close() def recall(ticket_no, window_id): 过号重叫绑定窗口仅允许原窗口把过号票叫回 conn get_conn() try: with conn: cur conn.execute( UPDATE queue_ticket SET status 1, window_id ?, called_at ? WHERE ticket_no ? AND status 2, (window_id, datetime.now().isoformat(), ticket_no) ) return cur.rowcount 0 finally: conn.close()mark_no_show 的 WHERE 条件带 status 1保证只有正在被呼叫的票能过号。如果窗口对一个还没叫过的号点过号rowcount 会是 0接口直接告诉前端操作无效这就拦住了一半误操作。recall 的玄学在于必须绑定 window_id。真实场景里 A 窗口过号的客户可能跑到 B 窗口问轮到我没B 窗口一查确实过号了顺手就点重叫结果人还在 A 那边B 窗口白喊三遍。我在表结构里没有单独加归属字段直接用 window_id 当归属recall 里带上当前窗口号谁过号谁重叫跨窗口重叫交给现场人工协调。重叫不改动队列顺序还有个额外好处called_at 更新成当前时间大厅屏按 called_at 排序展示时这个号会跳到正在呼叫最前面视觉上就是原窗口重新喊了一遍符合直觉。3.3 完成与作废终态处理和大厅屏刷新办结动作在代码里最不起眼但它决定了这张票能否进入统计报表。完成和作废都是终态终态之后任何状态跳转都必须失败这是状态机设计的底线。def finish_ticket(ticket_no): 办结已叫或过号的票进入终态 conn get_conn() try: with conn: cur conn.execute( UPDATE queue_ticket SET status 3, finished_at ? WHERE ticket_no ? AND status IN (1, 2), (datetime.now().isoformat(), ticket_no) ) return cur.rowcount 0 finally: conn.close() def void_ticket(ticket_no): 作废未完成的票直接作废保留记录 conn get_conn() try: with conn: cur conn.execute( UPDATE queue_ticket SET status 4 WHERE ticket_no ? AND status IN (0, 1, 2), (ticket_no,) ) return cur.rowcount 0 finally: conn.close()finish_ticket 允许从已叫或过号直接办结是因为现场经常出现客户过号后直接来办不需要再喊一遍的情况。void_ticket 允许从待叫作废用于取号机多打了一张空白票。两个函数都把终态排除在 WHERE 条件之外所以已经完成或作废的票再调这些接口会返回 False前端拿到 False 提示该号已结束不会产生脏数据。大厅屏的数据源我一般不做复杂的推送让窗口屏每三秒轮询一次接口查最近一条 status 1 的记录。几百个号以内的量级完全够用轮询接口记得按 called_at 倒序因为过号重叫会刷新 called_at倒序才能让最新呼叫顶到最前面。4. 排队叫号常见问题排查编号重复、重复叫号、重启丢号4.1 现象并发取号时打出了两张相同票号现象是两台取号机同时出票一张 A048另一张也是 A048客户拿着号互相瞪眼。原因很直接票号由SELECT COUNT INSERT两步组成两个请求在同一时刻读到相同 COUNT生成相同序号。解决靠三道防线第一调用端加分布式锁只能锁单机多台取号机锁不住所以不推荐第二靠数据库唯一索引兜底撞号时 INSERT 抛异常捕获后重新生成第三真上了高并发把票号的序号来源换成数据库自增 ID插入后拿 cur.lastrowid 格式化彻底告别 COUNT 方式。我现在的主力做法是第三种自增 ID 永远不会重复连重试逻辑都省了。4.2 现象两个窗口同时叫到了同一个号窗口端 A 和 B 同时按呼叫下一个语音播报同时喊出 A048。原因就是先 SELECT 再 UPDATE 的经典竞态两窗口查到的都是最早的待叫号然后各自 UPDATE 成功。解决方法是第二章里 call_next 的写法UPDATE 语句里带 WHERE status 0并把子查询放进 UPDATE两个并发请求只有一个能命中另一个 rowcount 为 0 后重新取号。这里有个排查技巧如果线上还出现重复叫号先看窗口端代码是不是绕过了服务端接口自己用本地列表维护队列那属于客户端逻辑和数据源不一致必须强制窗口屏的所有状态变更都走服务端。4.3 现象过号重叫后又被别的窗口抢走W1 窗口叫了 A050 没人来标记过号。五分钟后客户回来了W1 点重新呼叫这时 W2 窗口正好也在点叫号结果 A050 被 W2 叫走。原因是重叫动作没有绑定原窗口任何窗口都能对过号票执行重叫。解决方法是 recall 接口强制传入 window_id并且只允许把状态为 2 且 window_id 等于当前窗口的票改成已叫。我的表结构里没有单独的归属窗口字段window_id 第一次叫号时被写入过号时保留重叫时校验一致性这是最简单可靠的方案。如果现场要求谁都可以重叫那就要在界面上加二次确认弹窗否则这种抢号投诉会一直出现。4.4 现象服务一重启排队数据全没了重启之后所有客户的号都没了这是用内存队列做存储的典型翻车现场。虽然很多人觉得现场就几十个人存内存够了但营业厅的排队数据要留存到当天结束中途服务发布、断电都要能恢复。解决思路只有一个所有状态变更落库取号成功即提交事务。落库后还有另一个坑——恢复现场。我习惯在启动时加一段自检逻辑查询 status IN (0,1,2) 的记录把已叫状态全部改成过号并记录一条日志服务重启未完成呼叫全部转过号。这样重启后客户回来还能重叫不会出现窗口喊了但库里没有的状态。4.5 现象大厅屏显示的号码和窗口实际叫的不一致屏上写着正在呼叫 A048窗口语音已经在叫 A049。这种问题十有八九不在服务端而在前端刷新时序大厅屏轮询时正好赶上 A049 刚被叫出但还没提交或者前端用了本地缓存排序。排查顺序我建议先看数据库查 status 1 的记录按 called_at 倒序取第一条如果库里是 A049 而屏幕显示 A048那是前端渲染问题如果库里也是 A048那是叫号逻辑问题。解决前端问题让显示端每次轮询都带上本地时间戳服务端返回的数据如果比本地旧就丢弃或者干脆不用缓存、每次都重新拉取。另外注意过号重叫会刷新 called_at显示端判断当前呼叫必须用 called_at 而不是 ticket_no 大小否则重叫的号永远排不到最前面。5. 跑完代码这样验证用并发脚本检查重复号和状态跳转5.1 并发取号压测脚本写完核心源码别急着接界面先用脚本把并发场景压一遍。下面的脚本模拟 10 个线程同时取号每个线程取 20 个然后查重复号import threading import queue_service as qs def worker(_): for _ in range(20): qs.take_number(A) threads [threading.Thread(targetworker, args(i,)) for i in range(10)] for t in threads: t.start() for t in threads: t.join() conn qs.get_conn() rows conn.execute( SELECT ticket_no, COUNT(*) c FROM queue_ticket GROUP BY ticket_no HAVING c 1 ).fetchall() print(重复号检查:, rows if rows else 通过无重复)脚本跑完会输出两种结果有重复号说明唯一索引兜底逻辑出了问题无重复则说明并发取号稳定。10 个线程 200 个号对 SQLite 来说压力不大建议再跑一轮 50 线程版本观察取号耗时是否线性增长。5.2 一条命令把叫号全流程走一遍并发只是第一关状态跳转的完整性要用一条脚本按真实业务路径走到底取号、叫号、过号、重叫、完成每个环节检查返回值。t qs.take_number(A) print(取号:, t) called qs.call_next(W1, A) print(叫号:, called) ok qs.mark_no_show(t) print(过号:, ok) ok qs.recall(t, W1) print(重叫:, ok) ok qs.finish_ticket(t) print(完成:, ok) # 验证终态不可再跳 ok qs.finish_ticket(t) print(终态二次完成应失败:, ok)最后一行是关键finish_ticket 对已完成的状态应该返回 False。如果你发现终态还能被改说明 WHERE 条件写漏了这是状态机最常见的破口。我习惯再加一个 SQL 做全面巡检统计每个状态的数量期望结果是从大到小递减待叫 0、已叫 0、过号 0、完成 200任何状态出现负数或票号断档都要查。这套验证方法本身不复杂但它逼着我把所有接口的边界条件都过了一遍。后来我养成的习惯是改任何状态相关代码先跑一遍全流程脚本再跑一遍并发脚本。状态机这种东西黑匣子式的看起来正常最害人等线上冒出重复号再回头查成本是写代码时的十倍。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?