Appearance
演唱会门票被黄牛秒光怎么防?用 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、账号常用地换成你们自己的业务字段,阈值按历史数据重新标定,就能上线。