全球AIS实时与历史数据处理:NMEA解析、清洗与轨迹存储
2026/9/18 12:49:28 网站建设 项目流程

跑 AIS 数据这行的人,多少都经历过这样的时刻:屏幕上几千条!AIVDM报文哗哗地刷,等你真要去算一条船的到港时间,才发现同一艘船在五分钟里出现了六个不同的位置,MMSI 还是重复的。船舶自动识别系统数据看起来只是"船的位置",但真把它当成一门数据处理活来做,从报文解析、接收链路、清洗规则到实时与历史两套管道,每一步都有自己的一套门道。这篇就把我这些年处理全球 AIS 实时数据与历史数据的完整思路摊开讲一遍——不管你是在做航运态势可视化、港口调度辅助,还是单纯想搭一套自己的船舶轨迹库,下面这些内容都能直接拿去用。

1. AIS报文里到底藏着什么:从NMEA语句到可用的船舶轨迹

1.1 一条报文的标准骨架

AIS 的物理层走的是 VHF 频段,两个信道交替工作:AIS1 是 161.975 MHz(对应信道 87B),AIS2 是 162.025 MHz(对应信道 88B),速率 9600 bps,调制方式 GMSK。信道交替的设计不是为了好看,而是为了让不同船舶的时隙发射互不打架——这套机制叫 SOTDMA(自组织时分多址),每艘船在自己的时隙里发一次,其他船监听并预约后续时隙。

数据到了你的接收机,输出的一般是 NMEA 0183 格式的文本行,长这样:

!AIVDM,1,1,,A,13u?etP005l;b0PDWQ5Sg?vN0<0l,0*5A

别被这一串乱码吓到,它其实结构很规整,用逗号切开逐个看:

字段序号含义
1!AIVDM语句类型,AIVDM是收到的他船报文,AIVDO是本船
21这条报文总共分成几段
31当前是第几段
4多段报文的顺序号,单段时留空
5A来自哪个信道,A 或 B
613u?etP...载荷,六比特编码后的"乱码"
70填充位数,最后一个字符用了几个比特
8*5A校验和

第 6 段那个"乱码"是整件事的核心。AIS 把二进制数据按每 6 个比特一组,映射成可打印的 ASCII 字符:字符码值落在 48~87 的,减 48;落在 96~119 的,减 56(等价于先减 48 再减 8)。解码的逻辑大概是这样:

def sixbit_to_bits(payload: str, fill_bits: int) -> str: bits = "" for ch in payload: v = ord(ch) - 48 if v > 40: v -= 8 if not 0 <= v <= 63: raise ValueError(f"非法字符: {ch!r}") bits += format(v, "06b") if fill_bits: bits = bits[:-fill_bits] return bits

拿到比特串之后,再按消息类型规定的偏移去切字段。这里有个新手特别容易翻车的地方:AIS 的所有数值字段都是"有符号整数 + 比例因子",不是浮点数,而且很多是补码表示。比如经度占 28 位,单位是 1/10000 分,读出原始整数后要除以 600000 才得到十进制度数。

1.2 三类报文各管一段事

AIS 一共定义了二十多种消息类型,但日常真正高频用到的就那么几个,可以粗暴地分成三组:

  • 动态信息:类型 1、2、3(A 类船位报告),类型 18、19(B 类船位报告)。给的是经纬度、对地航速 SOG、对地航向 COG、真航向、航行状态、转向率。这是轨迹的本体。
  • 静态信息:类型 5(A 类静态与航次数据),类型 24(B 类静态数据报告,分 A、B 两部分)。给的是船名、呼号、IMO 号、船型、尺寸、目的港、吃水、预计到达时间。
  • 辅助信息:类型 4(基站报告)、类型 21(航标)、类型 27(长距离广播,精度只有 1 比特,大概 185 米一格)。

很多人第一反应是"我只要位置就行",结果做船舶档案的时候发现没有船名、没有船型,只能靠 MMSI 去别的地方补。静态报文的发送频率远低于动态报文——A 类船的静态信息通常每 6 分钟一次,动态信息在航速 14~23 节时每 6 秒一次,转向时甚至 2 秒一次。这意味着你必须做"静态信息持久化":收到一次类型 5 就存起来,后续所有轨迹点都去关联这份档案,而不是指望每个位置点都带船名。

