☰
MySQL 8.0 数据类型选型指南:避开精度与索引的坑
2026/10/10 2:59:00 网站建设 项目流程

上周帮一个团队排查线上账单对不上的问题,最后定位到一张订单表里,价格字段用的是 FLOAT 而不是 DECIMAL。几十万行数据做聚合汇总时,后台总是平白多出几分钱。这种问题很典型,根子就出在 MySQL 8.0 的基本数据类型选型上。

其实 MySQL 8.0 的类型体系官方文档写得非常清楚,但日常开发里真正把整数、小数、字符串、日期、JSON 这些类型吃透的人真不多。大部分人还是靠 5.x 时代养成的习惯在选字段类型,等数据量上来、业务逻辑变复杂之后,一个个坑才暴露出来。这篇内容就是结合我的实践经验,把 MySQL 8.0 里的基本数据类型从头到尾过一遍,侧重点放在"为什么这样选"和"实际会踩到哪些坑"上,适合正在做版本升级、新项目建表、或者想系统梳理类型知识的后端开发。

1. 从 5.7 迁到 8.0,数据类型上有哪些老规矩失效了

MySQL 8.0 的很多东西表面看起来和 5.7 差不多,但底层的默认行为和类型处理发生过几处关键调整。你别看这些小变化不起眼,它们直接决定了老库迁移后会不会出现莫名其妙的慢查询和报错。

1.1 整数显示宽度被废弃,int(11) 的写法该扔掉了

老 MySQL 版本里,大家建表喜欢写 int(11)、tinyint(4) 这种东西,括号里的数字叫"显示宽度"。它原本的作用是配合 ZEROFILL 属性,在数值前补零,让查询结果打印出来对齐。这个功能听起来很贴心,但实际使用率极低,而且它不影响存储范围,很多人以为 int(11) 比 int(10) 装得下更大数字,这是个根深蒂固的误解。

在 MySQL 8.0 里,整数类型的显示宽度从 8.0.17 开始标记为废弃,8.0.19 之后,建表和修改列时虽然还能解析这种语法,但显示宽度属性已经不会再保存,也不再生效。也就是说你写成 int(11) 和 int(3) 完全没有区别,最终都是标准的 INT 类型,范围都是 -2147483648 到 2147483647。

这个变化影响面其实不小。很多旧迁移脚本里全是 int(11),虽然不会报错,但代码审查时会让人困惑。更需要注意的是 ZEROFILL 属性,它依赖显示宽度,同样被连坐废弃。如果你老项目里有用 ZEROFILL 做流水号补零的,趁早改手动 LPAD 或应用层处理。

从 8.0 开始建表,整数类型就老老实实写 TINYINT、SMALLINT、MEDIUMINT、INT、BIGINT,后面不要带任何括号数字。这不仅是规范问题,更关系到整个团队对类型语义的理解。

1.2 utf8mb4 成为默认字符集,字符串长度和索引上限都在变

MySQL 8.0 另一个容易忽略的变化,是默认字符集变成了 utf8mb4。5.7 时代默认 latin1,很多老库建表时显式指定 utf8mb4,而 8.0 新建库表如果不指定字符集,默认就是 utf8mb4_0900_ai_ci 排序规则。

这本身是好事,能完整存下 emoji 和生僻汉字。但它会带来一个连锁反应——同样长度的 VARCHAR(N),N 是"字符数"而不是"字节数",而 utf8mb4 下每个字符最多占 4 字节。也就是说,VARCHAR(255) 在最坏情况下需要 255×4=1020 字节存储空间,加上变长长度前缀,实际占用的行空间比 latin1 时代大得多。

InnoDB 行大小默认限制是 65535 字节,索引键最大 3072 字节(动态行格式下)。在 utf8mb4 字符集里,一个 VARCHAR 列做索引时,最大字符数不能超过 3072/4=768 个字符。这就是为什么有些老 SQL 在 5.7 里能成功创建索引,升到 8.0 后却提示"Index column size too large"。

所以迁移到 8.0 后,建表前先确认字符集,再倒推每列的实际字节开销。特别是同时建多个 VARCHAR 索引的场景,要核算总长度,不能凭感觉写长度。我见过不少团队把用户名字段一律 VARCHAR(255),其实 utf8mb4 下一个中文名 20 个字符绰绰有余,255 纯属浪费空间和内存。

2. 数值类型:把整数、小数、布尔和金额字段的底层逻辑讲透

