一、先说说批量地址解析到底难在哪

做地图开发的都会遇到一个事:手里拿着一堆地址,想在地图上标出来,但一个个去查太费劲。自己在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返回了坐标,再用反向地理编码看看这个坐标对应的地址是不是和原地址匹配。这样能发现一些隐性问题,代价是请求量翻倍,适合对数据质量要求极高的项目。

九、总结

批量地址解析不是一个高深的技术,但里面到处是细节。把并发、重试、缓存、参数调优、地址清洗这些点做好了,几万条地址也能稳稳处理完。关键是别急着发请求,先把流程想清楚:怎么控制速度,怎么提高准确率,怎么容错,然后一步步打磨。希望这篇文章能给你一个能直接用的起点。