顺带说一句动态报文的更新节奏,这个直接决定了你数据库的写入压力:

船舶状态更新间隔
A 类锚泊或系泊,速度低于 3 节3 分钟
A 类 0~14 节10 秒
A 类 0~14 节且在转向3.33 秒
A 类 14~23 节6 秒
A 类 14~23 节且在转向2 秒
A 类 23 节以上2 秒
B 类低于 2 节3 分钟
B 类高于 2 节30 秒

一艘跨洋航行的 A 类船,一天轻轻松松产生上万条位置点。全球同时在线的船舶量级是十万级,你算一下日均报文量,就知道为什么后面必须聊存储选型。

1.3 拿到原始字段之后,还有三道坎

第一道坎是不可用值的表示方式。AIS 协议里,经度 181 度、纬度 91 度、航向 511、航速 102.3 节这些特殊值表示"数据不可用",不是真的有一艘船在跑 102 节。你的解析器必须在入库前把这些值转成 NULL,否则后面聚类、画线的时候会看到船瞬移到地图外面。

第二道坎是时间戳的归属。报文里的 UTC 秒字段只有 0~59,没有日期,而且很多船压根不填。真正可信的时间是你接收机收到这条报文的本地时间(统一转 UTC)。我见过有人直接用报文里的秒字段去排序,结果整条轨迹的时间轴是错乱的。永远以接收时间为准,报文内的时间只做交叉校验。

第三道坎是多段报文的重组。类型 5 和类型 24 的内容比较长,一条 NMEA 语句塞不下,会被拆成两段甚至三段,靠第 2、3 字段的序号和顺序号来拼。这里的关键是设置一个合理的超时窗口——我的经验是 2 秒。超过 2 秒还没收齐第二段,就丢弃这个不完整包。窗口设太短会丢数据,设太长会让不同船舶的载荷串到一起,产生"船名变成乱码"的诡异现象。

2. 数据从哪来:岸基接收站、卫星链路与聚合平台的取舍

2.1 三种来源的覆盖和成本完全不同

数据源这件事,绕不开一个基本事实:AIS 是视距传播,岸基接收半径理论上也就 30~50 海里,实际受天线高度和地形影响,20~30 海里是常态。所以覆盖范围直接由你的站点位置决定。

来源覆盖更新频率成本结构主要问题
自建岸基站半径 20~50 海里原始频率一次性硬件投入 + 电费网费需要选址,维护跑不掉
卫星 AIS全球明显更稀按数据量或订阅付费时隙冲突严重,同一条报文可能被多颗卫星重复收到
聚合平台 API视平台站点密度平台加工后免费额度 + 超额计费限流、字段裁剪、历史深度受限

我个人的建议是:如果你只关心某个港口周边,自建站是性价比最高的选择;要做全球历史回溯,自建站基本没戏,还是得走聚合数据。两者不冲突,很多成熟的系统是自建站保实时、外部源补覆盖。

2.2 自建接收站,硬件清单和天线才是重点

接收机本体反而不贵。常见路线有三条:专用 AIS 接收机(出厂就带 NMEA 输出,插上就用)、软件无线电方案(RTL-SDR 加上开源解码程序)、以及带双信道的专业接收模块。第一条省心,第二条便宜但有折腾成本,第三条贵但抗干扰和灵敏度好。

真正决定成败的是天线这一段,我踩过的坑几乎都在这里:

  • 天线必须是 VHF 海事频段专用的,中心频率对准 162 MHz。拿个普通对讲机天线凑合,驻波比难看,灵敏度直接掉一半。
  • 馈线损耗要算清楚。162 MHz 下,常见的细同轴电缆每 10 米损耗大概 1.5~2 dB,粗一点的型号能压到 0.5 dB 上下。如果你天线在楼顶、接收机在三楼,二十米馈线用细线等于白白扔掉一半信号。宁可贵一点上粗线,或者干脆把接收机放到天线根部,用网线或者光纤把数据引下来。
  • 高度比增益重要。天线架高 10 米带来的覆盖提升,远比换一支高增益天线明显。因为 VHF 视距传播,架高就是在拉开视线。
  • 避雷和接地别省。户外天线不做等电位和避雷,下一场雷雨你就知道疼了。

