当前位置:首页 > 智能制造 CIM/MES > MES 高并发架构优化 > 正文内容

消息队列在FAB生产调度的应用:用RabbitMQ实现工序自动排程

消息队列在FAB生产调度的应用:用RabbitMQ实现工序自动排程

【摘要】我们厂的工序排程原来是每15分钟跑一次全量重算,一次要六分多钟还锁表,插单得等下一轮,计划员天天在车间跑腿改派工单。这篇写我怎么用RabbitMQ把排程从定时全量改成事件驱动的局部重排,包括三个真实踩坑:自动ack丢消息导致批次卡了四小时、同设备并发排程派出两个工单、设备报警引发五万条事件风暴打爆消费者。附生产节拍与库存周转量化对比表、队列路由设计表和完整消费者代码。

栏目:MES高并发架构优化 | 发布日期:2026-08-15 | 环境:RabbitMQ 3.12 / php-amqplib 3.5 / ThinkPHP 6.1 / Redis 6.2 / 132台设备 / 日均调度事件18万条

一、问题背景:计划员在车间跑了三年腿

先交代场景。我们是功率半导体后段封测厂,132台生产设备,划成六个工艺区,产品品种大概四百多个料号,典型的多品种小批量,一天要处理两百到三百个工单的流转。这种生产模式的调度难度比大批量单一产品高得多,因为每台设备上换一个料号就要换配方、可能要换治具,换线一次少则十分钟多则四十分钟,而且不同工序的加工时间差异很大,前后道产能一旦不匹配就在中间堆一堆在制品。

改造之前,我们的排程是这么运作的:MES里有个定时任务,每十五分钟跑一次,把全厂所有待加工工序和所有可用设备拉出来,按一套写在存储过程里的规则算一遍优先级,生成每台设备接下来要做的三个工单,写进派工表,车间的电子看板从派工表读数据显示。

这套东西有四个要命的问题。第一是慢且重:全量重算一次要六分十几秒,因为它每次都从零算全厂,而且中间要锁派工表防止读到中间状态,这六分钟里车间看板刷新出来是空的或者是旧的。第二是不及时:一个工序刚完工,设备就空出来了,但排程要等下一轮才知道,最坏情况设备干等十五分钟。我们量过,全厂设备每天累计待料三点八小时(按设备数平均),这里面有相当一部分就是这么等出来的。

第三是插单无解。半导体行业客户催货是常态,业务今天下午说这批货必须明天早上出,计划员改完系统里的优先级标记,还要等下一轮全量重算才生效,而且经常算出来的结果还是不对,因为规则是写死的存储过程,急件权重要改就得改SQL发版。实际做法就变成了:计划员打印一张纸,跑到车间跟每个工位的组长口头交代「这批先做」。这个动作我们那位计划员做了三年,一天走两万多步。

第四是不可观测。排程为什么把A工单排在B前面?存储过程里那三百多行SQL没人说得清,出了问题只能靠猜,车间不信任系统的排程结果,干脆自己按经验做,于是MES的派工数据和实际生产越走越远,最后连产能分析都不准了。这一条其实是最严重的,系统失去信任之后,前面三个技术问题解决了也没意义。

二、技术原理:为什么排程适合做成事件驱动

2.1 全量重算与增量事件的根本差别

定时全量重算的思路,本质上是「不知道什么变了,所以全部重算一遍」。它的计算量跟全厂规模成正比,跟实际变化量没关系——哪怕这十五分钟里只有一台设备完工了,它照样把132台设备和几千条待加工工序全算一遍。而事件驱动的思路是「知道什么变了,只重算受影响的部分」。在排程这个问题上,一次工序完工只影响这一台设备的待加工队列,重算它一台的候选队列大概只需要几十毫秒。计算量从O(全厂规模)降到O(单设备队列),这就是从六分钟到八秒的数量级差别来源。

那么排程需要响应哪些事件?我们最终梳理出五类:工序完工(设备空出来了,要排下一个)、物料到位(原来缺料的工序现在可做了)、设备状态变化(报警、停机、维修完成恢复)、工单优先级调整(插单、降级、取消)、以及计划变更(交期改了、数量改了)。这五类事件都由MES的业务动作自然产生,我们只需要在原来的业务代码里加一行消息发布,并不需要额外的探测机制。

2.2 RabbitMQ的几个能力刚好对上排程的需求

