Skip to content

游戏服务器怎么按玩家 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_typearea(区县)。接口说明以官网为准:https://www.ip9.com.cn