软件侧,收到 NMEA 之后一般是解码成结构化数据,再推到消息队列或者直接写库。想给公开网络做贡献也行,很多聚合平台支持你把自己的站挂上去换一点免费查询额度。这里提醒一句:上传和下载是两码事,条款一定要看清楚,别把"可以上传"理解成"可以无限量拉取"。

2.3 免费源能给你什么,不能给你什么

免费或者低价的 AIS 数据源,普遍有几个共同特点,提前知道能省很多返工:

第一,限流是常态,而且是按请求次数或者按时间窗算的。你在本地跑循环疯狂拉实时位置,十分钟内就会被挡。正确做法是按区域拉取并做本地缓存,别每次都打全量。

第二,字段是裁剪过的。很多接口只给经纬度、航速、航向、船名,不给吃水、目的港、IMO 号。如果你的业务需要算载重或者做航线还原,就得提前确认字段清单。

第三,历史深度和粒度不一样。有的源只保留 30 天,有的只给降采样后的点(比如每 5 分钟一个点),你想要逐秒回溯是做不出来的。历史回溯类需求,最好在立项时就把"需要多深、多密"写死在需求里,否则后期换源等于重做。

第四,数据是加工过的,可能已经做过聚合。同一条船在同一个时间片里只返回一个点,看起来干净,但你做轨迹平滑和异常检测时就失去了原始噪声特征。做算法验证阶段,我建议至少保留一份原始粒度数据。

3. 把散乱报文变成可查询的轨迹:存储模型与清洗规则

3.1 分层存储:原始层、解析层、聚合层

我做过几套 AIS 数据系统,最后都收敛到同一个结构:原始层、解析层、聚合层三层分离

原始层就是把收到的 NMEA 文本原样归档,按天压缩成文件丢到对象存储。这一层看起来"没用",但它是你唯一的后悔药——解析规则改了、字段理解错了、要回填历史,全靠它。压缩后一条报文平均几十字节,全球一年也就几个 TB 级别,成本完全可控。

解析层是结构化点位表,这里才是查询的主力。选型上我给几个判断维度:

  • 数据量在千万级、又有空间查询需求:PostgreSQL + PostGIS + TimescaleDB是首选,SQL 表达力强,和地理函数天然契合。
  • 数据量到十亿级以上、以聚合分析为主:ClickHouse这类列存更合适,压缩比和扫描速度都夸张。
  • 只是做实时看板和短期缓存:Redis 的 Geo 结构足够,但别拿它当历史库用。
  • 纯离线批处理:Parquet + 对象存储 + DuckDB/Spark,成本最低。

解析层的表结构大概长这样,重点是分区和主键设计:

CREATE TABLE ais_position ( mmsi BIGINT NOT NULL, received_at TIMESTAMPTZ NOT NULL, lon DOUBLE PRECISION, lat DOUBLE PRECISION, sog REAL, cog REAL, heading SMALLINT, nav_status SMALLINT, msg_type SMALLINT, source_id SMALLINT, raw_hash BIGINT ); SELECT create_hypertable('ais_position', 'received_at', chunk_time_interval => INTERVAL '1 day'); CREATE UNIQUE INDEX ON ais_position (mmsi, received_at, raw_hash); CREATE INDEX ON ais_position USING GIST ( ST_SetSRID(ST_MakePoint(lon, lat), 4326) );

按天分区的理由是:你几乎所有的查询都带时间范围,而按天切分能让冷数据自动归档、热数据保持索引常驻内存。十年前的数据你不会天天查,但它必须还在。

聚合层则是"航次"和"轨迹段"这两张表,把连续的位置点抽象成一次航行:起点、终点、途经关键点、平均航速、总里程。业务查询打这一层,性能天差地别。

3.2 清洗规则清单:这几条不做,数据没法用

