Go Geocoding 教程:Goroutines、有界并发与 Backoff

生产可用的 Go geocoding 教程:goroutine 池、semaphore 限制并发、对 429 的 exponential backoff。可编译可运行。

| May 05, 2026
Go Geocoding 教程:Goroutines、有界并发与 Backoff

当你想要 concurrency 而不想要 async/await 仪式时,Go 是首选语言。Goroutines 是 2 KB 的 stack,channels 是带类型的 pipe,标准库 net/http 从第一天起就足够生产使用。没有要学的 event loop,没有要散布到每个函数中的 await 关键字,也没有 coloured-function 问题。你写直线的代码,然后用 go 和一个 semaphore channel 把它分散开。

这是一个通过 REST API 做 geocoding 的可运行 Go 教程。每个 snippet 都写进了 .go 文件,通过 go build 编译,并在发布前在干净的 Go 1.22 安装上对真实 API 执行过。没有 SDK,没有第三方 HTTP 库,没有 framework — 只有 net/httpencoding/jsonencoding/csvcontext。最后你将得到一个程序,它能 streaming 任意大小的 CSV,用可配置的 goroutine pool 对行做 geocoding,对 429 和 5xx 用尊重 Retry-After 的 jittered exponential backoff 重试,并写出一份 {lat, lng, error} 的 CSV,全程不把输入文件放进内存。大约 80 行 Go。

全文使用的 endpoint 是 csv2geo.com/api/v1。free tier 是每天 1,000 次 forward/reverse 请求外加每天 100 行 batch,无需信用卡。登录并到 /api-keys 取一个 key 跟着做。

Endpoint

两个 endpoint 几乎覆盖了所有现实世界的 geocoding 工作。Forward 把地址字符串变成坐标。Reverse 把坐标变成地址。两者都接受 GET(single)或 POST(batch)。

# Forward (single)
GET https://csv2geo.com/api/v1/geocode?q=ADDRESS&country=US

# Reverse (single)
GET https://csv2geo.com/api/v1/reverse?lat=LAT&lng=LNG

# Batch forward
POST https://csv2geo.com/api/v1/geocode
Body: { "addresses": ["addr1", "addr2", ...] }

# Auth: either ?api_key=KEY query string, or
# Authorization: Bearer KEY header

单个 forward 请求的响应形状是这样的。这是真实输出,不是文档示例。

{
  "query": "1600 Pennsylvania Ave NW Washington DC",
  "results": [
    {
      "formatted_address": "1600 Pennsylvania Ave NW, Washington, DC 20500-0005, United States",
      "location": { "lat": 38.89768, "lng": -77.03655 },
      "accuracy": "houseNumber",
      "accuracy_score": 1,
      "components": {
        "house_number": "1600",
        "street": "Pennsylvania Ave NW",
        "city": "Washington",
        "state": "District of Columbia",
        "postal_code": "20500-0005",
        "country": "USA"
      }
    }
  ],
  "meta": { "response_time_ms": 673, "source": "here" }
}

两个字段最重要。results[0].location 是你的 {lat, lng}results[0].accuracy 是匹配级别 — "houseNumber" 是 rooftop,"street" 是街道中心,"place" 是 POI 匹配,"postcode" 是 postcode 中心。数值的 accuracy_score(0.0–1.0)给出更细的 threshold;如何选择见 geocoding confidence scores explained

第一个请求

net/http 加上 encoding/json 就够了。第一个版本只返回坐标,这样我们可以端到端验证 wire format。

// geocode.go
package main

import (
    "encoding/json"
    "fmt"
    "net/http"
    "net/url"
    "os"
    "time"
)

const baseURL = "https://csv2geo.com/api/v1"

type Location struct {
    Lat float64 `json:"lat"`
    Lng float64 `json:"lng"`
}

