Appearance
游戏服务器怎么按玩家 IP 归属地就近分配节点?入口网关的 Go 实现
玩家点「开始游戏」,客户端连了两个小时的是一个大区外的节点,延迟稳定在 180ms 以上,客服群里隔几天就有人问「为什么我打别人总慢半拍」。这类问题很少是带宽不够,多数时候是调度根本不知道玩家此刻在哪儿:账号注册地、上次登录地、随机轮询,哪个都不等于现在的位置。
这篇讲入口网关侧的做法:拿到登录请求的来源 IP,查一下归属地,映射到最近的接入节点。附带一份 Go 实现,含缓存、超时和降级——调度这块最怕的不是查不到,而是因为查归属地把玩家挡在登录门口。
一、这个场景到底在解决什么
先把需求拆开看,游戏接入和普通网站选 CDN 节点不完全一样:
- 运营商跨网比地理距离更致命。 同在华东,电信用户连到联通线路的节点,抖动可能比连到 600 公里外的同网节点还大。所以只看「哪个省近」不够,要带上
isp。 - 玩家的位置是流动的。 出差、回老家、用加速器,都会让当前出口 IP 和账号注册地完全对不上。调度该信的是本次请求的 IP,不是账号资料里的城市。
- 入口必须稳。 归属地查询是外部依赖,超时、限速、返回异常的每一种情况都得有默认节点兜底。为了「选得更准」而让玩家登录失败,是彻底的亏本买卖。
- 大区比省份更适合作路由键。 接口返回的
big_area(华东 / 华北 / 华南 / 西南等)粒度适中,节点数量有限时用它分组比逐省配置好维护。
二、方案设计
链路大概是这条:
客户端 → 登录/匹配网关 → 查归属地(带缓存、超时、降级)
→ 省份 / 大区 + 运营商
→ 节点路由表 → 返回接入节点地址几个设计取舍,比代码本身重要:
缓存是刚需,不是优化。 IP9 免费版 60 次/分钟(约 1 秒 1 次),而游戏登录的 QPS 是它的成百上千倍。同一个 IP 在一段时间内归属地不会变,本地缓存 TTL 设 6 小时很合理;网关多实例部署时,把缓存换成 Redis 共享,命中率还能再高一个量级。
同一 IP 的并发请求要合并。 新玩家涌入时,同一批校园网、网吧出口 IP 会同时来一堆请求。缓存穿透的话,一瞬间几十个协程去查同一个 IP,白白撞限速。用一个「单飞」(singleflight)逻辑把并发合并成一次真实请求。
调度超时必须短。 归属地查询给 300ms 预算,超了就直接走默认节点;这个代价是延迟略差一点,比玩家坐在登录界面转圈好得多。
路由表按「省 → 大区 → 默认」三级回退。 同省同运营商最优,退而求其次同大区同运营商,再退就是默认节点。每一级都命中概率明确,线上出问题看日志就知道卡在哪一级。
三、Go 实现
核心是一个 Locator(带 TTL 缓存 + 并发合并 + 超时重试)加一个节点选择函数,用标准库就能跑,不需要引入第三方包。
go
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"sync"
"time"
)
const apiURL = "https://ip9.com.cn/get"
// ---------- 接口返回结构 ----------
type Location struct {
IP string `json:"ip"`
Country string `json:"country"`
Prov string `json:"prov"`
City string `json:"city"`
ISP string `json:"isp"`
BigArea string `json:"big_area"` // 华东 / 华北 / 华南 / 西南…
}
type apiResp struct {
Ret int `json:"ret"`
Data *Location `json:"data"`
}
// ---------- 带缓存与并发合并的查询器 ----------
type cacheItem struct {
loc *Location
exp time.Time
}
type inflight struct {
wg sync.WaitGroup
loc *Location
err error
}
type Locator struct {
client *http.Client
ttl time.Duration
mu sync.Mutex
cache map[string]cacheItem
inflight map[string]*inflight
}
func NewLocator() *Locator {
return &Locator{
client: &http.Client{Timeout: 300 * time.Millisecond}, // 给调度留的时间就这么多
ttl: 6 * time.Hour,
cache: make(map[string]cacheItem),
inflight: make(map[string]*inflight),
}
}
// Lookup 查归属地:先看缓存,再看是否有同 IP 请求在飞,否则真查一次。
func (l *Locator) Lookup(ctx context.Context, ip string) (*Location, error) {
l.mu.Lock()
if it, ok := l.cache[ip]; ok && it.exp.After(time.Now()) {
l.mu.Unlock()
return it.loc, nil
}
if f, ok := l.inflight[ip]; ok { // 同一 IP 已有请求在路上,等它的结果
l.mu.Unlock()
f.wg.Wait()
return f.loc, f.err
}
f := &inflight{}
f.wg.Add(1)
l.inflight[ip] = f
l.mu.Unlock()
loc, err := l.fetch(ctx, ip)
l.mu.Lock()
delete(l.inflight, ip)
if err == nil {
l.cache[ip] = cacheItem{loc: loc, exp: time.Now().Add(l.ttl)}
}
l.mu.Unlock()
f.loc, f.err = loc, err
f.wg.Done()
return loc, err
}
func (l *Locator) fetch(ctx context.Context, ip string) (*Location, error) {
loc, err := l.doFetch(ctx, ip)
if err == errRateLimited { // 撞限速:等一下再试一次,还不行就让上层走降级
select {
case <-time.After(300 * time.Millisecond):
case <-ctx.Done():
return nil, ctx.Err()
}
return l.doFetch(ctx, ip)
}
return loc, err
}
var errRateLimited = fmt.Errorf("rate limited")
func (l *Locator) doFetch(ctx context.Context, ip string) (*Location, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL+"?ip="+url.QueryEscape(ip), nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "game-gateway/1.0")
resp, err := l.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var body apiResp
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, err
}
switch body.Ret {
case 200:
if body.Data == nil {
return nil, fmt.Errorf("empty data")
}
return body.Data, nil
case 400: // IP 格式不合法,不用重试
return nil, fmt.Errorf("invalid ip: %s", ip)
case 429:
return nil, errRateLimited
default:
return nil, fmt.Errorf("ret=%d", body.Ret)
}
}
// ---------- 节点路由表 ----------
type Node struct {
Name string // 节点名,日志用
Addr string // 实际下发地址
ISP string // 线路归属运营商,"any" 表示不限
}
// 生产环境这张表从配置中心下发,这里写死演示
var nodeTable = map[string][]Node{
"华东": {
{Name: "ec-telecom-1", Addr: "ec-tel.game.example.com:7000", ISP: "中国电信"},
{Name: "ec-unicom-1", Addr: "ec-uni.game.example.com:7000", ISP: "中国联通"},
{Name: "ec-common-1", Addr: "ec.game.example.com:7000", ISP: "any"},
},
"华北": {
{Name: "nc-telecom-1", Addr: "nc-tel.game.example.com:7000", ISP: "中国电信"},
{Name: "nc-common-1", Addr: "nc.game.example.com:7000", ISP: "any"},
},
"华南": {
{Name: "sc-telecom-1", Addr: "sc-tel.game.example.com:7000", ISP: "中国电信"},
{Name: "sc-mobile-1", Addr: "sc-mob.game.example.com:7000", ISP: "中国移动"},
{Name: "sc-common-1", Addr: "sc.game.example.com:7000", ISP: "any"},
},
"西南": {
{Name: "sw-common-1", Addr: "sw.game.example.com:7000", ISP: "any"},
},
}
// 兜底节点:任何查询失败的情况都走这里,宁可延迟兜底也不能让玩家登不进来
var fallbackNode = Node{Name: "default-ec", Addr: "gw-default.game.example.com:7000", ISP: "any"}
// PickNode 三级回退:同大区同运营商 → 同大区任意 → 默认节点
func PickNode(loc *Location) (Node, string) {
if loc == nil || loc.BigArea == "" { // 海外 IP、港澳台等没有大区字段
return fallbackNode, "no_big_area"
}
nodes, ok := nodeTable[loc.BigArea]
if !ok {
return fallbackNode, "area_not_configured"
}
for _, n := range nodes { // 第一优先:大区内同运营商线路
if n.ISP == loc.ISP {
return n, "area_same_isp"
}
}
for _, n := range nodes { // 第二优先:大区内任意线路
if n.ISP == "any" {
return n, "area_any_isp"
}
}
return nodes[0], "area_first"
}
// ---------- 入口网关 ----------
type Gateway struct {
locator *Locator
}
func (g *Gateway) LoginNode(w http.ResponseWriter, r *http.Request) {
ip := r.URL.Query().Get("ip")
ctx, cancel := context.WithTimeout(r.Context(), 300*time.Millisecond)
defer cancel()
loc, err := g.locator.Lookup(ctx, ip)
node, reason := PickNode(loc)
if err != nil {
reason = "lookup_failed:" + reason
log.Printf("归属地查询失败 ip=%s err=%v,已走默认节点", ip, err)
} else {
log.Printf("调度 ip=%s prov=%s big_area=%s isp=%s node=%s rule=%s",
ip, loc.Prov, loc.BigArea, loc.ISP, node.Name, reason)
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
json.NewEncoder(w).Encode(map[string]string{
"node": node.Addr,
"rule": reason,
})
}
func main() {
gw := &Gateway{locator: NewLocator()}
http.HandleFunc("/login-node", gw.LoginNode)
log.Println("gateway listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}跑起来之后可以这样验证(58.246.100.1 是上海联通的地址):
bash
curl 'http://127.0.0.1:8080/login-node?ip=58.246.100.1'
# {"node":"ec-uni.game.example.com:7000","rule":"area_same_isp"}rule 字段建议保留并打进监控:如果 lookup_failed 的比例突然抬头,说明接口侧或者本机出网有问题;如果 area_first 变多,说明路由表的运营商覆盖不全,该补节点了。
四、上线前值得想清楚的几件事
归属地字段会在个别 IP 上为空。 海外 IP 通常没有 big_area,部分边界 IP 的省市区也可能缺失,代码里必须走兜底而不是拿空字符串去查表。上面 PickNode 的第一行判断就是处理这个。
别用经纬度算「距离最近」。 接口返回的 lng / lat 是城市中心点,同一个城市上万个玩家都是同一个坐标,用它算两点距离做节点排序会引入假精度。按大区 / 省份分组的离散表更靠谱,也更好维护。
跨网延迟比地理距离更值得优先。 如果只能做一层判断,优先做「同运营商」,第二层再考虑地理就近。实际压测下来,这两层的收益顺序经常和直觉相反。
调度结果不要拿来做处罚。 玩家 IP 变化频繁,加速器、代理、运营商动态分配都会让归属地跳来跳去。归属地用于选节点、做灰度、看大盘分布都可以,唯独不能作为封号依据,站内《IP 风控总误伤正常用户》那篇讲共享出口豁免时提过这点。
网关多实例记得把缓存外置。 单实例内存缓存上线初期够用,实例扩到十几台后,同一个 IP 在每台机器上各查一次,免费版额度很快就撑不住。换成 Redis 存 ip -> 归属地 映射,TTL 6 小时,命中率基本能到 95% 以上。真正的请求量再往上走,就该考虑 VIP 版(18 万次/分钟)或私有化部署。
代码里调的是 IP9 的免费接口 https://ip9.com.cn/get?ip=<IP>,不传参数时返回调用方自身 IP 的归属地,IPv4 和 IPv6 都支持,返回字段里有省、市、大区(big_area)、运营商(isp)、邮编、区号、城市中心经纬度等,免注册、无需鉴权,免费版 60 次/分钟。要做更细的区分(比如判断某个 IP 是家庭宽带还是 IDC 机房,机房 IP 往往是加速器或者工作室),VIP 版额外提供 ip_type 和 area(区县)。接口说明以官网为准:https://www.ip9.com.cn