问题
当您监控 100+ 台分布在多个数据中心和国家的 MariaDB / MySQL 服务器时,每次显示主页面都会遇到一个问题:这台服务器在哪里?
经典答案:打开 MaxMind 的 GeoLite2 .mmdb 文件,对每个 IP 进行 lookup,然后显示国旗。看起来简单……但是:
- 每次 HTTP 请求都打开一个 70 MB 的
.mmdb文件开销很大 - 需要解析 100 台服务器,就是每页 100 次文件 lookup
- 该文件是为顺序读取优化的二叉树,不适合并发突发访问
- 在 PHP-FPM 中,每个 worker 独立加载该文件
在 PmaControl 中,server/main 页面每秒通过 AJAX 刷新。每秒打开 .mmdb 文件 100 次是不合理的。
解决方案:全部放入 MariaDB
思路很简单:将 GeoLite2 的全部 ranges 导入一个 MariaDB 表,然后进行经典的 SQL 查询。一个带索引的 SELECT 比在磁盘上遍历二叉树要快得多。
表结构
CREATE TABLE data_geoip (
id INT AUTO_INCREMENT PRIMARY KEY,
network_start VARBINARY(16) NOT NULL,
network_end VARBINARY(16) NOT NULL,
country_iso CHAR(2) NOT NULL DEFAULT '',
country_name VARCHAR(100) NOT NULL DEFAULT '',
INDEX idx_network_start (network_start)
) ENGINE=InnoDB;
选择 VARBINARY(16) 至关重要:
- 4 字节足以存储 IPv4(32 位)
- 16 字节用于 IPv6(128 位)
VARBINARY(16)统一存储两种格式INET6_ATON()将任何 IP(v4 或 v6)转换为可比较的二进制格式
Lookup 查询
SELECT country_iso FROM data_geoip
WHERE network_start <= INET6_ATON('89.30.104.134')
AND network_end >= INET6_ATON('89.30.104.134')
LIMIT 1;
结果:FR(法国)。执行时间:< 1 ms。
同样的查询也适用于 IPv6:
SELECT country_iso FROM data_geoip
WHERE network_start <= INET6_ATON('2001:4860:4860::8888')
AND network_end >= INET6_ATON('2001:4860:4860::8888')
LIMIT 1;
结果:US(美国——这是 Google 的公共 DNS)。
导入:遍历 IP 空间
IPv4:从 0.0.0.0 到 255.255.255.255
IPv4 空间有 2^32 = 43 亿地址。我们不逐个遍历——而是使用 MaxMind reader 的 getWithPrefixLen(),它为每个地址返回完整的 CIDR:
$ip = 0;
while ($ip <= 4294967295) {
[$record, $prefixLen] = $reader->getWithPrefixLen(long2ip($ip));
// 计算网络结束位置
$networkSize = 1 << (32 - $prefixLen);
$networkEnd = $ip + $networkSize - 1;
if ($record && !empty($record['country']['iso_code'])) {
// INSERT 到 data_geoip
}
// 跳过整个 CIDR 块
$ip = $networkEnd + 1;
}
结果:约 650,000 个 ranges 在几秒内导入。每个 range 覆盖一个完整的 CIDR 块(例如:89.30.104.0/22 -> 1024 个地址对应一行记录)。
IPv6:2000::/3 空间
IPv6 空间有 2^128 个地址——不可能像 IPv4 那样遍历。但公共可路由地址位于 2000::/3(Global Unicast)块中,GeoIP 分配通常为 /32 到 /48。
原理相同:按返回的前缀长度前进。区别在于:我们处理的是 16 字节的二进制地址。
$current = inet_pton('2000::');
$end6 = inet_pton('3fff:ffff:ffff:ffff:ffff:ffff:ffff:ffff');
while ($current <= $end6) {
[$record, $prefixLen] = $reader->getWithPrefixLen(inet_ntop($current));
$endBin = binNetworkEnd($current, $prefixLen);
if ($record && !empty($record['country']['iso_code'])) {
// INSERT,使用 UNHEX(bin2hex(...))
}
$current = binIncrement($endBin); // 128 位算术 +1
}
128 位二进制算术用纯 PHP 实现(不需要 GMP):
// 将 IPv6 地址加 1
function binIncrement(string $bin): string|false
{
$bytes = unpack('C16', $bin);
for ($i = 15; $i >= 0; $i--) {
$bytes[$i]++;
if ($bytes[$i] <= 255) return pack('C16', ...$bytes);
$bytes[$i] = 0; // 进位
}
return false; // 溢出
}
生产环境的结果
数据量
| 表 | IPv4 | IPv6 | 总计 |
|---|---|---|---|
data_geoip(国家) |
~650K | ~180K | ~830K ranges |
data_geoip_city(城市) |
~3.7M | ~1.2M | ~4.9M ranges |
性能
| 操作 | 时间 |
|---|---|
| 导入国家(IPv4 + IPv6) | ~30 秒 |
| 导入城市(IPv4 + IPv6) | ~5 分钟 |
| 查询 1 个 IP | < 1 ms |
| 查询 100 个 IP(server/main 页面) | 总计 ~15 ms |
| AJAX 刷新(每秒一次) | 可忽略不计 |
显示效果
在 PmaControl 的主页面上,每台服务器在 IP 旁边显示其国旗 emoji:
🇫🇷 89.30.104.134:3306 PIXID-MDB-MASTER1
🇩🇪 136.243.1.1:3306 Hetzner-Slave
🇯🇵 210.171.224.1:3306 NTT-Tokyo
🇺🇸 8.8.8.8:3306 Google-Test
私有 IP(10.x、172.16-31.x、192.168.x、127.x)没有 GeoLite2 记录——国旗直接不显示。
更新
MaxMind 每周更新 GeoLite2。刷新方法:
# 下载新的 .mmdb 到 data/
# 然后重新运行导入:
php App/Webroot/index.php server loadGeoip # 国家(~30 秒)
php App/Webroot/index.php server loadGeoipCity # 城市(~5 分钟)
导入先执行 TRUNCATE 然后重新插入所有数据。不需要 diff 或迁移——它是一个可丢弃的缓存。
城市表:更进一步
data_geoip_city 表增加了地区、城市、GPS 坐标和时区:
SELECT country_iso, region_name, city, latitude, longitude, time_zone
FROM data_geoip_city
WHERE network_start <= INET6_ATON('136.243.1.1')
AND network_end >= INET6_ATON('136.243.1.1')
LIMIT 1;
结果:DE | Saxony | Falkenstein | 50.4779 | 12.3713 | Europe/Berlin
这为服务器地图、跨数据中心延迟检测或简单地在界面中提供更丰富的显示打开了大门。
为什么不直接用 LEFT JOIN?
可能有人想直接在服务器查询中使用 LEFT JOIN data_geoip g ON g.network_start <= INET6_ATON(s.ip) AND g.network_end >= INET6_ATON(s.ip)。问题是:对于 650K 个 ranges,这个范围连接对优化器来说开销很大。我们更倾向于 N 个单独的 lookup(每个唯一 IP 一次),这些查询由于索引而是即时的。
总结
通过将 GeoLite2 数据导入 MariaDB,我们消除了每次页面渲染时对 .mmdb 文件的依赖。Lookup 变成了一个索引化的 SELECT,执行时间 < 1 ms,同时兼容 IPv4 和 IPv6,更新也只是每周一次简单的 TRUNCATE + INSERT。
源代码可在 GitHub 上获取——欢迎贡献。
评论 (0)
暂无评论。
发表评论