3103 字
16 分钟
MySQL 表结构设计规范

表结构设计是数据库建模的第一步,直接决定着后续 SQL 性能的天花板和业务代码的复杂度。大厂规范中最常强调的两条铁律——所有字段必须 NOT NULL 和时间字段的正确选型,背后都有底层存储、查询行为、以及业务语义的深层考量。理解这些设计决策的”为什么”,比记住规范本身更重要。


目录#

  1. 为什么大厂严禁使用 NULL
  2. DATETIME vs TIMESTAMP 选型
  3. Go 后端:时间字段的代码落地
  4. 常见面试题
  5. 总结

1. 为什么大厂严禁使用 NULL#

几乎所有一线互联网公司的 MySQL 规范第一条都是:所有字段必须设置 NOT NULL 并提供默认值。这不仅仅是代码洁癖,背后是对查询性能、数据一致性、以及业务语义的三维打击。

1.1 NULL 的语义陷阱:索引比较失效#

NULL 在 SQL 中的含义是”未知”(unknown),它不等于任何值,也不等于另一个 NULL:

-- 以下比较统统返回 NULL(即"未知"),而不是 TRUE 或 FALSE
NULL = NULL → NULL
NULL != NULL → NULL
NULL < 1 → NULL
NULL > 1 → NULL
-- 查询 NULL 只能用专门的语法
WHERE column IS NULL -- ✅
WHERE column IS NOT NULL -- ✅
WHERE column = NULL -- ❌ 永远查不到数据!

常见陷阱:如果你在 WHERE 条件中使用了 != NULL 或 = NULL,SQL 不会报错,但会静默地返回空结果集。这种 bug 极难排查。

在 B+ 树索引中,NULL 值的处理也比普通值复杂——它会被统一存放在索引树的最左侧(或最右侧,取决于 MySQL 版本),范围查询和等值匹配时可能触发额外的分支判断,甚至导致索引失效。关于 B+ 树索引的详细原理,参见 索引底层与优化。

1.2 聚合函数的暗坑:COUNT 自动忽略 NULL#

这是最容易被忽视的线上报表 Bug 源头:

-- 假设 user 表有 100 行数据,其中 10 行的 email 字段为 NULL
SELECT COUNT(email) FROM user; -- 返回 90 ← 自动忽略了 10 行为 NULL 的email
SELECT COUNT(*) FROM user; -- 返回 100 ← 正确
-- COUNT(1) vs COUNT(*) vs COUNT(字段):
-- COUNT(*) 或 COUNT(1) :统计所有行,不忽略 NULL
-- COUNT(字段) :只统计该字段非 NULL 的行,忽略 NULL

类似的还有 SUM()、AVG() 等聚合函数——它们同样会跳过 NULL 值。如果业务逻辑依赖这些函数的返回值做判断,NULL 的存在会让结果变得不可预测。关于 COUNT 更深入的优化讨论,参见 复杂SQL调优 第 3 节。

1.3 底层存储开销:NULL 标志位图#

在 InnoDB 的底层数据页格式中,如果一张表允许有 NULL 列,MySQL 必须在每一行的行头部额外开辟一段空间,叫作 NULL Bitmap(NULL 标志位图):

InnoDB 行格式(COMPACT / DYNAMIC):
┌──────────────┬──────────────────┬─────────────┬───────────┬─────────┐
│ 变长字段长度 │ NULL 标志位图 │ 记录头信息 │ 列1数据... │ 列2数据 │
│ (变长列列表) │ (每列为 NULL→1) │ (5 字节) │ │ │
└──────────────┴──────────────────┴─────────────┴───────────┴─────────┘
NULL 的标志位图大小 = CEILING(允许 NULL 的列数 / 8) 字节

这带来两个问题:

  1. 空间浪费:每一行多出来的位图字节,在亿级数据量下不可忽视
  2. 解析成本:每次读取一行数据时,InnoDB 都要先解析位图来判断哪些列为 NULL,增加了 CPU 开销

此外,在 Go 代码层面,用 database/sql 扫描允许 NULL 的字段时,必须使用 sql.NullString、sql.NullInt64 等包装类型,而不能直接用原生 string 或 int64。这给业务代码带来额外的转换负担:

// ❌ 字段允许 NULL 时,必须这样写:
var email sql.NullString
row.Scan(&email)
if email.Valid {
user.Email = email.String
} else {
user.Email = "" // 手动处理 NULL
}
// ✅ 字段 NOT NULL + 默认值 时,直接这样写:
var email string
row.Scan(&email)
user.Email = email

