在SaaS系统中,索引经常被当成解决查询变慢的第一工具。查询一旦出现全表扫描,开发人员很自然地想到“加一个索引”。但数据库并不是只负责SELECT,订单、支付、消息、日志、租户数据等核心表往往同时承受大量INSERT、UPDATE和DELETE。索引越多,数据库需要维护的结构也越多,因此一个看起来能够改善查询的索引,也可能成为写入性能的长期负担。
真正合理的原则不是“索引越少越好”,而是每一个索引都应该有明确的查询价值,并且这种价值能够覆盖它带来的写入、存储和缓存成本。
一、为什么索引会拖慢写入
以MySQL的InnoDB为例,INSERT一条记录时,不仅需要写入聚簇索引,还可能需要维护多个二级索引。UPDATE如果修改了某个索引列,也可能涉及相应二级索引记录的删除与插入;DELETE同样需要维护相关索引。
因此,一张表拥有10个二级索引,并不意味着一次INSERT只增加10个简单的“指针操作”。实际过程还涉及B+Tree页查找、页修改、页分裂、Redo Log、Undo以及Buffer Pool中的页面管理。在高并发写入场景下,这些额外工作会累积成CPU、内存、I/O和日志写入压力。
尤其需要注意的是,索引并不是只有“创建成本”,而是伴随整个生命周期存在。数据每天增加100万行,意味着相关索引也每天需要持续增长和维护。
二、哪些索引最容易成为负担
第一类是几乎没有实际查询价值的索引。典型例子是某些低基数字段,例如只有少量状态值的字段。如果查询经常结合tenant_id、status、create_time等条件,那么status单独建立索引未必有价值,但并不能简单得出“低选择性字段不能建索引”的结论。索引是否有效最终取决于实际查询条件、数据分布以及执行计划。
第二类是重复或者高度冗余的索引。例如已经存在 (tenant_id, create_time),又建立一个 (tenant_id),后者是否有必要需要根据实际查询和排序需求判断。如果多个索引只是覆盖了相近的访问路径,却没有对应的业务查询,它们就可能只是在增加写入成本。
第三类是为了某一条偶发查询建立的超宽索引。联合索引包含的字段越多,索引记录通常越大,占用的磁盘空间和Buffer Pool空间也越多。对于高频写入表,一个很少使用的宽索引尤其容易变成长期负担。
第四类是经常发生变化的索引列。比如订单状态从pending变成paid,再从paid变成completed,如果status本身位于某个索引中,那么状态变化就可能产生额外的索引维护成本。这里并不是说“经常更新的字段不能建索引”,而是要计算这个索引究竟为多少查询提供价值。
三、不要把“回表”误认为索引失效
原稿中“索引覆盖不足导致索引失效”的说法需要纠正。
假设存在 (user_id, create_time)索引,而查询条件是 WHERE user_id = ? AND status = ?,这个索引仍然可能首先利用user_id缩小扫描范围。因为status不在索引中,MySQL可能需要回表读取完整记录,再判断status。
这叫回表成本增加,并不等于索引失效。
如果某个查询非常高频,而且回表成本已经成为瓶颈,可以根据实际查询增加 (user_id, status, create_time)等更适合的联合索引。但不能为了避免所有回表,把SELECT需要的几十个字段全部塞进索引。这样很容易形成一个庞大的“索引怪物”,查询可能快了一点,写入和缓存压力却明显增加。
四、不要迷信固定的“选择性百分比”
“选择性超过10%就建立索引,低于5%就不要建立”并不是MySQL的通用规则。
索引是否值得建立,取决于数据规模、查询条件、数据分布、访问频率、排序需求以及执行计划。例如status只有几个值,但如果某个查询只访问其中极少数记录,或者它与tenant_id、时间范围组成了高效的联合索引,依然可能具有实际价值。
反过来,一个看起来拥有大量不同值的字段,如果查询很少使用,或者查询本身每次都需要返回大部分数据,那么单独建立索引也未必值得。
实际判断应该使用EXPLAIN和EXPLAIN ANALYZE观察执行计划,并结合慢查询日志、Performance Schema以及真实业务负载验证,而不是套一个固定百分比。
五、索引数量增加后,真正增加的是什么成本
最直接的是存储空间。索引大小没有一个适用于所有表的固定比例,它取决于索引字段类型、长度、基数、联合索引数量以及数据规模。一张表的索引总大小可能远小于数据,也可能非常庞大,因此不能简单说“索引通常占数据表2到5倍”。
第二个成本是Buffer Pool竞争。InnoDB的Buffer Pool同时缓存数据页和索引页。无用索引越多,就越有可能占用本可以用于缓存热点数据和真正重要索引的内存空间。
第三个成本是写放大。写入一条数据需要维护多个相关索引,索引越多,产生页面修改、日志记录以及后台刷盘工作的机会越多。对于日志、事件、订单流水等写入密集型表,这个问题尤其值得重视。
至于“索引越多就一定导致锁竞争”,则同样过于简单。锁等待与事务范围、访问顺序、隔离级别、热点记录、唯一性检查以及具体执行计划等因素有关。不能把所有锁竞争都归因于索引数量。
六、不要把Index Merge当成减少索引数量的万能方案
MySQL能够在部分查询中使用Index Merge,把多个索引的扫描结果进行合并,但Index Merge并不是“建立多个单列索引就可以代替联合索引”。
例如查询长期固定使用:
WHERE tenant_id = ? AND status = ? AND create_time >= ?
如果这是一个高频核心查询,经过测试后发现一个合理的联合索引能够明显减少扫描范围,那么专门设计联合索引通常比单纯依赖Index Merge更容易控制执行路径。
索引设计应该从真实SQL出发,而不是为了“少建联合索引”而刻意制造多个单列索引。
七、写入密集型SaaS表应该怎样设计
首先建立查询矩阵,把高频SQL、WHERE条件、ORDER BY、分页方式、返回字段以及调用频率整理出来。然后根据实际访问路径设计候选索引。
其次重点观察P95和P99延迟,而不是只看平均响应时间。一个索引如果让核心查询从几百毫秒降低到几十毫秒,同时每天只增加少量写入成本,通常具有明确价值。反过来,一个索引只服务极少数后台查询,却让高频订单写入承担持续成本,就应该重新评估。
对于SaaS系统,还要特别注意tenant_id的数据分布。如果绝大多数租户数据规模很小,但存在少数超级租户拥有数百万甚至更多记录,那么平均数据分布可能掩盖真正的性能问题。索引设计必须考虑这种数据倾斜。
八、索引不是靠“定期重建”维持性能
MySQL/InnoDB并不存在所有业务表都应该按照固定周期重建索引的通用规则。频繁执行OPTIMIZE TABLE或者类似维护操作,反而可能造成额外I/O和业务影响。
如果怀疑索引或表结构出现问题,更合理的方式是先观察表大小、碎片情况、执行计划和实际I/O,再决定是否需要维护。
对于长期没有使用价值的索引,删除通常比定期“重建所有索引”更加直接。MySQL还可以利用Invisible Index在一定场景下测试某个索引被隐藏后的执行计划变化,从而降低直接删除索引的风险。
九、最终原则是让每个索引证明自己的价值
SaaS数据库索引设计最怕两个极端:一个是“查询慢就加索引”,最后一张表堆积十几个甚至几十个索引;另一个是为了追求写入速度,把索引删到只剩主键,最终让业务查询重新陷入大范围扫描。
真正成熟的做法是让每一个索引都有明确的服务对象。它应该对应真实、高频或者重要的查询路径,同时把INSERT、UPDATE、DELETE带来的维护成本计算进去。随着数据规模增长,还需要结合慢查询、执行计划、Buffer Pool命中情况、锁等待、Redo Log压力和P95/P99延迟持续调整。
当单表数据继续增长以后,问题也不一定还能靠增加索引解决。冷热数据归档、合理分区、读写分离、缓存、查询模型调整乃至分库分表,都可能成为后续架构演进的一部分。数据库优化的核心从来不是让索引数量达到某个“标准值”,而是在查询速度、写入吞吐、存储成本和系统复杂度之间找到一个能够长期运行的平衡点。
SaaS 数据库索引不是越多越好,哪些索引反而会拖慢写入
图片说明:示意图 图片来源:Public Domain(公有领域)
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP