Appearance
PCDN 节点怎么识别?用 IP 归属地和流量特征排查私跑 PCDN(Go 实现)
某视频平台的带宽成本季度报表突然涨了 30%,机房出口流量曲线却看不出明显异常——再一查,大量内容请求没有命中 CDN 节点,而是被「分享」到了全国各地的家庭宽带用户家里。这些用户用闲置路由器和盒子跑着 PCDN 程序,把大流量从机房搬到了别人家。平台侧想把这些节点识别出来踢出调度,第一反应是「查 IP 归属地」,但拿到的字段全是省市和运营商,看不出任何问题——因为 PCDN 节点的 IP,本来就是最普通的家庭宽带 IP。
识别 PCDN 从来不是「查个归属地」就能完事,但 IP 归属地确实是整套判定里不可缺少的一环。这篇文章讲清楚平台侧怎么用「IP 归属地 + 流量特征」的组合把私跑 PCDN 的节点捞出来,给一段 Go 实现。
一、PCDN 节点为什么难认
PCDN(P2P CDN)把内容分发下沉到普通用户的家庭带宽:每个参与者的路由器、电视盒子、NAS 都变成「边缘节点」,上行带宽被用来给其他人供流。对内容平台来说这是省钱的路子,对运营商来说是破坏网络秩序的行为——国家整治 PCDN 的文件早就出了,各地运营商也在限速封堵。
难认在哪?三层:
- IP 层无异常:节点 IP 归属地是正常城市、运营商是正常家宽运营商(电信/联通/移动),和普通用户一模一样;
- 机房特征不存在:它不在 IDC,查 ASN 也是家庭宽带网络,用「机房 IP」规则直接漏光;
- 只有流量维度露馅:长期高上行、连接数巨大、活跃时段固定(晚上睡觉也满负荷)——这些才是 PCDN 和马甲。
所以判定链是:先靠 IP 归属地/运营商给会话打上「家宽」标签,再叠加流量特征打分,两把尺子一起量。只量一把,要么全漏、要么全误伤。
二、方案设计
- 数据源:网关或交换机导出的连接日志(五元组 + 上下行字节数 + 同时连接数),不需要全部流量,抽样一小时足够;
- 特征分组:按「源 IP」聚合,计算上行/下行比、峰值并连接数、24 小时活跃度;
- 归属地打标:每个源 IP 查归属地和运营商,
isp字段区分家宽运营商(VIP 版还能拿ip_type直接分 ISP/BUS/IDC,识别更省事); - 打分规则:家宽 + 上行占比 > 60% + 并连接数 > 阈值 + 夜间活跃 → 嫌疑分累加,超过阈值进名单;
- 输出:嫌疑节点 Top 表(IP、归属地、运营商、特征指标),交给运维核对。
免费版 IP 接口 60 次/分钟/IP,批量查必须限速 + 缓存。下面的 Go 程序里配额按 50 次/分钟留余量,并发用一个 worker 池压住。
三、Go 实现
go
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
)
// 连接日志一行: 源IP 上行字节 下行字节 并连接数 时段
// 例: 112.10.22.33 9830400 2457600 128 night
type NodeInfo struct {
Up int64 `json:"up"`
Down int64 `json:"down"`
Conn int `json:"conn"`
}
type Geo struct {
Prov string `json:"prov"`
City string `json:"city"`
ISP string `json:"isp"`
}
const (
apiBase = "https://ip9.com.cn/get?ip="
maxQPS = 50 // 免费版 60 次/分钟,留 10 次余量
suspectTh = 3 // 嫌疑分阈值
)
var (
geoCache = sync.Map{}
rateLimit = time.Tick(time.Second / maxQPS)
)
func queryGeo(ip string) *Geo {
if v, ok := geoCache.Load(ip); ok {
return v.(*Geo)
}
<-rateLimit // 全局限速
client := http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(apiBase + ip)
if err != nil {
return nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var d struct {
Ret int `json:"ret"`
Data Geo `json:"data"`
}
if err := json.Unmarshal(body, &d); err != nil || d.Ret != 200 {
return nil // 非法 IP 或限速,跳过
}
geoCache.Store(ip, &d.Data)
return &d.Data
}
// 判断家宽:运营商字段命中三大运营商且非专线/机房特征。
func isResidentialISP(isp string) bool {
for _, kw := range []string{"电信", "联通", "移动", "广电"} {
if strings.Contains(isp, kw) {
return true
}
}
return false
}
// 特征打分:每命中一条加 1 分。
func scoreNode(g *Geo, n *NodeInfo) int {
s := 0
if isResidentialISP(g.ISP) {
s++
}
if n.Up > 0 && n.Up > n.Down*3 { // 上行远大于下行:纯供流特征
s++
}
if n.Conn > 500 { // 并连接数远超普通家宽上网
s++
}
return s
}
func main() {
logPath := flag.String("log", "conn.log", "连接日志路径")
flag.Parse()
// 1. 按源 IP 聚合连接日志
agg := map[string]*NodeInfo{}
f, err := os.Open(*logPath)
if err != nil {
fmt.Fprintln(os.Stderr, "打开日志失败:", err)
os.Exit(1)
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
parts := strings.Fields(sc.Text())
if len(parts) < 5 {
continue
}
var up, down int64
var conn int
fmt.Sscanf(parts[1], "%d", &up)
fmt.Sscanf(parts[2], "%d", &down)
fmt.Sscanf(parts[3], "%d", &conn)
k := parts[0]
if _, ok := agg[k]; !ok {
agg[k] = &NodeInfo{}
}
agg[k].Up += up
agg[k].Down += down
if conn > agg[k].Conn {
agg[k].Conn = conn
}
}
// 2. 并发查 IP 归属地(worker 池)
type item struct {
ip string
n *NodeInfo
}
ips := make([]item, 0, len(agg))
for ip, n := range agg {
ips = append(ips, item{ip, n})
}
results := make(chan struct {
ip string
g *Geo
n *NodeInfo
}, len(ips))
var wg sync.WaitGroup
for i := 0; i < 8; i++ { // 8 个 worker,全局限速在 queryGeo 内
wg.Add(1)
go func() {
defer wg.Done()
for _, it := range ips {
results <- struct {
ip string
g *Geo
n *NodeInfo
}{it.ip, queryGeo(it.ip), it.n}
}
}()
}
go func() { wg.Wait(); close(results) }()
// 3. 打分排序
type row struct {
ip string
geo *Geo
up int64
down int64
conn int
score int
}
var rows []row
for r := range results {
if r.g == nil {
continue
}
s := scoreNode(r.g, r.n)
if s >= suspectTh {
rows = append(rows, row{r.ip, r.g, r.n.Up, r.n.Down, r.n.Conn, s})
}
}
sort.Slice(rows, func(i, j int) bool { return rows[i].score > rows[j].score })
// 4. 输出嫌疑名单
fmt.Printf("%-16s %-8s %-8s %-6s %10s %10s %6s %s\n",
"IP", "省份", "城市", "运营商", "上行MB", "下行MB", "连接", "分")
for _, r := range rows {
fmt.Printf("%-16s %-8s %-8s %-6s %10.1f %10.1f %6d %d\n",
r.ip, r.geo.Prov, r.geo.City, r.geo.ISP,
float64(r.up)/1048576, float64(r.down)/1048576, r.conn, r.score)
}
fmt.Printf("\n嫌疑节点 %d 个\n", len(rows))
}代码里三层逻辑对应开头说的判定链:isResidentialISP 用归属地接口的运营商字段把节点分成「家宽/非家宽」,scoreNode 叠加流量特征打分,最后的名单让运维人工复核。worker 池开的 8 个并发不会冲破限速,因为真正的节流在 queryGeo 里的全局 rateLimit。
四、落地注意事项
- 归属地是辅助,别当主判据:PCDN 判定必须带流量维度,只靠归属地等于没查;反过来只靠流量特征,公司 VPN 出口、办公室大内网上行也会误伤;
- 阈值先粗后细:上行比 3 倍、连接数 500 是保守初值,先跑一周样本看误报率再调,宁可先漏后严;
- 时段的权重:夜间(23 点—6 点)满负荷上行是最硬的 PCDN 信号,评分里可以单独加权重;
- 合规提示:识别到的是「疑似私跑 PCDN 的家庭宽带」,处理方式应是对接运营商或按用户协议处置,别公开点名;判断依据留日志,方便后续举证。
总结
PCDN 节点长得和普通家庭用户一模一样,识别靠两把尺子:归属地接口(免费版 https://ip9.com.cn/get?ip=xxx 的运营商/省市字段,VIP 版再加 ip_type 区分 ISP/BUS/IDC)打「家宽」标签,流量特征(上行比、并连接数、活跃时段)打「供流」标签,两把尺子一起量再打分。上面这段 Go 代码可以直接跑:喂一小时连接日志,出来一张嫌疑节点表。先试跑一周,把误报率调下来,再接进调度系统做节点摘除,比人工翻日志高效得多。官网:https://www.ip9.com.cn