做网络相关的系统时,经常要存大量IP地址,比如访问日志、风控黑名单、设备指纹库。不少人图省事直接用VARCHAR存"192.168.1.100"这种字符串,数据量一上来,存储膨胀和索引效率的问题就暴露出来了。其实IPv4地址就是一个32位整数,IPv6是一个128位整数,MySQL的BINARY类型天生就是为这种定长二进制数据准备的。这篇文章聊聊怎么在Go里把IP地址、MAC地址这类数据以二进制形式存进MySQL,再完整地取出来。

为什么用BINARY而不是VARCHAR存IP地址
先算一笔账。一个IPv4字符串最长15个字节(比如255.255.255.255),用VARCHAR(15)加上长度字节,实际占用16字节;而BINARY(4)只占4个字节,空间只有四分之一。IPv6更夸张,完整格式的字符串要39个字符,BINARY(16)只要16字节。当表里有几亿条记录时,这个差距会直接体现在磁盘占用、内存缓存命中率和备份时长上。
其次是索引性能。BINARY是定长类型,MySQL对定长列的索引结构更紧凑,比较操作直接按字节进行,不需要考虑字符集排序规则(collation)。字符串比较受字符集影响,某些排序规则下大小写转换、去尾随空格等行为都会带来额外开销,而二进制比较就是纯粹的memcmp,速度更快也更可预测。
需要说明的是,BINARY类型会自动在数据末尾补0x00到固定长度,读取时会带出来,所以Go侧解析时要注意;VARBINARY则不补齐,存变长二进制时更合适。对于IP这种长度固定的数据,推荐BINARY(4)和BINARY(16)。
建表与Go侧的数据打包
先看建表语句,一张简单的访问黑名单表:
CREATE TABLE ip_blacklist (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
ip_addr BINARY(16) NOT NULL COMMENT '统一按IPv6长度存,IPv4也放前16字节或4字节按需定',
remark VARCHAR(64) DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_ip (ip_addr)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;如果业务确定只有IPv4,用BINARY(4)即可;如果IPv4和IPv6混杂,常见做法是统一用BINARY(16),IPv4映射到IPv4-mapped IPv6(即前12字节为0,后4字节是原地址),或者另加一个类型字段区分。
Go这边把IP转成字节切片非常方便,标准库net包自带的To4()和To16()方法直接返回对应长度的[]byte:
package main
import (
"database/sql"
"fmt"
"net"
_ "github.com/go-sql-driver/mysql"
)
func ipToBytes(s string) ([]byte, error) {
ip := net.ParseIP(s)
if ip == nil {
return nil, fmt.Errorf("无效的IP地址: %s", s)
}
if v4 := ip.To4(); v4 != nil {
return v4, nil // 4字节
}
return ip.To16(), nil // 16字节
}
func main() {
db, err := sql.Open("mysql", "user:password@tcp(127.0.0.1:3306)/testdb")
if err != nil {
panic(err)
}
defer db.Close()
b, _ := ipToBytes("192.168.1.100")
_, err = db.Exec("INSERT INTO ip_blacklist (ip_addr, remark) VALUES (?, ?)",
b, "测试黑名单")
if err != nil {
panic(err)
}
}这里的关键点是:database/sql的参数绑定天然支持[]byte类型,驱动会把它当作二进制数据发送,不会经过字符集编码,所以不存在乱码问题。这也是为什么存图片、哈希值、加密后的密文时,Go侧统一用[]byte配BINARY/VARBBINARY/BLOB是最稳妥的路线。
读取二进制字段并还原为IP
读取时,把目标变量声明为[]byte,Scan会把BINARY字段的原始字节填进去,之后用net.IP包一下再调用String()就能还原成可读格式:
func queryByIP(db *sql.DB, ipStr string) (string, error) {
b, err := ipToBytes(ipStr)
if err != nil {
return "", err
}
var raw []byte
var remark string
err = db.QueryRow("SELECT ip_addr, remark FROM ip_blacklist WHERE ip_addr = ?", b).
Scan(&raw, &remark)
if err != nil {
return "", err
}
ip := net.IP(raw)
return ip.String(), nil
}有一个容易踩的坑要特别提醒:如果字段定义是BINARY(16)而你存的是IPv4的4字节,MySQL会自动在后面补12个0x00,读出来的raw长度是16。直接用net.IP(raw).String()会得到形如"::ffff:c0a8:164"的IPv6格式字符串。解决办法有两种:要么统一存16字节版本(先调To16()),要么读取后判断net.IP(raw).To4() != nil再转换。定长和定长之间不一致是二进制存储最常见的bug来源,写入和读取的长度约定一定要在代码里固定死。
另一个坑是不要把Scan的目标写成string。虽然不会报错,但驱动返回的是原始字节,某些情况下中间经过的字符转换可能引入意外行为,规范做法永远是[]byte。另外,如果读取的是可能为NULL的BINARY字段,应该用sql.RawBytes或者先Scan到sql.NullString类似的机制判断空值,不过标准写法是用[]byte配合判空。
批量写入与性能建议
批量场景下,拼接一条多值INSERT比循环单条插入快一个数量级以上。由于参数仍是[]byte,拼参数占位符即可:
func batchInsert(db *sql.DB, ips []string) error {
valueStrings := make([]string, 0, len(ips))
args := make([]interface{}, 0, len(ips)*2)
for _, s := range ips {
b, err := ipToBytes(s)
if err != nil {
continue // 跳过非法IP,也可以记录日志
}
valueStrings = append(valueStrings, "(?, ?)")
args = append(args, b, "批量导入")
}
_, err := db.Exec("INSERT INTO ip_blacklist (ip_addr, remark) VALUES "+
strings.Join(valueStrings, ","), args...)
return err
}除了批量写入,还有几点实践建议。第一,给BINARY列建索引时确认查询条件传的也是二进制数据,如果一边存二进制一边用字符串查,索引会直接失效,这是实际项目里出现频率很高的低级错误。第二,调试时可以在MySQL里用HEX(ip_addr)和UNHEX('C0A80164')来查看和构造二进制数据,也可以用INET6_NTOA(ip_addr)直接在SQL层把BINARY(16)转成可读IP,排查问题非常方便。第三,如果不想在应用层做转换,MySQL自带的INET6_ATON和INET6_NTOA函数也能完成字符串与二进制的互转,只是把计算压力放到了数据库侧,高并发写入时更推荐在Go侧打包好再传参,减少数据库CPU负担。
总结一下,Go操作MySQL的BINARY字段核心就三步:用net.IP把地址转成[]byte、用占位符参数写入、Scan到[]byte后再转回net.IP。把长度约定统一好,避开自动补零带来的格式混淆,这套方案在存储空间和查询性能上都能拿到明显收益。
Go语言MySQL BINARY类型二进制存储修改时间:2026-09-04 05:16:38