深蓝黑背景封面:右侧大标题「IP 地址别再用字符串存了」,副标题「整数存储、转换函数与范围查询」;左侧原创示意——点分四段 192.168.1.1 经过一道换算漏斗变成一个 32 位整数 3232235777,下方一条数轴表示区间比较;左上标注「面试经典 · 数据处理」,右上标注「Day 03 · 10-21」 图 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' 后面——字典序的错就是这么直白。

对比加换算图:上方三方案对照——VARCHAR(15) 字符串存点分文本,最长 15 字节、排序按字典序会出错、范围查询无法用索引;INT UNSIGNED 存 32 位整数,固定 4 字节、数值比较天然有序、网段查询变 BETWEEN;IPv6 用 VARBINARY(16) 或 BINARY(16),配合 INET6_ATON 一族函数转换。中部换算演示:192.168.1.1 的每一段左移拼成 32 位整数(192×2 的 24 次方 + 168×2 的 16 次方 + 1×2 的 8 次方 + 1 = 3232235777),数据库里 INET_ATON 与 INET_NTOA 负责互转。下方范围查询演示:192.168.0.0/16 网段转成整数区间 [3232235520, 3232301055],WHERE ip BETWEEN 下界 AND 上界直接走 B+ 树索引。底部提示:字符串方案在排序和区间两件事上同时吃亏,整数方案在体积与索引效率上双赢 图 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 是用什么类型存的?欢迎聊聊踩过的坑。