在做交通可达性、OD 时空联系、通勤圈识别、城市群联系强度等研究/业务时,我们经常要获取两地之间的驾车通行时间(duration)和行驶距离(distance) 。
如果 OD 对数量大(几千、几万甚至几十万),手动查询显然不现实,而直接用多线程并发请求又很容易触发百度地图的 QPS 限制/限流,导致大量失败(302/401/429/请求过于频繁等)。
本文提供一套稳定可复用的批量采集工具,核心特点:
✅ 线程池并发 (提升吞吐)
✅ 全局 QPS 平滑限速 (所有线程共享,避免"瞬时扎堆超限")
✅ 指数退避重试 + 随机抖动 (限流/网络波动自动恢复)
✅ 支持 CSV 批量输入,输出 CSV/Excel(直接可用于后续分析)
1. 采集接口说明(你真正需要的结果是什么?)
百度地图方向规划类接口通常会返回:
-
distance:路径距离(单位 米) -
duration:路径耗时(单位 秒)
我们批量采集时,最常用的就是这两个字段,后续可以换算成:
-
distance_km:公里 -
duration_min:分钟
2. 为什么并发会失败?关键是 "QPS 超限"
很多人会遇到这种情况:
-
线程开到 20/30/50
-
刚启动一瞬间请求扎堆
-
结果返回:请求过于频繁/超过并发限制/429/配额限制
问题根源不是"线程数不够",而是:
你发请求的速度超过了接口允许的 QPS(每秒请求数)。
所以正确姿势是:
并发可以开,但必须有一个"全局节流器",让所有线程共享 QPS 上限。
3. 方案设计:线程池 + 全局 QPS 平滑限速 + 自动重试
这套脚本做了几件关键事:
✅ 3.1 全局限速器(最核心)
传统 TokenBucket 允许"突发",启动瞬间很容易扎堆超限。
这里我用的是更稳的策略:
-
强制 请求间隔 = 1 / QPS
-
不允许突发
-
所有线程共用同一个 limiter
这样能显著减少 "明明 QPS 设置 24,却仍然被限流" 的情况。
✅ 3.2 每个线程独立 Session(更快更稳)
requests.Session() 不建议跨线程共享 ,容易出现莫名其妙的问题。
脚本里采用 threading.local():
-
每个线程拥有自己的 Session
-
连接复用更快(Keep-Alive)
-
更稳
✅ 3.3 指数退避重试(应对限流/抖动)
遇到限流时,不是立即重试,而是:
-
0.6s、1.2s、2.4s...逐步退避
-
叠加随机抖动(避免所有线程"同一时刻"再扎堆)
这样可以让采集任务在长时间运行时更加稳健。
4. 输入 CSV 文件格式(非常重要)
脚本读取 CSV 后会自动把每一行转为一个 OD 请求,所以建议你的输入表至少包含以下列:
| 字段名 | 说明 |
|---|---|
| 序号 | 可选(方便对照) |
| 起点 | 可选(用于打印日志) |
| 终点 | 可选(用于打印日志) |
| 起点经度 | 必须 |
| 起点纬度 | 必须 |
| 终点经度 | 必须 |
| 终点纬度 | 必须 |
示例(CSV 表头建议如下):
序号,起点,终点,起点经度,起点纬度,终点经度,终点纬度
1,A,B,118.796623,32.060255,120.155070,30.274085
2,C,D,121.473701,31.230416,116.407526,39.904030
5. 一键运行前准备
5.1 安装依赖
pip install requests pandas openpyxl
(如果只保存 CSV,不保存 Excel,也可以不装 openpyxl)
5.2 填入你的 AK
在百度地图开放平台创建应用后获取 AK,并在代码里替换:
AK = "你的AK"
6. 完整代码(并发采集 + 全局 QPS 限速 + 自动重试 + 输出结果)
下面代码可以直接复制运行(你只要改 AK 和 file_path)
python
# -*- coding: utf-8 -*-
import os
import time
import random
import threading
from typing import List, Dict
import requests
import pandas as pd
from concurrent.futures import ThreadPoolExecutor, as_completed
class TokenBucketRateLimiter:
"""
更平滑的全局限速器:强制"请求间隔 = 1/qps"
- 不允许突发(解决启动瞬间扎堆导致的 QPS 超限)
- 所有线程共享同一个 limiter,确保全局 QPS 控制
"""
def __init__(self, qps: float, burst: int = None):
self.qps = float(qps)
self.min_interval = 1.0 / self.qps if self.qps > 0 else 0.05
self.next_time = time.monotonic()
self.lock = threading.Lock()
def acquire(self):
with self.lock:
now = time.monotonic()
if now >= self.next_time:
wait = 0.0
self.next_time = now + self.min_interval
else:
wait = self.next_time - now
self.next_time = self.next_time + self.min_interval
if wait > 0:
time.sleep(wait)
class BaiduMapDistanceCollector:
def __init__(
self,
ak: str,
max_workers: int = 8,
qps_limit: float = 24.0, # 建议 20~28,别贴 30 跑
max_retries: int = 4,
timeout: int = 10
):
"""
初始化百度地图距离采集器(全局QPS限速 + 退避重试)
Args:
ak: 百度地图API的AK密钥
max_workers: 并发线程数(线程再多也不会突破QPS,因为有全局限速器)
qps_limit: 全局QPS上限(建议 < 30)
max_retries: 失败重试次数(限流/网络抖动时自动重试)
timeout: 单次请求超时(秒)
"""
self.ak = ak
self.max_workers = max_workers
self.timeout = timeout
self.base_url = "https://api.map.baidu.com/directionlite/v1/driving"
# 全局限速器:所有线程共享(核心)
self.limiter = TokenBucketRateLimiter(qps=qps_limit)
# 每个线程一个 Session(requests.Session 不建议跨线程共享)
self._local = threading.local()
# 打印锁
self.print_lock = threading.Lock()
self.max_retries = max_retries
def _get_session(self) -> requests.Session:
if not hasattr(self._local, "session"):
s = requests.Session()
adapter = requests.adapters.HTTPAdapter(
pool_connections=self.max_workers,
pool_maxsize=self.max_workers,
max_retries=0
)
s.mount("https://", adapter)
s.mount("http://", adapter)
self._local.session = s
return self._local.session
@staticmethod
def _is_qps_or_throttle_error(api_data: Dict) -> bool:
"""
判断是否属于"频率/限流/QPS"类错误(尽量兼容不同返回)
"""
status = api_data.get("status")
msg = str(api_data.get("message", "")).lower()
keyword_hit = any(k in msg for k in [
"qps", "quota", "limit", "too frequent", "too many", "throttle",
"频率", "配额", "并发", "限制", "请求过于频繁", "超过限制"
])
# 有的接口会用类似 429 / 302 等(不保证一致,兜底)
status_hit = status in {302, 401, 429}
return keyword_hit or status_hit
def get_distance_duration(self, data: Dict) -> Dict:
"""
获取两点之间的驾驶距离和时长(带全局QPS限速 + 退避重试)
"""
origin = (data['起点纬度'], data['起点经度'])
destination = (data['终点纬度'], data['终点经度'])
params = {
'ak': self.ak,
'origin': f"{origin[0]},{origin[1]}",
'destination': f"{destination[0]},{destination[1]}",
'coord_type': 'wgs84'
}
last_error = None
for attempt in range(self.max_retries + 1):
# ===== 核心:全局限速放行(保证全局QPS <= qps_limit)=====
self.limiter.acquire()
# 微抖动(可选):进一步避免"同一时刻"堆叠
time.sleep(random.uniform(0.005, 0.02))
try:
session = self._get_session()
response = session.get(self.base_url, params=params, timeout=self.timeout)
response.raise_for_status()
api_data = response.json()
# 成功
if api_data.get('status') == 0 and api_data.get('message') == 'ok':
route = api_data['result']['routes'][0]
result = {
'distance': route.get('distance'),
'duration': route.get('duration'),
'status': (route.get('restriction_info') or {}).get('status'),
'error': None
}
result.update(data)
with self.print_lock:
print(f"已完成: {data.get('起点')} -> {data.get('终点')} 距离:{result['distance']}m 时长:{result['duration']}s")
return result
# 非成功:若疑似限流/QPS,退避重试
if self._is_qps_or_throttle_error(api_data) and attempt < self.max_retries:
backoff = (0.6 * (2 ** attempt)) + random.uniform(0, 0.3) # 指数退避 + 抖动
last_error = f"触发限流/超QPS:{api_data.get('message')},退避{backoff:.2f}s后重试"
time.sleep(backoff)
continue
# 其他错误直接返回
result = {
'distance': None,
'duration': None,
'status': None,
'error': api_data.get('message', 'API返回错误')
}
result.update(data)
with self.print_lock:
print(f"已完成: {data.get('起点')} -> {data.get('终点')}")
print(f" 错误: {result['error']}")
return result
except requests.exceptions.RequestException as e:
# 网络/HTTP异常:可重试
if attempt < self.max_retries:
backoff = (0.6 * (2 ** attempt)) + random.uniform(0, 0.3)
last_error = f"网络/HTTP错误: {str(e)},退避{backoff:.2f}s后重试"
time.sleep(backoff)
continue
result = {
'distance': None,
'duration': None,
'status': None,
'error': f'网络/HTTP错误: {str(e)}'
}
result.update(data)
with self.print_lock:
print(f"已完成: {data.get('起点')} -> {data.get('终点')}")
print(f" 错误: {result['error']}")
return result
except (KeyError, IndexError, ValueError) as e:
# 解析错误通常重试意义不大
result = {
'distance': None,
'duration': None,
'status': None,
'error': f'数据解析错误: {str(e)}'
}
result.update(data)
with self.print_lock:
print(f"已完成: {data.get('起点')} -> {data.get('终点')}")
print(f" 错误: {result['error']}")
return result
# 理论上不会走到这里
result = {'distance': None, 'duration': None, 'status': None, 'error': last_error or '未知错误'}
result.update(data)
return result
def batch_collect(self, coordinate_data: List[Dict]) -> List[Dict]:
results = []
total = len(coordinate_data)
print("开始批量采集距离和时长信息...")
print(f"总共需要处理 {total} 条数据,线程数 {self.max_workers},已启用全局QPS平滑限速")
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
future_to_data = {
executor.submit(self.get_distance_duration, data): data
for data in coordinate_data
}
for i, future in enumerate(as_completed(future_to_data), start=1):
try:
results.append(future.result())
if i % 10 == 0:
print(f"进度: {i}/{total} ({(i / total) * 100:.1f}%)")
except Exception as e:
data = future_to_data[future]
error_result = {
'distance': None,
'duration': None,
'status': None,
'error': f'线程执行错误: {str(e)}'
}
error_result.update(data)
results.append(error_result)
print(f"处理 {data.get('起点')} -> {data.get('终点')} 时发生错误: {str(e)}")
return results
@staticmethod
def save_to_excel(results: List[Dict], filename: str):
df = pd.DataFrame(results)
df['distance_km'] = df['distance'].apply(lambda x: round(x / 1000, 2) if x is not None else None)
df['duration_min'] = df['duration'].apply(lambda x: round(x / 60, 1) if x is not None else None)
columns_order = [
'序号', '起点', '终点',
'起点经度', '起点纬度', '终点经度', '终点纬度',
'distance', 'distance_km', 'duration', 'duration_min', 'status', 'error'
]
columns_order = [col for col in columns_order if col in df.columns]
df = df[columns_order]
df.to_excel(filename, index=False)
print(f"结果已保存到 {filename}")
@staticmethod
def save_to_csv(results: List[Dict], filename: str):
df = pd.DataFrame(results)
df['distance_km'] = df['distance'].apply(lambda x: round(x / 1000, 2) if x is not None else None)
df['duration_min'] = df['duration'].apply(lambda x: round(x / 60, 1) if x is not None else None)
columns_order = [
'序号', '起点', '终点',
'起点经度', '起点纬度', '终点经度', '终点纬度',
'distance', 'distance_km', 'duration', 'duration_min', 'status', 'error'
]
columns_order = [col for col in columns_order if col in df.columns]
df = df[columns_order]
df.to_csv(filename, index=False, encoding='utf-8-sig')
print(f"结果已保存到 {filename}")
def read_coordinates_from_file(filepath: str) -> List[Dict]:
"""
从CSV文件读取坐标数据
"""
try:
df = pd.read_csv(filepath, encoding='utf-8')
except UnicodeDecodeError:
try:
df = pd.read_csv(filepath, encoding='gbk')
except UnicodeDecodeError:
df = pd.read_csv(filepath, encoding='gb2312')
coordinate_data = df.to_dict('records')
print(f"成功读取 {len(coordinate_data)} 条坐标数据")
return coordinate_data
if __name__ == "__main__":
# !!! 建议你不要在公开位置暴露 AK,最好重置后再使用 !!!
AK = "你的AK"
file_path = r"csv文件路径"
if not os.path.exists(file_path):
print(f"错误:文件不存在 - {file_path}")
raise SystemExit(1)
input_dir = os.path.dirname(file_path)
input_filename = os.path.basename(file_path)
input_name_without_ext = os.path.splitext(input_filename)[0]
output_csv = os.path.join(input_dir, f"{input_name_without_ext}_结果.csv")
output_xlsx = os.path.join(input_dir, f"{input_name_without_ext}_结果.xlsx")
# 建议:qps_limit 先用 22~26,确保稳;若仍触发,降到 20~22
collector = BaiduMapDistanceCollector(
ak=AK,
max_workers=8,
qps_limit=24, # 你可以改成 22 更稳
max_retries=4,
timeout=10
)
print("正在读取CSV文件...")
coordinate_data = read_coordinates_from_file(file_path)
print("\n前3条数据样例:")
for i, data in enumerate(coordinate_data[:3]):
print(f"第{data.get('序号', i + 1)}条: {data.get('起点')} -> {data.get('终点')}")
print(f" 起点坐标: ({data.get('起点纬度')}, {data.get('起点经度')})")
print(f" 终点坐标: ({data.get('终点纬度')}, {data.get('终点经度')})")
start_time = time.time()
results = collector.batch_collect(coordinate_data)
end_time = time.time()
success_count = sum(1 for r in results if r.get('error') is None)
fail_count = len(results) - success_count
print("\n采集完成!")
print(f"总耗时: {end_time - start_time:.2f} 秒")
print(f"成功: {success_count} 条, 失败: {fail_count} 条")
if len(results) > 0:
print(f"平均每条耗时: {(end_time - start_time) / len(results):.2f} 秒")
print("\n前5条采集结果:")
for i, result in enumerate(results[:5]):
print(f"第 {result.get('序号', i + 1)} 条:")
print(f" 起点: {result.get('起点')} -> 终点: {result.get('终点')}")
if result.get('error'):
print(f" 错误: {result.get('error')}")
else:
d = result.get('distance')
t = result.get('duration')
print(f" 距离: {d} 米 ({(d or 0) / 1000:.2f} 公里)")
print(f" 时长: {t} 秒 ({(t or 0) / 60:.1f} 分钟)")
print(f" 状态: {result.get('status')}")
print()
BaiduMapDistanceCollector.save_to_csv(results, output_csv)
BaiduMapDistanceCollector.save_to_excel(results, output_xlsx)
print("\n所有结果已保存到:")
print(f"CSV文件: {output_csv}")
print(f"Excel文件: {output_xlsx}")
7. 运行结果长什么样?
脚本会在控制台打印类似信息:
-
每条 OD 的距离/时长
-
当前进度(每 10 条打印一次)
-
成功/失败数量与耗时
并输出两份文件:
-
xxx_结果.csv -
xxx_结果.xlsx
新增字段包括:
-
distance_km(公里) -
duration_min(分钟) -
error(失败原因)