Go 后台会员表设计:手机号可为空时,唯一索引该怎么处理

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 NULLDROP 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 的脚本,最后用查询验证结果。这样改动不大,但能少掉一类很烦的注册、导入和后台编辑冲突。

相关推荐
谭光志17 分钟前
Vibe Coding 时代,程序员该何去何从
前端·后端·ai编程
GoGeekBaird1 小时前
我用 BeeWeave 跑完了一次完整写作闭环
后端·github
仿生狮子2 小时前
别再说“全栈”了,AI 时代团队只认这 5 种人
前端·人工智能·后端
许彰午2 小时前
95_Python内存管理与垃圾回收
开发语言·python
多加点辣也没关系3 小时前
JavaScript|第13章:原始类型的方法
开发语言·javascript·ecmascript
এ慕ོ冬℘゜3 小时前
深入理解 JavaScript 事件体系:Window、鼠标与键盘事件详解
开发语言·javascript·okhttp
chouchuang4 小时前
day-030-综合练习-笔记管理器
开发语言·笔记·python
Reart5 小时前
Leetcode 213.打家劫舍2(内含闲谈,打劫真是技术活,好题,716)
后端·算法
触底反弹5 小时前
🔥 RAG 到底是怎么工作的?掰开揉碎了给你讲明白!
javascript·人工智能·后端
云空5 小时前
《Three.js 3D实例大全》
开发语言·javascript·3d·three.js