做工厂物流改造这些年,最怕听到的词就是“AGV卡在电梯口”。重载AGV本来就是为了省人力,结果一旦遇上跨楼层搬运,梯控系统就成了整个链路里最容易出幺蛾子的一环。今天想聊的,是一套我在实际项目中落地过的工业级IoT架构,核心思路是靠着边缘计算加分布式锁,把重载AGV梯控系统里的通讯死锁和并发冲突压下去。这套方案适合正在做智能工厂物流、AGV调度或者电梯联动的工程师参考,哪怕你没做过AGV,里面处理分布式锁和边缘网关的思路,也能直接挪到其他设备协同场景里用。
1. 项目背景与问题拆解
1.1 重载AGV梯控的场景和痛点
多楼层厂房里,AGV要跨楼层送料,这里的电梯不是普通客梯,而是载重三吨到五吨的货梯。重载AGV空车自重就有一吨多,载上满盘物料后刚好卡在电梯承重范围内。问题在于,电梯控制器原本是按“人来按按钮”的逻辑设计的,一个楼层按钮按下后,电梯按顺序响应,不会出现两个按钮同时按还各执一词的情况。AGV不一样,它是自动系统,一辆车到了一楼电梯口要呼叫,另一辆车在二楼也发起了呼叫,第三辆车可能还在电梯里没出来,梯控面对的是同时到达的多条指令,而它本身的处理能力是按单用户设计的。这种“一梯多车”的并发冲突,是场景里最基础的痛点。
其次,重载AGV进电梯比普通AGV危险得多。车身长、载重高,一旦电梯门在AGV进到一半时开始关门,轻则刮擦货叉,重则把AGV卡在轿厢与楼层之间。通常我们要求梯控能输出门状态、楼层位置、轿厢到位信号,并且响应延迟必须稳定在200毫秒以内。纯云端调度很难保证这个指标,哪怕内网时延不高,一旦调度服务或数据库出现抖动,AGV就可能错过电梯门的开窗期,于是整个环节开始重试,越重试越乱。
再有,通讯链路本身也不可靠。很多老厂房里的货梯控制器是串口或MODBUS接口,线缆长、接头多,强电柜和变频器在旁边,电磁干扰大。波特率一高,误码率就上来。AGV调度系统与梯控之间的通讯如果只做最简轮询,发一条指令等不到回应就超时重发,很快会把控制器队列堵死。这种问题不是单纯的软件bug,而是整个通讯链路缺少一种“一车一锁”的互斥机制,导致请求重叠和响应错乱。所以项目一开始,我定的目标就两个:一是任何时刻只有一个AGV在与梯控交互;二是通讯链路能自动从异常中恢复,而不是靠人工断电重启。
1.2 通讯死锁与并发冲突的典型表现
死锁这个词,很多后台开发先想到的是数据库死锁或Java线程死锁。我把现场的故障日志翻出来看,实际表现比教科书复杂。最常见的三种:
第一种,请求队列拥堵型。梯控控制器的指令缓冲区只有几十个字节,AGV调度模块同时发来五辆车的呼叫请求,控制器处理不过来,后面的请求排队,而AGV侧等不到响应就开始重发,重发帧又插到队尾,于是缓冲区永远清不掉。表面上是“通讯超时”,本质上是并发请求在单机串行处理模型上撞了车。
第二种,半双工链路占用型。串口通讯是半双工的,同一时刻只能有一方占用总线。如果两路AGV调度线程同时往同一个串口写数据,数据就会在物理链路上交织,帧校验必错。很多网关程序忽略了“写串口要加锁”这件事,操作系统层面的write虽然是原子的,但两帧之间没有间隔,接收方根本无法正确分帧。
第三种,业务互锁型。AGV A已经取得电梯使用权,但在进电梯过程中卡住了,迟迟不释放;AGV B在另一个楼层也拿到了“电梯已到达”的状态,但实际上电梯被A占用。两边都认为自己有权限,梯控收到的指令互相矛盾,陷入逻辑死锁。这类问题用单纯的超时重试解决不了,必须有资源锁和状态机。
1.3 为什么选边缘计算加分布式锁
把计算放到电梯厅墙边的边缘网关上,让梯控在毫秒级响应远程请求时不需要绕道云端,这是工业现场最现实的选择。边缘计算解决的是两个问题:一是时延,二是断网容灾。电梯是安全设备,哪怕网络断开,AGV也要能在梯控门口安全停下,不能因为云端不可达就悬在半空。边缘网关放在现场,持续与梯控保持心跳,就算上层调度服务器宕机了,网关也能执行本地降级逻辑,把AGV引导到安全区域。
但边缘网关只是把计算资源下沉到现场,并没有解决多车抢电梯的并发问题。在同一个边缘节点下,可能有多辆AGV同时请求,所以要加一把“分布式锁”。为什么是分布式锁而不是本地synchronized?因为AGV调度系统通常是一个集群,可能有两台以上的调度服务器,它们各自与边缘网关通讯,锁必须跨进程、跨机器生效。Redis分布式锁是这里最常见的开源方案,因为边缘网关上跑一个Redis实例成本很低,而且读写延迟都在1毫秒以内,完全能满足200毫秒的电梯响应窗口。
2. 整体架构设计与技术选型
2.1 边缘计算节点的硬件与系统
我当时选的边缘计算节点不是普通PC,是一台无风扇工业工控机,CPU用的x86四核低功耗,内存8GB,双千兆网口,一个接工厂内网,一个直连梯控控制器。硬盘是32GB工业级SSD,系统用的Linux加Docker。为什么不选ARM?倒不是不行,只是x86生态里调试工具更顺手,串口驱动和MODBUS库都更容易装,现场工程师也更熟悉。ARM盒子性能也够,但梯控项目里多半没有专职Linux维护,x86出问题好排查。
网络拓扑分为三层。最上层是AGV调度服务器,负责全局任务分配,通过HTTP或MQTT把“呼叫电梯”“确认就绪”这类高级指令发给边缘网关。中间层是边缘网关,负责协议转换、锁管理、状态缓存。最下层是梯控控制器和电梯本体,网关通过MODBUS TCP或RS485与梯控连接。这样设计的好处是,调度服务器不需要关心电梯是哪个品牌、控制器是哪种协议,它只跟边缘网关打交道。如果厂区有多台电梯,我建议一台电梯配一个边缘网关,然后在调度侧做电梯资源池,这样即使某一台电梯的网关故障,也不影响其他电梯。分布式锁的key按电梯ID隔离,天然支持多电梯并行。
2.2 分布式锁选型:Redis开源版还是自研
用数据库做锁我直接否了,不管是MySQL还是PostgreSQL,单条事务的提交延迟在梯控场景里还是太大,而且会放大数据库压力。Redis最合适,因为锁就放在边缘网关本地,不存在跨机房延迟。有人担心高并发场景下Redis分布式锁要谨慎,我们是工厂内网,最多十来辆车抢一台电梯,QPS非常低,但并发突发性强,Redis完全能扛住。
锁实现我用的SET NX PX加Lua脚本,没有直接上Redlock。Redlock在分布式环境下能避免主从切换丢锁,但要在多个Redis节点之间投票,复杂度高。工业现场边缘网关的单机Redis就算挂了,我们还有主备切换和降级策略,锁失效几秒钟的代价,比Redlock引入的复杂度低。如果你有多台电梯共用一个边缘集群,可以考虑用Redis Sentinel做主从,锁的读写走主节点。现场实施时,我给每台网关上的Redis配了AOF持久化,防止重启后锁数据全丢。为什么不用ZooKeeper或etcd?它们当然能提供更严格的一致性锁,但工业现场没人愿意为一个任务去维护三节点的ZooKeeper集群,边缘网关资源本来就有限,越少依赖越好。
2.3 通讯链路与协议设计
梯控控制器一般支持MODBUS TCP,但更古老的货梯只提供RS485接口。边缘网关要做一个协议转换盒子:对内网调度系统提供JSON over HTTP或MQTT,对梯控走MODBUS或自定义串口帧。这样上层逻辑和底层物理链路解耦,以后换梯控品牌时不用改调度代码。
通讯协议层我做了几个硬性规定:一是帧长固定不超过64字节,短帧不容易被干扰;二是每个帧必须带序列号,应答帧必须回同样的序列号,这样能对上“请求-响应”;三是CRC16校验必须做,不能省;四是发送指令后默认等待500ms,超时立即重发,连续三次失败则切换备用通讯口并报警。这些规定看起来很基础,但很多项目就是没做全,才导致通讯死锁。
下面是一个自定义单帧的参考格式:
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2 | 0xAA 0x55 |
| 指令 | 1 | 呼叫、楼层、开门、关门等 |
| 序列号 | 2 | 自增,应答帧必须回填 |
| 数据长度 | 1 | 数据字节数 |
| 数据 | 0~16 | 参数内容 |
| CRC16 | 2 | 帧校验 |
网关收到完整帧后先校验CRC,校验通过才解析;解析完立刻把结果放入内存缓冲,并通过发布订阅通知锁管理器。这套协议看着不起眼,但现场跑起来后,误码重传率从原来的百分之几降到零点几。
3. 核心实现:分布式锁与状态机
3.1 分布式锁的封装与参数设计
锁的key设计为elevator:{id}:lock,value存当前持有锁的AGV唯一标识,比如车体编号加会话ID。获取锁时执行Lua脚本,保证原子性:
if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) == 1 then redis.call('SET', KEYS[1] .. ':owner', ARGV[1], 'PX', ARGV[2]) return 1 else return 0 end释放锁时,必须比较owner后再删除:
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end参数上,锁租期TTL我默认设成2000ms。为什么是这么久?因为一次标准的AGV进电梯动作包括:呼叫、电梯到位、开门、AGV确认到位、进轿厢、关门、楼层移动,整个过程最慢也就15秒,但单次请求-应答窗口只需要200ms。TTL设太长,崩溃后资源长时间冻结;设太短,业务还没做完锁就自动释放了。所以我用2000ms配合看门狗续租,既保证安全性又保证可用性。还有一个等待锁的超时,AGV在电梯口等待获取锁最多等3000ms,超时就上报调度系统重新排队,而不是无限阻塞,避免AGV堆在电梯口堵住其他车辆。
3.2 锁超时与看门狗
业务时长可能超过锁租期,所以要加看门狗。看门狗线程每隔一段时间给锁续期,下面是核心逻辑:
public class LockWatchdog { private final ScheduledExecutorService scheduler; private final String lockKey; private final String owner; private final long ttlMs; private volatile boolean released = false; public void start() { scheduler.scheduleAtFixedRate(() -> { if (released) return; long lease = ttlMs * 4 / 5; String lua = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) else return 0 end"; redisClient.eval(lua, lockKey, owner, String.valueOf(lease)); }, ttlMs / 5, ttlMs / 5, TimeUnit.MILLISECONDS); } public void stop() { released = true; } }TTL是2000ms时,每400ms续一次,负数余量充足。这样即使业务慢了一点,锁也不会中途丢失。要注意的是,看门狗线程本身也要有最大运行时间。比如电梯动作总超时30秒后,强制释放锁并通知调度系统,防止AGV卡死后锁一直被续租,其他车辆永远拿不到电梯。这个设计把“自动续期”和“最大超时”两个逻辑同时做进看门狗里,现场的稳定性会好很多。
3.3 AGV调度状态机设计
为了防止业务互锁,我并没有让调度代码直接散落着调用锁,而是把整个梯控交互流程抽象成状态机。状态机放在边缘网关执行,因为网关离梯控最近,读状态时延迟最小。下面是一张简化版状态表:
| 状态 | 触发条件 | 动作 | 超时策略 |
|---|---|---|---|
| IDLE | 调度任务下发 | 等待分配电梯 | 无 |
| WAIT_LOCK | 选中电梯 | 获取分布式锁 | 3s超时重新排队 |
| CALL_ELEVATOR | 获得锁 | 发送呼叫指令 | 3次重发失败走降级 |
| DOOR_OPENED | 收到门开信号 | 确认AGV可进入 | 10s超时重新开门 |
| AGV_INSIDE | AGV进入轿厢 | 请求关门 | 5s超时重发关门 |
| ELEVATOR_MOVE | 门已关 | 等待到达目标层 | 60s超时报警 |
| RELEASE | AGV驶出电梯 | 释放锁 | 无 |
每个状态都有超时上限,流程不会卡在某个中间态。比如AGV已经取得锁,但电梯门开信号一直没来,10秒后网关会主动重新发送开门指令,同时标记一次异常。如果异常次数超过阈值,就释放锁并呼叫人工处理。状态机里最关键的一步是WAIT_LOCK到CALL_ELEVATOR的切换:只有真正持有电梯锁的AGV才能发指令,这样就从根本上杜绝了两车同时向梯控发指令的情况。
3.4 通讯模块防死锁设计
通讯线程专门处理收发,不为每辆AGV开独立线程。所有AGV的命令统一通过一个命令队列进入通讯模块,通讯模块在持有电梯锁时才允许发指令。这样串口层面天然串行,不会出现两个线程同时写串口的问题。读线程只负责把数据包解析后按序列号存入结果Map,请求侧用带超时的Future等待。这个设计把并发冲突从物理链路转移到了锁层面,锁层面再解决不了就上报,不会造成真正的通讯死锁。
这里有个细节:串口或网络端口的读写缓冲区要做好清空机制。每次开锁成功后,先清空上一次残留的输入缓冲,再发送新指令。否则旧帧的应答会和新帧的应答混在一起,导致状态机误判。这个做法看起来简单,但我见过不少项目忽略它,最后花了很长时间抓日志才定位到问题。
4. 死锁排查与实战调优
4.1 从一次现场故障说起
某个现场曾经出现过三台重载AGV同时呼叫一台电梯,结果电梯门反复开关,AGV集体报“通讯超时”。刚开始我们以为是梯控PLC的队列满了,但重启梯控之后问题复现,说明不是偶然故障。翻日志发现,AGV A显示“获取电梯锁成功”,但随后五秒没有任何动作;AGV B也显示“获取锁成功”,两边都认为自己有权限控制电梯。继续查,发现线上跑的版本有一段非常典型的错误代码:
if (redis.get(key).equals(owner)) { redis.del(key); }这段代码在并发下会有一个可怕的时间窗口:A判断锁的owner是自己,在还没执行delete时,锁刚过期,B立刻获取锁成功;A接着执行delete,把B的锁误删了。两条AGV同时认为有锁,指令同时发出去,梯控自然乱套。后来我们把这个逻辑全部收敛到Lua脚本里,才彻底解决误删问题。
还有一次是看门狗续租失败。但并不是看门狗代码有问题,而是边缘网关的Redis连接池被其他状态查询请求打满,看门狗执行续租时拿不到连接,超时后锁自动过期,导致两台车同时操作电梯。那之后我把Redis连接池按照业务类型做了隔离,看门狗用独立连接,状态查询用另一组连接,互相不抢占。
4.2 排查死锁的思路
遇到这类问题,我会按下面的顺序排查,先别急着怀疑分布式锁:
- 先看AGV和梯控之间的通讯心跳是否正常。如果心跳断了,优先检查网线、串口线和干扰源,通讯问题是死锁最底层的诱因。
- 再查电梯锁的持有者。用
redis-cli连进边缘网关,执行get elevator:3:lock和ttl elevator:3:lock,确认锁在谁手上,还有多久过期。这一步能快速区分是锁没释放还是锁丢失。 - 然后看通讯模块的队列深度。如果电梯空闲时命令队列还有积压,说明旧指令没有及时清理,需要检查序列号和应答匹配逻辑。
- 最后做并发复现。用一个边缘网关、两个仿真AGV同时压测,在Wireshark里抓网口报文或串口报文,看是否存在交叉帧和乱序。
这套排查顺序帮我节省了大量时间,很多时候问题根本不在锁,而在底层通讯。
4.3 参数调优与压测结果
为了让参数有据可循,我做了一次并发压测。模拟不同数量的AGV同时抢一部电梯,统计锁冲突率、平均交互耗时和超时次数。优化前的参数是锁TTL=1000ms,看门狗周期=500ms,等待锁超时=5000ms,结果如下:
| 并发AGV数 | 锁冲突率 | 平均电梯交互耗时 | 超时次数 |
|---|---|---|---|
| 2 | 1% | 180ms | 0 |
| 4 | 28% | 340ms | 2 |
| 6 | 47% | 580ms | 7 |
| 10 | 62% | 820ms | 15 |
把TTL从1000ms调到2000ms,看门狗周期从500ms调到400ms,等待锁超时从5000ms降到3000ms后,同样场景的冲突率明显下降:
| 并发AGV数 | 锁冲突率 | 平均电梯交互耗时 | 超时次数 |
|---|---|---|---|
| 2 | 0% | 170ms | 0 |
| 4 | 5% | 220ms | 0 |
| 6 | 9% | 290ms | 1 |
| 10 | 15% | 360ms | 3 |
这里面的逻辑是:TTL太短,AGV还没完成进电梯操作,锁就被自动释放,下一辆车立刻拿到锁,反而和上一辆车抢指令;TTL太长,一旦AGV故障锁会一直占着。因此最适合的方式是给一个不长不短的初始租期,再靠看门狗持续续租,让“正常业务永远不丢锁,异常业务最终会释放锁”。
5. 避坑指南与经验总结
5.1 分布式锁常见坑
第一个坑是误删锁,前面已经讲过了,释放锁一定用Lua脚本比较owner后再删,不能用两步操作。第二个坑是Redis主从切换导致的锁丢失。边缘网关虽然是单机Redis,但如果做了主从,并发生成锁时主节点还没同步到从节点就宕机,新主节点上锁就丢了。工业场景如果承受不了,可以加多个Redis实例用一致性算法,不过大多数AGV梯控场景下,短时间锁丢失并不可怕,状态机超时机制能兜住,所以别为了一个不太可能的故障引入过重方案。
第三个坑是可重入问题。一辆AGV可能会在一个任务里连续使用同一部电梯多次,比如先从三楼到一楼卸料,又回三楼取货。这时候AGV自已持有锁再申请锁应该直接成功。实现上可以在锁的value里保存AGV标识,在获取锁时判断是否已是同一owner,是则直接返回成功并维护一个重入计数器。这个需求很容易被忽略,导致AGV在同一任务里二次呼叫电梯时卡在WAIT_LOCK状态。
第四个坑是时钟跳跃。Redis的PX过期依赖服务器时间,如果边缘网关的NTP做了大幅校时,锁可能提前过期或延迟过期。工业现场建议关闭NTP的时间步进,或者用单调递增的方式校准,避免时间瞬间跳变。
5.2 边缘节点容灾
边缘节点本身也会宕机,不能只做单点。我给每台电梯配了两个边缘网关,一主一备。主网关持有串口和网络连接,同时写Redis做锁和状态备份;备网关通过心跳监控主网关,发现失联后自动切换,接替主网关继续与梯控交互。切换过程中,AGV可能会短暂收不到电梯状态,但状态机能保证车辆停在安全位置,不会撞门。
如果所有边缘网关都不可用,调度系统必须停止派发新的电梯任务。已经进入电梯的AGV要依赖梯控本地的安全回路完成关门和运行,这部分需要在电梯侧做硬线联锁,不能只依赖软件。很多厂家忽略这个细节,以为网关挂了大不了重启,结果AGV卡在轿厢半空,处理起来非常被動。所以我在设计方案时,永远把“边缘节点故障后的物理安全”放在最高优先级。
5.3 后续演进方向
这套架构做完之后,其实还能继续扩展。比如把调度服务器与边缘网关之间从HTTP改成MQTT,让AGV状态、锁状态、电梯状态都通过主题发布订阅,多车协作时状态一致性会更好。也可以用eKuiper这类边缘规则引擎,在网关本地把锁状态、AGV状态和电梯状态合流做复杂事件处理,减少硬编码的状态判断。再往后,可以把每一次电梯交互的时序数据存进本地TSDB,用历史轨迹做电梯预测性维护和AGV路径优化,这些都是顺着现有架构自然长出来的能力。
最后分享一个我压箱底的调试小技巧:在做并发压测时,把锁的TTL故意设成400ms,让业务动作永远做不完,锁就会不断过期,这样一来所有竞态场景都会被强制暴露出来。等系统在“异常快过期”的情况下还能稳定跑,再把TTL调回正常值。我踩过很深的一个坑就是把锁的过期时间设得刚刚好,结果一个月后在一个特殊工况下才暴露出竞态,现场差点翻车。提前用极端参数把问题逼出来,比事后救火舒服得多。