☰
PHP按省生成随机IPv4地址:倒排索引与加权抽样实践
2026/10/26 3:57:24 网站建设 项目流程

简介:面向网络编程与地理定位学习者的PHP类工具包,围绕按省份随机生成IPv4地址的需求封装实现,可用于模拟访问、网络测试与数据分析前的环境构造,适合已掌握PHP基础并希望理解IP分段与整数转换的开发者参考。压缩包共85个文件,以83个PHP源码为主体,另附1个txt说明与1个md文档,整体约110KB,便于查阅类结构、IP段数据与调用方式;说明文档交代IP段数据结构与使用边界,源码中的分段查询与生成逻辑也便于二次开发。目前已有301人学习。读者可了解inet_aton与inet_ntoa在IP和32位整数间的转换、按省份划分起止地址段、在区间内随机取值的完整链路,也能查看类属性存储省份与IP范围、构造函数接收省份参数、generateIP等方法组织代码的思路,为网络测试数据构造与相关实验提供可复用脚本参考。

1. 手上有 PHP 业务要演示,怎么造一批归属到指定省份的 IP

上周帮一个做地域化推荐的朋友收拾现场:他用rand(0,255)拼了四段 IP 灌进测试库,结果一查归属地,九成落在保留段和机房网段上,能查出来的还横跨好几个省,演示当场尴尬。他真正要的是“随机、但归属明确落在某个省”的 IPv4 地址,用来做多地域分流、灰度名单、地区化展示的回归数据。

这份 PHP 类干的正是这件事:读一张 IP 段表,按省份做倒排,再在省内地址空间里加权随机取址,输出格式合法、归属可查的 IPv4。下面把段表怎么倒排、随机怎么做才不偏、保留地址怎么滤、批量跑到十万条为什么会爆内存,一条条拆开。适合手上有一张离线归属地库、又不想为此单独起一套服务的 PHP 开发者。

2. 从段表到按省索引:倒排构建与加权抽样的四个细节

2.1 正向查询和反向取址,差的不止一个方向

离线归属地库的原始形态几乎都是“段 → 归属地”:一张按起始地址升序排列的区间表,每行记着起始 IP、结束 IP、归属地编码。查一个 IP 落在哪,对起始地址做二分,四十万段也就十九次比较,毫秒级都算不上。

但“给我一个属于某省的 IP”是反过来的问题。库里没有省这一维的索引,你只能从头到尾扫一遍,把该省的段全挑出来。四十万行在 PHP 里线性扫一次,几百毫秒起步,批量生成时这条路线直接封死。

不少人第一反应是拒绝采样:随机拼一个 IP,查归属地,是目标省就留下,不是就重来。这个思路能跑,但代价被严重低估。一个地址量中等的省份,地址数占整个 IPv4 空间的比例往往在千分之几量级,意味着平均要采几百次才能中一次,每次还要走一遍二分。省份越小越惨,抽到小省时循环次数会上一个数量级,而且中间大量采样落在根本没分配的段上,纯属空转。

真正省事的做法是在加载阶段一次性把方向倒过来:遍历段表,按归属地把区间塞进各自的桶里,形成“省 → 区间集合”。构建是一次性的,几十万行的遍历在 CLI 里也就一两秒;之后每次取址只在该省的区间集合里做二分,和省份大小无关。这个一次性成本和每次请求的常数成本,是选型时最该先算清楚的一笔账。

2.2 把段表读成倒排索引

段表来源五花八门,CSV 是最容易复现的一种:三列,起始 IP、结束 IP、省编码。下面这段是加载和倒排的核心,直接接在类的构造函数里就能用。

