做工厂物流改造这些年最怕听到的词就是“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超时立即重发连续三次失败则切换备用通讯口并报警。这些规定看起来很基础但很多项目就是没做全才导致通讯死锁。下面是一个自定义单帧的参考格式字段长度说明帧头20xAA 0x55指令1呼叫、楼层、开门、关门等序列号2自增应答帧必须回填数据长度1数据字节数数据0~16参数内容CRC162帧校验网关收到完整帧后先校验CRC校验通过才解析解析完立刻把结果放入内存缓冲并通过发布订阅通知锁管理器。这套协议看着不起眼但现场跑起来后误码重传率从原来的百分之几降到零点几。3. 核心实现分布式锁与状态机3.1 分布式锁的封装与参数设计锁的key设计为elevator:{id}:lockvalue存当前持有锁的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_INSIDEAGV进入轿厢请求关门5s超时重发关门ELEVATOR_MOVE门已关等待到达目标层60s超时报警RELEASEAGV驶出电梯释放锁无每个状态都有超时上限流程不会卡在某个中间态。比如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同时抢一部电梯统计锁冲突率、平均交互耗时和超时次数。优化前的参数是锁TTL1000ms看门狗周期500ms等待锁超时5000ms结果如下并发AGV数锁冲突率平均电梯交互耗时超时次数21%180ms0428%340ms2647%580ms71062%820ms15把TTL从1000ms调到2000ms看门狗周期从500ms调到400ms等待锁超时从5000ms降到3000ms后同样场景的冲突率明显下降并发AGV数锁冲突率平均电梯交互耗时超时次数20%170ms045%220ms069%290ms11015%360ms3这里面的逻辑是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调回正常值。我踩过很深的一个坑就是把锁的过期时间设得刚刚好结果一个月后在一个特殊工况下才暴露出竞态现场差点翻车。提前用极端参数把问题逼出来比事后救火舒服得多。