下面是经过实战验证、每条都真的救过命的清洗规则。它们不复杂,但漏掉任何一条都会让下游算法失真。

  • MMSI 合法性校验。水上移动业务标识是 9 位数字,前 3 位是 MID 码。9 位以内、全 0、明显越界的直接丢。另外,970 开头的是 AIS 搜救应答器,972 是人员落水设备,974 是应急无线电示位标,这些不是船,做船舶统计时必须排除,否则你的"在线船舶数"会莫名多出几千条。
  • 坐标范围与占位值过滤。经度绝对值大于 180、纬度绝对值大于 90,或者正好等于 181/91 的,一律置空。
  • 时间乱序与重复。同一 MMSI 在极短时间窗内出现多条内容完全一致的报文,是正常的信道重复接收,按raw_hash去重即可。但时间戳回退要单独处理:如果一条记录的时间比它前一条还早,且差距不大,直接丢弃;差距很大则说明数据源做了补发,需要按实际接收时间重排。
  • 跳点检测。用两点间的球面距离除以时间差算出"隐含速度",超过合理上限(比如 60 节,快艇也就这个量级)就判定为异常点。但注意,这个阈值不能设得太死——接收站切换、卫星过顶时,同一艘船可能在几秒内从两个不同源拿到位置,误判率会上去。我的做法是先做一次异常标记,不急着删除,等轨迹平滑阶段再决定。
  • 船名字段的清洗。AIS 的船名是定长 20 字符,不足的部分用@填充,而且实际数据里充斥着全角空格、控制字符、大小写混乱。清洗顺序应该是:先去掉@和不可见字符,再 trim 首尾空白,再统一转大写做索引字段,保留原始大小写做展示字段。

3.3 抽稀与压缩:磁盘和精度之间找平衡

原始粒度的数据量非常可观,可视化的时候一次性拉一条船跨洋航线的全部点位,浏览器会直接卡死。抽稀是必须的,但要分场景用不同方法。

道格拉斯-普克(Douglas-Peucker)适合几何形状保真:给定一个容差(比如 0.1 海里),递归保留偏离直线最多的端点,删掉中间冗余点。它对"航线拐了几个弯"这件事保留得很好,但对"在锚地原地打转"这种场景会过度压缩——因为轨迹本身几乎重合,最后可能只剩两个点。

按时间窗聚合适合做统计和看板:每 1 分钟取一个代表点(通常是最后一条),保留速度、航向的平均值。这种抽稀实现简单、结果稳定,代价是丢掉了转向瞬间的细节。

按状态分段保留是我最推荐的折中方案:先用航行状态切分轨迹段,锚泊段用长时间窗抽稀,航行段用短时间窗,转向点(转向率明显变化的点)全部保留。这样既控制了点数量,又保住了业务上真正关心的信息。

再补一个实用技巧:给每个位置点算一个Geohash 或 S2/H3 单元编号存成索引列。做"某区域内所有船舶"这类查询时,先按单元号过滤再算距离,比直接上空间索引快不少,尤其在高并发看板场景下效果明显。

4. 实时流与历史回溯怎么共存:一条管道两头用

4.1 消息队列的键怎么选,直接决定顺序性

实时链路我一般这么搭:接收端解析 → 投递到消息队列 → 消费端分流(一路写库、一路跑实时告警)。队列的 topic 设计有一个关键决策点:分区键选什么

选 MMSI 做分区键的好处是,同一艘船的所有报文严格有序,你在消费端做"状态机"式的处理(比如判断航次起止、计算累计里程)会很舒服。坏处是热点问题——繁忙港口里几条 MMSI 可能会集中在同一分区。规模不大的时候这不是问题,量级上来了可以用hash(mmsi) % N加上二级拆分,或者干脆按 Geohash 前缀分区,让地理位置相近的船落到一起,方便做区域态势计算。

消费端有两个必须做到的点:幂等写入消费位点管理。幂等靠数据库唯一索引兜底,别指望消息队列的"精确一次"承诺——跨系统了,谁也保不了。位点管理则要定期提交,同时设置一个"重放窗口",允许你在发现解析 bug 之后重新消费最近几小时的消息。

4.2 历史回溯的断点续传与限流控制

历史数据回溯的活儿,本质上是个"长时间、大批量、不能中断"的批处理。这里有几条经验是花钱买来的。

