Don’t Hold My Data Hostage – A Case For Client Protocol Redesign 论文阅读 & Apache IoTDB TsBlock 对比

本文分析了数据库客户端协议对查询性能的影响,特别是结果集序列化(RSS)的效率。实验表明RSS在序列化/反序列化和网络传输中起关键作用。论文提出了基于列的序列化方法,并在PostgreSQL和MonetDB中实现,性能提升一个数量级。研究还探讨了不同压缩算法、chunk大小和网络条件对传输耗时的影响,以及如何优化字符串处理以减少开销。此外,文章对比了ApacheIoTDB的TsBlock结构,讨论了其与论文中方法的异同及可能的优化方向。

Don’t Hold My Data Hostage – A Case For Client Protocol Redesign 是 VLDB 2017 的一篇论文,主要着眼于数据库客户端协议的设计。本文主要是个人对论文的一些理解,以及结合自己较熟悉的开源时序数据库 Apache IoTDB 进行了一些对比分析。如果有谬误之处,欢迎留言指正~

论文阅读

Introduction

将大量级数据从数据库传输到客户端程序的需求非常常见,比如统计分析或者机器学习应用需要大量样本数据来构建或者验证模型。但是 export 本身会比较耗时,当需要通过网络传输数据库数据时会更加耗时(数据库服务端和客户端程序不在同一服务器上)。

本论文首先在多个数据库系统上测试了通过 ODBC Connector 查询 lineitem table (SF10) 全量数据时的耗时,Baseline 是用 netcat 直接发送 csv 文件的耗时。实验时客户端程序和数据库服务端在统一服务器上,不涉及网络 I/O,因此下图结果主要衡量的是序列化/反序列化过程的 CPU 消耗。本文后续有专门章节探讨网络传输的影响。

论文实验结果表明,Result set serialization (RSS) 的性能可能对数据库查询接口性能有较大的影响。(下图中绝大部分耗时都在 RSS 上,连接数据库和查询实际执行耗时甚至可以忽略不计)

img

本论文主要工作如下:

  • 对主要数据库系统使用的 Result set serialization(RSS)进行了基准测试,并衡量它们在不同网络环境中传输大量数据时的表现。分析不同数据库系统如何进行 RSS,并且分析了这些 RSS 方法在传输大量数据时的缺陷。
  • 分析了设计 RSS 方法时需要考虑的因素,并且调研了一些可以用于设计高效 RSS 方法的技术,且对这些技术进行了基准测试并分析了其优缺点。
  • 提出了一种适用于导出大型结果集的基于列的序列化方法。在开源数据库系统 PostgreSQL 和 MonetDB 中实现了该方法,并且证明了其性能比 SOTA 高一个数量级。

State of the Art

Overview

每个支持远程客户端的数据库系统都实现了一个客户端协议(最常见的是基于 ODBC 和 JDBC API 实现)。通过这个协议,客户端可以向数据库服务器发送查询请求,然后从数据库获取查询结果。下图描述了服务器和客户端之间的典型通信场景。通信始于身份验证,然后客户端和服务器交换元信息(例如协议版本、数据库名称)。在这个初始握手之后,客户端可以向服务器发送查询。在计算查询结果之后:

  1. 服务器将数据序列化为结果集格式。
  2. 转换后的消息通过 Socket 发送给客户端。
  3. 客户端对结果集进行反序列化,实际使用数据。
img

结果集格式的设计决定了上述三个步骤花费的时间。**如果结果集格式较复杂,或者使用了较复杂的压缩算法,结果集的序列化和反序列化计算成本(包含了压缩/解压缩)会较高,但一般来说可以通过传输更少的数据节省网络开销。另一方面,使用更简单的客户端协议可能会需要发送更多字节的数据,但可以节省序列化/反序列化的计算成本。**因此,需要在发送的字节数和序列化/反序列化的开销之间进行 trade off。

Network Impact

网络情况会显著影响不同客户端协议的性能。较低的带宽意味着网络传输数据的代价更高,那么需要传输的数据量更少的协议可能效果就更好。

论文为了测试不同网络状况对不同协议的影响,在限制延迟/限制带宽的情况下记录了获取100万行 lineitem 表数据的耗时。文中使用 Linux 工具 netem 模拟不同的网络情况(netem 可以模拟带宽和延迟方面的网络限制)。

Latency

下图反映了随着 Latency 变化时,不同系统获取100万行 lineitem 表数据耗时的变化。文中并没有给出此时 Throughput 是多少。

img

延迟越高,每次网络传输的固定耗时(无论实际传输数据量多少)就会更高。尽管可以预计较高的延迟会影响建立连接所需的时间,但大型结果集的传输不应受到延迟的影响,因为服务器可以在无需等待任何确认的情况下发送整个结果集。在记录耗时时,论文也排除了建立连接阶段的耗时,因此理论上较高的延迟应该不会对结果集传输所需的时间产生显著影响。

但是实际的实验结果与预测相反,所有系统的性能受到高延迟的严重影响。这是因为虽然服务器和客户端不会直接向对方发送确认消息,但底层的 TCP/IP **层在接收数据时会发送确认消息。**TCP 数据包会在底层缓冲区填满时发送,触发一个 ACK 消息。因此,发送更多数据的协议会触发更多的 ACK,并且在高延迟下受到更大的影响。

Throughput

下图反映了随着 Throughput 变化时,不同系统获取100万行 lineitem 表数据耗时的变化。文中并没有给出此时 Latency 是多少。

img

吞吐量反映了在给定时间内通过网络传输数据的能力或速率,较高的 Throughput 意味着能够更快地传输更多的数据。

在上图中我们可以看到 Throughput 对不同协议的影响。当 Throughput 下降时,发送大量数据的协议开始表现得比发送较少数据的协议更差。尽管 PostgreSQL 协议在高吞吐量下表现良好,但在较低吞吐量下,它的性能明显不如其他协议。当吞吐量降低时,压缩的效果就更明显,因为此时实际数据传输的耗时成为主要瓶颈,而数据(解)压缩的成本就没那么明显了。

Result Set Serialization

本节主要讨论不同数据库系统客户端协议使用的结果集格式。本节列出了 PostgreSQL 和 DBMSX 的客户端协议(论文中还有别的几种客户端协议,本文没有全部列出来)采用的结果集格式下,Table 2中数据的十六进制表示。实际数据使用的字节用绿色标记,其他额外开销则用白色标记,前导零用灰色标记。

img
PostgreSQL
img

PostgreSQL 的结果集中:

  • 每一行都作为单独的 prococal message 传输。每一行都包括 Message Type,Total Length,Field Count。
  • 从上图中可以看出 PostgreSQL 的结果集格式中,所有元数据都是定长的(比如 Total Length 固定是4字节),用于表示元数据的字节数可能比实际数据的字节数还多。
  • **有冗余信息。**比如理论上来说每一行的 Message Type 和 Field Count 应该是一样的,每一行都传输一遍造成了不必要的开销。

整体来说该协议非常简单,因此结果集序列化和反序列化时的计算成本很低,如果网络不是瓶颈的话,该协议的性能较好。

DBMS X
img

DBMS X 的协议非常简洁,结果集看上去也很紧凑。不过实际上这个格式比 PostgreSQL **的协议的计算开销大得多。**DBMS X 的结果集:

  • 每行都有一个 packet header(具体的作用不太清楚),后面是值。
  • 每列的值前面都有该值的长度作为前缀。并且这个长度是以可变长度整数的形式传输的。因此,对于较小的长度,长度字段只有一个字节。对于NULL 值,长度字段为0,不传输任何实际值。
  • DBMS X 对整数值采用了其定制的编码方式。

整体来说,可以认为该格式集在进行序列化/反序列化时的计算开销比较大,但是要传输的数据量会比较少。

Protocol Design Space

设计序列化格式时主要需要权衡序列化/反序列化计算成本和数据传输成本:

  • 如果计算成本不是瓶颈,复杂的结果集格式或者诸如 XZ 等重量级压缩方法能够大幅降低传输成本。
  • 如果数据传输成本不是瓶颈(例如在与数据库服务器相同的机器上运行客户端),那么减少计算的成本可能效果更好。

为了探讨不同设计选择对序列化格式性能的影响,论文对这些选择逐个进行了基准测试,主要记录了结果集(反)序列化和进行数据传输的耗时以及传输数据量的大小。论文在三个数据集上进行这些基准测试:

  • lineitem from the TPC-H benchmark:此表与真实世界的数据仓库事实表相似,包含16列,类型为 INTEGER、DECIMAL、DATE 和 VARCHAR。该数据集不包含缺失值。论文使用 SF10 的 lineitem 表,该表有6000万行,CSV 格式下大小为7.2GB。
  • **American Community Survey (ACS) :**该数据集包含数百万条人口普查数据,由274列组成,其中大部分为 INTEGER 类型。数据集16.1%的字段包含缺失值,共910万行,CSV 格式下大小为7.0GB。
  • **Airline On-Time Statistics:**该数据集描述了商业航空交通的准点情况。有109列,最多的类型是 DECIMAL 和 VARCHAR,55.2%的字段包含缺失值。该数据集有1000万行,CSV 格式下大小为3.6GB。