核心结论:NULL 让系统分不清”业务上的 null”和”数据上的 null”——到底是用户没有填邮箱,还是数据采集失败?通过强制 NOT NULL + DEFAULT 的方式,用空字符串 "" 或 0 来明确表达”没有值”的语义,比用 NULL 好一万倍。


2. DATETIME vs TIMESTAMP 选型#

面试官问这两者的区别,主要考察你对**跨国业务(时区)和存储边界(Y2K38)**的理解。

2.1 核心区别对比#

核心维度DATETIME(日期时间)TIMESTAMP(时间戳)
时区相关性毫无关系。你存进去是北京时间 8:00,磁盘上就记录 8:00。哪怕把服务器搬到美国,取出来依然是 8:00。强相关。底层始终把时间转换为 UTC(世界标准时间) 进行物理存储。查询时根据当前数据库连接的时区,再转换回本地时间显示。
底层存储空间8 字节4 字节
存储时间范围1000-01-01 ~ 9999-12-31(覆盖人类文明史)1970-01-01 ~ 2038-01-19 03:14:07 UTC

Y2K38 定时炸弹:TIMESTAMP 底层只有 4 个字节(32 位有符号整数),最大值就是 2038-01-19 03:14:07 UTC。超过这个时间点,存储会溢出。这就是著名的 2038 年问题,和当年 2000 年千年虫问题同根同源。

TIMESTAMP 的 4 字节上限图解:
1970-01-01 2026-05-29 2038-01-19 03:14:07
├─────────···········─────────··· ─ ─ ─ ┤
│ 当前时间 溢出! 💥
0 INT32_MAX (2147483647)

2.2 架构选型结论#

  • 全球化应用:统一使用 DATETIME,在应用层(Go 业务代码中)自己统一使用 UTC 时间戳进行转换。DATETIME 不受服务器时区影响,不会因为 DBA 改了时区配置导致数据语义变化。
  • 极其在意空间的单机场景,且明确知道业务活不过 2038 年(或确信 MySQL 版本会持续升级):可考虑 TIMESTAMP。

大多数团队的技术规范里,默认选 DATETIME。


3. Go 后端:时间字段的代码落地#

理解了数据库层面的选型,还要知道在 Go 后端代码中,这个”毫无感情”的 DATETIME 字符串是怎么被转换和接管的。这才是从理论走向实际代码落地的关键一步。

3.1 数据库层的纯文本状态#

当 Go 程序向 MySQL 发起 SELECT create_time FROM user 时,MySQL 传给 Go 程序的,其实就是一个纯文本字符串:

"2023-10-25 08:00:00"

这个字符串身上没有任何时区标记! MySQL 不知道它是北京时间还是纽约时间——因为 DATETIME 本身就是”不关心时区”的类型。

3.2 Go 驱动的 DSN 配置:赋予时区灵魂#

在 Go 开发中,连接 MySQL 通常使用 go-sql-driver/mysql 驱动(GORM 底层也是它)。真正给这个纯文本赋予时区意义的,是 DSN(Data Source Name)连接字符串。

标准 Go-MySQL DSN(时间相关部分已标出):

user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local
↑ ↑
参数1:开启时间解析 参数2:指定时区

parseTime=True#

  • 不加此参数:Go 把 MySQL 传过来的 "2023-10-25 08:00:00" 当 []byte 或 string 处理。在 Go 结构体中只能用 string 接收,无法做时间运算。
  • 加上此参数:Go 的 MySQL 驱动会自动把字符串转换成 Go 标准的 time.Time 对象。

loc=Local(或 loc=Asia%2FShanghai)#

驱动在把字符串转成 time.Time 时,必须知道这个时间属于哪个时区。loc=Local 的底层动作相当于:

// 驱动底层等价于执行了:
time.ParseInLocation("2006-01-02 15:04:05", "2023-10-25 08:00:00", time.Local)

经过这一步,原本死板的 DATETIME 字符串就变成了 Go 中带有明确时区信息的 time.Time 对象。

3.3 经典大坑:相差 8 小时的 Bug#

这是 Go 后端新人必踩的坑:

Bug 复现条件:数据库里存的是北京时间 08:00。DSN 写了 parseTime=True,但忘记写 loc 参数。

连锁反应:

  1. 驱动默认以 UTC(零时区) 解析 "08:00" → 被当成 UTC 时区的早上 8 点
  2. 把 time.Time 通过 JSON 返回给前端 / 打印时 → Go 自动加上 8 小时偏差
  3. 前端显示 → 16:00!凭空多了 8 小时。
