ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

AGV调度系统梯控并发竞态问题:基于Redis分布式锁的实战方案

AGV调度系统梯控并发竞态问题:基于Redis分布式锁的实战方案 去年给一家仓储物流园区做AGV调度系统改造时被同一个问题连续折磨了三天十二台AGV共用一部货梯一到下午订单洪峰梯控PLC就间歇性卡死。看日志发现电梯门在四十秒内收到了六次开关指令AGV在电梯口排队堵成一团监控屏上全是呼梯超时和防碰撞急停告警。当时我第一反应是协议转换盒坏了换了两个串口服务器都没用最后抓总线报文才发现真正的肇事者是调度中心的两台服务器——它们几乎同时抢了同一部电梯的下发权限。这篇文章就围绕这个场景展开讲清楚在工业IoT架构下AGV机器人梯控的多机并发竞态问题到底怎么形成以及我用分布式锁把这个问题按住的完整过程。适合正在做AGV调度系统、电梯联动控制、多节点高可用部署的工程师也适合那些在准备分布式锁相关面试、想搞清楚锁真正用在哪的人。不写空话直接给方案和代码重点是我踩过的坑。1. 竞态从哪来一次AGV抢电梯事故的完整复盘1.1 现场单部货梯收到的指令风暴先还原事故日志。梯控PLC自带Modbus寄存器可以记录每次呼梯指令。那天下午的日志是这样的14:32:01.233 调度节点A 下发 去2楼 请求ID1001 14:32:01.240 调度节点B 下发 去2楼 请求ID1002 14:32:01.251 调度节点A 下发 开门 请求ID1003 14:32:01.265 调度节点B 下发 去3楼 请求ID1004 14:32:01.301 调度节点A 下发 关门 请求ID1005同一部电梯在不到70毫秒内收到五条互相矛盾的控制指令。电梯PLC按顺序执行了去2楼-去2楼-开门-去3楼-关门实际表现就是电梯门刚开一条缝就开始关门门还没关严又触发防夹人保护弹开反复三次之后PLC直接急停所有AGV都堵在一楼。这个问题的根源不在于电梯也不在于AGV而在于调度层有两个并发指令源。我司调度系统为了高可用做了双活部署两台调度服务器同时处理任务队列它们各自认为自己是唯一合法派梯者结果就是同一个呼梯动作被重复执行、互相覆盖。1.2 链路背景AGV调度双活架构下的指令通道工业IoT环境下AGV梯控的完整链路通常是这个走向上层WMS/订单系统 ↓ MQ任务 AGV调度中心多节点 ↓ 指令下发 边缘协议网关串口服务/PLC网关 ↓ RS485 / Modbus TCP / 干接点 电梯控制器(PLC)AGV本身不带直接控梯的权限它通过车载无线终端把我要去3楼上报给调度中心调度中心负责分配电梯、生成呼梯指令再经边缘协议网关转成电梯控制器能识别的总线报文。问题就出在调度中心多节点这层。双活部署意味着两个JVM同时监听同一个任务队列正常情况下有任务分片规则不会重复消费但电梯资源是共享的任何一台AGV的到达时间、任何一条任务的优先级变化都可能让两个节点同时判定这部电梯现在应该归我管。1.3 冲突点电梯控制器根本不理解并发很多人对梯控的误解是跟数据库一样并发操作最多报个错。实际根本不是这样。电梯控制器的PLC扫描周期通常在100-300ms之间对RS485这种半双工总线来说两个指令在同一个扫描周期内到达大概率是后者覆盖前者或者两者都进缓存队列被顺序执行——但执行结果完全不可预测。换句话说电梯控制器不是并发安全的它压根没有同时处理两个客户端请求的概念。它只认识一个串行指令流。多节点调度系统在并发下发指令时相当于有人同时往一条单行道里开两辆车路本身没有错错的是入口缺少红绿灯。这个红绿灯就是分布式锁的定位在多个调度节点之间做一个互斥仲裁保证同一时间段内只有一个人拿到电梯的下发权限。2. 三类竞态窗口逐一拆解先把防御目标定清楚在写锁之前一定要先把竞态窗口列清楚。梯控场景里的竞态不是一种是三种如果只锁一个方向另外两个方向照样出事。2.1 指令重复两个节点同时登记同一楼层最常见也是最容易复现的竞态。任务队列里有两个任务几乎同时触达同一部电梯节点A和节点B各自计算完路径后都认为应该按2楼于是各下各的指令。电梯对同楼层的第二次呼梯不会报错它只是重新登记一次但这个过程消耗了电梯的响应时间还容易触发门区重复开关。这种竞态的防御目标是幂等——同一部电梯同一目标楼层在锁的保护下只允许一次真实的指令下发。2.2 状态回退基于过期状态的旧判断覆盖新指令这是更隐蔽的一类。调度节点A读取电梯状态时电梯还停在1楼空闲于是发出去2楼的指令节点B在几乎同一时刻读到的也是1楼空闲但它算出来的目标是3楼也发了出去。表面上看两个指令都合法实际上电梯收到后已经无法判断该听谁的。工业IoT设备状态同步大多不是实时的电梯状态从控制器传到调度中心有经过边缘网关的轮询周期比如200ms一次。在这个窗口内所有节点拿到的都是旧状态。如果不加锁任何基于旧状态的决策都是危险的。2.3 响应丢失电梯确实动了但回复包半路丢了这个坑在弱网车间里尤其常见。边缘网关下发指令后电梯PLC执行成功并返回ACK但ACK报文在无线回传链路上丢了调度节点A以为指令没发出去于是重新下发了一次。第二次下发的指令本质上是重复指令但因为它带着新的请求ID电梯那边认为来了新任务可能中断当前运行重新响应。这种竞态无法只靠锁解决需要配合指令幂等号和设备端去重。锁的作用是保证同一条指令只有一个节点下发但下发了但响应丢失这个窗口还需要额外设计。理清这三类问题之后我对锁的需求就变成了同一部电梯的控制权必须互斥锁内操作必须返回真实的指令执行结果锁外还必须做幂等兜底。3. 为什么单机锁、数据库行锁撑不住梯控这种物理并发3.1 单机锁管不住多实例如果调度中心只有一台服务器那问题特别简单用一个JVM内的ReentrantLock或者synchronized把电梯指令客户端锁住就行。按电梯ID维度做细粒度锁一个电梯一把锁互不干扰。但双活部署之后两台服务器的JVM是完全隔离的各自的synchronized互不可见。节点A持有锁并不妨碍节点B同时进入临界区。这是所有单机锁方案的先天边界——它能锁住进程内部的并发线程锁不住跨进程的多机协作。3.2 数据库行锁的长事务困局有人会说那用数据库行锁不行吗对电梯表作SELECT ... FOR UPDATE拿行级锁确保同一部电梯只有一个事务在操作。理论上成立但工程上是灾难。为什么因为梯控指令链路不是一个纯数据库操作它包含读电梯状态 → 判断决策 → 下发指令给PLC → 等ACK → 更新缓存五个步骤其中最耗时的PLC等待可能长达几百毫秒甚至几秒。数据库行锁在事务里霸占这么久遇到的直接后果就是锁等待超时,并发高一点的场景下所有调度线程都卡在抢行锁上系统吞吐量直接崩盘。更重要的是数据库行锁保护的是数据库行它管不住电梯物理实体。电梯不是数据库表它的状态变化不归数据库管就算你在业务表上加了锁也没办法阻止另一个节点绕过数据库直接向边缘网关发指令。3.3 分布式锁的适用边界锁住的是下指令人而不是电梯本身说清楚一点分布式锁并不能对电梯做任何物理保护。电梯控制器不会主动检查你有没有锁它只认指令。分布式锁解决的仅仅是多个调度节点之间如何达成一致——谁能下发指令也就是把并发指令源收敛成串行。这个边界决定了锁的使用方式。电梯本身的状态校验、指令合法性判断、失败重试必须在锁内和锁外分别做。锁不是万能保险它只是一个排队收银台把多车道汇聚成单车道让后面的业务逻辑有机会保证正确性。我在设计时把锁定位成电梯控制权的令牌。拿到令牌的节点负责完成一轮下发-确认-更新状态的循环没拿到令牌的节点就把任务重新放回队列或者等待重试。很粗暴但很有效。4. Redis分布式锁的梯控实现从Key设计到完整代码4.1 锁的粒度控制权锁 呼叫幂等锁锁的粒度设计直接影响梯控系统的并发能力和稳定性。我在这个项目里用了两级锁。第一级是电梯控制权锁粒度是一部电梯一把锁agv:lift:control:{elevatorId}持有这把锁的节点才有权限向该电梯下发任何指令。其他节点想下发时必须先竞争到这把锁竞争失败就退避重试。第二级是呼叫幂等锁粒度是一部电梯 目标楼层agv:lift:order:{elevatorId}:{floor}:{actionType}这级锁主要用于解决多个任务都想去同一楼层时的重复下发问题。比如两个AGV都想去2楼控制权锁只能保证它们分别排队但排队完之后每个AGV都会再下发一次去2楼——第二次下发在业务上完全没有意义通过幂等锁可以直接拦掉。这种两级设计配合使用既保证了下发通道的互斥性又减少了电梯收到的无效指令数量。实测下来电梯每天接受的指令总量下降了约40%因为大量重复目标楼层指令在幂等锁层就被干掉了。4.2 基于Redisson的锁参数与看门狗策略代码层面我直接用的Redisson版本3.x。为什么不自己写SETNX原因我在后面避坑章节详细说这里先给结论直接用成熟客户端比自己维护加锁/续期/释放的原子性靠谱得多。核心参数配置如下Configuration public class RedissonConfig { Value(${redis.host}) private String host; Value(${redis.port}) private int port; Bean public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(redis:// host : port) .setConnectionMinimumIdleSize(4) .setConnectionPoolSize(16) .setRetryAttempts(3) .setRetryInterval(500); return Redisson.create(config); } }Redisson的lock()如果不传leaseTime默认启用看门狗机制每隔10秒续期一次默认锁超时时间是30秒。这对梯控场景很重要因为锁内操作要等PLC响应这个时间是不可控的不能写死一个固定leaseTime然后等着超时。如果非要用自定义leaseTime我的建议是至少设置为业务预估最长耗时的两倍加上3-5秒缓冲。当时我们估算的电梯完整开门-进人-关门周期最长约15秒所以如果走自定义leaseTime路线稳妥值是35秒以上。但实际工程上还是推荐用看门狗省心。4.3 核心下发代码加锁→重新校验状态→下发→确认→更新缓存直接贴核心方法。这里是调度节点下发电梯指令的统一入口所有节点都走这一个方法通过分布式锁实现互斥。Component public class LiftDispatchService { private static final String CONTROL_LOCK_PREFIX agv:lift:control:; private static final String ORDER_IDEMPOTENT_PREFIX agv:lift:order:; Autowired private RedissonClient redissonClient; Autowired private LiftStatusCache liftStatusCache; Autowired private PlcGateway plcGateway; /** * 下发电梯指令的统一入口 */ public boolean dispatchLiftCommand(String elevatorId, LiftCommand cmd) { // 1. 先抢电梯控制权锁最多等2秒 RLock controlLock redissonClient.getLock(CONTROL_LOCK_PREFIX elevatorId); boolean locked false; try { locked controlLock.tryLock(2, TimeUnit.SECONDS); if (!locked) { log.warn(elevator {} control locked by other dispatcher, command rejected, elevatorId); return false; } // 2. 拿到锁之后重新读取电梯状态不能用本地缓存旧数据 // 这是最容易踩坑的地方详见第5章 LiftStatus status liftStatusCache.refresh(elevatorId); if (!status.isOperable()) { log.warn(elevator {} not operable, current status{}, elevatorId, status); return false; } // 3. 幂等检查同一楼层同类型指令已经登记直接视为成功 String idempotentKey ORDER_IDEMPOTENT_PREFIX elevatorId : cmd.getTargetFloor() : cmd.getActionType(); boolean firstCall redissonClient.getLock(idempotentKey).tryLock(0, 5, TimeUnit.SECONDS); if (!firstCall) { log.info(elevator {} command {} already registered, skip, elevatorId, cmd); return true; } // 4. 生成全局唯一指令序列号用于设备端去重 cmd.setSequenceId(IdGenerator.nextLiftCmdSeq(elevatorId)); // 5. 下发到PLC此方法内部会等待PLC的ACK PlcSendResult result plcGateway.sendCommandAndWaitAck(elevatorId, cmd, 3000); // 6. 根据执行结果更新本地状态缓存 if (result.isSuccess()) { liftStatusCache.applyCommand(elevatorId, cmd); log.info(elevator {} command {} executed, seq{}, elevatorId, cmd, cmd.getSequenceId()); return true; } else { // ACK超时或失败状态未知标记电梯状态为verifying liftStatusCache.markVerifying(elevatorId); log.error(elevator {} command {} ack failed, result{}, elevatorId, cmd, result); return false; } } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error(interrupted while dispatching command, e); return false; } finally { if (locked controlLock.isHeldByCurrentThread()) { controlLock.unlock(); } } } }这段代码有三个地方是刻意设计的。第一个是拿到锁之后强制刷新电梯状态。锁保护的不只是下发指令这个动作还包括基于什么状态来下发。如果不刷新节点A拿到锁时用的还是加锁前读的旧状态场景2.2照样会发生。第二个是幂等锁的tryLock(0, 5, TimeUnit.SECONDS)。0等待时间意味着拿不到幂等锁直接认定已有重复指令而不是死等。这样做的目的是让重复请求快速失败返回而不是挤压锁资源。第三个是ACK失败后标记verifying状态。这是对2.3节响应丢失场景的兜底——我不能确定电梯到底动没动所以先把状态置为待校验交给补偿任务去刷新而不是立刻重发指令。4.4 锁内代码要短把耗时操作拆到锁外锁内代码越短锁释放就越快系统整体的并发能力就越高。这是分布式锁设计的铁律在梯控场景下尤其重要。一开始我傻乎乎地把等待电梯到位也放在锁里一个锁最长要持有一分多钟。结果两台调度服务器的锁请求大量堆积电梯本身反而闲置了因为所有节点都在排队抢锁而不是在干活。后来重构了流程把锁内操作收缩为三步加锁 → 刷新状态并决策 → 下发指令并等待ACK3秒→ 释放锁等待电梯物理到位这种长轮询彻底放到锁外。锁只保证指令下发瞬间的决策正确和通道互斥之后电梯开始动了控制权就应该被释放让其他电梯指令可以进来排队。这个改动让电梯的利用率直接提升了30%以上AGV等梯时间明显下降。分布式锁的正确姿势不是锁着等待结果而是锁着做最短的关键操作。5. 多机并发下的四个坑锁过期、误删锁、时钟跳变、弱网失联理论设计只是第一步。真正让锁跑稳是在连续踩了四个坑之后做到的。5.1 GC停顿引发的锁过期两台AGV同时进电梯这是分布式锁面试题里最常见的锁过期导致双重执行问题在真实梯控场景里它是真事故。某个巡检任务高峰节点A的JVM发生了一次Full GC停顿了约15秒。Redisson的看门狗虽然有30秒默认锁超时但GC停顿期间JVM里所有线程都暂停包括续期线程。15秒停顿结束时锁已经过期了。节点B在锁过期后抢到了控制权向下发指令。而节点A的GC恢复后业务线程还没意识到锁已经易主继续向电梯PLC发送指令。结果就是两台AGV同时往电梯间里挤防夹光电触发电梯急停。规避方案有三层JVM参数调优避免长GC用G1配合停顿目标设置把FGC概率降到最低。看门狗续期间隔从默认10秒缩短到5秒降低上一次续期到锁过期之间的空洞。最硬的一层在锁内下发指令前额外查询PLC的当前执行状态确认电梯空闲且非probe状态才下发。锁过期不可怕锁过期后设备端状态校验兜底了。5.2 自己实现SETNX时的误删锁问题早期版本我没用Redisson自己用SETNX实现了一把锁。原理想得很简单加锁用SET key value NX PX 30000释放时对比value再删除。结果在某次压测中就出事了。节点A的锁已经过期节点B加锁成功。节点A业务执行完后主动释放锁——它对比value时发现value不匹配本应跳过删除但代码里对比和删除是两个独立操作对比完成后、删除执行前锁又过期了节点B马上重抢节点A直接DEL把节点B的锁删了。现在的Redisson为什么没问题因为它的释放锁内部用了Lua脚本保证原子性if get(key)myValue then del(key)是原子执行不会出现中间窗口。这也是我强烈建议别自己造轮子的原因。5.3 边缘网关NTP时钟跳变看门狗续期直接失效这个坑非常隐蔽。梯控边缘网关有时候会用NTP自动校时某次校时动作导致系统时钟向前跳了3秒。Redisson续期指令里携带时间戳跳变后的时钟正好落在锁的过期边界附近直接让一条有效的续期被服务端判定为过期锁提前释放了。处理办法是给边缘网关配置STEP式校时策略而不是SLEW式渐进校准。更稳妥的做法是把看门狗剩余时间的判断从JVM本地时钟迁移到Redis服务端时间避免本地时钟抖动影响锁的寿命。5.4 Redis弱网断连后的自以为有锁黑洞期AGV车间环境无线信号不稳定调度节点和Redis之间的连接随时可能断。问题不在断连本身而在于断连后节点端的锁状态是什么。节点A持有电梯锁Redis连接断开锁自然无法续期几秒后过期。但节点A根本不知道连接断了还在按我持有锁的逻辑往下发指令。节点B看到锁过期也来抢锁下发。两边又在物理上并发操作电梯了。这个坑的根基在于锁持有者的认知和锁在服务端的状态不一致。我做了两件事来缓解在plcGateway.sendCommandAndWaitAck里增加一个前置检查发送前必须确认Redis连接可用不可用则拒绝下发。增加一个lockHealth机制每次续期成功本地更新lastRenewalTime下发指令前若发现当前时间与lastRenewalTime的间隔超过锁过期时间则认为锁已失联放弃下发。5.5 每个坑对应的参数调整与规避清单把上面的经验整理成一张表方便直接抄作业风险点触发场景规避方案关键参数参考锁过期业务未完成JVM GC停顿、续期失败看门狗缩短续期间隔设备端状态二次校验续期间隔5秒锁超时30秒误删锁自己实现SETNX非原子释放使用Redisson或Lua脚本释放脚本必须原子时钟跳变网关NTP校时用服务端时间做续期判断校时策略选STEP弱网断连无线信号抖动下发前检查Redis连接与续期时间设置Redis socket重连超时这些参数的精确值要结合自己的业务耗时来调但方向不会错。核心思想就一句话不要让锁持有者单方面相信自己还有权限要用多一层设备端状态检查来兜底。6. 分布式锁不是梯控的全部边缘状态机与降级兜底锁解决了多节点调度层的互斥但工业IoT环境的复杂度远不止两个JVM抢锁这么简单。把视角从调度中心往下移到边缘侧整个系统还有另一层保护要做。6.1 边缘网关本地互斥队列把并发问题挡在总线之外无论调度中心有多少个节点最终指令都要汇聚到边缘协议网关再通过RS485或Modbus总线发给电梯PLC。边缘网关是一个天然的串行通道它在物理上就具备并发指令汇聚再串行下发的能力。我在边缘网关侧加了一个本地互斥队列调度节点下发的指令先进入网关内部的队列网关按顺序逐条执行只有当前指令收到ACK后才执行下一条。这个设计让调度层并发被彻底压制在网关之前——分布式锁管住调度层的逻辑并发边缘队列管住物理通道的串行执行。这样做还有个额外好处即使调度中心多节点短暂失联边缘网关也会按最后收到的指令顺序执行完剩余队列电梯不会因为上层网络抖动而停在半空。AGV调度系统最怕的就是电梯中途卡住边缘队列大大降低了这个概率。这里顺带提一句边缘网关本身的操作系统不管是Linux容器环境还是Windows IoT这种嵌入式系统队列互斥逻辑都在应用层做与底层系统关系不大关键是应用层面保证串行出队。6.2 锁服务不可用时的降级剧本状态机判决优先于抢锁Redis不是永远不挂的。锁服务不可用的时候如果系统直接停止派梯仓库就瘫痪了如果系统无视锁照常派梯电梯就又回到最开始的事故状态。两个都不能选所以要有一套降级剧本。我的降级策略是锁服务不可用超过30秒后调度中心自动转入边缘仲裁模式。这个模式下调度节点不再互相抢锁而是把指令直接投递给边缘网关的本地队列由边缘网关内的电梯状态机来做最终判决。电梯状态机是一组很简单的规则电梯处于idle且门关接受新呼梯指令。电梯处于moving、doorOpen、doorClosing不接受新指令按当前执行的实际状态完成后再进入待命。电梯处于error拒绝所有指令触发维护告警。这套状态机不依赖分布式锁只依赖边缘网关从PLC侧实时读取的状态。锁服务恢复后调度中心再切回正常锁控模式。降级切换的过程对上层业务透明AGV只是偶尔感觉到呼梯响应慢了一点点但不会出现并发控梯事故。6.3 实测效果与可参考的量化数据这套方案上线后我持续观察了两周。直接给数据指标改造前改造后日均梯控冲突指令数23次左右0次电梯PLC急停告警每周约4次0次调度节点加锁平均等待时长未统计无锁约7ms加锁成功率-99.97%AGV平均等梯时间约4分40秒约2分20秒整体的效果是超过预期的。AGV等梯时间下降了一半直接带动了仓库整条物流线的搬运效率。梯控相关的故障工单从每周三四张降到整个观察期只有一张——那张还是因为维护人员手动触发了电梯急停按钮。这个数据只代表我这个项目的具体场景12台AGV、1部货梯、双活调度节点。如果AGV数量更多、电梯更多锁的冲突率会变高参数也需要跟着调但架构思路是通用的。最后说回我自己的一个体会。分布式锁在梯控这个场景里解决的是同一时刻谁有权下发指令这个一致性问题但它真正能跑稳靠的是把指令链路本身做成幂等的靠的是边缘侧状态机兜底靠的是每个节点对锁已失效这件事有多层感知。锁只是一个串联装置真正保护电梯的是整个系统的冗余设计。踩完这些坑之后我现在的习惯是接到类似需求时先画清楚设备的物理响应时序再决定在哪里加锁、加什么锁——工具永远是为流程服务的。
返回列表