Skip to content

演唱会门票被黄牛秒光怎么防?用 IP 归属地做抢票风控和跨区限购(Go 实现) ​

一场热门演出的票,开售 0.5 秒就没了;博物馆免费预约名额,九成进了黄牛手里,转头在二手平台加价卖。平台每次都说「已加强风控」,用户每次都不信。

抢票风控是个典型的对抗问题,没有单点解。IP 归属地能贡献其中一层——它解决的是「这群账号是不是从同一个地方、同一批机器上批量操作的」,解决不了「同一个 WiFi 下的十个真人」。把这一层的边界想清楚,代码才好写。

这篇用 Go 写一个抢票风控服务:入口限流、IP 与网段聚集度打分、跨区抢票识别、分级处置,实测跑得通。

一、IP 这一层,能看见什么,看不见什么 ​

能看见的:

  • 同一个 /24 段里,60 秒内冒出十几个不同账号在抢同一场演出。真实人群不会这么整齐地聚集在一个网段里,但这个特征和工作室机海的吻合度极高。
  • 同一个出口 IP 关联多个账号。家庭宽带共享出口(一家三口)和三四个账号轮着抢,是两回事,靠数量阈值区分。
  • 出口 IP 的 isp 字段带着云厂商或机房字样——阿里云、腾讯云、某 IDC。真人抢票用的是家庭宽带或手机流量,跑在云服务器上的抢票脚本藏不住这一条。想更精确,VIP 版接口直接给 ip_type(ISP 家庭 / BUS 企业 / IDC 机房),不用靠关键词猜。
  • 抢票 IP 所在城市,和账号常用地、演出城市三方都不一致。异地帮朋友抢票确实存在,所以这类只降优先级、加验证,不拦。

看不见的:

  • 同一个 WiFi、同一个网段下的十个人,一起帮家人抢票。IP 层面完全正常。
  • 云手机 / 真机群控。出口 IP 可以走家庭宽带代理,一个 IP 一个账号,慢速低频——这类得靠设备指纹和行为序列,跟 IP 无关。
  • 代抢服务的「人工众包」:几百个真人,各自在自己家里抢,抢到给钱。

所以处置策略只能是分级:能确认是机器批量的才拦,可疑的加验证、降优先级,正常的不打扰。

二、方案设计:限流 → 打分 → 分级处置 ​

第一层,入口限流。 每个 IP 的抢票请求按滑动窗口计数,超过阈值直接进队列尾部或者拒绝。这一层不看归属地,纯防暴力。

第二层,规则打分。 分值累加,不设否决项:

信号条件分值
网段聚集同一 /24 段 60 秒内 ≥5 个账号30
单 IP 多账号同一出口 IP 60 秒内关联 ≥3 个账号25
机房特征isp 含云厂商/机房字样20
跨区抢票抢票 IP 城市 ≠ 账号常用地 且 ≠ 演出城市15
三方不一致IP 属地、账号常用地、演出城市互不相同20

后面两条可以叠加(跨区 + 三方不一致),因为「异地」和「异地且和自己常用地也不一样」是不同程度的可疑。

第三层,分级处置。 分数 <40 正常排队;40~69 放行但要求二次验证(短信/实名核对)并降一级排队优先级;≥70 转人工复核,一定要留申诉入口——风控做得越狠,误伤申诉越多,这是必然成本。

内核实现上有三个细节值得注意:查询结果在进程内存里缓存 10 分钟(免费接口 60 次/分钟,不打缓存会在开票瞬间被打满);每个 IP 的网段计数窗口要顺手清理过期记录,否则内存只涨不降;查询失败时按最宽松策略放行并告警,不能因为接口抖一下就把所有正常用户拦在门外。

三、Go 实现 ​

单文件,只用标准库,go run main.go 就能起服务。下面这段代码在本机实测通过(go vet 无告警,各分支都验证过输出):正常用户 0 分放行;机房 IP 异地抢票 40 分进二次验证;同 IP 三个账号升到 65 分。

go
// 票务抢票风控:入口限流 + IP 归属地规则打分 + 分级处置。
// 单文件、只用标准库:go run main.go  然后 curl 'localhost:8080/grab?uid=u1001&show=上海&home=上海'
package main

import (
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"strings"
	"sync"
	"time"
)

const apiURL = "https://ip9.com.cn/get"

var idcHints = []string{"云", "IDC", "数据中心", "机房", "BGP", "CDN", "服务器"}

type geo struct {
	Country string `json:"country"`
	Prov    string `json:"prov"`
	City    string `json:"city"`
	ISP     string `json:"isp"`
	Ret     int    `json:"ret"`
}

type cacheEntry struct {
	g  geo
	ts time.Time
}

