文章目录
- 一、建表语句修正与解析
- 1.1 原始语句的问题
- 1.2 修正后的完整建表语句
- 1.3 字段与约束说明
- 1.4 注意事项
- 二、索引基础:KEY 是什么意思
- 2.1 语法与命名
- 2.2 为什么给时间字段建索引
- 2.3 索引不是越多越好
- 2.4 单列索引 vs 联合索引
- 2.5 与主键的区别
- 2.6 查看索引是否生效
- 三、InnoDB 聚簇索引与二级索引
- 3.1 一句话结论
- 3.2 为什么叫"二级"
- 3.3 二级索引的存储结构
- 3.4 与秒杀建表语句的联系
- 四、回表机制详解
- 4.1 回表是怎么发生的
- 4.2 回表的代价
- 4.3 常见误区
- 五、B+ 树节点存储结构
- 5.1 核心结论
- 5.2 用一张图理解
- 5.3 为什么非叶子节点不存数据
- 5.4 聚簇索引 vs 二级索引,非叶子节点的区别
- 5.5 把整个查找串起来
- 六、实践建议总结
- 七、附:完整 SQL 脚本
- 全文总结
本文整合了关于秒杀系统建表、索引基础、聚簇索引、二级索引、回表机制、以及 B+ 树非叶子节点存储结构等问题的全部讨论,形成一篇从建表到索引底层原理的完整资料。
一、建表语句修正与解析
1.1 原始语句的问题
原始 SQL:
useseckill;CREATETABLEseckill(...);问题:数据库名和表名都叫seckill。
虽然 MySQL 允许seckill.seckill这种写法,但实际项目中极易混淆。建议:
- 数据库叫
seckill,表名改成seckill_goods; - 或者数据库叫
seckill_db,表名保留seckill。
本文采用第一种:数据库seckill,表seckill_goods。
1.2 修正后的完整建表语句
-- 1. 创建并选中数据库CREATEDATABASEIFNOTEXISTSseckillDEFAULTCHARACTERSETutf8;USEseckill;-- 2. 秒杀商品库存表CREATETABLEseckill_goods(`seckill_id`BIGINTNOTNULLAUTO_INCREMENTCOMMENT'商品库存ID',`name`VARCHAR(120)NOTNULLCOMMENT'商品名称',`number`INTNOTNULLCOMMENT'库存数量',`create_time`TIMESTAMPNOTNULLDEFAULTCURRENT_TIMESTAMPCOMMENT'创建时间',`start_time`TIMESTAMPNOTNULLCOMMENT'秒杀开始时间',`end_time`TIMESTAMPNOTNULLCOMMENT'秒杀结束时间',PRIMARYKEY(`seckill_id`),KEYidx_start_time(`start_time`),KEYidx_end_time(`end_time`),KEYidx_create_time(`create_time`))ENGINE=InnoDBAUTO_INCREMENT=1000DEFAULTCHARSET=utf8COMMENT='秒杀库存表';-- 3. 秒杀成功明细表(配套)CREATETABLEsuccess_killed(`seckill_id`BIGINTNOTNULLCOMMENT'秒杀商品ID',`user_phone`BIGINTNOTNULLCOMMENT'用户手机号',`state`TINYINTNOTNULLDEFAULT-1COMMENT'状态: -1无效 0成功 1已付款',`create_time`TIMESTAMPNOTNULLDEFAULTCURRENT_TIMESTAMPCOMMENT'创建时间',PRIMARYKEY(`seckill_id`,`user_phone`),-- 联合主键,防重复秒杀KEYidx_create_time(`create_time`))ENGINE=InnoDBDEFAULTCHARSET=utf8COMMENT='秒杀成功明细表';1.3 字段与约束说明
| 字段 | 类型 | 说明 |
|---|---|---|
seckill_id | BIGINT | 主键,自增,从 1000 开始 |
name | VARCHAR(120) | 商品名称 |
number | INT | 库存数量 |
create_time | TIMESTAMP | 创建时间,默认当前时间 |
start_time | TIMESTAMP | 秒杀开始时间 |
end_time | TIMESTAMP | 秒杀结束时间 |
success_killed表使用联合主键(seckill_id, user_phone),这是秒杀系统的核心设计——靠数据库唯一约束防止同一用户重复秒杀同一商品。
1.4 注意事项
TIMESTAMP的范围限制
MySQL 的TIMESTAMP只能表示1970-01-01 00:00:01到2038-01-19 03:14:07(UTC)。若活动时间可能超过 2038 年,建议改用DATETIME。create_time的默认值DEFAULT CURRENT_TIMESTAMP在 MySQL 5.6.5 之前,一张表只能有一个TIMESTAMP列带此默认值。5.6.5+ 无此限制。AUTO_INCREMENT=1000
仅表示 ID 从 1000 开始,属于业务习惯,不影响功能。
二、索引基础:KEY 是什么意思
2.1 语法与命名
KEYidx_start_time(`start_time`),KEYidx_end_time(`end_time`),KEYidx_create_time(`create_time`)KEY和INDEX在 MySQL 中是同义词。idx_是命名习惯,idx= index,便于识别。- 也可以省略索引名,MySQL 会自动用列名作为索引名。
- 这三行是给表建普通索引(非唯一索引)。
2.2 为什么给时间字段建索引
秒杀系统常见查询:
-- 查询当前正在进行的秒杀商品SELECT*FROMseckill_goodsWHEREstart_time<=NOW()ANDend_time>=NOW();没有索引时,每次查询都要全表扫描。有了索引,MySQL 可以通过 B+ 树快速定位,大幅提升速度。
同样,若业务需要按创建时间排序:
SELECT*FROMseckill_goodsORDERBYcreate_timeDESC;idx_create_time也能让排序直接利用索引顺序,避免额外的 filesort。
2.3 索引不是越多越好
每建一个索引,INSERT/UPDATE/DELETE时都要额外维护索引树,写入会变慢。秒杀商品表数据量通常不大,这几个索引的收益有限,教程中更多是演示规范写法。
2.4 单列索引 vs 联合索引
上面三个都是单列索引。如果查询条件经常同时使用start_time和end_time,单列索引只能各用到一个,MySQL 一般只会选其中一个走。想更高效可建联合索引:
KEYidx_time(start_time,end_time)但对于WHERE start_time <= NOW() AND end_time >= NOW()这种双范围查询,联合索引的帮助也有限。
2.5 与主键的区别
| 特性 | PRIMARY KEY | KEY / INDEX |
|---|---|---|
| 唯一性 | 唯一 | 允许重复 |
| 空值 | 不允许 NULL | 允许 NULL |
| 索引类型 | 聚簇索引(InnoDB) | 二级索引 |
| 叶子节点内容 | 整行数据 | 索引列 + 主键值 |
| 数量 | 一张表只有一个 | 可以有多个 |
2.6 查看索引是否生效
用EXPLAIN查看执行计划:
EXPLAINSELECT*FROMseckill_goodsWHEREstart_time<=NOW();关注key列是否为idx_start_time,type是否优于ALL(全表扫描)。
三、InnoDB 聚簇索引与二级索引
3.1 一句话结论
- 聚簇索引:叶子节点直接存整行数据,一张表只有一个,通常就是主键。
- 二级索引:叶子节点存的是主键值,不是整行数据。一张表可以有多个。
- 回表:用二级索引查到主键值后,再拿主键去聚簇索引里捞完整行数据,这个"再查一次"的过程就叫回表。
3.2 为什么叫"二级"
InnoDB 的数据本身就是按聚簇索引组织的——聚簇索引就是数据本身,是"第一级"。其他所有索引都建立在它之上,是"第二级"。
第一级(聚簇索引 / 主键索引) 叶子节点: [主键值 | 整行数据] ← 数据就在这 第二级(二级索引,比如 idx_start_time) 叶子节点: [start_time | 主键值] ← 只存主键,不存整行 ↓ 拿着主键回到第一级去找整行 ← 这就是回表注意:MyISAM 没有这个概念,它的索引叶子节点存的是行地址,主键索引和普通索引结构上是一样的。这是 InnoDB 和 MyISAM 的关键区别之一。
3.3 二级索引的存储结构
假设表:
CREATETABLEseckill_goods(seckill_idBIGINTPRIMARYKEY,nameVARCHAR(120),numberINT,start_timeTIMESTAMP,KEYidx_start_time(start_time))ENGINE=InnoDB;数据行(聚簇索引叶子):
| seckill_id (主键) | name | number | start_time |
|---|---|---|---|
| 1000 | 苹果手机 | 100 | 10:00 |
| 1001 | 小米手机 | 50 | 11:00 |
| 1002 | 华为手机 | 80 | 10:30 |
聚簇索引按seckill_id排序,叶子节点就是上面每一整行。
二级索引idx_start_time按start_time排序,叶子节点只有两列:
| start_time | seckill_id(主键) |
|---|---|
| 10:00 | 1000 |
| 10:30 | 1002 |
| 11:00 | 1001 |
二级索引不存 name、number,只存索引列 + 主键。
3.4 与秒杀建表语句的联系
回到seckill_goods表:
PRIMARYKEY(seckill_id),-- 聚簇索引KEYidx_start_time(start_time),-- 二级索引,叶子存 (start_time, seckill_id)KEYidx_end_time(end_time),-- 二级索引,叶子存 (end_time, seckill_id)KEYidx_create_time(create_time)-- 二级索引,叶子存 (create_time, seckill_id)每个KEY建的二级索引,叶子节点实际存的是索引列 + 主键seckill_id。这就是为什么:
- 主键用
BIGINT而不是VARCHAR(36)的 UUID——主键越短,每个二级索引的叶子节点越小,一个页能装更多条目,树更矮,回表也更快。 - 主键最好自增有序——插入时聚簇索引是顺序追加,不会页分裂;回表时也更容易顺序读。
四、回表机制详解
4.1 回表是怎么发生的
执行:
SELECT*FROMseckill_goodsWHEREstart_time='10:30';过程:
- 走二级索引
idx_start_time,找到start_time = 10:30的记录; - 拿到对应的主键值
seckill_id = 1002; - 拿着 1002 回到聚簇索引,找到主键为 1002 的那一整行;
- 取出 name、number 等所有字段返回。
第 3 步就是回表。
如果查询只想要主键:
SELECTseckill_idFROMseckill_goodsWHEREstart_time='10:30';第 2 步就已经拿到seckill_id = 1002,不需要回表,直接返回。这叫覆盖索引(covering index)——查询要的列全在索引里,就不用回表了。
4.2 回表的代价
- 多一次 B+ 树查找:每次回表都要从聚簇索引的根节点重新往下走一遍,是 O(log n) 的额外开销。
- 随机 IO:二级索引里主键的顺序,和聚簇索引里数据的物理顺序不一定一致。回表时主键跳来跳去,可能触发大量随机磁盘 IO,比顺序读慢很多。
- 回表次数多时放大明显:如果一条查询命中 1 万行,就可能回表 1 万次。
优化方向:减少回表次数(覆盖索引)或让回表更顺序(主键有序、主键短)。
4.3 常见误区
“我建了索引,为什么查询还是慢?”
可能回表开销吃掉了索引的收益。例如:
SELECT*FROMseckill_goodsWHEREnumber>0;若number上建了索引但number > 0命中 90% 的行,优化器可能直接放弃索引走全表扫描——因为回表 90% 的行,还不如顺序全表扫一遍快。索引选择性低(重复值多)时,回表反而拖累性能。
五、B+ 树节点存储结构
5.1 核心结论
叶子节点的父节点(也就是所有非叶子节点)存的都是"索引键 + 指向子节点的指针",不存数据行。
但聚簇索引和二级索引的"索引键"内容不同:
| 非叶子节点存的键 | 非叶子节点存的指针 | 叶子节点存的 | |
|---|---|---|---|
| 聚簇索引 | 主键值 | 指向子节点的页号 | 整行数据 |
| 二级索引 | 索引列的值 | 指向子节点的页号 | 索引列 + 主键值 |
关键点:非叶子节点永远不存整行数据,它只是"路标",负责把查找导向正确的叶子节点。
5.2 用一张图理解
假设聚簇索引主键是seckill_id,有 1000~1005 六行数据:
[根节点 / 非叶子] [1002 | 1005] ← 只存主键值做分隔 / | \ / | \ [非叶子] [非叶子] [非叶子] [1000|1001] [1003|1004] [1005] ← 还是只存主键值 / \ / \ | ↓ ↓ ↓ ↓ ↓ [叶子] [叶子] [叶子] [叶子] [叶子] 1000 1001 1002 1003 1005 整行 整行 整行 整行 整行 ← 只有叶子存整行- 非叶子节点:只有
[主键值 | 子页指针],作用是"导航"。 - 叶子节点:存完整的行数据(name、number、start_time……全在这)。
二级索引idx_start_time结构完全类似,只是:
[根节点] [10:30 | 11:00] ← 存索引列 start_time 做分隔 / | \ ↓ ↓ ↓ [叶子] [叶子] [叶子] 10:00 10:30 11:00 1000 1002 1001 ← 叶子只存 (start_time, 主键)5.3 为什么非叶子节点不存数据
这是 B+ 树设计的精髓,原因有三:
1. 让非叶子节点尽可能"小",树尽可能"矮"
非叶子节点只存键 + 指针,一个 16KB 的页能装下成百上千个这样的条目。如果非叶子节点也存整行数据,一个页可能只能装几十个条目,树就会变得又高又胖,每次查找要多读好几层。
树矮 = 磁盘 IO 少 = 快。一般 InnoDB 的 B+ 树 3 层就能存上千万行数据,查任意一行最多 3 次磁盘 IO。
2. 所有数据都在叶子,查找路径等长
B+ 树的所有数据都在叶子节点,且叶子节点之间用双向链表连接。这意味着:
- 任何一次查找,都要走到叶子,路径长度一样,性能稳定。
- 范围查询特别高效:找到起点后,顺着叶子链表往后扫就行,不用回到上层。
SELECT*FROMseckill_goodsWHEREstart_timeBETWEEN'10:00'AND'11:00';先定位到10:00的叶子,然后沿叶子链表顺序读到11:00,非常快。
3. 非叶子节点可以常驻内存
因为非叶子节点小,整棵树的非叶子部分(也就是"索引目录")往往能全部加载进内存。查找时大部分层级在内存里完成,只有最后定位到叶子才需要真正读磁盘。
5.4 聚簇索引 vs 二级索引,非叶子节点的区别
聚簇索引的非叶子节点:
[seckill_id 值 | 子页指针]按主键排序,导航依据是主键。
二级索引的非叶子节点:
[start_time 值 | 子页指针]按索引列排序,导航依据是索引列。
共同点:
- 都只是"目录",不存业务字段;
- 都通过键值大小决定往哪个子节点走;
- 都保证所有真实数据只出现在叶子层。
不同点:
- 键不同:聚簇索引用主键,二级索引用索引列;
- 叶子内容不同:聚簇索引叶子是整行,二级索引叶子是"索引列 + 主键"。
5.5 把整个查找串起来
用之前的查询:
SELECT*FROMseckill_goodsWHEREstart_time='10:30';走二级索引
idx_start_time:- 从根节点开始,非叶子节点比较
10:30落在哪个区间,逐层向下; - 到达叶子,找到
(start_time=10:30, seckill_id=1002)。
- 从根节点开始,非叶子节点比较
回表:
- 拿着主键
1002走聚簇索引; - 从聚簇索引根节点开始,非叶子节点比较
1002落在哪个区间,逐层向下; - 到达叶子,取出整行数据。
- 拿着主键
两次查找,每次都经历"非叶子节点导航 → 叶子节点取数据"的过程。非叶子节点负责指路,叶子节点负责给数据。
六、实践建议总结
主键设计:短、有序
- 用
BIGINT自增,而不是 UUID 字符串。 - 自增主键插入时顺序追加,避免页分裂;二级索引叶子更小,回表更快。
- 用
索引选择性
- 选择性高的列(重复值少)更适合建索引。
- 选择性低的列(如性别、状态)建索引可能反而拖慢查询。
善用覆盖索引
- 查询只取索引中已有的列,避免回表。
- 例如
SELECT seckill_id FROM ... WHERE start_time = ?就能覆盖。
秒杀场景特殊考虑
success_killed使用联合主键(seckill_id, user_phone),靠数据库唯一约束防止重复秒杀。- 库存扣减建议用
UPDATE ... SET number = number - 1 WHERE seckill_id = ? AND number > 0,利用行锁保证并发安全。
时间字段类型选择
- 若活动时间可能超过 2038 年,用
DATETIME替代TIMESTAMP。
- 若活动时间可能超过 2038 年,用
理解 B+ 树的两层结构
- 非叶子节点只是"路标",负责导航;
- 叶子节点才是真正的数据层;
- 树矮、非叶子小、叶子有序,是 B+ 树高效的根本原因。
七、附:完整 SQL 脚本
-- 创建数据库CREATEDATABASEIFNOTEXISTSseckillDEFAULTCHARACTERSETutf8;USEseckill;-- 秒杀商品库存表CREATETABLEseckill_goods(`seckill_id`BIGINTNOTNULLAUTO_INCREMENTCOMMENT'商品库存ID',`name`VARCHAR(120)NOTNULLCOMMENT'商品名称',`number`INTNOTNULLCOMMENT'库存数量',`create_time`TIMESTAMPNOTNULLDEFAULTCURRENT_TIMESTAMPCOMMENT'创建时间',`start_time`TIMESTAMPNOTNULLCOMMENT'秒杀开始时间',`end_time`TIMESTAMPNOTNULLCOMMENT'秒杀结束时间',PRIMARYKEY(`seckill_id`),KEYidx_start_time(`start_time`),KEYidx_end_time(`end_time`),KEYidx_create_time(`create_time`))ENGINE=InnoDBAUTO_INCREMENT=1000DEFAULTCHARSET=utf8COMMENT='秒杀库存表';-- 秒杀成功明细表CREATETABLEsuccess_killed(`seckill_id`BIGINTNOTNULLCOMMENT'秒杀商品ID',`user_phone`BIGINTNOTNULLCOMMENT'用户手机号',`state`TINYINTNOTNULLDEFAULT-1COMMENT'状态: -1无效 0成功 1已付款',`create_time`TIMESTAMPNOTNULLDEFAULTCURRENT_TIMESTAMPCOMMENT'创建时间',PRIMARYKEY(`seckill_id`,`user_phone`),KEYidx_create_time(`create_time`))ENGINE=InnoDBDEFAULTCHARSET=utf8COMMENT='秒杀成功明细表';全文总结
从一条秒杀建表语句出发,我们依次理清了:
- 建表层面:命名冲突的修正、字段类型选择、联合主键防重设计;
- 索引基础:
KEY的含义、为什么给时间字段建索引、单列 vs 联合索引; - 存储结构:InnoDB 的聚簇索引(一级,叶子存整行)与二级索引(二级,叶子存主键);
- 回表机制:二级索引查到主键后回聚簇索引取整行的过程、代价与优化;
- B+ 树底层:非叶子节点只存"键 + 指针"做导航,数据只在叶子;聚簇索引用主键做键,二级索引用索引列做键。
一句话贯穿全文:InnoDB 的数据本体是聚簇索引,二级索引是它的"目录",回表是"按目录找正文"的过程;而 B+ 树用"非叶子只导航、叶子才存数据"的设计,让这一切高效运转。理解了这层结构,就能明白为什么主键要短而有序、为什么覆盖索引能加速、为什么索引选择性低时反而拖慢查询。