多机器人重启失联:DDS 发现机制与传输层静默故障
一台机械狗断电重启后,调度主车再也收不到它的里程数据;重启桥接进程数据立刻恢复;换用另一套传输配置后,点云又"静默消失"------同一个症状,四次排查,四个不同层面的根因。本文以这次多机器人系统联调中的真实故障为锚点,把 DDS 自动发现机制、QoS 兼容性、共享内存传输的静默失败模式,以及 WiFi 省电掉线,一次讲透。
🔍 现象
三机器人系统(主车 + 两台次车)通过跨域桥接交换数据。观测到的现象按时间顺序:
- 次车(机械狗)断电重启后,主车视角下它的 ROS 话题端点全部存在,数据为零;
- 重启桥接进程后数据立刻恢复;
- 换用另一个建图栈启动时,点云话题"有发布者、有订阅者、
topic info显示端点匹配,但topic echo永远超时"; - 同时观察到:对次车 ping 延迟飙到 20 秒、丢包 100%,但它的数据仍在小流量到达------ICMP 全挂、TCP 全挂、UDP 数据还在流。
四个症状指向不同层面,但表象都是"收不到数据"。这正是多机系统排查最容易走错方向的地方:把传输层故障当成发现层故障,把网卡层故障当成软件层故障。
🧠 根因追溯
这组故障最终被拆解为四个相互独立的根因,每一个都发生在不同的协议层:
#mermaid-svg-JAMsGNsmkWhk80Oi{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-JAMsGNsmkWhk80Oi .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JAMsGNsmkWhk80Oi .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JAMsGNsmkWhk80Oi .error-icon{fill:#552222;}#mermaid-svg-JAMsGNsmkWhk80Oi .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JAMsGNsmkWhk80Oi .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JAMsGNsmkWhk80Oi .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JAMsGNsmkWhk80Oi .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JAMsGNsmkWhk80Oi .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JAMsGNsmkWhk80Oi .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JAMsGNsmkWhk80Oi .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JAMsGNsmkWhk80Oi .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JAMsGNsmkWhk80Oi .marker.cross{stroke:#333333;}#mermaid-svg-JAMsGNsmkWhk80Oi svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JAMsGNsmkWhk80Oi p{margin:0;}#mermaid-svg-JAMsGNsmkWhk80Oi .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-JAMsGNsmkWhk80Oi .cluster-label text{fill:#333;}#mermaid-svg-JAMsGNsmkWhk80Oi .cluster-label span{color:#333;}#mermaid-svg-JAMsGNsmkWhk80Oi .cluster-label span p{background-color:transparent;}#mermaid-svg-JAMsGNsmkWhk80Oi .label text,#mermaid-svg-JAMsGNsmkWhk80Oi span{fill:#333;color:#333;}#mermaid-svg-JAMsGNsmkWhk80Oi .node rect,#mermaid-svg-JAMsGNsmkWhk80Oi .node circle,#mermaid-svg-JAMsGNsmkWhk80Oi .node ellipse,#mermaid-svg-JAMsGNsmkWhk80Oi .node polygon,#mermaid-svg-JAMsGNsmkWhk80Oi .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-JAMsGNsmkWhk80Oi .rough-node .label text,#mermaid-svg-JAMsGNsmkWhk80Oi .node .label text,#mermaid-svg-JAMsGNsmkWhk80Oi .image-shape .label,#mermaid-svg-JAMsGNsmkWhk80Oi .icon-shape .label{text-anchor:middle;}#mermaid-svg-JAMsGNsmkWhk80Oi .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-JAMsGNsmkWhk80Oi .rough-node .label,#mermaid-svg-JAMsGNsmkWhk80Oi .node .label,#mermaid-svg-JAMsGNsmkWhk80Oi .image-shape .label,#mermaid-svg-JAMsGNsmkWhk80Oi .icon-shape .label{text-align:center;}#mermaid-svg-JAMsGNsmkWhk80Oi .node.clickable{cursor:pointer;}#mermaid-svg-JAMsGNsmkWhk80Oi .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-JAMsGNsmkWhk80Oi .arrowheadPath{fill:#333333;}#mermaid-svg-JAMsGNsmkWhk80Oi .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-JAMsGNsmkWhk80Oi .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-JAMsGNsmkWhk80Oi .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JAMsGNsmkWhk80Oi .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-JAMsGNsmkWhk80Oi .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JAMsGNsmkWhk80Oi .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-JAMsGNsmkWhk80Oi .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-JAMsGNsmkWhk80Oi .cluster text{fill:#333;}#mermaid-svg-JAMsGNsmkWhk80Oi .cluster span{color:#333;}#mermaid-svg-JAMsGNsmkWhk80Oi div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-JAMsGNsmkWhk80Oi .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-JAMsGNsmkWhk80Oi rect.text{fill:none;stroke-width:0;}#mermaid-svg-JAMsGNsmkWhk80Oi .icon-shape,#mermaid-svg-JAMsGNsmkWhk80Oi .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JAMsGNsmkWhk80Oi .icon-shape p,#mermaid-svg-JAMsGNsmkWhk80Oi .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-JAMsGNsmkWhk80Oi .icon-shape .label rect,#mermaid-svg-JAMsGNsmkWhk80Oi .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JAMsGNsmkWhk80Oi .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-JAMsGNsmkWhk80Oi .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-JAMsGNsmkWhk80Oi :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 次车重启后主车收不到数据
发现层: 桥不重建
传输层: SHM 静默丢失
工具层: CLI daemon 假象
网卡层: 省电掉线
wait_for_publisher 默认开启
auto_remove 默认关闭
桥只创建一次
共享内存端口锁残留
发布静默丢弃
ros2cli daemon 图快照过期
误报 未发布
网卡省电模式
DTIM 间隔内缓冲丢包
解法 --auto-remove true
强制 UDPv4 + 清理 /dev/shm
重启 daemon 或改用 echo 实测
关闭省电并持久化
四个根因叠加的后果是:同一个"收不到数据"的症状,可能有四种完全不同的修法。任何一次只修了其中一种,症状就会"复发"------这正是它值得写一篇长文的原因。
⚙️ 原理
核心知识点 1:DDS 自动发现协议与"桥只建一次"的缺陷
定义 :DDS(Data Distribution Service)的节点在启动后通过发现协议 互相感知------参与者(Participant)周期性组播**SPDP(Simple Participant Discovery Protocol)报文宣告自己,收到后通过SEDP(Simple Endpoint Discovery Protocol)**交换端点(发布者/订阅者)清单。匹配成功的发布-订阅对之间才开始真正传输数据。
本质 :发现的本质是"先双向握手,再传数据 "。一个订阅者只有在 SEDP 阶段确认了某发布者的存在(并满足 QoS 兼容),才会向它请求数据。这意味着:"端点存在"和"数据流动"是两件事 ------你可以看到 Publisher count: 1, Subscription count: 1 的完美端点,同时数据为零。
诊断判据:出现"端点匹配但零数据"时,按顺序检查三件事:
- 双方 QoS 是否兼容(见延伸);
- 传输层是否有静默丢包(SHM 锁残留);
- 中间转发件(桥、中继)是否在端点出现之前就已定型。
本案的机制缺陷 :跨域桥(domain bridge)启动时的默认语义是"等待发布者出现 → 创建桥"。这个语义有两个默认参数组合出一个陷阱:
wait_for_publisher = true:桥在扫描到发布者之前,不创建桥接管道;auto_remove = false:当发布者消失时,已创建的桥不移除、也不重建。
组合结果:桥只在"第一次发现发布者"时创建一次。次车重启后,新的发布者携带新的端点标识回到网络中------但桥的内部状态还停留在旧端点上,永远不会为新端点重建管道。唯一的恢复方式是重启桥本身。这不是玄学,是两个布尔默认值叠加出的确定性行为。
官方帮助文本里其实写着答案:开启 auto_remove 后,"被等待的端点消失则移除桥,且当等待条件再次满足时自动重建"。一行参数,把"人工重启"变成"系统自愈"。
核心认知:在 DDS 世界里,"重启中间件"能解决的故障,几乎都是发现/匹配层的故障;而能用参数让系统自动恢复的,就不该靠人重启。
权衡 :开启 auto-remove 的代价是什么?当次车离线时,该话题的桥会彻底拆除 ------这意味着离线期间主车侧连"该话题存在过"的痕迹都没有(对新加入的订阅者而言)。而关闭 auto-remove 的"好处"仅仅是:桥的端点常驻,次车回来时如果 DDS 发现恰好重建成功,数据无缝接上------但实测证明这个"恰好"并不可靠。结论:对随时可能上下线的移动机器人,确定性自愈远比常驻端点重要。
核心知识点 2:QoS 兼容性矩阵------"有端点无数据"的第二种成因
定义 :DDS 中每条话题的发布者和订阅者各自声明 QoS(Quality of Service)策略,其中最关键的两项是 可靠性(Reliability)与持久性(Durability)。
数学/物理本质 :QoS 兼容规则本质上是请求-供给的单向不等式:
- 可靠性:订阅方的可靠性等级必须 ≤ 发布方(发布方 RELIABLE 可以匹配订阅方 BEST_EFFORT------数据可丢;反之发布方 BEST_EFFORT 无法满足要求 RELIABLE 的订阅方------端点不匹配);
- 持久性:订阅方的持久性等级必须 ≥ 发布方(发布方 TRANSIENT_LOCAL 保留历史,订阅方 VOLATILE 也能匹配------只是拿不到历史;反之订阅方 TRANSIENT_LOCAL 遇到发布方 VOLATILE 则不匹配)。
关键点:QoS 不兼容时,DDS 不报错 。端点照常注册到发现协议里,topic info 照常显示计数,唯一的区别是数据一个包都不会发。这是 DDS 排障中最反直觉的一条。
本案中的实证 :给狗的桥接配置添加地图转发(transient_local + 自定义 QoS 块)后,曾出现"狗侧 10Hz 在发、桥端点存在、主车侧零数据"的现象------最终确认是桥配置里 QoS 块写法与实际发布端不一致导致匹配失败。同一个"端点在、数据无",本次的根因却是 QoS------和发现层缺陷是两类问题,修法完全不同。
核心知识点 3:共享内存传输的"静默失败"模式
定义 :FastDDS 支持多种传输层,默认在同机进程间优先使用 **SHM(共享内存)**传输:数据不经过内核网络栈,直接通过 /dev/shm 下的共享内存段交换。
为什么它危险 :SHM 的实现依赖一套端口锁文件 (/dev/shm/fastrtps_*)。当进程异常退出、系统在传输中掉电、或者短时间内大量进程生灭(比如排查时反复启停的命令行工具,每个都会创建自己的段),锁文件会残留。残留锁会导致两种症状:
- 发布静默丢失 :发布者以为发出去了,订阅者一个包都收不到------无任何报错;
- 发现间歇异常:新进程无法正确注册。
本案实证:一次排查中统计到主车 /dev/shm 下残留 194 个 fastrtps 锁文件(正常应为个位数)。清理后连同重启栈,静默丢失消失。更隐蔽的是:这个故障在"同一 launch 内的发布-订阅对 "之间依然正常(它们的 SHM 段在故障发生前已建立),只影响新建的匹配------于是现象呈现为"老数据流正常、新订阅收不到",极易误判为"数据源有问题"。
为什么 UDP-only 是那个被验证的解 :把传输强制为 UDPv4(禁用 SHM),牺牲同机数据拷贝的一点效率,换来失败模式的可见性------UDP 发送失败至少会在网络栈层面可观测,而不是无声消失。在对可靠性要求高于极限性能的机器人系统里,这个交换是值得的。本案中,把 UDP-only 配置补进建图服务单元后,点云静默丢失再未复现。
诊断口诀:端点在、数据无,先查 QoS 匹配,再查 SHM 锁,最后才怀疑数据源。
核心知识点 4:ros2cli 守护进程的"图快照"陷阱
定义 :ros2 命令行工具(topic list、topic hz、node list)依赖一个用户级守护进程 (ros2cli daemon)维护"计算图"快照。daemon 以它第一次启动时的环境变量 (尤其是 ROS_DOMAIN_ID)创建参与者,此后所有查询都基于这份快照。
本案的实证 :排查中反复出现"topic hz 报 does not appear to be published yet,但实际消费者数据正常"的假象------最严重的一次,同一台车上 topic hz 对某话题报不存在,而 MQTT 上报里的消费者正以 10Hz 接收同一话题。跨域操作时更迷惑:daemon 若以域 A 启动,用域 B 查询会得到域 A 的图,所有话题显示"不存在"。
为什么这是排障的致命陷阱:如果诊断者相信了这个假"未发布",就会往"数据源崩溃""网络断了"的错误方向走------本案中曾因此误判"下行链路已断",浪费了整轮排查。
正确姿势:
- 判定数据是否真实流动,用实际消费者 (业务节点)的视角,或直接
ros2 topic echo(强制新建端点); - 定期清杀 daemon:杀掉后新的查询会以当前 shell 的域重建参与者;
- 跨域查询时,永远记得 daemon 是域敏感的。
📚 延伸知识
延伸 1:802.11 省电模式与"掉线循环"(与主线关系:次车反复掉线的物理层根因)
WiFi 网卡的**省电模式(Power Save Mode)**工作原理:网卡在 beacon 间隔内休眠,接入点将发往该客户端的帧缓存在 DTIM(Delivery Traffic Indication Message)周期内,网卡醒来后一次性拉取。代价是:延迟随 DTIM 周期波动,高负载下缓冲区溢出直接丢帧。
在 Jetson(RTL8822CE 网卡)上的实测表现:单机轻载时省电模式的抖动无感;一旦空口出现高负载(本案中远端视频流占用 ~2.8MB/s),重传延迟放大到秒级,省电导致的缓冲溢出让关联本身反复掉线------表现为 ping 通几分钟后彻底消失,重启恢复几分钟后再掉。
诊断判据 :iw dev <iface> get power_save 显示 on,且掉线与负载正相关、重启后暂时恢复。
纠偏 :一个常见错误认知是"省电模式对机器人没影响,反正电池够用"。错。省电的延迟代价在低负载下不可见,但它的关联稳定性代价在高负载下是灾难性的。机器人平台的无线网卡,省电应当默认关闭------这是本案用一整天的反复掉线换来的结论。
延伸 2:网络拥塞下的"分层可达性"------为什么 UDP 数据还在流而 TCP/ICMP 全挂
本案最诡异的现象:对狗 ping 丢包 100%,SSH 连接超时,但它的 ROS 数据(同样是 UDP)仍在以 10Hz 到达主车。
原理在队列调度:空口拥塞时,接入点的出队顺序对三类流量极不平等------
- 组播/低速率 UDP:包小、持续、抢占能力弱,但每次只要挤进一个包就能交付;
- TCP :依赖三次握手建立连接,握手阶段的 SYN 丢一次就要指数退避重试------建立期对丢包最敏感;
- ICMP:无重传机制,丢就是丢。
于是在深度拥塞下出现"数据还在流、管理通道全死 "的劈叉状态。这正是"在线三层判定"设计的由来:设备可达性(TCP:22 探测)与数据活性(里程话题新鲜度)必须分开测量 ------两者背离时,才能准确描述"网络差到 SSH 进不去、但车还在干活"的真实状态。任何把两层混为一个 online 布尔值的做法,都会在这种场景下误报。
延伸 3:DDS 组播在受限网络(手机热点)中的行为
手机热点(尤其 iOS)对组播流量有天然的节流:Multicast 客户端休眠机制会让热点在无前台设置页时停止广播 SSID,同时限制客户端间组播。对 DDS 的影响是:依赖组播做 SPDP 发现的节点,在热点环境里可能"时能看到、时看不到"。
工程对策是单播对等表(initial peers list):在 FastDDS 配置中显式列出对端地址,让发现走单播,绕开组播限制。这也是容器/虚拟机/受限 WiFi 环境下跑 DDS 的通用手法。
何时用得上:只要你的 DDS 系统要进手机热点、公司隔离网络或 Docker/VM 的 NAT 边界,这个配置就该提前埋好。
延伸 4:发现协议的确定性 --- 为什么"重启大法"总能修好却又总是复发
重启桥/重启栈之所以"万能",是因为重启强制所有参与者重新走一遍 SPDP/SEDP------把所有发现层、QoS 层、传输层的状态机复位。这既是它的价值,也是它的陷阱:能被重启修复的故障,说明某个状态机的转移条件有缺陷 。本案中三个状态机缺陷(桥不重建、SHM 锁残留、省电复位)都是"重启可修、复发照旧"型------判别方法很简单:如果一个故障在 24 小时内第二次出现,就停止用重启解决它,去追那个状态机。
🛠 方案
最终生效的完整方案是一套四层自愈设计,每一层对应一个根因:
| 层 | 设计 | 为什么必须存在 |
|---|---|---|
| 发现层 | 上行桥统一加 --auto-remove true |
次车重启 → 发布者消失 → 桥拆除 → 回来自动重建,主车零操作 |
| 传输层 | 两台 Jetson 的所有 ROS 服务统一 UDP-only profile + 持久化到服务单元 | SHM 静默失败模式不可接受;且必须写进服务单元而非 shell 脚本------否则换一种启动方式就失效(本案建图服务漏配即复现) |
| 链路层 | 网卡省电关闭:运行时 iw + NetworkManager 配置文件双重持久化 |
运行时设置会被任何接口重启重置------只改一处必复发 |
| 诊断层 | 在线三层判定(TCP:22 / odom 新鲜度 <3s / pose >5s 置空)+ 测试用长驻发布者 | 短命 --once 发布者在发现完成前就把数据发完,必丢------曾造成整轮误判 |
配套的两条纪律:
- 测试 DDS 链路禁止使用一次性发布者。用长驻发布进程或连续发送 20 次------短命进程在发现完成前就把消息发完了,制造"链路断了"的假警报;
- 判定链路真假只信三个证据 :实际消费者的接收状态、
topic echo实拉、杀掉 daemon 后复测。topic hz的"未发布"不作为结论。
📝 一页总结
故障归因路径
| 现象 | 归因路径 | 解法 | 代价与配套 |
|---|---|---|---|
| 次车重启后数据断 | 桥的发现状态机只建一次 | --auto-remove true |
次车离线期间端点消失(可接受) |
| 端点匹配但零数据 | QoS 单向不等式不满足 | 对齐两侧 QoS 块 | 牺牲持久性语义需双方确认 |
| 新订阅收不到、老数据正常 | SHM 端口锁残留 | UDP-only profile 持久化 | 同机拷贝效率略降 |
topic hz 假"未发布" |
CLI daemon 图快照过期 | 杀 daemon / 用 echo / 信消费者 | 无 |
| 次车反复掉线 | 网卡省电模式 | 运行时 + 配置文件双关 | 续航略降(机器人不在乎) |
| ping 全挂但数据在流 | 拥塞下分层可达性劈叉 | 在线三层判定(TCP/数据/位姿分开测) | 判定逻辑复杂度上升 |
本文知识点清单
| 知识点 | 一句话本质 | 诊断或应用判据 |
|---|---|---|
| DDS 自动发现(SPDP/SEDP) | 先双向握手、再传数据;匹配是数据流动的前提 | 端点计数正常却零数据 → 查匹配条件 |
| QoS 兼容规则 | 请求-供给的单向不等式,不满足即静默断流 | 双端 reliability/durability 逐项对表 |
| SHM 静默失败 | 端口锁残留使新建匹配的发布静默丢弃 | /dev/shm 计数异常 + 老流正常新流死 |
| ros2cli daemon | 图快照按首次启动环境冻结,跨域即错图 | 诊断结论前必须杀 daemon 复测 |
| 802.11 省电模式 | DTIM 缓存 + 休眠关联,高负载下反复掉线 | iw get power_save 为 on 且掉线周期性 |
| 分层可达性 | 拥塞下 ICMP/TCP 先死、小包 UDP 后死 | 管理通道与数据通道状态劈叉时分别测量 |
| 组播受限网络对策 | 单播对等表替代组播发现 | 手机热点/VM/容器环境必配 |
三条可迁移认知
- 分布式系统里,"端点存在"与"数据流动"是两个独立的事实------任何一次"看得见配置、收不到数据"的排查,都应先走匹配条件(QoS/发现状态),而不是怀疑数据源。
- "重启能修"是缺陷信号,不是解决方案:凡 24 小时内复现的、靠重启恢复的故障,必有一个未修复的状态机------找到它,把它变成自愈设计。
- 同一症状必须分层归因:物理层(省电掉关联)、传输层(SHM 静默丢)、发现层(桥不重建)、工具层(daemon 假象)可以叠加出完全相同的表象------分层排除法比任何单一手段都快。