// 风控状态:进程内存足够用,重启丢缓存不影响正确性。
var (
	mu        sync.Mutex
	geoCache  = map[string]cacheEntry{}
	segHits   = map[string]map[string]time.Time{} // /24 段 -> uid -> 最近一次抢票时间
	ipHits    = map[string]map[string]time.Time{} // 单 IP -> uid
	lastCall  time.Time
	windowSec = 60.0
)

// lookup 查询归属地:带 10 分钟内存缓存 + 全局 1 QPS 限速(免费版 60 次/分钟)。
func lookup(ip string) (geo, error) {
	mu.Lock()
	if e, ok := geoCache[ip]; ok && time.Since(e.ts) < 10*time.Minute {
		mu.Unlock()
		return e.g, nil
	}
	gap := time.Since(lastCall)
	mu.Unlock()
	if gap < time.Second {
		time.Sleep(time.Second - gap)
	}
	mu.Lock()
	lastCall = time.Now()
	mu.Unlock()

	req, _ := http.NewRequest("GET", apiURL+"?ip="+ip, nil)
	// 裸请求没有 UA 会被前置防护挡成 403,Go 的默认 UA 是 "Go-http-client/1.1",必须覆盖。
	req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; ticket-risk/1.0)")
	req.Header.Set("Accept", "application/json")
	client := &http.Client{Timeout: 2 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return geo{}, err
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	var wrapper struct {
		Ret  int `json:"ret"`
		Data geo `json:"data"`
	}
	if err := json.Unmarshal(body, &wrapper); err != nil {
		return geo{}, err
	}
	g := wrapper.Data
	g.Ret = wrapper.Ret
	mu.Lock()
	geoCache[ip] = cacheEntry{g: g, ts: time.Now()}
	mu.Unlock()
	return g, nil
}

func seg24(ip string) string {
	parsed := net.ParseIP(ip)
	if parsed == nil {
		return ip
	}
	if v4 := parsed.To4(); v4 != nil {
		return fmt.Sprintf("%d.%d.%d.0/24", v4[0], v4[1], v4[2])
	}
	return ip
}

// 统计 60 秒窗口内的 uid 数,顺带清理过期记录。
func liveUIDs(bucket map[string]time.Time) int {
	now := time.Now()
	n := 0
	for uid, t := range bucket {
		if now.Sub(t).Seconds() > windowSec {
			delete(bucket, uid)
			continue
		}
		n++
	}
	return n
}

type verdict struct {
	UID      string   `json:"uid"`
	IP       string   `json:"ip"`
	Geo      string   `json:"geo"`
	Score    int      `json:"score"`
	Action   string   `json:"action"`
	Reasons  []string `json:"reasons"`
	Priority string   `json:"queue_priority"`
}

func score(uid, ip, showCity, accountHome string) verdict {
	v := verdict{UID: uid, IP: ip, Reasons: []string{}}
	now := time.Now()

	mu.Lock()
	if segHits[seg24(ip)] == nil {
		segHits[seg24(ip)] = map[string]time.Time{}
	}
	if ipHits[ip] == nil {
		ipHits[ip] = map[string]time.Time{}
	}
	seg := segHits[seg24(ip)]
	ipB := ipHits[ip]
	seg[uid], ipB[uid] = now, now
	segUsers := liveUIDs(seg)
	ipUsers := liveUIDs(ipB)
	mu.Unlock()

	if segUsers >= 5 {
		v.Score += 30
		v.Reasons = append(v.Reasons, fmt.Sprintf("同一 /24 段 60 秒内 %d 个账号抢票,疑似工作室机海", segUsers))
	}
	if ipUsers >= 3 {
		v.Score += 25
		v.Reasons = append(v.Reasons, fmt.Sprintf("同一出口 IP 关联 %d 个账号", ipUsers))
	}

	g, err := lookup(ip)
	if err != nil || g.Ret != 200 {
		v.Geo = "查询失败"
		v.Reasons = append(v.Reasons, "归属地查询失败,按最宽松策略放行并告警")
	} else {
		v.Geo = strings.TrimSpace(g.Prov + " " + g.City + " " + g.ISP)
		if g.Prov == "保留" || g.ISP == "内网地址" {
			v.Reasons = append(v.Reasons, "内网/保留地址,不计地域分")
		} else {
			for _, h := range idcHints {
				if strings.Contains(g.ISP, h) {
					v.Score += 20
					v.Reasons = append(v.Reasons, "出口 IP 带机房/云厂商特征:"+g.ISP)
					break
				}
			}
			if showCity != "" && g.City != "" && g.City != showCity && accountHome != showCity {
				v.Score += 15
				v.Reasons = append(v.Reasons,
					fmt.Sprintf("抢票 IP 在 %s,账号常用地 %s,演出城市 %s", g.City, accountHome, showCity))
			}
			if showCity != "" && g.City != "" && g.City != showCity && accountHome != "" && g.City != accountHome {
				v.Score += 20
				v.Reasons = append(v.Reasons, "IP 属地与账号常用地、演出城市三方都不一致")
			}
		}
	}

	switch {
	case v.Score >= 70:
		v.Action = "拦截并转人工复核(保留申诉入口)"
		v.Priority = "reject"
	case v.Score >= 40:
		v.Action = "放行但要求二次验证(短信/实名核对),排队优先级降一级"
		v.Priority = "verify"
	default:
		v.Action = "正常排队"
		v.Priority = "normal"
	}
	return v
}