选RabbitMQ而不是Kafka,是因为排程场景要的是灵活路由和单条消息的精细控制,而不是超高吞吐的日志流。我们日均调度事件十八万条,峰值每秒也就一百多条,这个量级RabbitMQ完全无压力,而它有几个能力恰好对上我们的需求。

第一是topic类型的exchange加路由键通配。我们的路由键设计成「eqp.区域.分片.事件类型」四段,比如eqp.area3.shard5.op_done。这样一个队列可以用eqp.*.shard5.#这样的模式把某个分片的所有事件都收进来,而急件插单可以用eqp.*.*.hot_insert单独绑到另一个队列。路由规则改起来只是改绑定,不用动代码。

第二是优先级队列。声明队列时带上x-max-priority参数,发布消息时指定priority,高优先级消息会插到队首。急件插单给优先级10,正常完工事件给5,低优先级的定期巡检重排给1。这一条直接解决了计划员跑腿的问题:她在系统里点一下急件,消息带最高优先级进队,几秒钟后车间看板上就变了。

第三是消费可靠性的一整套机制:消息持久化加发布确认保证消息不丢,手动ack保证消息处理完才算消费掉,死信交换机接住处理失败的消息,prefetch控制单消费者的在途消息数。这几样在排程场景里都不是可选项,因为丢一条排程指令的后果是一台设备或一个批次停在那里没人管,而且这种「静默失败」极难发现,这正是我们踩的第一个坑。

第四是队列分片实现有序性。排程有个硬约束:同一台设备的排程计算必须串行,否则两个并发的计算会各自算出一个结果互相覆盖,甚至把同一个工单派给两台设备。RabbitMQ本身保证单队列单消费者的消息是有序的,所以我们的做法是按设备ID哈希取模分成八个分片,同一台设备的所有事件恒定落到同一个分片队列,每个分片队列只挂一个消费者。这样既保住了同设备串行,又让八个分片之间可以并行,兼顾了顺序和吞吐。

图1:数据取上线前后各连续30个生产日的均值,剔除设备大修与停线日。节拍与WIP是越低越好,周转率与达成率是越高越好,其中在制品周转由8.6次/月提升到13.4次/月是财务侧最认可的一项。

三、实战案例:三个坑和一次五万条的事件风暴

3.1 坑一:自动ack丢消息,一个批次卡了四小时

第一版上线是个周一,跑得挺顺,我还挺得意。周三下午车间来找我,说有个批次从上午十点多就一直显示在等待排程,设备那边什么都没收到,已经卡了四个多小时。我查日志,发现那条工序完工事件确实发出来了,RabbitMQ的管理界面上也确实被消费了,但派工表里就是没有对应记录。

根因是我在basic_consume里把no_ack参数设成了true,也就是自动确认——消息一投递给消费者,RabbitMQ立刻就认为它被成功处理了,从队列里删掉。而那个时间点我们做过一次消费者滚动重启(发版),重启瞬间已经投递到消费者内存里但还没处理完的消息,就随着进程退出一起消失了,RabbitMQ这边已经删了,消费者那边没处理完,这条消息就人间蒸发。

改法是三条一起上。第一,把no_ack改成false用手动ack,业务处理完成后才调用ack,处理过程中进程挂掉,消息会自动重新入队投给别的消费者。第二,给所有失败路径配死信队列,重试三次仍失败的消息进死信,绝不允许静默丢弃。第三,也是最重要的一条,加业务层的兜底巡检:有个低优先级定时任务每五分钟扫一遍「状态是待排程且等待超过三分钟」的工序,发现就补发一条重排事件。这个兜底任务是我从这次事故里学到的最有价值的东西——事件驱动架构一定要有一个基于状态而非事件的补偿机制,因为事件可能丢、可能被漏掉,但数据库里的状态是真的。我们线上这个巡检平均一天补发四五条,数量不多,但每一条都是一次避免的停线。

3.2 坑二:同一台设备被派了两个工单

第二个坑出现在两周后,现象很怪:有台键合机的看板上,同一个序号位置先显示工单A,几秒后变成工单B,组长按A准备好治具,结果系统里要做B。

排查发现是并发覆盖。当时我为了提高吞吐,给排程队列挂了四个消费者,想着谁空谁拿。但这样同一台设备的两条事件(比如完工事件和物料到位事件几乎同时发生)会被两个消费者并发处理,各自读取待加工队列、各自算分、各自删除旧计划再写入新计划,两个事务交错执行,最后写进去的结果是错乱的。这在数据库层面是个典型的读-改-写竞态,我在设计时完全没意识到排程有顺序性要求。