type GeocodeResult struct {
    FormattedAddress string   `json:"formatted_address"`
    Location         Location `json:"location"`
    Accuracy         string   `json:"accuracy"`
    AccuracyScore    float64  `json:"accuracy_score"`
}

type GeocodeResponse struct {
    Query   string          `json:"query"`
    Results []GeocodeResult `json:"results"`
}

var client = &http.Client{Timeout: 10 * time.Second}

func geocode(address, country string) (*Location, error) {
    q := url.Values{}
    q.Set("q", address)
    if country != "" {
        q.Set("country", country)
    }
    req, err := http.NewRequest("GET", baseURL+"/geocode?"+q.Encode(), nil)
    if err != nil {
        return nil, err
    }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("CSV2GEO_KEY"))

    resp, err := client.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    if resp.StatusCode >= 300 {
        return nil, fmt.Errorf("http %d", resp.StatusCode)
    }

    var out GeocodeResponse
    if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
        return nil, err
    }
    if len(out.Results) == 0 {
        return nil, nil
    }
    return &out.Results[0].Location, nil
}

func main() {
    loc, err := geocode("1 Apple Park Way, Cupertino, CA", "US")
    if err != nil {
        fmt.Fprintln(os.Stderr, "error:", err)
        os.Exit(1)
    }
    fmt.Printf("%+v\n", loc)
}

运行:

CSV2GEO_KEY=geo_live_xxx go run geocode.go
# &{Lat:37.33177 Lng:-122.03042}

有几点值得指出。defer resp.Body.Close() 不是可选的 — 如果忘记,底层 TCP 连接无法被归还到 pool,在压力下会泄漏 file descriptor。使用单一的 package-level http.Client 而不是直接用 http.Get;默认客户端的 Timeout: 0 意味着挂死的服务器会永远阻塞你的 goroutine。使用 Authorization: Bearer 而不是 query 形式的 ?api_key=,这样 key 永远不会出现在 shell 历史或 log 文件里。

Reverse geocoding

形状相同,参数不同。传 latlng,得到一个地址。

type ReverseResult struct {
    FormattedAddress string   `json:"formatted_address"`
    Location         Location `json:"location"`
    Accuracy         string   `json:"accuracy"`
    DistanceMeters   float64  `json:"distance_meters,omitempty"`
}

type ReverseResponse struct {
    Results []ReverseResult `json:"results"`
}

func reverse(lat, lng float64) (*ReverseResult, error) {
    q := url.Values{}
    q.Set("lat", fmt.Sprintf("%f", lat))
    q.Set("lng", fmt.Sprintf("%f", lng))

    req, err := http.NewRequest("GET", baseURL+"/reverse?"+q.Encode(), nil)
    if err != nil {
        return nil, err
    }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("CSV2GEO_KEY"))

    resp, err := client.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    if resp.StatusCode >= 300 {
        return nil, fmt.Errorf("http %d", resp.StatusCode)
    }

    var out ReverseResponse
    if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
        return nil, err
    }
    if len(out.Results) == 0 {
        return nil, nil
    }
    return &out.Results[0], nil
}

Reverse 响应包含一个 distance_meters 字段 — 匹配到的地址距离输入坐标多远。如果你在对 delivery app 的 GPS pings 做 reverse-geocoding,超过约 50m 的通常意味着 GPS fix 不好,而不是 geocoder 不好。低于约 10m 你就在看 rooftop。Reverse geocoding 唯一诚实的精度指标是 ground-truth distance;其他都多少撒点谎。

Go 风格的 bounded concurrency

在 Go 中给 10,000 个地址做 geocoding 的错误方式是 for _, a := range addrs { go geocode(a) }。这会启动 10,000 个无界 goroutines,打开 10,000 个 TCP 连接,在第二轮迭代就用尽 API rate limit,并同时耗尽你本地的 file descriptor。Goroutines 便宜,但不是免费的,网络也从来不免费。

