Go 后台会员表设计:手机号可为空时,唯一索引该怎么处理
会员表里有一个很常见的字段:mobile。它看起来只是手机号,但一旦业务允许"暂时不填手机号",数据库设计就容易埋坑。
很多人会把可选手机号保存成空字符串 '',字段继续 NOT NULL,再给 mobile 加唯一索引。这样写在接口层很省事,后面却容易遇到两个问题:多个未填手机号的用户都写入 '',唯一索引会冲突;如果项目同时支持 MySQL 和 PostgreSQL,空字符串和 NULL 的语义差异会让迁移脚本变得更绕。
这篇只讨论一个具体问题:手机号可为空时,唯一索引应该怎么处理。重点不在框架介绍,而在 SQL、历史数据和上线验证。
先把 `''` 和 `NULL` 分开
空字符串表示"有一个值,这个值长度为 0"。NULL 表示"没有值"。这两个东西在业务表里不能混着用。
如果手机号是可选字段,我更倾向于把"未填写"落成 NULL。原因很简单:唯一索引应该约束真实手机号,没填手机号的记录不应该互相冲突。
可以按这张表理解:
| 写入值 | 业务含义 | 是否参与唯一值比较 | 适合可选手机号 |
|---|---|---|---|
| `''` | 已写入一个空字符串 | 会被当成同一个值 | 不适合 |
| `NULL` | 未填写 | 多数数据库允许多个 `NULL` 通过唯一索引 | 更合适 |
| `13800138000` | 真实手机号 | 必须唯一 | 合适 |
这里不要把结论扩大成"所有可选字段都必须用 NULL"。比如昵称为空可以是空字符串,备注为空也可以是空字符串。手机号、邮箱、身份证号这类带唯一约束的字段,才更需要认真区分。
MySQL 侧迁移怎么写
假设原来的字段是这样的:
sql
CREATE TABLE xy_member (
id bigint unsigned NOT NULL AUTO_INCREMENT,
username varchar(64) NOT NULL,
mobile varchar(20) NOT NULL DEFAULT '',
PRIMARY KEY (id),
UNIQUE KEY uk_mobile (mobile)
);
这个设计的问题是:第一条空手机号可以写进去,第二条空手机号会撞到 uk_mobile。
迁移时可以分两步做。先把历史空字符串归一成 NULL,再把字段改成允许为空:
sql
UPDATE xy_member
SET mobile = NULL
WHERE mobile = '';
ALTER TABLE xy_member
MODIFY COLUMN mobile varchar(20) DEFAULT NULL COMMENT '手机号';
如果线上已经存在重复脏数据,不能直接跑迁移。先查一遍:
sql
SELECT mobile, COUNT(*) AS cnt
FROM xy_member
WHERE mobile IS NOT NULL AND mobile <> ''
GROUP BY mobile
HAVING COUNT(*) > 1;
这条 SQL 只查真实手机号的重复。查出来有结果,就先处理数据归属,再改字段。不要把迁移脚本当成清洗脚本,尤其是会员表这种可能参与登录和通知的表。
PostgreSQL 侧不要照抄 MySQL
PostgreSQL 的写法不一样。它通常会拆成几条 ALTER COLUMN:
sql
UPDATE xy_member
SET mobile = NULL
WHERE mobile = '';
ALTER TABLE xy_member
ALTER COLUMN mobile DROP NOT NULL;
ALTER TABLE xy_member
ALTER COLUMN mobile DROP DEFAULT;
CREATE UNIQUE INDEX IF NOT EXISTS uk_member_mobile
ON xy_member (mobile);
这里有两个细节。
第一,DROP NOT NULL 和 DROP DEFAULT 分开写,迁移意图更清楚:字段允许为空,同时不再默认写入空字符串。
第二,唯一索引最好用数据库自己的语法重新确认。MySQL 里是 UNIQUE KEY uk_mobile (mobile),PostgreSQL 里更常见的是 CREATE UNIQUE INDEX ... ON ...。如果项目需要同时支持两种数据库,不要试图用一份 SQL 覆盖所有细节。
Go 代码里也要守住写入边界
数据库改完以后,接口层也要避免继续把空手机号写成 ''。
如果请求参数里手机号是空字符串,写入前应该转成 NULL。伪代码大概是这样:
go
func normalizeMobile(mobile string) any {
mobile = strings.TrimSpace(mobile)
if mobile == "" {
return nil
}
return mobile
}
实际项目里不一定直接返回 any。有些团队会用 sql.NullString,有些会在 DAO 层处理,有些会在入库 map 里把空值置成 nil。重点不是形式,而是别让接口层继续把"没填"写成空字符串。
还有一个容易忽略的地方:Go 实体字段如果仍然是 string,只能说明读取时会得到字符串表示,不等于整个链路已经完成了 nullable 类型建模。这个边界要写清楚,不然以后排查会很别扭。
用真实迁移脚本看一眼
XYGo Admin v1.4.6 里修过这个小坑:会员手机号从空字符串改成 NULL,MySQL 和 PostgreSQL 的迁移脚本分开写。这个例子有参考价值,不是因为它代码复杂,而是因为它把"历史空值"和"唯一索引语义"放在同一次迁移里处理了。
仓库地址: https://github.com/z312193608/xygo-admin
可以重点看这几类文件:
text
server/cmd_tools/migrate/1.4.6_member_mobile_nullable.mysql.sql
server/cmd_tools/migrate/1.4.6_member_mobile_nullable.pgsql.sql
server/internal/model/entity/member.go
注意,案例里的 Go 实体当前 Mobile 仍是字符串字段。所以正文这里只把它当成数据库迁移案例,不把它包装成"完整 nullable 类型方案"。这个限制说出来,反而更方便读者判断自己项目要不要继续改模型层。
上线前我会按这个顺序检查
迁移手机号字段前,至少跑完这些检查:
sql
-- 1. 看历史空字符串数量
SELECT COUNT(*) AS empty_mobile_count
FROM xy_member
WHERE mobile = '';
-- 2. 看真实手机号是否重复
SELECT mobile, COUNT(*) AS cnt
FROM xy_member
WHERE mobile IS NOT NULL AND mobile <> ''
GROUP BY mobile
HAVING COUNT(*) > 1;
-- 3. 迁移后确认空字符串已经归零
SELECT COUNT(*) AS empty_mobile_count
FROM xy_member
WHERE mobile = '';
-- 4. 迁移后确认 NULL 数量符合预期
SELECT COUNT(*) AS null_mobile_count
FROM xy_member
WHERE mobile IS NULL;
如果是生产库,我还会加三个动作:备份表结构和关键数据;先在预发环境跑同一份迁移;上线后用只读 SQL 验证,不在验证阶段顺手改数据。
回滚点也要提前想好。比如 MySQL 里如果要退回 NOT NULL DEFAULT '',必须先把 NULL 改回空字符串:
sql
UPDATE xy_member
SET mobile = ''
WHERE mobile IS NULL;
ALTER TABLE xy_member
MODIFY COLUMN mobile varchar(20) NOT NULL DEFAULT '' COMMENT '手机号';
这不是推荐回滚方案,只是提醒:字段语义一旦从空字符串切到 NULL,回滚也会改数据含义。上线窗口里最好别临时想。
最后留一个判断标准
如果字段没有唯一约束,空字符串和 NULL 的选择更多是团队规范问题;如果字段有唯一约束,尤其是手机号、邮箱这类登录或通知字段,就不要只图接口层省事。
我的判断是:可选手机号用 NULL 表示未填写,唯一索引只约束真实手机号。迁移时先处理历史空字符串,再分别写 MySQL 和 PostgreSQL 的脚本,最后用查询验证结果。这样改动不大,但能少掉一类很烦的注册、导入和后台编辑冲突。