解决方案就是前面原理部分讲的分片。按设备ID的CRC32哈希取模8,发布消息时把分片号拼进路由键,八个队列各挂一个消费者,同设备的事件必然串行。另外我还加了一层Redis设备锁做兜底:处理前用SET NX抢一个以设备ID为键的锁,抢不到就nack让消息重回队列稍后再试。这层锁理论上永远不该被触发,因为分片已经保证了串行,但我在监控里给它埋了计数,一旦计数不为零就说明分片路由出了问题,这是个很好用的架构自检信号。实际运行半年,这个计数触发过两次,都是有人手工往队列里补消息时路由键写错了。

3.3 坑三:一次设备报警引发五万条消息的风暴

这是最惊心动魄的一次。某天早上九点出头,一个工艺区的空压系统波动,导致该区十九台设备在两分钟内先后报警又恢复,有几台还反复跳了七八次。我们的规则是设备状态变化要触发重排,于是每次跳变都发一条消息。更糟的是我们当时有个级联逻辑:一台设备状态变化会触发它下游工序的重排评估,下游又触发下游的下游,结果这十九台设备的抖动被放大成了五万一千条消息。

消费者被彻底打爆,八个分片全部堆积,排程指令延迟从八秒涨到十分钟以上,等于瞬间退回改造前的水平,而且因为积压里混着大量已经过时的重排请求,算出来的结果也没意义。车间那半小时基本是靠组长自己判断在干活。

止血是手工的:我进管理界面把设备状态变化那个路由键对应的绑定临时解掉,让这类消息先不进主队列,积压消化完再恢复。整个过程大概十二分钟。

之后做了三层加固。第一层是去重合并窗口,也就是代码里那个dedup键:同一台设备的重排事件在两秒内只处理第一条,后面的直接ack丢弃。这么做的依据是排程是幂等的——重排一次算的就是当前最新状态,同一设备两秒内算五次和算一次结果完全一样。这一层直接把风暴的量级砍掉了十几倍。

第二层是砍掉级联触发。我重新审视了那个下游级联的逻辑,发现它的价值很有限:下游工序真正需要重排的时机是「上游完工物料到位」,而不是「上游设备报警」,后者只是可能影响未来的物料到位时间。所以我把级联整个去掉了,只保留物料到位这个真实事件触发下游。这是我的一个体会:事件风暴很多时候不是技术问题,是事件建模过度了,把「可能有关系」当成了「必须触发」。

第三层是发布端限流加上队列lazy模式。发布端按设备维度做令牌桶,每台设备每秒最多发一条重排事件;队列声明加上x-queue-mode为lazy,让消息直接落盘不占内存,万一再有积压不会把RabbitMQ的内存打爆触发流控。这三层做完,后来又遇到过一次类似的空压波动,积压峰值只有三百八十条,车间完全没感觉。

3.4 排程评分规则:把存储过程里的黑盒拿出来

顺便说下排程规则本身。我没有上什么复杂的APS算法,就是一套四因子加权评分,写在代码里而不是存储过程里:交期剩余时间越少分越高(权重1.5)、与设备当前配方相同则不扣分否则扣18分(换线成本)、已等待时长越长分越高(权重2.5,防止某个批次被永远饿死)、急件直接加40分。这套规则简单到可以口头解释给车间听,这一点比算法先进更重要——我们做了个页面把每台设备的候选队列和每个工单的得分明细展示出来,组长点开就能看到「为什么A排在B前面」。车间对系统的信任是从这个页面上线之后才建立起来的,而信任建立之后,实际执行和系统派工的一致率从原来的六成多升到了九成以上。

图2:V1在早班九点被一次设备报警引发的重排风暴顶到5.1万条积压,排程指令延迟超过十分钟,等于回到了改造前;加上去重合并与发布端限流后,全天峰值压到380条。

四、完整代码:排程消费者的完整实现

相关文章

MES系统数据库读写分离:主从复制+分库分表实战踩坑

MES系统数据库读写分离:主从复制+分库分表实战踩坑【摘要】上一篇把应用层从320 QPS压到12800之后,瓶颈完整地转移到了MySQL:主库CPU长期90%,质量追溯查询40秒超时。这篇记录我做读...