数值类型是建表时用的最多的,也是最容易被随意对待的。大部分开发只记得"整数用 INT、小数用 FLOAT",至于范围够不够、精度准不准、存储空间多大,完全没有概念。这一部分把每种数值类型的真实行为掰开讲清楚。

2.1 整数类型范围表与自增键该用 INT 还是 BIGINT

MySQL 8.0 支持五种整数类型,区别只在存储字节和取值范围。我把它们整理成一张对比表,建议收藏当速查手册用。

类型存储字节有符号范围无符号范围
TINYINT1-128 ~ 1270 ~ 255
SMALLINT2-32768 ~ 327670 ~ 65535
MEDIUMINT3-8388608 ~ 83886070 ~ 16777215
INT4-2147483648 ~ 21474836470 ~ 4294967295
BIGINT8-2^63 ~ 2^63-10 ~ 2^64-1

注意,默认情况下 MySQL 整数类型是有符号的。TINYINT 看似只有 1 个字节,但有符号范围只有 -128 到 127。如果你确认某个字段不可能为负数(比如年龄、库存数量),应该显式加上 UNSIGNED,这样能把上限翻一倍,同样的存储空间装下更多正数。

自增主键到底用 INT 还是 BIGINT,这是我被问得最多的问题。核心判断标准只有一个:这个表预计能撑到多少行。INT 无符号上限约 42.9 亿,听起来很大,但很多日志表、流水表、消息表在三年内就能轻松突破几亿行,如果关联表还做了分库分表,全局序列增长更快。一旦自增到达上限,写入会直接报主键冲突,想改列类型还需要重建表,那种痛苦我经历过一次就再也不想经历了。

我的建议是:核心业务表,尤其是订单、用户、支付流水这类长期积累的表,直接上 BIGINT。理由不是 INT 不够用,而是 BIGINT 和 INT 在索引性能上的差距非常小,但上限近 922 亿亿,多花 4 字节换来几年的安心很划算。只有明确知道表行数可控、生命周期短的表,才用 INT。

2.2 DECIMAL、FLOAT、DOUBLE:为什么一分钱经常对不上

金额字段选型是这个话题里的重灾区。很多项目图省事用 FLOAT 或 DOUBLE 存价格,结果就是累加对不上账、四舍五入出现偏差。原因很简单:FLOAT 和 DOUBLE 是浮点数,内部用二进制近似表示十进制小数,0.1 这种看似简单的小数在二进制里是无限循环的,运算时必然产生精度误差。

MySQL 8.0 里的 DECIMAL 才是真正的定点数,它不按二进制浮点方式存储,而是用十进制数字存储。定义方式 DECIMAL(M,D),M 是总位数,D 是小数位数。比如 DECIMAL(10,2) 表示最多 8 位整数部分、2 位小数部分,用户看到的是精确的十进制数,完全没有浮点误差。

DECIMAL 的存储机制也比较特殊,不是按固定字节数存,而是每 9 位十进制数字打包到 4 个字节里。以 DECIMAL(18,2) 为例,整数部分 16 位拆成 9 位和 7 位,分别用 4 字节和 3 字节存储,小数部分 2 位用 1 字节,总共 8 字节。虽然比 INT 大,但完全可控。

金额、税率、余额、折扣率这类字段,直接选 DECIMAL,不要抱任何侥幸。FLOAT/DOUBLE 更适合科学计算场景,比如温度传感器读数、经纬度坐标、统计指标,这类数据本身来自测量,天然带有误差,不需要精确到分。

还要提一个小技巧:DECIMAL 的 D 不要随便设。很多业务把金额字段设成 DECIMAL(10,4),结果应用层每笔金额都要自己处理四位小数,汇总时看着不整齐。如果业务只需要到分,就用 DECIMAL(10,2);需要到厘,就用 DECIMAL(10,3)。精确到你真正需要的位数就够了,多余的小数位只会增加计算复杂度和存储成本。

2.3 BIT、BOOL 与布尔字段的设计误区

MySQL 里没有标准的布尔类型。我们在建表时写的 BOOLEAN 或 BOOL,本质上是 TINYINT(1) 的别名,存 0 表示假,非 0 表示真。这个设计让很多人踩过坑:你给某个字段插入了 2,查询 WHERE flag = TRUE 时能匹配到,但应用层拿到 2 就可能懵了。

