🪟 第14章 索引与数据完整性
📄 一句话概括

DROP INDEX 两种写法:SQL Server 是「表名.索引名」, SQLite 只有「索引名」——写错了直接报错。 主键的索引删不掉:它是表结构的一部分,只能改表定义。 实测 10 万行删掉一半,索引还是 280 页;REINDEX 之后才降到 140 页——删数据不等于腾空间。

📝 本章和前面几章的关系

14.1 讲概念、14.2 讲建,这一节讲删——以及「删了数据之后索引会变虚」这件事。 教材 14.3 分三小节:界面方式、DROP INDEX 语句、索引维护。 界面那节同样跳过,重点在后两个:怎么删、什么时候该重建。 「索引维护」那一小节是这一节真正的价值。 很多人建完索引就不管了, 结果大批删除之后查询越来越慢,还以为是数据变多了——其实是索引里全是空洞。

一、删除索引:两种写法别混

SQL Server 的写法要带表名,因为索引名只在表内唯一:

DROP INDEX tb_goods.ix_goods_gname;
GO

SQLite 的索引名是全库唯一的,所以不用带表名:

DROP INDEX ix_t_k

写成 DROP INDEX t.ix_t_k 在 SQLite 上会报错——这不是谁对谁错, 是「索引名的作用域」这个设计不一样。 换库时这种小地方最容易卡住。 删索引不动数据,只是「不再有这本目录」。 之后再按 k 查, 就回到扫全表了——所以删索引之前,先确认没有查询在靠它。

二、主键索引删不掉

试着删主键自动生成的那个索引,直接报错:

消息 102,级别 15,状态 1
no such index: sqlite_autoindex_t_1

主键的索引不是「另建的一个对象」,它就是表定义的一部分。 想删它只能改表定义(先删主键约束、再重建)。 SQL Server 上这件事叫「删聚集索引」,动静大得多: 行数据本来就按聚集索引排着,删掉它要把整张表重新排一遍—— 几十 GB 的表能干上几个小时,还会把日志写满。 所以生产库上「删主键」这种操作,基本都要停机窗口。

三、索引会「虚」:删了数据,页不一定还

索引是棵 B 树,删行只是把叶子上的记录标掉,那些页不一定马上还给系统。 实测 10 万行删掉一半,用 dbstat 看索引占几页:

阶段 索引占几页 说明
建好索引 280 10 万行都在
删掉一半行 280 行没了,索引页没还
REINDEX 之后 140 重建索引,耗时 29 ms

行少了一半,页一页没少。 这就是「索引碎片」: 页里空着一半位置,但树的结构还是按 10 万行铺开的。 为什么慢? 因为查一次要走同样的层数、读同样多的页, 但每页里有效数据只有一半——等于「书变薄了,目录还是原来那么厚」。 REINDEX 做的事就是「按现在的数据重建一棵树」,10 万行 29 毫秒, 因为它只需要扫一遍索引本身。 SQL Server 上对应的是 ALTER INDEX ... REBUILD(重建)和 REORGANIZE(重整), 碎片率这个指标由 sys.dm_db_index_physical_stats 给。 dbstat 是 SQLite 才有的东西,两个库没有可比的视图。

四、什么时候该重建索引

信号 该做什么
查询突然变慢,执行计划没变 先看统计信息是不是过期了(ANALYZE / UPDATE STATISTICS)
大批删除、或做过分区切换 重建索引
索引碎片率高 超过 30% 就重建,5%~30% 可以重整
写入变慢、日志暴涨 看看是不是索引建太多了

第一行值得单说: 执行计划没变、语句没变、数据量也没暴增, 但就是慢了——十有八九是统计信息过期,不是索引碎片。 统计信息告诉优化器「这一列大概有多少不同值、怎么分布」,它过期了,优化器就在瞎猜。 所以排查顺序是:先看统计信息,再看碎片,最后才怀疑索引设计。 反过来做,往往折腾半天重建了所有索引,慢的那条语句一点没变。 还有一句实话:别把「重建索引」当万能药。 参数嗅探(11.3 讲过)、锁等待、返回数据量太大,都会表现成「查询变慢」, 重建索引治不了这些病。

五、动手 Demo:删索引、删不掉的主键索引、以及 280 页变 140 页

这一段先删一个普通索引(顺手看它从目录里消失),再试着删主键的索引(会被拒), 最后做那个关键实验:10 万行删掉一半,索引占的页数会不会变。 页数是拿 dbstat 数的——它是 SQLite 的自带虚拟表,能看到每个索引用了几页。

import sqlite3

c = sqlite3.connect(u":memory:", isolation_level=None)
# ... 建 10 万行的 t,并建 ix_t_k ...

c.execute(u"DROP INDEX ix_t_k")     # 删得掉
c.execute(u"DROP INDEX sqlite_autoindex_t_1")   # 主键那个,报错


def ipages():
    return c.execute(u"SELECT COUNT(*) FROM dbstat"
                      u" WHERE name = 'ix_t_k'").fetchone()[0]


c.execute(u"CREATE INDEX ix_t_k ON t(k)")
print(u"建好", ipages())
c.execute(u"DELETE FROM t WHERE id % 2 = 0")
print(u"删一半", ipages())
c.execute(u"REINDEX")
print(u"重建后", ipages())

实跑输出:

--------------------------------------------------------------------
删除索引与索引维护
--------------------------------------------------------------------


  ① 删除:DROP INDEX
  --------------------------------------------------------------------

  1> CREATE INDEX ix_t_k ON t(k)

  1> SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 't'
  name
  ------
  ix_t_k
  (1 行受影响)

  1> DROP INDEX ix_t_k
  删完剩 0 个(不算 SQLite 自动建的那些)。删索引不动数据,只是「不再有这本目录」。
  ! SQL Server 的写法是 DROP INDEX 表名.索引名,跟 SQLite 的 DROP INDEX 索引名 不一样。

  ② 主键索引删不掉
  --------------------------------------------------------------------

  1> DROP INDEX sqlite_autoindex_t_1
  消息 102,级别 15,状态 1
  no such index: sqlite_autoindex_t_1
  报错。主键的索引是表结构的一部分,要删它只能改表定义——这在 SQL Server 上叫「删聚集索引」。
  SQL Server 里删聚集索引会把整张表重排一遍(行数据本来就按它存),代价很大。

  ③ 大量删改之后,索引会「虚」
  --------------------------------------------------------------------
  索引是 B 树,删行不一定会立刻把索引页收回去。删得多了,索引里会有空洞。
  SQLite 的 dbstat 能直接看到某个索引占了几页,下面就用它当尺子。

| 阶段 | 索引占几页 | 说明 |
|---|---:|---|
| 建好索引 | 280 | 10 万行都在 |
| 删掉一半行 | 280 | 行没了,索引页没还 |
| REINDEX 之后 | 140 | 重建索引,耗时 26 ms |

  这个对比说明一件事:删数据不等于「腾出空间」,索引要重建才会真正紧凑。
  SQL Server 上对应的是 ALTER INDEX ... REBUILD,还能顺手看到「碎片率」这个指标。
  碎片率在 SQL Server 上由 sys.dm_db_index_physical_stats 给出。

  ④ 什么时候该重建索引
  --------------------------------------------------------------------
| 信号 | 该做什么 |
|---|---|
| 查询突然变慢,执行计划没变 | 先看统计信息是不是过期了(ANALYZE/UPDATE STATISTICS) |
| 大批删除或分区切换之后 | 重建索引 |
| 索引碎片率高 | 超过 30% 就重建,5%~30% 可以重整 |
| 写入变慢、日志暴涨 | 看看是不是索引太多 |

  别把「重建索引」当万能药。先确认慢的原因是不是索引,再动手——
  统计信息过期、参数嗅探(11.3 讲过)、锁等待,都会表现成「查询变慢」,重建索引治不了。

280 → 280 → 140。 中间那一格是这一节的核心:行删掉一半,索引一页没还。 因为删行只是把叶子上的槽位标空,树的分支结构原样留着。 REINDEX 之后掉到 140,几乎是正好一半。 这说明重建做的事很朴素: 「按现在的数据重新铺一棵树」——不神秘,就是重排。 耗时 29 毫秒,代价很低? 因为这里只有 5 万行、还是内存库。 换成几十 GB 的真表,重建一次能跑几十分钟,还要占大量日志空间—— 所以生产上要么挑低峰、要么用「在线重建」。 主键那条报错是 no such index,不是「不许删」。 SQLite 的意思是「没有这个独立对象」——它压根不把主键索引当成可删的东西。 SQL Server 的说法更清楚:那是聚集索引,删它要重排整张表。

30 秒实验

  1. 把删除条件从「一半」改成「九成」(id % 10 <> 0),重跑。 页数基本还是 280——删得再多,页也不还,重建后的差距会更大(140 → 28 左右)。
  2. 把 REINDEX 换成 VACUUM,看页数变不变。 VACUUM 整理的是整库的空闲页,对这个索引的页数影响不一样—— 两个命令管的事不同:REINDEX 管索引,VACUUM 管整个文件。
  3. 想一想:一张表每天归档删掉一个月前的数据,删完查询变慢了,你先做什么? 先看统计信息和执行计划,再考虑重建——很可能真正的原因是统计信息还按「有几千万行」在估。

常见误区

  1. 「删了数据,索引自动就变小了」。 实测 280 页一页没少。 索引页要重建才还回去——这也是「归档之后要维护索引」的原因。
  2. 「查询慢了就重建索引」。多数情况不是碎片的问题。 统计信息过期、参数嗅探、锁等待,都会表现成「慢」,重建索引治不了。先看执行计划。
  3. 「DROP INDEX 到处都写索引名就行」。SQL Server 要写「表名.索引名」, 因为索引名只在表内唯一;SQLite 反过来,索引名全库唯一,带表名反而报错。
自测三问(点开对答案) 1. SQL Server 和 SQLite 的 `DROP INDEX` 写法差在哪? —— **SQL Server 是 `DROP INDEX 表名.索引名`**(索引名只在表内唯一); **SQLite 是 `DROP INDEX 索引名`**(索引名全库唯一,带表名会报错)。 2. 为什么删掉一半行,索引页数不变? —— **因为删行只是把叶子上的记录标空,树的页结构还按原来的规模铺着。** **要收紧必须重建(`REINDEX` / `ALTER INDEX ... REBUILD`)。** 3. 查询变慢,第一件事该查什么? —— **先看执行计划和统计信息,别急着重建索引。** **统计信息过期最常见**——优化器按过时的分布估算,选错了路。 **碎片只在「大批删除之后」才是首要嫌疑。**

下一篇 14.4 讲数据完整性:主键、CHECK、NOT NULL、 外键这五道关卡各拦什么——以及 SQLite 里那个「外键默认是关的」的坑。

相关笔记