在分布式系统的世界里,我们习惯了 HTTP 的请求/响应模式,也熟悉了 Kafka 和 RabbitMQ 那种中心化的消息队列。但在自动驾驶、工业机器人、航空航天等高实时性领域,有一个名字频繁出现——DDS(Data Distribution Service)。
很多人初识 DDS 会觉得它很"神秘":没有 Broker,数据却能在毫秒级送达;程序随便启动,却能自动发现彼此建立连接。这背后的原理究竟是什么?本文将从架构到协议,为你彻底拆解 DDS 的实现原理,并穿插介绍如何用 eProsima 的工具直观观察这些原理的运行。
一、核心哲学:以数据为中心
要理解 DDS,首先要理解它最根本的设计哲学——“以数据为中心”(Data-Centric),这与传统的消息中间件有本质区别。
1.1 两种思维模式的对决
| 维度 | 消息中心 (Message-Centric) | 数据中心 (Data-Centric) |
|---|---|---|
| 代表 | Kafka, RabbitMQ, MQTT | DDS |
| 架构 | 中心化 Broker | 去中心化 P2P |
| 发送方视角 | “我把这条消息发给 Topic A” | “我把这个值更新到数据空间的 X 位置” |
| 接收方视角 | “我从 Topic A 消费消息” | “我看到数据空间 X 位置的值变了” |
| 数据语义 | 消息流过即消失 | 数据是"有状态的",一直存在于空间中 |
| 是否关心对方 | 关心(发到 Broker 即可) | 不关心(只关心数据本身) |
1.2 最恰当的类比:共享白板
邮件系统(MQTT/Kafka) 共享白板(DDS) ───────────────────────── ──────────────────────── 你写好一封信 你走到白板前 ↓ ↓ 交给邮局(Broker) 擦掉旧值,写上你的新状态 ↓ ↓ 邮局通知收信人来取 路过的人看一眼白板 ↓ ↓ 收信人拿到信,看完就扔 就知道最新情况,不用等人通知在共享白板模式下:
- 你不需要知道谁在看白板——你只管写
- 看白板的人不需要知道谁写的——只管看最新值
- 白板上的信息是持久存在的——随时路过随时看
- 完全不需要中介——大家直接围在白板前
1.3 DDS 的"白板"叫什么?
DDS 构建了一个抽象的全局数据空间(Global Data Space, GDS)。每个"白板位置"就是一个Topic。发布者把数据更新到 Topic 上,订阅者通过 Topic 名关注这个位置的变化。
关键词:Data-Centric、Global Data Space、Topic
二、四大核心概念
DDS 的一切都围绕 4 个基本概念展开。理解它们,就理解了 DDS 的骨架。
2.1 Domain——逻辑隔离的"微信群"
Domain 0 Domain 1 ┌─────────────────┐ ┌─────────────────┐ │ Participant A │ │ Participant C │ │ Participant B │ │ Participant D │ │ ↑互相可见↑ │ │ ↑互相可见↑ │ │ A 看不到 C, D │ │ C 看不到 A, B │ └─────────────────┘ └─────────────────┘- Domain 通过Domain ID(0-232 的整数)区分
- 不同 Domain 完全隔离,互不干扰
- 同一个进程可以同时加入多个 Domain
- 作用:网络分区隔离、多租户、安全性
2.2 Participant——“群成员”
Participant 是 DDS 网络中的一个节点。一个进程可以有一个或多个 Participant。
每个 Participant 持有:
- GUID(Globally Unique Identifier)— 全球唯一标识
- 结构:
HostId + AppId + ObjectId(共 16 字节) - 例如:
01.0f.001122334455.0a1b2c3d.0.0.1 - 相当于 DDS 世界的 MAC 地址 + PID
- 结构:
- IP 地址和端口— 用于网络通信
- QoS 声明— 自己支持哪些服务质量
调试技巧:运行
fastddsspy.exe后输入participants命令,会列出当前 Domain 中所有 Participant 的 GUID 和名称。你可以看到每个节点的"身份证"。
2.3 Topic——“白板的位置”
Topic 是数据发布和订阅的通道名称。发布者和订阅者通过 Topic 名称来匹配。
Topic 的三要素: ┌─────────────────────────────┐ │ 名称: "Square" │ ← 频道名(必须一致才能通信) │ 类型: "ShapeType" │ ← 数据结构(必须兼容) │ QoS: RELIABLE + KEEP_LAST 5│ ← 行为规则(必须兼容) └─────────────────────────────┘一个 Topic 可以关联多个 Writer 和 Reader。同名的 Topic 在不同进程间构成一个逻辑数据通道。
2.4 Writer & Reader——“真正读写白板的人”
| 角色 | 类比例子 | 说明 |
|---|---|---|
| DataWriter | 在白板上写字的人 | 发布数据到 Topic |
| DataReader | 看白板的人 | 从 Topic 订阅数据 |
关键点:
- 一个 Publisher 可以有多个 Writer(一个 Topic 一个)
- 一个 Subscriber 可以有多个 Reader(一个 Topic 一个)
- Writer 和 Reader通过 Topic 匹配,不直接连接
- 一个 Writer 可以匹配多个 Reader;一个 Reader 可以匹配多个 Writer
进程 A 进程 B ┌──────────┐ ┌──────────┐ │ Publisher│ │Subscriber│ │ ┌──────┐ │ │ ┌──────┐ │ │ │Writer│─┼─── Topic ────┼─│ Reader│ │ │ └──────┘ │ │ └──────┘ │ └──────────┘ └──────────┘调试技巧:在
fastddsspy.exe中输入endpoints,可以看到当前 Domain 中所有的 Writer 和 Reader 的详细列表,包括它们关联的 Topic 名称和 GUID。
Domain/Participant的概念
• 这两个概念是整个 DDS 的基础中的基础,也是初学者最容易混淆的地方。
———
一句话直击本质
概念 一句话定义
━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Domain DDS 世界的"隔离区"——只有同 Domain 的节点才能看到彼此
───────────── ─────────────────────────────────────────────────────────────────────────────────────────
Participant DDS 世界的"入口/容器"——一个进程需要先创建一个 Participant,才能在这个 Domain 里做任何事
———
先理解 Domain:比作"微信群"
微信里有多个群:
┌─ 技术交流群 (Domain 100) ─┐
│ 张三 │ 李四 │
│ 王五 │ …… │
└───────────────────────────┘
┌─ 家庭群 (Domain 200) ─────┐
│ 张三 │ 张三妈妈 │
│ 张三爸爸 │ …… │
└───────────────────────────┘
张三同时在两个群里。
他在技术群发的消息,家人看不到;
他在家庭群发的消息,同事看不到。
Domain = 微信群。 就是这么回事。
DDS 里的 Domain 通过一个 Domain ID(整数,0-232) 区分。
Participant A (Domain 0) Participant B (Domain 0)
可以互相看到 ✓
Participant A (Domain 0) Participant C (Domain 1)
完全隔离,谁也看不见谁 ✗
关键是:
- 同一个进程可以加入多个 Domain(像张三同时在两个群)
- 不同 Domain 完全隔离——没有数据泄漏,没有广播风暴
- 多租户部署就用不同 Domain 隔开
———
再理解 Participant:比作"手机登录微信"
Participant 可以理解为**“一个人用他的手机登录了微信群”**。
┌─ 微信 App (进程) ──────────┐ │ │ │ 登录账号 A (Participant 1) │ ← 可以加群 │ 登录账号 B (Participant 2) │ ← 可以加群 │ │ └───────────────────────────┘对应到 DDS:
一个 C++ 进程:
┌──────────────────────────────────┐
│ participant1 = factory->create_participant(0, …) │ ← 加入 Domain 0
│ participant2 = factory->create_participant(1, …) │ ← 加入 Domain 1
│ participant3 = factory->create_participant(0, …) │ ← 又加入 Domain 0
└──────────────────────────────────┘
Participant 就是 DDS 中一切活动的"容器"。在你创建 Participant 之后,你才能在它上面:
participant
├── register_type() ← 注册数据类型
├── create_topic() ← 创建 Topic
├── create_publisher() ← 创建发布者
├── create_subscriber() ← 创建订阅者
└── … ← 所有 DDS 操作都从 Participant 开始
———
用一个真实例子串起来
进程 = 你的电脑上运行的一个 .exe 程序
场景:一个自动驾驶系统
┌─ 电脑 A:感知模块 ──────────────────────────────┐
│ │
│ Participant “Perception” (Domain 0) │
│ ├── Publisher “LidarScan” │
│ │ └── Writer → Topic “scan” │
│ ├── Publisher “CameraImage” │
│ │ └── Writer → Topic “image” │
│ └── Subscriber “ControlCmd” │
│ └── Reader ← Topic “cmd_vel” │
└──────────────────────────────────────────────────┘
┌─ 电脑 B:规划控制模块 ───────────────────────────┐
│ │
│ Participant “Planner” (Domain 0) │
│ ├── Subscriber “LidarHandler” │
│ │ └── Reader ← Topic “scan” │ ← 自动匹配上 A 的 Writer
│ ├── Subscriber “CameraHandler” │
│ │ └── Reader ← Topic “image” │ ← 自动匹配上 A 的 Writer
│ └── Publisher “ControlOutput” │
│ └── Writer → Topic “cmd_vel” │ ← A 自动订阅这个
└──────────────────────────────────────────────────┘
┌─ 电脑 C:调试电脑(仅监控) ─────────────────────┐
│ │
│ Participant “Monitor” (Domain 0) │
│ └── Fast DDS Spy 自动加入 │
│ → 能看到所有 Topics 和数据 │
└──────────────────────────────────────────────────┘
注意:
- 三台电脑都在 Domain 0 → 可以互相通信
- 每个电脑启动一个 Participant(名字不同)
- Participant “Perception” 里有 2 个 Publisher + 1 个 Subscriber
- Participant “Planner” 里有 2 个 Subscriber + 1 个 Publisher
- 不需要配置任何 IP 地址——SPDP/SEDP 自动发现匹配
———
容易混淆的 5 个问题
Q1:一个进程可以有几个 Participant?
一个或多个。但通常一个进程只需要一个。
Q2:一个 Participant 可以同时发布和订阅吗?
当然可以。一个 Participant 里同时有 Publisher 和 Subscriber 是很常见的(比如上面的规划模块)。
Q3:不同进程里的 Participant 名字相同会怎样?
没事。名字只是给人看的标签,DDS 内部靠 GUID 区分(所以名字重复没关系)。
Q4:Participant 加入 Domain 后做了什么?
就两件事:
- 发多播告诉所有人"我来了"(SPDP:我叫什么、IP 在哪、开了哪些端口)
- 监听别人的多播,看还有谁在(SPDP:谁加入了、谁离开了)
Q5:Participant 可以中途换 Domain 吗?
不行。Participant 创建时指定 Domain ID,之后就固定了。想加入另一个 Domain 就再创建一个 Participant。
———
一句话记忆
- Domain = 微信群(隔离不同业务的数据)
- Participant = 你(容器/入口,持有 GUID 身份)
- 创建 Participant 之后,才能在这个 Domain 里做发布/订阅/发现等一切操作
三、自动发现:如何找到彼此
DDS 最令人惊叹的特性之一是零配置自动发现——你不需要配置 IP 地址、不需要启动中心化服务,程序启动后自动找到彼此。这背后是两层发现协议在工作。
3.1 SPDP:参与方发现(“这里有哪些人?”)
SPDP(Simple Participant Discovery Protocol)解决第一个问题:这个网络上还有谁?
工作流程
Participant A(刚启动) Participant B(已运行) │ │ │ ① 多播宣告自己存在 │ │--- DATA(p) ──────────────────→ │ │ 多播地址: 239.255.0.0 │ │ 端口: 7400 + domain*2 │ │ │ │ ② B 收到后回复自己的信息 │ │←── DATA(p) ──────────────────── │ │ │ │ ③ 双方都知道了对方的存在 │ │ 存入本地的 Participant 列表 │ │ 之后 A 不再发多播,改为直接单播 │DATA§ 报文中包含什么?
DATA(p) 报文 ≈ DDS 世界的"自我介绍名片" ┌─────────────────────────────────┐ │ GUID: 01.0f.xxxx...xx.0.0.1 │ ← 唯一身份 ID │ Vendor ID: eProsima │ ← 哪个厂商的实现 │ Protocol Version: 2.5 │ ← RTPS 版本 │ IP 地址: 192.168.1.100 │ ← 通信地址 │ 端口号: 7410 │ ← 通信端口 │ 存活周期: 30 秒 │ ← "我每隔 30 秒喊一声" │ QoS 能力: │ ← 支持哪些策略 │ - RELIABLE │ │ - TRANSIENT_LOCAL │ │ - ... │ └─────────────────────────────────┘租约机制(Lease):如何检测节点离开?
DDS 的节点可能随时离开(崩溃、网络断开)。SPDP 通过租约机制检测:
正常情况(每 30 秒一次心跳): A ─── DATA(p) ───→ B ("我还活着") A ─── DATA(p) ───→ B ("我还活着") A ─── DATA(p) ───→ B ("我还活着") 异常情况(A 崩溃了): A ──── ✗ ───────→ B (心跳停了) B 等待 2.5 × 30 秒 = 75 秒 B 判定 A 已离开,从列表中移除调试技巧:用
fastddsspy.exe启动后,participants列出的就是通过 SPDP 发现的所有 Participant。你可以在另一个终端启动或关闭一个 DDS 程序,观察 Spy 中列表的实时变化——这就是 SPDP 租约机制在工作的直接体现。
GUID 结构的秘密
GUID = 16 字节 = HostId(4) + AppId(4) + ObjectId(8) HostId: 通常来自 MAC 地址或随机生成 AppId: 区分同一主机上的不同进程 ObjectId: 区分同一进程内的不同实体 所以两个进程在同一台机器上运行时: GUID 的前 4 字节相同(同一 MAC) GUID 的中间 4 字节不同(不同进程)3.2 SEDP:端点匹配(“我们能合作吗?”)
知道对方 Participant 存在后,SEDP(Simple Endpoint Discovery Protocol)解决第二个问题:我们能合作吗?
工作流程
Participant A Participant B │ │ │ ① 单播交换端点信息 │ │--- DATA(w) ──────────────────→ │ │ "我发布 Topic 'Square' │ │ 类型: ShapeType │ │ QoS: RELIABLE, KEEP_LAST 5" │ │ │ │←── DATA(r) ──────────────────── │ │ "我订阅 Topic 'Square' │ │ 类型: ShapeType │ │ QoS: RELIABLE, KEEP_ALL" │ │ │ │ ② DDS 内核自动执行匹配算法 │ │ │ │ ③ 匹配成功 → 建立数据通道 │ │══════ RTPS 数据流 ════════════→ │匹配算法三要素
DDS 内核会逐项比对以下三个条件,全部通过才建立连接:
| 匹配条件 | 判定规则 | 失败例子 |
|---|---|---|
| Topic 名称 | 必须完全一致 | Writer 发布 “Square”,Reader 订阅 “Circle” ✗ |
| 数据类型 | 必须兼容(类型名一致) | Writer 用 ShapeType,Reader 用 ImageType ✗ |
| QoS 兼容性 | 发布端和订阅端 QoS 必须能兼容 | Writer RELIABLE,Reader BEST_EFFORT ✗ |
QoS 兼容性矩阵(最常用的 Reliability):
Writer ↓ / Reader → RELIABLE BEST_EFFORT ────────────────────────────────────────────── RELIABLE ✓ 兼容 ✗ 不兼容 BEST_EFFORT ✓ 兼容 ✓ 兼容简单记忆:更严格的 Writer 要求 Reader 也严格;更宽松的 Writer 兼容一切。
调试技巧:
- 在
fastddsspy.exe中用topics查看当前所有 Topic——你能看到 SEDP 交换后的结果endpoints可以看到每个 Writer/Reader 的 QoS 设置- 如果两个进程匹配不上,先检查 Topic 名称是否完全一致(包括大小写),再用 Spy 确认两端都能发现对方的存在
3.3 发现协议总结
时间线: Participant 启动 │ ▼ SPDP:发多播 DATA(p) 到 239.255.0.x:7400+ "我来了!这是我的 GUID 和地址" │ ▼ 收到其他 Participant 的 DATA(p) 回复 互相知道"有人在" │ ▼ SEDP:单播交换 DATA(w) 和 DATA(r) "我发布 XXX" / "我订阅 XXX" │ ▼ DDS 内核自动匹配: Topic 名称 ✓ 类型 ✓ QoS ✓ → 建通道 Topic 名称 ✓ 类型 ✓ QoS ✗ → 不建通道 名称或类型不匹配 → 不建通道 │ ▼ 匹配成功 → 开始传输数据(RTPS)四、RTPS 协议:数据如何高效可靠传输
发现和匹配完成只是第一步。DDS 真正的核心在于 RTPS(Real-Time Publish-Subscribe Protocol)——定义了数据如何在 UDP 这种不可靠的传输层上实现可靠、实时、高效的传输。
4.1 History Cache:DDS 的"本地 Git 仓库"
每个 DataWriter 和 DataReader 在内存中维护着一个历史数据缓冲区,叫 History Cache。
┌───────── DataWriter ─────────┐ │ History Cache │ │ ┌───┬───┬───┬───┬───┬───┐ │ │ │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ │ ← 最近发送的样本 │ └───┴───┴───┴───┴───┴───┘ │ │ Next SN: 7 │ └──────────────────────────────┘ ┌───────── DataReader ─────────┐ │ History Cache │ │ ┌───┬───┬───┬───┬───┬───┐ │ │ │ 1 │ 2 │ │ 4 │ 5 │ 6 │ │ ← SN=3 丢了! │ └───┴───┴───┴───┴───┴───┘ │ │ Missing: [3] │ └──────────────────────────────┘每个样本(Sample)都带一个递增的Sequence Number (SN),类似于 TCP 的序列号:
- SN=1→ 第一个样本
- SN=2→ 第二个样本
- SN=k→ 第 k 个样本
Cache 的深度由History QoS控制:
KEEP_LAST N→ 只缓存最近 N 个样本KEEP_ALL→ 缓存所有样本(直到内存耗尽)
类比 Git:Writer Cache ≈ 你的本地提交历史,Reader Cache ≈ 别人拉取后的本地仓库。丢了的 commit 就是 missing SN,需要 git fetch(NACK)来补上。
4.2 Heartbeat & ACKNACK:UDP 上的可靠传输
UDP 本身是不可靠的——数据包可能丢失、乱序、重复。RTPS 在 UDP 之上实现了一整套可靠机制。
正常流程(无丢包)
Writer Reader │ │ │── DATA(SN=1, temp=25.5) ──────────────→│ ✓ 收到,存入 Cache │── DATA(SN=2, temp=25.6) ──────────────→│ ✓ 收到 │── DATA(SN=3, temp=25.4) ──────────────→│ ✓ 收到 │ │ │── HEARTBEAT(SN=3) ────────────────────→│ 确认 "最新 SN=3" │←─ ACK(SN=1~3全部收到) ────────────────│ 回复 "全收到了"丢包处理(重传流程)
Writer Reader │ │ │── DATA(SN=1) ─────────────────────────→│ ✓ │── DATA(SN=2) ─────────────────────────→│ ✗ 网络丢包! │── DATA(SN=3) ─────────────────────────→│ ✓ │ │ │── HEARTBEAT(SN=3) ───────────────────→│ "我最新到 SN=3" │←─ NACK(SN=2) ─────────────────────────│ "SN=2 我没收到,请重发" │ │ │── DATA(SN=2, 重传) ──────────────────→│ ✓ 终于收到了! │ │ │←─ ACK(SN=1~3全部收到) ───────────────│ "齐了"与 TCP 的对比
| 特性 | TCP | RTPS (DDS) |
|---|---|---|
| 连接 | 需要建立连接 | 无连接,基于 UDP |
| 重传触发 | 超时 + 重复 ACK | Heartbeat + NACK(主动查询) |
| 选择性重传 | 不支持(只能重传第一个丢失的) | 支持(可以指定重传任意 SN) |
| 多对多 | 只支持 1 对 1 | 原生支持 1 对 N |
| 发送方状态 | 每个连接一个状态 | 每个匹配的 Reader 一个状态 |
核心洞察:TCP 是为"两台机器间的一个连接"设计的。RTPS 是为"一台机器上的一个 Writer 与 N 台机器上的 M 个 Reader 通信"设计的。这就是为什么 RTPS 选择了 NACK 式重传——Writer 为每个 Reader 维护一个"位图"(哪些 SN 收到了,哪些没收到),然后按需补发。
4.3 完整的 RTPS 子消息体系
RTPS 定义了多种子消息(Submessage),每种有特定用途:
| 子消息 | 方向 | 用途 |
|---|---|---|
| DATA | Writer → Reader | 传输用户数据样本 |
| HEARTBEAT | Writer → Reader | 告知"我最新到哪个 SN" |
| ACKNACK | Reader → Writer | 确认已收到 / 请求重传丢失的 SN |
| GAP | Writer → Reader | 告知"SN=5 到 SN=8 被我跳过了,不用等了" |
| PAD | 任意 | 填充字节(对齐用途) |
| DATA_FRAG | Writer → Reader | 大数据分片传输 |
| HEARTBEAT_FRAG | Writer → Reader | 分片传输的心跳 |
GAP 的妙用:如果 Writer 的 History QoS 设为
KEEP_LAST 3,那么当 SN=5 被发送后,SN=2 已经从缓存中被移除了。此时如果有新 Reader 加入请求重传 SN=2,Writer 会发一个 GAP 消息说"SN=2 已经没有了,请跳过它"。这样 Reader 不会死等永远收不到的包。
4.4 传输优化:不只是 UDP
共享内存(SHM)——同机通信的"高速公路"
当两个 Participant 在同一台机器上时,Fast DDS 默认启用共享内存传输:
进程 A 进程 B ┌──────────────┐ ┌──────────────┐ │ Writer │ │ Reader │ │ │ │ │ ↑ │ │ ▼ │ │ │ │ │ 序列化 → 写入 │ │ 读取 → 反序列化│ │ 共享内存区域 │ │ 共享内存区域 │ └──────┬───────┘ └──────┬───────┘ │ │ └─────────同一块物理内存────┘ 绕过网络栈,微秒级延迟SHM 的优势:
- 延迟:微秒级 vs UDP 的毫秒级(1000 倍差距)
- 吞吐量:受内存带宽限制,远高于网卡带宽
- 零拷贝:数据写入共享内存后,Reader 直接读取,无需拷贝
调试技巧:如果运行 DDS 程序时遇到 SHM 错误(“Failed to create segment”),通常是因为进程没有权限写入共享内存文件。解决方案:
- 代码中指定仅用 UDP:
pqos.setup_transports(BuiltinTransports::UDPv4)- 或以管理员身份授权:
icacls C:\ProgramData\eprosima /grant Users:(OI)(CI)F /T
数据分片与重组
对于超过网络 MTU(通常 1500 字节)的大数据:
原始数据 10KB │ ▼ 分片: ┌────────┬────────┬────────┬────────┬────────┐ │Frag 1 │Frag 2 │Frag 3 │Frag 4 │Frag 5 │ │SN=10.1 │SN=10.2 │SN=10.3 │SN=10.4 │SN=10.5 │ └────────┴────────┴────────┴────────┴────────┘ │ │ │ │ │ ▼ ▼ ▼ ▼ ▼ Reader 收到全部 5 个分片后自动重组为原始数据每个分片都有逻辑端口号 + 片段编号,允许 Reader 即使乱序收到也能正确重组。
多通道智能选择
Fast DDS 可以同时启用多种传输通道,并智能选择最优路径:
Writer → 同时可用: [SHM] [UDPv4] [TCPv4] │ ├─ 同机 Reader → SHM (微秒级) ├─ 局域网 Reader → UDPv4 (毫秒级) └─ 跨网段 Reader → TCPv4 (可靠穿越防火墙)4.5 延迟与吞吐量的权衡
DDS 通过配置可以在"低延迟"和"高吞吐量"之间权衡:
| 场景 | 推荐配置 | 预期表现 |
|---|---|---|
| 控制指令 | RELIABLE, KEEP_LAST 1, SHM | 微秒级延迟,单条发送 |
| 传感器数据 | BEST_EFFORT, KEEP_LAST 1 | 毫秒级延迟,按帧发送 |
| 大文件传输 | RELIABLE, KEEP_ALL, TCP | 高吞吐量,确认发送 |
| 点云数据 | BEST_EFFORT + 分片 | 兼顾实时性和吞吐量 |
调试技巧:Fast DDS Monitor 可以实时显示每个 Topic 的吞吐量曲线(bytes/sec、samples/sec),帮助你观察 QoS 配置的实际效果。
五、QoS:数据的交通规则
如果说 Topic 是数据的"地址",那么 QoS(Quality of Service)就是数据的**“交通规则”**。DDS 提供了 20+ 种 QoS 策略,以下是最核心的 5 种。
5.1 Reliability:丢包了怎么办?
| 选项 | 快递类比 | 行为 |
|---|---|---|
RELIABLE | 挂号信(签名确认) | 丢包必重传,直到 Reader 确认为止 |
BEST_EFFORT | 普通平信(丢了就算了) | 丢了不重传,永远只看最新数据 |
怎么选?
- 控制指令 →
RELIABLE(丢失了车就失控了) - 视频流 →
BEST_EFFORT(丢一帧没关系,重传的帧到了也已经过时了)
调试技巧:如果跨进程通信收不到数据,检查两端的 Reliability 设置是否兼容。用 Spy 的
endpoints命令可以查看每个端点的 QoS。
5.2 Durability:晚来的人能看到历史吗?
| 选项 | 行为 | 快递类比 |
|---|---|---|
VOLATILE | 晚加入的订阅者看不到之前的数据 | 不留底稿 |
TRANSIENT_LOCAL | 晚加入可以看到 Writer Cache 中仍然保留的历史数据 | 保留最近几份底稿 |
TRANSIENT | 数据持久化到内存中,即使原 Writer 退出也保留 | 公司档案室 |
PERSISTENT | 数据持久化到磁盘,重启不丢失 | 国家档案馆 |
场景举例:
- 自动驾驶的 GPS 数据 →
TRANSIENT_LOCAL(新加入的节点需要知道当前位置) - 实时传感器流 →
VOLATILE(只看当前值,历史不重要)
5.3 Deadline:数据多久更新一次?
Deadline 定义的是最大更新间隔。
设定 Deadline = 100ms Writer 每 50ms 更新一次 → ✓ 正常 Writer 每 150ms 更新一次 → ✗ 违反 Deadline(触发回调通知应用) Writer 停止更新 → ✗ 连续违反 Deadline典型应用:
- 心跳检测:设定 Deadline = 1 秒,超过 1 秒没收到数据就报警(节点可能挂了)
- 周期性传感器:Lidar 10Hz = 100ms Deadline,超过说明设备异常
5.4 History:缓存深度控制
| 选项 | 行为 |
|---|---|
KEEP_LAST N | 只缓存最近 N 个样本(N 默认 1) |
KEEP_ALL | 缓存所有样本(需要配合 Resource Limits 防止内存溢出) |
场景举例:
KEEP_LAST 1:传感器数据永远只看最新KEEP_LAST 5:控制指令保留最近 5 条,供新加入节点恢复状态KEEP_ALL:数据记录器,每条都要保留
5.5 Ownership:多人发言听谁的?
当多个 Writer 向同一个 Topic 写数据时,Ownership 决定订阅者听谁的:
| 选项 | 行为 |
|---|---|
SHARED | 所有 Writer 的数据都可见,订阅者都会收到 |
EXCLUSIVE | 只有Strength 最高的 Writer 的数据被订阅者接收 |
典型应用:主备切换
正常时: Writer A (Strength=10) → 主控,订阅者收 A 的数据 Writer B (Strength=5) → 备控,数据被忽略 A 崩溃后: Writer A (死了) → 不再发送 Writer B (Strength=5) → 自动接管,订阅者开始收 B 的数据5.6 QoS 策略组合实战
一个真正的自动驾驶系统,不同数据流的 QoS 配置:
Topic Reliability Durability History Deadline ───────────────────────────────────────────────────────────────────────────── /control/cmd_vel RELIABLE VOLATILE KEEP_LAST 1 50ms /scan (Lidar) BEST_EFFORT VOLATILE KEEP_LAST 1 100ms /gps/fix RELIABLE TRANSIENT_LOCAL KEEP_LAST 5 200ms /map RELIABLE TRANSIENT_LOCAL KEEP_ALL 1s /camera/image BEST_EFFORT VOLATILE KEEP_LAST 1 33ms (30fps) /vehicle/status RELIABLE TRANSIENT_LOCAL KEEP_LAST 10 1s调试技巧:如果你的 DDS 应用行为不符合预期(如收不到数据、延迟过高),先用 Fast DDS Spy 确认端点和 Topic 信息,再用 Fast DDS Monitor 查看实时吞吐量曲线,这比猜问题快 10 倍。
六、一次完整的通信旅程
让我们把前面所有的原理串联起来,看一条数据从写入到接收的完整旅程:
时间点 步骤 发生什么 ──────────────────────────────────────────────────── T+0ms ① Publisher 调用 writer->write("Hello") ↓ T+0.1ms ② DDS 序列化:C++ 对象 → CDR 二进制格式 ↓ T+0.2ms ③ RTPS 封包:加头部、分配 SN=42、打时间戳 存入 Writer History Cache (SN=42) ↓ T+0.3ms ④ 传输层调度: 同机有 Reader → SHM 写入共享内存 远程有 Reader → UDP 多播发送 ↓ T+1ms ⑤ Reader 收到数据: SHM: 直接读取共享内存 UDP: 网卡中断 → 内核缓冲区 → 应用缓冲区 ↓ T+1.1ms ⑥ 存入 Reader History Cache (SN=42) ↓ T+1.2ms ⑦ 触发 on_data_available() 回调 ↓ T+1.3ms ⑧ 用户代码读取数据:take_next_sample() ← 得到 "Hello" ↓ T+5ms ⑨ Writer 发送 HEARTBEAT(SN=42) 确认 Reader 回复 ACK(SN=42) ↓ T+100ms ⑩ 下一个周期,重复①~⑨如果第 4 步的 UDP 包在网络中丢失了:
T+0.3ms ④ DATA(SN=42) 在路由器被丢弃 T+5ms ⑨ HEARTBEAT(SN=42) 到达 Reader Reader 检查 Cache:有 SN=41,没有 SN=42 → 回复 NACK(SN=42) "请重传 SN=42" T+10ms ⑨' Writer 收到 NACK 从 History Cache 取出 SN=42 的副本 重新发送 DATA(SN=42) T+11ms ⑩ Reader 收到重传的 SN=42 存入 Cache,触发回调这就是 DDS 即使运行在 UDP 上也能保证可靠传输的原因。
七、监控调试工具:看到原理在运行
理解原理是一回事,亲眼看到原理在运行是另一回事。以下工具可以让你观察到前面讲的所有机制:
7.1 Fast DDS Spy——命令行网络侦探
Spy 是一个静默的 DDS Participant,它加入你的 Domain 但不参与数据通信,只监听:
你的应用 A ←→ 你的应用 B ↑ ↑ └── Spy ───────┘ (只监听,不发言)用 Spy 观察原理:
Spy 命令 观察到的 DDS 原理 ───────────────────────────────────────────── participants SPDP 发现的每个 Participant(GUID、名称) topics SEDP 交换后列出的所有 Topic endpoints SEDP 交换后列出的所有 Writer/Reader echo <topic> RTPS 传输的每个样本(序列号、内容)实战示例:
# 终端 1:启动你的 DDS 应用run_your_app.exe# 终端 2:启动 Spyfastddsspy.exe# Spy 交互:>>participants → 看到你的应用的 Participant 信息(通过 SPDP 发现)>>topics → 看到你应用发布的 Topic 名和类型(通过 SEDP 交换)>>echoMyTopic → 实时看到每条数据(通过 RTPS 传输)[echo]RECEIVED: MyType{index:1,...}[echo]RECEIVED: MyType{index:2,...}7.2 Fast DDS Monitor——图形化拓扑视图
Monitor 是一个图形化工具,可以实时显示 DDS 网络的拓扑图和数据流。
| 功能 | 看到什么 |
|---|---|
| 拓扑图 | 所有 Participant 的图标 + 连线(SPDP 结果可视化) |
| Topic 列表 | 所有 Topic 及其类型、Writer/Reader 数(SEDP 结果) |
| 吞吐量曲线 | 每个 Topic 的 bytes/sec 和 samples/sec |
| 延迟统计 | 端到端的通信延迟 |
7.3 日志分析
Fast DDS 提供详细的运行时日志,可以帮助你理解内部状态:
# 开启调试日志运行你的程序your_app.exe --log-verbosity info# 你会看到类似这样的日志:[SPDP]Sending DATA(p)to239.255.0.0:7400[SEDP]Received DATA(w):Topic=Square,Type=ShapeType[RTPS]Received DATA(SN=42)from Writer 01.0f.xxxx[RTPS]Sending NACK(SN=42)formissing sample[RTPS]Received HEARTBEAT(SN=50), cache has up toSN=50八、总结
DDS 并不神秘,它是一套设计极其精巧的分布式实时数据总线,通过层层递进的协议栈解决了分布式通信的核心难题:
问题层 DDS 的答案 ──────────────────────────────────────────────── "数据怎么传?" → 以数据为中心,构建全局数据空间(GDS) "如何找到人?" → SPDP:多播发送 DATA(p) 报文 "怎样谈合作?" → SEDP:单播交换 DATA(w) 和 DATA(r) "可靠怎么办?" → RTPS:History Cache + Heartbeat/ACKNACK "太慢了怎么办?" → SHM 共享内存 + 数据分片 + 多通道 "规则怎么定?" → 20+ 种 QoS 策略灵活配置 "如何调试?" → Fast DDS Spy(CLI)+ Fast DDS Monitor(GUI)+ 日志 通信全流程: ① 多播发现 (SPDP) → "找人" ② 端点匹配 (SEDP) → "谈判" ③ 序列化 (CDR) → "打包" ④ 发送 (RTPS/UDP/SHM) → "发货" ⑤ 确认 (ACK/NACK) → "签收" ⑥ 重传 (NACK→Data) → "补发" ⑦ 反序列化 → "拆包" ⑧ 回调通知 → "送达"正是这些机制的叠加,使得 DDS 成为了 ROS2 的底层基石,自动驾驶和工业 4.0 时代的"神经系统"。
理解 DDS,不仅是学会使用一个中间件,更是理解一种构建高实时、高可靠分布式系统的架构思维。
延伸阅读
- DDS 规范 (OMG)
- RTPS 规范 (OMG)
- Fast DDS 文档
- Fast DDS Spy 文档
- Fast DDS Monitor 文档