如果你的业务只有真/假两种状态,建议直接使用 TINYINT(1) 并加上 NOT NULL DEFAULT 0,应用层也只写入 0 或 1。不要用 BIT(1),虽然 BIT 也能存布尔,但很多 ORM 驱动把 BIT 读取成二进制字符串,处理起来麻烦。另一条路是用 ENUM('Y','N'),后面单独说。

如果状态不止真假两种,比如订单状态有"待支付、已支付、已取消、已退款",就不要用 TINYINT 硬编码 0/1/2/3 然后靠注释解释。虽然这样省空间,但每次加一个状态都要改代码和文档。更好的方案是用 VARCHAR 存状态英文标识,或者用 TINYINT 存储并配合一张状态字典表,将状态变化管理起来。这个选择没有绝对答案,核心是"代码可读性"和"存储效率"之间的平衡。

3. 字符串类型:CHAR、VARCHAR、TEXT、ENUM、SET 的真实差异与坑

字符串类型是 MySQL 里最容易被误解的一族。开发者要么无脑 VARCHAR(255) 走天下,要么把大段文本塞进 TEXT 就不管了。这部分的差距,在数据量变大后会直接转换为磁盘空间膨胀和索引失效。

3.1 CHAR 和 VARCHAR:定长和变长的账要算清楚

CHAR(N) 是定长字符串,存储时不足 N 个字符会用空格补足,读出来时再自动去掉尾部空格。VARCHAR(N) 是变长字符串,只存储实际字符数,额外用 1 到 2 个字节记录长度。

这个区别带来的影响很实际。CHAR 因为定长,行的物理布局更固定,某些场景下扫描会更快,但空间浪费也肉眼可见。如果 N 设置过大,比如 CHAR(255) 且每行只存 5 个字符,utf8mb4 下每行白白浪费约 1000 字节,这种表注定膨胀成怪物。

VARCHAR 的设计目标就是省空间,但要注意类型定义里的 N 是字符数不是字节数。也就是说 VARCHAR(50) 在 utf8mb4 下最多可能占用 200 字节(50×4),而不是 50 字节。这个细节在估算索引大小和内存排序成本时很关键。

日常经验来看:固定长度的编码字段(身份证号、手机号、订单号)用 CHAR 没问题,但如果实际长度波动较大,比如昵称、地址、标题,统一用 VARCHAR 更稳。无论哪种,都要克制 N 的数值。VARCHAR(50) 就能解决的别扩到 255,因为 MySQL 内存临时表会按照 VARCHAR 定义长度去分配排序缓冲区,255 和 50 在一百万行排序时的内存差距是数倍的。

3.2 TEXT、BLOB 与隐形存储开销

TEXT 族和 BLOB 族在类型上分别对应四种尺寸:TINYTEXT、TEXT、MEDIUMTEXT、LONGTEXT,以及 BLOB 系列。TEXT 存的是字符,BLOB 存的是二进制字节,两者物理存储方式其实类似,就是变长大对象。

很多人以为 TEXT 长得像 VARCHAR,只是容量更大,于是在不需要索引的备注字段上放心用 TEXT。快醒醒,TEXT 有几个特性必须知道:

  • TEXT 不能设置默认值,除非你配合 8.0 的表达式默认值使用特殊写法。在 5.7 时代,给 TEXT 加 DEFAULT 字符串直接语法报错。
  • TEXT 建立索引必须指定前缀长度,比如INDEX idx_content (content(50)),因为若不指定前缀,整列索引长度会超出索引键上限。
  • InnoDB 存储 TEXT/BLOB 大字段时,如果数据长度超过阈值,会把数据主体放到溢出页,数据行里只保留前几十字节的局部前缀和指针。这导致查询大字段时会额外读页,这也是为什么"我只查询几条记录却那么慢"的常见原因。

正确思路是:大段内容确实存在,该用 TEXT 就用 TEXT,但查询时尽量避免 SELECT *,只取出需要的字段,不要把大字段拖进结果集。能在应用层拆分的(比如文章正文单独一张表),就不要和主列表混在一起。

3.3 ENUM 和 SET:节省空间的背后是有代价的

ENUM 是单选枚举,底层存储 1 到 2 个字节的整数,映射到定义时指定的字符串列表。SET 是多选集合,底层用位图标识,每个值对应一个位,最多支持 64 个值。