Row/Column-wise

大量数据库系统都采用按行序列化结果集的方法(不管其内部是行存还是列存),这可能主要是因为流行的 ODBC 和 JDBC 都主要提供按行访问的 API。

列式结果集理论上可以有更好的压缩率,并且对于本来就需要按列访问数据的应用程序来说,可以避免行->列的转换开销。使用纯列格式结果集的问题在于在发送下一列之前需要先传输当前的整个列,如果客户端想以逐行方式访问数据,则首先必须读取并缓存整个结果集。对于大型结果集,这种方式可能不可行。

论文中采用的结构是一种行列混合的向量结构,即一个 chunk 包含若干行的数据,而 chunk 内部按列编码。这样如果用户要按行访问数据,只需要缓存一个 chunk 的数据即可,并且内部按列编码的 chunk 也可以利用到列存的压缩和性能优化。

Chunk Size

chunk size 的选择对性能的影响较大:

  • 较大的 chunk size 意味着用户需要分配更大的 buffer 内存
  • 太小的 chunk size 可能导致一个 chunk 内行数太少,按列编码的优势就没了。

为了选择较优的 chunk size,论文在三个数据集上均做了实验。论文将 chunk 用 Snappy 压缩,然后记录了随 chunk size 变化,传输完整个数据集的耗时变化,以及实际传输数据量的变化(计算压缩率)。下图是实验结果:

img

chunk size 范围:2KB(保证 chunk 至少有一行数据)—100MB

结果分析:

  • chunk size <= 10KB 时,三个数据集上效果都较差,这可能是因为此时一个 chunk 行数太少,退化成了行式结构
  • 三个结果集在 chunk size 为10MB 时都有较好的表现,说明使用向量结构时,用户并不需要太大的 buffer 内存就可以获得较优的性能。
  • chunk size 继续增大(100 MB)时,性能出现了下降,论文中没有给出原因。

Data Compression

前提:网络传输成本和压缩/解压缩的计算成本需要进行 trade off,一般来说在网络受限的情况下才需要考虑压缩数据。

不同压缩算法的侧重点不同,一般来说压缩率越高,压缩/解压缩的计算成本越高。轻量级的压缩工具 Snappy 和 LZ4 注重快速压缩,但牺牲了压缩比。XZ 压缩速度很慢,但实现了非常紧凑的压缩。GZIP 在两者之间取得了平衡,实现了较好的压缩比,同时速度不会太慢。

压缩算法压缩比压缩速度
Snappy
LZ4
GZIP较高较快
XZ

在网络状况较好,传输数据成本较低的情况下,就没有必要过于关注压缩率,轻量级的压缩方法可能效果更好;相应的,网络状况较差,传输数据成本较高的情况下,压缩率更好的压缩方法可以传输更少的数据,也许效果更好。

论文做了一个实验,验证不同压缩算法在不同 throughput 限制下的表现。

img

上表是通过网络传输 SF10 lineitem,不同压缩算法在不同网络带宽下的耗时(论文中没有明确给出此时用的是什么系统,什么客户端协议,猜测应该是论文自己实现的协议)。

结果分析:

  • 客户端和数据库服务端在同一节点时,不压缩的耗时最少(T local)
  • 千兆网下,轻量级的压缩算法 Snappy 和 LZ4 效果会比不压缩好,但重量级压缩算法表现仍较差。
  • 网速极慢(10M)时,GZIP 的效果才会好于轻量级压缩算法,但是重量级压缩算法仍然效果不好,因为其花费了太多时间在压缩/解压缩上。

压缩算法对传输耗时的影响与网络情况强相关,而让用户根据网络来手动配置会比较麻烦,论文中在其自己的实现中采取方法为:

  • 如果客户端和数据库服务端在同一节点,则不适用压缩算法
  • 如果客户端和数据库服务端在同一节点,使用轻量级压缩算法,因为用户局域网网络情况一般不会太差。

Column-Specific Compression

除了通用的压缩方法之外(压缩整条 message),还可以对单独的列进行压缩。例如,可以对数值列使用游程编码或差分编码。数据库中的一些统计信息也对列压缩有帮助。例如,通过最小/最大值索引可以为特定的列选择位压缩长度,而无需扫描该列。

用这些特定的列压缩算法对列进行压缩,可能可以用更短的压缩时间同时获得更高的压缩率,尤其是整数列,用一些向量化的压缩算法(binpacking 和 PFOR)可以有很好的压缩效果。

论文中测试了针对整数列的压缩算法的效果。只查询三个数据集中的整数列,然后测试不同压缩算法下的传输耗时和压缩效果,下表是实验结果:

img

三个数据集的结果分析:

  • Lineitem:PFOR 和 binpacking 算法都比 Snappy 在更低的计算成本下实现了更高的压缩比,且在所有网络情况下都比 Snappy 表现更好。将 PFOR 和 binpacking 与 Snappy 结合(先按列压缩,然后压缩整条 message),可以实现更高的压缩比。然而,客户端和服务端在同一主机时,仍然是不压缩表现最好。
  • ACS:ACS 数据集包含了大量的整数列。在传输该数据集时,Snappy 效果明显更好。这是因为该数据集整数列数过多,导致在每个传输的 chunk 的行数较少,导致针对每一列进行的压缩算法在小数据块上被频繁调用,从而导致性能下降。Snappy 压缩方法不会针对单个列进行操作,而是压缩整个消息,因此不会受到这个影响,即使在压缩大量整数列时也可以表现良好。
  • Ontime:PFOR 和 binpacking 在这个数据集上表现都很差,但是 Spappy 的效果较好。

总体来看,按列进行压缩在某些数据集上有不错的表现,但是在全体数据集上可能表现不佳,因此论文的实现方法决定不使用按列压缩。

Data Serialization

实验论证了论文自定义的序列化方法比用 ProtoBuf 好,因为 ProtoBuf 的压缩效果较自定义方法差,但是没有具体分析。

String Handling

常见的字符串编码方法有:

  • Null-Termination:用一个 zero byte 标示字符串结尾
    • 优点:只需要为每个字符串额外多记录一个 byte,但该字节始终是相同的值,易于压缩,开销较小。
    • 缺点:必须扫描当前整个字符串才能读取下一个字符串。
  • Length-Prefixing:在字符串头部先记录其长度
    • 优点:可以提前读出字符串长度,然后跳过不需要读的字符串
    • 缺点:需要额外位数来记录长度,可能会让消息长度变大(尤其是有大量短字符串的时候)。如果用 varint 来记录字符串长度,又会增加计算开销。
  • Fixed-Width:固定长度(预留最大长度)
    • 优点:如果每个字符串具有相同的大小,则不会有不必要的填充开销。
    • 缺点:如果字符串长度不一,在存在少量长字符串和大量短字符串(或 NULL 字符串)时,可能会引入大量不必要的填充开销。

论文为了探究三种字符串编码方法的性能,记录了使用三种字符串编码方式下,传输 lineitem 表部分字符串列的耗时:

  • 表8是传输 l_returnflag(1个字符) 列的耗时结果。可以看到,Fixed-Width 在传输短字符串列时表现非常好。Null-Termination 和 Lengh-Prefixing 由于每个字符串都多传输了一个字节,所以传输的字节数增加了一倍。
  • 表9是传输 l_comment(不定长,最长44个字符) 列的耗时结果。可以看到,传输该列时,所有方法的性能都相当。但是,Fixed-Width 传输的字节数明显更高。这是因为许多字符串不完全是44个字符长,因此必须进行填充。
  • 论文还做了后续实验,发现 Fixed-Width 传输的字节数随字符串最大长度的上升而明显急剧上升,而其较好的压缩效果并不能弥补多传输数据的损失。

imgimg

实验结论:

  • Null-Termination 有更好的压缩率,大部分情况下的最优选项。
  • 字符串长度都为1时(VARCHAR(1)) ,Fixed-Width 效果最佳。

Implementation and Results

MonetDB Implementation

img

