PHP按省份批量生成随机IP:CIDR加权抽样与反查校验

发布时间:2026/10/26 3:56:52
PHP按省份批量生成随机IP:CIDR加权抽样与反查校验 简介这是一份面向PHP开发者与网络编程学习者的实用代码包聚焦“按省份随机生成IPv4地址”这一需求可用于模拟用户访问、网络测试与数据分析等场景。压缩包共85个文件以83个PHP源码为主体另附1个txt说明与1个md文档整体约110KB便于快速查阅与二次改造。核心内容围绕省份IP段数据整理、IPv4与32位整数互转、区间内随机取值及结果封装展开类中通常由构造参数指定省份再由生成方法返回对应地址读者可据此理解地理定位数据与随机数生成如何协同并学习类结构组织与接口调用方式。已有301人学习适合具备PHP基础、希望补齐网络协议与地理IP处理经验的中级开发者参考。使用时需注意IP分配动态性与隐私合规边界仅将其作为大致地理位置的模拟工具。1. 一个 PHP 类把「某个省的 IP」批量造出来手写测试数据时最烦的往往不是造人名而是造 IP。一个 PHP 类专门解决这件事你给它一个省份它随机生成一个落在该省网段里的 IP 地址返回点分十进制字符串再给一个反查方法把地址丢回去就能验证到底归不归这个省。业务里只要沾一点区域判断——首页按省份推内容、运费按省份算、风控看登录地——你就得有一批看起来确实来自某地的地址不能随手rand(1,255)拼四段糊弄过去。更别说往测试库灌几十万条带地域字段的假用户手工编根本编不出来。整个类不依赖扩展和第三方包一个类文件加一张数据表就能跑塞进老项目里不跟框架打架丢进命令行脚本也能用。2. 项目定位与技术选型数据生成器不是归属地查询服务2.1 先把边界说清楚这个类的正事只有一件给定省份产出落在该省网段内的 IP 字符串。围绕这件事衍生出三个能力——单个生成、批量生成带去重、反查校验。反查校验不是主功能它的作用是让你在写完一批假数据后能自证「我造的数据确实没跑偏」顺带也能当个粗糙的归属地查询用。它做不到的事同样要说清楚。它不保证生成的地址在真实网络里活着很多段里的地址可能早被回收或从没分配出去它不判断运营商它不保证城市级精度因为源数据本身多数只到省。拿它去做风控判定、做真实用户定位方向就错了。它就是个假数据生成器服务对象是测试环境和演示环境。判断一个项目值不值得用我一般看两点接口是不是窄依赖是不是少。这个类两条都占。接口就四五个方法依赖为零出问题的时候能一眼看完整个文件不用翻框架源码。2.2 为什么是「单类 外部数据表」的组合最省事的写法是把省-网段表直接写成一个巨大的 PHP 数组常量塞进类里。我一开始也这么干过后来被两件事教育了。第一件改数据要发版。数据表这东西是需要持续维护的网段会被回收、会被重新分配你不可能每修一条记录就走一次发布流程。第二件文件体积。一个省几百条 CIDR全国加起来几千条全写在类文件里这个文件会膨胀到几千行opcache 每次编译都得多啃一块热路径上白白浪费。所以拆成两层类只管逻辑数据放外部文件。常见做法是人工维护一份可读的 CIDR 清单JSON 或 TSV再用一个构建脚本把它编译成 PHP 数组文件。为什么编译成.php而不是每次json_decode因为 PHP 文件能被 opcache 缓存 opcode请求进来直接require就是内存里的数组省掉解析开销。这个套路在配置加载里很常见血泪经验是数据量过千条以后json_decode的那几毫秒在压测里是看得见的。至于为什么不用数据库——测试环境经常连库都没有单元测试里挂个 SQLite 也嫌重。为什么不用扩展——老项目装不了装了也不一定给你开权限。纯 PHP 文件是这个场景下的最大公约数。2.3 对外接口四个方法覆盖全部用法类的骨架长这样方法名故意起得直白不搞花活?php final class ProvinceIpGenerator { /** var arraystring, arrayint, array{0:int,1:int,2:int} 省 [[起始整数, 结束整数, 容量], ...] */ private array $ranges []; /** var arrayint, array{0:int,1:int,2:string} 全量区间按起始地址升序摊平供反查二分 */ private array $flat []; /** var arraystring, int[] 省 容量前缀和用于加权抽样 */ private array $prefix []; /** var arraystring, int 省 总容量 */ private array $total []; public function __construct(?string $cacheFile null) { /* 载入编译后的数据 */ } /** 生成一个属于该省的随机 IP */ public function generate(string $province): string { /* 见 4.3、4.4 */ } /** 批量生成可选去重 */ public function batch(string $province, int $count, bool $unique true): array { /* 见 4.5 */ } /** 反查IP 属于哪个省查不到返回 null */ public function lookup(string $ip): ?string { /* 见 4.5 */ } /** 数据表里有哪些省 */ public function provinces(): array { return array_keys($this-ranges); } }参数上只有两个地方需要留意。构造函数接受一个可选的缓存文件路径默认指向包内的data/ranges.cache.php你把它放进自己的项目时改这一处就行。batch()的第三个参数控制去重小网段比如只有几十个可用地址的 /26在请求上千条时必须考虑重复具体处理见 4.5。generate()抛出OutOfRangeException而不是返回false这是有意的。返回假值会让调用方写出if (!$ip)这种判断而空字符串和0.0.0.0在弱类型比较里都是坑异常至少能让你在测试阶段就发现省份名拼错了。3. 目录结构与数据表设计省-网段表怎么组织才查得快3.1 目录结构包解开之后大概是这个形状比我见过的一堆「一个文件几千行」的同类实现要好读得多province-ip/ ├── src/ │ └── ProvinceIpGenerator.php # 主类只有逻辑没有数据 ├── data/ │ ├── ranges.json # 人工维护省 CIDR 列表 │ └── ranges.cache.php # 构建产物整数区间 反查表 ├── bin/ │ └── build.php # 把 json 编译成 cache ├── tests/ │ └── GeneratorTest.php # 分布与边界用例 ├── composer.json └── README.mdranges.json是唯一需要人动手的文件格式简单到不会写错{ 省份A: [192.0.2.0/24, 198.51.100.0/25], 省份B: [203.0.113.0/26] }提示示例里用的是文档保留段只做格式演示。实际使用时换成你自己聚合出来的省-网段清单键名建议统一用行政区划编码而不是中文名中文名容易因为编码、空格、简称不一致导致查不到。ranges.cache.php是构建产物不要手改也不建议提交到版本库每次构建都会重新生成冲突起来没意义。3.2 三种存法的取舍同一个网段在内存里可以有三种表示CIDR 字符串、起止整数、起始整数加容量。它们在生成和反查两条路径上的表现完全不一样。存法单条体积加载开销段内取址反查适合场景CIDR 字符串a.b.c.d/n最小可读每次都要explodeip2long要先转整数要先转整数且得逐条比对人工维护的源文件起止整数[start, end]中等直接可用直接random_int(start, end)可直接二分运行期数据起始整数 容量[start, size]中等直接可用直接算偏移需start size - 1推荐加权抽样也要用容量我选第三种理由很实际加权抽样本来就要知道每个段有多少个地址用起止整数还得每次多算一遍减法不如构建时就存好容量。反查时的结束地址是一次加法代价可以忽略。真正决定性能的不是这三种存法的差别而是反查要不要全表扫。如果你写成foreach ($ranges as $prov $list) { if ($ip $s $ip $e) ... }几千条记录下每次查询都是几千次比较。摊平 二分能把这一步压到十几次比较这也是构建脚本要额外产出一张flat表的原因。3.3 构建脚本把 CIDR 摊平成整数区间bin/build.php干三件事解析 CIDR、算出区间、摊平排序。命令行里php bin/build.php跑一次几秒钟出结果。?php // bin/build.php把人工维护的 CIDR 清单编译成运行期可直接 require 的缓存 $rawPath __DIR__ . /../data/ranges.json; $cachePath __DIR__ . /../data/ranges.cache.php; $raw json_decode(file_get_contents($rawPath), true, 512, JSON_THROW_ON_ERROR); $ranges []; foreach ($raw as $province $cidrs) { foreach ($cidrs as $cidr) { [$ip, $bits] explode(/, $cidr); $bits (int) $bits; $base ip2long($ip); if ($base false || $bits 0 || $bits 32) { fwrite(STDERR, 跳过非法网段: {$province} {$cidr}\n); continue; } $size 1 (32 - $bits); // 该网段地址总数/24 256 $ranges[$province][] [$base, $base $size - 1, $size]; } } // 反查表按起始地址升序摊平二分查找的前提 $flat []; foreach ($ranges as $province $list) { foreach ($list as [$s, $e]) { $flat[] [$s, $e, $province]; } } usort($flat, static fn(array $a, array $b): int $a[0] $b[0]); // 编译成 PHP 文件让 opcache 接管避免每次请求解析 JSON $code ?php\n// 自动生成请勿手改\nreturn . var_export([ranges $ranges, flat $flat], true) . ;\n; file_put_contents($cachePath, $code, LOCK_EX); printf(省 %d 个区间 %d 条缓存 %.1f KB\n, count($ranges), count($flat), filesize($cachePath) / 1024);ip2long在 64 位环境下对合法 IPv4 返回非负整数1 (32 - $bits)也在整数范围内所以这一步是安全的。JSON_THROW_ON_ERROR这个参数值得加源文件被编辑器改坏时你能立刻看到异常而不是拿到一个空数组继续往下跑——空数据表导致的「生成的 IP 全是随机的」这种问题排查起来相当费劲。var_export输出的数组结构会被完整保留require回来就是现成的 PHP 数组连类型转换都省了。数据量再大比如十万条以上可以考虑igbinary或者干脆切成按省分文件懒加载但对省级粒度的数据来说没必要。4. 核心模块拆解一次随机生成的完整链路4.1 IP 与整数互转先把 ip2long 的负数吃掉所有区间运算都建立在整数上所以转换函数是整个类的基石也是最容易出事的地方。final class Ip { /** 点分十进制 无符号 32 位整数 */ public static function toInt(string $ip): int { $long ip2long($ip); if ($long false) { throw new InvalidArgumentException(非法 IPv4 地址: {$ip}); } // 32 位 PHP 上 ip2long 可能返回负数统一归一到 0 ~ 2^32-1 return $long 0 ? $long 4294967296 : $long; } /** 无符号 32 位整数 点分十进制 */ public static function toIp(int $n): string { if ($n 0 || $n 4294967295) { throw new OutOfRangeException(整数超出 IPv4 范围: {$n}); } return long2ip($n); } }ip2long返回负数这件事在 64 位 PHP 上基本见不到但只要你的代码要跑在 32 位环境某些嵌入式或老容器镜像里还真有192.168.0.1会变成-1062731775。如果不处理后面所有比较都是错的生成出来的地址会跑到0.0.0.0附近去。加个 4294967296是最省事的归一方式。toIp()里的范围校验不能省。long2ip对超范围整数在某些版本上会返回奇怪结果与其让它静默产出脏数据不如直接抛。4.2 CIDR 解析一个 /24 到底有多少个地址/24有 256 个地址/25有 128 个/32只有 1 个——2^(32-n)。这个公式简单但边界要想清楚/32时$size 1起止相同/31时$size 2起止差 1。这两个小段在后面「剔除网络号和广播地址」的逻辑里是特例必须单独处理否则会把唯一可用的地址也剔掉得到一个空区间然后random_int()直接抛错。/** 解析 CIDR返回 [起始整数, 结束整数, 容量] */ public static function parseCidr(string $cidr): array { if (!str_contains($cidr, /)) { $cidr . /32; // 裸 IP 按单主机处理 } [$ip, $bits] explode(/, $cidr, 2); $bits (int) $bits; if ($bits 0 || $bits 32) { throw new InvalidArgumentException(掩码位数不合法: {$cidr}); } $base self::toInt($ip); $size 1 (32 - $bits); // /0 时是 2^3264 位下没问题 // 对齐到网络地址防止有人写成 192.0.2.77/24 这种「主机位不为零」的写法 $start $base ~($size - 1); return [$start, $start $size - 1, $size]; }最后那行按位与是防呆用的。真实数据里经常混进192.0.2.77/24这种写法——语义上它等价于192.0.2.0/24但如果直接拿192.0.2.77当起始地址算出来的区间会整体偏移末尾还会溢出到下一个网段。按位与一下强制对齐到网络地址这类脏数据就被默默修正了。4.3 按容量加权抽段别让 /16 和 /28 平起平坐这是整个类里最有技术含量的一步也是同类实现最常翻车的地方。天真的做法是先array_rand随机挑一个段再在段内随机取址。问题在于一个省可能既有/1665536 个地址也有/2816 个地址等概率挑段意味着那个/28被选中的概率和/16一样高最终生成的 IP 分布严重偏向小段。如果拿这批数据去做按地域的统计报表数字会明显失真。正确做法是按容量加权每个段被选中的概率正比于它包含的地址数。实现上用前缀和加二分复杂度O(log n)。/** 构建期调用一次为每个省算容量前缀和 */ private function buildWeight(): void { foreach ($this-ranges as $province $list) { $acc 0; $this-prefix[$province] []; foreach ($list as [, , $size]) { $acc $size; $this-prefix[$province][] $acc; } $this-total[$province] $acc; } } /** 在前缀和数组里找第一个 target 的下标 */ private function upperBound(array $prefix, int $target): int { $lo 0; $hi count($prefix) - 1; while ($lo $hi) { $mid ($lo $hi) 1; if ($prefix[$mid] $target) { $lo $mid 1; } else { $hi $mid; } } return $lo; } /** 按容量加权抽一个段返回 [起始, 结束, 容量] */ private function pickRange(string $province): array { $target random_int(1, $this-total[$province]); // 1..总容量闭区间 $idx $this-upperBound($this-prefix[$province], $target); return $this-ranges[$province][$idx]; }$target从 1 开始而不是 0是为了让prefix[i] target这个判断落在正确的段上前缀和是「累加到第 i 段为止的总容量」所以第 1 个满足prefix[i] target的 i就是 target 落在的那一段。取 0 的话第一段会被少算一个地址虽然影响微乎其微但这种偏一位的错误在做分布校验时会被卡方检验抓出来。random_int而不是mt_rand原因是random_int走的是系统熵源不可预测且分布更均匀。测试数据虽然没有安全要求但如果生成的地址会进入日志或演示环境「可预测」总归不是好事而且random_int的额外开销在这个量级下完全无感。4.4 段内取址把网络号和广播地址剔掉抽到段之后random_int($start, $end)看上去就完事了实际上有两个坑。其一段的第一个地址是网络地址最后一个是广播地址这两个在真实网络里不可能分配给主机。虽然测试数据不必这么较真但如果你生成的 IP 要喂给某些会做合法性校验的中间件.0和.255结尾的地址有概率被拦下来。其二/31和/32是特例本来就没有网络号和广播地址的概念剔除逻辑必须跳过。public function generate(string $province): string { $province $this-normalize($province); if (!isset($this-ranges[$province])) { throw new OutOfRangeException(没有该省份的网段数据: {$province}); } [$start, $end, $size] $this-pickRange($province); // /31 与 /32 没有网络号、广播号的概念不能剔 if ($size 2) { $start; $end--; } return Ip::toIp(random_int($start, $end)); } /** 省份名归一去空格、转大写、处理简称映射 */ private function normalize(string $province): string { $province strtoupper(trim($province)); return $this-alias[$province] ?? $province; }normalize()里的别名表是我后来补的。第一版直接拿传入字符串当键去查结果调用方传来的是小写编码、带空格的中文名、或者各种简称每种都查不到报错信息还是「没有该省份的网段数据」一度让我以为是数据表缺了。加一层别名映射之后这类问题就没了。别名表放在ranges.json同级的一个alias.json里格式是{BJ: 省份A, 京: 省份A}这样。4.5 批量生成与反查自检批量接口的核心不是循环调generate()而是去重和防死循环。/** * param int $count 需要的条数 * param bool $unique 是否去重 * return string[] */ public function batch(string $province, int $count, bool $unique true): array { if ($count 0) { return []; } // 去重时给一个尝试上限小网段凑不够条数是必然的不能无限转 $guard $unique ? $count * 10 : $count; $out []; while (count($out) $count $guard-- 0) { $ip $this-generate($province); if (!$unique) { $out[] $ip; continue; } $out[$ip] $ip; // 用键去重比 in_array 快一个量级 } return array_values($out); }$guard这个上限很关键。假设某省只有一个/28段去掉首尾剩 14 个可用地址你请求 100 条且要求去重循环永远凑不齐。没有上限的话就是死循环页面直接 502——这种问题上线前测不出来因为测试数据的段通常都很大。设成$count * 10是个经验值正常情况下十次尝试足够覆盖碰撞凑不齐就说明数据表本身就撑不起这个量应该让调用方拿到一个较短的数组自行决定而不是让它卡死。反查走的是摊平后的flat表加二分逻辑在 3.3 已经铺好了数据结构public function lookup(string $ip): ?string { $n Ip::toInt($ip); $lo 0; $hi count($this-flat) - 1; // 找最后一个「起始地址 n」的区间 while ($lo $hi) { $mid ($lo $hi) 1; if ($this-flat[$mid][0] $n) { $lo $mid 1; } else { $hi $mid - 1; } } if ($hi 0) { return null; } [$s, $e, $province] $this-flat[$hi]; return $n $e ? $province : null; // 落在空隙里就是查不到 }这个二分找的是「最后一个起点不大于目标值的区间」找到之后还要再验一次上界。原因很简单区间之间是有空隙的比如192.0.2.0/24之后直接跳到198.51.100.0/24中间那些地址不属于任何省二分只会给出前一个区间必须用$n $e把它挡掉否则你会得到「任何 IP 都属于某个省」这种荒谬结论。有了lookup()自检就一行public function assertBelongs(string $ip, string $province): bool { return $this-lookup($ip) $this-normalize($province); }注意这里是不是。lookup()查不到时返回null用比较会让null 0之类的边界悄悄通过。写单测时我用assertBelongs()跑了一万次生成全部通过才敢往项目里塞。5. 避坑清单五个把我坑过的 IP 生成问题5.1 现象生成的地址全都挤在同一个网段现象很直观某个省跑一万条数据八成以上落在同一批/24里另外几个大段几乎不出现。原因是最初的抽样写成了array_rand($this-ranges[$province])各段等概率与小段容量无关。解决就是 4.3 那套按容量加权的前缀和加二分改完之后用卡方检验粗略看了一下各段被选中的频次和它的容量占比基本吻合。顺带说个验证技巧不要只看前一百条生成十万条按段统计计数把每段的实际命中数除以理论期望数偏离超过 15% 就说明抽样还有问题。5.2 现象random_int()抛ValueError: max must be greater than or equal to min报错信息指向 4.4 的random_int($start, $end)。原因是某个/32或/31段被剔除了首尾/32的$size是 1剔完$start大于$end区间反过来。解决是在剔除前判断$size 2只有大于 2 的段才有真正的网络号和广播地址。这个坑很隐蔽因为大部分省的表里都是/22、/24这类大段只有个别表混进了单主机记录正常跑几百次都碰不到。5.3 现象查不到某省数据但ranges.json里明明有现象是generate(某省)抛「没有该省份的网段数据」打开 json 一看键名确实存在。原因是调用方传进来的字符串带了不可见字符或者大小写、全半角与表里的键不一致还有一种情况是 json 文件本身存成了带 BOM 的 UTF-8json_decode之后第一个键名开头挂了个 BOM 字节。解决分两层。代码层统一走normalize()trim加strtoupper加别名映射。数据层在构建脚本里加一次键名清洗preg_replace(/[\x{FEFF}\x{200B}]/u, , $key)把 BOM 和零宽字符抹掉。这类问题最难查因为打印出来肉眼看不出差别得用bin2hex()对比字节才看得出来。5.4 现象反查时把不属于任何省的地址判成了某省用户随手输了个10.0.0.5lookup()返回了一个省的名字。原因就是 4.5 提到的二分只找到前一个区间没验上界。此外还有一层如果数据表里混进了私有地址段或保留段10.0.0.0/8、127.0.0.0/8、169.254.0.0/16这些反查会给出更奇怪的结果。解决是两手抓。二分之后强制验$n $e构建脚本里加一段过滤把 RFC 1918 私有段、回环段、链路本地段、组播段从源数据里剔掉。剔的时候要注意源数据里出现私有段通常意味着聚合过程出了问题值得回头查一下而不是简单丢掉。5.5 现象把批次调大到十万条后内存爆了现象是脚本跑到七八万条时报Allowed memory size exhausted。原因是batch()把所有结果都存在数组里十万条字符串加上 PHP 数组的哈希表开销实际占用是原始字符串的好几倍。解决不是调memory_limit了事而是改接口加一个生成器版本边生成边yield调用方自己决定要不要落地。public function stream(string $province, int $count): Generator { $guard $count * 10; $seen []; while ($count 0 $guard-- 0) { $ip $this-generate($province); if (isset($seen[$ip])) { continue; } $seen[$ip] true; $count--; yield $ip; // 调用方逐条消费内存恒定 } }$seen数组本身还是会涨但它只存键且小网段下规模有上限比存整条结果轻得多。真要灌库就直接在foreach里跑INSERT走批量提交内存曲线是平的。6. 二次开发把这个类改成适合你自己项目的形状拿到手之后最容易改的三个方向按改动成本从低到高排一下。改造点改动位置说明加城市级精度ranges.json键名 generate()参数键从省换成省-市组合方法签名加一个参数即可接框架新增一个 ServiceProvider类本身不依赖框架用容器注册成单例最省内存换成只读外部数据源构造函数把require换成从 SQLite 或缓存服务读注意连接复用城市级精度的改法要小心不能简单地在generate()上加个可选参数就完事因为加权抽样的维度变了。prefix和total得按「省-市」这个新键重建否则抽样还是按省算的市一级会塌陷。我一般会在buildWeight()里保留一份省级汇总一份市级明细两套索引各查各的。接框架时最常见的错误是在每次请求里new ProvinceIpGenerator()。构造函数要require一个几百 KB 的数组文件高频接口下这是个实打实的浪费。用容器注册成单例或者干脆在bootstrap阶段挂到一个全局位置都是常规做法。验证方法很简单在构造函数里error_log()打一行压一百个请求看日志出现几次出现一百次就是没复用。还有一个我自己踩过的点值得单独提写完改造之后一定要跑一遍分布校验而不是只验「能生成、不报错」。我试过把加权的normalize()改坏结果所有省份都命中同一个别名接口一点不报错生成的数据全是一个省的。从那以后我每次动到数据层或者别名表都强制走一遍「生成一千条 → 反查 → 断言归属一致 → 统计各省占比」这条链路哪怕只是改了一个字符。这个类我整理成了一个压缩包里面是类文件、一份示例数据表、构建脚本和几个测试用例解压后改一下ranges.json就能用。测试数据这种活看着不起眼但真正省下来的时间是在你为了三条假数据跟测试环境较劲的那些下午。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询