先夸它们:ENUM 在"状态值严格有限"的场景确实省空间,比如性别、订单状态,一个 TINYINT 能做的事 ENUM 也能做,而且查出来的数据直接是可读字符串,不用 JOIN 字典表。SET 适合"一个字段同时保存多个标签"的场景,比如"兴趣爱好"一列可以同时存"足球、篮球、游泳",底层位运算效率很高。

再骂它们:最头疼的是修改枚举列表。ENUM('pending','paid','cancelled') 要新增一个 'refunded' 值,必须执行 ALTER TABLE 修改列定义,这在大表上是高成本操作,且期间会锁表。另外,ENUM 的排序是按照定义顺序而不是字典顺序,如果你按枚举字段排序,得到的结果可能和预期完全不一致。

我的结论是:如果枚举列表已经稳定,且扩展可能性极低,ENUM 可以用。涉及状态机演进、后续大概率加新状态的表,优先用 VARCHAR 或 TINYINT 加字典映射,把扩展成本留给应用层而不是数据库重建。SET 的使用场景更窄,大多数业务里"多选标签"用单独的关联表设计会更灵活,SET 更适合确定分类体系永不变化的老系统。

3.4 字符串比较规则:utf8mb4 字符集下的 collation

字符串类型除了类型本身,还要看排序规则(collation)。MySQL 8.0 默认的 utf8mb4_0900_ai_ci 是基于 Unicode 9.0 的新版本排序规则,ai 表示不区分重音,ci 表示不区分大小写。

这个默认规则对大多数业务没问题,但有几个细节会在查重和去重时暴露差异:

  • 不区分大小写意味着 'abc' 和 'ABC' 在 WHERE name = 'abc' 时都会匹配到。
  • 不区分重音意味着 'café' 和 'cafe' 会被当成同一个值,唯一索引可以直接挡住。
  • 如果你需要精确按字节比较,得用 utf8mb4_bin,它把字符按二进制码点比较,最严格。

在建表时指定COLLATE utf8mb4_bin是个常见但容易踩的场景:用户表登录账号需要区分大小写时,尤其建议使用 bin 排序规则。也别忘了索引和排序规则要匹配,JOIN 两张字符集不同的表时会出现"Illegal mix of collations"错误,通常在 ON 条件里加上 COLLATE 强制转换才能解决,性能还因此打折。

4. 日期和时间类型:按业务场景选出最匹配的那一个

日期时间类型看似简单,实际是选型错误里最隐蔽的一类。因为"存进去的是字符串,查出来也是字符串",看起来都差不多,但底层存储、时区处理和索引性能完全不同。

4.1 五种日期类型的存储与范围速览

MySQL 8.0 支持五种日期时间类型:DATE、TIME、DATETIME、TIMESTAMP、YEAR。我把关键参数整理成表格。

类型存储字节范围典型用途
DATE31000-01-01 ~ 9999-12-31生日、日期统计
TIME3-838:59:59 ~ 838:59:59持续时间、作息区间
DATETIME81000-01-01 00:00:00 ~ 9999-12-31 23:59:59业务时间点
TIMESTAMP41970-01-01 00:00:01 UTC ~ 2038-01-19 03:14:07 UTC自动时间戳、UTC 业务
YEAR11901 ~ 2155年份统计

TIME 的范围超出 24 小时,可以存储"持续时长"这种概念,比如视频播放时长 150:30:00,这是很多人不知道的。YEAR 类型只有 1 字节,但能覆盖 1901 到 2155 年,做"今年是哪一年"这种标记很省空间。

4.2 DATETIME 和 TIMESTAMP:时区敏感决定了业务风险

DATETIME 和 TIMESTAMP 是实际业务里用的最多的两个类型,也是最容易选错的。两者最大的区别在于时区感知:TIMESTAMP 存储时把当前会话时区的时间转换成 UTC 存储,读取时再转回会话时区;DATETIME 则完全不管时区,存什么就是什么。

举个例子:北京时间 2025-06-01 12:00:00,如果用 TIMESTAMP 写入,数据库内部存的是 2025-06-01 04:00:00 UTC。当会话时区设成纽约时间,读出来就变成 2025-06-01 00:00:00。这对跨国业务是合理的,但对本地业务是灾难——团队里有人把会话时区改了,历史时间全部错乱。

