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 是 300/分 — 从 5 开始。API 10 到 API 100($54–$294)是 1,500/分 — 从 25 开始。API 250($604)是 1,800/分 — 从 30 开始,并观察 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

包括 Free 在内的所有 tier 都提供一个 batch endpoint,它在一次 POST 中接受一个地址数组。计划上限:

| 计划 | 每月额度 | 每分钟 | Batch 大小 | |---|---|---|---| | Free | 3,000/天 | 300 | 1,000 | | API 10 ($54) | 300,000 | 1,500 | 1,000 | | API 25 ($104) | 750,000 | 1,500 | 1,000 | | API 50 ($174) | 1,500,000 | 1,500 | 1,000 | | API 100 ($294) | 3,000,000 | 1,500 | 1,000 | | API 250 ($604) | 7,500,000 | 1,800 | 1,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 — 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,500/分 ÷ 60 = 25,所以在付费 API tier 上 pool size 24 是一个安全的默认。真相之源是 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 个地址要做时使用 batch — 包括 Free 在内的所有 tier 都允许。一次 POST 比 100 次 GET 对双方都更便宜,节省的延迟大约是 avg_per_request_ms * count / concurrency。Singles 在地址随时间到达的 streaming workload 中更简单,batch 在 free tier 上同样可用,在其每天 3,000 次调用的范围内。完整权衡分析见 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

相关文章

More in Developers →
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 →