正确的方式是用 semaphore channel 做 bounded concurrency。容量为 struct{}、上限 N 的 buffered channel 是 Go 的标准 semaphore — 一旦已经有 N 个 goroutines 在飞,向其发送就会阻塞。

// pool.go
package main

import (
    "fmt"
    "sync"
)

func geocodeMany(addresses []string, concurrency int) []*Location {
    sem := make(chan struct{}, concurrency)
    out := make([]*Location, len(addresses))
    var wg sync.WaitGroup

    for i, addr := range addresses {
        wg.Add(1)
        sem <- struct{}{} // acquire
        go func(i int, addr string) {
            defer wg.Done()
            defer func() { <-sem }() // release

            loc, err := geocode(addr, "US")
            if err != nil {
                fmt.Println("err:", addr, err)
                return
            }
            out[i] = loc
        }(i, addr)
    }
    wg.Wait()
    return out
}

这段代码里有三个习语值得保留。第一,sem <- struct{}{} 发生在 go 关键字 *之前*,因此 for 循环在 throttle 处阻塞,而不是调度成千上万个待执行的 goroutines。第二,defer func() { <-sem }() 即使 worker panic 也会释放 slot — 这条 defer 链你永远不应跳过。第三,out[i] = loc 写入每个 goroutine *唯一* 的 index,因此不需要 mutex;在 Go 中对 slice 不同 slot 的并发写是安全的。把 iaddr 作为 goroutine literal 的参数捕获,可避免 1.22 之前咬过每个 Go 开发者的 loop-variable bug。

concurrency 一个有用的起步规则:取你计划的 per-minute rate limit,除以 60,瞄准大致那么多的 in-flight 请求。Free 是 100/分 — 从 4 开始。Starter ($49, 1K/分) — 16。Growth ($149, 5K/分) — 64。Pro ($499, 10K/分) — 128 并观察 headers。完整拆解见 concurrency tuning for geocoding

Retry、backoff 和 context 模式

每个生产级 geocoder 都需要的三件事:(1) 把 429 和 5xx 视为可 retry,(2) 当服务器发送时尊重 Retry-After header,(3) 限制尝试次数,让一个永久死亡的 key 不会永远循环。加上 context.Context,这样 caller 可以取消一条长 retry 链,你就有了一个可以扔进真实服务的安全函数。

// retry.go
package main

import (
    "context"
    "encoding/json"
    "fmt"
    "io"
    "math/rand"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "time"
)

type GeocodeError struct {
    Status  int
    Message string
}

func (e *GeocodeError) Error() string {
    return fmt.Sprintf("geocode: status=%d msg=%s", e.Status, e.Message)
}

func geocodeWithRetry(ctx context.Context, address, country string, maxAttempts int) (*GeocodeResponse, error) {
    q := url.Values{}
    q.Set("q", address)
    if country != "" {
        q.Set("country", country)
    }
    target := baseURL + "/geocode?" + q.Encode()

    var lastErr error
    for attempt := 1; attempt <= maxAttempts; attempt++ {
        req, err := http.NewRequestWithContext(ctx, "GET", target, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("CSV2GEO_KEY"))

        resp, err := client.Do(req)
        if err != nil {
            // Network errors are retriable.
            lastErr = err
            if attempt == maxAttempts {
                return nil, fmt.Errorf("network error after %d attempts: %w", attempt, err)
            }
            if err := sleep(ctx, backoff(attempt)); err != nil {
                return nil, err
            }
            continue
        }

        if resp.StatusCode < 300 {
            defer resp.Body.Close()
            var out GeocodeResponse
            if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
                return nil, err
            }
            return &out, nil
        }

        // Non-retriable: 4xx other than 429 means bad key, bad input, etc.
        if resp.StatusCode != 429 && resp.StatusCode < 500 {
            body, _ := io.ReadAll(resp.Body)
            resp.Body.Close()
            return nil, &GeocodeError{Status: resp.StatusCode, Message: string(body)}
        }

        // Retriable: 429 or 5xx. Honour Retry-After if present.
        retryAfter := resp.Header.Get("Retry-After")
        resp.Body.Close()
        if attempt == maxAttempts {
            return nil, &GeocodeError{Status: resp.StatusCode, Message: "exhausted retries"}
        }
        delay := backoff(attempt)
        if retryAfter != "" {
            if secs, err := strconv.ParseFloat(retryAfter, 64); err == nil {
                delay = time.Duration(secs * float64(time.Second))
            }
        }
        if err := sleep(ctx, delay); err != nil {
            return nil, err
        }
    }
    return nil, fmt.Errorf("unreachable: lastErr=%v", lastErr)
}

