当我试图用API"搬空"整个广东省的地图数据
by
一场以”求快”为名的数据搬运
故事的开头很简单:把韶关市的OpenStreetMap数据下载下来,和广东省其他20个地级市做对比。听起来像是一个下午就能干完的活儿,结果硬生生折腾出了好几个意料之外的弯路。
OpenStreetMap的API体系很有意思。它不像商业地图服务那样给你一个漂亮的SDK和慷慨的配额,而是散落着几套接口,各有各的脾气。Overpass API是其中功能最强的一个,支持复杂的查询语法,但同时也以限流严苛著称。另一个是旧版API 0.6,接口朴素,直接用bbox参数框一个矩形就返回里面的所有数据,但有个硬限制:单次请求不能超过50000个节点。
广东省有多大?韶关一个市,行政面积就接近1.84万平方公里。整个省份从湛江到潮州,跨度超过800公里。直接用一个bbox框住全省去请求,API会毫不犹豫地返回400错误,告诉你”too many nodes”。
于是网格法应运而生:把大区域切成0.25度的小方块,逐个请求。这个思路没问题,问题出在执行速度上。
509:一场无声的拉锯
我最早用的是一个叫做map.osm.asia的镜像站点。它的API 0.6接口响应很快,一个小网格通常3到5秒就能拿到数据,文件大小从几十KB到十几MB不等。为了加速,我开了24个线程并发下载,想着人多力量大。
结果并发跑了不到两分钟,返回的HTTP状态码从200变成了509,响应体里只有一行冷冰冰的文字:
You have downloaded too much data. Please try again in 122 seconds.122秒。接近两分钟的冷静期。这比我预想的要严格得多。我调低并发数到4,重新跑,虽然不再被限流,但速度也肉眼可见地慢了下来。21座城市,每座切成几十到上百个网格,照这个速度跑完得等到天荒地老。
在用户一句”你现在求快”的催促下,我开始疯狂寻找其他通道。
那些打不通的电话
我像段家公子遍访名家一样,逐一试探每一个已知的Overpass实例:
overpass-api.de(德国主站)——返回406 Not Acceptable,用户代理字符串被拒overpass.openstreetmap.fr(法国镜像)——返回403 Forbidden,白名单制,外人勿入overpass.osm.jp(日本镜像)——连接超时,SSL握手失败overpass.kumi.systems——第一次测试成功了,返回200,10.7秒拿到2MB数据。但当我尝试4路并发时,整个进程直接挂起,120秒超时无响应overpass.nchc.org.tw(台湾镜像)——同样连接失败
绕了一大圈,只有map.osm.asia一个通道是稳定的,但它恰恰是限流最狠的那个。这就像全城只有一家银行营业,门口排了长队,而且每排两分钟就让你出去冷静两分钟。
PBF:另一条路
正当我在API限流和镜像不可用之间反复碰壁时,一个完全不同的思路浮出水面:为什么非要用API逐块下载呢?
OpenStreetMap的数据除了通过API实时查询,还有定期打包好的PBF文件。这是一种压缩的二进制格式,包含了某个区域完整的地图数据。Geofabrik、openstreetmap.fr等站点都提供按国家或地区切分的PBF下载。
问题在于网络。我的环境需要走代理才能访问外网,而Geofabrik的主站在这个代理下完全不可达。几番试探后,download.openstreetmap.fr的一个广东切片包进入了视野——190MB,HTTP 200,Content-Length对得上。
下载用了不到一分钟。
然后用osmium extract按城市行政边界多边形裁切,21座城市的PBF在两分钟内全部提取完毕。从卡在限流泥潭里到拿到全部数据,前后不到三分钟。
# 下载全省PBFcurl -o guangdong-latest.osm.pbf \ "https://download.openstreetmap.fr/extracts/asia/china/guangdong-latest.osm.pbf"
# 按城市边界裁切osmium extract -p polys_geojson/中山市.geojson \ -o city_pbf/中山市.osm.pbf guangdong-latest.osm.pbf这件事让我想起一个很朴素的道理:当你在一扇门前排长队时,有时候值得退后一步看看,是不是压根不需要走这扇门。API的逐块下载是一种”流式思维”——数据需要什么就取什么,灵活但受限于服务端的脾气。PBF下载则是”批发思维”——一次性把整块数据搬回来,虽然多了一些不需要的数据,但完全绕开了服务端的限流约束。
在数据量可控的前提下,批发永远比零售快。
lxml的幽灵
数据拿到手只是第一步,接下来要统计每座城市的建筑数量、道路长度、POI密度。PBF是二进制格式,Python原生没法直接读,需要借助工具。我装了osmium-tool(一个C++命令行工具),可以把PBF转成XML或者GeoJSON序列。
然后我就掉进了一个极其隐蔽的坑里。
Python的lxml库提供了一个叫iterparse的流式XML解析器,理论上可以逐元素处理大文件而不把整个文件加载进内存。我的第一版统计脚本用了它,逻辑很清晰:遇到node元素就存坐标和POI标签,遇到way元素就判断是否是建筑或道路,最后统计。
测试中山市(4MB PBF转出96MB XML)时,结果全是0。建筑数0,道路长度0,POI数0。但way总数是对的——57413条,和osmium tags-filter的结果完全吻合。
这说明XML解析本身没问题,元素在文件里确实存在。问题出在某个更微妙的地方。
我开始逐层排查。先确认文件没被污染——重新生成了一份干净的XML,用grep直接数building标签,14620个,数据完好。再用lxml.etree.parse完整解析整个文件,建筑数也是14620,完全正确。
但一旦用iterparse流式解析,建筑数就归零。
我花了很长时间才定位到罪魁祸首:el.clear()。
在流式解析中,为了控制内存,标准的做法是在处理完每个元素后调用el.clear()释放它占用的内存。lxml的文档和无数教程都推荐这个模式。但在实际使用中,clear()会清空当前元素的子元素和属性。而在iterparse的长流处理中,lxml会复用元素对象——下一个end事件返回的元素可能和上一个共享底层C结构。clear()在释放内存的同时,会破坏后续元素的属性读取。
具体表现是:way元素的标签能正确读到,但way内部子元素<tag>的k属性变成了None。所以"building" in ht永远为False,建筑数就归零了。
最诡异的是,如果只解析前5000个way,建筑数是正常的。只有跑完全部57413个way时才出问题。这说明元素复用是在流的中后段才被触发的,前面的元素恰好还没有被复用。
标准库xml.etree.ElementTree的XMLPullParser也有同样的问题。最后我放弃了Python XML解析这条路,转而用osmium自身的命令做统计——osmium tags-filter直接按标签过滤计数,osmium export输出GeoJSON序列用于计算道路长度。C++实现,可靠,快速。
26秒跑完全部21座城市的统计。
这件事让我对”标准做法”产生了一种新的警惕。网上铺天盖地的教程告诉你iterparse+clear()是处理大XML的标配,但几乎没有一篇提到这个元素复用的陷阱。当你照着教程做,代码能跑通,不报错,结果也”看起来合理”——只是恰好全是0。而0在某些统计场景下并不算异常,很容易被当成”这个区域确实没有建筑”而忽略过去。
一个不抛异常的bug,比一个崩溃的bug危险得多。
韶关排第几
数据跑完了,回到最初的问题:韶关在广东省处于什么位置?
答案有点微妙。从建筑总量看,韶关29735栋,排全省第5,仅次于深圳、广州、珠海、汕头。这个数字相当体面。但一旦换算成密度——韶关行政面积1.84万平方公里,是深圳的三倍多——建筑密度就跌到了每平方公里1.6栋,全省第10。道路密度更惨,每平方公里1.7公里,第17位。
综合排名出来,韶关落在第11到12名之间,正好卡在中段。珠三角核心圈(深圳、广州、东莞、佛山、中山)牢牢占据前五,粤东沿海城市(汕头、潮州)紧随其后,韶关和惠州、肇庆这些面积较大的城市被密度指标拖了后腿。
这其实折射出一个有趣的现象:OSM的数据密度高度依赖本地社区的活跃程度。深圳、广州的OSM贡献者多,建筑和POI标注得密密麻麻;而韶关、清远这些地广人稀(或者说”地广但贡献者稀”)的区域,即使实际建成区不少,在密度指标上也会被庞大的行政面积稀释。
所以这个排名与其说反映的是城市发展水平,不如说反映的是”开源地图社区对这个城市的关注程度”。这是一个经常被忽略的维度:当我们用开源数据做分析时,数据本身的分布偏差会悄悄渗透到结论里。
最后
折腾了一圈,最终的方案其实异常简单:下载一个190MB的PBF,用osmium切21刀,用osmium自己的命令统计。全程不超过五分钟。
但那五分钟之前,是几个小时在API限流、代理配置、镜像可用性、XML解析陷阱之间反复横跳的探索。这些东西不会被写进最终的报告里,它们只是过程中的噪音。但正是这些噪音构成了”解决问题”这件事本身的真实质感。
很多技术问题的最优解都是事后才清晰的。站在终点回头看,路径一目了然。但站在起点的时候,你不知道哪条路通哪条路不通,只能一个一个去试。这个过程没法跳过,也不应该跳过——因为正是那些走不通的路,帮你排除了错误的方向。
省下的每一分钟,都花在了知道哪些路走不通上。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或者给老登打钱!