1. MES项目里哪些环节必须上存储过程:先找准三个高压场景
做MES这几年,我见过太多项目启动时定下规矩:"业务逻辑全部写在服务层,数据库只做存储。"结果呢?等第一条产线开始高频报工、等物料倒冲开始对不上账、等品质追溯开始按批次拉全链路数据时,团队又灰头土脸地把核心操作从ORM里拆出来,改写成存储过程。这个话题我反复验证过多次:MES 系统里真正吃性能的,往往是几个高频、强事务、大聚合的数据库操作,而这些操作天然适合用存储过程封装。今天不聊概念,拿三个我在产线上实际落地过的场景讲透:工序完工报工、物料倒冲扣料、批次追溯汇总,包括存储过程具体怎么写,才能扛住车间里的高并发。
1.1 从车间数据流看:为什么有些操作走ORM会特别难受
MES和普通管理系统有一个本质差别:它不是一个以查询为主的系统,而是一个以"收数据、改状态、写流水"为主的系统。生产线上的一个报工动作,数据流是从工位终端或扫码枪触发,后端要同时更新工单状态、工艺路线当前工序的完成数量、设备累计产量、人员绩效、不良品数量,还要判断是不是该触发下一道工序或整单完工。操作工不可能等你在服务层里一步一步循环调数据库,系统响应超过两秒,产线就堵住了。
这种场景下,如果用ORM一层一层地查询再更新,代码会变得极其绕:先查工单,再查工序,再判断状态,再逐条更新,最后再来一次行锁拼抢。每多一次网络往返,就多一分锁等待和超时的概率。更麻烦的是,多个业务动作必须在一个事务里保持一致,应用层事务和数据库锁之间一旦配合不好,死锁几乎是必然的。存储过程把十几步数据库操作压缩成一次数据库调用,事务边界在数据库内部闭环,网络往返次数降到最低,这是MES这类强事务系统选择它的根本原因。
1.2 为什么偏偏是报工、倒冲、追溯这三个场景
MES里能用得上存储过程的地方很多,但真正"必用"的,我理解是那些同时踩中三个特征的操作:第一,调用频率高,是产线上的日常动作,不是管理员偶尔点一下的菜单;第二,事务性强,数据一旦写错,直接造成工单物料账不平,后面所有人都在为这个错填坑;第三,数据访问模式复杂,不是单表增删改查,而是跨了多张表的状态流转或大批量聚合。
工序报工满足这三个特征。它是MES里最高频的写操作,一条产线一天几千次报工很正常。每次报工背后是工单、工序、设备、流水表的状态联动,任何一步失败都必须整体回滚。物料倒冲也满足这三个特征。车间领料不一定要按工单逐笔做,很多企业采用完工后按标准用量倒冲扣料,这意味着在高并发下同时对同一批号物料做余额扣减。批次追溯则是典型的"低频但复杂"查询,一次事故追溯可能要跨几百万条流水,还要往上层一层层展开批次关系,用慢查询的方式跑根本等不起。
1.3 也要泼盆冷水:不是所有业务都该塞进存储过程
把这三个场景拎出来说"必用",不代表我主张MES里的所有逻辑都往存储过程里塞。相反,我见过不少反面案例:有人把一张只有几千行的小字典表的简单CRUD也写成存储过程,结果一个分页查询被拆成三个SP,代码管理变得一团糟。存储过程最怕的其实是"为了用而用"。
通常我的取舍标准是这样:如果业务逻辑本身只是简单的增删改查,调用频率又低,ORM直接写完全够用,可读性还更好;只有当操作涉及跨表事务、高并发下的状态流转,或者大数据量聚合时,存储过程才真正有优势。管理后台的列表查询、低频的低风险维护操作,没必要进SP。还有一类要注意:业务需求变动极频繁的模块,比如各种基础资料维护界面,也别放存储过程,否则每改一个字段都要走一遍数据库脚本发布流程,迟早出问题。存储过程要用,就要用在这些高压、稳定的业务节点上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 报工完成存储过程:把十几个业务动作拧成一笔完整事务
我参与上线过的几条总装线里,产量最高的线体每天报工记录能到一万条以上,高峰时段经常有几十个工位同时在提交报工。最开始用服务层代码实现的时候,最典型的问题是工单状态被覆盖:两个不同工序的报工请求几乎同时到达,各自读取工单状态,各自判断,最终后提交的一方把先提交的一方更新结果覆盖掉。后来把整个报工动作收敛成一个存储过程,才把这类问题从根上解决。
2.1 一次普通的完工报工,数据库层要完成哪些环节
一次在界面上看起来很像"扫码点确认"的报工动作,落到数据库上通常不止一两行SQL。至少包括这几个环节:
- 按工单号锁定工单主记录,确认工单存在且处于可报工状态。
- 查询当前工序的工艺路线记录,确认它是已下发状态,防止上道工序还没完工就抢先报工。
- 更新当前工序的合格数量、不良数量、完成状态和完成时间。
- 插入一条报工流水,留存操作人、设备、时间等信息,后续绩效和设备利用率都靠它。
- 更新设备累计运行数量,让车间看板上的设备产量实时变化。
- 判断工单是否所有工序均已完工,如果已完工,则需要把工单主状态改为完工。
这些环节必须在同一个事务内执行:工序更新成功但报工流水插入失败,就会导致后续追溯查不到记录;工单状态判断错误,则可能导致下一道工序提前开工。服务层代码要做到这种一致性和并发控制也不是不行,但代码量和排查成本会成倍增加,远不如在存储过程里用一个事务包住更直接。
2.2 核心代码设计:显式锁工单,用事务把状态流转包住
下面的写法是我在SQL Server环境里比较常用的一套骨架,简化和脱敏了一部分业务字段,但保留了两个核心点:一个是用 UPDLOCK 显式锁定工单主行,把同一工单的并发报工串行化;另一个是全程 SET XACT_ABORT ON,一旦语句出错立即回滚整个事务。
sql复制CREATE OR ALTER PROCEDURE [mes].[usp_ProcessReport_Complete]
@WorkOrderNo NVARCHAR(50),
@ProcessCode NVARCHAR(50),
@EquipmentCode NVARCHAR(50),
@OperatorCode NVARCHAR(50),
@QualifiedQty INT,
@ScrapQty INT,
@ReportTime DATETIME = NULL,
@ResultMessage NVARCHAR(200) OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @Now DATETIME = ISNULL(@ReportTime, SYSDATETIME());
DECLARE @WoId INT = NULL;
DECLARE @OrderQty INT = 0;
DECLARE @BeforeQty INT = 0;
BEGIN TRY
BEGIN TRANSACTION;
-- 用 UPDLOCK 锁住工单主记录,
-- 防止同一个工单的多个报工请求并发推进状态
SELECT @WoId = w.Id, @OrderQty = w.OrderQty
FROM mes.WorkOrders w WITH (UPDLOCK, ROWLOCK)
WHERE w.WorkOrderNo = @WorkOrderNo;
IF @WoId IS NULL
BEGIN
SET @ResultMessage = N'工单不存在';
THROW 50001, N'工单不存在', 1;
END
-- 读取当前工序的已有合格数,并锁定该工序行
SELECT @BeforeQty = p.QualifiedQty
FROM mes.Processes p WITH (UPDLOCK, ROWLOCK)
WHERE p.WorkOrderId = @WoId
AND p.ProcessCode = @ProcessCode;
IF @@ROWCOUNT = 0
BEGIN
SET @ResultMessage = N'当前工序不存在';
THROW 50002, N'当前工序不存在', 1;
END
-- 更新工艺路线工序完成数量
UPDATE mes.Processes
SET QualifiedQty = @BeforeQty + @QualifiedQty,
ScrapQty = ScrapQty + @ScrapQty,
Status = CASE
WHEN @BeforeQty + @QualifiedQty >= @OrderQty THEN 3
ELSE 2
END,
CompleteTime = CASE
WHEN @BeforeQty + @QualifiedQty >= @OrderQty THEN @Now
ELSE CompleteTime
END
WHERE WorkOrderId = @WoId
AND ProcessCode = @ProcessCode;
-- 插入报工流水
INSERT INTO mes.ProcessReports
(WorkOrderId, ProcessCode, EquipmentCode, OperatorCode,
QualifiedQty, ScrapQty, ReportTime)
VALUES
(@WoId, @ProcessCode, @EquipmentCode, @OperatorCode,
@QualifiedQty, @ScrapQty, @Now);
-- 更新设备累计产量,供车间看板实时刷新
UPDATE mes.Equipments
SET TotalRunPcs = TotalRunPcs + @QualifiedQty + @ScrapQty,
LastRunTime = @Now
WHERE EquipmentCode = @EquipmentCode;
-- 如果工单所有工序均已完成,则工单主表置为完工
IF NOT EXISTS (
SELECT 1
FROM mes.Processes p
WHERE p.WorkOrderId = @WoId
AND p.Status <> 3
)
BEGIN
UPDATE mes.WorkOrders
SET Status = 3, CompleteTime = @Now
WHERE Id = @WoId;
END
COMMIT TRANSACTION;
SET @ResultMessage = N'OK';
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0
ROLLBACK TRANSACTION;
SET @ResultMessage = ERROR_MESSAGE();
THROW;
END CATCH
END;
这里有个很容易被新手忽略的点:在 UPDATE mes.Processes 的 CASE 表达式里,判断"是否整单完工"时用的是变量 @BeforeQty + @QualifiedQty,而不是在同一个UPDATE语句里直接写 QualifiedQty + @QualifiedQty。原因是UPDATE语句里右侧对列值的引用取的是更新前的旧值,虽然实际结果差不多,但写成显式变量更不容易被后续维护的人误解。
UPDLOCK 加在工单主表查询上,作用是把并发报工的入口先串行化。同样的工单号同时来了两道工序的报工,第二个请求会被阻塞到第一个事务提交或回滚,然后再继续。这个锁的粒度很小,只锁单行,不会影响其他工单的报工。比在服务层用分布式锁要轻量得多,也比靠"最后更新时间"做乐观锁更可靠,因为乐观锁失败后还需要客户端重试,徒增复杂度和响应时间。
2.3 调用端的几个细节:事务、超时和输出参数
存储过程写得再好,C# 调用端不配合也白搭。很多项目在接报工接口时踩过这几个坑:一是为了让界面上报工和日志写入保持同步,在应用层又开了一个 SqlTransaction,结果存储过程内部也有事务,一旦两个事务要升级为分布式事务,性能立刻崩掉。我的建议是,存储过程内部已经把核心账务包在事务里了,应用层不需要再开事务,外部日志写入走另一个独立连接就好,不要把网织得太大。
第二是 CommandTimeout。报工接口在高并发下难免会有锁等待,而 .NET 的 SqlCommand 默认 CommandTimeout 只有30秒,产线高峰期一排队,很容易出现"数据库明明没死,应用层先超时"的假象。我一般会把报工这类写接口的 CommandTimeout 调到60秒以上,但不能设成0,0代表无限等待,一旦真有死锁,客户端会卡死在那里,比超时更难受。
第三是输出参数。上面代码里的 @ResultMessage 是一个 OUTPUT 参数,C#端可以在执行完后读取它,用于判断业务是否成功以及失败原因。取的时候要注意:必须先把参数 Direction 设为 ParameterDirection.Output,再执行,执行完立刻读取,否则很容易取到空值。如果错误是通过 THROW 抛出的,客户端实际会捕获到 SqlException,此时不要只靠 @ResultMessage 判断,因为事务回滚后输出参数的内容不一定可靠。
3. 倒冲扣料存储过程:高并发下物料余额怎么防超扣
MES里最让我觉得"必须上存储过程"的场景其实是物料倒冲。很多工厂的物料发料模式不是按工单比例逐笔发,而是先把一批料放在线边仓,等成品完工后按BOM用量倒冲扣减。这个模式的并发冲突非常典型:总装线和返修线可能同时消耗同一批号的物料,如果扣减逻辑写得不好,轻则账实不符,重则整批物料余额变成负数。
3.1 并发冲突现场:余量明明够,扣完却变成负数
先描述那个经典的现场。假设某批次物料当前库存100件,两个工位同时报工倒冲,各自要扣80件。若按很多人习惯的写法:先查余额,判断够不够,再执行更新。两个请求同时查到余额100,都认为够扣,然后各自执行 UPDATE,库存最终变成-60件。这种先查后改的代码,在高并发下几乎必然出错,只是时间早晚的问题。
有人会说,我在应用层给这段逻辑加了锁不就行了?问题是MES是多客户端场景,锁要么作用在数据库层,要么就要引入分布式协调组件。对大多数制造企业来说,为一个库存扣减动作引入额外组件,成本和运维复杂度都不划算。正确的做法是把"检查余额"和"扣减余额"合并成一条原子UPDATE,让数据库的行锁替我们完成并发控制。
3.2 核心思路:用UPDATE的原子性同时完成校验和扣减
倒冲扣料的存储过程核心并不长,关键就是那条带条件的UPDATE和紧跟其后的影响行数判断:
sql复制CREATE OR ALTER PROCEDURE [mes].[usp_Material_ConsumeByBatch]
@WoId INT,
@WorkOrderNo NVARCHAR(50),
@PartCode NVARCHAR(50),
@BatchNo NVARCHAR(50),
@LocationCode NVARCHAR(50),
@ConsumeQty DECIMAL(12,3),
@OperatorCode NVARCHAR(50),
@RemainQty DECIMAL(12,3) OUTPUT
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @Now DATETIME = SYSDATETIME();
BEGIN TRY
BEGIN TRANSACTION;
-- 核心:条件里带 OnhandQty >= @ConsumeQty
UPDATE mes.MaterialStock
SET OnhandQty = OnhandQty - @ConsumeQty,
LastUpdateTime = @Now
WHERE PartCode = @PartCode
AND BatchNo = @BatchNo
AND LocationCode = @LocationCode
AND OnhandQty >= @ConsumeQty;
IF @@ROWCOUNT = 0
BEGIN
SET @RemainQty = -1;
THROW 50201, N'库存不足或批次不存在,扣减失败', 1;
END
-- 插入倒冲流水
INSERT INTO mes.MaterialConsumeLog
(WoId, WorkOrderNo, PartCode, BatchNo, LocationCode,
ConsumeQty, OperatorCode, ConsumeTime)
VALUES
(@WoId, @WorkOrderNo, @PartCode, @BatchNo, @LocationCode,
@ConsumeQty, @OperatorCode, @Now);
-- 返回扣减后的实时余量
SELECT @RemainQty = OnhandQty
FROM mes.MaterialStock
WHERE PartCode = @PartCode
AND BatchNo = @BatchNo
AND LocationCode = @LocationCode;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0
ROLLBACK TRANSACTION;
SET @RemainQty = -1;
THROW;
END CATCH
END;
要理解这段代码为什么能防超扣,关键在于UPDATE语句的特性:当事务对某一行执行UPDATE时,数据库会自动对该行加排他锁,直到事务结束。两个并发请求同时扣同一批物料,第二个请求会等在锁上,等第一个请求提交后才继续执行。它读到的余额已经不是更新前的旧值,而是扣减后的剩余值,此时如果余额不够,WHERE条件里的 OnhandQty >= @ConsumeQty 就不成立,影响行数为0,于是抛错回滚。
这比手动SELECT再加锁的方案少了一次查询往返,锁等待窗口更小,代码也更好理解。有人问,那我在存储过程里先用 SELECT ... WITH (UPDLOCK) 锁行,再判断再更新行不行?也能防超扣,但锁的持有时间更长,死锁概率更高。直接带条件UPDATE是目前最简洁的方案,我强烈建议优先用这种方式。
3.3 压测里的对比,以及上线后验证的注意事项
我在项目联调时拿几十个并发线程同时跑过两种版本:一个版本是应用层先查余额再扣减,另一个是上面的原子扣减版本。测试结果很符合预期:先查后改版本很快出现负库存或死锁报错,原子扣减版本稳定执行,所有扣减后的余额要么成功变化,要么明确返回"库存不足"。这说明在强事务场景里,锁的设计比代码的所谓"业务判断"更关键。
验证倒冲功能时,我建议除了看最终库存是否对得上,还要检查流水表是否有缺失或重复。因为倒冲的金额直接构成生产成本,一旦出现重复扣料,当月成本核算就会乱套。有一个细节值得注意:上面代码里扣减完成后又查了一次实时余额,如果调用频率特别高,可以改用 OUTPUT inserted.OnhandQty 直接把更新后的值返回,省掉最后一次SELECT,进一步提高性能。另外在Oracle里判断影响行数用的是 SQL%ROWCOUNT,MySQL里则是 ROW_COUNT(),SQL语法细节不同,但"带条件UPDATE + 影响行数判断"这个思路完全通用。
4. 批次追溯与汇总存储过程:弃循环改集合的高性能查询写法
追溯是MES里被问得最多、也最容易被人用"血泪教训"的方式记住的功能。很多时候它平时没人用,等客诉和质量事故发生时,品质部才跑过来要求查某批成品用了哪些原料批次、流经了哪些设备、操作员是谁。此时数据可能已经积累了几百万甚至上千万条,如果追溯逻辑写得烂,查一次能让数据库CPU跑满几分钟,业务直接跟着遭殃。
4.1 追溯慢的根源:逐条循环查询的"放大效应"
我先碰到过最典型的反面实现:在服务层写一个循环,遍历每个成品SN或批次号,逐条去查它的工序流转记录和物料消耗记录。假设一次要追溯5000个SN,每个SN要查两三张表,那就有上万次数据库往返。哪怕单次查询只要5毫秒,累积下来也是几十秒,再加上并发用户一多,数据库很快被拖垮。
循环查询被放大还有一个更隐蔽的原因:每次单条查询走的都是随机I/O,数据库要反复在索引和堆表之间跳转,缓存命中率很低。而用集合思维一次性把需要的数据JOIN出来,可能只需要对几张表各扫描一次,配合合适的索引,速度反而比"只查几行"快得多。很多人写坏追溯就是坏在这个思路上:下意识觉得"反正每批只查几行,很快",忽略了循环总次数带来的性能灾难。
