超大 GeoJSON 读不动?换行式格式流式处理:NDJSON 与 RFC 8142 实战
--- title: 超大 GeoJSON 读不动?换行式格式流式处理:NDJSON 与 RFC 8142 实战 ---
一个几百 MB 的区县边界 GeoJSON 文件,readFileSync 加 JSON.parse 直接把 Node 进程干趴下,报 heap out of memory。处理全国级边界数据的人多半撞见过这一幕。这不是代码写错了,是标准 GeoJSON 的结构天生不适合流式处理。换成行式变体格式,内存占用就能压到恒定水平,文件多大都一样。
为什么标准 GeoJSON 无法流式解析
标准 GeoJSON 的 FeatureCollection 是一个单一 JSON 对象,features 数组的闭合括号躺在文件最末尾。解析器要读完整个文件才能确认结构完整,所以 JSON.parse 只能把整棵对象树一次性塞进内存。
还有一道更早的关卡:V8 引擎里单个字符串的长度上限约 512MB。文件超过这个体积,连 readFileSync(path, 'utf8') 都过不去,字符串还没构造出来就抛错了,根本轮不到 JSON.parse 出场。
两种行式格式
思路很直接:把"一个大 JSON"拆成"一行一个小 JSON"。可选的方案有两个。
一是 NDJSON 风格(常用扩展名 .geojsonl),每行一个独立的 Feature 对象,行与行用换行符 \n 分隔。文件整体不是合法 JSON,但每一行都是。
二是 RFC 8142 定义的 GeoJSON Text Sequences,媒体类型为 application/geo+json-seq。它在每条记录前加一个 ASCII 记录分隔符 RS(0x1E),记录末尾跟换行符。RS 前缀让解析器能从损坏数据中恢复,代价是文件在普通文本编辑器里看起来会有点怪。
GDAL 的 GeoJSONSeq 驱动两种都认,读取时自动识别,写出时用 RS=YES/NO 选项控制。
用 ogr2ogr 互转
装好 GDAL,标准 GeoJSON 和行式格式的互转各是一行命令:
# 标准 GeoJSON 转行式(默认不带 RS 前缀,即 NDJSON 风格)
ogr2ogr -f GeoJSONSeq quxian.geojsonl quxian.geojson
# 行式转回标准 GeoJSON(给浏览器端用)
ogr2ogr -f GeoJSON quxian.geojson quxian.geojsonl
ogr2ogr 本身就是流式工作的,转换过程内存占用很低。GDAL 工具链在行政区划数据处理中的完整用法,Shapefile 转 GeoJSON 的 ETL 实战里写得更细。
Node.js 逐行处理:内存恒定
拿到 .geojsonl 文件后,用 Node 内置的 readline 模块逐行读,每次内存里只有一个 Feature。下面这段代码从全国区县文件里切出广东子集(adcode 前两位 44 是广东省的省级代码):
import { createReadStream, createWriteStream } from 'node:fs';
import { createInterface } from 'node:readline';
const rl = createInterface({ input: createReadStream('quxian.geojsonl') });
const out = createWriteStream('guangdong.geojsonl');
let kept = 0;
for await (const line of rl) {
if (!line.trim()) continue;
const f = JSON.parse(line);
if (String(f.properties.adcode).startsWith('44')) {
out.write(JSON.stringify(f) + '\n');
kept++;
}
}
out.end();
console.log('保留要素:', kept);
处理 1GB 的文件和处理 10MB 的文件,这段代码的峰值内存几乎没有差别。任何时刻堆里都只驻留当前这一行。
手里只有标准 GeoJSON、又不想装 GDAL 怎么办
npm 上的 stream-json 库可以对标准 GeoJSON 做增量解析,配合 pick 过滤器直接定位到 features 数组,逐个吐出 Feature:
const { createReadStream } = require('node:fs');
const { chain } = require('stream-chain');
const { parser } = require('stream-json');
const { pick } = require('stream-json/filters/Pick');
const { streamArray } = require('stream-json/streamers/StreamArray');
const pipeline = chain([
createReadStream('quxian.geojson'),
parser(),
pick({ filter: 'features' }),
streamArray(),
]);
let count = 0;
pipeline.on('data', ({ value }) => {
// value 就是一个完整的 Feature 对象
count++;
});
pipeline.on('end', () => console.log('要素总数:', count));
V8 字符串上限在这里管不着,因为文件从头到尾都以小块 Buffer 的形式流过解析器,从未拼成一个完整字符串。代价是解析速度比原生 JSON.parse 慢。一次性任务够用,高频场景还是先转成行式格式更划算。
三种形态对比
| 特性 | 标准 GeoJSON | NDJSON(.geojsonl) | RFC 8142 Text Sequences |
|---|---|---|---|
| 记录分隔 | 无(单一对象) | 换行符 \n | RS(0x1E)+ 换行符 |
| 整个文件是合法 JSON | 是 | 否 | 否 |
| 可流式逐条处理 | 需专用解析器 | 是 | 是 |
| 浏览器 fetch 后直接 JSON.parse | 是 | 否 | 否 |
| 损坏记录恢复能力 | 无 | 弱(依赖换行) | 强(RS 重新同步) |
| GDAL 驱动 | GeoJSON | GeoJSONSeq | GeoJSONSeq(RS=YES) |
生态支持上还有个实用细节:矢量瓦片工具 tippecanoe 的 -P(并行读取)参数只对行式输入生效,标准 GeoJSON 会退化成单线程解析。要素数量大的时候,这一个参数的差距就是几分钟和几十分钟的区别。
两个容易踩的坑
第一,行式文件不能直接喂给前端。浏览器里 fetch 回来 JSON.parse 会直接报语法错误,因为整个文件不是合法 JSON。给 Leaflet 或 MapLibre 用之前,要么用 ogr2ogr 转回标准 GeoJSON,要么前端按行 split 后逐行解析再手动组装 FeatureCollection。
第二,RS 字符是不可见字符。带 RS 和不带 RS 的两种行式文件在编辑器里看起来一模一样,解析行为却不同。分不清的时候用 od -c file.geojsonl | head -1 看首字节,036 就是 RS(0x1E 的八进制)。
与体积优化是互补关系
流式处理解决的是"读得动",让有限内存处理无限数据;Douglas-Peucker 抽稀和TopoJSON 压缩解决的是"传得动",把发给浏览器的字节数降下来。生产管线里两者通常串着用:服务端流式清洗全量数据,抽稀压缩后再输出给前端。更习惯用 SQL 做这类筛选的话,DuckDB 直接查询 GeoJSON 也是流式友好的选择。
全国省、市、区县三级行政区划边界的 GeoJSON 数据,可以在 GeoJSONcn 主站按行政区划检索预览,下载后用本文的命令即可转成行式格式接入自己的处理管线。