图 1:本文封面,点分文本换算成 32 位整数是核心动作。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「IP 地址如何在数据库中存储」看起来像小事,却是数据建模的基本功:访问日志、审计流水、风控名单里 IP 都是高频字段,存储选型差一个字节量级,在亿级日志表上就是实打实的索引体积与查询性能差距。
第一步,先看字符串方案为什么不行。
最直觉的存法是 VARCHAR(15),把点分文本原样塞进去。它能跑,但有三宗罪。一是体积:最长 15 个字符,还没算长度字节,而 IPv4 本质只有 32 位、4 个字节的信息量。二是排序错误:字符串排序是字典序不是数值序,'9.0.0.1' 会排在 '10.0.0.1' 后面,因为逐字符比较时 '9' 大于 '1'——按 IP 排序的报表直接出错。三是范围查询报废:「查 192.168.0.0/16 网段的所有记录」在字符串上没法用索引表达,只能全表扫或者用 LIKE 硬凑,网段边界稍微复杂就凑不出来。
第二步,正确姿势:整数存储加转换函数。
IPv4 地址就是四个 0 到 255 的数,拼起来正好 32 位。MySQL 里的标准方案是用 INT UNSIGNED 存这个整数,读写时用内置函数互转:INET_ATON 把点分文本转成整数,INET_NTOA 转回来。以 192.168.1.1 为例,换算规则是每段依次乘以 256 的幂再相加:192×256³ + 168×256² + 1×256 + 1 = 3232235777,存储的就是这个大整数。注意函数按网络字节序(大端)计算,段序不能搞反。
整数方案把字符串的三宗罪逐一化解:固定 4 字节,比 VARCHAR(15) 省三分之二以上,索引体积同步缩小;数值比较天然有序,排序不会再翻车;范围查询变成普通整数区间——192.168.0.0/16 换算成整数区间 [3232235520, 3232301055],查询就是 WHERE ip BETWEEN 3232235520 AND 3232301055,直接走 B+ 树索引的范围扫描。
第三步,用代码验证换算与排序差异。
下面这份代码实现了与 INET_ATON/INET_NTOA 等价的互转逻辑,实测换算结果、网段区间判断,并复现字符串排序翻车现场。用 Node.js 22.15.0 直接运行(node ip.js)即可:
'use strict';
// IPv4 点分文本 <-> 32 位无符号整数 互转
function ipToLong(ip) {
const parts = ip.split('.');
if (parts.length !== 4) throw new RangeError('非法 IPv4 地址');
let result = 0;
for (const part of parts) {
const n = Number(part);
if (!Number.isInteger(n) || n < 0 || n > 255) throw new RangeError(`非法段:${part}`);
result = result * 256 + n; // 等价于左移 8 位再相加
}
return result;
}
function longToIp(num) {
const parts = [];
for (let i = 3; i >= 0; i--) {
parts.push(Math.floor(num / 256 ** i) % 256);
}
return parts.join('.');
}
const ip = '192.168.1.1';
const num = ipToLong(ip);
console.log(`${ip} -> ${num}(INET_ATON 等价结果)`);
console.log(`${num} -> ${longToIp(num)}(INET_NTOA 等价结果)`);
console.log(`手工验算:192×256^3 + 168×256^2 + 1×256 + 1 = ${192 * 256 ** 3 + 168 * 256 ** 2 + 1 * 256 + 1}`);
// 网段 192.168.0.0/16 的范围查询:转成整数区间
const base = ipToLong('192.168.0.0');
const maskBits = 32 - 16;
const upper = base + 2 ** maskBits - 1;
console.log(`192.168.0.0/16 -> 整数区间 [${base}, ${upper}],即 WHERE ip BETWEEN ${base} AND ${upper}`);
console.log(`区间内判断:192.168.1.1 在内?${num >= base && num <= upper};10.0.0.8 在内?${ipToLong('10.0.0.8') >= base && ipToLong('10.0.0.8') <= upper}`);
// 字符串排序翻车现场:字典序不是数值序
const ips = ['9.0.0.1', '10.0.0.1', '192.168.1.1'];
console.log(`按字符串排序:${[...ips].sort().join(' < ')}(9 排在了 10 后面,字典序错误)`);
console.log(`按整数排序:${[...ips].sort((a, b) => ipToLong(a) - ipToLong(b)).join(' < ')}(数值序正确)`);
实测输出:
192.168.1.1 -> 3232235777(INET_ATON 等价结果)
3232235777 -> 192.168.1.1(INET_NTOA 等价结果)
手工验算:192×256^3 + 168×256^2 + 1×256 + 1 = 3232235777
192.168.0.0/16 -> 整数区间 [3232235520, 3232301055],即 WHERE ip BETWEEN 3232235520 AND 3232301055
区间内判断:192.168.1.1 在内?true;10.0.0.8 在内?false
按字符串排序:10.0.0.1 < 192.168.1.1 < 9.0.0.1(9 排在了 10 后面,字典序错误)
按整数排序:9.0.0.1 < 10.0.0.1 < 192.168.1.1(数值序正确)
最后一行输出里 '9.0.0.1' 排到了 '192.168.1.1' 后面——字典序的错就是这么直白。
图 2:三种存储方案对比与网段范围查询的完整链路。
IPv6 与易错点。
IPv6 是 128 位,塞不进任何整数列,MySQL 的方案是 VARBINARY(16) 存二进制形式,配合 INET6_ATON 与 INET6_NTOA 互转——这套函数同时兼容 IPv4(返回 VARBINARY(4)),所以新系统如果想一套字段通吃两个协议,直接用 VARBINARY(16) 加 INET6 一族函数即可。易错点记三个:INET_ATON 对 '127.1' 这类短格式可能返回非空结果,官方明确不建议用于短格式,严格校验请换 IS_IPV4 或 INET6_ATON(非法地址返回 NULL);整数列必须加 UNSIGNED,有符号 INT 放不下 255 开头的地址;INET6_ATON 不接受带端口或掩码后缀的字符串,入库前要先清洗。
落到行动建议:新表 IPv4 用 INT UNSIGNED、IPv6 或混存用 VARBINARY(16),转换交给数据库函数而不是应用层字符串拼接;存量 VARCHAR 字段迁移时注意先双写校验。你的日志表里 IP 是用什么类型存的?欢迎聊聊踩过的坑。