1. 先想清楚再动手:建表前的关键决策
我最早接触MySQL的时候,跟大多数人一样,拿到需求就敲CREATE TABLE,字段名照着需求文档抄一遍,类型全都用VARCHAR(255),主键加个id就完事。直到后来在一个实际项目里,用户表数据量上了千万级,查询慢得让人怀疑人生,我才开始认真琢磨"建表"这件事——它真不是写几行SQL那么简单。
表的基本操作,说到底就是四件事:建表、看表、改表、删表。但每件事背后都有一堆"当时不知道、后来吃了亏"的细节。这篇文章我就从实战角度把这些细节掰开揉碎讲清楚,适合刚学MySQL的新手照着操作,也适合写过一段时间SQL但从来没思考过表结构设计的人查漏补缺。我不会讲太多高深的理论,就讲你真正会用到的那些操作,以及操作背后你该懂的道理。
1.1 字符集和排序规则:第一个必踩的坑
打开MySQL随便建一张表,你不指定任何选项,它就会用MySQL实例默认的配置。但问题恰恰出在这里——很多开发环境的MySQL是历史原因安装的,默认字符集可能是latin1,也可能是utf8,还有可能是utf8mb4。如果你不管,存中文没问题,存emoji表情就等着报错吧。
这里必须搞清楚一个概念:utf8在MySQL里其实是个历史遗留问题。MySQL的utf8最多只支持3个字节,而真正的Unicode字符集需要4个字节才能完整覆盖,尤其表情符号(emoji)这种字符,在utf8下面根本存不进去。所以MySQL后来推出了utf8mb4,这才是真正完整的UTF-8实现。
我的建议是,不管你是MySQL 5.7还是8.0,建表时都明确指定字符集,别依赖默认值:
CREATE TABLE `user` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;COLLATE是排序规则,utf8mb4_unicode_ci和utf8mb4_general_ci是两种常见的方案。unicode_ci的排序更符合Unicode标准,general_ci性能略有优势但排序规则相对粗糙。从MySQL 8.0开始,utf8mb4_0900_ai_ci成了默认规则,它基于Unicode 9.0,比前两者都更精确。简单说:8.0用默认的就行,5.7就选utf8mb4_unicode_ci,不会有毛病。
提示:改字符集这件事,越早做代价越小。一张表的字符集是可以修改的,但如果你表里已经有一堆历史数据,修改后还要重新校验数据合法性,几条SQL能搞定的事可能变成一次运维事故。我见过因为表字符集不一致导致JOIN查询时索引失效的问题,虽然不是直接原因,但排查起来极其痛苦。
1.2 字段类型:别什么都是VARCHAR
新手建表最常见的问题就是字段类型一刀切,全部用VARCHAR。虽然大多数场景下MySQL能正常工作,但性能和数据准确性上会埋雷。我把常见的类型按使用场景分个类,你可以直接对照着选。
整数类型,记住一句话:够用就行。TINYINT(1字节,范围0-255或-128到127)、SMALLINT(2字节)、MEDIUMINT(3字节)、INT(4字节)、BIGINT(8字节)。统计一个表的用户年龄,TINYINT UNSIGNED就够了,非要用BIGINT,逻辑上没错,但每条记录白白多占7个字节,千万级的数据表就是70MB的差距。而且MySQL做索引的时候,更窄的字段意味着一个页能装载更多索引项,查询时磁盘IO更少,速度自然更快。
浮点数和小数则是很多人搞混的重灾区。FLOAT和DOUBLE是浮点数,有精度损失,存钱绝对不能用它们,否则账算不平。要存金额,必须用定点数DECIMAL。比如价格:
price DECIMAL(10, 2) NOT NULL DEFAULT 0.00DECIMAL(10,2)表示总共10位数字,小数占2位,也就是说整数部分最多8位,最大能表示99999999.99。这个精度在绝大多数业务里都够用了。
字符串类型,CHAR和VARCHAR的区别是很多人问了八百遍还是搞不清的点。CHAR是定长的,你定义CHAR(10),存"abc"它也占10个字符的空间,剩下的用空格填充(取出来时MySQL会自动去掉末尾空格)。VARCHAR是变长的,它额外需要1到2个字节记录长度信息。所以:长度短、长度固定的字段用CHAR,比如手机号(CHAR(11))、身份证号、MD5后的哈希值;长度不确定、可能很长的用VARCHAR。至于TEXT类型,能不用就不用,MySQL的TEXT字段不能有默认值,而且会干扰索引设计,大段文本更应该考虑别放在主表里。
日期时间类型,DATETIME和TIMESTAMP就像一对双胞胎,但性格完全不一样。DATETIME存的是绝对时间,范围是1000年到9999年,不依赖时区;TIMESTAMP存的是从1970年1月1日到现在的秒数,范围只到2038年,而且它显示时受MySQL会话时区影响。简单说:如果你要记录"北京时间2025年1月1日"这种业务时间,用DATETIME;如果你要记录"用户操作了这个系统在哪个时刻",用TIMESTAMP。不过说实话,对大多数应用来说,两者差别没那么致命,别用错就行了。
1.3 主键:自增就是最优解吗
主键是表的灵魂,它决定了数据在InnoDB存储引擎里物理存储的聚类结构。这句话听着玄乎,其实就是一个道理:InnoDB的聚簇索引就是主键本身,数据行按主键顺序物理排列。这意味着主键的选择直接影响写入性能和查询性能。
最省心的方案是自增整数主键:
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', PRIMARY KEY (`id`)写入时MySQL会在最大值基础上+1,新数据总是插到索引树的最后面,不会产生页分裂,效率最高。但自增主键有个问题:数据一旦删除,自增ID不会回退,所以在高并发写入场景下,ID会被"浪费"很多,这也正常。另外,如果你需要合并多个库的数据,自增ID会撞车,这时候就要考虑分布式ID方案,但那是进阶话题,这里不展开。
很多同学爱用业务字段当主键,比如username、order_no,这个我强烈不建议。业务字段是可能有变化的,比如用户名允许修改,那你改主键就是改聚簇索引,要移动整行数据,代价极大。更关键的是,主键如果是VARCHAR,索引树会更大,因为字符串比较比整数慢得多。所以我的习惯是:永远单独设一个无业务含义的整数主键,业务上需要唯一的字段(用户名、订单号),用唯一索引来约束,不一定要当主键。
还有一个容易忽略的细节:INT和BIGINT的选择。自增ID用INT UNSIGNED,最大值能到42亿多,大部分业务够用。但如果你做的是用户系统、订单系统这种长期高增长的业务,一个表几十亿行不是梦,建议直接BIGINT。一张表从INT改成BIGINT,ALTER TABLE在千万级数据量上会非常痛苦,所以宁可起步就选大一点的类型。
2. ALTER TABLE实战:改表结构的正确姿势
建表只是开始,业务迭代才是永恒的。今天加个字段,明天改个长度,后天删掉一个废弃字段。ALTER TABLE是每个MySQL开发者都要频繁面对的命令,也是线上事故的高发地带。我在工位上就处理过同事在生产库执行ALTER TABLE ADD COLUMN,导致主库锁了整整二十分钟的事故,那天晚上全公司都在等我们恢复服务。
2.1 理解ALTER的完整语法
ALTER TABLE看起来就是"修改表结构"一句话的事,但它涵盖的操作比想象中多得多。完整的能力包括:
- 添加字段:
ADD COLUMN - 删除字段:
DROP COLUMN - 修改字段定义:
MODIFY COLUMN - 修改字段名和定义:
CHANGE COLUMN - 修改表名:
RENAME TO - 修改表选项:字符集、存储引擎、自增值
- 添加约束和索引:
ADD INDEX、ADD PRIMARY KEY、ADD FOREIGN KEY - 删除约束和索引:
DROP INDEX、DROP PRIMARY KEY
一条ALTER TABLE只能做一种操作?不是的,你可以用逗号分隔多个子句,在一次操作里完成多项修改:
ALTER TABLE `user` ADD COLUMN `age` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '年龄' AFTER `username`, MODIFY COLUMN `username` VARCHAR(64) NOT NULL COMMENT '用户名', ADD INDEX `idx_age` (`age`);这样写的好处显而易见:一条SQL完成,事务里只锁一次表,执行时间更短。如果你写三条ALTER TABLE,每条都会做一次全表扫描和锁表,在千万级表上等于把时间翻了四倍。
2.2 增删改字段的完整示例
我用一个具体的例子走一遍全流程。假设现在有张user表,需求是:
- 新增
phone字段,放在username后面 - 把
remark字段从VARCHAR(255)改为TEXT - 把
nickname改名成display_name - 删除过时的
old_remark字段
对应SQL:
ALTER TABLE `user` ADD COLUMN `phone` VARCHAR(20) NOT NULL DEFAULT '' COMMENT '手机号' AFTER `username`, MODIFY COLUMN `remark` TEXT COMMENT '备注', CHANGE COLUMN `nickname` `display_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '昵称', DROP COLUMN `old_remark`;几个容易踩坑的细节:
AFTER关键字控制新字段位置,MySQL不支持"字段插到最前面"之外的调整。如果你想把字段放到第一位,用FIRST关键字:ADD COLUMN id INT FIRST。但说实话,表字段顺序对业务没影响,别为了美观折腾生产表。MODIFY和CHANGE的区别:CHANGE可以改字段名,语法是CHANGE 旧字段名 新字段名 类型定义,注意旧字段名和新字段名之间不是逗号,容易写错。如果只是改类型不改名,用MODIFY更简洁。MODIFY COLUMN必须写上完整的字段定义,包括类型、默认值、是否非空、注释。很多人只写了类型,忘了默认值,结果字段默认值被重置了,线上数据写入开始报错,这种问题定位起来非常费劲。
还有一点要注意:ADD COLUMN添加字段时,如果表已经有数据,建议给新字段设置一个默认值,否则MySQL会对已有行填充一个隐式默认值,这个过程中如果表很大,会触发长时间的元数据锁操作,影响读写。
2.3 锁表问题:为什么改个字段会拖垮整个库
这里必须聊一下MySQL执行ALTER TABLE时到底发生了什么。在MySQL 5.6之前,绝大多数ALTER TABLE操作是通过"创建新表→拷贝数据→删除旧表→重命名"三步来完成的,这个过程全程锁表,期间任何读写都只能排队等待。MySQL 5.6推出了Online DDL,一部分操作可以在不阻塞DML的情况下执行,但并不是所有操作都支持。
这个问题的本质是:MySQL在执行某些DDL时需要生成一个COPY或INPLACE的算法。INSTANT(8.0开始支持)最快,只修改元数据,秒级完成;INPLACE不需要拷贝整表,但期间可能锁部分操作;COPY最慢,需要完整拷贝数据。比如:
ADD COLUMN:8.0.x用INSTANT算法,但如果指定了AFTER位置,可能退化为INPLACE,因为数据行需要挪位置- 修改字段类型(比如
INT改BIGINT):通常需要INPLACE或COPY VARCHAR长度从50改成60:如果不超过65535边界,可能INSTANT;改到很长的TEXT,则往往需要COPY
所以,生产环境下改表结构,你得先问自己三件事:
- 表的数据量有多大?数据量越大,
COPY的代价越高 - 这个表业务高峰期是什么时候?能不能在凌晨窗口操作
- 能不能用工具平滑执行?比如
pt-online-schema-change或者gh-ost,这两个工具的思路是把DDL操作转化成对临时表的增量同步,不锁业务表
如果你运维的是几十万行以内的小表,随便改,问题不大;到了几千万行,我强烈建议用pt-osc这类工具,或者先在从库上执行,确认无误后再切换流量。这一条可以直接决定你是顺利完成上线还是被拉去开复盘会。
2.4 自增列的高级操作
改表结构里有一个需求很频繁:重置自增ID的起始值。场景通常是某张表的数据被删光了,但自增列已经涨到了十万级,你希望从1重新开始。
ALTER TABLE `user` AUTO_INCREMENT = 1;但这里有个隐藏规则:MySQL只会把自增值调整到"当前最大ID+1"和指定值之间的较大值。也就是说,如果你的表里还有ID为500的数据,你执行AUTO_INCREMENT = 1,下一次插入的ID还是501,不会有变化。要彻底重置,你得先把表清空(TRUNCATE)再设置,或者直接TRUNCATE,因为TRUNCATE本身就会重置自增值。
另外注意,自增值不是以"当前值"为准,而是以"当前最大值"为准。删除几条最大ID的数据,自增并不会回退。这一点我之前专门写过排查,坑太多了。
3. 查看表的三个层次:从HELLO到深挖元数据
建完表、改完表,你总要回过头去看看表结构对不对。尤其是在接手别人项目的时候,第一件事就是搞清楚库里有哪几张表、每张表长什么样。我一般分三个层次来"看表":概览、明细、元数据。
3.1 概览表清单和基础信息
最基础的命令:
SHOW TABLES;这条SQL会把当前数据库里所有表名列出来。如果你想看别的库,加FROM 库名:
SHOW TABLES FROM `my_db`;默认情况下它会把视图也列出来,如果你只想看表:SHOW FULL TABLES会多显示一张表的类型(BASE TABLE或VIEW)。
光有表名还不够,我想知道每张表大概多大、行数多少,这时候用SHOW TABLE STATUS:
SHOW TABLE STATUS LIKE 'user'\G它会返回一大堆字段,其中比较有用的包括Engine(存储引擎)、Rows(估算行数)、Avg_row_length(平均行长度)、Data_length(数据占用字节)、Create_time(创建时间)、Collation(排序规则)。注意:InnoDB的Rows是估算值,不是精确值,千万别拿它当统计数据去对账,差得很远。
3.2 用DESC看字段明细
要看具体字段,DESC是最高频的命令:
DESC `user`;输出结果包含字段名、类型、是否为空、键类型、默认值、额外信息(比如auto_increment)。这个命令我已经按了无数遍了,属于肌肉记忆。它还有一个等价写法:DESCRIBE table和SHOW COLUMNS FROM table,效果一样。
如果你只想看某一个字段的定义:
SHOW COLUMNS FROM `user` LIKE 'username';3.3 重建库表结构的终极武器:SHOW CREATE TABLE
DESC能看字段,但是它不会告诉你索引的具体定义,不会告诉你分区信息,更不会告诉你建表时的完整选项。在需要100%复刻一张表结构的时候,最靠谱的是:
SHOW CREATE TABLE `user`\G返回结果里有一条完整的CREATE TABLE语句,拿过去就能直接执行,重建一张一模一样的表。我常用的场景包括:
- 在测试环境复制生产环境表结构
- 排查主从同步时表结构是否一致
- 确认索引和约束是否如预期生效
这条SQL的输出有时候会比我写建表语句时多几行,比如ENGINE=InnoDB AUTO_INCREMENT=xxx DEFAULT CHARSET=utf8mb4,这些是MySQL自动附加的元数据,不用慌。
3.4 通过information_schema做进阶查询
当你管理的表数量多了,人肉SHOW TABLES就不现实了。这时候要借助MySQL自带的元数据库information_schema:
SELECT table_name, table_rows, data_length, index_length FROM information_schema.tables WHERE table_schema = 'my_db' ORDER BY data_length DESC;一句话把所有表按数据量排序,一眼看出哪张表是"大胖子"。另外,你还可以查某张表的所有索引信息:
SELECT index_name, column_name, seq_in_index, non_unique FROM information_schema.statistics WHERE table_schema = 'my_db' AND table_name = 'user';这一套查下来,你基本就把一张表的底裤看透了。
4. 删表和清数据的极限操作:一条SQL引发的数据事故
删除操作是MySQL里最危险的操作之一,跟它打交道必须保持敬畏。很多新手分不清DROP、TRUNCATE、DELETE三者的区别:能删表为什么不直接DELETE?TRUNCATE和DELETE到底该用哪个?我来从原理讲清楚,顺便聊聊事务和闪回的边界。
4.1 DROP、TRUNCATE、DELETE三兄弟
先看一张对比表:
| 操作 | 作用范围 | 是否记录日志 | 能否回滚 | 自增值影响 | 速度 |
|---|---|---|---|---|---|
DROP TABLE | 删除整张表(结构和数据) | 是 | 否 | 表都没了 | 极快 |
TRUNCATE TABLE | 清空表数据,保留结构 | 否 | 否 | 重置为初始值 | 很快 |
DELETE FROM | 按条件删除行 | 是 | 是(事务内) | 不回退 | 较慢 |
很多人问:为什么TRUNCATE更快?因为它本质上是把表"重新初始化"了,逻辑就是把原来的表结构留下来,数据文件砍掉重建,不像DELETE那样逐行删、逐行写binlog。DELETE每删一行都要写binlog,10万行就是10万条日志,自然慢。
但快是要付出代价的。TRUNCATE在事务里执行了也没法回滚,是真的"覆水难收"。任何时候做删除操作前,先备份。这句话我在公司里的新人培训上说了无数遍,但每年总有新人不信邪,线上一条DELETE FROM user WHERE xxx没加WHERE,或者WHERE条件写错,直接把核心表清空了。
4.2 删除操作的实战建议
我总结了几条经验,全是血泪换来的:
- 开发环境随便造,生产环境先备份。生产环境任何
DELETE,执行前先SELECT COUNT(*),确认影响行数在你预期范围。 DELETE前开启一个事务,先SELECT再DELETE,确认无误再COMMIT:
START TRANSACTION; SELECT * FROM `user` WHERE `status` = 0; DELETE FROM `user` WHERE `status` = 0; -- 检查影响行数,确认无误后执行: COMMIT;这样搞,即使条件写错了,一条ROLLBACK就能救回来。 3.删除大批量数据要分批,别一次性删100万行。每删1万行COMMIT一次,可以避免长时间锁表、binlog膨胀、主从延迟。 4.DROP TABLE不要在生产环境随手敲,先确认没人依赖这张表,最好先RENAME成备份名,观察几天再删。比如改名为user_bak_20250101。
4.3 复制表:创建临时表的三种招式
日常开发里,"我需要一张跟某张表结构一样的表"这个需求太常见了。比如做报表统计、数据归档、测试环境造数。复制表有三种玩法,优先级从高到低:
玩法一:只复制结构,不要数据
CREATE TABLE `user_copy` LIKE `user`;这条命令会完整复制user表的表结构、索引、约束、自增值设置,但一行数据都不带。这是我最推荐的方式,因为连索引和约束都一起复制了,后面不用再补。缺点是不能自定义部分结构,如果只想复制部分字段,就得用下面这种方式。
玩法二:复制结构加数据,适合造测试数据
CREATE TABLE `user_copy` AS SELECT * FROM `user`;这个写法注意:它只复制字段数据,不会复制索引、主键、约束。所以生成的是一张"裸表"。如果你只是拿来做临时统计、导出数据,问题不大;但如果你打算在这张表上继续做业务操作,得手动补主键和索引,比较容易漏。
玩法三:复制部分字段和部分数据
CREATE TABLE `user_archive` AS SELECT id, username, created_at FROM `user` WHERE created_at < '2024-01-01';这种多用于数据归档。我每年都会做一次历史数据归档,把一年前的订单记录从核心表搬到归档表,核心表变小了、查询变快了,归档数据也还在。这个操作虽然简单,但非常管用。
5. 表操作的高频坑位实测:索引、排序和事务的避雷指南
讲了这么多"语法正确"的操作,但实际生产里你还会遇到一堆"语法没毛病,但行为出乎意料"的情况。这些坑,书里通常不会写,但网上搜索热词已经把大家的需求暴露得很清楚了。我挑几个最常见的展开说说。
5.1 加了索引为什么查询还是很慢
很多同学说"我给字段加了索引,为什么SQL还是不走索引?"排查思路是这样的:
首先,确认索引是否存在:
SHOW INDEX FROM `user`;然后,用EXPLAIN看执行计划:
EXPLAIN SELECT * FROM `user` WHERE `username` = 'zhangsan';看type列和key列。type如果是ALL,说明全表扫描,索引没生效;如果ref或const,说明索引生效了。
索引失效的原因通常有几种:
WHERE条件里对索引字段用了函数,比如WHERE LEFT(username, 3) = 'abc',索引直接废掉。正确做法是改写为WHERE username LIKE 'abc%'。- 隐式类型转换。字段是
VARCHAR,查询条件是WHERE phone = 13800138000(数字),MySQL会把字符串转成数字比较,索引失效。数字和字符串类型不一致时,把查询条件写成字符串是最稳的。 - 前导模糊查询
LIKE '%abc',索引没法从最左匹配开始,除非你建反向索引或者干脆全表扫。 - 排序和分组字段没在索引里,绕不开临时表和文件排序。比如
ORDER BY created_at,而索引是idx_username,那排序就要走filesort。
所以,"我加了索引"不等于"查询一定快",你要看索引是不是和你的SQL匹配。设计索引一定要考虑最左前缀原则,联合索引(a, b, c),只有a、a,b、a,b,c的顺序能走索引,b单独查就走不了。
5.2 ORDER BY排序的几个隐性行为
排序是MySQL里最容易被低估的操作。ORDER BY基本语法很简单:
SELECT * FROM `user` ORDER BY `created_at` DESC, `id` ASC;但如果排序列没索引,MySQL会走filesort。别慌,filesort不是磁盘排序,它只是在内存排序区(sort_buffer_size)里做排序,数据量太大放不下才落盘。问题是:如果你查1000万行再排序,即使内存能放下,CPU和IO开销也很大。
优化手段:如果你的排序字段恰恰就是索引字段,并且查询条件也在索引的范围内,那ORDER BY就能直接利用索引的有序性,根本不用排序,EXPLAIN里Extra没有Using filesort就是成功了。复合排序时要注意顺序一致性:ORDER BY a ASC, b ASC能配合索引(a, b),但ORDER BY a ASC, b DESC就未必了(MySQL 8.0+支持降序索引,但默认升序索引可能就没那么顺滑)。
还有一个细节:中文排序。默认的utf8mb4_unicode_ci排序规则会把中文按拼音排吗?答案是看具体规则。unicode_ci对中文字符排序是按Unicode编码排的,不是你想象的中文笔画或拼音顺序。如果业务上真的要按拼音排中文,得用CONVERT函数或者额外加拼音列,比如ORDER BY CONVERT(username USING gbk)。这个我在做通讯录功能时踩过,查了好久资料才想起来还有这种用法。
5.3 事务和锁:为什么DML有时候会互相堵死
表的基本操作里,UPDATE和DELETE是最容易触发锁等待的。很多人搞不懂InnoDB的锁机制到底是什么,我用一句大白话总结:InnoDB锁的不是表,而是索引记录和索引范围。也就是说,你执行UPDATE的时候,它会在你涉及的行上加X锁(排他锁),没有索引的情况下才会退化成表锁(实际是锁全表所有记录,效果跟表锁差不多)。
两个常见的问题:
- 没有索引导致锁范围扩大。你的条件是
WHERE status = 0,但status没有索引,InnoDB只能扫全表找符合条件的行,扫过的行都会被上锁。虽然最终只锁匹配行,但扫描过程里的间隙锁(gap lock)很可能把其他插入操作堵住。 - 事务没提交导致锁不释放。很多人写代码,开了事务执行了
UPDATE,逻辑走完了忘了COMMIT,锁就一直挂着。后面所有对这个表的操作全部卡住,连接池被占满,服务雪崩。排查方法:SHOW PROCESSLIST看有没有长事务,SELECT * FROM information_schema.innodb_trx看当前活跃事务情况。
注意:生产环境里执行DDL(比如
ALTER TABLE ADD COLUMN)时,如果你害怕锁表影响线上,先确认有没有长事务存在。一个未提交的事务会阻塞DDL,导致DDL一直处于Waiting for table metadata lock状态,表面上看是"卡住了",其实是有人偷偷开着事务不关。
5.4 从MySQL到其他数据库的表结构迁移
搜索引擎热词里有一类需求很有意思:把MySQL的表结构迁移到其他数据库,比如"mysql表结构自动转tdengine超级表+子表"、"使用flink实现mysql同步到clickhouse"。这说明在实际项目中,MySQL常常不是终点,而是数据流转的一环。
这类迁移的核心思路其实大同小异:先读取information_schema里的元数据,或者通过SHOW CREATE TABLE拿到建表SQL,然后用脚本解析字段名、字段类型、主键、索引,再转换成目标库的DDL语法。MySQL和PostgreSQL、ClickHouse这些数据库的数据类型映射是有一套常见规则的,比如VARCHAR对应String,BIGINT对应Int64,DECIMAL(10,2)对应Decimal(10, 2)。
但我提醒一句:在迁移表结构前,先确认两件事。一是目标库有没有自增主键的概念,比如ClickHouse就没有,你得用String类型的ID或者业务主键替代;二是排序规则和字符集是否兼容,很多数据库默认字符集不是utf8mb4,迁移过去后中文乱码是你最不想面对的事。我吃过这个亏,后来养成了习惯:任何跨库迁移,第一步永远是在目标库里用一小批数据试运行,查乱码、查精度、查时间类型,没问题再全量。
6. 建表之后的三件小事:索引、注释和MySQL版本的选择
很多人建完表就跑,等查询慢了再来补索引。我的习惯是建表时就顺手把索引设计好。还有两件小事,看似无关紧要,实际影响深远:表和字段上的注释,以及你用的MySQL版本。
6.1 建索引的三个实用原则
索引不是越多越好,越多越慢,因为每次写入都要同步维护索引。我给自己定了几条铁律:
- 单表索引不超过5个,这是个经验值。索引太多,写入性能急剧下降,尤其是在批量导入数据的场景里,每个索引都是一次额外IO。
- 联合索引把等值查询的字段放前面,排序字段放后面,最大程度利用最左前缀原则。比如高频查询是
WHERE status = 1 ORDER BY created_at DESC,索引建(status, created_at)就比(created_at, status)好。 - 区分度低的字段慎重建索引。比如
status字段只有0和1两个值,区分度是1/2,这种情况下索引对查询收益很小——MySQL优化器发现走索引要读的数据超过全表的30%,干脆全表扫了。反而性别这种字段,更别建。
如果你想在已经有很多数据的表上加索引,ALTER TABLE ADD INDEX同样面临锁表问题。速度跟表大小有关,几千万行的大表,建索引操作也很伤,尽量在业务低峰期执行。
6.2 注释不写,半年后你自己也看不懂
我在公司review代码时有个原则:字段必须有注释,表必须有说明。这不仅仅是为了团队协作,更是为了半年后的自己。人的记忆是会衰退的,你写的时候觉得"这个字段叫type,意思不是明摆着吗",三个月后你就得翻代码看枚举值才想起来它到底存的是1还是2。
建表时把注释带上并不费事:
CREATE TABLE `order` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '订单ID', `order_no` VARCHAR(32) NOT NULL COMMENT '订单号(全局唯一)', `user_id` BIGINT UNSIGNED NOT NULL COMMENT '下单用户ID,关联user.id', `status` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '订单状态:0待支付,1已支付,2已发货,3已完成,4已取消', `amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '订单金额(元)', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单主表';看到没,几乎每一行都写了注释,包括表注释。这一张表的设计意图,即使是新人接手,看一眼注释就能快速理解,省了大量沟通成本。字段命名上我也有一套约定:主键一律id;时间字段created_at、updated_at;关联字段直接表名_id;布尔字段加is_前缀(比如is_deleted)。约定比技术更重要。
6.3 MySQL版本差异:同样的SQL,不同的命运
最后想聊聊版本。我见过不少公司还在跑MySQL 5.7,也见过已经开始用8.0的。这两种版本在表操作上确实有实质差异,不注意会踩坑。
MySQL 8.0的几大变化:
- 默认字符集是
utf8mb4,而5.7默认还是latin1。这意味着5.7上升级到8.0,如果不主动改,表可能默认还是latin1。 - 新增了
INSTANT算法,很多ALTER TABLE操作秒级完成,这在5.7上是不可想象的。 - 取消了对
FROM后加逗号隐式连接的支持(老派写法SELECT * FROM a, b WHERE a.id = b.a_id会报错),8.0要求明确JOIN。 - 窗口函数、CTE(公用表表达式)都是8.0加入的,7.0没法用。在做复杂统计的时候,8.0的写法会简洁非常多。
还有一个很多人遇到过的报错:[ERROR] [MY-010206] InnoDB: ...这类错误文本看着吓人,实际上常常是表损坏或版本不兼容造成的。比如5.6生成的表文件拿到5.7/8.0去加载,或者MySQL进程意外退出导致表空间不一致。处理思路一般是:先备份(物理文件),然后尝试OPTIMIZE TABLE table_name修复,修不了再用mysqlcheck工具,最后才考虑建新表倒数据。千万不要一上来就DROP,先备份永远是对的。
我自己现在新项目一律用8.0,而且尽量用Docker部署,方便统一版本。如果公司还是5.7,那你写表操作SQL的时候就得多想一步:这个ALTER会不会锁表,这个排序规则够不够完善,这些老版本的历史包袱你都得背。
说到底,表的基本操作不仅仅是几条SQL命令的记忆,而是你对数据存储方式的理解。从字段类型的选择,到索引的设计,再到改表时的锁机制,每一步都在影响着你未来几个月甚至几年的开发体验。多花十分钟在建表前,能省下来的是未来无数个小时的排查时间。