一、先说说批量地址解析到底难在哪
做地图开发的都会遇到一个事:手里拿着一堆地址,想在地图上标出来,但一个个去查太费劲。自己在Excel里手点,几百条还能忍,几千条几万条的时候就只想摔键盘。
批量处理真正麻烦的有三个地方。第一是“快”,请求一次性发太多,服务端会直接拒绝;发太少,跑一个晚上也跑不完。第二是“准”,同一个地址,用户可能写成“望京街10号”,也可能写成“北京市朝阳区望京街10号”,还可能写成“望京SOHO旁边”,返回的结果偏差很大。第三是“稳”,网络抖动、接口超时、返回格式变化,都可能导致脚本跑一半就挂掉。所以,批量解析看着简单,实际是个细节活。
二、Mapbox地理编码的基本玩法
2.1 正向和反向,别搞混
Mapbox的地理编码接口分两种。正向是把地址文本变成经纬度,比如把“北京市东城区东长安街”变成一个坐标。反向是拿着经纬度反查地址,比如用GPS坐标找附近的地名。咱们这篇主要说正向,因为批量地址解析绝大多数是正向。
调用方式很简单,向下面这个地址发GET请求:
https://api.mapbox.com/geocoding/v5/mapbox.places/地址文本.json?access_token=你的令牌
注意,地址文本需要做URL编码,不然有中文和空格就乱了。
2.2 看一眼返回的数据
返回的是JSON,里面有个features数组。每个feature代表一个候选结果,通常第一个最像。关键字段有center(经度和纬度)、place_name(完整地名)、relevance(匹配程度)、properties里还有一些补充信息。
实际用的时候,不要只取第一个结果,最好判断一下relevance,太低就说明这个地址可能不靠谱,需要人工复查。
2.3 为什么结果总是不在你想要的街区
不少人说,我明明传了一个很详细的地址,结果Mapbox返回了隔壁城市的地标。原因往往是漏了“国家”和“位置偏好”这两个参数。地址里的关键词太通用时,接口会分不清远近。比如“人民公园”这种名字,在一个国家可能有几十个。如果不加限制,它就可能默认返回某个热门城市的那个。解决办法很简单,见下一章。
三、让结果更准的几个小套路
3.1 用参数圈定范围
Mapbox支持很多参数,比较常用的有:
- country:限定国家,比如country=cn,就不会搜到美国加州去了。
- bbox:限定一个边界框,比如你只管北京市区的地址,就把经度纬度范围传进去。
- proximity:给一个大致位置,比如离公司比较近,结果会优先选附近的。
- language:返回的语言,比如zh。
这些参数看着不起眼,但效果立竿见影。比如同一个“中央公园”,在美国和在中国都有,不限制的话结果五花八门。
3.2 地址该清洗还是得清洗
接口再智能,也怕脏数据。批量地址里经常带乱七八糟的符号、括号、多余的空格,还有一些“XX路XX号XX室”这种写法。程序处理前先统一做一次清洗,比如把逗号全角半角转成一致,去掉无意义的“XX省XX市”重复前缀,能显著提高匹配率。
3.3 自己加一层判断
拿到结果之后,别急着入库。先看看center有没有值,place_name里有没有包含你预期的城市名或街道名。如果不包含,宁可标记为“低置信度”,也不要硬塞给用户。这个判断逻辑很简单,但能避免很多脏数据进入地图。
3.4 举个例子:一个地址的清洗前后对比
假设原始地址是这样:
北京市,北京市, 朝阳区, 望京街10号,SOHO旁边
清洗之后可以变成:
北京市朝阳区望京街10号
或者再带上一个参考区域:
北京市朝阳区望京街10号
同时请求里带上country=cn和proximity=116.47,39.99,这样“SOHO旁边”这种模糊信息就不用依赖接口去猜了,命中率会高很多。
四、批量处理的核心:并发和重试
4.1 并发太高会被限流
Mapbox不是无限制随便用的,每个套餐都有每秒或每分钟的请求上限。真按1000个地址for循环一起发,不到几秒就会被返回429状态码,甚至封IP。所以必须控制并发量,比如一次最多同时发5个请求。
4.2 手写一个简单并发控制
下面用一个小的Node.js脚本演示。技术栈是Node.js + axios。
// 批量解析地址,控制并发数量
const axios = require('axios');
const token = '你的Mapbox令牌';
// 并发控制函数:把一堆任务交给指定数量的工人去执行
async function parallelMap(items, limit, workerFn) {
const results = [];
let index = 0; // 指向下一个要处理的任务下标
// 工人函数,从items里按顺序拿任务
async function run() {
while (index < items.length) {
const current = index++; // 取出当前下标,然后后移
const value = await workerFn(items[current], current);
results[current] = value; // 按原有顺序保存结果
}
}
// 创建limit个工人,同时跑
const workers = Array.from({ length: limit }, () => run());
await Promise.all(workers);
return results;
}
// 单个地址的解析请求
async function geocode(address) {
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(address)}`;
const { data } = await axios.get(url, {
params: {
access_token: token,
limit: 1,
country: 'cn',
language: 'zh'
}
});
return data;
}
// 使用示例
(async () => {
const addresses = [
'北京市朝阳区望京街10号',
'上海市浦东新区世纪大道100号',
'广州市天河区珠江新城'
];
// 一次最多同时发2个请求
const results = await parallelMap(addresses, 2, async (addr) => {
try {
const data = await geocode(addr);
const feature = data.features && data.features[0];
return {
ok: true,
address: addr,
lng: feature ? feature.center[0] : null,
lat: feature ? feature.center[1] : null
};
} catch (err) {
return { ok: false, address: addr, error: err.message };
}
});
console.log(JSON.stringify(results, null, 2));
})();
这段代码里有几个用心的地方。第一,用index++和results[current]来保证结果顺序不乱,即使请求完成时间不一样。第二,用try-catch把单个请求的错误包住,一个失败不会让整个任务挂掉。第三,limit可以随便调。
4.3 失败重试记得用退避
网络请求偶尔失败很正常,比如某个瞬间连接不上。重试是有必要的,但不能失败了立刻重试,否则服务端压力更大。常见的做法是“指数退避”:第一次失败等1秒,第二次等2秒,第三次等4秒。下面在6.2的完整示例里会用到。
4.4 并发数怎么定才合适
不要拍脑袋写死。先看你的套餐文档,比如允许每分钟60次请求,那就把并发数设为1,确保每秒不超过1次;如果允许每分钟600次,并发数设为5到10都行。另一个原则是“留出三分之一余量”,因为重试时的请求也会占用配额,给缓冲才能避免突然被限流。
五、缓存:省钱又省时间的利器
5.1 重复地址比你想象得多
批量数据里经常有重复地址,可能是同一个点位在不同表里出现了好多次,也可能只是写法稍有不同。如果不做缓存,同样的坐标会花钱重复查,很浪费。
5.2 一个简单的内存缓存
我们可以在程序里放一个Map,查之前先看有没有,有就直接用,没有才发请求。下面这个片段强调缓存逻辑:
// 全局缓存
const cache = new Map();
async function geocodeCached(address) {
// 命中缓存,直接返回
if (cache.has(address)) {
return cache.get(address);
}
// 没命中,发请求
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(address)}`;
const { data } = await axios.get(url, {
params: { access_token: token, limit: 1 }
});
const feature = data.features && data.features[0];
const result = feature ? {
ok: true,
lng: feature.center[0],
lat: feature.center[1]
} : { ok: false };
// 写进缓存,下次直接用
cache.set(address, result);
return result;
}
5.3 生产环境建议用Redis做共享缓存
如果程序部署了多个实例,内存缓存就不太灵了,因为每个实例各有一份,A缓存了B不知道。这时候可以用Redis,查一次写一次,所有实例都共享。而且Redis天然支持过期时间,可以给缓存设置“有效期”,防止地址数据太久没更新。
5.4 怎么提高缓存命中率
缓存命中率低,往往是因为地址文本太“个性化”。比如“北京市朝阳区望京街10号”和“望京街10号”看起来是同一个地方,但字符串不一样,缓存就没办法命中。一个简单办法是在入缓存前先做地址脱敏,比如统一城市前缀、统一大小写、把全角数字都转半角。虽然不能百分百合并,但能明显提高命中率。
六、生产环境实战:完善的一个批处理脚本
6.1 要准备什么
把前面讲的东西揉到一起,做一个比较像真实项目的脚本。技术栈依旧是Node.js + axios,文件读写用fs自带的模块。
假设有一个addresses.txt文件,每行一个地址。运行脚本后,生成results.csv,里面包含:原始地址、是否成功、经度、纬度、解析出来的地名、错误信息。
6.2 完整代码
// 生产环境可用的批量地址解析脚本示例
// 技术栈:Node.js + axios + fs(内置)
const axios = require('axios');
const fs = require('fs');
const readline = require('readline');
const TOKEN = '你的Mapbox令牌';
const CONCURRENCY = 5; // 并发数
const MAX_RETRIES = 4; // 单个地址最大重试次数
const CACHE_ENABLED = true; // 是否启用缓存
// 简单内存缓存
const cache = new Map();
// 从txt文件里读取地址,每行一个
function readAddresses(filePath) {
return new Promise((resolve, reject) => {
const addresses = [];
const rl = readline.createInterface({
input: fs.createReadStream(filePath),
crlfDelay: Infinity // 兼容Windows换行符
});
rl.on('line', (line) => {
const trimmed = line.trim();
if (trimmed) {
addresses.push(trimmed);
}
});
rl.on('close', () => resolve(addresses));
rl.on('error', reject);
});
}
// 等待多久,单位毫秒
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// 解析单个地址,带缓存和重试
async function geocodeAddress(address) {
// 1. 查内存缓存
if (CACHE_ENABLED && cache.has(address)) {
return cache.get(address);
}
// 2. 循环重试
let lastError = null;
for (let i = 0; i < MAX_RETRIES; i++) {
try {
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(address)}`;
const { data } = await axios.get(url, {
params: {
access_token: TOKEN,
limit: 1,
country: 'cn',
language: 'zh'
},
timeout: 10000 // 10秒没响应直接算失败
});
const feature = data.features && data.features[0];
let result;
if (feature && feature.relevance > 0.5) {
result = {
ok: true,
lng: feature.center[0],
lat: feature.center[1],
placeName: feature.place_name
};
} else {
result = {
ok: false,
error: feature ? '置信度太低' : '没有匹配结果'
};
}
// 3. 写缓存
if (CACHE_ENABLED) {
cache.set(address, result);
}
return result;
} catch (err) {
lastError = err;
if (err.response && err.response.status === 429) {
// 被限流了,多等一会儿,退避时间翻倍
await sleep(1000 * Math.pow(2, i));
} else {
// 其他错误,稍微等一点
await sleep(200 * (i + 1));
}
}
}
const failResult = { ok: false, error: lastError ? lastError.message : '未知错误' };
if (CACHE_ENABLED) {
cache.set(address, failResult);
}
return failResult;
}
// 并发执行批量解析
async function runBatch(addresses) {
const queue = addresses.slice();
const results = [];
async function worker() {
while (queue.length) {
const address = queue.shift();
const result = await geocodeAddress(address);
results.push({ address, ...result });
}
}
const workers = [];
for (let i = 0; i < CONCURRENCY; i++) {
workers.push(worker());
}
await Promise.all(workers);
return results;
}
// 入口
(async () => {
const filePath = 'addresses.txt';
const addresses = await readAddresses(filePath);
console.log(`读到了${addresses.length}个地址,开始解析...`);
const results = await runBatch(addresses);
// 拼CSV,注意简单转义逗号和换行
const header = '原始地址,是否成功,经度,纬度,地名,错误信息';
const rows = results.map((r) => {
const safeAddress = (r.address || '').replace(/"/g, '""');
const safePlace = (r.placeName || '').replace(/"/g, '""');
const safeError = (r.error || '').replace(/"/g, '""');
return `"${safeAddress}","${r.ok}","${r.lng || ''}","${r.lat || ''}","${safePlace}","${safeError}"`;
});
fs.writeFileSync('results.csv', [header, ...rows].join('\n'));
console.log('解析完成,结果已写入results.csv');
})();
注意,上面的代码用了relevance > 0.5这个门槛,只是示例,大家可以根据实际数据情况调整。另外,为了展示缓存和重试,我用了一个Map;如果数据量很大,可以改成Redis,逻辑一样,只是存储换一下。
6.3 怎么把它接到生产流程里
这个脚本跑完会生成一个CSV,但这不叫“生产环境”。生产环境通常是把它做成一个定时任务,或者一个异步接口。比如用cron每天夜里跑一遍,或者收到用户上传文件后丢到消息队列里处理。这里的核心思路是一样的:把解析结果保存成结构化数据,再喂给下游。
简单说,如果一次任务要跑几万条,最好做成“任务队列”。先把所有地址塞进队列,后台一批批处理,处理完的结果更新到数据库里,用户随时能看到进度,而不是傻等一个HTTP响应。
七、这些坑一定要避开
7.1 不要把令牌写进代码
上面的示例里写了“你的Mapbox令牌”,实际项目中必须放在环境变量或配置中心里。代码一泄露,令牌就可能被拿去刷流量,账单也是你的。
7.2 并发数别拍脑袋写死
并发数需要根据套餐上限来算。比如你每个月有10万次配额,每分钟限120次,那并发数就是每分钟能发的请求数。如果自己不确定,先保守一点,设1到3。宁可慢一点,也不要被拉黑。
7.3 注意请求超时
一定要设置timeout,否则一个请求卡住了,整个worker会一直占着。上面代码里设置了10秒,实际可以根据网速调整。
7.4 地址清洗是长期工程
批量数据里总是有惊喜,比如“北京市北京市朝阳区”、“xx路 10号楼下包子铺”这种。清洗规则需要持续补充,不能一劳永逸。
7.5 结果要留原始查询
保存结果时,要把原始地址也存下来,不然以后想排查“这个结果到底是谁的”都查不了。上面示例里已经做了。
7.6 注意时区和定时任务时间
如果用了定时任务,要确认服务器时区。很多时候订单地址都是早上产生的,凌晨处理会拿到过时的数据,所以定时任务的时间要结合业务场景来设计。
八、技术优缺点和应用场景
如果只是几十个地址,直接手工查也没问题,没必要上并发和缓存。但如果面对的是几万条门店信息、地址库同步、用户地址补全,那批量处理的价值就出来了。
这种方案的好处是灵活、成本低,用Mapbox免费额度也能跑不少数据;缺点是依赖第三方服务,网络不稳定会导致整体变慢。另外,价格随着量上去之后,优化缓存和查询参数就成了省钱的关键。
8.1 什么时候用并发,什么时候用缓存
并发适合“量大但地址不重复”的场景,比如每天新增的地址列表。缓存适合“重复度高”的场景,比如同一个渠道反复提交的订单地址。两者其实不冲突,我的建议是都要,因为它们解决的问题不一样。
8.2 还有什么办法能再进一步
如果精度要求特别高,可以考虑结合其他数据源做“交叉验证”。比如Mapbox返回了坐标,再用反向地理编码看看这个坐标对应的地址是不是和原地址匹配。这样能发现一些隐性问题,代价是请求量翻倍,适合对数据质量要求极高的项目。
九、总结
批量地址解析不是一个高深的技术,但里面到处是细节。把并发、重试、缓存、参数调优、地址清洗这些点做好了,几万条地址也能稳稳处理完。关键是别急着发请求,先把流程想清楚:怎么控制速度,怎么提高准确率,怎么容错,然后一步步打磨。希望这篇文章能给你一个能直接用的起点。
评论
围绕“Mapbox地理编码批量处理:生产环境中批量地址解析的效率与精度优化”参与讨论