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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteAPI Keys 管理https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite第二把 Key 写进环境变量别写进脚本。Windows 下用系统环境变量Linux 下写进 profile# Linux / macOS写入当前用户环境 export TAOTOKEN_API_KEYsk-你的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_seeks0 且 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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite把索引定义贴进去让它帮你判断包含列和键列的重叠情况比人眼扫快得多。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_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite6. 长期跑批与 Agent 化把清理做成可复用能力单次清理脚本解决的是“这一次”但索引会持续增长你需要的是“每个月自动跑一次、结果可查、异常可告警”。这时候可以把清理逻辑封装成存储过程配合 SQLServer Agent 定时执行审计表就是你的历史记录。如果你打算把这件事做得更工程化——比如让编码助手帮你生成不同规则的清理脚本、或者把清理流程接入 CI/CD 做变更审计——可以了解 Coding PlanCoding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewrite它适合长期做数据库运维脚本开发、需要统一 Key 管理多个工具链的场景。回到索引清理本身记住三条删除前先写回滚语句、执行前后做占用对比、审计表永远保留。做到这三点你就从“不敢删”变成了“随时能删、随时能回”。