上图是用论文提出的 MonetDB 客户端协议表示表2数据的结果。论文提出的协议将 chunk 按列序列化。

  • 每个 chunk 的开头是该 chunk 包含数据的行数,然后按照结果集 header 中列的顺序依次序列化每一列。(理论上结果集 header 肯定也记录了每一列的数据类型)
  • MonetDB 内部没有使用 Null Mask,直接用特殊值来表示 Null Value,因此论文针对 MonetDB 的协议也没有记录 Null Mask,直接沿用特殊值的做法。
  • 定长数据类型的列之前可以不存列长度(就是 rowCount * 数据类型长度),变长数据类型的列之前记录了该列的长度,这样客户端就可以通过这个长度跳过不想读取的列。

在客户端进行 authentication 的时候可以设置 chunk 的最大大小(bytes),这样 chunk 的大小和其包含的数据行数就无关了,可以更灵活地决定 buffer 的大小。大部分情况下都可以算出一行数据大致有多少字节,这样就可以计算出一次发送的 chunk 中包含多少行数据。MonetDB 是列存格式,因此每个列的数据都可以直接按顺序复制到缓冲区中。如果为特定列启用了列特定的压缩,则数据将直接压缩到缓冲区中。如果启用了 chunk 级别的压缩,则在传输之前将对整个 chunk 进行压缩。

PostgreSQL Implementation

img

上图是用论文提出的 PostgreSQL 客户端协议表示表2数据的结果。论文提出的协议同样将 chunk 按列序列化。

  • 每个 chunk 的开头是该 chunk 包含数据的行数,然后按照结果集 header 中列的顺序依次序列化每一列。
  • 每一列数据的开头是一个 Null Mask(Null Mask 的长度应该与 row count 有关),Null Mask 记录了对应行的数据是否为 null。猜测 PostgreSQL 的协议采用 Null Mask 应该是因为 PostgreSQL 内部不像 MonetDB 一样使用特殊值表示 Null。
  • 由于启用了 Null Mask,所以哪怕定长数据类型的列的长度现在也是不确定的了(不知道有多少数据是 null)。因此每一列前都记录了该列的长度,客户端可以通过这个长度跳过不想读取的列。
  • 由于按列存 Null Mask,对于没有任何缺失值的列,可以不用存 Null Mask。比如标记了 NOT NULL 标志的列或数据库统计信息表明列不包含缺失值时,就可以不用存 Null Mask。哪些列没有缺失值可以在结果集 header 里面记录,这样可以减少记录 Null Mask 的开销。

由于 PostgreSQL 是行存格式,将其转换为列式结果集格式有一些潜在的问题:

  • 由于有 Null Mask,即使某列数据是固定长度类型,也无法提前知道该列的长度。为避免在存在许多缺失值时浪费大量空间,论文的做法是首先在迭代行时将每个列的数据复制到临时缓冲区中。等缓冲区填满再将每个列的数据复制到流缓冲区中然后传输给客户端。
  • 将行式数据转换成列式格式的时候可能会随机访问(读第一行的第一列,第二行的第一列…如果 cache 不够大的话,可能还会导致 cache 的抖动)。

Evaluation

为了验证文中提出的协议的效果,论文在三个结果集上进行了实验,记录了客户端从服务端拉取完整结果集数据需要的时间(实验设置超时时间是 1h):

  • 实验环境:Linux VM running Ubuntu 16.04; 16G main memory; 8 cores;
  • 网络环境:使用 netem 分别模拟局域网和广域网网速情况。
    • 局域网:1000 Mb/s throughput and 0.3ms latency
    • 广域网:100 Mb/s throughput and 25ms latency,1.0% uniform random packet loss
  • baseline:使用 netcat 传输 CSV 格式的结果集的耗时,论文还记录了使用 GZIP 和 Snappy 压缩结果集后传输的耗时
img

表10是数据集 SF10 lineitem 上的结果(+C 表示启用 chunk 压缩):

  • 论文提出的两种协议在 Local 和 Lan 场景下都明显优于其他协议(快一个数量级)。
  • Local 场景下不启用 chunk 压缩耗时更短,因为此时传输数据成本很低;Lan 场景下启用 chunk 压缩耗时更少,此时压缩和解压缩的计算成本小于网络传输的成本。
  • 不启用 chunk 压缩的情况下,论文提出的两种协议需要传输的数据量是最小的,因为它们按列序列化结果集,不额外记录任何 per-row header;启用 chunk 压缩的情况下,论文提出的两种协议需要传输的数据量仍然是最小的,这是因为在压缩前这两种协议需要传输的数据量就小,且按列压缩可能效果优于按行压缩。
img

表11是数据集 ACS 上的结果(+C 表示启用压缩):

  • 论文提出的两种协议在 Local 和 Lan 场景下仍然都明显优于其他协议(快一个数量级)。
  • 仍然是 Local 场景下不启用 chunk 耗时更短,Lan 场景下启用 chunk 压缩耗时更少。
  • MySQL 的协议传输数据量最少。这是因为 ACS 数据集主要是整数列,一个整数列占4字节,但整数的实际值通常小于 100。MySQL 使用 text 编码,在表示小整数的时候效果更好,因为小于 10 的数字只需要两个字节来编码(一个字节用于长度字段,一个字节用于文本字符)。
  • 尽管 MySQL 的协议在启用 chunk 压缩的时候压缩效果很明显,但是总体耗时在网络变差的情况下没有明显下降。这是因为 MySQL 采用的是 GZIP,压缩效果较好但是压缩计算成本太高,占到了总体耗时的大部分,数据传输耗时的下降反而影响不大了。
img

表12是数据集 Ontime 上的结果(+C 表示启用压缩):

  • 论文提出的两种协议在 Local 和 Lan 场景下仍然都明显优于其他协议(快一个数量级)。
  • 仍然是 Local 场景下不启用 chunk 耗时更短,Lan 场景下启用 chunk 压缩耗时更少。
  • PostgreSQL++ 在该数据集上的表现比在其他两个数据集上要好一些,这应该是因为 Ontime 数据集相比其它两个数据集要多很多缺失值,PostgreSQL++ 使用了 Null Mask,可以少传输这些缺失值。

**总体来看,MonetDB++ 的效果在三个结果集上都是最优的。**虽然 PostgreSQL++ 的结果集格式和 MonetDB++ 的很相似,但因为 MonetDB 本身就是列存格式,因此每列的数据都可以直接按顺序复制到缓冲区中,不用像 PostgreSQL++ 一样按行读取数据再组装成列。这样能减少不少计算开销,因此 MonetDB++ 效果最好。

Future Work

  1. 论文当前的协议实现使用简单的规则判断来确定使用哪种压缩方法来压缩 chunk(服务端和客户端在同一主机则不压缩,反之采用 Snappy)。实际上可以通过网络情况来动态判断使用哪种压缩方法,这样可以对压缩成本进行调整并动态适应不断变化的网络条件。
  2. 可以尝试根据列中的数据分布使用不同的列压缩方法(比如可以通过数据库已有的统计信息来判断数据分布,以此选择列压缩方法)。也可以先压缩一列作为样本,试验哪种方法有最高的压缩比,然后选择压缩算法。
  3. 并行序列化结果集。在可并行查询的场景下,线程可以在计算结果时立即开始将结果集序列化到线程本地缓冲区中,而无需等待整个查询完成。

Apache IoTDB TsBlock 对比

如前文所述,Don’t Hold My Data Hostage – A Case For Client Protocol Redesign 中阐述了设计数据库客户端协议应该注意的点,并且在 PostgreSQL 和 MonetDB 中实现了基于 chunk 的新的结果集格式的协议。

Apache IoTDB 的客户端协议在序列化/反序列化结果集时使用的 TsBlock 结构,和这篇论文中的 chunk 结构非常相似,只是在对 chunk 的部分元数据处理上有一些不同。

论文提出的 chunk 结构:

img 截屏2023-07-18 21.30.17

IoTDB 中 TsBlock 在序列化时的格式:

img

格式说明:

  • 在进行序列化时,TsBlock 的 metadata 和除字符串以外的数据类型都是定长的。
  • val col cnt 指 TsBlock 的列的数量,val col types 是各列的数据类型,pos cnt 是数据的行数,encodings 是各列的编码形式,time col 和 val col 都可以认为是值列。
  • TsBlock 中像 PostgreSQL 一样用了 Null Mask 来标识缺失值。在序列化某一个值列前,如果该列没有缺失值,会序列化一个标记位表示该列没有缺失值;否则会先序列化一个 bitmap 来记录该列那些行是缺失的,bitmap 只需要占用 [rowCount / 8 + 1] 字节即可。因此 val col 应该理解成 bitmap + value column。