func backoff(attempt int) time.Duration {
    // 1, 2, 4, 8, 16... seconds + up to 1s jitter.
    base := time.Duration(1<<(attempt-1)) * time.Second
    jitter := time.Duration(rand.Float64() * float64(time.Second))
    return base + jitter
}

func sleep(ctx context.Context, d time.Duration) error {
    t := time.NewTimer(d)
    defer t.Stop()
    select {
    case <-ctx.Done():
        return ctx.Err()
    case <-t.C:
        return nil
    }
}

关于设计的几条说明。time.NewTimer 加一个对 selectctx.Done() 上的 select 是实现可取消 sleep 的标准方式 — time.Sleep 忽略 context,会让你的 goroutine 在 deploy 或 SIGTERM 期间一直被钉住。backoff 中的 jitter 在很多 workers 同时 retry 时很重要;没有它,每个 worker 在同一时刻醒来并对服务器再次 DDoS。retry 预算和 dead-letter queues 的数学见 exponential backoff: when to retry, when to stop

每次成功响应都会返回三个 rate-limit headers,在生产中值得检查:

| Header | 含义 | |---|---| | X-RateLimit-Limit | 你计划的 per-minute 上限 | | X-RateLimit-Remaining | 当前窗口剩余多少 | | X-RateLimit-Reset | 距离窗口重置的 Unix 秒数 | | Retry-After | 在 429s 时发送 — retry 前要等的秒数 |

如果 Remaining 跌到 Limit 的 10% 以下,自愿减速 — sleep、降低 semaphore、切换到 batch。比在 429 上死磕更便宜。这些 headers 由哪种 limiter algorithm 产生的更深理论见 token bucket vs leaky bucket vs sliding window

Batch endpoint

从 Starter ($49/月) 起的订阅 tier 提供一个 batch endpoint,它在一次 POST 中接受一个地址数组。计划上限:

| 计划 | 月行数 | 每分钟 | Batch 大小 | |---|---|---|---| | Free | — (1K/天 API, 100/天 batch) | 100 | 100 | | Starter ($49) | 50,000 | 1,000 | 1,000 | | Growth ($149) | 250,000 | 5,000 | 5,000 | | Pro ($499) | 1,000,000 | 10,000 | 10,000 |

// batch.go
package main

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "net/http"
    "os"
    "time"
)

type BatchRequest struct {
    Addresses []string `json:"addresses"`
}

type BatchResponse struct {
    Results []GeocodeResponse `json:"results"`
}

var batchClient = &http.Client{Timeout: 60 * time.Second}

func batchGeocode(ctx context.Context, addresses []string) (*BatchResponse, error) {
    body, err := json.Marshal(BatchRequest{Addresses: addresses})
    if err != nil {
        return nil, err
    }

    req, err := http.NewRequestWithContext(ctx, "POST", baseURL+"/geocode", bytes.NewReader(body))
    if err != nil {
        return nil, err
    }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("CSV2GEO_KEY"))
    req.Header.Set("Content-Type", "application/json")

    resp, err := batchClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    if resp.StatusCode >= 300 {
        return nil, fmt.Errorf("batch http %d", resp.StatusCode)
    }

    var out BatchResponse
    if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
        return nil, err
    }
    return &out, nil
}