我的建议是:绝大多数国内单一地区业务,直接用 DATETIME 加"统一使用东八区字符串时间"的约定,不要指望数据库帮你换算时区,把时区转换放在应用层做。TIMESTAMP 更适合系统自带的时间戳,比如记录的创建时间,使用 DEFAULT CURRENT_TIMESTAMP 和 ON UPDATE CURRENT_TIMESTAMP,自动维护且占 4 字节更小。

还有个绕不开的问题:2038 年危机。TIMESTAMP 用 4 字节存储,范围只到 2038 年,一旦业务系统生命周期可能超过 2038 年,凡是存"未来时间"的字段都别用 TIMESTAMP。虽然 4 字节能省些空间,但为了省 4 字节赌一个 13 年后的炸弹,不值得。

4.3 8.0 的默认值表达式:告别"0000-00-00"时代的写法

早期 MySQL 版本对日期字段的默认值支持很差,导致很多老表里出现 '0000-00-00 00:00:00' 这种幽灵值,查询时还要到处在 WHERE 条件里排除它。

MySQL 8.0 对默认值做了大幅改进,从 8.0.13 开始,默认值可以是一个括号包裹的表达式。DATETIME 和 TIMESTAMP 可以直接写 DEFAULT (CURRENT_TIMESTAMP),也可以写 DEFAULT (NOW())。对于不需要当前时间的字段,可以直接DEFAULT (DATE('2024-01-01'))这类任意表达式。

这里有一个实用写法值得记住:如果你的业务需要"创建时间默认当前时间,更新时间自动刷新",可以在单列上写两个属性:

create_time DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP), update_time DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP) ON UPDATE CURRENT_TIMESTAMP

注意 ON UPDATE CURRENT_TIMESTAMP 只在整行被更新且没有显式指定该列值的时候生效。如果你用了REPLACE INTO或显式给 update_time 赋值,就不会自动刷,这是不少人排查"更新时间为什么不变"时踩到的坑。

5. JSON 类型不是普通的"大字段":8.0 里值得认真对待的一等公民

MySQL 8.0 对 JSON 的支持从 5.7 开始引入,到 8.0 已经相当成熟。但很多团队还是只把 JSON 当成一个"能存 JSON 的 TEXT",完全没发挥它的能力,反而造成查询性能黑洞。

5.1 JSON 字段在 8.0 底层是怎么存的

JSON 字段在存储层面并不是简单的文本字符串。MySQL 会解析 JSON 字符串,转成二进制格式,自动去除重复键、规范化空格,并在写入时校验合法性。写入非法 JSON 时会直接报错,这是普通 TEXT 永远做不到的。

底层的二进制格式还支持对 JSON 文档做高效的路径提取,而不是每次查询都全量解析字符串。JSON 字段内部通过 "键值对" 的二进制树形结构组织,当使用 JSON_EXTRACT 等函数时,不需要把整个 JSON 重新解析一遍,性能比从 TEXT 里 LIKE 或正则匹配好太多。

JSON 字段和 BLOB/TEXT 一样,在 8.0 里同样不能直接设置默认值,但可以通过表达式默认值DEFAULT (JSON_OBJECT())绕过这个限制。这一点容易踩坑,建表时如果直接DEFAULT '{}'会报错。

5.2 常用 JSON 函数的搭配用法

在实际使用中,我常用的 JSON 函数就这么几个,掌握它们基本能覆盖 90% 的业务需求:

  • JSON_EXTRACT(doc, '$.key'):提取 JSON 路径对应的值,返回 JSON 类型。
  • ->操作符:doc->'$.key',等价于 JSON_EXTRACT。
  • ->>操作符:doc->>'$.key',提取后转为字符串,去掉 JSON 引号,最常用。
  • JSON_CONTAINS(doc, '"value"', '$.key'):判断数组或对象中是否包含某个值。
  • JSON_ARRAY_APPEND、JSON_SET:在 SQL 里直接修改 JSON 内容。
  • JSON_TABLE:把 JSON 数组展开成关系表,8.0 里做 JSON 和关系数据的桥接很好用。

举个例子,如果配置文件是 JSON 格式,要在里面查某个用户的手机号:

SELECT user_id, meta->>'$.phone' AS phone, meta->>'$.address.city' AS city FROM user_profile WHERE meta->>'$.phone' = '13800000000';

这里->>是最顺手的选择,因为它在大多数场景里返回的就是我们想要的裸字符串。不过要注意,->>返回的字符串排序规则可能有坑,在 JOIN 或 GROUP BY 场景里如果凑巧和另一列类型/排序规则不一致,会触发隐式转换问题,后面会单独说。