Bug 时间线:
MySQL: "2023-10-25 08:00:00"(DATETIME,无时区)
↓ Go 驱动,loc 默认为 UTC
time.Time{ 2023-10-25 08:00:00 +0000 UTC } (被错误标记为 UTC)
↓ JSON 序列化 / 转本地时间
"2023-10-25 16:00:00 CST" → 前端显示 16:00 ❌
正确做法:
MySQL: "2023-10-25 08:00:00"
↓ Go 驱动,loc=Asia/Shanghai
time.Time{ 2023-10-25 08:00:00 +0800 CST } ✅
↓ JSON 序列化
"2023-10-25 08:00:00 CST" → 前端显示 8:00 ✅

3.4 最佳架构实践:UTC 贯穿全链路#

如果在面试中被问到”结合 Go,你们团队如何处理 MySQL 的时区转换问题”,以下是满分的架构话术:

“UTC 贯穿全链路”策略:

  1. 数据库层:统一使用 DATETIME,规定写入的时间全部是 UTC 时间
  2. 驱动层:DSN 固定配置 parseTime=True&loc=UTC,确保读出来的字符串被解析为带 UTC 标记的 time.Time
  3. 接口层:Go 把 time.Time 格式化为标准 ISO-8601 格式(如 2023-10-25T00:00:00Z)或 Unix 毫秒时间戳 吐给前端
  4. 展示层:前端拿到时间戳后,根据用户浏览器的本地时区(日本→东京时间,中国→北京时间)进行动态渲染
UTC 贯穿全链路架构图:
┌──────────┐ ┌───────────┐ ┌───────────┐ ┌──────────┐
│ MySQL │ ───→ │ Go 后端 │ ───→ │ JSON │ ───→ │ 前端 │
│DATETIME │ │ loc=UTC │ │ Unix ms │ │ 本地时区 │
│ UTC 存储 │ 字符串 │ time.Time │ 时间戳 │ 时间戳 │ 动态渲染 │
└──────────┘ └───────────┘ └───────────┘ └──────────┘
无时区语义 统一 UTC 标记 纯数字,无歧义 离用户最近

这样做的好处是彻底解耦了数据库和业务逻辑的时区依赖——数据库不关心时区(DATETIME),Go 后端统一用 UTC 作为内部表示,前端的转换发生在离用户最近的终端上。


4. 常见面试题#

Q1: 为什么大厂规范要求所有字段 NOT NULL?

从三个维度回答:

  • 语义层面:NULL 代表”未知”,与空字符串/0 等”有明确含义的无值”混淆,业务代码难以区分
  • 性能层面:NULL 导致索引比较失效(必须用 IS NULL),聚合函数自动忽略 NULL(COUNT(字段) 不计入 NULL 行),容易产生报表数据差异
  • 存储层面:InnoDB 行头部需要 NULL Bitmap 记录哪些列是 NULL,增加行结构解析成本

Q2: DATETIME 和 TIMESTAMP 有什么区别?怎么选?

维度DATETIMETIMESTAMP
时区不相关(存什么取什么)相关(底层 UTC 存储,查询时按时区转换)
存储8 字节4 字节
范围1000 ~ 9999 年1970 ~ 2038 年(Y2K38 问题)

选型:全球化应用优先选 DATETIME + 应用层 UTC 统一管理。

Q3: Go 连接 MySQL 时 parseTime 和 loc 参数的作用是什么?

  • parseTime=True:驱动自动把 DATETIME/TIMESTAMP 字符串转为 time.Time 对象
  • loc=Local:指定解析时使用的时区;不指定则默认 UTC,容易导致”差 8 小时”Bug
  • 最佳实践:parseTime=True&loc=UTC,贯穿全链路 UTC 策略

5. 总结#

表结构设计的两条核心原则:

  1. NOT NULL + DEFAULT:用明确的默认值取代 NULL,消除了语义歧义、避免了聚合函数暗坑、减少了行存储开销。本质是把不确定性从数据层转移到业务层——业务逻辑可以明确说”空字符串表示未填写”,而 SQL 层面的 NULL 什么也表达不了。

  2. DATETIME + 应用层 UTC:数据库层用 DATETIME 存时间(无时区干扰),Go 中间层通过 parseTime=True&loc=UTC 统一为 UTC 表示的 time.Time,前端负责最终时区转换。这样做到了时区职责的分层解耦——每层只做自己该做的事,不越界。

MySQL 表结构设计规范
https://shirone.mysqil.com/posts/mysql/mysql-表结构设计规范/
作者
HikariTish
发布于
2026-05-29
许可协议
CC BY-NC-SA 4.0

分享文章

生成精美分享图或复制链接,与更多人分享本文。

继续阅读

沿着主题读

基于共同的标签与分类

换条路线

从其他文章中稳定抽取

最后更新于 ,距今已过 139 天

部分内容可能已过时