Batch 响应保留输入顺序:响应中的位置 N 始终对应输入中的位置 N。无需往返一个 id 字段。注意专用的 batchClient 有 60 秒 timeout — Growth 上的 1,000 地址 batch 端到端要 10–15 秒,比用于 singles 的 10 秒 per-request 客户端长得多。

无 OOM 的 Streaming CSV

如果输入文件有 100,000 行,你不会想把它读进一个 [][]string 然后用 range 遍历。把它 stream 起来。模式是 worker pool:一个 goroutine 从磁盘读行并 push 到 jobs channel,N 个 worker goroutines 消费 channel 并把结果写到 output channel,一个 writer goroutine 把 output channel drain 到磁盘。

// stream.go
package main

import (
    "context"
    "encoding/csv"
    "fmt"
    "io"
    "os"
    "sync"
)

type job struct {
    ID      string
    Address string
}

type result struct {
    ID      string
    Address string
    Lat     float64
    Lng     float64
    Err     string
}

const concurrency = 8

func streamGeocode(ctx context.Context, inPath, outPath string) error {
    in, err := os.Open(inPath)
    if err != nil {
        return err
    }
    defer in.Close()

    out, err := os.Create(outPath)
    if err != nil {
        return err
    }
    defer out.Close()

    reader := csv.NewReader(in)
    writer := csv.NewWriter(out)
    defer writer.Flush()

    header, err := reader.Read()
    if err != nil {
        return err
    }
    idIdx, addrIdx := indexOf(header, "id"), indexOf(header, "address")
    if idIdx < 0 || addrIdx < 0 {
        return fmt.Errorf("csv must have id,address columns")
    }
    if err := writer.Write([]string{"id", "address", "lat", "lng", "error"}); err != nil {
        return err
    }

    jobs := make(chan job, concurrency*2)
    results := make(chan result, concurrency*2)

    var wg sync.WaitGroup
    for i := 0; i < concurrency; i++ {
        wg.Add(1)
        go worker(ctx, &wg, jobs, results)
    }

    // Writer goroutine drains results to disk.
    done := make(chan struct{})
    go func() {
        for r := range results {
            _ = writer.Write([]string{
                r.ID, r.Address,
                fmt.Sprintf("%f", r.Lat), fmt.Sprintf("%f", r.Lng), r.Err,
            })
        }
        close(done)
    }()

    // Producer: read CSV row by row.
    for {
        row, err := reader.Read()
        if err == io.EOF {
            break
        }
        if err != nil {
            close(jobs)
            wg.Wait()
            close(results)
            <-done
            return err
        }
        jobs <- job{ID: row[idIdx], Address: row[addrIdx]}
    }
    close(jobs)
    wg.Wait()
    close(results)
    <-done
    return nil
}

func worker(ctx context.Context, wg *sync.WaitGroup, jobs <-chan job, results chan<- result) {
    defer wg.Done()
    for j := range jobs {
        out, err := geocodeWithRetry(ctx, j.Address, "US", 5)
        if err != nil {
            results <- result{ID: j.ID, Address: j.Address, Err: err.Error()}
            continue
        }
        if len(out.Results) == 0 {
            results <- result{ID: j.ID, Address: j.Address, Err: "no_match"}
            continue
        }
        top := out.Results[0]
        if top.AccuracyScore < 0.7 {
            results <- result{ID: j.ID, Address: j.Address, Err: "low_confidence"}
            continue
        }
        results <- result{
            ID: j.ID, Address: j.Address,
            Lat: top.Location.Lat, Lng: top.Location.Lng,
        }
    }
}

func indexOf(row []string, name string) int {
    for i, c := range row {
        if c == name {
            return i
        }
    }
    return -1
}

func main() {
    if len(os.Args) < 3 {
        fmt.Fprintln(os.Stderr, "usage: stream input.csv output.csv")
        os.Exit(1)
    }
    if err := streamGeocode(context.Background(), os.Args[1], os.Args[2]); err != nil {
        fmt.Fprintln(os.Stderr, "error:", err)
        os.Exit(1)
    }
}

