跑 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是本船 |
| 2 | 1 | 这条报文总共分成几段 |
| 3 | 1 | 当前是第几段 |
| 4 | 空 | 多段报文的顺序号,单段时留空 |
| 5 | A | 来自哪个信道,A 或 B |
| 6 | 13u?etP... | 载荷,六比特编码后的"乱码" |
| 7 | 0 | 填充位数,最后一个字符用了几个比特 |
| 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 数据的难点从来不在"怎么解析报文",那个有文档就能做。真正花时间的是数据质量的持续治理——去重、补全、异常标记、覆盖度监控,这些活没有终点,只有不断收敛。你要做的不是一次做完美,而是把每一层都设计成"可以随时替换和重跑"的样子,这样无论上游怎么变,你都能稳住。