5.3 用生成列给 JSON 字段建索引:性能实测

JSON 字段本身无法直接建索引,官方给出的方案是"生成列加索引"。简单说,就是从 JSON 里提取某个字段生成一列普通列,然后在这列上建索引。这样查询条件能走普通 B-Tree 索引,而不是全表扫 JSON。

ALTER TABLE user_profile ADD COLUMN phone VARCHAR(20) GENERATED ALWAYS AS (meta->>'$.phone') VIRTUAL, ADD INDEX idx_phone (phone);

VIRTUAL 生成列不占物理存储,索引单独存放,每次写入时计算一次,查询时直接命中索引,收益非常大。我当时用一个十万行的模拟表测过,直接WHERE meta->>'$.phone' = 'some-phone'全表扫描耗时 80 毫秒,建生成列索引强制走索引后耗时降到 8 毫秒左右,加载效果非常直观。

唯一要留意的:生成列表达式和查询条件的表达式必须完全一致,否则 MySQL 不会使用这个索引。比如生成列用的meta->>'$.phone',查询里写JSON_UNQUOTE(JSON_EXTRACT(meta, '$.phone')),虽然语义一样,但优化器不一定识别,所以建议统一使用同一种写法。

6. 用一张会员表复盘:类型选型顺序、隐式转换与三个真实教训

前面是分类解析,这里我用一个完整的会员表设计案例,把选型逻辑串起来。你会发现,实际建表时不是"每个类型单独选最优解",而是要综合考虑存储、索引、扩展性和团队协作。

6.1 会员表建表设计:每个字段为什么这么选

假设要设计一张会员主表,包含会员ID、手机号、昵称、性别、生日、状态、余额、标签、注册时间和最后登录时间。下面是字段选型,附带选择理由。

CREATE TABLE `member` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '会员ID', `phone` CHAR(11) NOT NULL COMMENT '手机号', `nickname` VARCHAR(30) NOT NULL COMMENT '昵称', `gender` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '性别 0未知 1男 2女', `birthday` DATE DEFAULT NULL COMMENT '生日', `status` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '状态 1正常 2冻结 3注销', `balance_cents` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '余额,单位分', `tags` JSON DEFAULT NULL COMMENT '标签,如["vip", "老客户"]', `create_time` DATETIME NOT NULL DEFAULT (CURRENT_TIMESTAMP) COMMENT '注册时间', `last_login_at` DATETIME DEFAULT NULL COMMENT '最后登录时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_phone` (`phone`), KEY `idx_status_create` (`status`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='会员主表';

逐字段说理由:

  • id用 BIGINT UNSIGNED AUTO_INCREMENT,理由在 2.1 里说过了,主键一旦溢出的代价远超多花的 4 字节。
  • phone用 CHAR(11) 是因为中国手机号固定 11 位,定长存储能省下 VARCHAR 的长度前缀 1 到 2 字节,同时强制约束长度。虽然有人喜欢 VARCHAR 应对未来号码变长,但 11 位已经非常稳定了。
  • nickname用 VARCHAR(30),不是 255。昵称一般 5 到 10 个字符就能覆盖绝大多数情况,30 字符已经非常宽松,还能限制前端恶意提交超长文本。
  • gender用 TINYINT UNSIGNED 加注释,不用 ENUM,因为性别这个字段扩展性虽然极低,但团队里不同服务可能通过字典表映射,用数字更通用。
  • birthday用 DATE,因为它只需要年月日,不需要时分秒,3 字节足够。存 DATETIME 等于白浪费 5 字节,在千万级表里就是几千万行乘 5 字节的额外空间。
  • balance_cents用 BIGINT UNSIGNED 存储"分",这样能把金额全部转成整数运算,彻底避开 FLOAT/DECIMAL 的精度和复杂性问题。余额最大可到 1844 万亿分,日常场景绝对够用。如果业务有更复杂的小数位需求,改用 DECIMAL 也不迟。
  • tags用 JSON,虽然设计上多标签用关联表更正规,但如果标签只做展示、不做复杂筛选,直接 JSON 存数组最省心,配合 JSON_CONTAINS 还能做简单检索。
  • create_time直接用 DATETIME 加表达式默认值,不依赖应用层传时间。
  • last_login_at用 DATETIME 且可空,由应用层在登录时显式更新,不设置 ON UPDATE 是因为它不等于"行更新时自动改"。
  • 复合索引idx_status_create是给"按状态再按注册时间排序"这种典型后台查询准备的。状态字段区分度很低,单独建索引收益很小,加上 create_time 后能覆盖更多场景。