这个脚本做了三件初学者代码通常不会做的事。第一,输入是逐行读的,从不整体加载。第二,channel 的缓冲区很小(concurrency*2),所以 producer 自然地对 workers 形成 backpressure — 如果所有 workers 都忙,producer 在 channel 发送上阻塞,OS 让文件大部分留在磁盘上。第三,错误作为一列被记录而不是把整个 job 拖崩。这就是一个跑完 100K 行的脚本和一个必须用「停在哪了」的问题重启的脚本之间的区别。

类型化响应

本文开头的 struct 是最小可行的类型化。如果你想要带可选字段的完整覆盖,omitempty 是你的朋友 — 当某个字段缺失时,它让 JSON 输出干净,并避免在解码响应时的 zero-value 混淆。

type Components struct {
    HouseNumber string `json:"house_number,omitempty"`
    Street      string `json:"street,omitempty"`
    City        string `json:"city,omitempty"`
    State       string `json:"state,omitempty"`
    PostalCode  string `json:"postal_code,omitempty"`
    Country     string `json:"country,omitempty"`
}

type Meta struct {
    ResponseTimeMs int    `json:"response_time_ms"`
    Source         string `json:"source"`
}

type FullGeocodeResult struct {
    FormattedAddress string     `json:"formatted_address"`
    Location         Location   `json:"location"`
    Accuracy         string     `json:"accuracy"`
    AccuracyScore    float64    `json:"accuracy_score"`
    Components       Components `json:"components"`
}

type FullGeocodeResponse struct {
    Query   string              `json:"query"`
    Results []FullGeocodeResult `json:"results"`
    Meta    Meta                `json:"meta"`
}

Accuracy 定义为类型化别名(type Accuracy string 加上每个值的 const 声明)会给 switch 语句一个免费的 domain check。编译器不会捕捉字符串字面量的拼写错误,但当你对 Accuracy 做 switch 时,未处理的值至少在 diff 中可见。

Go 开发者会被咬到的事

忘了 defer resp.Body.Close()。 每一次返回非 nil response 的 http.Client.Do *必须* 跟上一个对 body 的 Close,即使你从不读 body。Go runtime 在 body 关闭并 drain 之前不会把底层 TCP 连接归还给 connection pool。漏掉一次,你的服务在压力下会泄漏 file descriptor,直到内核拒绝新的 socket。

使用默认的 http.Client。 http.Get 等同伴使用 http.DefaultClient,它有 Timeout: 0。一个行为不端的服务器会让那个 goroutine 无限期阻塞。始终用显式 timeout 声明一个 package-level 客户端,或者用带 deadline 的 http.NewRequestWithContext。同样的话也适用于 p99 latency 故事 — 没有 timeout 你就没有 p99,你只有一条无限延伸的尾巴。

无缓冲 channel 死锁。 如果 worker 从 results 读取,而 writer goroutine 还没启动,发送就会阻塞。如果 producer 从 jobs 读取,而没有 worker 存活,发送也会阻塞。streaming 例子里的 concurrency*2 缓冲对 forward progress 已经够用;选择一个让 producer 比 workers 领先约 1 步而又不存储整个输入的缓冲。

幼稚的 WaitGroup + channel 模式。 一个常见 bug:在 workers 完成前关闭 results channel,于是 writer goroutine 看到一个已关闭的 channel 并以部分输出退出。修复是在 streamGeocode 末尾的显式顺序:close(jobs) 先(workers 看到 jobs channel 关闭后退出),然后 wg.Wait() 确认所有 workers 都完成,然后 close(results),然后 <-done 等 writer。把这个顺序搞错,你会看到只在长输入上才能复现的 missing-rows bug。

常见问题

需要 SDK 吗?