与论文中结构的对比:

  • 与论文中实现不同的是,TsBlock 中的 val col cnt、val col type、encodings 等 metadata 在每个 chunk 中都序列化了一遍,而不是通过 result set header 传递,这其实是不必要的开销。但是由于 TsBlock 不止作为客户端协议传输数据的载体,也是查询引擎内部传输数据的载体,目前 val col cnt、val col type、encodings 仍然需要在 TsBlock 中被序列化。
  • TsBlock 序列化时,没有在每一列前序列化该列的长度,所以客户端不能跳过不想读取的列,只能把整个 TsBlock 反序列化出来再读取想要的数据。

TsBlock 默认的最大行数是 1000,对列数没有限制,按照论文中的实验结果,一个 chunk 的大小在 1MB-10MB 间时启用压缩效果最好,那一个 TsBlock 大约需要有 150+ 列才能大于 1MB。

在 IoTDB 目前遇到的场景中,尚未发现论文中提到的 RSS + Transfer 是瓶颈的情况,所以 IoTDB 在传输 TsBlock 时,并没有将 TsBlock 压缩后再传输。

为了验证压缩 TsBlock 是否会有效果,下面做一个小实验。

压缩 TsBlock 实验

配置

三台服务器机器配置:

  • CPU:Intel I7-11700 8C16T
  • 内存:32 GB DDR 2400 MHZ + 8GB Swap 内存
  • 硬盘:3 块希捷 ST16000NM000J 机械硬盘,单盘 14 T,7200 RPM
  • 操作系统:Ubuntu 22.04.2 LTS

IoTDB 版本:

  • rel 1.2.0

数据集

使用 IoTDB Benchmark 写入数据:

  • 设置一个 DataBase,10个 device,每个 device 下 48个 sensors,可以认为一共有 480 列
  • INT32 : INT64 : FLOAT : DOUBLE : TEXT : BOOLEAN = 1 : 1 : 1 : 1 : 1 : 1
  • 共 500w 行数据,无缺失值

实验步骤

通过 IoTDB 的 Session 客户端发送查询,查询全量数据,查询语句为:

select * from root.**
  1. 部署 IoTDB 3C3D 集群
  2. 使用 Benchmark 写入数据
  3. 停 IoTDB 集群,清理系统缓存
  4. 启动 IoTDB,运行客户端程序执行一次全量查询进行 warm up
  5. 再次运行客户端程序,记录客户端 fetch 全量数据的耗时

共需进行8次实验,分别为:

  • 不进行压缩,客户端程序与服务端在同一节点
    • MaxTsBlockSize = 128KB
  • 不进行压缩,客户端程序与服务端不在同一节点(客户端获取数据需要进行网络传输)
    • MaxTsBlockSize = 128KB
  • 进行压缩,客户端程序与服务端在同一节点
    • MaxTsBlockSize = 128KB
    • MaxTsBlockSize = 1MB
    • MaxTsBlockSize = 10MB
  • 进行压缩,客户端程序与服务端不在同一节点(客户端获取数据需要进行网络传输)
    • MaxTsBlockSize = 128KB
    • MaxTsBlockSize = 1MB
    • MaxTsBlockSize = 10MB

实验结果

客户端和服务端在同一节点拉取数据耗时(s)客户端和服务端在不同节点拉取数据耗时(s)客户端接收的 ByteBuffer 总大小(MB)
不压缩,MaxTsBlockSize = 128KB14823311669
使用 Snappy 压缩 TsBlock 再传输,MaxTsBlockSize = 128KB1361411455
使用 Snappy 压缩 TsBlock 再传输,MaxTsBlockSize = 1MB1431481674
使用 Snappy 压缩 TsBlock 再传输,MaxTsBlockSize = 10MB1371491661

现象:

  • 当客户端和服务端在不同节点时,使用 Snappy 压缩 TsBlock 序列化的 ByteBuffer 后再传输耗时明显降低。可以看到客户端接收的 ByteBuffer 总大小在开启压缩后下降非常明显。
  • 开启压缩时,客户端和服务端在同一节点时的耗时会低于不在同一节点的耗时。
  • 调大 MaxTsBlockSize 对结果影响不大。

分析:

  • 千兆网局域网下使用 Snappy 压缩 TsBlock 序列化后的 ByteBuffer 再传输耗时更少,这与论文的预期符合,因为减少了网络开销。
  • 开启压缩时客户端和服务端在同一节点时的耗时会略低于但不明显低于不在同一节点时的耗时,因为即使客户端和服务端在同一节点也需要经过 RPC 传输数据,在同一节点应该只是相当于网速更快。
  • 调大 MaxTsBlockSize 对结果影响不大应该是由于算子有时间片限制,TsBlock 又有480列,很有可能在时间片内,能够生产的数据行数较少,因此每个 TsBlock 的行数较少,退化成了行式结构,故尽管调大了 MaxTsBlockSize 和 MaxTsBlockLineNumber 也不能使 TsBlock 变大。
