做AGV仓储项目这几年,被同行问得最多的问题是:WMS里已经生成了搬运任务,怎么让现场的海康AGV真正跑起来?很多人第一次接触Java集成海康RCS系统AGV任务下发时,以为就是调两个HTTP接口的事,真正接入后才发现,认证鉴权、任务状态机、轮询限流、车辆调度逻辑,每个环节都有看不见的坑。这篇把从零对接海康RCS的关键代码、设计思路和踩坑经过完整整理出来,给准备接RCS的Java团队做个参考,也顺便聊聊哪些事不该我们来管。
1. 集成之前,先把AGV调度这件事的边界搞清楚
1.1 业务系统和RCS到底谁说了算
一句话回答:业务系统只负责“产生搬运需求”,RCS负责“怎么搬、哪台车搬、走哪条路”。我在项目里见过不少团队试图绕过RCS直接去控制AGV,结果无一例外都碰壁了,因为硬件层、调度层的逻辑太复杂。
一个完整AGV搬运链条通常是这样的:
| 层级 | 承担系统 | 核心职责 |
|---|---|---|
| 业务层 | WMS/MES/ERP | 生成搬运指令,比如“货架A搬到拣选台B” |
| 集成层 | Java集成服务 | 把业务指令翻译成RCS能识别的任务格式,并跟踪状态 |
| 调度层 | 海康RCS | 路径规划、车辆分配、交通管制、充电调度、任务拆分 |
| 执行层 | AGV本体 | 接收RCS指令,执行行走、举升、放货等机械动作 |
从这张表能看到,RCS是调度大脑。它内部集成了路径规划算法、多车防碰撞逻辑和任务优先级仲裁,这些在对接时完全不用我们关心——哪怕热搜里那些AGV路径规划、A*算法的帖子再热闹,对做Java集成的人来说,它们属于RCS的“黑盒能力”。我们真正要做的只有三件事:下发任务、查询状态、处理异常。
很多初学者会纠结要不要学习海康RCS内部的A*算法或者AGV协同调度,我的建议是:可以当兴趣了解,但别陷进去。集成层和调度层的关注点是两套东西,把RCS当作一个带鉴权的远程服务来对接,反而更清爽。
1.2 一个合格的Java集成层该管哪些事
结合我几个落地项目来看,Java集成层的核心职责可以拆成五块:
- 任务转换:把WMS的搬运单转成RCS的任务批次(TaskBatch)和任务单元(TaskUnit),字段映射往往是最容易出错的地方。
- 接口调用:封装登录鉴权、任务下发、状态查询、任务取消等HTTP接口,处理超时和重试。
- 状态同步:RCS的任务状态变化会发生在服务端,集成层必须通过轮询或回调把状态同步回业务库,供WMS/前端展示。
- 可靠性兜底:网络抖动、服务重启、RCS短暂不可用,都要有补偿机制,不能让搬运任务凭空丢失。
- 数据留存:每次下发都会产生一个任务单据,完整留在本地数据库,方便以后对账排查。
同时要明确哪些事不该做:不要直接操作数据库去改RCS里的站点配置,不要绕过RCS去给AGV下发底层运动指令。集成层是“翻译器”和“监控器”,不是“调度器”。明白这个边界,后面写代码就有章法了。
2. 环境准备与接口摸底:先别急着写代码
2.1 技术栈选型与依赖配置
海康RCS官方一般不提供开箱即用的Java SDK,大多数项目都是自己写HTTP客户端封装。这里先给一套我验证过的技术组合:
- JDK 8或11都可以,生产环境以稳定为主,Spring Boot 2.x是主流选择;
- HTTP客户端首选OkHttp 3.x/4.x,连接池、超时、重试都好控制;也可以用Apache HttpClient,但代码会啰嗦一些;
- JSON序列化用Jackson或Fastjson,看团队习惯,我用的Jackson;
- 定时任务用Spring自带的@Scheduled就够了,高并发场景再考虑分布式调度平台。
一个参照的pom依赖配置:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>4.12.0</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency>这里有个容易被忽略的细节:不要图省事用Hutool的HttpUtil,虽然写起来快,但每次请求都会新建连接,生产环境并发一上来就会把RCS服务端的连接数打满,还会本地端口耗竭。OkHttp的ConnectionPool和Dispatcher机制更适合做这种有QPS压力的对接服务。
2.2 RCS关键概念:任务批次、任务单元、站点编码
打开海康RCS的接口文档,会被一堆名词淹没。这里先把四个绕不开的核心概念过一遍:
| 概念 | 说明 | 对应到代码里 |
|---|---|---|
| access_token | 登录后拿到的访问凭证,有有效期 | 每次请求放Header里 |
| 站点(Station) | AGV执行搬运动作的物理位置编码 | 作为fromStation和targetStation传入 |
| 任务批次(TaskBatch) | 一次下发的一组任务,同一个批次共享一个批次号 | batchId,生成后要能幂等 |
| 任务单元(TaskUnit) | 批次里的具体单个搬运任务 | 一般一个TaskUnit对应“从一个站点搬到另一个站点” |
站点编码这个坑特别值得提前说:RCS里的站点通常是一串有规则的编码,比如STATION_LEAVING_01或者P03-2F-A12,不是界面上显示的中文名称。我在项目里见过有人直接传了中文名,结果AGV根本找不到站点,任务一直卡在等待分配。
2.3 从接口清单里挑出必用的三个接口
海康RCS不同版本的接口路径差异很大,但功能维度基本大同小异,核心就三个:
- 登录鉴权接口:POST提交用户名密码或appKey/appSecret,返回access_token和过期时间。
- 任务下发接口:POST提交任务批次、任务单元列表、优先级、站点信息,返回任务批次在RCS侧的编号。
- 任务状态查询接口:按批次号或任务号查询任务执行状态、关联的AGV编号、站点信息。
另外,有条件的话一定要搞清楚有没有事件回调接口。RCS支持在任务状态变化时主动推送到你提供的回调地址。能推就尽量用推送,轮询只做兜底。后面我会详细讲为什么。
3. 认证封装与HTTP客户端:一个能上生产的RcsClient
3.1 token的生命周期管理
RCS的access_token通常有两小时左右的有效期,它的生命周期管理直接决定集成层稳不稳定。最忌讳的写法是每个请求前无条件登录一次,这样不仅慢,还可能把RCS端的老token顶掉,导致别的线程突然401。
我的做法是单独封装一个RcsClient组件,用volatile变量缓存token,到期前5分钟主动刷新,刷新动作加锁保证只有一个线程去调登录接口:
@Component public class RcsClient { private final OkHttpClient httpClient; private final RcsProperties properties; private volatile String accessToken; private volatile long tokenExpireAt; public RcsClient(OkHttpClient httpClient, RcsProperties properties) { this.httpClient = httpClient; this.properties = properties; } /** * 获取有效token,若即将过期则由当前线程触发刷新 */ public String getAccessToken() { long now = System.currentTimeMillis(); if (accessToken != null && now < tokenExpireAt - 5 * 60 * 1000L) { return accessToken; } synchronized (this) { // 双检锁,防止多个线程同时刷新 if (accessToken == null || now >= tokenExpireAt - 5 * 60 * 1000L) { refreshToken(); } return accessToken; } } private void refreshToken() { // 调用RCS登录接口,解析返回的accessToken和expiresIn // 这里用你们项目现有的JSON库解析即可 } }这种“提前刷新+双检锁”的组合几乎不会出问题,唯一要留意的是如果RCS登录接口连续失败,要抛异常让上层感知,不能静默返回旧token。
3.2 统一响应体与异常处理
RCS的接口返回一般是JSON,不管字段叫什么(有的版本是code,有的是resultCode,有的是status),核心逻辑都是“非成功码即视为失败”。封装一个泛型响应体会让所有调用代码清爽很多:
public class RcsResponse<T> { private int code; private String msg; private T data; public boolean isSuccess() { // 注意有的RCS版本用0表示成功,有的用1,以文档为准 return code == 0; } }3.3 HTTP客户端与连接池的坑
OkHttp的配置里,超时时间、连接池大小、队列策略都很关键。直接给出我生产环境在用的参数:
@Bean public OkHttpClient rcsHttpClient() { return new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .retryOnConnectionFailure(true) .connectionPool(new ConnectionPool(20, 5, TimeUnit.MINUTES)) .build(); }这里想特别提醒一点:readTimeout不要设置成几秒钟的短超时。AGV任务的调度过程可能很慢,RCS收到任务后要等待车辆分配,分配不到时任务会排队。如果发下请求后RCS迟迟不给结果,30秒读超时还算合理,再短就会出现“RCS其实收下了任务,但客户端已经超时报错”的尴尬情况。这种超时导致的任务状态不确定,是对账机制要解决的头号问题。
4. 任务下发主链路的代码实现
4.1 任务下发DTO层的设计
设计DTO之前,我建议先去RCS接口文档确认每个字段的类型和是否必填,尤其注意站点编码是字符串还是整型。下面是一个通用性较强的任务下发请求模型:
public class TaskUnitDTO { private String taskUnitGuid; // 任务单元唯一标识,业务系统生成 private String taskUnitName; // 任务单元名称,方便在RCS端排查 private String taskTyp; // 任务类型,如搬运/举升,按文档取值 private String fromStation; // 起始站点编码 private String toStation; // 目标站点编码 private Integer priority; // 优先级,数值越高越优先,按文档约定 private Integer vehicleNo; // 可选,指定车辆编号;不传则由RCS调度 } public class TaskBatchSendRequest { private String taskBatchId; // 幂等键,业务系统用UUID生成 private String taskBatchName; // 任务批次名称 private List<TaskUnitDTO> taskUnitList; }在真实的仓储场景里,一次搬运单可能包含多个子任务。比如“从收货区搬到存储区”,中间可能要求AGV先到A站点,再绕到B站点放货,这时候就要拆成多个TaskUnit放在同一个TaskBatch里。我的经验是:能拆细的尽量拆细,因为RCS的调度是按TaskUnit粒度安排的,太粗的任务会让RCS没法做路径优化。
4.2 下发与幂等控制
任务下发的核心代码逻辑并不复杂,麻烦的是让RCS知道“这条任务之前已经发过了,别再建一份”。我习惯用taskBatchId做幂等键,每次生成UUID。这样如果第一次调用超时、结果没收到,重试时RCS能根据batchId识别出批次已存在,返回已有的批次号。
伪代码逻辑如下:
public TaskSendResult sendTask(TaskBatchSendRequest request) { String token = rcsClient.getAccessToken(); String url = rcsProperties.getBaseUrl() + rcsProperties.getTaskSendPath(); Map<String, Object> payload = new HashMap<>(); payload.put("taskBatchId", request.getTaskBatchId()); payload.put("taskBatchName", request.getTaskBatchName()); payload.put("taskUnitList", request.getTaskUnitList()); // 组装OkHttp请求,Header中带上Authorization: Bearer token // 执行请求,解析RcsResponse if (resp.isSuccess()) { // 返回的data里一般包含RCS侧的批次号 return toTaskSendResult(resp.getData()); } // 根据错误码区分:重复提交/参数错误/服务端异常 throw new RcsException(resp.getMsg()); }这里有个非常重要的实操细节:返回成功不代表AGV已经在执行了。RCS成功接收任务后,还要经历站点分配、车辆分配、路径规划几个内部环节。所以下发成功之后,真正的战场在状态同步。
4.3 状态轮询与业务状态联动
任务下发之后,需要把RCS任务状态同步到本地业务表。最可靠的做法是“回调优先 + 轮询兜底”,但如果项目初期回调还没联调好,也可以先纯轮询。我的轮询实现长这样:
@Scheduled(fixedDelay = 15000) public void pollTaskStatus() { List<TaskRecord> records = taskMapper.findByStatus(PollingStatus.WAITING_POLL); if (records.isEmpty()) { return; } for (TaskRecord record : records) { try { TaskStatusQueryResponse rcsResp = rcsClient.queryTaskStatus(record.getBatchId()); updateTaskStateMachine(record, rcsResp); } catch (Exception e) { log.error("轮询任务状态异常 batchId={}", record.getBatchId(), e); } } }状态机设计上我建议至少包含这几个状态:SUBMITTED(已下发未确认)、PENDING(RCS已接收排队中)、EXECUTING(AGV执行中)、FINISHED(完成)、FAILED(失败)、CANCELED(取消)。
本地业务表会有一列rcs_status专门存RCS返回的原始状态,另有一列biz_status存业务侧最终表达的状态,两者不要混。曾经有同事图省事直接用RCS状态当业务状态,后来RCS版本一升级、状态值变了,前端跟着崩。
5. 实测中的典型故障与排查链路
5.1 token并发刷新导致的偶发鉴权失败
第一次写token管理时,我用的是“取token前先查缓存,没有就登录”的朴素逻辑。上线后出现一个诡异现象:某个时间段任务下发接口偶发401,看日志没有任何规律。
排查链路:
- 先看RCS服务端日志,发现同一秒内有多个登录请求;
- 查本地日志,多个线程同时判断token过期,同时调登录接口;
- 再查RCS机制,确认后登录的请求会刷新服务端token,之前登录返回的token立刻失效;
- 其他线程拿着已经失效的token去请求,自然401。
解决方案就是我前面写的volatile缓存 + synchronized双检 + 提前5分钟刷新。改完之后再没出现过偶发401。
5.2 站点名称当编码用,AGV原地罚站的教训
某个项目联调时,现场AGV收到任务后一直不动,RCS界面上任务状态是“分配车辆失败”。一开始怀疑车辆被占用,后来发现传的fromStation是中文名“东门收货区”,而RCS里的站点编码是STATION_DM01。
排查逻辑很简单:先去RCS后台查到站点管理里真正的唯一编码,再去业务库里看站点表发现我们存的是展示名称。这是个很低级但非常常见的错误,建议对接前先让项目组成员都明确:凡是在接口里传的站点参数,一律用编码,不允许用名称,最好在字段上就加注释,或者传参前做一次编码合法性校验。
5.3 轮询太猛,RCS网关主动限流
有段时间我们内部测试,200个任务同时下发,每秒查一次状态,RCS接口响应开始出现HTTP 429和“too many requests”。RCS作为工厂级调度系统,网关层的限流策略比大家想象得严格得多。
排查后我们调整了三处:
- 轮询间隔从1秒调到15秒;
- 支持批量查询任务状态的接口尽量用批量,不要一个任务一个任务地循环查;
- 上线回调推送之后,轮询降级为兜底手段,每30秒才查一次。
这个教训让我意识到,对接第三方系统时,频率策略是在开发阶段就要放进设计文档里的,不能等上线后再调。如果RCS文档里没有明确限制,从保守的间隔开始加频,比从激进开始被限流要舒服得多。
5.4 下发成功但AGV不执行,去RCS端看什么
“接口返回200,RCS也说任务创建成功,但现场AGV一动不动”——这是群里聊得最多的一个问题。整理一下我在现场排查的标准顺序:
- 看任务状态:RCS界面或查询接口里,任务是PENDING还是EXECUTING?如果一直PENDING,通常是车辆分配阶段卡住了;
- 看车辆状态:AGV是空闲、任务中、充电、故障还是急停?有的场景AGV在充电点自动执行充电策略,不响应任务优先级;
- 看站点可达性:任务里的起始站和终点站是不是在同一张地图上?AGV当前位置到目标站点的路径是否被障碍物或封闭区域阻断;
- 看任务参数:任务类型和车型是否匹配?比如这个车型不支持指定任务类型;
- 看优先级:如果现场同时有其他高优任务在跑,低优先级任务会被无限排队。
我见过最大比例的原因是站点归属地图错误,尤其改造项目里RCS里往往配置了多张地图,代码里写死了一个站点编码,但那个站点属于老地图,新AGV根本不在那张图上跑。排查这类问题时不要纠结代码,先跑现场、上RCS运维界面看,效率最高。
6. 性能优化与生产可观测性的一些升级做法
6.1 异步化与消息队列削峰
业务高峰期,WMS可能同时产生上千条搬运需求。如果集成服务同步逐条调用RCS,一旦RCS响应超过几百毫秒,整个业务链路就会拖垮。
我的做法是引入异步化:
- 业务系统只把任务落库,然后发送一条MQ消息;
- 集成服务的消费端拉取消息,批量调用RCS下发;
- 下发结果写回任务表,供状态轮询统一处理。
如果项目规模没那么大,不引入MQ也可以,用Spring的@Async配一个独立线程池也能扛住大部分峰值。关键点是:调用RCS的线程池和业务处理线程池要分离,避免阻塞正常的业务请求线程。
6.2 任务对账与失败补偿
跟银行对账一个思路:本地任务状态和RCS真实状态不可能天然一致,超时、网络闪断、进程重启都会造成漂移。我设计了一个定时对账任务,每30分钟扫描本地表中“未进入终态”的任务,逐个向RCS查询真实状态:
- 本地显示已下发、RCS查不到:说明可能因为某种原因没创建成功,需要重新下发或人工介入;
- 本地显示下发中、RCS已执行完:以RCS为准修正本地状态,同时把成功事件推给WMS;
- 连续对账N次(比如3次)仍异常的任务,自动标红并推送告警。
对账任务还可以设置指数退避重试:第一次等30秒,失败后等1分钟、2分钟、4分钟,最多重试5次。这套补偿机制救过我好几次,尤其是半夜RCS短暂重启的场景,有它兜底第二天早上打开工单列表根本不用人工补数据。
6.3 监控指标与告警阈值
对接RCS这类第三方调度系统,最怕的不是出问题,而是出了问题没人第一时间知道。我整理了核心监控指标:
| 指标 | 说明 | 建议告警阈值 |
|---|---|---|
| 下发接口成功率 | RCS返回成功码的比例 | 低于99%告警 |
| RCS平均响应时间 | 任务下发接口的耗时 | 超过2000ms告警 |
| 轮询限流次数 | 收到的429/限流码次数 | 5分钟内大于10告警 |
| 任务完成周期 | 从下达到FINISHED的平均耗时 | 超过30分钟告警 |
| 本地积压任务数 | 未进入终态的任务总量 | 大于50告警 |
| token刷新失败数 | 登录接口异常次数 | 一票否则告警 |
用Spring Boot Actuator + Micrometer + Prometheus + Grafana,基本零成本就能搭起来。在真正做工业项目时,这个告警面板的价值远大于那些花哨的展示页面——AGV停在产线中央的时候,早一分钟发现问题就是早一分钟止损。
做这类集成,最有价值的经验往往不在代码本身,而在于你怎么把一个外部调度系统当作一个有脾气的在线服务来对待:token要想着提前刷新,任务要想着定期对账,失败要想着重试补偿。把这几点做到位,海康RCS的对接就成功了大半。如果你们团队正准备做类似的项目,先把边界理清、接口摸底摸透,再动手编码,能少走很多弯路。