如果你维护过稍微大一点的日志系统或者写过带访问控制的接口一定见过这种场景IP地址在数据库里被存成了varchar(15)每次按来源IP查排行榜都要先全表扫一遍用ORDER BY排序结果10.0.0.1跑到了9.0.0.1前面做个黑名单区间判断字符串比较更是绕得人头疼。这些问题的根源多半是没用上IPv4地址与int类型之间的转换。这篇总结把原理、代码、数据库设计和坑一次讲清楚适合后端开发、数据清洗、网络运维以及对IP处理有兴趣的同学直接抄作业。1. 为什么需要转换先理清业务场景1.1 IPv4地址的本质一串32位二进制要把IP转成int得先知道IP到底是什么。IPv4地址在协议栈里并不是一串带点的字符而是一个32位的二进制数只是为了让人好读才按8位一拆改成点分十进制的写法。比如192.168.1.10拆开看是这样的192 - 11000000 168 - 10101000 1 - 00000001 10 - 00001010四个8位拼起来就是完整的32位11000000 10101000 00000001 00001010既然IP本身就是32位二进制那它天然就等价于一个无符号32位整数。所谓转换本质上是把点分十进制字符串解析成这个32位整数以及反向的把这个整数还原成可读字符串。想通了这一点后面所有的代码都只是表达方式不同而已。1.2 什么场景必须转什么场景不转先说结论不是所有地方都要转。如果只是在页面上显示、写个简单日志、做少量人工排查用字符串完全没问题。但有几个场景转了之后收益非常明显数据库存储varchar(15) 存一个IP要占15字节和额外索引空间UNSIGNED INT只占4字节索引树更矮查询更快。排序字符串排序是按字典序来的10.0.0.1会排在9.0.0.1前面语义上完全错误。转成int再排序就跟路由器视角一致了。范围判断判断IP是否命中某个段比如192.168.1.0/24字符串需要拆段逐位比对int直接用和一条SQL就搞定。去重与聚合日志系统按IP计数、按子网聚合int用位运算一行代码算出来字符串就没这么方便。我最早接触这个需求是做某线上服务的访问日志统计。上千万条日志按IP聚合不改存储结构之前一个统计任务跑十几分钟改成int存储、加上索引之后同样的统计几十秒就出来了。从那之后凡是涉及IP的比较和存储我都会默认走int方案。2. 从IP到整数的换算原理2.1 基础公式乘法和移位两条路一个IPv4地址a.b.c.d转成整数最直观的办法是套乘法公式整数 a × 256³ b × 256² c × 256 d为什么权重是256的幂因为每一段是8位二进制能表示0到255下一个8位单位就是256。就像十进制里千位权重是10³、百位权重是10²道理一模一样。写代码时更常用位移版本整数 (a 24) | (b 16) | (c 8) | d每个8位移到属于自己的32位区间里互不重叠最后用按位或拼起来。两个公式计算结果完全相同位移写法更贴近把二进制段拼接起来这个语义也避免了几个大数相乘时的手误。2.2 手工计算示例以192.168.1.10为例拿192.168.1.10实际算一遍。四个段分别是192、168、1、10权重如下IP段权重乘积192256³ 16,777,2163,221,225,472168256² 65,53611,010,0481256¹ 25625610256⁰ 110四项相加3,221,225,472 11,010,048 256 10 3,232,235,786所以192.168.1.10对应的整数是3232235786。这个值正好落在无符号32位整数的有效范围内0到4,294,967,295之间。再看两个常见的例子加深记忆0.0.0.0对应0255.255.255.255对应4294967295。127.0.0.1对应2130706433这是开发时最容易验证的数。2.3 为什么或运算和加法在这里等价有读者可能会问你一会儿用加法一会儿用按位或到底哪个对其实在每一段都占独立8位、没有进位重叠的前提下两者结果完全一样。举个例子192 24 的结果低24位全是0168 16 的结果中间16位是数据、其余位是0两者没有任何一位同时为1所以按位或和求和的结果一致。用按位或更符合拼接的意图用加法更像日常数学直觉。代码里我建议统一用按位或顺手能提醒自己每一段的位区间是固定的不要越界。还有个小建议写移位表达式时一定要加括号。不同语言的运算符优先级不一样a 24 | b 16这种写法在多数语言里没问题但为了可读性和避免某些脚本语言抽风老老实实写成(a 24) | (b 16) | (c 8) | d。3. 反向转换从32位整数还原IPv4地址3.1 位移加掩码把每一位抠出来从整数还原IP是正向过程的逆操作。核心动作有两个右移和掩码。拿到整数n之后要取出第一段a (n 24) 0xFF意思是先把n右移24位让原本最高8位落到最低位再用0xFF也就是255把低8位以外的部分全部清零。后面的三段同理b (n 16) 0xFF c (n 8) 0xFF d n 0xFF还是用3232235786来验证。这个数换算成十六进制是0xC0A8010A拆开看就是0xC0、0xA8、0x01、0x0A分别对应十进制192、168、1、10。右移24位得到0xC0掩码后还是192右移16位得到0xC0A8如果不做掩码结果就是49320根本不是合法的IP段掩码把高8位去掉后才是168。这一步是新手最常漏的。写成一个通用函数大概长这样def int_to_ip(n): return ..join(str((n shift) 0xFF) for shift in (24, 16, 8, 0))3.2 反向转换的边界条件做反向转换时先判断输入是否在0到4294967295之间。n小于0或者大于4294967295肯定不是合法的IPv4整数。很多语言里如果不检查右移和掩码也能算出几个看着挺像的数字但那只是无符号截断后的幻觉。另外注意反向转换一般不需要考虑负数但如果你用的是有符号32位存储把4.29亿以上的值记录成了负数转回IP前要先做无符号处理。比如Java里用longMySQL里用UNSIGNED INTGo里用uint32核心目的都是保住无符号语义。4. 各语言中的最佳写法4.1 Python优先用标准库手写也不难Python自带ipaddress模块处理这个需求几乎是完美的from ipaddress import IPv4Address # 字符串转整数 ip_int int(IPv4Address(192.168.1.10)) print(ip_int) # 3232235786 # 整数转字符串 ip_str str(IPv4Address(3232235786)) print(ip_str) # 192.168.1.10模块内部会做合法性校验地址格式不对直接抛异常省了自己写判断的逻辑。如果不想引标准库之外的东西手写也很短def ip_to_int(ip: str) - int: parts ip.split(.) if len(parts) ! 4: raise ValueError(invalid IP) nums [int(p) for p in parts] if any(p 0 or p 255 for p in nums): raise ValueError(octet out of range) return (nums[0] 24) | (nums[1] 16) | (nums[2] 8) | nums[3] def int_to_ip(n: int) - str: if n 0 or n 0xFFFFFFFF: raise ValueError(out of IPv4 range) return ..join(str((n shift) 0xFF) for shift in (24, 16, 8, 0))补充一个在socket编程里更常见的做法用struct.pack和struct.unpack显式指定网络字节序。import socket import struct # 字符串转int ip_int struct.unpack(!I, socket.inet_aton(192.168.1.10))[0] # int转字符串 ip_str socket.inet_ntoa(struct.pack(!I, 3232235786))注意!I里的感叹号表示网络字节序大端不要写成不带感叹号的I否则在x86这类小端机器上字节顺序会整个颠倒。4.2 JavaScript注意无符号右移JS里写这个转换要格外小心因为按位运算符会把数字强制转成32位有符号整数。以128.0.0.1为例它的整数是2147483649已经超了有符号int正数上限JS的运算会算出负数。解决办法是转换结果后面加一个 0强制按无符号32位解释function ipToInt(ip) { const parts ip.split(.).map(Number); return (((parts[0] 24) | (parts[1] 16) | (parts[2] 8) | parts[3]) 0); } function intToIp(n) { n n 0; return [(n 24) 255, (n 16) 255, (n 8) 255, n 255].join(.); }实测ipToInt(192.168.1.10)返回3232235786intToIp(3232235786)返回192.168.1.10。这个 0 就是JS版的无符号转换开关忘记写的话碰到这类地址值就会得到莫名其妙的负数。4.3 Java用long而不是intJava的int是32位有符号最大值只有2147483647装不下255.255.255.255对应的4294967295所以要么用long要么用别的包装类型。实际项目里推荐用long代码最直观public static long ipToLong(String ip) { String[] parts ip.split(\\.); if (parts.length ! 4) { throw new IllegalArgumentException(invalid IPv4 address); } long result 0; for (int i 0; i 4; i) { int octet Integer.parseInt(parts[i]); if (octet 0 || octet 255) { throw new IllegalArgumentException(octet out of range); } result (result 8) | octet; } return result; } public static String longToIp(long ip) { if (ip 0 || ip 0xFFFFFFFFL) { throw new IllegalArgumentException(out of IPv4 range); } return String.format(%d.%d.%d.%d, (ip 24) 0xFF, (ip 16) 0xFF, (ip 8) 0xFF, ip 0xFF); }这里有两个细节split里要写\\.而不是.因为点是正则表达式里的通配符longToIp里的0xFFFFFFFFL要加L后缀否则字面量超int范围编译不过。4.4 GoParseIP和To4的正确姿势Go的net.ParseIP会返回16字节的IP对象既能表示IPv4也能表示IPv6。想转IPv4整数必须显式调用To4()拿到4字节切片不然直接按下标取数据会取到错误位置package main import ( fmt net ) func ipToInt(ipStr string) uint32 { ip : net.ParseIP(ipStr) if ip nil { return 0 } ip ip.To4() if ip nil { return 0 } return uint32(ip[0])24 | uint32(ip[1])16 | uint32(ip[2])8 | uint32(ip[3]) } func intToIp(n uint32) string { return fmt.Sprintf(%d.%d.%d.%d, byte(n24), byte(n16), byte(n8), byte(n)) }使用uint32是Go里表达无符号IPv4整数最自然的方式。如果业务数据在数据库里用BIGINT保存查询时转uint32注意溢出即可。4.5 MySQL直接使用INET_ATON和INET_NTOA数据库层是最能体现这个转换价值的地方。MySQL从很早就内置了IP转换函数SELECT INET_ATON(192.168.1.10); -- 3232235786 SELECT INET_NTOA(3232235786); -- 192.168.1.10MySQL 5.7以后还提供了校验函数IS_IPV4()可以在写入前判断字符串是否合法INSERT INTO access_log(ip_int) VALUES (INET_ATON(192.168.1.10));注意INET_ATON直接返回的是无符号整数语义别把它塞进普通的INT字段否则超过2147483647的IP地址会报错。等下第五部分会专门讲数据库字段选择。5. 放进数据库之后查询性能的差距5.1 字段类型不要用varchar存IP把IP存成字符串的残留原因主要是看起来方便但代价很高。对比一下维度varchar(15) 存IPint存IP占用空间最多15字节加长度开销4字节索引体积大B树层数可能多一层小查询更稳排序语义字典序结果错误数字序语义正确范围查询需要拆段或正则直接用比较运算符子网聚合需字符串截取位运算一条SQLMySQL里推荐用INT UNSIGNED它正好表示0到4294967295的无符号整数和IPv4完全对应。PostgreSQL里可以用BIGINT或者直接用自带的inet类型但inet类型在范围查询上也可以直接用运算符只是存储优化角度上int仍然有优势。SQLite用INTEGER即可。业务代码写入时先做一次IP到int的转换查询时也用int做过滤条件千万不要图省事在SQL里对列调INET_ATON。列上套了函数索引会失效等于全表扫描。5.2 IP段范围查询的SQL写法判断一个IP是否落在某个网段内是风控、黑白名单、访问控制里最高频的需求。存储转成int之后SQL非常清爽-- 查找所有落在 192.168.1.0/24 内的记录 SELECT * FROM access_log WHERE ip_int INET_ATON(192.168.1.0) AND ip_int INET_ATON(192.168.1.255);这是典型的索引范围扫描性能比字符串的LIKE查询稳定得多。在实际项目里如果黑名单本身也有一张表可以直接用JOIN和BETWEEN来做区间关联数据量再大也能扛住。5.3 按子网聚合统计的小技巧int型IP在做子网聚合时优势更大。统计每个 /24 网段的访问量字符串方案需要截取前三段int方案只需要做一次位运算SELECT INET_NTOA(ip_int 0xFFFFFF00) AS subnet, COUNT(*) AS cnt FROM access_log GROUP BY ip_int 0xFFFFFF00 ORDER BY cnt DESC;这里的ip_int 0xFFFFFF00是把最后8位清零只保留前24位也就是去掉主机号得到网段起始IP。同理统计 /16 就用ip_int 0xFFFF0000统计 /8 就用ip_int 0xFF000000。这种写法不仅简单而且能直接利用int分组不需要改表结构加冗余字段。6. 我在实践中踩过的坑6.1 有符号/无符号最经典的翻车现场这个坑十个人里至少有八个人踩过。4,294,967,295 超出了有符号32位整数的上限一旦把255.255.255.255存进普通INT字段MySQL直接报ERROR 1264: Out of range value。用Java的int变量去接数据变成-1。用PHP的ip2long转换32位系统下也会拿到负数得用sprintf(%u, ip2long($ip))包一下。应对方法就一句话所有参与IPv4整数转换和存储的类型都要用无符号语义。MySQL里是INT UNSIGNEDJava里是longGo里是uint32JS里每次按位运算后用 0 转一次。6.2 输入校验split一下不等于合法一个常见误解是把IP字符串split之后数量是4就一定合法。实际上192.168.999.1也能split成4段但它根本不是合法IP。还有192.168.1这种只有3段的以及192.168.1.1.1这种5段的都要拦下来。比较稳的校验方式是逐段转数字后检查范围def is_valid_ipv4(ip: str) - bool: parts ip.split(.) if len(parts) ! 4: return False try: nums [int(p) for p in parts] except ValueError: return False return all(p 0 and p 255 for p in nums)正则也能做但我更推荐上面这种拆段数值检查的组合逻辑清晰不容易漏边界。别只用^\d{1,3}(\.\d{1,3}){3}$这种正则因为它会放过999这类越界值。6.3 前导零与特殊写法192.168.001.010看起来像是同一个IP但如果你不做控制某些语言会把它当成八进制解析得到完全不同的结果。碰到这种情况建议直接拒绝带前导零的输入或者统一用十进制解析并单独校验。还有一类输入是192.168.1.10 或者 192.168.1.10这种带空格的底层函数有时能容忍有时不能。写业务接口时最好先trim避免脏数据混进存储。6.4 字节序的影响做socket编程的人容易在字节序上翻车。整数转成字节数组时网络协议规定用大端序网络字节序而x86机器是小端序。如果在Python里用struct.pack(I, ip_int)而不是struct.pack(!I, ip_int)得到的字节顺序正好是反的发给对端就会解析成完全不同的IP。这个问题的排查思路很简单先确认你用的库函数默认字节序。凡是带网络协议交互的一律显式指定大端。6.5 IPv6与IPv4映射地址IPv4转换整数的方案不能直接套用到IPv6因为IPv6是128位一个int根本装不下。如果业务要同时支持IPv6建议单独存BINARY(16)MySQL里用INET6_NTOA、INET6_ATON处理。还有一个隐蔽场景IPv4映射地址比如 ::ffff:192.168.1.10。这类地址本质上是IPv6格式包着一个IPv4直接用IPv4解析会失败。Python的ipaddress库可以这样提取import ipaddress addr ipaddress.ip_address(::ffff:192.168.1.10) if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped: v4_int int(addr.ipv4_mapped) print(v4_int) # 3232235786Go的net.ParseIP配合To4()也能处理这种情况To4()会把IPv4映射地址自动转成4字节表示。接到外部请求头时这类地址比想象中常见值得加一层兼容处理。6.6 常见问题速查表症状原因解决大IP转出来是负数有符号int溢出Java用longJS加0MySQL用INT UNSIGNEDINET_ATON(255.255.255.255)存库报错列类型用了有符号INT修改列为INT UNSIGNEDORDER BY ip_str结果顺序错乱字符串字典序存储用int排序按int列上套INET_ATON后查询慢索引失效写入时转int查询用常量socket收发IP数据解析不对字节序不一致用网络字节序!I192.168.999.1被当成合法IP只split没检查数值逐段转数字并检查0到255IPv4映射地址解析失败::ffff:前缀Go用To4Python用ipv4_mapped7. 最后两个我坚持的生产习惯有一点想单独提出来纵然IP转int有这么多好处也别在业务代码里到处散落手工转换逻辑。建议把它封装成一个公共工具模块正向反向各一个函数数据库访问层统一走这个模块。这样万一之后要支持IPv6只需要在工具函数里加判断调用方不用改。另一个习惯是写单元测试时固定测几个边界值0.0.0.0、127.0.0.1、192.168.1.10、128.0.0.1、255.255.255.255。这几个值分别覆盖了最小值、回环地址、常规地址、有符号溢出临界点、最大值测试能跑过这组数据基本就稳了。回头想想这个转换本身并不复杂难的是意识到IP不该当字符串处理。很多服务用字符串存IP看似没出大问题是因为数据量还没到临界点。真到了日志每天上千万条、黑名单要秒级过滤的时候再回头改存储结构就折腾了。希望这篇总结能帮你一开始就选对方向少走我走过的那些弯路。
阅读完成 · 觉得有帮助?