📘 **第一季·《100 SQL Server Mistakes and How to Avoid Them》**本季围绕 Peter A. Carter 的经典著作,系统梳理 SQL Server 开发与管理的常见错误。全书 408 页,涵盖 T-SQL 开发、安装配置、性能优化、高可用性、安全等全领域。
今天早上「DBA晨报·第4期」我们讨论了 AI 智能调优和 PostgreSQL 外键索引检测——这恰好印证了今晚的主题:索引是性能优化的核心,但错误的设计会让索引从「加速器」变成「负担」。
今晚我们从《100 SQL Server Mistakes》第四章出发,系统梳理索引设计中的常见错误,包括聚集索引键选择陷阱、索引泛滥、覆盖索引误用等,每一个都来自生产环境中的真实案例。
一、本期概览
本书第四章聚焦索引设计错误。作者 Peter A. Carter 指出:
"索引是双刃剑——正确设计,查询飞驰;错误设计,写入拖垮、存储爆炸、优化器迷失。"
本章核心观点:
-
• 索引不是越多越好,每个索引都有维护成本
-
• 聚集索引键的选择决定了表的物理存储和写入性能
-
• 非聚集索引需要精心设计包含列,避免昂贵的键查找
-
• 定期清理无用索引是 DBA 的重要职责
本期提炼出 4 个最常见的索引设计错误,并给出正确的设计原则和实战方案。
二、核心错误与解决方案
错误 1:聚集索引键选错——随机 GUID 引发的页分裂灾难
问题场景:
某 SaaS 平台为了方便分布式合并,在聚集索引上使用了 NEWID() 生成的随机 GUID 作为主键:
-- ❌ 错误示范:随机 GUID 作为聚集键
CREATE TABLE BadDesign (
Id UNIQUEIDENTIFIER DEFAULT NEWID() PRIMARY KEY, -- 聚集索引
Data NVARCHAR(MAX)
);
为什么会成为灾难?
随机 GUID 导致每次插入新行时,新数据可能插入到已有数据页的中间,SQL Server 必须频繁执行页分裂——将原页拆分成两半,一半保留原数据,一半存放新数据。这不仅消耗大量 I/O,还会产生大量磁盘碎片,写入性能急剧下降。
性能影响:
-
• 页分裂导致写入延迟增加 3~5 倍
-
• 索引碎片率快速增长,定期重建索引成为额外负担
-
• 存储空间浪费(每个页分裂后约 50% 空间利用率)
✅ 正确做法:
-- ✅ 正确示范:自增 BIGINT 作为聚集键,GUID 作为非聚集逻辑主键
CREATE TABLE GoodDesign (
Id BIGINT IDENTITY(1,1) PRIMARY KEY, -- 聚集索引:有序、窄小
GuidId UNIQUEIDENTIFIER DEFAULT NEWSEQUENTIALID(), -- 逻辑主键:连续 GUID
Data NVARCHAR(MAX)
);
CREATE UNIQUE NONCLUSTERED INDEX UIX_GoodDesign_GuidId
ON GoodDesign(GuidId);
设计原则:
| 原则 | 说明 | | --- | --- | | 窄小 | 聚集键越小,非聚集索引的行指针开销越低 | | 唯一 | 非唯一时 SQL Server 会追加 4 字节 uniquifier | | 稳定 | 键值更新会导致行物理移动,触发级联更新 | | 递增 | 单调递增避免页分裂,写入性能最优 |
如果必须用 GUID,使用
NEWSEQUENTIALID()生成连续值,避免随机插入;将 GUID 作为非聚集索引,保留分布式优势的同时不影响写入性能。
错误 2:索引泛滥——每个查询一个索引,写入被拖垮
问题表现:
开发者为了解决慢查询,习惯在 WHERE 子句的每一列上都创建索引。一张表上可能有数十个索引,覆盖各种查询组合。
后果:
-
• 写入性能暴跌:每次
INSERT/UPDATE/DELETE都需要维护所有索引,写入延迟增加数倍 -
• 存储空间浪费:索引可能占用比数据本身更多的空间
-
• 优化器迷失:过多的索引让查询优化器难以选择最优执行计划
如何识别无用索引:
使用 sys.dm_db_index_usage_stats DMV 查看索引使用情况:
-- 查询当前数据库中未使用的索引(从未被读取,但持续被写入维护)
SELECT
SCHEMA_NAME(o.schema_id) AS SchemaName,
OBJECT_NAME(i.object_id) AS TableName,
i.name AS IndexName,
i.type_desc AS IndexType,
user_seeks, -- 用户查找次数
user_scans, -- 用户扫描次数
user_lookups, -- 键查找次数
user_updates -- DML 更新次数(维护成本)
FROM sys.dm_db_index_usage_stats s
JOIN sys.indexes i
ON s.object_id = i.object_id AND s.index_id = i.index_id
JOIN sys.objects o
ON i.object_id = o.object_id
WHERE s.database_id = DB_ID()
AND i.name IS NOT NULL
AND (user_seeks + user_scans + user_lookups) = 0 -- 从未被读取
AND user_updates > 0 -- 但持续被写入维护
ORDER BY user_updates DESC;
清理原则:
错误 3:忽略键查找——非聚集索引设计不当
问题场景:
在 Student 表的 Name 列上创建非聚集索引,查询需要返回 Age 和 Grade 列:
-- 在 Name 列上创建普通非聚集索引
CREATE NONCLUSTERED INDEX NIX_Student_Name ON Student (Name);
-- 执行查询:需要 Age 和 Grade,但索引中没有这两列
SELECT Name, Age, Grade FROM Student WHERE Name = 'Bob';
执行过程(有额外 I/O):
键查找是性能瓶颈——每次命中都需要额外 I/O,尤其在大量行匹配时代价极高。
✅ 正确做法:覆盖索引
使用 INCLUDE 子句创建覆盖索引,将查询所需列包含在索引叶节点中:
-- ✅ 覆盖索引:包含 Age 和 Grade 列,查询无需键查找
CREATE NONCLUSTERED INDEX NIX_Student_Name_Cover
ON Student (Name)
INCLUDE (Age, Grade); -- 存储在叶节点,不参与索引排序
-- 现在查询完全在索引中完成,零键查找
SELECT Name, Age, Grade FROM Student WHERE Name = 'Bob';
设计原则:
|
设计要点
|
说明
|
| --- | --- |
|
键列保持窄小
|
索引键有长度限制(最大 900 字节)
|
|
包含列可以较宽
| INCLUDE
列不影响索引树结构
|
|
覆盖高频查询
|
识别 TOP N 慢查询,针对性设计覆盖索引
|
覆盖索引是"用空间换时间"的经典策略——存储成本持续下降的今天,性价比极高。
错误 4:分区表索引与分区键不对齐
问题场景:
按时间分区的表(如订单、日志),查询经常包含分区键,但也需要按非分区键(如客户 ID)查询。如果索引设计与分区方案不匹配,会导致跨分区查询性能低下。
关键规则:
-
• 分区对齐索引:索引首列必须包含分区键,否则 SQL Server 会强制创建全局非对齐索引
-
• 唯一索引限制:唯一索引必须包含分区键,否则报错
❌ 错误示例:
-- 按 OrderDate 分区
CREATE PARTITION FUNCTION pf_year (DATE)
AS RANGE RIGHT FOR VALUES ('2024-01-01', '2025-01-01');
-- ❌ 错误:唯一索引未包含分区键
CREATE UNIQUE INDEX UIX_Orders_OrderId ON Orders(OrderId);
-- 报错:The unique index must contain the partitioning column
✅ 正确做法:
-- ✅ 将分区键加入唯一索引
CREATE UNIQUE INDEX UIX_Orders_OrderId_OrderDate
ON Orders(OrderId, OrderDate);
冷热数据分离建议:
-
• 对于按时间分区、历史数据只读、近期数据高并发的场景:
-
• 避免在非分区键上建全局非聚集索引——这类索引会跨越所有分区维护 B-tree,写入时需更新整个索引页链
-
• 更优策略:在每个分区内部建本地非聚集索引(对齐索引),配合分区函数过滤,精准定位目标分区
三、本期小结
|
错误类型
|
后果
|
正确姿势
|
| --- | --- | --- |
|
随机 GUID 作为聚集键
|
页分裂、写入性能暴跌、碎片严重
|
使用自增 INT/BIGINT;GUID 改用 NEWSEQUENTIALID()
|
|
索引泛滥(每个查询一个索引)
|
写入性能下降、存储浪费、优化器迷失
|
用 DMVs 定期识别并清理未使用索引
|
|
忽略覆盖索引设计
|
键查找增加 I/O,查询变慢
|
高频查询用 INCLUDE 创建覆盖索引
|
|
分区表索引未对齐
|
跨分区查询性能差;唯一索引报错
|
索引首列包含分区键;唯一索引必须包含分区键
|
关于本书第四章
《100 SQL Server Mistakes and How to Avoid Them》第四章 "Index Design Mistakes" 深入探讨:
-
• 聚集索引与非聚集索引的本质差异
-
• 如何选择聚集索引键
-
• 覆盖索引与键查找的实战优化
-
• 无用索引识别与清理策略
-
• 分区表索引设计原则
"索引设计没有银弹,关键在于理解业务查询模式和数据分布。DBA 的核心价值不是创建索引,而是创建正确的索引。"—— Peter A. Carter
下期预告
📖 下期主题:《DBA夜读·第一季第5期》 我们将进入查询优化器陷阱——
-
• 为什么同样的查询有时快有时慢?
-
• 参数嗅探如何让执行计划「跑偏」?
-
• 如何利用查询存储强制固定计划?
-
• 统计信息过时对优化器决策的影响
💬 读者讨论: 你是否遇到过因索引设计不当导致的性能问题?比如随机 GUID 做聚集键、索引泛滥、或覆盖索引缺失?欢迎留言分享,我会在下期精选回复。
本文为学习笔记,内容基于《100 SQL Server Mistakes and How to Avoid Them》第四章提炼总结,作者 Peter A. Carter,Manning Publications 出版。