#!/usr/bin/python3 # -*- coding: utf-8 -*- # 脚本解释器声明:指定使用 Python3 运行,编码格式为 UTF-8(支持中文注释和字符串) # 导入依赖库 import re # 正则表达式库:用于处理 DeepSeek 输出的字符串格式 import ast # 抽象语法树库:用于将字符串转为列表/字典(解析任务指令) import cv2 # OpenCV 库:图像处理(此处用于接收相机图像) import time # 时间库:计时、延时 import copy # 复制库:深拷贝检测结果,避免原数据被修改 import numpy as np # 数值计算库:矩阵运算、三角函数等 from scipy.spatial.transform import Rotation # 姿态转换库:用于旋转矩阵/欧拉角转换 from utils import * # 自定义工具库:包含计数、坐标转换等通用函数(需提前实现) from arm_motions import * # 机械臂运动库:包含抓取(grab)、放置(put)等核心动作函数(需提前实现) # ROS 相关库导入 import rospy # ROS 核心库:节点初始化、话题订阅/发布等 from std_msgs.msg import Bool, String, Int16 # ROS 标准消息类型:布尔、字符串、16位整数 from sensor_msgs.msg import CameraInfo, Image, JointState # 传感器消息:相机参数、图像、关节状态 from cv_bridge import CvBridge # 图像格式转换库:ROS Image &harr; OpenCV 图像 from geometry_msgs.msg import Twist, PoseStamped # 几何消息:速度指令、位姿目标 from user_msgs.msg import CarCmd, ExitCondition, ObjDet, ObjDets # 自定义消息:车辆命令、退出条件、目标检测结果 # ========================== 全局字典定义(数据映射)========================== # 颜色名称 &rarr; 编号映射(用于将文字指令转为代码可识别的数字) hostage_dict = { &quot;红&quot;: 0, &quot;蓝&quot;: 1, } # 编号 &rarr; 颜色名称映射(用于日志打印,将数字转回文字) hostage_dict_ = { 0: &quot;红&quot;, 1: &quot;蓝&quot;, } # 放置位名称 &rarr; 编号映射(一号/二号放置位 &rarr; 0/1) place_dict = { &quot;一号&quot;: 0, &quot;二号&quot;: 1, } # 编号 &rarr; 放置位名称映射(日志打印用) place_dict_ = { 0: &quot;一号&quot;, 1: &quot;二号&quot; } # ========================== 可调参数(提前配置,方便调试)========================== LATERAL_SPEED = 0.20 # 车辆侧向平移速度 (m/s):正值=向左,负值=向右 FORWARD_SPEED = 0.32 # 车辆前进/后退速度(m/s):正值=前进,负值=后退 PTS = 0.2 # 预留参数(未使用,可根据需求扩展) # ============================================================================== FDT1 = 3.6 # 第一段前进持续时间(秒) FDT2 = 1.0 # 预留前进时间参数(未使用) TPS1 = 0.58 # 第一段侧向平移持续时间(秒) TPS2 = 1.0 # 预留平移时间参数(未使用) TPS3 = 0.5 # 车位间平移时间(秒):从一个车位中心到相邻车位中心 # ============================================================================== # ========================== 全局变量定义(跨函数共享数据)========================== img_raw = None # 存储原始相机图像(从相机话题订阅) car_cmd = CarCmd() # 存储车辆当前命令状态(导航/保持/到达等) arrived = False # 导航到达标志位:True=已到达目标点,False=未到达 cylinder_dets = ObjDets() # 存储圆柱目标检测结果(包含目标颜色、位置、尺寸) # 左臂逆运动学计算结果:关节角度 left_arm_ik_servo_degree = JointState() left_arm_get_cmd_ik = False # 左臂逆解结果接收标志:True=已收到有效关节角度 # 右臂逆运动学计算结果:关节角度 right_arm_ik_servo_degree = JointState() right_arm_get_cmd_ik = False # 右臂逆解结果接收标志 success_rescure_count = 0 # 成功救援(抓取+放置)计数:最多救援2个目标 # ========================== 话题回调函数(数据接收)========================== def image_raw_cb(msg:Image): &quot;&quot;&quot; 相机图像回调函数:订阅相机原始图像,转换为 OpenCV 格式并存储到全局变量 :param msg: ROS 图像消息(/ascamera_hp60c/rgb0/image 话题) &quot;&quot;&quot; global img_raw # 声明使用全局变量 bridge = CvBridge() # 创建图像转换实例 img_raw = bridge.imgmsg_to_cv2(msg, &#39;bgr8&#39;) # ROS Image &rarr; OpenCV BGR 图像 def car_cmd_cb(msg:CarCmd): &quot;&quot;&quot; 车辆命令回调函数:订阅车辆状态命令,更新全局变量并设置到达标志位 :param msg: 自定义车辆命令消息(/car_cmd 话题) &quot;&quot;&quot; global car_cmd, arrived # 逻辑:如果之前未到达,且车辆从&ldquo;导航/保持&rdquo;状态切换为&ldquo;到达&rdquo;状态 &rarr; 置位 arrived if not arrived and (car_cmd.cmd == CarCmd.NAVIGATION or car_cmd.cmd == CarCmd.HOLD) and msg.cmd == CarCmd.ARRIVE: arrived = True # 逻辑:如果已到达,且车辆切换为&ldquo;导航&rdquo;状态 &rarr; 复位 arrived elif arrived and msg.cmd == CarCmd.NAVIGATION: arrived = False car_cmd = msg # 更新全局车辆命令 def cylinder_dets_cb(msg:ObjDets): &quot;&quot;&quot; 圆柱检测结果回调函数:订阅 YOLO 检测到的圆柱目标,存储到全局变量 :param msg: 自定义目标检测消息(/usb_cam/obj_dets_with_pose 话题) &quot;&quot;&quot; global cylinder_dets cylinder_dets = msg # 更新全局检测结果 def left_arm_ik_servo_degree_cb(msg:JointState): &quot;&quot;&quot; 左臂逆解结果回调函数:订阅左臂逆运动学计算出的关节角度 :param msg: 关节状态消息(/arm_left/arm_ik_servo_degree 话题) &quot;&quot;&quot; global left_arm_ik_servo_degree, left_arm_get_cmd_ik left_arm_ik_servo_degree = msg # 更新左臂关节角度 left_arm_get_cmd_ik = True # 置位&ldquo;已收到逆解结果&rdquo;标志 def right_arm_ik_servo_degree_cb(msg:JointState): &quot;&quot;&quot; 右臂逆解结果回调函数:订阅右臂逆运动学计算出的关节角度 :param msg: 关节状态消息(/arm_right/arm_ik_servo_degree 话题) &quot;&quot;&quot; global right_arm_ik_servo_degree, right_arm_get_cmd_ik right_arm_ik_servo_degree = msg # 更新右臂关节角度 right_arm_get_cmd_ik = True # 置位&ldquo;已收到逆解结果&rdquo;标志 # ========================== 核心工具函数(车辆控制)========================== def car_lateral_translation(speed, duration, car_cmd_pub, cmd_vel_pub): &quot;&quot;&quot; 控制车辆进行侧向平移(左右移动) :param speed: 平移速度 (m/s):正值=向左,负值=向右 :param duration: 平移持续时间 (秒):控制移动距离(距离=速度&times;时间) :param car_cmd_pub: 车辆命令发布者(发布 /car_cmd 话题) :param cmd_vel_pub: 速度指令发布者(发布 /cmd_vel 话题) &quot;&quot;&quot; # 1. 切换车辆为&ldquo;遥控模式&rdquo;(只有遥控模式才能接收速度指令) car_cmd = CarCmd() car_cmd.cmd = CarCmd.REMOTE # 连续发布20次(确保车辆控制器稳定接收模式切换指令) for _ in range(20): car_cmd_pub.publish(car_cmd) rospy.sleep(0.01) # 每次发布后延时0.01秒 # 日志打印:提示平移方向、速度、持续时间(便于调试) direction = &quot;左&quot; if speed &gt; 0 else &quot;右&quot; rospy.loginfo(f&quot;开始向 {direction} 平移,速度: {abs(speed):.2f} m/s, 持续时间: {duration:.2f} s...&quot;) # 2. 发布侧向平移速度指令(ROS Twist 消息:linear.y 对应侧向运动) twist = Twist() twist.linear.y = speed # 设置侧向速度 start_time = time.time() # 记录开始时间 rate = rospy.Rate(10) # 设定发布频率:10 Hz(每秒发布10次指令,避免指令丢失) # 循环发布指令,直到达到指定持续时间或节点关闭 while time.time() - start_time &lt; duration and not rospy.is_shutdown(): cmd_vel_pub.publish(twist) rate.sleep() # 按设定频率休眠 # 3. 停止移动:发布零速度指令 twist.linear.y = 0 cmd_vel_pub.publish(twist) # 4. 切换车辆为&ldquo;保持模式&rdquo;(停止后锁定当前状态,防止误动) car_cmd.cmd = CarCmd.HOLD car_cmd_pub.publish(car_cmd) rospy.loginfo(&quot;车辆平移完成。&quot;) def car_forward_backward(speed, duration, car_cmd_pub, cmd_vel_pub): &quot;&quot;&quot; 控制车辆进行前进或后退 :param speed: 移动速度 (m/s):正值=前进,负值=后退 :param duration: 移动持续时间 (秒):控制移动距离(距离=速度&times;时间) :param car_cmd_pub: 车辆命令发布者(发布 /car_cmd 话题) :param cmd_vel_pub: 速度指令发布者(发布 /cmd_vel 话题) &quot;&quot;&quot; # 1. 切换车辆为&ldquo;遥控模式&rdquo;(接收速度指令的前提) car_cmd = CarCmd() car_cmd.cmd = CarCmd.REMOTE # 连续发布20次(确保模式切换成功) for _ in range(20): car_cmd_pub.publish(car_cmd) rospy.sleep(0.01) # 日志打印:提示移动方向、速度、持续时间 direction = &quot;前进&quot; if speed &gt; 0 else &quot;后退&quot; rospy.loginfo(f&quot;开始 {direction},速度: {abs(speed):.2f} m/s, 持续时间: {duration:.2f} s...&quot;) # 2. 发布前进/后退速度指令(ROS Twist 消息:linear.x 对应前后运动) twist = Twist() twist.linear.x = speed # 设置前后速度 start_time = time.time() # 记录开始时间 rate = rospy.Rate(10) # 10 Hz 发布频率 # 循环发布指令,直到达到指定时间 while time.time() - start_time &lt; duration and not rospy.is_shutdown(): cmd_vel_pub.publish(twist) rate.sleep() # 3. 停止移动:发布零速度指令 twist.linear.x = 0 cmd_vel_pub.publish(twist) rospy.loginfo(&quot;车辆停止。&quot;) # 4. 切换回&ldquo;保持模式&rdquo;,交还控制权 car_cmd.cmd = CarCmd.HOLD car_cmd_pub.publish(car_cmd) rospy.loginfo(&quot;车辆前进/后退动作完成,已切换回保持模式。&quot;) # ========================== 核心工具函数(检测辅助)========================== def get_current_detected_colors(cylinder_dets, timeout=2.0): &quot;&quot;&quot; 实时获取当前检测到的圆柱目标颜色(从全局检测结果中提取) :param cylinder_dets: 全局目标检测结果(ObjDets 类型) :param timeout: 超时时间(秒):超过时间未检测到则返回空列表 :return: list:检测到的颜色列表,如 [&#39;红&#39;, &#39;蓝&#39;] 或 [] &quot;&quot;&quot; start_time = time.time() detected_colors = [] # 存储检测到的颜色 # 循环检测,直到超时或检测到目标 while not rospy.is_shutdown() and (time.time() - start_time &lt; timeout): if cylinder_dets.num &gt; 0: # 如果检测到目标(num=目标数量) detected_colors = [] # 清空之前的结果(避免重复) # 遍历所有检测到的目标 for det in cylinder_dets.dets: det: ObjDet # 类型注解:明确 det 是 ObjDet 类型 # 根据目标的 class_id 映射到颜色名称 if det.class_id in hostage_dict_: color = hostage_dict_[det.class_id] detected_colors.append(color) # 如果检测到颜色,立即返回结果 if detected_colors: rospy.loginfo(f&quot;实时检测到颜色: {detected_colors}&quot;) return detected_colors rospy.sleep(0.1) # 每0.1秒检测一次,避免占用过多CPU # 超时或未检测到目标,打印警告并返回空列表 if not detected_colors: rospy.logwarn(&quot;未检测到任何物体颜色&quot;) return detected_colors # ========================== 主函数(核心任务逻辑)========================== def main(): # 声明使用全局变量(跨函数修改需要声明) global arrived, left_arm_get_cmd_ik, right_arm_get_cmd_ik global success_rescure_count # 1. 初始化 ROS 节点:节点名=mission_manage_node,anonymous=True&rarr;自动添加随机后缀(避免节点名冲突) rospy.init_node(&#39;mission_manage_node&#39;, anonymous=True) # 2. 从 ROS 参数服务器读取配置参数(参数在 launch 文件中通过 rosparam 加载) # 2.1 读取导航目标点参数(救援位置、过渡点、放置位置、返回点、停车位置) go_to_grab_num = rospy.get_param(&#39;/mission_magage/go_to_grab/wp_num&#39;, 0) # 救援位置点数量 go_to_grab_wp = [] # 存储救援位置点列表 for i in range(go_to_grab_num): # 读取每个救援位置点(参数名格式:/mission_magage/go_to_grab/wp_1, wp_2...) go_to_grab_wp.append(rospy.get_param(f&#39;/mission_magage/go_to_grab/wp_{i+1}&#39;)) transit_wp = rospy.get_param(&#39;/mission_magage/transit_pt/wp_1&#39;) # 过渡点(救援&rarr;放置的中间导航点) grab_num = rospy.get_param(&#39;/mission_magage/grab/wp_num&#39;, 0) # 抓取位置点数量 grab_wp = [] # 存储抓取位置点列表 for i in range(grab_num): grab_wp.append(rospy.get_param(f&#39;/mission_magage/grab/wp_{i+1}&#39;)) put_num = rospy.get_param(&#39;/mission_magage/put/wp_num&#39;, 0) # 放置位置点数量 put_wp = [] # 存储放置位置点列表 for i in range(put_num): put_wp.append(rospy.get_param(f&#39;/mission_magage/put/wp_{i+1}&#39;)) go_back_wp = rospy.get_param(&#39;/mission_magage/go_back/wp_1&#39;) # 返回点(放置&rarr;停车的中间点) park_num = rospy.get_param(&#39;/mission_magage/park/wp_num&#39;, 0) # 停车位置点数量 park_wp = [] # 存储停车位置点列表 for i in range(park_num): park_wp.append(rospy.get_param(f&#39;/mission_magage/park/wp_{i+1}&#39;)) # 2.2 初始化机械臂抓取参数(左右臂分别配置) grab_param_l = GrabParam() # 左臂抓取参数实例(自定义类,包含相机内参、坐标转换参数等) grab_param_r = GrabParam() # 右臂抓取参数实例 # 2.3 读取坐标转换参数(各部件相对车体的位置/姿态) t_larm_2_car = rospy.get_param(&#39;/arm_left/translation&#39;, [0, 0, 0]) # 左臂&rarr;车体的平移向量(x,y,z) t_rarm_2_car = rospy.get_param(&#39;/arm_right/translation&#39;, [0, 0, 0]) # 右臂&rarr;车体的平移向量 t_cam_2_car = rospy.get_param(&#39;/ascamera/translation&#39;, [0, 0, 0]) # 相机&rarr;车体的平移向量 rpy_cam_2_car = rospy.get_param(&#39;/ascamera/rotation&#39;, [-90, 0, -90]) # 相机&rarr;车体的姿态(欧拉角,度) # 2.4 读取抓取相关参数(视觉识别区域、目标距离、机械臂爪部参数) sight_middle_h = rospy.get_param(&#39;/grab_param/sight_middle_h&#39;, 300) # 视觉识别区域中线(高度方向像素) sight_middle_w = rospy.get_param(&#39;/grab_param/sight_middle_w&#39;, [269, 457]) # 视觉识别区域左右边界(宽度方向像素) x_obj_to_cam_close = rospy.get_param(&#39;/grab_param/x_obj_to_cam_close&#39;, 0.205) # 目标&rarr;相机的近距离(m) x_obj_to_cam_middle = rospy.get_param(&#39;/grab_param/x_obj_to_cam_middle&#39;, 0.26) # 目标&rarr;相机的中距离(m) x_obj_to_cam_far = rospy.get_param(&#39;/grab_param/x_obj_to_cam_far&#39;, 0.34) # 目标&rarr;相机的远距离(m) x_j5_2_claw = rospy.get_param(&#39;/grab_param/x_j5_2_claw&#39;, 0.085) # 机械臂关节5&rarr;爪部的x方向距离(m) z_j5_2_claw = rospy.get_param(&#39;/grab_param/z_j5_2_claw&#39;, 0.0115) # 机械臂关节5&rarr;爪部的z方向距离(m) l_j5_2_claw = np.sqrt(x_j5_2_claw**2 + z_j5_2_claw**2) # 关节5&rarr;爪部的直线距离(m) theta_j5_2_claw = np.rad2deg(np.arctan2(z_j5_2_claw, x_j5_2_claw)) # 关节5&rarr;爪部的角度(度) # 2.5 读取机械臂关节角度参数(左右臂独立配置) left_arm_front = rospy.get_param(&#39;turn_front&#39;, 90) # 左臂向前转动角度(度) left_arm_middle = rospy.get_param(&#39;turn_middle&#39;, 0) # 左臂中间位置角度(度) left_arm_back = rospy.get_param(&#39;turn_back&#39;, -90) # 左臂向后转动角度(度) left_arm_tight = rospy.get_param(&#39;/arm_left/claw_tight&#39;, 30) # 左臂爪部夹紧角度(度) left_arm_loose = rospy.get_param(&#39;/arm_left/claw_loose&#39;, 60) # 左臂爪部松开角度(度) right_arm_front = rospy.get_param(&#39;/arm_right/turn_front&#39;, 90) # 右臂向前转动角度(度) right_arm_middle = rospy.get_param(&#39;/arm_right/turn_middle&#39;, 0) # 右臂中间位置角度(度) right_arm_back = rospy.get_param(&#39;/arm_right/turn_back&#39;, -90) # 右臂向后转动角度(度) right_arm_tight = rospy.get_param(&#39;/arm_right/claw_tight&#39;, 30) # 右臂爪部夹紧角度(度) right_arm_loose = rospy.get_param(&#39;/arm_right/claw_loose&#39;, 60) # 右臂爪部松开角度(度) # 2.6 读取任务时间参数(总任务时间,预留25秒返回时间) mission_time = rospy.get_param(&#39;/mission/mission_time&#39;, 300) - 25 # 3. 创建 ROS 话题订阅者(接收数据) image_raw_sub = rospy.Subscriber(&#39;/ascamera_hp60c/rgb0/image&#39;, Image, image_raw_cb) # 订阅相机图像 car_cmd_sub = rospy.Subscriber(&#39;/car_cmd&#39;, CarCmd, car_cmd_cb) # 订阅车辆命令 yolov5_dets_sub = rospy.Subscriber(&#39;/usb_cam/obj_dets_with_pose&#39;, ObjDets, cylinder_dets_cb) # 订阅目标检测结果 left_arm_servo_degree_sub = rospy.Subscriber(&quot;/arm_left/arm_ik_servo_degree&quot;, JointState, left_arm_ik_servo_degree_cb) # 订阅左臂逆解结果 right_arm_servo_degree_sub = rospy.Subscriber(&quot;/arm_right/arm_ik_servo_degree&quot;, JointState, right_arm_ik_servo_degree_cb) # 订阅右臂逆解结果 # 4. 创建 ROS 话题发布者(发送指令) ds_task_pub = rospy.Publisher(&#39;/deepseek/task&#39;, Bool, queue_size=1) # 控制 DeepSeek 节点启停 ds_input_pub = rospy.Publisher(&#39;/deepseek/input_text&#39;, String, queue_size=1) # 向 DeepSeek 发送任务指令 at_target_pub = rospy.Publisher(&#39;/at_target_id&#39;, Int16, queue_size=1) # 发布 AprilTag 目标ID car_target_pub = rospy.Publisher(&#39;/car_target&#39;, PoseStamped, queue_size=1) # 发布车辆导航目标位姿 exit_condition_pub = rospy.Publisher(&#39;exit_condition&#39;, ExitCondition, queue_size=1) # 发布任务退出条件 car_cmd_pub = rospy.Publisher(&#39;/car_cmd&#39;, CarCmd, queue_size=3) # 发布车辆控制命令 cmd_vel_pub = rospy.Publisher(&#39;/cmd_vel&#39;, Twist, queue_size=3) # 发布车辆速度指令 left_arm_target_pose_pub = rospy.Publisher(&#39;/arm_left/arm_target&#39;, PoseStamped, queue_size=3) # 发布左臂目标位姿 left_arm_servo_degree_pub = rospy.Publisher(&#39;/arm_left/arm_joint_states&#39;, JointState, queue_size=3) # 发布左臂关节角度指令 right_arm_target_pose_pub = rospy.Publisher(&#39;/arm_right/arm_target&#39;, PoseStamped, queue_size=3) # 发布右臂目标位姿 right_arm_servo_degree_pub = rospy.Publisher(&#39;/arm_right/arm_joint_states&#39;, JointState, queue_size=3) # 发布右臂关节角度指令 # ========================== 任务指令解析(核心逻辑1)========================== # 说明:此处注释了 DeepSeek 交互逻辑,直接手动指定任务指令(仅供调试) # 实际使用时可取消注释,通过输入文本指令让 DeepSeek 生成任务列表 # ################################################################################ # 输入任务指令 等待deepseek输出 # ############################## # ds_input = String() # input_temp = input(&quot;输入任务指令\n&gt;&gt;&gt;&quot;) # ds_input.data = &quot;在此填入自定义附加提示词&quot; \ # + input_temp + &quot;。&quot; # ds_input_pub.publish(ds_input) # t0 = time.time() # rospy.loginfo(&quot;任务开始\n&quot;) # 等待deepseek处理得到的结果 # ds_output: String = rospy.wait_for_message(&#39;/deepseek/output_text&#39;, String, timeout=None) # ################################################################################ # ################################################################################ # 手动指定结果 仅供调试使用(核心:任务指令&rarr;[[颜色, 放置位], [颜色, 放置位]]) # 示例:[[蓝色,一号], [红色, 二号]] &rarr; 蓝色目标放到一号位,红色目标放到二号位 # ################################################################################ t0 = time.time() # 记录任务开始时间 ds_output = &quot;[[蓝色,一号], [红色, 二号]]&quot; # 手动指定任务指令 # ################################################################################ # 解析任务指令:将字符串转为列表(如 &quot;[[蓝色,一号], [红色,二号]]&quot; &rarr; [[&#39;蓝色&#39;,&#39;一号&#39;], [&#39;红色&#39;,&#39;二号&#39;]]) orig_str = ds_output if &quot;色&quot; in orig_str: orig_str = orig_str.replace(&quot;色&quot;, &quot;&quot;) # 去除&ldquo;色&rdquo;字(如&ldquo;蓝色&rdquo;&rarr;&ldquo;蓝&rdquo;,适配 hostage_dict) try: mission = ast.literal_eval(orig_str) # 字符串&rarr;列表(正常格式直接解析) print(mission) except ValueError: # 格式异常时,用正则表达式给中文添加引号(如 [蓝色,一号] &rarr; [&quot;蓝色&quot;,&quot;一号&quot;]) processed = re.sub(r&#39;(?&lt;=[\[,\s])([\u4e00-\u9fa5]+)(?=[,\]\s])&#39;, r&#39;&quot;\1&quot;&#39;, orig_str) mission = ast.literal_eval(processed) # 重新解析 print(mission) # 将任务列表中的文字转为编号(如 &quot;蓝色&quot;&rarr;1,&quot;一号&quot;&rarr;0),便于代码处理 grab_mission = mission print(grab_mission) # 打印转换前的任务列表 for item in grab_mission: if isinstance(item, list) and len(item) == 2: if item[0] in hostage_dict: item[0] = hostage_dict[item[0]] # 颜色文字&rarr;编号 if item[1] in place_dict: item[1] = place_dict[item[1]] # 放置位文字&rarr;编号 # 提取救援目标颜色和放置位指令(便于后续匹配) hostage_cmd = [grab_mission[i][0] for i in range(2)] # 救援目标颜色编号列表:[1,0](对应蓝色、红色) place_cmd = [grab_mission[i][1] for i in range(2)] # 放置位编号列表:[0,1](对应一号、二号) print(&quot;救援放置任务配对:&quot;, grab_mission) # 打印最终任务:[[1,0], [0,1]] # 结束 DeepSeek 节点,释放内存(调试模式下无作用,实际使用时启用) ds_task_pub.publish(Bool(False)) # 5. 初始化机械臂抓取参数(从相机参数中获取内参) # 等待相机参数话题(/ascamera_hp60c/rgb0/camera_info),超时则报错 camera_info_msg: CameraInfo = rospy.wait_for_message(&#39;/ascamera_hp60c/rgb0/camera_info&#39;, CameraInfo, timeout=None) # 给左右臂抓取参数赋值(相机内参:cx/cy=主点坐标,fx/fy=焦距) grab_param_l.cx = camera_info_msg.K[2] grab_param_l.cy = camera_info_msg.K[5] grab_param_l.fx = camera_info_msg.K[0] grab_param_l.fy = camera_info_msg.K[4] grab_param_l.t_arm_2_car = t_larm_2_car grab_param_l.t_cam_2_car = t_cam_2_car grab_param_l.rpy_cam_2_car = rpy_cam_2_car grab_param_l.sight_middle_h = sight_middle_h grab_param_l.sight_middle_w = sight_middle_w grab_param_l.x_obj_to_cam_close = x_obj_to_cam_close grab_param_l.x_obj_to_cam_middle = x_obj_to_cam_middle grab_param_l.x_obj_to_cam_far = x_obj_to_cam_far grab_param_l.l_j5_2_claw = l_j5_2_claw grab_param_l.theta_j5_2_claw = theta_j5_2_claw # 右臂参数与左臂一致(如需差异配置可单独修改) grab_param_r.cx = camera_info_msg.K[2] grab_param_r.cy = camera_info_msg.K[5] grab_param_r.fx = camera_info_msg.K[0] grab_param_r.fy = camera_info_msg.K[4] grab_param_r.t_arm_2_car = t_rarm_2_car grab_param_r.t_cam_2_car = t_cam_2_car grab_param_r.rpy_cam_2_car = rpy_cam_2_car grab_param_r.sight_middle_h = sight_middle_h grab_param_r.sight_middle_w = sight_middle_w grab_param_r.x_obj_to_cam_close = x_obj_to_cam_close grab_param_r.x_obj_to_cam_middle = x_obj_to_cam_middle grab_param_r.x_obj_to_cam_far = x_obj_to_cam_far grab_param_r.l_j5_2_claw = l_j5_2_claw grab_param_r.theta_j5_2_claw = theta_j5_2_claw # 初始化机械臂关节状态消息(6个关节:joint1~joint6) arm_servo_degree = JointState() arm_servo_degree.name = [&quot;joint1&quot;, &quot;joint2&quot;, &quot;joint3&quot;, &quot;joint4&quot;, &quot;joint5&quot;, &quot;joint6&quot;] # ========================== 初始动作:离开起始区(核心逻辑2)========================== print(&quot;\n离开起始区...\n&quot;) rospy.loginfo(&quot;开始执行前进动作&quot;) car_forward_backward(FORWARD_SPEED, FDT1, car_cmd_pub, cmd_vel_pub) # 前进FDT1秒 rospy.loginfo(&quot;车辆开始向右平移&quot;) car_lateral_translation(-LATERAL_SPEED, TPS1, car_cmd_pub, cmd_vel_pub) # 向右平移TPS1秒(速度为负) rospy.sleep(1) # 延时1秒,稳定车辆状态 # 检测当前视野内的目标颜色,判断救援位置顺序 current_colors = get_current_detected_colors(cylinder_dets) print(f&quot;当前检测到的颜色: {current_colors}&quot;) # 逻辑:匹配检测到的颜色与任务指令,确定第一个救援位置 if hostage_dict[current_colors[0]] == hostage_cmd[0]: print(f&quot;{current_colors} 和 {hostage_cmd} 匹配,选择第一个救援位置&quot;) flag=True grab_wp_cnt = 0 # 救援位置计数器:0&rarr;第一个救援点 else: print(f&quot;{current_colors} 和 {hostage_cmd} 不匹配,选择第二个救援位置&quot;) flag=False grab_wp_cnt = 1 # 救援位置计数器:1&rarr;第二个救援点 # 如果不匹配,调整车辆位置(向右平移4秒+小幅前进) if flag==False: rospy.loginfo(&quot;车辆开始向右平移&quot;) car_lateral_translation(-LATERAL_SPEED, 4.0, car_cmd_pub, cmd_vel_pub) rospy.loginfo(&quot;开始执行前进动作&quot;) car_forward_backward(FORWARD_SPEED, 0.1, car_cmd_pub, cmd_vel_pub) flag=True # ========================== 救援循环:抓取+放置(核心逻辑3)========================== # 初始化 TF 监听器(用于坐标转换,如相机坐标系&rarr;车体坐标系) tf_buffer = tf2_ros.Buffer() tf_listener = tf2_ros.TransformListener(tf_buffer) count=0 # 单次救援计数(0&rarr;第一个目标,1&rarr;第二个目标) # 循环条件:未超时(任务时间)、未救援2个目标、节点未关闭 while not rospy.is_shutdown() and (time.time() - t0 &lt; mission_time) and count&lt;2 and success_rescure_count &lt; 2: # 1. 导航到救援位置 print(f&quot;\n前往{grab_wp_cnt+1}号救援位置...\n&quot;) arrived = False # 复位到达标志 # 发布救援位置导航目标(pub_wp 是自定义函数,用于发布导航点) pub_wp(grab_wp[grab_wp_cnt], car_cmd, at_target, car_target, exit_condition, car_cmd_pub, at_target_pub, exit_condition_pub, car_target_pub) # 等待车辆到达目标位置 while not rospy.is_shutdown(): if not arrived: time.sleep(1) else: break # 2. 检测救援目标(圆柱) print(f&quot;\n到达 {grab_wp_cnt+1}号 救援位置 开始检测\n&quot;) t1 = time.time() # 记录检测开始时间 cylinder_dets_ = None # 存储当前救援位置的检测结果 # 等待检测结果(超时2秒未检测到则跳过) while not rospy.is_shutdown() and cylinder_dets_ is None: if time.time() - t1 &gt; 2: rospy.logwarn(&quot;检测超时,未发现目标&quot;) break if not cylinder_dets.num: # 如果未检测到目标,继续等待 rospy.Rate(1).sleep() continue cylinder_dets_ = copy.copy(cylinder_dets) # 深拷贝检测结果(避免原数据被修改) # 如果未检测到目标,跳过当前救援位置 if cylinder_dets_ is None: print(f&quot;\n{grab_wp_cnt+1}号 救援位置无目标,跳过\n&quot;) continue # 3. 机械臂抓取目标 print(&quot;\n检测到人质 准备抓取\n&quot;) assign_left = False # 左臂分配标志(未使用,可扩展) assign_right = False # 右臂分配标志(未使用,可扩展) hostage_in_arm = [-1, -1] # 记录臂部抓取结果:[右臂结果, 左臂结果],-1=未抓取 # 遍历所有检测到的目标 for det in cylinder_dets_.dets: det: ObjDet # 只处理任务指令中指定的目标颜色(过滤无关目标) if det.class_id not in hostage_cmd: continue # 计算目标中心在图像中的像素坐标 target_center_pix = [det.bbox.x, det.bbox.y] ########################################## # 逻辑:根据目标在图像中的位置,判断抓取优先级(middle/top/bottom) if det.bbox.y &lt;= sight_middle_h and sight_middle_w[0] &lt;= det.bbox.x &lt;= sight_middle_w[1]: position_level = &quot;middle&quot; # 目标在中间区域(优先抓取) elif det.bbox.y &lt;= sight_middle_h: position_level = &quot;top&quot; # 目标在上半区域 print(&quot;目标在上半区域&quot;) else: position_level = &quot;bottom&quot; # 目标在下半区域 # 逻辑:根据目标在图像中的左右位置,分配机械臂(左/右) if target_center_pix[0] &lt; sight_middle_w[1]: lr = True # True=左臂 print(&quot;分配左臂抓取&quot;) arm_servo_degree_pub = left_arm_servo_degree_pub # 左臂关节角度发布者 loose = left_arm_loose # 左臂松开角度 tight = left_arm_tight # 左臂夹紧角度 else : lr = False # False=右臂 print(&quot;分配右臂抓取&quot;) arm_servo_degree_pub = right_arm_servo_degree_pub # 右臂关节角度发布者 loose = right_arm_loose # 右臂松开角度 tight = right_arm_tight # 右臂夹紧角度 # 准备抓取:拷贝检测结果,避免被后续回调修改 dets_to_process = copy.copy(cylinder_dets_.dets) init_fruit_count = count_fruits(cylinder_dets_) # 初始目标数量(自定义函数:统计检测到的目标数) print(&quot;初始识别到的目标数量:&quot;, init_fruit_count) after_fruit_count = init_fruit_count # 抓取后目标数量(初始化为初始数量) grab_degree=[] # 存储抓取时的关节角度(由 grab 函数填充) # 循环抓取:直到抓取成功(目标数量减少)或退出 while(True): # 调用抓取函数(自定义函数:arm_motions.py 中实现,控制机械臂完成抓取动作) grab(grab_degree, loose, tight, lr, arm_servo_degree, arm_servo_degree_pub, position_level) # 抓取后重新读取检测结果,判断是否抓取成功 yolov5_dets_after = copy.copy(cylinder_dets) after_fruit_count = count_fruits(yolov5_dets_after) print(&quot;抓取后识别到的目标数量:&quot;, after_fruit_count) # 逻辑:如果抓取后目标数量减少 &rarr; 抓取成功 if after_fruit_count &lt; init_fruit_count: if lr: hostage_in_arm[0] = det.class_id # 左臂抓取到的目标颜色编号 success_rescure_count += 1 # 成功救援计数+1 count += 1 # 单次循环计数+1 # 切换下一个救援位置(0&rarr;1,1&rarr;0) if grab_wp_cnt == 1: grab_wp_cnt = 0 elif grab_wp_cnt == 0: grab_wp_cnt = 1 print(f&quot;左臂抓取成功,目标颜色编号:{hostage_in_arm[0]}&quot;) else: hostage_in_arm[1] = det.class_id # 右臂抓取到的目标颜色编号 print(f&quot;右臂抓取成功,目标颜色编号:{hostage_in_arm[1]}&quot;) success_rescure_count += 1 # 成功救援计数+1 count += 1 # 单次循环计数+1 break # 抓取成功,退出循环 # 如果抓取失败,重新导航到当前救援位置,再次尝试 print(f&quot;抓取失败,重新前往{grab_wp_cnt+1}号救援位置...\n&quot;) arrived = False pub_wp(grab_wp[grab_wp_cnt], car_cmd, at_target, car_target, exit_condition, car_cmd_pub, at_target_pub, exit_condition_pub, car_target_pub) while not rospy.is_shutdown(): if not arrived: time.sleep(1) else: break ########################################################## # 4. 导航到过渡点(救援&rarr;放置的中间点,稳定车辆姿态) print(&quot;\n前往中间点...\n&quot;) arrived = False pub_wp(transit_wp, car_cmd, at_target, car_target, exit_condition, car_cmd_pub, at_target_pub, exit_condition_pub, car_target_pub) while not rospy.is_shutdown(): if not arrived: time.sleep(1) else: break # 5. 导航到放置位置并放置目标 # 配置放置参数(此处默认用左臂放置,可根据实际抓取的臂部调整) lr = True loose = left_arm_loose tight = left_arm_tight arm_servo_degree_pub = left_arm_servo_degree_pub # 根据当前救援计数,确定放置位(count-1&rarr;0/1,对应一号/二号放置位) place = place_cmd[count-1] print(f&quot;\n前往 {place+1} 号放置位...\n&quot;) # 导航到放置位 arrived = False pub_wp(put_wp[place], car_cmd, at_target, car_target, exit_condition, car_cmd_pub, at_target_pub, exit_condition_pub, car_target_pub) while not rospy.is_shutdown(): if not arrived: time.sleep(1) else: break # 调用放置函数(自定义函数:arm_motions.py 中实现,控制机械臂松开爪部放置目标) put(1, loose, tight, lr, arm_servo_degree, arm_servo_degree_pub) # ========================== 任务结束:返回并停车(核心逻辑4)========================== print(&quot;\n所有救援任务完成,开始返回...\n&quot;) # 车辆掉头(自定义函数:控制车辆旋转190度,准备返回) chassis_turn(190, car_cmd_pub, cmd_vel_pub) rospy.loginfo(&quot;开始执行返回前进动作&quot;) car_forward_backward(FORWARD_SPEED, FDT1, car_cmd_pub, cmd_vel_pub) # 前进返回 print(f&quot;\n开始停入车位...\n&quot;) # 导航到停车位置 arrived = False pub_wp(park_wp[0], car_cmd, at_target, car_target, exit_condition, car_cmd_pub, at_target_pub, exit_condition_pub, car_target_pub) while not rospy.is_shutdown(): if not arrived: time.sleep(1) else: break # 可选:微调停车位置(侧向平移,根据实际车位调整) # rospy.loginfo(&quot;车辆开始向右平移微调&quot;) # car_lateral_translation(-0.4, 7, car_cmd_pub, cmd_vel_pub) # ========================== 程序入口 ========================== if __name__ == &quot;__main__&quot;: main() # 启动主函数
11-30
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值