private function loadTable(string $path): void { $fh = fopen($path, 'rb'); if ($fh === false) { throw new RuntimeException("段表打不开: {$path}"); } $ranges = []; $lineNo = 0; // 第 5 个参数传空串,别用默认的反斜杠转义,PHP 8.4 起已弃用 while (($row = fgetcsv($fh, 0, ',', '"', '')) !== false) { $lineNo++; if ($lineNo === 1) { // 去掉 UTF-8 BOM,再判断首行是不是表头 $row[0] = preg_replace('/^\xEF\xBB\xBF/', '', $row[0] ?? ''); if (!filter_var($row[0], FILTER_VALIDATE_IP)) { continue; // 不是 IP 就当成表头跳过 } } if (count($row) < 3) { continue; // 列数不够的残行直接丢 } $start = self::ipToLong(trim($row[0])); $end = self::ipToLong(trim($row[1])); $prov = trim($row[2]); // 脏行必须在入口拦掉,否则后面二分会被打乱 if ($start === null || $end === null || $prov === '' || $end < $start) { continue; } $ranges[$prov][] = [$start, $end]; } fclose($fh); // 每省内部按起始地址排序,再把首尾相接的区间合并 foreach ($ranges as $prov => &$list) { usort($list, static fn(array $a, array $b): int => $a[0] <=> $b[0]); $merged = []; foreach ($list as [$s, $e]) { $n = count($merged); if ($n > 0 && $s <= $merged[$n - 1][1] + 1) { if ($e > $merged[$n - 1][1]) { $merged[$n - 1][1] = $e; } } else { $merged[] = [$s, $e]; } } $list = $merged; } unset($list); $this->ranges = $ranges; }

逻辑上分四步。第一步读行,顺手把 BOM 和表头处理掉——这一步不做,第一行的省编码会带一串看不见的字节,后面按省取址永远取不到那一个省,排查起来相当费劲。第二步把点分十进制转成 32 位无符号整数,转不动或者区间倒挂的行直接丢弃,脏数据在入口拦比在出口拦便宜得多。第三步按省分桶。第四步排序并合并首尾相接的区间。

合并这一步值得单独说一句:$s <= $merged[$n-1][1] + 1这个判断把“重叠”和“刚好相邻”一起处理了。相邻区间合并后地址集合完全不变,但段数会明显减少,二分的层数跟着降。有些不严谨的实现只合并真正重叠的区间,结果相邻段零散保留,段数多出几成,纯属给自己找麻烦。

ipToLong单独抽成静态方法,是因为它还兼着脏数据校验的职责:

private static function ipToLong(string $ip): ?int { $long = ip2long($ip); if ($long === false) { return null; } // 32 位环境下 ip2long 会返回负数,sprintf('%u') 能拿到无符号值 return (int) sprintf('%u', $long); }

参数就一个待转换的字符串。返回null表示这行不要了,返回整数表示合法。sprintf('%u', ...)这层包装不是可选项,后面 4.3 会讲它在什么情况下是救命的。

2.3 加权随机:均匀选段为什么会让结果偏

倒排建好之后,最容易踩的坑是“随机选一个段,再在段内随机取一个地址”。写法上很自然,$ranges[$prov][array_rand($ranges[$prov])],一行搞定。问题在于array_rand给每个段相同概率,而段的大小天差地别——一个有几百个小段的省,和一个只有几十个大段的省,内部地址被选中的概率分布完全不同。极端情况下,一个大段里的地址被选中的概率可能是小段地址的几十分之一。

如果只是拿几条数据喂测试,这个偏差无所谓;一旦用于地区分布的抽样统计、AB 实验分桶、或者“按省生成 N 条模拟用户”,地址分布的偏斜会顺着数据传导到指标上,最后变成一个没人说得清的玄学问题。

正确的做法是按段长加权:先把每省的区间长度累加成一个前缀和数组,取随机数时落在[0, 总地址数-1],再用二分定位到具体段。这样每个地址被选中的概率严格相等,分布均匀。

private function buildWeightIndex(): void { foreach ($this->ranges as $prov => $list) { $acc = 0; $cum = []; foreach ($list as [$s, $e]) { $acc += $e - $s + 1; // 区间闭区间,长度要 +1 $cum[] = $acc; } $this->cum[$prov] = $cum; $this->totals[$prov] = $acc; } } // 二分找第一个前缀和 >= target 的下标 private function locate(string $prov, int $target): int { $cum = $this->cum[$prov]; $lo = 0; $hi = count($cum) - 1; while ($lo < $hi) { $mid = ($lo + $hi) >> 1; if ($cum[$mid] < $target) { $lo = $mid + 1; } else { $hi = $mid; } } return $lo; }

buildWeightIndex里$acc += $e - $s + 1的那个+1是闭区间长度,漏掉它会让每个省的总权重少算“段数”那么多,虽然不影响相对分布,但边界地址永远取不到,属于典型的静默错误。

locate的$target传的是随机值 + 1,因为前缀和是 1 基的位置,而随机值从 0 开始。这一处偏移写反了不会报错,只会让每个段少一个地址、多一个邻段地址,测不出来,但结果就是不对。

2.4 三种加载策略,按场景挑

段表规模不同,加载方式也该跟着变。三种常见做法的取舍:

策略首次开销单次取址适用场景
全量倒排 + 前缀和秒级,内存占用最高两次二分,微秒级CLI 脚本、队列消费者、常驻进程
按省懒加载每省首次几百毫秒同上Web 请求只涉及少数几个省
预编译二进制 + 分段读取常数级一次 fseek + 一次二分段表超大、内存受限的容器

全量倒排是最省心的,前提是你的进程能扛住内存。按省懒加载适合 Web 场景:请求里带上省编码,第一次用到时只构建那一个省,构造函数里不碰全表。预编译方案则是把段表提前打包成定长记录的二进制文件,头部存省份索引表(省编码、段数、偏移),运行时fseek到对应省的数据块再读,内存只占当前省那一片。

我一般先用全量倒排跑通,等到压测发现 RSS 顶到容器上限,再按“当前请求只用一两个省”这个事实切到懒加载。预编译那套属于最后手段,除非段表确实大到没法整块塞进内存。

3. PHP 类的骨架与取址链路:从构造函数到批量输出的可跑代码

3.1 类骨架和构造参数

类本身没什么花哨设计,四个属性撑起全部功能:省份区间集合、前缀和数组、每省总权重、保留地址黑名单。构造函数把段表路径和几个行为开关吃进来。

final class ProvinceIpGenerator { private const BLOCKED = [ ['0.0.0.0', '0.255.255.255'], // 本网络 ['10.0.0.0', '10.255.255.255'], // 私网 A ['100.64.0.0', '100.127.255.255'], // 地址共享段 ['127.0.0.0', '127.255.255.255'], // 回环 ['169.254.0.0', '169.254.255.255'], // 链路本地 ['172.16.0.0', '172.31.255.255'], // 私网 B ['192.168.0.0', '192.168.255.255'], // 私网 C ['224.0.0.0', '239.255.255.255'], // 组播 ['240.0.0.0', '255.255.255.255'], // 保留 + 广播 ]; private array $ranges = []; private array $cum = []; private array $totals = []; private array $blocked = []; private array $opts; public function __construct(string $tablePath, array $options = []) { $this->opts = $options + [ 'allow_reserved' => false, // 是否允许保留地址混入 'seed' => null, // 传整数则结果可复现 'max_retry' => 32, // 命中保留地址后的重试上限 ]; if ($this->opts['seed'] !== null) { mt_srand($this->opts['seed']); } // 黑名单预转成整数区间,省得每次判断都做字符串解析 foreach (self::BLOCKED as [$s, $e]) { $this->blocked[] = [self::ipToLong($s), self::ipToLong($e)]; } $this->loadTable($tablePath); $this->buildWeightIndex(); } }

$options + [...]用的是数组并集而不是array_merge,前者保留用户传进来的键、补上缺省的键,后者在数字键上行为不一样,这里虽然都是字符串键,但养成用并集的习惯能少踩一类坑。

seed这个参数看着不起眼,实际很值钱。测试用例里固定种子,同一个省连续跑两次结果一致,回归对比才做得下去;线上不传,走系统熵源。

3.2 段内随机:随机源、边界和可复现

取址的完整链路是:定位到段、在段内均匀取一个偏移、拼回字符串、检查黑名单。三行核心逻辑,但每一行都有讲究。

public function pick(string $province): string { if (!isset($this->totals[$province])) { throw new InvalidArgumentException("未知省份: {$province}"); } for ($try = 0; $try < $this->opts['max_retry']; $try++) { $total = $this->totals[$province]; $r = $this->rand(0, $total - 1); // 0 基偏移 $idx = $this->locate($province, $r + 1); [$s, $e] = $this->ranges[$province][$idx]; $ip = $s + $this->rand(0, $e - $s); if ($this->opts['allow_reserved'] || !$this->isBlocked($ip)) { return long2ip($ip); } } throw new RuntimeException( "连续 {$this->opts['max_retry']} 次命中保留地址,段表质量有问题" ); } private function rand(int $min, int $max): int { return $this->opts['seed'] === null ? random_int($min, $max) // 密码学安全,慢一些 : mt_rand($min, $max); // 可复现,快 } private function isBlocked(int $ip): bool { foreach ($this->blocked as [$s, $e]) { if ($ip >= $s && $ip <= $e) { return true; } } return false; }

random_int和mt_rand的性能差距在批量场景下很直观,大致是五到十倍的量级。十万条生成,random_int要几百毫秒,mt_rand几十毫秒就完了。这里生成的是测试数据,不涉及任何安全用途,用mt_rand完全够,但必须配合固定种子才能复现——不传种子还调mt_rand,等于放弃了可复现性又没换来安全,两头不占。

isBlocked用的是九段线性扫描。有人会想改成二分,其实没必要,九次整数比较的开销远小于一次函数调用的开销,二分反而更慢。真正需要优化的是把这个判断移到段级别:如果整段都落在黑名单里,加载阶段就该剔掉,别留到运行时。

黑名单本身也有取舍。有些段表会把私网段一并收录,归属地标成“局域网”或者干脆留空,这类段在倒排时就该过滤。但有些段表会把私网和公网混在同一段里,这时候只能在运行时逐地址判断,allow_reserved就是给这种脏数据留的逃生口。

3.3 段边界不是 CIDR 边界,别乱加减

看到“随机取一个 IP”,很多人的直觉是排除段的首尾两个地址,理由是网络地址和广播地址不可用。这个直觉在 CIDR 块上成立,在归属地段表上不成立。

归属地段表的切分依据是注册信息变更点,不是掩码边界。一个段可能是1.2.3.0到1.2.7.255,也可能是1.2.3.17到1.2.9.200,中间没有任何对齐关系。首尾两个地址往往是合法可用的公网地址,随手+1/-1会白丢数据。数据量小的时候看不出来,做分布统计时就会露出尾巴。

真要处理广播地址,正确做法是判断这个 IP 是不是某个真实子网的广播地址,而不是看它在不在段边界上。而这件事在“生成测试数据”这个场景里根本不必要——测试数据不需要能 ping 通,只需要归属地正确、格式合法。

另一类容易忽略的情况是段重叠。同一个 IP 出现在两个省的段里,说明段表本身有冲突,倒排后这个地址会在两个省的桶里各出现一次。表现是某些 IP 被生成的频率异常高,或者两个省都能生成出同一个地址。加载阶段做一次重叠检测,把后出现的段裁掉重叠部分,成本不高,能省掉后面一堆解释不清的现象。

3.4 批量接口和输出格式

单条取址够用之后,批量接口是顺手的封装,但去重逻辑要留退路。

public function pickMany(string $province, int $count, bool $unique = true): array { $out = []; $seen = []; $guard = $count * 20; // 防死循环的兜底计数 while (count($out) < $count && $guard-- > 0) { $ip = $this->pick($province); if ($unique) { if (isset($seen[$ip])) { continue; } $seen[$ip] = true; } $out[] = $ip; } return $out; }

$guard那个计数器不是冗余代码。小省份的地址空间可能只有几万个地址,你让它去重生成十万条,循环永远退不出来。带上兜底计数,最多跑count * 20次就收手,返回实际拿到的条数,调用方自己判断够不够。这比让脚本挂在那里强得多。

输出格式上,纯字符串数组最通用,直接json_encode就是接口能吃的形状。如果下游要的是带省份标记的结构,在pickMany外面再包一层映射就行,别把展示逻辑塞进生成器里。

4. 避坑排查:生成结果跑偏的五个真实现场

4.1 同一个 IP,两个库查出两个省

现象:生成的 IP 拿去外部工具查,显示的是别的省,或者干脆查不到归属地。

原因通常不是生成逻辑错了,而是段表版本不一致。离线归属地库的段归属会随时间调整,两个不同时间点的库对同一个地址给出不同结论很正常。另一个来源是段表本身把大块地址粗粒度地划给了某个省,而更细的库会拆成多个地市。

解决:用同一份段表做自校验——生成后立刻拿本类的lookup反查一遍,命中省必须等于目标省,不等就是索引构建有问题。对外的交叉验证只作为参考,容忍一定比例的差异,别拿它当唯一标准。真要对外宣称准确率,先说清楚参照的是哪份库。

4.2 某些省永远抽不到,或者省名全是乱码

现象:倒排建完了,isset($this->totals[$prov])对某几个省一直返回 false,或者键名打印出来是问号和方块。

原因有三个常见来源。一是段表是 GBK 编码,直接按 UTF-8 读进来,中文字段全是乱码,键名对不上。二是文件带 UTF-8 BOM,首行的省编码前面多了三个字节,那一个省永远匹配不到。三是行尾是 CRLF,fgetcsv拿到的最后一列带\r,trim没做干净。

解决:读之前统一转码,mb_convert_encoding($content, 'UTF-8', 'GBK')或者用流过滤器;首行做 BOM 剥离;每一列读完都trim。这三件事写进加载函数里,别指望调用方记得。

4.3 32 位环境里区间判断全反了

现象:在 32 位 PHP 上跑,生成的 IP 五花八门,很多根本不在目标省;或者直接报“未知省份”。

原因:ip2long在 32 位环境下,超过2147483647的地址会返回负数。段表里大概一半的地址都超过这个值,正负混在一起排序,整个区间表就废了。

解决:所有转换都走(int) sprintf('%u', ip2long($ip)),拿到无符号语义的整数。更稳的做法是在构造函数里检查PHP_INT_SIZE,小于 8 就直接抛异常,别让它带着错误数据一路跑下去。这个坑没有后悔药,只能靠环境检查拦住。

4.4 抽出来的地址是网关或者回环

现象:生成的 IP 落在192.168.x.x、10.x.x.x,或者干脆是127.0.0.1。

原因:段表里混了内网段,或者有0.0.0.0到0.255.255.255这种明显是占位的脏段。也有可能是段表把某个省的整个大块直接收录,里面包含了未分配部分。

解决:黑名单必须常开,别图省事关掉。加载阶段再做一次整段过滤:如果某段完全落在黑名单区间内,直接丢弃,不参与倒排。这样运行时的重试次数会降到接近零,max_retry只是最后的保险。

4.5 批量跑十万条,内存直接顶到上限

现象:生成一万条还行,加到十万条时进程被 OOM 杀掉,日志里看不出任何异常。

原因:不是生成结果占内存,而是倒排索引本身。PHP 的数组每个元素都有可观的头部开销,几十万个二元组小数组,实际占用很容易到几百 MB。如果类被实例化多次,每次都在内存里留一份完整的倒排,问题会成倍放大。

解决:生成器在进程内做成单例或者依赖注入的共享实例,别在循环里 new。段表特别大时改用SplFixedArray或者把区间拆成两个平行的整数数组,减少小数组的数量。再不行就走 2.4 里的预编译二进制方案,按省分块读。

5. 回验与压测:用自校验把随机 IP 的可信度钉住

生成器写完,别急着往业务里灌数据。先加一个lookup,把正向查询也实现一遍,用同一份段表做闭环校验。这段代码不长,但它是唯一能证明“生成的地址确实归属目标省”的手段。

private array $flat = []; // [[start, end, prov], ...],按 start 升序 public function lookup(string $ip): ?string { $n = self::ipToLong($ip); if ($n === null) { return null; } $lo = 0; $hi = count($this->flat) - 1; while ($lo <= $hi) { $mid = ($lo + $hi) >> 1; [$s, $e, $prov] = $this->flat[$mid]; if ($n < $s) { $hi = $mid - 1; } elseif ($n > $e) { $lo = $mid + 1; } else { return $prov; } } return null; }

$flat在loadTable末尾顺带构建,把各省级别的区间摊平后按起始地址全局排序。因为可能存在重叠,二分的命中结果取第一个覆盖该地址的段即可;如果要求严格,就在构建时先把重叠部分裁干净。

有了这条闭环,验证就变成一行断言:

$gen = new ProvinceIpGenerator(__DIR__ . '/ranges.csv', ['seed' => 20240101]); foreach ($gen->pickMany('P01', 2000) as $ip) { if ($gen->lookup($ip) !== 'P01') { throw new RuntimeException("自校验失败: {$ip}"); } }

固定种子跑两千条,全过就说明倒排、加权、二分偏移这三处没有逻辑错误。这一步我一般会塞进 CI,改完代码跑一遍,比事后在业务数据里翻问题省事得多。

压测要盯的不是单条耗时,而是“首次构建耗时”和“常驻内存增量”两个数。十万条生成在mt_rand下通常是百毫秒量级,真正的瓶颈永远在构造函数那一两秒。如果服务是短生命周期容器,每秒都要重建一次索引,那就该切到懒加载或者预编译了。

还有个容易忽略的细节:固定种子不等于结果稳定。只要段表更新过,同样种子的输出就会变。所以回归用例里的预期结果不要硬编码具体 IP,硬编码“校验通过”这个断言。

从那以后我每次接这类生成器,都先把pick出来的样本走一遍lookup再做业务断言,宁可多跑两秒,也不想再在演示现场解释为什么目标省份的 IP 查出来是另一个省。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询