刚接手一个跑了三年的项目,发现线上用户表里存的手机号全是乱码,前排同事一脸无辜地说"当初用的默认字符集",我当场血压就上去了。这种戏码在MySQL日常运维里太常见了。今天这篇就从数据库和表的最基础操作讲起,把我这些年踩过的字符集坑、主键设计的纠结、ALTER TABLE改表结构的惊魂时刻,全部掰开揉碎写出来。不管你是刚装好MySQL准备建库的新手,还是被线上大表DDL卡到怀疑人生的老手,这篇都值得花十分钟看完。
1. 建库前必须定好的三件事:字符集、排序规则和库名规范
1.1 字符集选错,后面洗地洗到怀疑人生
很多教程上来就让你CREATE DATABASE test_db;完事,这其实是最坑的开局。MySQL默认字符集在不同版本、不同安装方式下可能完全不一样。比如MySQL 5.7时代,我第一次用官方Yum源装完,直接建库,默认是latin1,存中文进去就是问号或者一堆乱码。更麻烦的是,如果表都已经建好、数据已经跑起来了,再回头改字符集,那真是一部血泪史。
我从MySQL 5.6一直用到当前主流的MySQL 8.0,现在养成的习惯是,任何新库创建时都显式指定字符集和排序规则,绝不依赖默认值。建库这样写:
CREATE DATABASE app_user DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;可能有人问,"我见网上很多人用utf8,为什么你要用utf8mb4?" 这件事必须说清楚:MySQL里的utf8是历史遗留问题,它其实不是真正的全量UTF-8,最多只能存3个字节,像emoji表情、生僻字这些需要4字节编码的字符,一存就报错或者变问号。而utf8mb4才是真正的四字节UTF-8,能完整覆盖所有Unicode字符。
我自己的项目里出现过一次线上事故,用户昵称带个emoji,接口报错,排查半天发现表是utf8字符集。后来把整批表都用ALTER转成utf8mb4,但因为数据量大,转换过程锁表锁到凌晨,业务直接受影响。所以,建库这一步定下utf8mb4,是最省事的选择。
1.2 排序规则不是玄学,是排序和比较的底层规则
排序规则(Collation)是初学者最容易忽略的一块。它决定了数据库比较字符串时"a"和"B"谁大谁小,也决定了中文排序时是按拼音还是按偏旁部首。
MySQL 8.0默认的utf8mb4_0900_ai_ci里,ai表示重音不敏感,ci表示大小写不敏感。如果你的业务需要区分大小写查用户名,就要考虑用utf8mb4_0900_bin这种二进制比较规则。举一个我真实踩过的坑:做了一个用户名登录系统,用户注册时输入Lucy,登录时输入lucy,因为排序规则是ci(大小写不敏感),登录居然成功了。产品经理说是bug,其实从数据库层面来说,这个行为是完全符合排序规则定义的。
所以建库或者建表之前,想清楚业务对大小写、重音符号的敏感度,比后期在SQL里拼命用BINARY包裹字段要优雅得多。如果你不确定业务未来的走向,优先选择默认的排序规则,别自己造轮子改些花里胡哨的,后续迁移和对比会很痛苦。
1.3 库名与大小写敏感的约定
库名、表名、列名的命名,我这些年吃过不少亏。MySQL在Linux下默认区分表名大小写,在Windows下不区分,这个差异在跨平台迁移时特别容易炸。
具体来说,Linux的lower_case_table_names这个关键参数如果等于0,说明表名严格区分大小写;user表和User表会被当成两个表。但Windows上默认忽略大小写,你写USER也能查到user表的数据。有一次我把Windows开发的库导到Linux服务器,程序里查ORDER,表名叫order,直接报"表不存在",排查了很久才发现是大小写问题。
解决思路很明确:生产环境统一把小写表名作为硬性规范,所有建表脚本、业务SQL里一律小写。如果你要临时确认当前环境的行为,执行这个:
SHOW VARIABLES LIKE 'lower_case_table_names';拿到结果后,再决定脚本怎么写。这点看似不起眼,但遇到过一次之后,你就知道它有多折磨人。
2. 建表的核心决策:引擎、主键和字段层面的细节
2.1 为什么我只用InnoDB
我知道现在谈存储引擎可能有点老生常谈,但我还是想多说一句。默认的存储引擎在MySQL 8.0已经是InnoDB了,但仍有很多老项目里留着MyISAM表。每次看到这种表,我都有一种"拆弹"的紧迫感。
InnoDB支持事务、行级锁、外键和崩溃恢复,这些特性在业务系统里几乎是刚需。MyISAM虽然读性能在某些场景下不错,但它用的是表级锁,写一篇文章把整张表锁住,两个并发更新就能互相卡死。更致命的是MyISAM不支持事务,一旦写入过程中崩了,表可能损坏,修复起来非常头疼。
如果你是新建表,别犹豫,直接ENGINE=InnoDB。如果是线上遗留的MyISAM表,也不是完全不能转换,但要注意转换大表时会锁表,建议在低峰期执行:
ALTER TABLE user ENGINE=InnoDB;执行前用SHOW TABLE STATUS LIKE 'user'看一眼表当前的行数和数据量,心里有个底。
2.2 主键:宁可多花三分钟,不要省这三分钟
主键设计是最值得认真对待的事情。我的原则很简单:业务表默认使用自增BIGINT作为主键,除非有极特殊的场景,否则不要用业务字段当主键。
为什么不用业务字段?业务字段是有可能会变的。比如你用手机号当主键,后面业务调整要做"一个用户多个号码"的功能,或者用户注销后号码被别人注册,你改起来会想哭。自增主键本身和业务无关,纯粹是行的唯一标识,稳定、占用空间小、插入性能好。
还有一种主键方案是用UUID,这个我真的劝退。UUID是字符串,长度大,而且随机分布,插入时会导致聚簇索引不断的随机页分裂,写入性能下降很明显。之前接手一个表,主键用的UUID,几百万行数据后每次批量插入都要好几秒,后来迁移成自增BIGINT,写入速度翻了几倍。
建表时主键这样写就够了:
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', PRIMARY KEY (`id`)2.3 字段命名:避免关键字,保持可读性
MySQL有很多保留字,像order、group、desc、key、rank这些,直接拿来做字段名会让你每次查询都得加反引号,烦不胜烦。我在项目规范里规定,字段名一律不用保留字,如果实在要表达这类语义,就用业务化的前缀,比如order_no、group_id、desc_text。
此外,我强烈建议所有表统一加上几个"基础设施"字段:created_at、updated_at、deleted_at。前两个是审计追踪,第三个是软删除标记。可能有人觉得软删除多余,直接DELETE不好吗?但你做数据分析、做历史溯源的时候,软删除的数据能救你一命。
updated_at在MySQL 8.0里可以设置自动更新,建表的时候直接声明好:
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'这样应用层什么都不用管,任何更新都会自动刷新这个字段。
2.4 一份可复用的建表模板
讲完原则,给一份我平时用得最顺手的建表模板,你直接拿来改改就能用:
CREATE TABLE user_profile ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID', username VARCHAR(64) NOT NULL COMMENT '用户名', phone VARCHAR(20) NOT NULL COMMENT '手机号', email VARCHAR(128) DEFAULT NULL COMMENT '邮箱', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1正常 0禁用', points INT NOT NULL DEFAULT 0 COMMENT '积分', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间', PRIMARY KEY (`id`), UNIQUE KEY uk_username (`username`), KEY idx_status (`status`), KEY idx_created_at (`created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户资料表';这里面的门道:用户名加了唯一键,确保不重复;状态字段走TINYINT,比字符串省空间;时间索引用于范围查询,这在后台列表页特别有用。
3. 列类型选型,直接决定这张表能走多远
3.1 整数类型:别为了省空间埋雷
MySQL整数类型有TINYINT、SMALLINT、INT、BIGINT等,分别占1、2、4、8字节。我看到网上很多表设计图,动不动就INT,但如果一个字段的值只有0和1,用INT纯属浪费磁盘和内存。
同时也要小心别抠门抠过头了。主键用INT,在数据量超过21亿行的时候就会溢出,虽然大部分表一辈子也到不了这个量级,但既然主键逃不掉BIGINT的成本,直接一步到位最安心。
我建议的规矩是:状态、类型、布尔值用TINYINT;数量、计数、常规ID用INT,且业务上明确可能海量增长时用BIGINT。别小看这几字节的选择,在几千万行的表上,字段类型收紧一大圈,整体占用空间能省出好几个GB。
如果你在建表时还能加上UNSIGNED(无符号),意思是只允许正数,这个约束不仅节省负数的语义空间,还能让正数的上限翻倍。比如INT UNSIGNED的上限是42亿多,普通INT上限是21亿多。像用户ID、积分这种不可能为负的数字,都可以直接加UNSIGNED。
3.2 字符串:CHAR、VARCHAR和TEXT的分工不同
CHAR是定长字符串,VARCHAR是变长字符串。一个常见的误解是,VARCHAR一定比CHAR省空间。其实要看长度波动情况。像手机号、身份证号这种长度固定的字段,用CHAR反而更好,因为MySQL不需要额外记录长度前缀。而用户名、地址这种长度差异大的,VARCHAR是正解。
TEXT类型,我一般不建议放在核心业务表的常用查询路径里。因为TEXT字段无法直接设置默认值,而且可能被放到独立的溢出页,查询时会多一次IO。像文章内容、大段日志,我都建议拆出去单独建表,或者直接存对象存储,数据库里只存访问路径。
还有一点关于VARCHAR的长度,别一上来就是VARCHAR(255),那是祖传习惯。在utf8mb4字符集下,255字符乘以4字节等于1020字节,加上长度前缀刚好徘徊在索引限制的边缘。如果做前缀索引,实际能利用的长度还得再压缩。定长度时,先想清楚业务最大值的真实边界。比如订单号固定18位,就写VARCHAR(32);用户名限制30字符,就写VARCHAR(64),留一点余量就够了,不用给到255。
3.3 小数:永远不会用FLOAT存金额
金额、价格、费率这类型的字段,我的态度非常坚决:用DECIMAL,绝对不用FLOAT或者DOUBLE。原因很简单,浮点数在二进制表示下是不精确的,0.1加0.2可能得到0.30000000000000004。存用户余额的时候差一分钱,对账的时候就是大事故。
比如要和第三方支付对账,每一笔分账都要精确匹配,浮点数的误差会把你心态搞崩。DECIMAL(10,2)表示最多10位数字,其中小数占2位,小而美。如果你要存BTC之类的小数位超多的场景,再把小数位加大,比如DECIMAL(20,8)。
也许有人问,那我应用层用BigDecimal,数据库只用FLOAT行不行?理论上可以,但我实测过,查询条件里出现范围筛选时,浮点数的比较很容易出现意向不到的偏差。所以从源头就用DECIMAL,省心。
3.4 时间:DATETIME和TIMESTAMP怎么选
时间字段是表设计里经常被忽略、但实际非常关键的部分。MySQL里主要就DATETIME和TIMESTAMP两种。
DATETIME存储范围大,从1000年到9999年,不依赖时区设置,存进去是什么就展示什么。TIMESTAMP范围只有1970年到2038年,而且会自动跟随数据库时区转换。看起来两个都能用,但要结合业务场景。
如果系统是纯国内业务,我在中间不涉及多时区转换的情况,一般首选DATETIME,存储和展示逻辑简单直接。如果系统是国际化、需要按用户时区显示本地时间,那TIMESTAMP能帮你省不少应用层转换的代码。但要注意2038年问题,如果你建的是长寿系统,这个坎就得提前避开。
建表时我还习惯配合默认值,让数据库自己处理创建时间:
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP如果是业务自定义的时间,比如活动开始时间、优惠券过期时间,就不要用CURRENT_TIMESTAMP,直接手工写入具体值。明白这个边界,能避免很多前后端时间显示不一致的诡异bug。
4. 表和库的修改:ALTER TABLE的执行代价与稳妥做法
4.1 一次ALTER TABLE引发的"血案"
讲一个真实的线上事故。某个交易表每天有百万级写入,某天开发同学需要在表上加一个索引,于是直接执行:
ALTER TABLE transaction ADD INDEX idx_user_id (user_id);这条语句在MySQL 5.7的年代,默认会拷贝整张表的数据,期间表被锁住,读写全部阻塞。我们那次加索引跑了17分钟,业务侧支付超时告警满天飞,最后只能紧急回滚。这件事之后,我在团队里强制推行了"大表DDL改造前先做评估"的制度。
好消息是,MySQL 8.0的Online DDL已经比5.7改善了不少,很多操作支持ALGORITHM=INPLACE,部分操作不需要全表拷贝,但依然有一些操作目标是锁表的。所以你的MySQL版本,决定了你的操作风险等级,这句话一点都不夸张。
4.2 常用ALTER操作的正确姿势
ALTER操作有很多细节,这里整理成表,直接看:
| 操作场景 | 推荐语句 | 注意点 |
|---|---|---|
| 添加列 | ALTER TABLE t ADD COLUMN remark VARCHAR(255) NOT NULL DEFAULT ''; | 大表尽量放低峰期,指定默认值能减少应用层改动 |
| 修改列类型 | ALTER TABLE t MODIFY COLUMN phone VARCHAR(20) NOT NULL; | 会重建表,务必确认数据长度都在新类型范围内 |
| 修改列名和类型 | ALTER TABLE t CHANGE COLUMN old_name new_name VARCHAR(64); | CHANGE比MODIFY多一个改名的能力,但会把旧的类型信息全量替换 |
| 删除列 | ALTER TABLE t DROP COLUMN remark; | 删除后恢复成本很高,先确认有没有查询依赖 |
| 添加索引 | ALTER TABLE t ADD INDEX idx_col (col); | MySQL 8.0支持在线,但仍建议低峰 |
| 删除索引 | ALTER TABLE t DROP INDEX idx_col; | 删除索引一般很快,但要和查询计划一起评估 |
执行前,我习惯先跑一下SHOW CREATE TABLE t;,把DDL完整保存下来,作为回滚依据。别只靠记忆里的建表语句,线上版本可能已经改过好几轮了。
4.3 改列名为什么比想象中麻烦
有些同学会问,CHANGE COLUMN不就能改列名吗,有什么麻烦的?麻烦在应用层。数据库列名一改,所有涉及这个字段的SQL、ORM映射、报表SQL全都要跟着改。你以为你只改了一个列名,实际上你改的是一张依赖网。
我经历过一次比较惨痛的教训:把订单表的pay_time改成paid_at,数据库执行只花了一分钟,但后续花了三天时间排查接口报错,因为同事代码里有个硬编码SQL没搜到。所以我的建议是,改列名前,先在代码仓库里全局搜索该字段名,确认所有引用点都同步改完,再动手执行ALTER。切换方式上也建议用"先加新列、双写、再迁移、最后删除旧列"的灰度方案,虽然繁琐,但对线上业务最安全。
5. 删除类操作和大表场景,数据没了才知道的事
5.1 DELETE和TRUNCATE的差别
删除表里的数据,总有新人分不清DELETE和TRUNCATE,这两个的差别在某些场景能影响你是否能保住饭碗。
DELETE FROM t;是一行一行标记删除,支持条件过滤,如果开了事务还可以回滚,但它是逐条写binlog的,几千万行的时候删除速度极慢,而且会产生大量碎片。TRUNCATE TABLE t;直接释放整个表的存储空间,速度飞快,但不能加条件、不能回滚。它会把表结构重置,自增ID也从1重新开始。
日常工作里,删少量数据用DELETE加条件;清空全表数据、但保留表结构,用TRUNCATE。最怕的就是线上执行DELETE FROM t WHERE ...忘了加条件,把全表删了。虽然DELETE有事务保护,但如果没开事务、没有binlog,照样找不回来。
说到这,我必须提一下DELETE全表数据后的自增问题。删除完之后,AutoIncrement计数器不会自动归零,而是记住历史最大值。如果你想清完数据后ID从1开始,用TRUNCATE最稳妥。
5.2 删库删表:动手之前先做完三件事
DROP DATABASE和DROP TABLE是最危险的操作,没有之一。我现在每一次执行这类操作之前,都强迫自己走完三个步骤。
第一步,用mysqldump备份。就算表里有几亿行数据,我也会先把表结构备份出来,再决定是否备份数据。
mysqldump -h 127.0.0.1 -P 3306 -u root -p 数据库名 表名 > backup.sql第二步,把binlog检查打开。MySQL的binlog记录了所有变更,如果你误删了数据且binlog没有过期,理论上可以做到基于时间点的恢复。线上环境只要空间允许,我从不关闭binlog,这是最后一道保险。
第三步,删除前先确认有没有外键和关联对象。一张被其他表外键引用的表,你直接DROP可能报错,也可能连带影响。用information_schema查一下关联关系:
SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_NAME = '目标表名';全部确认完毕,再执行删除。开发环境我无所谓,但生产环境的DROP操作,我都是先截图、再执行、删完马上验证业务。
5.3 大表操作思路:先查元数据,再定方案
几千万行的大表,任何操作都不能拍脑袋。我处理大表问题的第一件事,永远是先查元数据,搞清楚这张表到底有多大、行数大概多少、占用多少空间。
SELECT TABLE_NAME, TABLE_ROWS, DATA_LENGTH / 1024 / 1024 AS data_mb, INDEX_LENGTH / 1024 / 1024 AS index_mb FROM information_schema.TABLES WHERE TABLE_SCHEMA = '数据库名' ORDER BY DATA_LENGTH DESC;注意,TABLE_ROWS在InnoDB里是估算值,不是精确值,但足够判断量级。如果数据量已经到了千万级甚至亿级,任何全表ALTER操作都意味着巨大的IO压力。我的处理套路是:利用Online DDL加索引时,把ALGORITHM=INPLACE, LOCK=NONE显式写出来,让数据库明确你的意图:
ALTER TABLE big_table ADD INDEX idx_col (col), ALGORITHM=INPLACE, LOCK=NONE;如果MySQL版本较老,或者操作本身不支持INPLACE,那就切到主从架构,先在从库上完成结构变更,再切换流量。这步操作虽然要动架构,但比直接卡死线上业务要强一万倍。
我见过不少人追着问"优化SQL"还是"优化表结构",其实在大表面前,两者同样重要。结构不合理,SQL再优化也兜不住。反过来,SQL写得稀烂,表结构再完美也白搭。
在做MySQL数据库和表操作这件事上,我的经验就一句话:每次执行DDL之前多问自己一句"这个操作要锁多久?怎么回滚?备份在哪?"想清楚这三个问题,你已经比绝大多数同行稳了。如果你正准备处理一张线上大表,别着急动手,先把元数据查出来,按上面说的方法评估一轮,找好低峰期窗口再操作,你会感谢自己这份耐心的。