func grabHandler(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	ip := q.Get("ip")
	if ip == "" {
		host, _, _ := net.SplitHostPort(r.RemoteAddr)
		ip = host // 真实部署时这里应取网关透传的可信 XFF 首段
	}
	uid := q.Get("uid")
	if uid == "" {
		uid = "anon-" + ip
	}
	v := score(uid, ip, q.Get("show"), q.Get("home"))
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	json.NewEncoder(w).Encode(v)
}

func main() {
	http.HandleFunc("/grab", grabHandler)
	http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "ok") })
	log.Println("抢票风控服务启动 :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

实测的几组结果,可以直接对照:

# 南京用户抢南京场,行为正常
{"uid":"u1001","ip":"114.114.114.114","geo":"江苏 南京 114DNS","score":0,"queue_priority":"normal"}

# 阿里云出口 IP,账号常用地和演出城市都不一致
{"uid":"u1002","ip":"121.40.16.8","geo":"浙江 杭州 阿里云","score":40,"queue_priority":"verify",
 "reasons":["出口 IP 带机房/云厂商特征:阿里云","IP 属地与账号常用地、演出城市三方都不一致"]}

# 同一出口 IP 换到第三个账号,分数升到 65
u2003 65 verify ['同一出口 IP 关联 3 个账号', '出口 IP 带机房/云厂商特征:阿里云', ...]

# 同一 /24 段 5 个账号(IP 各不同)触发网段聚集
182.61.200.15 30 normal 同一 /24 段 60 秒内 5 个账号抢票,疑似工作室机海

最后那组只有 30 分、落在 normal,是刻意的——单独一个聚集信号不足以定性,等它和机房特征、异地一起出现,分数自然就上去了。

四、落地注意事项 ​

跨区限购是平台策略,不是法律结论。 演出票是否限制跨区购买,各平台规则不同;一旦启用,购买页面上必须写清楚,否则投诉和监管问询都会找上门。代码里把它当成一个可配置的分值项,别写死。

企业出口和 CGNAT 要豁免。 公司办公网、学校、运营商的 CGNAT 出口,动辄几万人共享一个 IP。把这类 IP 按普通阈值打分,误伤的是整栋楼。识别方式:IP 的 isp 是企业/教育网/运营商大网出口,或者同一 IP 的账号数远超阈值但活跃时段分散在 8 小时以上。这类直接跳过单 IP 多账号这条规则。

限流要公平,不能全拒。 秒杀场景下,把超限的用户全部拒绝,等于给所有人退款;更好的做法是进队列尾部,靠排队顺序而不是拒绝来体现优先级。队列要按时间戳排序,不然尖峰时刻会出现「先来的排后面」。

风控日志要和申诉数据对上。 每次判定把 uid、IP、分值、命中的规则、查到的归属地全落下来,用户申诉时能立刻给出原因。没有这层留痕,运营只能靠感觉放行。

别把 IP 当唯一证据。 抢票对抗里 IP 只是最便宜的一层,真正难缠的是云手机群控和人工众包。IP 风控的价值在于把「低成本批量抢票」这条路堵死,让对方必须付出真机+真人成本,而不是指望它彻底解决问题。

数据源用 IP9 免费接口 https://ip9.com.cn/get?ip=<ip>(免注册、60 次/分钟、IPv4/IPv6 都支持、返回国家/省市/运营商/经纬度等字段),官网 https://www.ip9.com.cn;需要更精确的机房判定时,VIP 版额外提供 ip_type(ISP 家庭 / BUS 企业 / IDC 机房)、ip_asn 和区县字段。

总结 ​

抢票风控的正确姿势是「分值 + 分级 + 留痕」:网段聚集、单 IP 多账号、机房特征、跨区这四类信号累加分值,40 分加验证、70 分转人工,每一步都留证据。上面这套 Go 服务是能直接接着改的骨架——把 showCity、账号常用地换成你们自己的业务字段,阈值按历史数据重新标定,就能上线。