第一,一定要有检查点。按"数据源 + 时间窗口 + 地理范围"三元组做检查点,每隔一批写一次。任务断了重启,从检查点继续,而不是从头再来。我见过一次回溯任务跑了十六个小时,因为一个网络超时全废,那种心情不想再体验第二次。

第二,限流要主动做,别等被挡。在客户端维护一个令牌桶,把请求速率压到服务方限额的 70% 左右,留出余量。同时给失败请求做指数退避重试,重试三次还不行的记录单独落到死信表,事后人工或定时任务补跑。

第三,时间窗口不能开太大。有人喜欢一次请求一个月的范围,结果要么被服务方拒绝,要么响应体巨大直接超时。我一般用 6~12 小时一个窗口,遇到数据密度高的区域再缩到 1 小时。

第四,回溯和实时要隔离。回溯任务跑起来会占满带宽和数据库写入,如果和实时链路共用一个数据库连接池,实时看板会直接卡顿。做法很简单:给回溯任务单独开一个只写批量接口,写入走批量提交,或者直接写到一个临时的 staging 表,验证完再合并进主表。

4.3 全球覆盖下的时间与分区策略

全球数据最容易出问题的地方,是时间和分区。

时间上,存储层统一用 UTC,只在展示层做本地化转换。这条规则听起来像废话,但实际项目里因为时区混乱导致的"船明明到港了系统显示还在海上"的 bug,我至少见过三次。特别是做 ETA 预测时,如果你的历史数据里混进了本地时间,模型学出来的东西完全是错的。

分区上,跨时区的数据按 UTC 日期分区是最稳的,不要按"当地日期"。同时建议在点位表里额外存一列"数据源所在区域",做溯源和去重时很有用——同一条报文被两个接收站收到,位置可能相差几百米,你需要知道差在哪。

冷热分离也要提前规划。我的经验分界线是90 天:90 天内的数据放在高性能存储上供实时查询,90 天以前转成列式格式归档,需要时再按需加载。这个分界线可以根据业务调整,但一定要有一条,否则存储成本会慢慢吃掉整个预算。

5. 落到业务上:AIS数据能回答哪些问题

5.1 到港预测:ETA 为什么总是算不准

ETA 预测看起来是个简单题:剩余距离除以平均航速。但真做起来,误差来源有一堆。

首先是距离怎么算。直线球面距离是不行的,船不会穿岛走。理想做法是用历史航迹做主成分提取,或者干脆用公开的航道数据生成一条参考航线。退一步,至少用"当前点到港口的方位角"做个约束,避免把绕行算成直穿。

其次是速度怎么取。用瞬时 SOG 会被噪声带着跑,用整段平均又反映不了当前的减速。我比较推荐用最近 2 小时的加权滑动平均,权重按时间衰减,同时对 SOG 做一次中值滤波去掉毛刺。

第三是航行状态的影响。锚泊、系泊、受限操纵状态下的船,根本不该按正常航速外推。至少要按航行状态分模型,锚泊状态直接给一个"待定"而不是硬算出一个时间。

最后是误差评估。一个负责任的 ETA 系统应该输出置信区间,而不是一个孤零零的时刻。用历史数据回测,统计不同距离区间的误差分布,把 P50 和 P90 都亮出来,比拍一个准确率数字有用得多。

5.2 异常识别:信号中断、漂航与轨迹突变

AIS 数据里的"异常"主要有几类,处理思路完全不同。

信号中断是最常见的一类。统计同一 MMSI 相邻两点的时间间隔,如果超过某个阈值(比如正常更新间隔的 5 倍),就标记为一个 gap。这里要注意区分原因:远离岸基覆盖、设备故障、以及信道拥塞,都会造成 gap。如果你的数据源覆盖本身就稀疏,gap 会非常多,这时候直接判定为异常是没有意义的。判断依据应该是"在覆盖良好的区域内出现异常 gap"。

漂航指的是船在明显不应该移动的状态下(比如锚泊)产生了长距离位移。这通常是定位误差或者 MMSI 被错误复用导致的。用一个滑动窗口算位移均值,超过阈值就告警。

