做海事数据这行久了,最常被问的一句话就是:AIS数据到底难在哪儿,不就是一堆带经纬度和时间的点吗?说实话,第一次接入AIS(船舶自动识别系统)实时数据流的时候,我也这么想。真正把全球AIS实时及历史数据接进来、清洗干净、存下来、还能被业务方秒级查到之后,才发现这个领域的坑几乎全在细节里:同一条船同一秒被七个岸站重复收到、报文里的91.0坐标其实是"位置不可用"的占位符、Type 5静态报文永远分段到达、历史表跑到三十亿行之后一个轨迹回放查询能扫全表。这篇东西不打算讲什么宏大叙事,就是把我在AIS数据接入、解析、存储、查询、应用这一整条链路上的实操经验摊开来讲——从一条NMEA原始报文怎么变成数据库里一行可用记录,到几十亿行历史数据怎么做到秒级响应。不管你是刚开始做船舶轨迹可视化的初学者,还是已经在维护一个海事数据平台的老手,应该都能从里面捡到几个能直接抄走的配置和规则。
1. AIS数据到底是什么:先把原始报文看明白
很多人一上手就去调第三方API拿JSON,结果遇到数据对不上的时候完全没有排查能力,因为根本不知道上游那条船到底发了什么。所以第一节我不讲架构,先讲报文本身。这是后面所有清洗规则和存储设计的依据,跳过这一步,后面全是玄学。
1.1 船端是怎么把位置发出来的:VHF与TDMA的底层逻辑
AIS工作在VHF海事频段,具体是两个信道:161.975 MHz和162.025 MHz,业内一般叫AIS1和AIS2(对应信道87B和88B)。这两个信道的带宽是25 kHz,物理层用的是GMSK调制,速率9600 bps。注意这个速率——比你家里随便一根网线慢几个数量级,所以AIS协议设计的第一原则就是"话要短、要抢着说"。
为了让几千条船在同一个海域不互相淹没,AIS用了TDMA(时分多址)。把一个分钟切成2250个时隙,两个信道合起来就是4500个时隙每分钟,每个时隙26.0417毫秒。Class A设备用的是SOTDMA,也就是自组织时分多址,船会自己"预约"未来要用的时隙,并且把这个预约信息广播出去,让邻居知道"这个格子我占了"。Class B设备用的是CSTDMA,先监听再发送,优先级更低。这就是为什么Class B的船位更新明显比Class A稀疏。
提示:理解了TDMA机制,你就能明白为什么两条船在同一时刻同一位置,Class B的船位会明显少一大截。这不是数据源的问题,是协议设计如此。做轨迹回放时对Class B船只做插值,心里要有数。
发送频率是很多人第一次接触AIS时会算错的点。Class A在航状态下,如果航速低于14节,位置报文的间隔是10秒;航速在14到23节之间变成6秒;超过23节是2秒。如果船在转向(航向变化超过阈值),间隔会临时缩短到3.33秒。锚泊或者系泊状态下,间隔拉长到3分钟。Class B统一是30秒(航速低于2节时3分钟)。
算一笔账:假设全球同时在线的Class A船舶有20万条,平均更新间隔按20秒估算,那每秒产生的报文大概是 200000 ÷ 20 = 10000 条。一天下来就是 10000 × 86400 ≈ 8.6亿条。这个数字是你后面所有存储预算的起点,我见过太多团队一开始按"几千条船"估算容量,上线三个月磁盘就爆了。
1.2 报文类型决定业务价值:Type 1/5/18/24各自能干什么
AIS报文按ITU-R M.1371标准分了很多类型,实际工程里高频出现的就那么几种。我把它们整理成一张表,方便你对着自己的业务需求挑。
| 报文类型 | 名称 | 关键字段 | 更新频率 | 业务价值 |
|---|---|---|---|---|
| Type 1/2/3 | Class A位置报告 | MMSI、经纬度、SOG、COG、艏向、航行状态、ROT | 2~10秒 | 轨迹主干,所有实时应用的基础 |
| Type 5 | Class A静态与航次数据 | 船名、呼号、IMO号、船型、尺寸、吃水、目的港、ETA | 6分钟一次 | 船舶档案、航线意图分析 |
| Type 18 | Class B位置报告 | 经纬度、SOG、COG、艏向 | 30秒 | 小型船、渔船、游艇覆盖 |
| Type 19 | Class B扩展数据 | 在18基础上加船名和船型 | 30秒 | 免去关联Type 24 |
| Type 24 | Class B静态数据 | 船名、船型、呼号、尺寸 | 6分钟一次 | Class B船舶的档案补全 |
| Type 21 | 助航设备报告 | 浮标、灯塔的位置和名称 | 3分钟 | 航道设施、虚拟航标 |
| Type 27 | 远程AIS广播 | 粗粒度位置 | 3分钟 | 卫星AIS的远洋覆盖补充 |
这里有个关键认知:位置和身份是分开传的。Type 1里只有MMSI这个9位数字身份标识,没有船名。船名要到Type 5或Type 24里才有,而且6分钟才发一次。所以你在做实时看板时,如果只接了位置报文,看到的就是一堆光秃秃的数字;要做"显示船名",就必须维护一张MMSI到静态信息的映射表,并且考虑到船会改名、换呼号、换船东。
Type 5里有几个字段特别值得挖。一个是吃水(Draught),单位是0.1米,这个对判断船舶是否满载非常有用;一个是目的港(Destination),是船长手动输入的字符串,格式极其自由,同一个上海港可能被写成"SHANGHAI"、"CN SHA"、"SHANG HAI"、"上海"乃至"FOR ORDERS",做目的地分析前必须先做标准化。还有一个是ETA,年月日时分各字段独立编码,经常出现月份填0、日期填0的废数据,需要过滤。
1.3 岸基、卫星两张网的覆盖差异与拼接口径
AIS数据来源其实分两大类,理解它们的差异对数据质量判断至关重要。
岸基AIS是沿海岸线架设的接收站,覆盖范围一般在视距内,天线架得高、海况好的情况下能到40到60海里,通常也就是70到110公里。岸基数据的优点是更新频率高、延迟低(秒级到十几秒)、数据完整度好;缺点是一旦船开出去就断,远洋完全没覆盖。
卫星AIS靠低轨卫星搭载接收机在轨接收,能覆盖全球远洋。但它有两个硬伤:一是卫星过顶才有机会收到,同一条船可能几十分钟甚至几小时才有一条记录;二是同一时刻多个信号同时到达会产生严重碰撞,实际解码率在繁忙海域可能只有一二十个百分点。很多商业卫星AIS数据在远洋的更新间隔是分钟级甚至十分钟级,做密接分析基本不够用。
工程上常见的做法是分海域采用不同策略:沿海和港口区域以岸基数据为主,做秒级实时监控和精细轨迹;远洋区域用卫星数据做宏观流向和到达时间预测,并在数据表里打上source_type字段(terrestrial / satellite),让下游的算法能区别对待。这个字段千万别省,我见过因为混在一起导致速度异常告警天天炸的项目——卫星数据的时间间隔大,算出来的平均航速看起来正常,但连续两条记录之间的瞬时速度会离谱,因为中间丢了几小时的位置。
1.4 必须接受的前提:AIS不是GPS真值
这一点我一定要反复强调。AIS是船舶自愿或依法上报的数据,不是雷达测的,更不是权威测绘数据。它的误差来源包括:
- 船端GPS本身的误差,以及位置字段被量化成1/10000分(约0.185米)的精度损失;
- 位置精度标志位(Position Accuracy)明确告诉你这是GPS还是DGPS;
- 坐标
91.0和181.0是"位置不可用"的约定值,不是真的在那个位置; - 存在设备故障、天线遮挡、以及人为填报错误;
- 极端情况下存在身份伪造,MMSI可以被随意配置。
所以任何基于AIS的下游应用,都必须先过一遍质量规则。把纬度=91、经度=181、纬度=0且经度=0、超出合理经纬度范围的点直接丢掉,这一步能消掉相当比例的脏数据。我在一个东南亚港口项目里统计过,原始流里大约有0.3%到1.5%的报文属于位置不可用或明显越界,海域越繁忙这个比例越高。
2. 数据源选型:免费、商业、自建三本账
搞清楚报文之后,第二个绕不开的问题就是数据从哪来。这块我踩的坑最多,因为"看起来免费"和"实际上能用"完全是两件事。
2.1 免费与半免费渠道的真实可用性评估
公开渠道确实存在一些可以获取AIS数据的入口,常见形式包括社区共建的接收网络、部分机构开放的科研数据集、以及一些众包平台。它们的特点很一致:
- 覆盖不均。欧美沿海密度极高,某些区域甚至能看到几百个接收站;而非洲、南美部分海域、太平洋岛屿几乎是空白。如果你做的是全球业务,免费源只能当补充。
- 更新有延迟。不少平台的公开数据是分钟级甚至十几分钟级的快照,不是原始秒级流。
- 有调用限制。多数平台对免费账号限制每小时请求次数、限制可查询的时间跨度(比如只能查最近24小时)、限制轨迹点数量上限。
- 字段被裁剪。只给你MMSI、经纬度、时间、船名,Type 5里的吃水、目的港、ETA经常拿不到。
我一般的建议是:免费源适合做原型验证和技术选型,比如你想试试轨迹压缩算法、想跑通一个可视化Demo,用它完全够。但一旦要上生产,尤其是有SLA要求的场景,免费源的稳定性会成为长期拖累。
2.2 商业API与历史数据采购的计费口径
商业AIS数据服务商的计费方式五花八门,常见的几种口径你得看懂:
| 计费口径 | 典型形式 | 隐藏成本 | 适合谁 |
|---|---|---|---|
| 按API调用次数 | 每万次请求计费 | 轮询式拉取会快速烧钱 | 低频查询、单船追踪 |
| 按船舶数订阅 | 每条船每月固定费用 | 船队变动需重新计费 | 固定船队监控 |
| 按数据量(GB) | 按流量或记录条数 | 压缩方式影响实际用量 | 大数据分析 |
| 按历史区间买断 | 一次性购买某年数据 | 增量更新可能另收费 | 科研、模型训练 |
| 按海域圈选 | 指定地理围栏 | 围栏边缘重复计费 | 区域港口业务 |
这里有两个坑我踩过。第一个是轮询陷阱:有的接口只能"查询当前状态",你想做实时监控就得每秒轮询,假设每条船每秒一次,1000条船一天就是8640万次调用,按万次计费的单价算下来一个月能烧掉一辆车。正确做法是优先选支持长连接推送(TCP流、WebSocket、Kafka)的服务,或者用批量查询接口一次拿一个区域的快照。
第二个是历史数据的"时钟口径"。采购历史数据前一定要问清楚:时间戳是卫星/岸站的接收时间还是船端报文里自带的UTC秒?两者可能差几秒到几十秒,做多源融合时会打架。还要问清楚去重是不是做过了、原始报文有没有保留。我个人强烈建议选能拿到原始NMEA报文的方案,哪怕贵一点。原因很简单:解析规则会更新,字段含义会因为ITU标准修订而变化,只有保留原始串,你才有重新解析的机会。只给你JSON的供应商,等于把解释权捏在他们手里。
2.3 自建接收站的成本与维护现实账
如果业务范围集中在某个港口或某段海岸线,自建接收站其实是个很划算的选择。硬件清单大概是:
- AIS接收机(双信道),常见的入门型号几百到两千元;
- VHF天线,增益6到9 dB的全向天线,几百元;
- 馈线,这个别省,损耗直接吃掉你的覆盖半径;
- 架设点位的租金或协调成本,这一项往往最贵;
- 一台低功耗工控机或树莓派级别的边缘设备;
- 稳定的网络接入。
总投入可以控制在几千到几万元人民币级别。覆盖半径的估算可以用视距公式:d ≈ 4.12 × (√h1 + √h2),其中d单位是公里,h1、h2是两端天线高度,单位是米。假设你的天线架在50米高的楼顶,船上天线按20米算,那么d ≈ 4.12 × (√50 + √20) ≈ 4.12 × (7.07 + 4.47) ≈ 47.5公里。实际因为大气折射和多径,通常还能再远一些,但海况差的时候会缩水。
自建站的真正难点不在硬件,在于稳定运行。我在一个项目里部署过四个接收站,两年下来遇到的故障包括:馈线接头进水导致信噪比骤降、雷击打坏接收机、边缘设备SD卡写坏、运营商网络波动导致推流中断。所以我后来固定了一套做法:边缘端做本地缓冲(断网时先写本地文件,恢复后补传)、加UPS、用只读文件系统配合日志分区、所有站点的健康状态上报到一个统一面板。
2.4 选型决策速查
把上面的内容压缩成一条决策路径,你可以直接对着自己的场景选:
- 只是做Demo、做算法验证、学习AIS解析 → 免费/半免费源;
- 固定船队(几十到几百条)、需要档案和历史轨迹 → 商业订阅,优先选支持推送和原始报文的;
- 港口/近岸区域业务、数据敏感、要求低延迟 → 自建接收站 + 商业源兜底;
- 全球远洋分析、宏观流向研究 → 商业卫星AIS + 岸基源拼接,接受分钟级更新间隔。
注意:不要幻想"一个数据源解决所有问题"。生产环境的标配是多源融合,把岸基、卫星、商业API三个来源打在同一个数据模型里,用source_type区分,用优先级规则做融合决策。
3. 报文解析与清洗:从比特流到能用的记录
拿到原始流之后,第一道工序是解析。这部分看着机械,实际上决定了你后面80%的数据质量。
3.1 NMEA封装与六位ASCII解码
AIS的原始数据外面套了一层NMEA 0183的壳,长这样:
!AIVDM,1,1,,A,13u?etPv2;0n:dDPwUM1U1Cb069D,0*24字段依次是:句子类型(!AIVDM是其他船,!AIVDO是本船)、分片总数、当前分片序号、分片消息ID(多分片时用于关联)、AIS信道(A或B)、载荷、填充位数、校验和。
载荷里的字符不是普通ASCII,而是"六位ASCII装甲"(six-bit ASCII armoring)。解码规则是:取字符的ASCII码,减48;如果结果大于40,再减8。这样把可打印字符映射回0到63的六位值。举个例子,字符1的ASCII是49,49-48=1,得到6位值1;字符W的ASCII是87,87-48=39,39不大于40,所以是39。
得到一串6位值之后,把它们按位拼成一个大位串,再按照ITU标准定义的字段位置去切片。比如Type 1报文的第0到5位是消息类型,第8到37位是MMSI,第61到88位是经度,第89到115位是纬度。
提示:填充位(Fill Bits)处理是新手最容易翻车的地方。最后一个字符可能只用了高几位,低几位是填充的。如果不按
fill_bits截掉,后面的字段会整体错位,解出来的经纬度会飘到一个完全无关的位置。
这里给出一个Python的六位解码核心片段,可以直接用:
def sixbit_to_bits(payload: str, fill_bits: int = 0) -> str: """把AIS载荷解码成位串""" bits = [] for ch in payload: v = ord(ch) - 48 if v > 40: v -= 8 if not (0 <= v <= 63): raise ValueError(f"非法字符: {ch}") bits.append(format(v, '06b')) s = ''.join(bits) return s if fill_bits == 0 else s[:-fill_bits] def get_uint(bits: str, start: int, length: int) -> int: return int(bits[start:start + length], 2) def get_int(bits: str, start: int, length: int) -> int: """AIS中的有符号数是二进制补码""" v = get_uint(bits, start, length) if v & (1 << (length - 1)): v -= (1 << length) return v经纬度的解码要特别注意:经度占28位、纬度占27位,单位是1/10000分。所以解码出来要先除以600000才是度。有符号数用补码,经度范围-180到180,纬度-90到90。
def decode_pos(bits: str): lon_raw = get_int(bits, 61, 28) lat_raw = get_int(bits, 89, 27) lon = lon_raw / 600000.0 lat = lat_raw / 600000.0 return lat, lon3.2 多分片重组:Type 5为什么总是缺
Type 5静态报文有424位,超过一个NMEA句子的承载能力,所以会被拆成两个分片,分片序号是1和2。你的解析器必须维护一个短暂的缓冲区,按分片消息ID + 信道作为key,等到两个分片都到齐再拼起来。
现实是,两个分片经常只有一个能收到。原因可能是信号衰落、时隙冲突、接收机丢包。经验数据是,在信号一般的海域,Type 5的完整率可能只有60%到80%。这就带来一个问题:你的船舶档案会残缺。
我的做法是维护一张慢变维表。收到任何一个完整的Type 5就更新对应MMSI的档案,并且记录last_static_update时间。即使某次更新丢了,下次船再发(6分钟一次)就能补上。对于长时间没更新过档案的MMSI,就用历史快照兜底。这张表不要用宽表设计,用"MMSI + 生效时间 + 失效时间"的SCD2结构,这样才能回答"这条船在2023年5月叫什么名字"这种问题——因为船改名是常态,尤其是二手船交易之后。
3.3 去重、异常判定与坐标护栏
原始流里最普遍的问题是重复。同一条船的同一帧报文,可能被同一边缘节点的多个接收站收到,也可能被不同节点重复上报。去重的key怎么选很关键,直接用MMSI + 时间戳不够,因为不同来源的接收时间不一样。
我的经验做法是分两层:
- 第一层,按
MMSI + 报文内UTC秒 + 纬度原始值 + 经度原始值去重,这个能干掉同一帧被多站接收的重复。 - 第二层,按
MMSI + 接收时间(截断到秒)+ 消息类型去重,处理同一路径上的重复推送。
然后是异常判定,我固定了几条规则:
| 规则 | 判定条件 | 处理方式 |
|---|---|---|
| 坐标占位符 | 纬度=91.0 或 经度=181.0 | 直接丢弃 |
| 零坐标 | 纬度=0 且 经度=0 | 直接丢弃 |
| 越界 | 经度超出[-180,180]或纬度超出[-90,90] | 直接丢弃 |
| 陆地位置 | 落在陆地上且非内河 | 标记,降权不丢 |
| 速度异常 | SOG > 102.2节(1023/10是无效值) | 视为不可用 |
| 时间倒流 | 同一MMSI时间戳回退超过阈值 | 丢弃旧记录 |
| 位置跳变 | 推算速度超过60节 | 标记为可疑,不参与平滑 |
关于陆地位置这条我要多说一句。AIS的坐标精度在±0.185米量级,靠泊时有些船确实会"压在"码头线的陆侧。硬性过滤会把大量靠泊数据干掉,反而影响港口作业分析。所以我的建议是标记而不是删除,用一个on_land布尔字段,让下游按需过滤。
3.4 时间口径统一:接收时间才是你的主轴
这是我在两个项目里都上过当的地方。AIS报文里的UTC秒字段只有秒,没有年月日时分,你需要用接收时间补齐。问题是:接收时间可能来自不同的服务器、不同时区、甚至是不同节点。
我最终的方案是:统一用数据接入网关的UTC毫秒时间戳作为主时间轴(event_time),把报文里的UTC秒单独存成一列(ais_second),两者都保留。这样做的价值在于:
- 报文里那一刻可能因TDMA时隙分配而存在微小偏差,ais_second更接近船端的真实时刻;
- 网关接收时间更接近数据可用时刻,用来计算端到端延迟;
- 做时间窗口聚合时用event_time,避免乱序导致窗口反复触发。
如果只保留一个时间列,后期想排查"为什么两个来源的同一条船轨迹错开了20秒"就完全没有抓手。
4. 实时链路工程实现:从TCP流到可查询的热数据
实时数据的核心诉求是"低延迟"和"不丢",但这两个目标经常冲突。我的原则是:宁可延迟几百毫秒,也不要用同步阻塞换低延迟。
4.1 接入层的连接管理与背压控制
大多数商业AIS流的接入方式就是一个长期的TCP连接,服务端按行推送NMEA句子。这一层要做好几件事:
断线重连用指数退避,起始1秒,最大60秒,加随机抖动,避免服务端重启时几百个客户端同时重连把它打死。
心跳与超时要有,超过60秒没收到任何数据就主动重连。我遇到过一次运营商网络"假连接"——TCP连接还在,但数据一个字都不来,如果没有超时检测,链路会静默挂掉好几个小时。
本地缓冲是关键。消费速度会因为数据库抖动、GC、网络拥塞而短时下降,如果这时还在从socket源源不断读数据,内存会涨上去,最后OOM。做法是设一个有界队列(比如10万条),队列满了就落盘或者直接丢弃低优先级消息类型。丢什么也是有讲究的:位置报文Type 1丢了影响小,下一个几秒后就到;Type 5丢了要等6分钟,所以Type 5应该优先保留。
import time import random import socket from collections import deque class AisStreamClient: def __init__(self, host, port, buffer_size=100_000): self.host, self.port = host, port self.buf = deque(maxlen=buffer_size) self.backoff = 1.0 self.max_backoff = 60.0 def connect_loop(self): while True: try: sock = socket.create_connection((self.host, self.port), timeout=30) sock.settimeout(60) self.backoff = 1.0 self._read_loop(sock) except Exception as exc: wait = min(self.backoff, self.max_backoff) wait += random.uniform(0, wait * 0.3) print(f"连接异常 {exc},{wait:.1f}s 后重试") time.sleep(wait) self.backoff = min(self.backoff * 2, self.max_backoff) def _read_loop(self, sock): while True: raw = sock.recv(65536) if not raw: raise ConnectionError("对端关闭连接") for line in raw.decode('ascii', errors='ignore').splitlines(): if line.startswith('!AIVDM') or line.startswith('!AIVDO'): self.buf.append(line)4.2 消息队列分区设计:为什么不能只按MMSI分区
解析之后的记录要进消息队列。常见的选型是Kafka,用量小一点可以用Redis Stream或者NATS。
分区策略是个技术活。按MMSI哈希分区能保证同一条船的消息有序,这对轨迹拼接非常有利。但问题来了:全球船舶的活跃度分布极度不均,某些海区的船极其密集,如果MMSI哈希函数不够均匀,会出现明显的热点分区,某个broker的负载是其他broker的好几倍。
我的折中方案是双主题:
- 主题A按MMSI哈希分区,服务轨迹类消费(需要单船有序);
- 主题B按地理网格(S2或H3)分区,服务区域统计类消费(需要空间局部性)。
数据从解析层同时写两个主题,消费端各取所需。存储成本上升了,但避免了所有消费场景挤在一种分区逻辑上互相迁就。如果预算紧张,至少要把分区数设置为broker数量的整数倍,并且用足够均匀的哈希(不要用Java的默认hashCode,用MurmurHash3或者直接对MMSI取模)。
4.3 热数据落库与在线查询
实时数据要能"秒查最近位置",这个需求用关系库硬扛是不行的。我的架构一般是三层:
- 最新位置缓存:Redis,key是
ais:pos:{mmsi},value是一个紧凑的哈希,存经纬度、速度、航向、时间戳。查询单船最新位置时直接读Redis,耗时在1毫秒级。同时可以按地理网格再写一份ais:grid:{s2cell}的集合,用于"某个区域当前有哪些船"。 - 热数据写入:从队列批量写入时序库,攒批策略是"5000条或者1秒,谁先到写谁"。批量写入能把写入吞吐提升十几倍,代价是最多1秒的延迟。
- 查询回退:Redis里没命中(船挺久没发消息了),回退到时序库查最近N分钟。这里要设一个兜底超时,避免慢查询拖垮接口。
写入语句示例(以PostgreSQL + TimescaleDB为例):
INSERT INTO ais_position ( mmsi, ts, geom, sog, cog, heading, nav_status, rot, source_type, on_land ) VALUES ( %(mmsi)s, to_timestamp(%(ts)s / 1000.0), ST_SetSRID(ST_MakePoint(%(lon)s, %(lat)s), 4326)::geography, %(sog)s, %(cog)s, %(heading)s, %(nav_status)s, %(rot)s, %(source_type)s, %(on_land)s ) ON CONFLICT (mmsi, ts, source_type) DO NOTHING;那个ON CONFLICT DO NOTHING很重要,配合唯一索引能兜住队列重平衡、消费重试带来的重复写入。别指望上游一定送准一次,分布式消息系统在极端情况下就是会重复。
5. 历史数据存储:几十亿行怎么查得不卡
历史数据是AIS项目里真正烧钱和烧脑的部分。表一旦过了十亿行,之前所有的"能跑"都会变成"不能跑"。
5.1 容量估算与存储选型
先做容量估算,这是所有设计的前提。假设全球日均8.6亿条位置报文,每条记录去掉原始报文后的结构化字段包括:MMSI(4字节)、时间戳(8字节)、经纬度(16字节,用double双精度)、速度航向等(8字节)、来源和其他标记(4字节),合计约40字节。
- 未压缩:8.6亿 × 40字节 ≈ 34.4 GB/天,一年约12.6 TB;
- 列式压缩(ZSTD,时序数据通常有8到12倍压缩比):约3.5到4.3 GB/天,一年约1.3到1.6 TB;
- 如果保留原始NMEA报文(平均每条约60字节),再加约51 GB/天的原始量,压缩后大概再加5到8 GB/天。
结论很清楚:必须用列式存储或者针对时序优化的引擎,行存关系库在AIS场景下基本是自杀。常见选型对比:
| 方案 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| TimescaleDB | 兼容SQL、生态好、时空函数齐全 | 单机写入有上限,扩展要加节点 | 中小规模、需要复杂SQL |
| ClickHouse | 写入吞吐极高、聚合快、压缩比好 | 单条更新弱、join能力有限 | 大规模分析、报表 |
| Parquet + 对象存储 | 成本极低、和Spark生态天然契合 | 随机查询延迟高 | 冷数据归档、离线训练 |
| Elasticsearch | 聚合灵活、可视化方便 | 存储成本高、时序场景不是强项 | 日志型检索、小规模 |
我个人的组合拳是:热数据(最近7到30天)放ClickHouse或TimescaleDB,温数据(1年内)放压缩过的列存,冷数据(1年以上)转Parquet放对象存储。这一套下来,1年的成本能控制在很舒服的范围里。
5.2 分区、排序键与索引设计
ClickHouse的话,建表语句大概是这样:
CREATE TABLE ais_position ( mmsi UInt32, ts DateTime CODEC(Delta, ZSTD(1)), lon Float32 CODEC(Gorilla, ZSTD(1)), lat Float32 CODEC(Gorilla, ZSTD(1)), sog UInt16, cog UInt16, heading UInt16, nav_status UInt8, source_type UInt8 ) ENGINE = MergeTree PARTITION BY toYYYYMM(ts) ORDER BY (mmsi, ts) TTL ts + INTERVAL 24 MONTH DELETE;几个关键点解释一下:
ORDER BY (mmsi, ts)决定了数据在磁盘上的物理排序,也就是主键索引。把MMSI放前面,是假设最高频的查询是"查某条船在某个时间段的轨迹"。这个顺序让单船轨迹查询只需要扫描极少的part。如果你的主要查询是"某个区域在某个时间段有哪些船",那排序键应该反过来考虑,比如ORDER BY (geohash, ts)。
PARTITION BY toYYYYMM(ts)按月分区。分区数量要控制,ClickHouse的建议是单表分区数别超过1000,按月分一年12个,非常安全。分区太细(比如按天)会导致合并压力大、文件句柄多。
CODEC的选择对压缩比影响巨大。时间戳用Delta(存差值),浮点经纬度用Gorilla(专为浮点时间序列设计),都能明显压下去。我实测过同一份数据,不加CODEC是780 GB,加上之后降到92 GB,差不多8.5倍。
还有一个必须做的动作:把MMSI从字符串改成整数。原始数据里它是9位字符串,看着无害,但30亿行的时候,字符串和整数的存储差距和比较效率差距非常明显。转换时注意前导零,012345678这种要正确处理。
5.3 轨迹抽稀与历史查询加速
即使是时序库,查一条船半年的轨迹也可能是几十万个点,前端渲染不动,接口也慢。这时候需要抽稀。
常用的抽稀算法有几种:
- Douglas-Peucker:按几何形状保真,用垂直距离作为误差度量。适合做地图展示,但对时间维度不敏感,直航段可能被压成一个点,丢失了时间信息。
- SED(Spatial-Error-Distance):同时考虑空间距离和时间间隔,更适合轨迹回放。
- 固定时间窗口聚合:最简单粗暴,比如每5分钟取一条,用窗口内第一个或平均值。实现成本最低,效果对大多数业务够用。
我的实践是分层存储:原始点全量保留在冷数据里,同时生成几套降采样版本——
| 层级 | 采样间隔 | 用途 | 数据量占比 |
|---|---|---|---|
| L0 | 原始(2~10秒) | 精细行为分析、靠泊检测 | 100% |
| L1 | 1分钟 | 常规轨迹回放 | 约15% |
| L2 | 15分钟 | 宏观流向、跨洋航线 | 约1% |
| L3 | 进出港事件点 | 港口统计、航次重建 | 约0.1% |
L3是我特别想推荐的一层。它不是简单的时间采样,而是语义事件:什么时候进入某个港区、什么时候离开、什么时候从在航变成锚泊。这层数据量极小,但能支撑绝大多数业务报表。生成方式是用地理围栏(GeoFence)配合状态机:船进入围栏且速度低于阈值持续N分钟 → 记一条"到港"事件。
关于空间索引,如果用的是PostgreSQL,可以用PostGIS的GiST索引配合geography类型,配合ST_DWithin做半径查询。如果是ClickHouse,可以用geohash前缀做粗筛,再在应用层做精确距离过滤。注意geohash的边界问题——两个相邻格子的点可能实际很近,但前缀完全不同,所以查询时要把周围8个邻居格子一起查。
6. 典型应用场景与落地拆解
数据链路打通之后,能做的事其实非常多。挑三个我实际做过、且投入产出比最高的场景展开讲。
6.1 港口拥堵监测与到港时间预测
这是AIS商业化最成熟的场景之一。核心逻辑是:
第一步,定义锚地和泊位的围栏。锚地围栏覆盖港口外待泊区域,泊位围栏覆盖码头岸线。围栏用GeoJSON定义,加载成内存中的空间索引。
第二步,识别船舶状态。结合AIS里的航行状态字段(nav_status,0表示在航、1表示锚泊、5表示系泊)和实际速度。经验规则是:SOG小于0.5节且nav_status为1或5,持续15分钟以上,判定为真正停泊。为什么不用nav_status单独判断?因为船长忘记改状态的情况太常见了,我见过整条船靠在泊位上,状态还是"在航"。
第三步,计算排队指标。常用指标有:
- 锚地船舶数(实时排队长度);
- 平均待泊时长(船进入锚地到进入泊位的时间差,取近7天滚动中位数);
- 泊位占用率(泊位内有船的时间占比)。
第四步,预测到港时间。一个简单但效果不错的公式是:
ETA = 当前时间 + 剩余距离 / 有效航速 + 预期待泊时长其中剩余距离用大圆距离或沿着航道折线计算,有效航速取最近1小时的中位SOG(不用瞬时值,抖动太大)。这个方案的误差在6到24小时范围内通常能控制在2到4小时。要做得更准,可以把"预期待泊时长"换成基于历史数据的回归模型,特征包括船型、载重吨、船公司、当前排队长度、季节性因素。
实操心得:ETA预测里最容易被忽略的是船在锚地内的漂移。很多船锚泊时因为走锚或者调整锚链,位置会来回移动几海里。如果你用"到锚地中心的距离"来算剩余距离,就会出现"越走越远"的诡异曲线。解决办法是把锚地区域整体视为一个"到达即停"的节点,一旦判定进入锚泊状态,剩余距离直接归零,剩下的时间交给待泊时长模型。
6.2 异常行为识别:数据缺口、漂移与绕行
AIS在风险控制领域的价值被严重低估。几个可用的信号:
信号一:AIS数据缺口。一条船在连续航行中突然消失几小时又出现,且出现位置和消失位置的推算距离超出合理范围。这需要区分是设备故障、卫星覆盖间隙还是人为关闭。判定规则可以设计成:缺口时长超过30分钟,且缺口期间不在已知的卫星覆盖盲区,且该船在缺口前后的航向航速连续,则标记为高疑。这个信号在渔业监管、船舶合规领域有实际应用。
信号二:锚地漂移。前面提过,正常的锚泊位置变化应该在几百米内。如果一条船声称锚泊但位置在一个小时里移动了3海里,那可能是走锚,也可能是状态填报错误。这个信号对港口安全管理有价值。
信号三:绕行与异常停留。船的航线偏离了历史常规路径,或者在中途某个非常规位置停留超过阈值。计算方法是用历史轨迹聚类出"常规航路带",新轨迹的偏离距离超过阈值就告警。这里要注意,用聚类做出来的航路带对潮汐、季节、吃水都很敏感,误报率不低,务必要加上人工确认环节。
实现上我用的是滑动窗口 + 状态机,而不是复杂的机器学习模型。原因是可解释性远比精度重要——每一条告警都要能给出"因为在X时刻位置突变Y海里、推算速度Z节"这样的证据链,业务方才敢用。
6.3 数据接口设计:让下游用得舒服
这一节虽然不属于AIS技术本身,但对项目成败影响很大。AIS数据的下游消费方往往是不懂时序数据的业务开发,接口设计得不友好,最后所有问题都会回到你这里。
我的接口设计习惯:
- 单船最新位置:
GET /vessel/{mmsi}/latest,走Redis,返回毫秒级,字段做扁平化,不要嵌套太深。 - 单船轨迹:
GET /vessel/{mmsi}/track?from=&to=&level=L1,用level参数控制抽稀层级,默认给L1,需要精细时显式请求L0。这一点非常关键,默认返回L0会把接口打垮。 - 区域快照:
POST /area/snapshot,body里传多边形和时间点,返回该区域内的船列表。内部用空间索引过滤,限制返回条数上限(比如5000),超过了就返回一个"结果已截断"的标记。 - 事件流:
GET /events?type=arrival&port=X&since=,基于L3事件表,查询极快。
所有接口都要有超时和限流。我习惯按调用方下发配额,而不是全局一刀切,因为不同消费方的使用模式差别太大。另外,返回值里带上数据的时间戳和来源,让下游能自己判断数据新鲜度,这比你在文档里写十遍"数据可能有延迟"都有效。
7. 常见问题与排查手册
这一节整理的都是我在值班和排障过程中反复遇到的,做成速查表,遇到问题时可以直接对照。
| 现象 | 可能原因 | 排查路径 | 处理办法 |
|---|---|---|---|
| 某片海域数据整段消失 | 接收站掉线、运营商网络故障、上游限流 | 看接入层心跳日志、比对多源数据 | 切备用源、检查边缘设备网络 |
| 轨迹出现"瞬移" | 原始报文未去重、坐标占位符未过滤 | 抽样看原始NMEA、检查经纬度原始值 | 补齐过滤规则、加唯一索引去重 |
| 同一MMSI两条轨迹并行 | MMSI冲突(两船配置了同一个号) | 按MMSI聚合后看是否同时出现两个相距很远的点 | 用"MMSI + 地理连续性"做二次拆分,标记可疑 |
| 船名全空 | Type 5未完整接收、静态报文解析字段偏移 | 查Type 5完整率、检查位偏移是否按ITU最新版 | 加大维表缓存、核对字段表 |
| 查询越来越慢 | 分区过多、排序键不匹配查询模式、缺索引 | 看执行计划、看扫描的part数量 | 调整ORDER BY、加物化视图、做降采样 |
| 写入延迟高 | 单条写入、批量太小、锁竞争 | 看写入QPS和批次大小 | 攒批到5000条或1秒、关掉同步提交 |
| 磁盘爆满 | 未设TTL、压缩没配、原始报文全量留存 | 看各表占用、看压缩比 | 加TTL、上CODEC、冷数据转对象存储 |
| 卫星数据速度异常 | 采样间隔大导致瞬时速度计算失真 | 按source_type分开统计 | 计算速度用间隔大于阈值的点,或直接对卫星数据不算速度 |
几个补充的技巧:
技巧一:永远保留原始报文,并且给它一个独立的、可以按天淘汰的生命周期。我一般保留30天的原始NMEA,够用来回溯排查解析问题,再长就是浪费钱。
技巧二:给每条记录打上"数据血缘"字段,包括来源节点ID、接入时间、解析器版本。当解析规则升级后,你能明确知道哪些数据是用旧规则解析的,需要重新处理。
技巧三:建立一个"黄金测试集"。挑100到200条有代表性的原始报文(覆盖各种类型、各种边界情况、各种异常值),每次修改解析代码后跑一遍回归。我自己就靠这个集合抓到过一次坐标符号位处理的回归bug——有符号数补码处理写错,导致南半球的船全部跑到北纬去了。
技巧四:监控口径要区分"收到"和"解析成功"。很多人只看接入层的消息数,觉得一切正常,但解析失败率可能已经悄悄涨到5%了。两个指标必须分开监控,并且对解析失败做分类计数(是不认识的报文类型,还是字段越界,还是校验和失败),这样出问题时一眼能定位。
8. 一些实操心得
最后分享几个不太容易在文档里找到、但真的很值钱的经验。
关于成本:AIS项目的成本结构里,数据采购往往不是最大的那项,存储和人力才是。我做过一个粗略的对比,在一个中等规模的项目里,数据采购占25%左右,存储加计算占40%,剩下是研发和运维。所以早期把存储设计做对,比砍数据源的价钱重要得多。尤其是压缩和TTL这两件事,越早做收益越大——上线三个月之后再改,你要面对的是迁移几年的数据。
关于精度预期:不要期待AIS能给你"米级"的定位体验。它的定位精度取决于船端GPS,而且位置字段本身被量化到约0.185米,看着很精细,但实际误差在开阔海面通常在5到15米,港口内因多径和遮挡可能更大。做靠泊分析时要留足容差,别把泊位围栏画得刚好卡在船宽上。
关于Class B船:做渔船、游艇、小型作业船业务的时候,Class B的比重很高,而它们的更新间隔长、静态信息经常缺失、设备质量参差。这类数据的处理策略要和Class A分开——轨迹平滑的窗口要拉长,速度异常的阈值要放宽,不要用同一套规则硬套。
关于数据的"活"与"死":历史数据不是越热越好。我现在的分层策略是热数据留7天、温数据留12个月、冷数据归档保留5年。冷数据用Parquet按天分区,查询时用DuckDB或者Spark直接读,速度对分析类场景完全够,成本能降到热存储的十分之一以下。很多人舍不得把数据挪到冷存储,结果磁盘成本一路涨,其实大部分数据的访问频率在30天之后就断崖式下降了。
关于可解释性:如果你做的AIS应用涉及告警和判断,一定要把原始报文和中间计算结果都留痕。业务方来找你说"这条船为什么被标记异常",你能在30秒内拿出证据,比任何模型精度都更能赢得信任。这套留痕机制从项目第一天就要建,后期补建的成本高得离谱。