1. 建库前的规划:先想清楚再动手
1.1 版本选择与存储引擎的基本功
MySQL 学了这么久,真正自己动手建库的时候才发现,建库这个操作远不止敲一行CREATE DATABASE那么简单。很多人包括我自己,早期就在这一步踩了不少坑——明明语句执行成功了,跑起来却各种乱码、连接不上、权限报错,回头一看都是建库前没想清楚。
这篇笔记记录的就是我从零开始搞数据库的完整过程:版本怎么选、字符集怎么定、库怎么建、表怎么建、各类 SQL 怎么跑,以及遇到问题怎么排查。适合刚学完基础语法准备上手实操的同学,也适合那些已经写了不少 SQL 但始终没系统梳理过建库环节的开发者。
先聊版本。MySQL 现在主流的是 5.7 和 8.0 两个大版本。如果你是从官网下载新装,直接上 8.0 就行了,它的默认字符集已经改成了 utf8mb4,排序规则也换了,性能优化比 5.7 强不少。但如果你的项目要兼容老环境、跟别人共用服务器,那就先查一下现有实例的版本和生产环境用的版本,尽量保持一致。举个例子,5.7 里的utf8mb4_general_ci和 8.0 里的utf8mb4_0900_ai_ci排序规则差别很大,如果程序里硬编码了对排序结果的期望,跨版本迁移时会莫名其妙出问题。我吃过这个亏,5.7 排序出来的结果和 8.0 不完全一致,排查了半天才发现是排序规则差异。
存储引擎方面,InnoDB 是绝对的主流选择——支持事务、支持行级锁、崩溃恢复能力强。MyISAM 这个老引擎虽然查询快,但表级锁并发差、不支持事务,除非你确定自己的场景完全不需要这些特性,否则别选。我记得早期用 MySQL 的人特别喜欢 MyISAM,因为它的全文索引好用,但现在 InnoDB 也支持全文索引了,MyISAM 真的没有继续用的理由了。
1.2 字符集与排序规则的决策
字符集这个坑,应该是所有 MySQL 新手都会经历的。我建第一个库的时候,直接用了默认的latin1,结果中文存进去全是问号,折腾了一晚上终于明白是怎么回事。
现在的原则很简单:字符集只要不是 utf8mb4,就要多想三秒钟。utf8mb4 是 UTF-8 的超集,能存 4 字节的字符,比如 emoji、生僻字这些,全都不在话下。用 utf8 的话,一个汉字三个字节没问题,但 emoji 存进去就直接报错或者变成乱码。
排序规则也顺手说一下。utf8mb4 下面有两套常见选择:utf8mb4_general_ci和utf8mb4_unicode_ci(8.0 里是utf8mb4_0900_ai_ci)。general_ci 速度略快,但排序精确度不如 unicode_ci。对于绝大多数业务场景,这两者的差异你根本感知不到,随便选一个坚持统一就好。真正重要的问题不是选哪套,而是整个数据库链路的字符集要一致——库、表、连接、客户端,全都要统一。连接层那边如果用了SET NAMES utf8mb4或者连接串指定了 characterEncoding,服务端这边也要对得上,否则你库和表都是 utf8mb4,写入的数据还是会乱。
建库时的字符集设定语句大概是这样的:
CREATE DATABASE IF NOT EXISTS my_app DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;这一步直接把字符集钉死,之后就再也不用来回改。改已存在的库的字符集很麻烦,虽然可以执行ALTER DATABASE ... CHARACTER SET ...,但历史表不会被自动迁移,你得逐个表去改,痛苦得很。所以我的习惯是:宁可建库时多敲几个字符,也不要事后补作业。
2. 创建数据库与数据表的完整实操
2.1 建库语句的细节与验证
先给出一套我常用的建库模板,适配 MySQL 8.0:
CREATE DATABASE IF NOT EXISTS shop DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;IF NOT EXISTS这个关键字我每次必带。它的意义在于:脚本重复执行不会报错。尤其你写部署脚本的时候,谁也没办法保证整个流程不重复跑两遍。我当时写初始化脚本第一次没加,第二次执行直接弹ERROR 1007 (HY000): Can't create database 'shop'; database exists,当场尴尬。加上之后,重复执行就是静默跳过,安全很多。
然后是反引号的问题。对于库名、表名、字段名,我的建议是:能用小写下划线的组合就绝对不用特殊字符,比如user_order、order_detail。但如果你接手老项目,发现表名里有大写字母或空格,那 SQL 里就得用反引号包起来,否则语法报错。写代码时的习惯是:从代码层生成 SQL 时,全部加上反引号,这样任何合法的表名都不会出问题;手写探索性的 SQL 时,就不加,保持简洁。
建完库之后,验证一下:
SHOW CREATE DATABASE shop\G这条命令会把数据库的创建语句原样打印出来,你就能确认字符集和排序规则是否真的生效了。注意SHOW后面跟的是CREATE DATABASE,不是CREATE TABLE,别搞混了。同时可以用SHOW DATABASES LIKE 'shop'确认库是否存在。
建库之后,我做的第一件事不是立刻建表,而是查看一下物理目录确认数据文件真的落盘了。用SHOW VARIABLES LIKE 'datadir';查到数据目录,然后ls一下能看到一个名为shop的目录。这样做的好处是:你能感知到 MySQL 的物理存储结构到底是什么样的,后面做备份、做迁移的时候会更有感觉。纯逻辑层面的操作做多了,容易忽略数据其实是落在磁盘上的文件这回事。
2.2 建表设计与字段选择的经验
建完库,紧接着建表。这是整个过程中最容易暴露设计问题的环节。我见过太多一上来就CREATE TABLE users (id int, name varchar(255))的示例代码,但放到真实项目里,这种设计跑起来的性能惨不忍睹。实际建表时,字段类型的选择稍微多花点时间,后面能省很多事。
整数类型的选择遵循一个逻辑:范围够用就选最小的。比如用户表的id用INT UNSIGNED能存 40 多亿,一般业务足够。但如果你做的是消息表、日志表这种一天可能上百万条数据的表,从第一天就用BIGINT更省心。另一个例子是status字段,0 表示待处理、1 表示已处理、2 表示失败,这种用TINYINT就够了,用INT就是浪费字节,索引还变宽了,扫描速度也受影响。
字符串类型的选择也是经典问题。VARCHAR存可变长度字符串,CHAR是定长。身份证号、手机号这种长度固定的业务字段,用CHAR存储性能略好;但大部分字段像用户名、备注、地址,长度完全不可控,肯定选VARCHAR。再有一个关键点:VARCHAR的长度单位是字符,不是字节,所以VARCHAR(255)在 utf8mb4 下最多存 255 个字符,不是 255 字节,别被这个坑到。
文本类型就TEXT和BLOB之间选择。实际业务中文档、详情大部分用TEXT就够了。像文章内容这种大文本,可以考虑拆到独立的表里,避免主表行变得过大,影响 InnoDB 的行存储效率。这是我做过一次博客系统后的教训:一开始把正文直接塞进主表,后来文章多了查询明显变慢,拆表后主表瘦身,性能立刻回来了。
时间字段,我的建议是:业务时间用DATETIME,因为可读性好;TIMESTAMP虽然有 2038 年问题和时区转换机制,但占用空间小,适合程序内部传递时间戳。如果你的业务要跨越多个时区,统一用TIMESTAMP配合时区配置反而更方便,但大多数单体应用根本不需要这么复杂,DATETIME从头用到尾即可。
一个规范的建表语句长这样:
CREATE TABLE IF NOT EXISTS `user` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', `username` VARCHAR(32) NOT NULL COMMENT '用户名', `phone` CHAR(11) DEFAULT '' COMMENT '手机号', `gender` TINYINT NOT NULL DEFAULT 0 COMMENT '性别 0未知 1男 2女', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '账号状态 0正常 1禁用', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';注意两点:第一,COMMENT一定要写,这是给未来的自己和同事看的,等三个月后你再回来看这个表,没有注释的字段真的想不起来是干嘛的;第二,updated_at的ON UPDATE CURRENT_TIMESTAMP非常实用,每次更新行数据时它会自动刷成当前时间,省了一堆手动赋值。这两个习惯是我在一线干活之后才逐渐养成的,属于账面上不会写清楚、但真实项目里非常好用的细节。
3. 各类 SQL 的运行与踩坑实录
3.1 DML 操作——写入、修改、删除的姿势
库有了表有了,接下来就是跑各类 SQL。经常听到一句话叫“MySQL 的 SQL 就是把增删改查写好”,但当业务复杂了之后,看似简单的增删改查里全是学问。
插入语句要搞清楚两件事:字段列表和值的对应关系。显式列出字段是一个值得坚持的习惯:
INSERT INTO `user` (`username`, `phone`, `gender`) VALUES ('张三', '13800138000', 1);不写字段列表直接VALUES (1, '张三', ...)是很危险的做法。一旦表结构发生变化,插进去的数据全错位,而且错位后的数据根本看不出来是错的,只有业务跑起来才会暴露。批量插入时,一条 INSERT 后跟多个 VALUES 会大幅提升效率,比循环单条插入的性能好了不少,这就是“减少 SQL 往返”的核心思路。
修改数据时,最容易犯的错是忘写WHERE。前几年有一次我在测试环境做数据订正,跑了一次UPDATE user SET status = 1,直接把全表状态都给改了,幸好是测试库。从那以后我给自己定了个规矩:任何 UPDATE 和 DELETE 语句,写 WHERE 条件之前先数清楚影响行数。MySQL 客户端执行完会显示Rows matched / Changed / Warnings三组数字,看一眼再回车,心里才踏实。正式库操作我甚至会在事务里先 SELECT 出来 count 一遍,确认数据量对了再执行 UPDATE。
DELETE 操作还要考虑是否带LIMIT。不带 LIMIT 的大范围 DELETE,在数据量大的表上会一直锁行锁间隙,长时间不释放可能导致核心业务阻塞。对于清理历史数据这种操作,我的做法是分批处理,每批几百条或者几千条,加上条件循环清理,宁可慢一点也不要一次把生产库锁死。还有一点,DELETE 不会重置自增 ID,如果你DELETE完后想id从 1 开始,那要考虑ALTER TABLE ... AUTO_INCREMENT = 1,或者干脆用TRUNCATE TABLE(但 TRUNCATE 是清空全表,不可按条件删,使用前务必三思)。
3.2 DQL 查询——排序、去重、分组的关键细节
查询是 SQL 里占比最高的操作,也是最能看出功底的环节。先说说排序。ORDER BY后面自然就是字段名和方向ASC / DESC,但有个细节是:如果你排序的字段没有索引,MySQL 就不得不使用文件排序(filesort),数据量一大性能直线下降。我给订单表做过一次优化,原本ORDER BY created_at DESC要 1.2 秒,加了一个普通索引后降到 12 毫秒,这个差距就是索引和没有索引的真实写照。排序还有个容易被忽略的点——多字段排序时顺序是有意义的,ORDER BY status ASC, created_at DESC会先按 status 排完再按时间排,亲测前端列表想要“未处理的在前、新的在前”,用这个写法就对了。
去重这个关键字DISTINCT很常用,但用起来有讲究。比如:
SELECT DISTINCT user_id FROM `order` WHERE status = 1;这是查出所有下单过的用户 ID,没问题。但DISTINCT的语义是“整行去重”,如果 SELECT 的字段里有多个列,那只有这些列组合完全一样才会被去重。很多人以为DISTINCT列名是按某一列去重,这是误解。另外DISTINCT写多了之后,逻辑会显得笨重,遇到“按某字段分组取最新一条”这种场景,DISTINCT是做不到的,实际工作中更常用GROUP BY配合聚合函数或者窗口函数来解决。比如我需要拿到用户最近一次下单时间:
SELECT user_id, MAX(created_at) FROM `order` GROUP BY user_id;分组之后,HAVING比WHERE更符合“对聚合结果过滤”的语义。在 WHERE 里写聚合条件是语法错误,在 HAVING 里过滤非聚合条件虽然语法上允许,但逻辑上性能并不好。我自己一般这样区分:WHERE 先过滤原始行,HAVING 再过滤分组结果。这行逻辑想清楚,GROUP BY 的很多怪问题都迎刃而解。
再说一个热搜词里出现频率很高的需求——“SQL语句去重”。除了DISTINCT之外,实际业务中更常见的是“按某个字段去重但保留完整行”,特别是日志表或者流水表。最早的版本用嵌套子查询和 GROUP BY 实现,后来 MySQL 8.0 支持了窗口函数,写法变得更优雅了:
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn FROM `operation_log` ) t WHERE t.rn = 1;这种写法是按user_id分组,组内按时间倒序编号,最后取每组的第一条,就得到了“每个用户最新的操作日志”。相比原来的 GROUP BY + 子查询方案,窗口函数的逻辑直观太多,我建议有条件的同学直接升级 MySQL 8.0 用起来。
3.3 事务处理与存储过程
事务是 InnoDB 的看家本领,也是业务数据一致性的基石。我最早写代码的时候,每次插入一条订单,再更新一次库存,各写一条 SQL,然后完事。结果有一次程序执行到一半报错了,订单插进去了,库存没减,整个数据就对不上了。后来学了事务,终于明白这类问题应该这样解决:
START TRANSACTION; INSERT INTO `order` (`user_id`, `product_id`, `amount`) VALUES (18, 2001, 2); UPDATE `product` SET `stock` = `stock` - 2 WHERE `id` = 2001 AND `stock` >= 2; COMMIT;事务的四个特性 ACID,概括下来就是:要么全成功,要么全失败。上面的库存更新里加了AND stock >= 2,这是防止超卖的关键条件。开启事务后,万一执行到一半发现库存不足,可以ROLLBACK,把刚才的操作全部撤销,数据回滚到事务开始之前。这个能力平时用不到,但真正出问题的时候它能救你一命。事务还牵涉隔离级别的问题,默认的REPEATABLE READ(可重复读)对大多数业务够用,不用一上来就研究各种隔离级别差异,等真碰上脏读、幻读再说。
存储过程这东西,有的人爱得深沉,有的人避之不及。我的观点是:存储过程适合封装复杂且相对固定的数据逻辑,比如批量对账、月末统计、数据归档等任务;但如果你天天在上面做业务逻辑,那说明代码分层可能出了问题。存储过程里比较实用的场景是循环处理大量数据,比如我写过一个小存储过程,把历史日志按月份迁移到归档表,一条 SQL 触发整个流程,省去了在代码里写循环的麻烦。不过存储过程的坑也很明显:不好调试、不好做版本管理、出错信息不够透明。我的建议是:可以用,但控制好边界,真正的业务逻辑尽量留在应用程序里。
一个简单的存储过程示例:
DELIMITER // CREATE PROCEDURE proc_archive_logs(IN days INT) BEGIN INSERT INTO operation_log_archive SELECT * FROM operation_log WHERE created_at < DATE_SUB(NOW(), INTERVAL days DAY); DELETE FROM operation_log WHERE created_at < DATE_SUB(NOW(), INTERVAL days DAY); END // DELIMITER ;执行CALL proc_archive_logs(30);就会把 30 天前的日志搬到归档表。注意DELIMITER的使用非常关键,因为默认的语句分隔符是分号,而存储过程体内部也有分号,不切换分隔符的话 MySQL 会错误地提前结束整个语句。这个细节是新手写存储过程时最常踩的坑,我看到过好几个人在这里卡了半天。
3.4 字段默认值与常见约束设置
热搜词里有“mysql设置默认值为0”,这个话题虽然看起来小,实际项目里用到的频率非常高。一个场景是给商品表的销量、库存,或者用户表的积分字段设置默认值 0:
ALTER TABLE `product` MODIFY COLUMN `sale_count` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '销量';这样就保证新插入的商品,如果不指定sale_count,它会自动取默认值 0,而不是 NULL。为什么这一点很关键?因为在程序里处理 NULL 和 0 完全不一样:NULL 参与运算的结果经常是 NULL,比如price * quantity如果 quantity 是 NULL,结果就是 NULL,前端展示就出现空白。而 0 参与运算则正常得多。很多线上数据异常,追根溯源就是字段允许了 NULL,计算时没做处理。所以我建表的偏好是:数值型字段尽量 NOT NULL + DEFAULT 0,字符串字段尽量 NOT NULL + DEFAULT '',时间字段用 DEFAULT CURRENT_TIMESTAMP。保持这个风格后,业务代码里 null 判断的数量少了一大半。
如果想要在已有表上做修改,MySQL 8.0 的ALTER TABLE ... ALTER COLUMN ... SET DEFAULT和MODIFY COLUMN都能用,注意区分:ALTER COLUMN只改默认值,MODIFY COLUMN需要重写整个字段定义。重写字段定义时如果漏写了原有属性,很可能把字段类型悄悄改掉,这个操作危险程度不低,最好先在测试库验证一遍。
4. 连接、权限与运维排查笔记
4.1 客户端连接与权限配置细节
数据库建好了,SQL 也会写了,接下来的问题是:我该怎么连上去?命令行、Navicat 或者其他客户端工具,本质上连接的链路是一回事,无非 TCP、账号、密码、端口。端口默认是 3306,修改过要记得去防火墙放行。
我在用 Navicat 连接 MySQL 时遇到过各种奇怪的问题,最常见的一类错误是所谓的 “mysql ssl连接错误”。现在的 MySQL 8.0 默认开启 SSL 连接,很多客户端默认也尝试用 SSL 去连,但证书配置不全就会握手失败。排查的顺序是:先用命令行本地连接测试,排除服务端问题;如果命令行能连而 Navicat 报 SSL 错误,那基本就是 SSL 参数不匹配。这时可以把连接配置里的 SSL 项改为“禁用”或“如果可用”,这个问题经常就迎刃而解了。注意这里只是客户端连接方式的调整,并不是什么高深操作,不用纠结。
权限相关的错误也排在前几名。ERROR 1045 (28000): Access denied for user几乎每个人都会撞到一次。这条错误出现时,第一时间确认的是账号是否存在、密码是否正确、该账号是否有从当前 IP 登录的权限。MySQL 的用户权限模型实际上是“用户 + 来源主机”的组合,'test'@'localhost'和'test'@'%'是两个不同的账号,这一点极其容易混淆。给开发同事授权时,我习惯这样写:
CREATE USER 'app_user'@'%' IDENTIFIED BY 'StrongP@ss123'; GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'app_user'@'%';之后刷新权限FLUSH PRIVILEGES;。这里有个细节:MySQL 8.0 的用户密码插件默认是caching_sha2_password,某些老版本的客户端驱动对这个插件支持不好,连接时会报认证失败。解决办法要么是升级驱动,要么是创建用户时显式指定IDENTIFIED WITH mysql_native_password BY '密码',但后者是过渡方案,长远看还是升级客户端驱动更稳妥。
4.2 常见报错速查整理
我把实操中遇到的高频错误整理成一张速查表,方便遇到问题时对照排查:
| 错误信息 | 常见原因 | 处理思路 |
|---|---|---|
| ERROR 1045 Access denied | 账号/密码不对,或来源主机无权限 | 先确认账号密码,再看 host 匹配,最后看权限表 |
| ERROR 1007 Can't create database | 数据库已存在 | 建库语句加IF NOT EXISTS |
| ERROR 1064 SQL 语法错误 | 比如遗漏符号、表名包含特殊字符没加反引号 | 查看语法提示位置,确认关键字和引号 |
| ERROR 1054 Unknown column | 字段不存在或拼写错误 | DESC table;查看表结构,核对字段名 |
| ERROR 1171 字符集排序规则不支持 | 排序规则与字符集不匹配 | 统一使用 utf8mb4 对应排序规则 |
| ERROR 1205 Lock wait timeout exceeded | 行锁等待超时 | 检查是否有长事务未提交,杀掉阻塞线程 |
| ERROR 1366 Incorrect string value | 字符集不一致导致乱码 | 检查库/表/连接三层字符集 |
| ERROR 1264 Out of range value | 数值超出字段范围 | 放大字段类型或者检查插入值是否有异常 |
| ERROR 1418 存储过程权限不足 | 建存储过程需要权限 | 为账号授予CREATE ROUTINE权限 |
| ERROR 1819 密码策略不满足 | 密码太简单,不符合 validate_password 策略 | 按提示调整密码复杂度,或调低策略级别 |
| ERROR 3024 for sha2 authentication | 客户端驱动不支持默认密码插件 | 升级驱动,或改用 mysql_native_password |
表格里最后一行的 authentication 问题,我直到做了 8.0 部署才真正体会。之前在公司做项目迁移时,服务器上的caching_sha2_password让一个用老版本 JDBC 驱动的服务在启动时反复报认证失败。排查了很久,最终是通过升级驱动解决。如果你比较着急,临时把用户密码插件改回去也能顶上,但记得这只是临时方案。
4.3 慢 SQL 分析与基础优化技巧
热搜词里“慢sql优化”出现频率很高,这个技能对于一个数据库用户来说比想象中重要。MySQL 开启慢查询日志非常简单:
SET GLOBAL slow_query_log = 'ON'; SET GLOBAL long_query_time = 1;之后执行超过 1 秒的 SQL 都会被记录下来。分析工具方面,其实不用一上来就用专业平台,直接看日志也行,配合EXPLAIN足够定位大部分问题。
有次我查一个报表接口的慢 SQL,日志显示某条查询耗时 4 秒,EXPLAIN 一看扫描行数 80 万,key 显示 NULL——没走索引。加完索引再查,耗时 50 毫秒。整个过程用不了一刻钟,收益却极其明显。这就是慢 SQL 优化的核心套路:先定位慢的 SQL,再看 EXPLAIN 的执行计划,然后针对性加索引或改写 SQL。索引也不是加得越多越好,每个索引都会拖慢写入速度,只给高频查询的 WHERE、JOIN、ORDER BY 字段加索引就够了。所谓优化,不是把所有能加的都加上,而是把钱花在刀刃上。
EXPLAIN输出里重点看三个字段:type(访问类型)、key(实际使用的索引)、rows(预估扫描行数)。type从高到低有 system > const > eq_ref > ref > range > index > ALL,看到 ALL 就基本说明全表扫描了,这是一个非常直观的警示信号。
5. 学习复盘与我的几个习惯
写到最后,分享几个我实际干活这几年沉淀下来的小习惯。
建库的习惯是:每个环境保持相同的字符集和排序规则。开发库、测试库、生产库,只要这三者的字符集不一致,早晚会出一次数据问题,而且问题特别隐蔽——本地正常,线上乱码,排查起来极度耗费时间。统一用 utf8mb4 后,这种问题直接绝迹。
跑 SQL 的习惯是:在事务里做有风险的写操作,并且养成操作前先备份、操作后验证结果的习惯。比如 UPDATE 之后我会立刻写一条 SELECT 确认关键数据确实变成预期值,而不是改完就跑。不是每次都需要,但大操作和关键库的小操作都要保持这个意识。
学习 SQL 的习惯是:别背语法,背思路。SQL 语法是有限的,工作里遇到不会的语法,随手查一下手册或者看别人写的案例就懂了。真正值钱的是思路——比如去重用窗口函数、防止库存超卖时用条件更新、加索引前先用 EXPLAIN 看执行计划。语法可以现查,思路需要反复沉淀。这篇笔记就是我用一条条实操中的选择沉淀出来的,希望对刚进入 MySQL 世界的同学有点帮助。