不需要。标准库 — net/httpencoding/jsoncontexttime — 涵盖本文的一切。大多数团队最终会写的「SDK」是一个围绕 geocodeWithRetry 的薄包装、一组等价于 Pydantic 的 struct 以及一个 worker pool helper。维护你自己的约 150 行代码比跟踪第三方发布节奏和每六个月一次 vendored 破坏性变更更便宜。

应该把错误当作值来处理吗?

是的 — 这是 idiomatic 的 Go。retry 函数中的 *GeocodeError 类型把 HTTP 状态作为类型化字段携带,这样调用方可以对它做 switch 而不必解析字符串。用 fmt.Errorf("...: %w", err) 包装网络错误,让 errors.Iserrors.As 在调用栈中继续工作。对任何跨 API 边界的东西避免使用 panic;把 panic 留给真正的程序员 bug。

net/http 对比 fasthttp

geocoding 客户端继续用 net/httpfasthttp 在微基准层面更快,但 API 不同,不支持 HTTP/2,并且以会咬人的方式在 goroutines 间复用 request/response 对象 — 当你把它们交给 worker pool 时尤其麻烦。geocoding pipeline 的瓶颈是网络往返,不是 HTTP parser — fasthttp 给你节省的是纳秒,而 API 要花数百毫秒。

如何调优 concurrency?

取你计划的 per-minute rate limit,除以 60,瞄准大致那么多并发请求。1,000/分 ÷ 60 ≈ 17,所以在 Starter 上 pool size 16 是一个安全的默认。真相之源是 X-RateLimit-Remaining header — 在每次成功响应里记录它,留意它是否比每请求 1 个掉得更快。最佳 concurrency 是一条曲线,弯肘通常接近 (rate_limit / 60) * 1.5。完整方法见 concurrency tuning for geocoding

如何区分 no-match 和成功的低 confidence 匹配?

results 在 no-match 时是空 slice。在低 confidence 匹配时 results[0] 存在但 accuracy"postcode""place" 而不是 "houseNumber""street",并且 accuracy_score 远低于 1.0。一个合理的默认 threshold 是 0.7;低于这个就当 no-match 处理。更严格的 pipeline(保险风险评分、医疗患者映射)使用 0.95 并要求 accuracy == "houseNumber"。完整图景见 geocoding confidence scores explained

这对非美国地址有用吗?

有用。在 country 参数中传入正确的 ISO alpha-2 — 德国是 DE,英国是 GB,巴西是 BR,日本是 JP。覆盖范围今天涵盖 39 个国家,包括按地址数前 10 名:USA(1.21 亿地址)、巴西(9000 万)、墨西哥(3000 万)、法国(2600 万)、意大利(2600 万)等。API 页面 列出了每国数量。

应该用单条请求还是 batch endpoint?

当你一次有 ≥100 个地址要做且计划允许时(Starter+)使用 batch。一次 POST 比 100 次 GET 对双方都更便宜,节省的延迟大约是 avg_per_request_ms * count / concurrency。Singles 在地址随时间到达的 streaming workload 中更简单,singles 也是 free tier 上的唯一选择。完整权衡分析见 batch vs realtime geocoding

接下来去哪

API 的完整参考在 csv2geo.com/api。如果你更愿意用别的语言,Node.js geocoding 教程fetchp-limit 遵循同样结构,Python geocoding 教程 涵盖 httpxasyncio。本文涉及模式的更深入阅读:rate limiting algorithms comparedconcurrency tuning for geocoding,以及 为什么 p99 latency 比平均更重要

如果你发现一个边缘 case,碰到本文未覆盖的响应形状,或想要对你正在构建的 Go pipeline 的反馈,网站上的联系表会到一个会读它的真人。带 curl(或 go run)复现的 bug 报告会被很快修好。

I.A. / CSV2GEO Creator

相关文章

Ready to geocode your addresses?

Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.

Try Batch Geocoding Free →