码上问学问代码之理,学编程之道

第 7 章 · 表结构、约束与索引

前面几章的查询、增删改,都是在「别人设计好的表」上操作。这一章我们换个身份——自己当设计者:用 CREATE TABLE 决定一张表有哪些列、每列什么类型,用约束把「不许为空、不许重复、不许乱填」写进表本身,用索引让查询从「翻遍全书」变成「查目录」。看懂 hiwen.sql 里那张 article 表,你就具备了独立建库的能力。

CREATE TABLE:把表结构写出来

拆解 article 表
CREATE TABLE 的读法:一列一个「字段名 + 类型 + 约束」三件套CREATE TABLE article (idINTPRIMARY KEY + AUTO_INCREMENT主键,自动编号titleVARCHAR(150)NOT NULL作品名,必填,最长 150 字contentTEXT正文,长文本category_idINTFOREIGN KEY → category(id)外键:属于哪个分类viewsINTDEFAULT 0浏览量,不填按 0created_atDATETIMEDEFAULT CURRENT_TIMESTAMP创建时间,自动填);选型口诀:整数 INT · 短文本 VARCHAR(n) · 长文本 TEXT · 金额 DECIMAL · 时间 DATETIME
先讲为什么建表就像盖房子先立框架:哪几根柱子(列)、每根多粗(类型)、哪里必须承重(约束),都得先定死。定好了,后面所有数据的进进出出都按这个框架走;定歪了,后期改起来要动筋骨。
好消息是——你已经用过的那张 article 表,本身就是一份教科书级的范例。读懂它,比看十篇教程都管用。
动手做下面这段摘自 hiwen.sql 的 article 建表语句。先不急着敲,从第一行读到最后一行,对照后面的「讲透」逐段理解。
hiwen.sql(节选)sql
CREATE TABLE `article` (
  `id`           INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '作品ID', -- ① 主键自增
  `title`        VARCHAR(150) NOT NULL                COMMENT '标题', -- ② 必填、变长
  `author`       VARCHAR(50)      DEFAULT NULL        COMMENT '作者(如 李白)',
  `dynasty`      VARCHAR(20)      DEFAULT NULL        COMMENT '朝代(如 唐)',
  `category_id`  INT UNSIGNED     DEFAULT NULL        COMMENT '所属分类', -- ③ 外键列
  `cover`        VARCHAR(255)     DEFAULT ''          COMMENT '封面图路径',
  `summary`      VARCHAR(500)     DEFAULT NULL        COMMENT '内容摘要',
  `content`      TEXT                                 COMMENT '正文', -- ④ 长文本
  `views`        INT UNSIGNED NOT NULL DEFAULT 0      COMMENT '浏览量', -- ⑤ 计数、默认 0
  `likes`        INT UNSIGNED NOT NULL DEFAULT 0      COMMENT '点赞数',
  `is_recommend` TINYINT      NOT NULL DEFAULT 0      COMMENT '1首页推荐 0普通', -- ⑥ 布尔式开关
  `status`       TINYINT      NOT NULL DEFAULT 1      COMMENT '1已发布 0草稿',
  `created_at`   DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP, -- ⑦ 自动记时
  `updated_at`   DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`), -- ⑧ 表级约束
  KEY `idx_article_category` (`category_id`), -- ⑨ 普通索引
  KEY `idx_article_recommend` (`is_recommend`, `status`), -- ⑩ 联合索引
  FULLTEXT KEY `ft_article_title_summary` (`title`, `summary`), -- ⑪ 全文索引
  CONSTRAINT `fk_article_category` FOREIGN KEY (`category_id`) -- ⑫ 外键
    REFERENCES `category` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文学作品表'; -- ⑬ 引擎与字符集
讲透:一条 CREATE TABLE 分三段
  • 第一段:列定义(①–⑦)。每行格式是 `列名` 类型 [NOT NULL] [DEFAULT 默认值] [COMMENT '说明']。列名用反引号包起来(避免和关键字撞名),注释写在行尾的 COMMENT 里——别人一 DESC 表就能看到,比写在文档里靠谱。
  • 第二段:表级约束与索引(⑧–⑫)。PRIMARY KEY 定主键;KEY 建索引;CONSTRAINT … FOREIGN KEY 建外键。
  • 第三段:引擎与字符集(⑬)。ENGINE=InnoDB——InnoDB 支持事务和外键,是 MySQL 8 的默认引擎,别写成 MyISAM(它不支持外键)。CHARSET=utf8mb4——能存下所有汉字和 emoji,别用 utf8(它其实是阉割版)。
  • 三条「通用铁律」:① 每张表都要有主键;② 建表和建库的字符集都写 utf8mb4;③ 每列都写 COMMENT。照着 hiwen.sql 抄,不会错。

常用数据类型怎么选

够用就好,别乱堆
先讲为什么类型选错,短期看不出问题,长期全是坑:VARCHAR(5) 存不下长标题;用 FLOAT 存金额会出现 19.99 变成 19.989999;用 TEXT 存 3 个字的分类名,纯属浪费。选型的原则只有一句:按业务真实需要,够用就行。
动手做建一张把常用类型都用上的演示表,再用 DESC 看它的最终结构。
常用类型.sqlsql可运行
CREATE TABLE `demo_book` (
  `id`       INT UNSIGNED  NOT NULL AUTO_INCREMENT COMMENT '主键',
  `title`    VARCHAR(150)  NOT NULL                COMMENT '书名',
  `isbn`     VARCHAR(20)   DEFAULT NULL            COMMENT 'ISBN',
  `price`    DECIMAL(10,2) NOT NULL DEFAULT 0.00   COMMENT '定价(元)',
  `stock`    INT UNSIGNED  NOT NULL DEFAULT 0      COMMENT '库存',
  `intro`    TEXT                                  COMMENT '简介',
  `genre`    ENUM('文学','历史','科技') NOT NULL DEFAULT '文学' COMMENT '分类',
  `added_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '入库时间',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='演示:图书表';

DESC `demo_book`;
查询结果SQL 执行结果
运行结果: Field | Type | Null | Key | Default | Extra id | int unsigned | NO | PRI | NULL | auto_increment title | varchar(150) | NO | | NULL | isbn | varchar(20) | YES | | NULL | price | decimal(10,2) | NO | | 0.00 | stock | int unsigned | NO | | 0 | intro | text | YES | | NULL | genre | enum('文学','历史','科技') | NO | | 文学 | added_at | datetime | NO | | CURRENT_TIMESTAMP | DEFAULT_GENERATED -- 共 8 行
讲透:七种类型,一张对照表
  • INT:4 字节整数,够存 id、浏览量、点赞数。加 UNSIGNED 表示非负数(上限从 21 亿涨到 42 亿)。
  • TINYINT:1 字节,范围 −128~127(UNSIGNED 是 0~255)。存状态、开关最合适,像 status(1/0)、gender(0/1/2)、is_recommend。
  • VARCHAR(n):变长字符串,n 是「上限」而不是「固定占用」——存「静夜思」只占 3 个字的长度。长度按业务上限估:标题 150、作者 50、邮箱 100、手机号 20。
  • TEXT:长文本(最长约 64KB),不能设默认值。作品正文 content 用它;但几百字以内、要建索引的字段,还是用 VARCHAR。
  • DATETIME:日期时间,格式 YYYY-MM-DD HH:MM:SS,不受时区设置影响(本库统一用它)。
  • DECIMAL(m,d):精确小数,m 是总位数、d 是小数位。金额、评分一定用它,绝不用 FLOAT/DOUBLE——浮点数有精度误差。
  • ENUM('a','b'):枚举,值只能是列表里的某一个。适合固定选项(user_like.target_type 的 'article' / 'comment')。缺点是改选项要 ALTER 表,选项会变就别用它。
避坑两个高频错误:① VARCHAR 长度给太小——MySQL 8 默认是「严格模式」,写入超长字符串会直接报 Data too long for column 'title' 并整条语句失败(而不是悄悄截断);② 给 TEXT 列设默认值——MySQL 会报 BLOB, TEXT, GEOMETRY or JSON column 'intro' can't have a default value。一句话:长度宁可留点余量,长文本别指望默认值。

约束:把规矩写进表里

PK / NOT NULL / DEFAULT / UNIQUE / CHECK
先讲为什么「用户名不能重复」「年龄不能是负数」——这些规则如果只写在 Python 代码里,那么用 Navicat 手动插数据、或者将来另一个程序员写了新接口,规则就形同虚设。最靠得住的办法,是把规矩写进表结构本身:谁来插数据都得守,越界直接报错。这就是约束的价值——它是数据的最后一道防线。
动手做建一张把五种约束都用上的表,先插一条合法的,看看默认值是怎么自动填上的。
约束示例.sqlsql可运行
CREATE TABLE `demo_member` (
  `id`       INT UNSIGNED  NOT NULL AUTO_INCREMENT COMMENT '主键自增',
  `username` VARCHAR(50)   NOT NULL                COMMENT '登录名,唯一',
  `email`    VARCHAR(100)  DEFAULT NULL            COMMENT '邮箱,唯一',
  `age`      TINYINT UNSIGNED NOT NULL DEFAULT 0   COMMENT '年龄',
  `balance`  DECIMAL(10,2) NOT NULL DEFAULT 0.00   COMMENT '余额',
  `nickname` VARCHAR(50)   NOT NULL DEFAULT '书友' COMMENT '昵称',
  `status`   TINYINT       NOT NULL DEFAULT 1      COMMENT '1正常 0禁用',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_demo_member_username` (`username`),
  UNIQUE KEY `uk_demo_member_email` (`email`),
  CONSTRAINT `chk_demo_member_age`     CHECK (`age` <= 150),
  CONSTRAINT `chk_demo_member_balance` CHECK (`balance` >= 0)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='演示:约束示例表';

-- 只给必填列,其余自动走默认值
INSERT INTO `demo_member` (`username`, `email`) VALUES ('zhangsan', 'zs@hiwen.local');
SELECT id, username, email, age, balance, nickname, status FROM `demo_member`;
查询结果SQL 执行结果
运行结果: id | username | email | age | balance | nickname | status 1 | zhangsan | zs@hiwen.local | 0 | 0.00 | 书友 | 1 -- 共 1 行(age、balance、nickname、status 都是默认值)
动手做故意违反约束,看看数据库怎么「拦下」你。
违反约束.sqlsql可运行
-- ① 违反 UNIQUE:username 重复
INSERT INTO `demo_member` (`username`) VALUES ('zhangsan');

-- ② 违反 CHECK:年龄 200 > 150
INSERT INTO `demo_member` (`username`, `age`) VALUES ('lisi', 200);

-- ③ 违反 CHECK:余额为负
INSERT INTO `demo_member` (`username`, `balance`) VALUES ('wangwu', -10);
查询结果SQL 执行结果
运行结果: -- ① ERROR 1062 (23000): Duplicate entry 'zhangsan' for key 'demo_member.uk_demo_member_username' -- ② ERROR 3819 (HY000): Check constraint 'chk_demo_member_age' is violated. -- ③ ERROR 3819 (HY000): Check constraint 'chk_demo_member_balance' is violated.
讲透:六种约束,逐个说清
  • PRIMARY KEY(主键):唯一 + 非空,一张表只能有一个。它既是唯一标识,也决定了数据在磁盘上的物理排列(InnoDB 的聚簇索引)——所以主键要「短、稳定、不变」,本库统一用自增 id。
  • FOREIGN KEY(外键):指向另一张表的主键,保证「引用的东西真实存在」。article.category_id 只能填 category 里已有的 id,下一节细讲。
  • UNIQUE(唯一):值不许重复,但允许为 NULL(而且多个 NULL 不算重复)。username、email 用它,favorite 表的 (user_id, article_id) 用的是联合唯一——两个字段的组合不许重复,这就是「同一用户对同一作品只能收藏一次」的实现。
  • NOT NULL(非空):这一列必须有值。标题、密码这类「缺了业务就不成立」的字段要加。
  • DEFAULT(默认值):插入时不写这列就用默认值。它让 INSERT 语句更短——你只需要给必填列。
  • CHECK(检查):限定取值范围,如年龄 ≤ 150、余额 ≥ 0。MySQL 8.0.16 以后才真正生效(旧版本只是「语法上接受、实际不检查」)。报错码是 3819。
避坑别把 UNIQUE 当成 NOT NULL:唯一列可以有多个 NULL(在 MySQL 里 NULL ≠ NULL)。所以 email 这种「可填可不填、填了就不能重复」的列,写成 UNIQUE 是正确的;但如果你想「必须填且不能重复」,得写成 UNIQUE + NOT NULL 两个一起加。另外,接口层仍然要做校验——约束是兜底,不是唯一防线,否则用户看到的是「数据库报错」而不是「友好的提示」。

外键与级联:删一张表,另一张怎么办

ON DELETE CASCADE / SET NULL
外键级联:删除被引用的行时,ON DELETE 决定「跟着的怎么办」category(被引用)id=3 「唐诗」article(引用方)category_id=3 的 3 行作品category_id 指过来DELETE category WHERE id=3 时CASCADE 级联删除3 篇作品跟着一起删危险,慎用SET NULL 置空作品留下,category_id 变 NULL常用RESTRICT 拒绝直接报错,不让删最安全本项目选择:分类被删 → 作品的 category_id 置空(作品是无价的内容,不能陪葬)
先讲为什么删掉一个分类,它下面的作品怎么办?删掉一篇作品,它的书评怎么办?如果不提前约定,就会出现「孤儿数据」——作品指向一个已经不存在的分类 id,或者一堆书评挂在一篇没有的作品上。
外键的级联(CASCADE)就是让你在建表时一次性回答这个问题:删除父行时,子行是跟着删、还是置空、还是干脆不许删。
动手做先看本库真实的外键是怎么写的(摘自 hiwen.sql),再动手验证两种级联行为。注意 article 用的是 SET NULL,comment 用的是 CASCADE——两种都出现了。
hiwen.sql(外键节选)sql
-- ① 作品 → 分类:删掉分类时,把所有相关作品的 category_id 置为 NULL(保留作品)
CONSTRAINT `fk_article_category` FOREIGN KEY (`category_id`)
  REFERENCES `category` (`id`) ON DELETE SET NULL ON UPDATE CASCADE

-- ② 书评 → 作品:删掉作品时,把它的书评一起删掉
CONSTRAINT `fk_comment_article`  FOREIGN KEY (`article_id`)
  REFERENCES `article` (`id`) ON DELETE CASCADE

-- ③ 书评 → 父书评(自关联也能级联):删掉父评论,回复一起删
CONSTRAINT `fk_comment_parent`   FOREIGN KEY (`parent_id`)
  REFERENCES `comment` (`id`) ON DELETE CASCADE
验证 ON DELETE SET NULL.sqlsql可运行
-- 删掉「散文」分类(id = 4),看《陋室铭》会怎样
START TRANSACTION;

DELETE FROM `category` WHERE `id` = 4;

SELECT id, title, category_id FROM `article` ORDER BY id;

ROLLBACK; -- 演示完还原
查询结果SQL 执行结果
运行结果: id | title | category_id 1 | 静夜思 | 1 2 | 春望 | 1 3 | 念奴娇·赤壁怀古 | 2 4 | 声声慢 | 2 5 | 天净沙·秋思 | 3 6 | 陋室铭 | NULL -- 共 6 行(《陋室铭》没被删,只是没了分类)
验证 ON DELETE CASCADE.sqlsql可运行
-- 删掉作品 1《静夜思》,它的书评会怎样?
START TRANSACTION;

DELETE FROM `article` WHERE `id` = 1;

-- 种子里作品 1 有 2 条书评(id 1、2)
SELECT COUNT(*) AS 剩余书评 FROM `comment` WHERE `article_id` = 1;

ROLLBACK; -- 演示完还原
查询结果SQL 执行结果
运行结果: 剩余书评 0 -- 共 1 行(2 条书评被 CASCADE 一起删掉了)
讲透
  • ON DELETE CASCADE(跟着删):父行没了,子行也一并删除。适合「子数据离开父数据就没有意义」的场景——书评离开作品毫无意义,所以用 CASCADE。
  • ON DELETE SET NULL(置空):父行没了,子行的外键列被设为 NULL,但子行本身保留。适合「子数据仍然独立存在」的场景——作品不该因为分类被删就消失,所以 category_id 置空即可。
  • 还有两种:ON DELETE RESTRICT(默认,有子数据就不许删,直接报错)和 ON DELETE NO ACTION(行为同 RESTRICT)。「绝对不能丢数据的表」用它最稳妥。
  • ON UPDATE CASCADE 管的是「父表主键被改」。本库 category.id 是自增的、几乎不会改,所以这条只是防御性写法。
  • 怎么选:问自己一句「父数据没了,子数据还算独立的东西吗?」——算,就 SET NULL;不算,就 CASCADE;不能确定,就用默认的 RESTRICT 拦住。
避坑CASCADE 是个「连锁反应」:删一篇作品 → 删它所有书评 → 再删这些书评下的所有回复(因为 fk_comment_parent 也是 CASCADE)。你以为删了 1 行,实际可能删掉几百行。所以:① 生产环境删主表数据前,先 SELECT 查清会连带影响多少行;② 能用「软删除」(改 status)就别用真删;③ 建外键时想清楚级联策略,别一律 CASCADE。

ALTER TABLE 改表、DROP TABLE 删表

表建好之后还能改
先讲为什么需求会变:作品要加个「来源」字段、用户名从 50 字放宽到 80 字、某个字段不要了……你不可能每次都删表重建(那样数据全没了)。ALTER TABLE 就是「在保留数据的前提下改表结构」。而 DROP TABLE 是彻底销毁——它比 DELETE 还狠,连表壳都不剩。
动手做用一张演示表走完「加列 → 改列 → 删列」三步,再看结果。
改表结构.sqlsql可运行
CREATE TABLE `demo_user` (
  `id`       INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `username` VARCHAR(50) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- ① 加一列(AFTER 指定插在哪个字段后面)
ALTER TABLE `demo_user` ADD COLUMN `email` VARCHAR(100) DEFAULT NULL AFTER `username`;

-- ② 改列定义:把 username 的长度从 50 放宽到 80
ALTER TABLE `demo_user` MODIFY COLUMN `username` VARCHAR(80) NOT NULL;

-- ③ 删一列
ALTER TABLE `demo_user` DROP COLUMN `email`;

-- 看最终结构
DESC `demo_user`;
查询结果SQL 执行结果
运行结果: Field | Type | Null | Key | Default | Extra id | int unsigned | NO | PRI | NULL | auto_increment username | varchar(80) | NO | | NULL | -- 共 2 行(email 加了又删,username 已变成 varchar(80))
删表.sqlsql可运行
-- 连表带数据一起销毁,不可恢复
DROP TABLE `demo_user`;

-- 建表时加 IF NOT EXISTS、删表时加 IF EXISTS,可避免「表已存在 / 不存在」的报错
-- DROP TABLE IF EXISTS `demo_user`;
查询结果SQL 执行结果
运行结果: Query OK, 0 rows affected -- 表 demo_user 及其所有数据已被删除,无法找回
讲透
  • ADD COLUMN 加列;MODIFY COLUMN 改类型/长度/约束(要重写整行定义,不能只写要改的那部分);CHANGE COLUMN 旧名 新名 类型 顺便改名;DROP COLUMN 删列(该列数据永久丢失)。
  • 加索引 / 加外键也用 ALTER:ALTER TABLE article ADD INDEX idx_x (col);、ALTER TABLE … ADD CONSTRAINT … FOREIGN KEY …;。下一节的 CREATE INDEX 是它的简写。
  • DROP TABLE IF EXISTS 是本库初始化脚本的第一道工序——这样脚本可以反复执行而不报错。注意它不检查有没有外键引用,所以 hiwen.sql 开头先 SET FOREIGN_KEY_CHECKS = 0 才敢批量删表。
  • 大表 ALTER 要小心:给几百万行的表加列,MySQL 会锁表或重建表,期间写入会阻塞。线上改大表通常要挑低峰期,或用 pt-online-schema-change 这类工具。
避坑别指望用事务保护改表:ALTER TABLE、DROP TABLE、CREATE TABLE 在 MySQL 里会隐式提交——哪怕你外面包了 START TRANSACTION,ROLLBACK 也救不回来。所以删表、删列之前,先导出备份:mysqldump -uroot -p hiwen demo_user > demo_user.sql。

主键自增 AUTO_INCREMENT

让数据库帮你编号
先讲为什么每张表都得有一个主键来唯一标识每一行。如果用「书名」当主键——两本书同名怎么办?书名改了怎么办?最省心的方案是:给每张表一个跟业务完全无关的自增整数 id。它永远唯一、永远不变,插入时你连写都不用写。
动手做category 表种子里有 5 条(id 1–5)。插一个新分类,看它自动拿到什么 id。
自增演示.sqlsql可运行
-- 不写 id,数据库自动分配
INSERT INTO `category` (`name`, `description`, `sort`) VALUES ('戏曲', '传统戏曲唱词', 6);

SELECT id, name, sort FROM `category` ORDER BY id;

-- 演示完删掉,恢复种子的 5 个分类
DELETE FROM `category` WHERE `name` = '戏曲';
查询结果SQL 执行结果
运行结果: id | name | sort 1 | 唐诗 | 1 2 | 宋词 | 2 3 | 元曲 | 3 4 | 散文 | 4 5 | 小说 | 5 6 | 戏曲 | 6 -- 共 6 行(新分类自动拿到 id = 6)
讲透
  • AUTO_INCREMENT 只能用在「被索引的整数列」上,实际上就是主键。写法和 INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY 配合使用。
  • 插入时不写这一列,数据库取「当前最大值 + 1」。也可以显式指定值(INSERT … (id, ...) VALUES (100, ...)),但没必要,也容易出问题,别这么干。
  • 删了记录,自增计数不回退:上一章删掉 id = 6 的「戏曲」后,再插一条还是 id = 7。所以主键有空洞是正常的,别折腾着去「补齐」——那反而会撞车。
  • 不同表各自计数:article 和 category 的自增互不影响;category 的 id 会重新从 1 开始,而 article 继续自己的序列。
避坑自增 id 有两个容易踩的坑:① 别把自增 id 当业务编号对外暴露——它会暴露「网站有多少用户」「增长速度」,还方便别人爬数据;对外用「随机订单号 / UUID」这类单独字段。② 别手工指定 id去「填空洞」——一旦指定的值超过当前自增计数,后续插入会接着这个值走;指定重复值则直接主键冲突。让它自己长就好。

索引:为什么查询会变快

像书的目录一样
索引 = 书的目录:不用一页页翻,直接跳到目标无索引:全表扫描第 1 行 不匹配,下一行第 2 行 不匹配,下一行第 3 行 不匹配,下一行第 4 行 不匹配,下一行第 5 行 不匹配,下一行第 6 行 ← 找到了!第 7 行 不匹配,下一行50 万行 = 看 50 万次有索引:走目录索引树(按 title 排好序)二分定位:2~3 次比较直接命中目标行50 万行 ≈ 十几次比较代价:索引占磁盘、拖慢写入(每写一行要同步维护目录)——给常查询的列建,别乱建
先讲为什么一本书 500 页,你要找「第三章讲了什么」。笨办法是从第一页翻到最后一页——这就是全表扫描。聪明的办法是翻到前面的目录,直接跳到第 87 页——这就是索引。
数据库里几百万行数据,如果每次查询都「翻遍全书」,慢得没法用。给常用来当条件的列建索引,就等于给表加了一份目录,查询从「逐行比对」变成「按图索骥」。
动手做先看看 article 表上已经有哪些索引。查 information_schema.STATISTICS 就能把它们列出来(NON_UNIQUE = 0 表示唯一索引)。
查看索引.sqlsql可运行
SELECT INDEX_NAME, SEQ_IN_INDEX, COLUMN_NAME, NON_UNIQUE
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'hiwen' AND TABLE_NAME = 'article'
ORDER BY INDEX_NAME, SEQ_IN_INDEX;
查询结果SQL 执行结果
运行结果: INDEX_NAME | SEQ_IN_INDEX | COLUMN_NAME | NON_UNIQUE ft_article_title_summary | 1 | title | 1 ft_article_title_summary | 2 | summary | 1 idx_article_category | 1 | category_id | 1 idx_article_recommend | 1 | is_recommend | 1 idx_article_recommend | 2 | status | 1 PRIMARY | 1 | id | 0 -- 共 6 行(PRIMARY 是主键;idx_article_recommend 是两列组成的联合索引)
动手做给「按朝代筛选」加一个普通索引,查看确认后再删掉。
创建与删除索引.sqlsql可运行
-- 普通索引:给「按朝代筛选」加速
CREATE INDEX `idx_article_dynasty` ON `article` (`dynasty`);

SELECT INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'hiwen' AND TABLE_NAME = 'article'
  AND INDEX_NAME = 'idx_article_dynasty';

-- 删除索引
DROP INDEX `idx_article_dynasty` ON `article`;
查询结果SQL 执行结果
运行结果: INDEX_NAME | COLUMN_NAME idx_article_dynasty | dynasty -- 共 1 行
讲透
  • 三种常见索引:普通索引(KEY / INDEX)只为加速查找;唯一索引(UNIQUE KEY)加速之外还保证不重复;联合索引(KEY (a, b))把多列组合成一份目录。
  • 联合索引讲「最左前缀」:idx_article_recommend (is_recommend, status) 对 WHERE is_recommend = 1 有效,对 WHERE is_recommend = 1 AND status = 1 更有效;但单查 WHERE status = 1 用不上它——因为「目录」是先按第一列排的。所以把「最常用、区分度最高的列」放最左边。
  • 什么时候该建索引:① 经常出现在 WHERE 里的列(category_id);② 用于 JOIN 的关联列;③ 用于 ORDER BY 的列。本库给 article.category_id 建索引,正是因为列表页天天要「按分类筛」。
  • 什么时候不该建:区分度极低的列(比如只有 1/0 两个值的 status 单独建索引意义不大);很少用来当条件的列;以及索引本身会占空间、拖慢写入——每次 INSERT/UPDATE 都得同步更新目录。
  • 怎么验证有没有用上:在语句前加 EXPLAIN,看 key 列有没有出现索引名、type 是不是 ALL(ALL 就是全表扫描,慢)。这是第 8 章的主题。
避坑「索引越多越好」是错觉:索引会拖慢写入、占用磁盘,一张表挂七八个索引,插入性能会明显下降。更常见的坑是「建了索引却用不上」——把列包进函数就会失效:WHERE YEAR(created_at) = 2026 用不上 created_at 的索引,得改写成 WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'。同理,WHERE title LIKE '%月%' 这种「以 % 开头」的模糊查询也用不上索引(这也是本库给标题配了 FULLTEXT 全文索引的原因)。

日期时间与自动时间戳

created_at / updated_at
先讲为什么「这条数据什么时候加的?」「上次改是啥时候?」——这类信息几乎每张表都要记。你会看到 hiwen.sql 里每张表几乎都有 created_at 和 updated_at 两列,而且它们从来不需要你在 INSERT 时手写。
MySQL 有专门的默认值语法,能让数据库「自动记时间、自动更新时间」,省事又不会忘。
动手做建一张小表,插入一条然后修改它,观察两个时间列的差别。
自动时间戳.sqlsql可运行
CREATE TABLE `demo_note` (
  `id`         INT UNSIGNED NOT NULL AUTO_INCREMENT,
  `content`    VARCHAR(200) NOT NULL,
  `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`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 插入:两个时间列都不用写,自动填当前时间
INSERT INTO `demo_note` (`content`) VALUES ('第一条笔记');

-- 隔一会儿再改一次
UPDATE `demo_note` SET `content` = '改过的笔记' WHERE `id` = 1;

SELECT id, content, created_at, updated_at FROM `demo_note`;
查询结果SQL 执行结果
运行结果: id | content | created_at | updated_at 1 | 改过的笔记 | 2026-10-12 09:30:15 | 2026-10-12 09:31:02 -- 共 1 行(created_at 还是插入那一刻;updated_at 已刷新为修改时刻,具体时间以你运行为准)
讲透
  • DEFAULT CURRENT_TIMESTAMP:插入时若没给值,就填当前时刻——这让 created_at 永远等于「写入那一刻」,不会因为代码忘了传而变 NULL。
  • ON UPDATE CURRENT_TIMESTAMP:这一行只要被 UPDATE 改动,就把当前时间写进 updated_at——这就是「最后修改时间」。注意:UPDATE 语句里没改这列,它也会自动变;反过来,如果某次 UPDATE 的值和原值一模一样(没实际变化),MySQL 可能不触发更新。
  • 为什么用 DATETIME 而不是 TIMESTAMP:TIMESTAMP 存的是 UTC 时间戳,会随服务器/连接的时区设置而变,范围只到 2038 年;DATETIME 存的是「字面的日期时间」,不受时区影响、范围到 9999 年。本库统一用 DATETIME。
  • 日期时间的常见操作:NOW() 取当前时间、CURDATE() 取今天、DATE_FORMAT(created_at, '%Y-%m-%d') 格式化输出,WHERE created_at >= '2026-01-01' 按范围筛——这些在第 8 章还会用到。
动手练习
1写出「红影赏析」movie 表的完整 CREATE TABLE:id 自增主键、title 非空、genre 用 ENUM、rating 用 DECIMAL(3,1) 默认 0.0 且用 CHECK 限制在 0–10、release_year 用 INT UNSIGNED、created_at / updated_at 自动维护。
2给 movie 表建一个「按评分筛选」的索引,并用 information_schema 查出来确认它真的存在。
3迁移收尾:把 movie 的 rating 从 DECIMAL(3,1) 改成 DECIMAL(4,1),再建一个联合索引 (genre, rating),最后把整张演示表删掉。想一想:这三步分别对应本章的哪三个知识点?
参考答案
练习答案.sqlsql可运行
-- 第 1 题
CREATE TABLE `movie` (
  `id`           INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '影片ID',
  `title`        VARCHAR(150) NOT NULL                COMMENT '片名',
  `genre`        ENUM('剧情','科幻','动画','喜剧') NOT NULL COMMENT '类型',
  `rating`       DECIMAL(3,1) NOT NULL DEFAULT 0.0    COMMENT '评分',
  `release_year` INT UNSIGNED 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 '最后修改时间',
  PRIMARY KEY (`id`),
  CONSTRAINT `chk_movie_rating` CHECK (`rating` >= 0 AND `rating` <= 10)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='影片表';

-- 第 2 题
CREATE INDEX `idx_movie_rating` ON `movie` (`rating`);

SELECT INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'movie'
ORDER BY INDEX_NAME, SEQ_IN_INDEX;

-- 第 3 题
ALTER TABLE `movie` MODIFY COLUMN `rating` DECIMAL(4,1) NOT NULL DEFAULT 0.0;
CREATE INDEX `idx_movie_genre_rating` ON `movie` (`genre`, `rating`);
DROP TABLE `movie`;
查询结果SQL 执行结果
运行结果: -- 第 1 题 Query OK, 0 rows affected -- movie 表创建成功(7 列:id、title、genre、rating、release_year、created_at、updated_at) -- 第 2 题 INDEX_NAME | COLUMN_NAME idx_movie_rating | rating PRIMARY | id -- 共 2 行 -- 第 3 题 Query OK, 0 rows affected -- rating 已改成 decimal(4,1);联合索引 idx_movie_genre_rating 已创建;movie 表已删除

第 3 题的三步,正好对应本章三块:ALTER TABLE 改结构(第 5 节)、索引(第 7 节)、DROP TABLE(第 5 节)。能自己默写出 movie 的建表语句,说明这张「电影表的框架」你已经能独立搭起来了。

本章小结