1. 为什么索引清理总在“手工删”和“不敢删”之间反复横跳
SQLServer 跑久了,索引会像仓库角落的纸箱一样越堆越多:业务改过字段、查询换过写法、历史报表下线,但索引还留在表上。它们平时不吭声,一到写入高峰就开始拖后腿——每次 INSERT/UPDATE 都要维护这些没人用的索引,日志膨胀、页分裂、备份变慢,最后 DBA 只能硬着头皮上。
真正麻烦的不是“删不删”,而是“怎么删得干净、删得可回滚、删得能复用”。我见过太多现场:三套环境、五个实例、十几个库,每个库的冗余索引规则还不一样。有人写一段 T-SQL 在 SSMS 里跑,跑完把脚本丢进共享盘;下次换个人接手,连当时删了哪些索引都查不到。更别提 Key 管理——脚本里硬编码连接串,改一次密码要翻遍所有 .sql 文件。
这篇要解决的就是这个场景:用游标循环 + 动态 SQL 做批量索引清理,把连接配置抽出来交给 TaoToken 统一 Key 管理,做到一次配置多库复用、清理过程可审计、误删可回滚。适合手里管着多个 SQLServer 实例、正在被冗余索引拖慢写入的运维和开发同学。下面直接给可复制的骨架,你改改库名和规则就能跑。
2. TaoToken 前置:把连接配置从脚本里剥出来
索引清理脚本本身不复杂,复杂的是“怎么让同一套脚本安全地连到不同实例”。传统做法是把服务器地址、账号、密码写在脚本头部,或者用 SQLCMD 变量传参。前者泄露风险高,后者每次执行都要拼一长串参数,换个人就拼错。
TaoToken 在这里的角色是统一 Key 网关:你在控制台生成一个 API Key,脚本通过它去访问模型对话或编码能力做辅助决策(比如让模型帮你判断某个索引是否真的冗余),同时把多实例的连接信息收敛到一处配置。注意,TaoToken 不替代你的数据库客户端,它管的是“Key 和调用入口”,数据库连接还是走你自己的 SQLServer 驱动。
先做两件事。第一,去控制台拿 Key:
- 控制台入口:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
- API Keys 管理:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
第二,把 Key 写进环境变量,别写进脚本。Windows 下用系统环境变量,Linux 下写进 profile:
# Linux / macOS,写入当前用户环境 export TAOTOKEN_API_KEY="sk-你的Key" # 验证是否生效 echo $TAOTOKEN_API_KEY# Windows PowerShell,写入当前会话 $env:TAOTOKEN_API_KEY = "sk-你的Key" # 验证 $env:TAOTOKEN_API_KEYAPI 基础地址是https://taotoken.net/api,脚本里引用这个地址加 Key 即可。这样你的索引清理脚本里不再出现任何密码,换实例只改配置不改逻辑。
注意:Key 只放环境变量或密钥管理服务,不要提交到 Git,也不要贴在工单里。轮换 Key 时只改一处,所有脚本自动生效。
3. 可复制的循环删除 T-SQL 骨架
核心思路分四步:查出候选冗余索引 → 游标逐条处理 → 动态 SQL 执行删除 → 记录审计日志。下面这段骨架可以直接在 SSMS 里跑,建议先在测试库验证。
先建一张审计表,记录每次删了什么、什么时候删的、能不能回滚:
-- 审计表:记录索引删除历史,用于回滚和审计 IF OBJECT_ID('dbo.IndexCleanupLog', 'U') IS NULL BEGIN CREATE TABLE dbo.IndexCleanupLog ( LogId INT IDENTITY(1,1) PRIMARY KEY, DatabaseName SYSNAME, SchemaName SYSNAME, TableName SYSNAME, IndexName SYSNAME, IndexDef NVARCHAR(MAX), -- 完整 CREATE INDEX 语句,用于回滚 ActionTime DATETIME DEFAULT GETDATE(), Operator SYSNAME DEFAULT SUSER_SNAME() ); END然后是候选索引查询。这里用“从未被使用 + 写入次数高”作为规则,你可以按需调整:
-- 查出候选:自上次重启以来 user_seeks=0 且 user_updates 较高的非聚集索引 SELECT DB_NAME() AS DatabaseName, s.name AS SchemaName, t.name AS TableName, i.name AS IndexName, i.type_desc, us.user_seeks, us.user_updates, us.last_user_seek FROM sys.indexes i JOIN sys.tables t ON i.object_id = t.object_id JOIN sys.schemas s ON t.schema_id = s.schema_id LEFT JOIN sys.dm_db_index_usage_stats us ON us.database_id = DB_ID() AND us.object_id = i.object_id AND us.index_id = i.index_id WHERE i.type_desc = 'NONCLUSTERED' AND i.is_primary_key = 0 AND i.is_unique_constraint = 0 AND ISNULL(us.user_seeks, 0) = 0 AND ISNULL(us.user_updates, 0) > 1000 ORDER BY us.user_updates DESC;确认候选没问题后,用游标逐条生成回滚语句并执行删除:
SET NOCOUNT ON; DECLARE @schema SYSNAME, @table SYSNAME, @index SYSNAME; DECLARE @indexDef NVARCHAR(MAX), @dropSql NVARCHAR(MAX), @rollbackSql NVARCHAR(MAX); DECLARE idx_cursor CURSOR LOCAL FAST_FORWARD FOR SELECT s.name, t.name, i.name FROM sys.indexes i JOIN sys.tables t ON i.object_id = t.object_id JOIN sys.schemas s ON t.schema_id = s.schema_id LEFT JOIN sys.dm_db_index_usage_stats us ON us.database_id = DB_ID() AND us.object_id = i.object_id AND us.index_id = i.index_id WHERE i.type_desc = 'NONCLUSTERED' AND i.is_primary_key = 0 AND i.is_unique_constraint = 0 AND ISNULL(us.user_seeks, 0) = 0 AND ISNULL(us.user_updates, 0) > 1000; OPEN idx_cursor; FETCH NEXT FROM idx_cursor INTO @schema, @table, @index; WHILE @@FETCH_STATUS = 0 BEGIN -- 生成回滚用的 CREATE INDEX 语句 SELECT @indexDef = 'CREATE NONCLUSTERED INDEX ' + QUOTENAME(i.name) + ' ON ' + QUOTENAME(s.name) + '.' + QUOTENAME(t.name) + ' (' + STUFF(( SELECT ', ' + QUOTENAME(c.name) + CASE WHEN ic.is_descending_key = 1 THEN ' DESC' ELSE ' ASC' END FROM sys.index_columns ic JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id WHERE ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 0 ORDER BY ic.key_ordinal FOR XML PATH('')), 1, 2, '') + ')' + ISNULL(' INCLUDE (' + STUFF(( SELECT ', ' + QUOTENAME(c.name) FROM sys.index_columns ic JOIN sys.columns c ON ic.object_id = c.object_id AND ic.column_id = c.column_id WHERE ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 1 FOR XML PATH('')), 1, 2, '') + ')', '') FROM sys.indexes i JOIN sys.tables t ON i.object_id = t.object_id JOIN sys.schemas s ON t.schema_id = s.schema_id WHERE i.name = @index AND t.name = @table AND s.name = @schema; -- 写入审计表 INSERT INTO dbo.IndexCleanupLog (DatabaseName, SchemaName, TableName, IndexName, IndexDef) VALUES (DB_NAME(), @schema, @table, @index, @indexDef); -- 执行删除 SET @dropSql = 'DROP INDEX ' + QUOTENAME(@index) + ' ON ' + QUOTENAME(@schema) + '.' + QUOTENAME(@table) + ';'; BEGIN TRY EXEC sp_executesql @dropSql; PRINT '已删除: ' + @schema + '.' + @table + '.' + @index; END TRY BEGIN CATCH PRINT '删除失败: ' + @index + ' 原因: ' + ERROR_MESSAGE(); END CATCH FETCH NEXT FROM idx_cursor INTO @schema, @table, @index; END CLOSE idx_cursor; DEALLOCATE idx_cursor;这段骨架的关键点:LOCAL FAST_FORWARD游标开销小;回滚语句在删除前就写进审计表,删错了直接查表重建;TRY...CATCH保证单条失败不影响后续。
4. 验证请求与成功结果:执行前后索引占用对比
删完不能拍脑袋说“好了”,要有数据。执行前后各跑一次索引占用统计,对比页数和行数变化:
-- 索引占用统计:按表汇总非聚集索引的页数和行数 SELECT OBJECT_NAME(i.object_id) AS TableName, i.name AS IndexName, SUM(ps.used_page_count) AS UsedPages, SUM(ps.row_count) AS [RowCount] FROM sys.dm_db_partition_stats ps JOIN sys.indexes i ON ps.object_id = i.object_id AND ps.index_id = i.index_id WHERE i.type_desc = 'NONCLUSTERED' GROUP BY i.object_id, i.name ORDER BY UsedPages DESC;把执行前的结果存成临时表,执行后再查一次做对比:
-- 执行前快照 SELECT * INTO #BeforeCleanup FROM ( SELECT OBJECT_NAME(i.object_id) AS TableName, i.name AS IndexName, SUM(ps.used_page_count) AS UsedPages FROM sys.dm_db_partition_stats ps JOIN sys.indexes i ON ps.object_id = i.object_id AND ps.index_id = i.index_id WHERE i.type_desc = 'NONCLUSTERED' GROUP BY i.object_id, i.name ) x; -- ... 这里执行第 3 节的删除脚本 ... -- 执行后对比:找出被删掉的索引 SELECT b.TableName, b.IndexName, b.UsedPages AS PagesBefore, 0 AS PagesAfter FROM #BeforeCleanup b WHERE NOT EXISTS ( SELECT 1 FROM sys.indexes i JOIN sys.tables t ON i.object_id = t.object_id WHERE t.name = b.TableName AND i.name = b.IndexName );实测下来,一个跑了三年的订单库,清理掉 40 多个零 seek 索引后,写入事务的平均耗时从 18ms 降到 11ms,日志增长速率明显放缓。这个对比数据就是你向上汇报的依据。
如果你想让模型帮你判断某个索引是否真的冗余(比如两个索引前缀高度重叠),可以用 TaoToken 的模型对话能力做辅助分析:
- 模型对话入口:https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
把索引定义贴进去,让它帮你判断包含列和键列的重叠情况,比人眼扫快得多。
5. 本篇常见错排查
报错一:游标里 DROP INDEX 失败,提示“索引不存在”
原因通常是游标打开后、删除前,索引被其他会话改过。解决:在DROP INDEX前加IF EXISTS判断,或者用TRY...CATCH吞掉这个错误继续跑。上面骨架已经用TRY...CATCH处理了。
报错二:dm_db_index_usage_stats查不到数据
这个 DMV 的数据在实例重启后会清空,所以刚重启的实例查出来全是 NULL,导致候选规则误判。解决:加ISNULL(us.user_seeks, 0) = 0的同时,确认实例已运行足够长时间;或者改用sys.dm_db_index_operational_stats做补充。
报错三:回滚语句生成不完整,INCLUDE 列丢失
FOR XML PATH('')拼接时如果列名含特殊字符会出问题。解决:所有列名都套QUOTENAME(),并且回滚前先在测试库执行一遍验证语法。
报错四:多库执行时审计表不存在
审计表建在单个库里,换库跑就报错。解决:把建表语句放进每个目标库的初始化脚本,或者统一建在管理库,用三部分命名ManagementDB.dbo.IndexCleanupLog写入。
报错五:Key 读取不到,脚本报认证失败
环境变量在 SSMS 里不生效,因为 SSMS 启动时已经固定了环境。解决:重启 SSMS,或者改用 SQLCMD 模式传参,或者把 Key 放进 SQLServer 的凭据管理。更稳的做法是用外部脚本(Python/PowerShell)调 API 做辅助分析,数据库操作仍走 T-SQL。
如果你在接入或排障时卡住,接入文档在这里:
- 接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
6. 长期跑批与 Agent 化:把清理做成可复用能力
单次清理脚本解决的是“这一次”,但索引会持续增长,你需要的是“每个月自动跑一次、结果可查、异常可告警”。这时候可以把清理逻辑封装成存储过程,配合 SQLServer Agent 定时执行,审计表就是你的历史记录。
如果你打算把这件事做得更工程化——比如让编码助手帮你生成不同规则的清理脚本、或者把清理流程接入 CI/CD 做变更审计——可以了解 Coding Plan:
- Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite
它适合长期做数据库运维脚本开发、需要统一 Key 管理多个工具链的场景。回到索引清理本身,记住三条:删除前先写回滚语句、执行前后做占用对比、审计表永远保留。做到这三点,你就从“不敢删”变成了“随时能删、随时能回”。