6.2 别踩隐式转换的坑:类型混用的隐患

建表选型难,更难的是查询条件里的类型混用。MySQL 在比较不同类型时会发生隐式转换,最常见也最危险的是"字符串列和数字比较"。

SELECT * FROM member WHERE phone = 13800000000;

phone 是 CHAR(11),右边是数字。MySQL 会把字符串列转成数字再比较,一旦 phone 里有非数字字符,或者索引列被函数转换,索引就会失效,全表扫描就来了。

另一个常见隐式转换是WHERE balance_cents = '1000',字符串常量会被转成数字,一般情况下没问题,但可能让优化器选择错误执行计划。为了避免所有这些麻烦,查询参数来自应用层时,绑定参数类型必须和列类型对齐。尤其注意从 HTTP 接口拿到的参数很多是字符串,ORM 映射时一定要明确指定数值类型。

还有 JOIN 时的现象:A表.id是 BIGINT,B表.member_id是 VARCHAR,JOIN 条件里会发生全表索引失效,两张百万表 JOIN 会直接卡到你怀疑人生。这种问题排查起来特别隐蔽,用EXPLAIN看执行计划时,如果看到类型一栏出现string或者ALL全表扫描,优先考虑是不是类型不一致了。

6.3 三个真实事故的复盘

第一个事故:金额字段用 FLOAT 导致对不上账。某项目用 FLOAT 存价格,单笔金额看起来正确,但汇总时浮点误差累积,月度对账多了几分钱。整个团队排查了一天,最后把字段改成 DECIMAL(10,2) 后报表才平。现在我对"金额用浮点"的容忍度为零。

第二个事故:时间字段全用 TIMESTAMP,遇到海外用户后全面崩盘。项目扩展海外版后,用户注册时间有 200 多个小时的时间差,查"今天有多少人注册"时数据怎么都不对。最后定位到 TIMESTAMP 的时区转换机制,统一改为 DATETIME 和应用层时区约定,才彻底解决。

第三个事故:状态字段用 ENUM,业务要加状态值,大表 ALTER 等待了三十多分钟。虽然在低峰执行,但期间 DDL 阻塞了 DML,线上体验受到波及。教训是:状态类字段如果业务迭代快,一开始就别用 ENUM,用 TINYINT 加字典表,状态扩展只在字典表加一行,完全不用动主表结构。

6.4 拿到任何 MySQL 库,3 条命令快速摸清类型现状

最后送你三个常用的数据字典查询,接手旧库或者自查表结构时,比一张张 SHOW CREATE TABLE 高效得多。

-- 查看某张表的字段类型、字符集、是否可空 SELECT COLUMN_NAME, COLUMN_TYPE, CHARACTER_SET_NAME, COLLATION_NAME, IS_NULLABLE, COLUMN_DEFAULT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'your_database' AND TABLE_NAME = 'member' ORDER BY ORDINAL_POSITION;
-- 统计库里所有表的最大长度字段,找出可疑大字段 SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'your_database' AND DATA_TYPE IN ('text', 'mediumtext', 'longtext', 'blob', 'json') ORDER BY CHARACTER_MAXIMUM_LENGTH DESC;
-- 找出所有没有显式主键的表,这类表往往连类型设计都没认真做过 SELECT TABLE_NAME FROM information_schema.TABLES t WHERE TABLE_SCHEMA = 'your_database' AND ENGINE = 'InnoDB' AND NOT EXISTS ( SELECT 1 FROM information_schema.TABLE_CONSTRAINTS c WHERE c.TABLE_SCHEMA = t.TABLE_SCHEMA AND c.TABLE_NAME = t.TABLE_NAME AND c.CONSTRAINT_TYPE = 'PRIMARY KEY' );

这类查询能帮你在一分钟内定位到建表规范最差的表,然后优先去补结构和数据类型的债。

写到这里,我其实没有额外要总结的。数据类型选型这件事,从来不是"哪个类型更好"的问题,而是"你的业务需要什么约束和特性"的问题。与其等字段出了偏差再做数据清洗,不如建表时多花十分钟把每列的定义、注释、长度、排序规则都过一遍,后期能给你省掉的麻烦远超过这点时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询