轨迹突变就是前面提到的跳点。工程上更好的做法不是逐点判断,而是先做一次轨迹拟合,把偏离拟合曲线超过若干倍标准差的点筛出来。这样比固定阈值稳健得多,尤其是不同海域的定位精度本来就差异很大。

补充一句:所有异常检测的输出都应该是"标记 + 置信度",而不是"删除"。原始数据不可变,异常只是一层附属信息,业务侧自己决定要不要用。

5.3 可视化:地图上一万艘船怎么不卡

做过船舶态势看板的人都懂,浏览器渲染一万个点的卡顿是灾难级的。几个实测有效的招:

第一,按缩放层级做聚合。缩到全球视角时,用 H3 或 S2 网格聚合成数量热力图,只显示网格颜色而不是单船图标。放大地图到港口级别,再切换到单船渲染。这个切换逻辑建议在后端做,前端只负责画。

第二,用 WebGL 渲染层。传统的 SVG 标记点几百个就撑不住了,deck.gl 这类基于 WebGL 的图层能轻松扛住十万级点位。代价是学习曲线,但值得。

第三,船位插值要克制。为了让船"平滑移动",很多人做前端补间动画。但如果插值逻辑没处理好,加上数据本身有跳点,船会在海面上来回抽搐。我的做法是:只对置信度高的连续点做插值,遇到 gap 直接让图标"跳"过去,反而更诚实。

第四,静态数据缓存到本地。船名、船型这些信息变化极慢,前端完全可以缓存几小时甚至一天,减少一大半请求量。

6. 我在长期跑这套数据时踩过的坑

6.1 MMSI 不是唯一真值,这是最容易误判的前提

我早期做船舶去重时,天真地以为 MMSI 就是主键。后来发现同一个 MMSI 可能对应不同船名、不同船型,甚至在不同时间段出现在相距上万海里的两个位置。原因有好几种:设备配置错误、MMSI 被冒用、设备更换后未及时更新。所以 MMSI 只能当"航次内的标识",不能当"船舶实体的标识"。

正确的做法是引入一个"船舶档案"概念,用 MMSI + 呼号 + IMO 号多字段做实体匹配,允许一个实体对应多个 MMSI。IMO 号有校验位,可靠性高得多,很多船都配了,能拿到就优先用它做主键。

6.2 那些看起来正常、实际是脏数据的值

有几个字段的"坑值"我要单独拎出来说,因为它们的表现太像正常数据了:

  • 航向 511 表示不可用,不是 511 度。有些解析库不处理,直接存进数据库,后面算角度差全是错的。
  • 船速 102.3 节表示不可用。这个值在数据里出现的频率比你想的高,尤其是停泊的船。
  • 转向率 -128 表示不可用,且这个字段的单位是"度每分钟"而不是"度每秒",还有个非线性的编码方式,用的时候要查表转换。
  • 吃水值 0 表示不可用,不是空载。空载的船吃水也有几米,写 0 的直接当缺失处理。

这些坑的共同点是:协议里定义得清清楚楚,但现实中的解析库十有八九没处理。所以我现在的习惯是,接入任何新数据源,先做一次全字段的取值分布统计,把出现频率异常高的特殊值全揪出来。

6.3 数据源稳定性:免费的东西最贵

最后聊一个心态层面的经验。用免费或低价数据源做生产系统,一定要有"它会挂"的假设。我遇到过数据源突然改接口字段名、突然下调限额、突然停止某个区域的更新的情况。应对方式有三条:一是做数据源抽象层,业务代码不直接依赖某个源的 SDK,换源只改适配器;二是做覆盖度监控,每天统计各区域的活跃船舶数,曲线断崖式下跌就告警;三是自建一份关键区域的兜底数据,比如你关心的几个港口,哪怕只有一个自建站,也能在外部源出问题时保住核心业务。

这套东西我前后迭代了几年,最大的体会是:AIS 数据的难点从来不在"怎么解析报文",那个有文档就能做。真正花时间的是数据质量的持续治理——去重、补全、异常标记、覆盖度监控,这些活没有终点,只有不断收敛。你要做的不是一次做完美,而是把每一层都设计成"可以随时替换和重跑"的样子,这样无论上游怎么变,你都能稳住。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询