十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

全球AIS实时与历史数据处理:NMEA解析、清洗与轨迹存储

全球AIS实时与历史数据处理:NMEA解析、清洗与轨迹存储 跑 AIS 数据这行的人多少都经历过这样的时刻屏幕上几千条!AIVDM报文哗哗地刷等你真要去算一条船的到港时间才发现同一艘船在五分钟里出现了六个不同的位置MMSI 还是重复的。船舶自动识别系统数据看起来只是船的位置但真把它当成一门数据处理活来做从报文解析、接收链路、清洗规则到实时与历史两套管道每一步都有自己的一套门道。这篇就把我这些年处理全球 AIS 实时数据与历史数据的完整思路摊开讲一遍——不管你是在做航运态势可视化、港口调度辅助还是单纯想搭一套自己的船舶轨迹库下面这些内容都能直接拿去用。1. AIS报文里到底藏着什么从NMEA语句到可用的船舶轨迹1.1 一条报文的标准骨架AIS 的物理层走的是 VHF 频段两个信道交替工作AIS1 是 161.975 MHz对应信道 87BAIS2 是 162.025 MHz对应信道 88B速率 9600 bps调制方式 GMSK。信道交替的设计不是为了好看而是为了让不同船舶的时隙发射互不打架——这套机制叫 SOTDMA自组织时分多址每艘船在自己的时隙里发一次其他船监听并预约后续时隙。数据到了你的接收机输出的一般是 NMEA 0183 格式的文本行长这样!AIVDM,1,1,,A,13u?etP005l;b0PDWQ5Sg?vN00l,0*5A别被这一串乱码吓到它其实结构很规整用逗号切开逐个看字段序号值含义1!AIVDM语句类型AIVDM是收到的他船报文AIVDO是本船21这条报文总共分成几段31当前是第几段4空多段报文的顺序号单段时留空5A来自哪个信道A 或 B613u?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、3A 类船位报告类型 18、19B 类船位报告。给的是经纬度、对地航速 SOG、对地航向 COG、真航向、航行状态、转向率。这是轨迹的本体。静态信息类型 5A 类静态与航次数据类型 24B 类静态数据报告分 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 数据的难点从来不在怎么解析报文那个有文档就能做。真正花时间的是数据质量的持续治理——去重、补全、异常标记、覆盖度监控这些活没有终点只有不断收敛。你要做的不是一次做完美而是把每一层都设计成可以随时替换和重跑的样子这样无论上游怎么变你都能稳住。
返回列表