提起MySQL,很多刚入行的同学首先接触的就是CRUD、约束和基本类型。这三个词听起来基础,却几乎决定了你后续写SQL的质量和数据库整体的“健康度”。CRUD对应增删改查,是业务操作的主体;约束负责守住数据的完整性,防止脏数据混进来;基本类型则决定了字段能存什么、怎么存、占多少空间。三者合在一起,就是一张表从设计到日常使用的完整框架。这篇文章适合那些已经会写简单SELECT、正准备系统补齐数据库基础的朋友,也适合干了几年业务、想回头把地基夯实的开发者。我会直接从实际项目视角出发,把概念、语法、选型思路和踩坑点一起讲透。
1. 从一张学生表说起:CRUD、约束、类型各自扮演什么角色
1.1 三个基础概念之间的关系
很多人以为CRUD、约束、类型是三个独立的知识点,实际它们在设计表结构时是一体的。举个例子,你要设计一张学生表,得先想清楚学号用数值还是字符串来存——这是类型问题;学号不能为空、不能重复——这是约束问题;把学生信息写入、修改、查询出来——这是CRUD问题。
可以这样类比:类型是“容器”的规格,约束是“容器”的规则,CRUD是“容器”里的日常搬砖。没有类型的字段,数据存进去就是一团乱麻;没有约束的表,重复、空值、不合法的数据会悄悄溜进来;而CRUD则是你每天都要重复的流程。三者并不是平行的三条线,而是层层嵌套的关系:建表时定好类型和约束,之后CRUD都会遵守这套“合同”。
1.2 为什么新手常在这块栽跟头
我见过很多同学,写CRUD非常熟练,但一让他建表就露怯。最常见的问题有三个。
一是类型乱选。明明存一个年龄,可以用TINYINT,偏要用BIGINT,白白浪费空间;存手机号,有人用INT,结果溢出加报错,最后不得不改成VARCHAR。类型选错,轻则浪费存储,重则直接丢精度或者插入失败。
二是约束缺失。很多业务表连主键都不设,或者所有字段都允许NULL,导致同一批数据出现多条完全相同的重复记录,程序里统计数字一塌糊涂。外键约束更是被很多人直接放弃,结果子表里的记录指向一个不存在的父记录,报表联查全靠碰运气。
三是不理解CRUD背后的代价。DELETE一下能删得很爽,但忘带WHERE会把整张表清空;UPDATE不带条件会全表更新;INSERT大批量数据不注意效率,事务大到把连接拖死。这些坑都不是语法问题,而是对机制理解不深。
2. MySQL基本数据类型:选型决定成败
2.1 数值型:INT、DECIMAL、FLOAT各管一段
MySQL数值类型从TINYINT、SMALLINT到INT、BIGINT,区别主要在于存储字节和取值范围。TINYINT只占1字节,范围是-128到127;INT占4字节,范围约正负21亿;BIGINT占8字节,常用于雪花ID或订单号。业务里用INT存用户ID、状态码、计数等都没问题,但要注意“无符号”和“自增”的搭配。
浮点和定点是另一个重点。FLOAT和DOUBLE属于浮点类型,存储小数时会有精度损失。比如存金额0.1,反复计算后可能变成0.100000000001。如果你要给商品价格、账户余额算钱,永远用DECIMAL。DECIMAL是定点数,会精确保存数字,比如DECIMAL(10,2)表示总长度10位、小数2位,适合金额、税率等场景。
实际项目里,我通常这样选:状态、年龄、计数用TINYINT或INT;主键用BIGINT或者INT自增;金额一律DECIMAL(10,2)或DECIMAL(12,2),后面要算报表才放心。
2.2 字符串型:CHAR、VARCHAR、TEXT别混用
CHAR是指定固定长度的字符串,比如CHAR(10)存5个字符,也会占10个字符的空间,但读取速度快。VARCHAR是可变长度,存多少占多少,最多还占用额外的长度标记空间,适合长度差异大的场景。TEXT用来存大文本,比如文章正文、备注。
一个常见误区是,很多人以为VARCHAR(255)是最大值,其实在MySQL 5.7以后,VARCHAR长度可以很大,但受行大小限制。一般来说,短号码、固定编码用CHAR;用户名、邮箱、地址用VARCHAR;超过几千字符的大段文本用TEXT。要注意VARCHAR括号里的是字符数而不是字节数,如果一个字段存中文,VARCHAR(20)可以存20个汉字,但若存的是一串英文,也能存20个,实际占用字节跟字符集有关。
这里有一个实战提醒:不要把手机号存成数值类型。手机号是取值范围巨大的号码,用INT会溢出,用BIGINT虽然不溢出,但你没法保存前导零,将来做手机号段匹配也会非常别扭。正确做法是用VARCHAR(20)。
2.3 日期时间型:DATETIME、TIMESTAMP、DATE选型
MySQL的日期时间类型有DATE(年月日)、TIME(时分秒)、DATETIME(年月日时分秒)和TIMESTAMP(时间戳)。DATE适合生日、节日;DATETIME适合记录创建时间、更新时间;TIMESTAMP实际存储的是UTC时间戳,存储范围到2038年,比较适合跨时区场景。
很多人纠结DATETIME和TIMESTAMP。DATETIME不带时区信息,存进去是什么就是什么;TIMESTAMP会自动根据会话时区转换,适合全球业务,但它的取值范围小。我平时做业务系统,默认用DATETIME,除非明确要做时区转换才用TIMESTAMP。创建时间和更新时间建议由数据库自动维护,MySQL 5.6之后可以用DEFAULT CURRENT_TIMESTAMP和ON UPDATE CURRENT_TIMESTAMP,省去应用层赋值。
2.4 类型选择的常见误区与最佳实践
第一个误区是“什么字段都用VARCHAR”。VARCHAR很方便,但索引效率低于定长类型,数字的比较也是数值型更快。第二是使用BOOL类型。MySQL并没有真正的BOOL,它用TINYINT(1)代替,赋值时0是假,非0是真。这个可以接受,但不要被“BOOL是独立类型”误导。
最佳实践可以总结成一句话:能用整型绝不用字符串,能用定长绝不用变长,能用日期类型绝不用字符串硬凑。日期用字符串存是大忌,排序会按字典序排错,也无法使用日期函数。另外,字段长度不要贪大,VARCHAR(255)只是个通用选择,如果你的业务里邮箱通常不超过50位,那就设为VARCHAR(64)或VARCHAR(100),为索引留出更多余地。
3. 约束体系:撑起数据完整性的骨架
3.1 主键约束与自增:每一行数据的身份证
主键(PRIMARY KEY)是唯一标识一行记录的字段,它自动包含非空和唯一两个属性。一个表只能有一个主键,但主键可以包含多个字段,这叫联合主键。实际业务中,我最推荐用无业务含义的自增主键,即INT或BIGINT配合AUTO_INCREMENT,因为自增主键插入有序、索引树稳定。
主键不要选可变长业务字段,比如身份证号、手机号,因为一旦业务变动要更新这些字段会很痛苦。若确实需要业务唯一键,可以在主键之外再加一个唯一索引。主键一旦建立,就不要轻易修改,因为它关系到所有外键引用和索引结构。
3.2 非空约束与默认值:给数据入口装闸门
非空约束(NOT NULL)规定字段不能为空,默认值(DEFAULT)则在不传值时给出一个兜底值。两者看似简单,却是防止脏数据的第一道防线。
我在设计表的时候,凡是核心业务字段,比如用户名、订单金额、状态,都尽量NOT NULL。注意,VARCHAR字符串的默认值常用空字符串'',而不是NULL,因为NULL参与计算时往往带来麻烦,比如SUM函数会忽略NULL。日期字段如果允许为空,访问层也要做好空值判断。
还有一个小技巧:建表时给数值类型设置默认0、枚举状态设置默认初始值,给时间字段设置默认当前时间,这样应用层少传很多不必要的参数。
3.3 唯一约束与业务逻辑
唯一约束(UNIQUE)能够保证字段或字段组合在表里不重复,但允许NULL(NULL不参与唯一性判断)。它的典型应用包括用户名、邮箱、订单流水号等。如果要保证两个字段组合不重复,可以直接在建表时创建联合唯一索引。
在业务里,唯一约束不只是防呆,更是数据一致性的兜底。比如你有一个领取优惠券的表,同一个用户对同一个优惠券只能领取一次,此时在user_id和coupon_id上建联合唯一索引,最安全。如果只靠应用层先查后插,在高并发下很容易出现重复记录,数据库端唯一约束才是真正的最终防线。
3.4 外键约束:关联表之间的行业规矩
外键(FOREIGN KEY)用于建立两张表之间的引用关系。比如订单表里有一个user_id,应关联用户表的id。有了外键,数据库会强制要求插入的user_id必须真实存在于用户表中,不能插入孤立数据;同时还能对父表删除、更新时做出限制,比如不允许删除仍然被订单引用的用户。
不过在互联网行业,很多人会刻意不用外键。原因是高并发写入时,外键约束检查会影响性能,还可能造成级联操作的不可控。取而代之的是在应用层保证业务完整性,表之间只保持逻辑关联。这个选择本身无所谓对错,但你要知道:如果不建外键,就必须在代码里补齐一致性的检查;如果建了外键,就要承担对应的性能与维护成本。
3.5 检查约束与MySQL的现状
严格来说,MySQL从8.0.16版本开始,才真正支持了受强制执行的CHECK约束。在8.0.16之前,CHECK约束在语法上允许,却不被强制执行,很多人建了等于没建。虽然现在可以用CHECK来做字段取值范围的校验,比如年龄必须在0到150之间,但我仍然建议把复杂的业务校验放在应用层,数据库里的CHECK适合简单、稳定的规则。
4. CRUD实操:从建库建表到删改查的完整闭环
4.1 建库建表时的细节:字符集、引擎和字段顺序
建库建表是整个CRUD的起点,也是最容易埋雷的地方。建议第一步就明确字符集:
CREATE DATABASE IF NOT EXISTS demo_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;为什么用utf8mb4?因为utf8在MySQL里最多只能存3字节的字符,很多生僻字和Emoji存不进去,而utf8mb4是全量支持。排序规则选择utf8mb4_unicode_ci,对大多数中英文业务都够用。
引擎方面,InnoDB是默认选择,它支持事务、行锁、外键,适合绝大多数OLTP场景。如果你的表只用来做日志、且不关心事务,可以考虑MyISAM,但在MySQL 8.0中,MyISAM已经一步步边缘化了。所以我的习惯是无脑InnoDB。
建表时建议把主键放第一列,然后按字段的取用频率和大小合理排布。InnoDB的行存储在磁盘上的顺序并不完全依赖列顺序,但主键和常用索引字段前置会让查询计划更清晰。
4.2 增:INSERT的几种写法与批量插入
INSERT的核心是把数据写入表。最简单的写法是指定列名,然后列出值。
INSERT INTO user (name, age, email) VALUES ('张三', 18, 'zhangsan@example.com');这里强烈建议总是显式指定列名,而不是省略列名直接写VALUES。否则一旦表结构加了字段,你的INSERT语句就会报错或错位。
如果需要同时插入多条记录,可以一条语句写多个VALUES:
INSERT INTO user (name, age, email) VALUES ('李四', 20, 'lisi@example.com'), ('王五', 22, 'wangwu@example.com');这种方式比一条条执行要快很多,因为它只发起一次网络往返。批量插入的规模也不是越大越好,一般建议每次几百行到几千行,具体取决于单行大小。如果数据来自文件或者异构系统,优先用LOAD DATA INFILE,更快。
4.3 改:UPDATE的语法与最容易踩的坑
UPDATE用于修改已有记录:
UPDATE user SET age = 19 WHERE id = 1;所有的教训里,最惨烈的一条是漏掉WHERE。一旦漏了,整张表的数据都会被更新。而MySQL在高版本里默认不开启“安全更新模式”,也就是没有WHERE的UPDATE和DELETE都能直接执行。我经常建议团队把我方环境的安全参数打开,也可以在客户端工具里设置语句必须带条件。
另外,UPDATE时涉及多个字段,用SET同时更新比逐条更新效率高。同时要注意,如果字段上有索引,频繁更新索引字段会带来额外开销。实际业务中,更新前最好先SELECT确认条件能命中正确记录,再执行UPDATE。
4.4 删:DELETE、TRUNCATE、DROP三兄弟的区别
DELETE是逐行删除,可加WHERE条件,可以配合事务回滚。执行后自增ID不会重置,默认情况下不会释放表存储空间。
DELETE FROM user WHERE id = 100;TRUNCATE是清空表数据,一次性释放存储,不能加WHERE,也不能回滚。DELETE删除千万行时,事务日志和回滚段压力很大,而TRUNCATE几乎瞬间完成。如果你的需求是彻底清空表,TRUNCATE可快得多。
DROP是删除整张表结构,数据、索引、约束全部消失。三者使用场景完全不同,测试环境随便用,生产环境一定要事先备份。
安全删除的习惯是:先SELECT看命中行数,再改成DELETE。或者写DELETE语句时,先把WHERE条件单独写在最前面,反复确认后再补充DELETE开头。
4.5 查:SELECT的基础、条件、排序与分页
SELECT是CRUD里最常用的,也最值得打磨。基础语法很容易,但性能取决于你是否会用索引、是否只查询必要的字段。
SELECT id, name, age, email FROM user WHERE age BETWEEN 18 AND 30 AND name LIKE '张%' ORDER BY age DESC LIMIT 20 OFFSET 40;这里LIKE左边不是%,才可以利用索引;ORDER BY排序字段尽量与WHERE条件中的索引方向一致;LIMIT/OFFSET是分页的老搭档,但OFFSET太大时会扫描大量偏移行。比如第1000000页用LIMIT 20 OFFSET 19999980,会非常慢。这时可以用“上一页最后一条记录的id”来做定位:
SELECT id, name, age, email FROM user WHERE id > 1000000 ORDER BY id ASC LIMIT 20;还有一点,SELECT *在业务中要尽量避免。字段过多会增加网络传输和底层行拷贝的成本,也容易由于字段顺序变动导致程序错乱。
5. 实战案例:图书管理系统的核心表操作
5.1 需求分析与建表SQL
假设我们要为一个非常简单的图书管理系统设计核心表。业务背景是:读者可以借书还书,一本书可以被多次借阅,需要记录每次借阅流水。为了体现类型和约束,这里建三张表:读者表、图书表、借阅流水表。
先建读者表:
CREATE TABLE reader ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, card_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, phone VARCHAR(20) NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT '1-正常,0-停用', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;card_no是借阅卡号,要求业务唯一,所以用UNIQUE。status用TINYINT节省空间,并加NOT NULL以及DEFAULT。created_at直接用数据库当前时间。
图书表:
CREATE TABLE book ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, isbn VARCHAR(20) NOT NULL, title VARCHAR(100) NOT NULL, author VARCHAR(50) NOT NULL, price DECIMAL(10,2) NOT NULL DEFAULT 0.00, stock INT UNSIGNED NOT NULL DEFAULT 0, UNIQUE KEY uk_isbn_title (isbn, title) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里isbn虽然业务上应该唯一,但同一本书的不同版本可能ISBN相同,所以我们用isbn+title做联合唯一约束,防止完全重复的图书记录。price用DECIMAL,存金额绝对不用FLOAT。
借阅流水表:
CREATE TABLE borrow_record ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, book_id BIGINT UNSIGNED NOT NULL, reader_id BIGINT UNSIGNED NOT NULL, borrow_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, return_time DATETIME DEFAULT NULL, FOREIGN KEY (book_id) REFERENCES book(id), FOREIGN KEY (reader_id) REFERENCES reader(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有意保留外键约束,用来展示关联关系。return_time默认NULL,表示未归还。
5.2 完整CRUD流程演示
新增读者:
INSERT INTO reader (card_no, name, phone) VALUES ('R0001', '小明', '13800000000');新增图书:
INSERT INTO book (isbn, title, author, price, stock) VALUES ('9780000000001', 'MySQL入门', '张三作者', 59.00, 3);查询某本书的库存:
SELECT id, title, stock FROM book WHERE id = 1;插入借阅记录时,同时扣减库存:
INSERT INTO borrow_record (book_id, reader_id) VALUES (1, 1); UPDATE book SET stock = stock - 1 WHERE id = 1 AND stock > 0;这里用stock > 0作为条件,可以在高并发下避免超卖。真正生产环境还需要结合事务和行锁。
还书时更新归还时间,并回补库存:
UPDATE borrow_record SET return_time = NOW() WHERE id = 1 AND return_time IS NULL; UPDATE book SET stock = stock + 1 WHERE id = 1;5.3 约束如何在实际操作中生效
我们刚才建的表已经设置了联合唯一约束,现在尝试重复插入同样的记录:
INSERT INTO book (isbn, title, author, price, stock) VALUES ('9780000000001', 'MySQL入门', '张三作者', 59.00, 1);数据库会返回类似“Duplicate entry”的错误,这就避免了重复数据。再尝试插入一个不存在的借阅记录,比如book_id=999:
INSERT INTO borrow_record (book_id, reader_id) VALUES (999, 1);由于外键约束,数据库会报“Cannot add or update a child row”错误。如果你在设计时放弃了外键,那么程序里必须在插入前主动确认book_id和reader_id都存在,否则就会产生脏数据。
6. 常见问题与排查技巧实录
6.1 乱码问题:字符集惹的祸
乱码几乎是每个MySQL新手都会遇到的。表现是数据看起来全是问号或火星文。原因往往出在三层:数据库/表字符集、连接字符集、程序中的字符串编码。
排查思路很简单:先执行
SHOW VARIABLES LIKE 'character_set_%';确认客户端连接字符集是否为utf8mb4。再确认表结构与列字符集。
如果是已有表,优先改表和列字符集,但已存在的乱码数据可能无法自动恢复。新项目请从一开始就统一建库默认字符集为utf8mb4,连接串里也加上characterEncoding=utf8。这个细节能帮你省下大量排查时间。
6.2 类型转换与隐式规则
MySQL在比较不同数据类型时会发生隐式转换,最常见的是字符串和数值比较。比如:
SELECT * FROM user WHERE phone = 13800000000;phone是VARCHAR,但这里会用数字去匹配。如果phone里有非数字字符,匹配规则会变得很诡异;若字段上建有索引,隐式转换常常会导致索引失效。原因在于当字段类型是VARCHAR而比较值是数字时,MySQL会把字段值转换为数字进行比较,这等于对每一行都做一次转换,无法使用索引。
正确做法是查询条件里的类型与字段类型保持一致:字符串就用引号,数值就不加引号。这个习惯比任何技巧都重要。
6.3 外键约束失败的原因分析
外键约束失败通常有几种情况:父表里没有对应主键、外键字段和引用字段的类型或字符集不一致、插入的子表记录违反外键指约束、父表删除时存在子表引用但外键动作设为了RESTRICT。
排查时先看表结构:
SHOW CREATE TABLE borrow_record;对比外键字段的类型、字符集和父表主键类型是否严格一致。例如子表外键是BIGINT,父表主键是INT,就会报错。另外,如果父表和子表某个字段的字符集不同,也会无法创建外键。解决方法是统一字符集排序规则。
生产中如果遇到外键被阻塞,比如外键检查需要等待相关行锁释放,可使用
SHOW ENGINE INNODB STATUS;查看锁等待信息。
6.4 一步步排查SQL语句的调试方法
写出SQL后报错,不要急着来回改。先在执行计划里看问题:
EXPLAIN SELECT ... ;EXPLAIN会返回type、key、rows等关键字段。如果type是ALL,说明走了全表扫描;如果key为NULL,说明没用上索引。此时检查WHERE条件里的字段是否有索引,或者条件写法是否破坏了索引。
如果语句本身没问题,但执行很慢,可以开启慢查询日志,找出哪些查询成本高。日志里记录执行时间、扫描行数和返回行数,再结合EXPLAIN优化。整个过程的核心是先定位再改,而不是凭感觉加索引。
6.5 一些可以提高效率的小习惯
最后分享几个我多年养成的习惯。
第一,建表时把所有常用查询字段的索引想明白,但不要盲目给每个字段都建索引,写操作会受影响。
第二,日期字段建议都用datetime类型,不要用int存时间戳,可读性太差,查慢日志时非常痛苦。
第三,所有线上删除操作前,先备份。简单的办法是先执行一句话把数据导出到一张临时表,或者用CREATE TABLE xxx_bak AS SELECT * FROM ... WHERE ...快速备份。
第四,SQL关键字一律大写,字段名一律小写加下划线,能极大提升可读性。团队协作时,代码评审的人扫一眼就能看清结构。
我在实际排查问题中最大的体会是:绝大多数故障都不是语法不会,而是设计时没想清楚类型和约束,或者忘了WHERE条件。如果你能把每一张表的字段都认真设计一遍,把每条DELETE和UPDATE都习惯性带上条件,数据库这块的底子就算稳了。后面有时间,可以再深入聊聊索引优化和事务隔离级别,那又是另一个值得写透的话题。