做了几年订单系统,最熟悉的一句话是“这个查询怎么又慢了”。数据量一旦过千万,订单表基本就是慢 SQL 的重灾区。这不是某个特定团队才会踩的坑,而是所有把订单当作核心表的系统都会遇到的一个共同问题。这一篇我们就从实际排查路径出发,把订单表索引设计这件事讲透:从一条慢 SQL 的定位开始,到索引设计的原则,再到加完索引后如何验证效果,最后把我自己踩过的和看别人踩过的坑一并交代清楚。
无论你是刚接手订单系统的开发,还是已经在跟慢 SQL 斗争了一段时间,这篇文章都适合你。我会尽量把每一步背后的判断逻辑也写出来,而不是只丢给你一条“CREATE INDEX”语句。知道为什么这样做,比照着抄一个索引重要得多。
1. 慢 SQL 定位:先搞清楚慢在哪,再谈优化
1.1 最快拿到慢 SQL 的方式:慢查询日志
我记得很多同行优化索引的第一步不是看代码,而是直接打开数据库的慢查询日志。以我们最常见的数据库为例,可以通过下面这个命令快速确认慢查询日志是否已经打开:
SHOW VARIABLES LIKE 'slow_query_log%'; SHOW VARIABLES LIKE 'long_query_time';一般情况下,线上环境会把long_query_time设置成 1 秒甚至 0.5 秒。也就是说,执行时间超过这个阈值的 SQL 会被记录下来。如果你的环境还没开,可以临时开一下,这里以 MySQL 为例:
SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 0.5;注意这个 set 只对当前实例生效,重启后还是会回到原本的配置,正式环境最好还是把配置写进配置文件里,不然下次维护实例时可能就把这茬忘了。
慢查询日志里通常会包含 SQL 文本、数据库名、执行时间、锁等待时间、扫描行数这些信息。拿到日志之后,先别急着复制 SQL 去跑 EXPLAIN,我习惯做两件事:第一,按执行次数和总耗时排序,找出现频率最高、累计消耗最大的 SQL;第二,确认这条 SQL 是不是当前业务的核心路径,比如订单列表页、订单详情页、定时任务里的状态批量查询,这些是最值得优先处理的。
1.2 用 EXPLAIN 看执行计划
定位到慢 SQL 之后,下一步就是查看执行计划:
EXPLAIN SELECT order_id, order_no, user_id, status, create_time FROM t_order WHERE user_id = 100123 AND status = 1 ORDER BY create_time DESC LIMIT 20;执行计划里最关键的是这几列:
- type:访问类型。从好到差大致是 const、eq_ref、ref、range、index、ALL。
- key:实际用到的索引。
- rows:预估扫描的行数。
- Extra:是否出现 Using filesort、Using temporary、Using index 等关键提示。
很多新手只盯着 key 看,觉得只要命中索引就行。其实 type=ALL 才是更需要警觉的,它意味着全表扫描。rows 特别大但实际返回结果很少时,也暗示索引选择有问题。如果 Extra 里出现 Using filesort,那就要注意排序字段是不是完全被索引覆盖了,后面我会单独展开。
1.3 一个典型的订单慢查询长什么样
假设我们有这样一张简化过的订单表,结构类似:
CREATE TABLE t_order ( id BIGINT PRIMARY KEY, order_no VARCHAR(64), user_id BIGINT, order_status TINYINT, pay_amount DECIMAL(10,2), create_time DATETIME, update_time DATETIME ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;随着订单量增长,最常出现的慢查询之一就是“查某个用户的订单列表”:
SELECT * FROM t_order WHERE user_id = 100123 ORDER BY create_time DESC LIMIT 20;在没有索引的情况下,这条语句会把整张表都扫一遍,把所有满足 user_id 条件的行找出来,再做一次排序,最后只拿 20 条返回给前端。如果表里已经有 2000 万行,即使只查一个用户的 200 条订单,优化器也可能选择全表扫描加 filesort。这是订单表最常见的慢 SQL 类型,也是最适合用索引解决的场景。
2. 订单表索引设计的原则:为什么这样建是对的
2.1 先分析查询模式,再动手建索引
很多开发看到慢 SQL 的第一反应是“这儿缺索引,加上”。这个思路不能说错,但容易加出一堆冗余索引。真正合理的做法是先梳理这个表的高频查询路径,把查询模式归纳一下,再决定索引怎么设计。
订单表常见的查询场景大概是这几类:
- 按用户查订单列表,通常会带状态过滤和创建时间排序
- 按订单号精确查询订单详情
- 运营后台按时间范围、状态、金额等条件组合筛选
- 定时任务批量轮询某个状态范围内的订单
你会发现每类查询涉及的字段不一样,需要的索引也完全不同。如果一上来就给所有字段都建索引,最后表上可能出现七八个索引,写入性能下降不说,优化器还可能因为索引太多而选错执行计划。
2.2 复合索引最左侧前缀原则
复合索引是我在设计订单表索引时最常用的手段。一个复合索引可以同时支持多个条件,但前提是查询条件必须满足最左侧前缀原则。
举个例子:
ALTER TABLE t_order ADD INDEX idx_user_create (user_id, create_time);这个索引能支持以下查询:
WHERE user_id = 100123 ORDER BY create_time DESC WHERE user_id = 100123 AND create_time > '2024-01-01' WHERE user_id IN (100123, 100124) ORDER BY create_time但下面的查询无法有效使用这个复合索引,因为它跳过了最左侧的 user_id:
WHERE create_time BETWEEN '2024-01-01' AND '2024-01-31'所以你在建复合索引之前,一定要想清楚“哪一个字段是查询里必然存在的”,把它放在第一位。对于订单表来说,user_id 通常是最稳定的过滤条件,所以像(user_id, create_time)这种组合非常常见。
2.3 排序字段与索引顺序的关系
还有一个很容易被忽略的地方:索引字段的顺序会直接影响排序能不能走索引。回到刚才的例子,如果查询是:
WHERE user_id = 100123 ORDER BY create_time DESC那么(user_id, create_time)这个索引天然就能让每个user_id分组内部的create_time有序,排序就可以走索引,Extra 里不会再出现 Using filesort。
但如果你把索引建成(create_time, user_id),情况就完全不同了。create_time 在前的索引虽然能用于时间范围查询,但对user_id = 100123 ORDER BY create_time DESC这种查询,索引内部是按 create_time 排列的,还需要在 user_id 上做过滤,排序字段并不是天然满足 ORDER BY 的需求,大概率会出现额外的排序操作。
所以我的经验是:先确定查询条件里最稳定的等值字段,再考虑排序字段,把它放到等值字段之后,这样既做了过滤又做了排序。
2.4 区分等值条件、范围条件和排序字段
有些时候查询条件里既有等值又有范围,比如:
WHERE user_id = 100123 AND order_status = 1 AND create_time BETWEEN '2024-01-01' AND '2024-01-31' ORDER BY create_time DESC;这里 order_status 是等值,create_time 既是范围条件又是排序字段。像这种情况下,索引顺序一般设计为(user_id, order_status, create_time)。
把等值字段放前面,范围字段放后面,这是复合索引字段顺序的一条核心规则。因为当 create_time 走了范围之后,它后面的字段就无法参与索引过滤了,但把它放在前面,却能同时满足排序需求。这里的取舍要有意识地做,不要机械照搬。
3. 实战改造:从零开始给订单表设计索引
3.1 高频场景一:用户订单列表
用户端最常见的操作是“查看我最近下的订单”,SQL 一般长这样:
SELECT order_id, order_no, order_status, pay_amount, create_time FROM t_order WHERE user_id = 100123 AND order_status IN (0, 1, 2) ORDER BY create_time DESC LIMIT 20;这条 SQL 里,user_id 是等值条件,order_status 是可选的 IN 条件,create_time 用于排序。索引优先考虑把 user_id 放在最前面,然后接 create_time,因为 IN 条件可以被优化器展开,即使不用 order_status,也能很好走索引。
改造方案:
ALTER TABLE t_order ADD INDEX idx_user_createtime (user_id, create_time);这个索引加上之前,你会发现查询走的是 ALL,扫描行数可能是几百万,排序使用 filesort。加上之后,type 变成 ref,rows 只剩用户自己的订单量,Extra 里的 Using filesort 也随之消失。结果基本就是从几百毫秒降到了个位数毫秒。
补充一个细节:如果查询的字段都在索引里,会出现 Using index,这是覆盖索引的标志。上面这条 SQL 如果只是返回 order_no、create_time,那走覆盖索引非常舒服。但一旦加了pay_amount,就必须回表拿数据了。下面的 3.4 小节我会继续讨论这个问题。
3.2 高频场景二:订单号精确查询
订单号也是查询频率极高的字段。用户下单后立刻跳转订单详情页,下单回调也要按订单号反查订单。这个查询基本是等值匹配:
SELECT * FROM t_order WHERE order_no = 'SN100001244' LIMIT 1;order_no 在业务上具有唯一性,所以索引首选唯一索引:
ALTER TABLE t_order ADD UNIQUE KEY uk_order_no (order_no);加了唯一索引之后,type 会从 ALL 变成 const 或者 eq_ref,查询速度几乎和主键查询一样快。这里要提醒一句:唯一索引会带来额外的写入开销,所以不要全文所有字段都建唯一索引,只在业务上真正有唯一语义的字段上建。
3.3 高频场景三:后台按时间范围筛选订单
运营后台的筛选功能经常是组合条件,比如根据时间范围、订单状态、支付状态组合查询。这种场景下,查询条件不确定,很难用一个固定索引满足所有组合。
我的建议是选一个区分度较高的固定条件作为切入点。如果后台查询几乎都会带上时间范围,那么可以建一个(create_time, order_status)的组合索引。如果时间范围是必选,但状态可选,把 create_time 放前面更合适。
也可以考虑给最常用的组合提供索引,而不是为每一个字段都建索引:
ALTER TABLE t_order ADD INDEX idx_status_create (order_status, create_time);这样能让“按状态 + 时间范围”的组合查询走 range 或 ref,而不是全表扫。后台列表通常还有分页,下面这块我会详细讲。
3.4 覆盖索引与回表问题
不少订单表查询只需要返回少数几个字段,比如订单列表只需要订单号、状态、金额、时间。如果这几个字段恰好都能被索引覆盖,那查询连回表都省了。
为了避免回表,我们可以把返回字段也考虑进索引设计。不过订单表字段很多,不能做什么都往索引里塞,常见做法是把高频返回字段放进去,比如:
ALTER TABLE t_order ADD INDEX idx_user_create_status ( user_id, create_time, order_status );这样查询SELECT user_id, create_time, order_status时,就可以直接走索引拿到所有需要的数据,Extra 能显示 Using index。
我见过一些团队为了减少回表,把一个索引加到七八个字段,这种做法的代价是索引体积越来越大,写入性能也会受影响。覆盖索引要适度,优先覆盖那些高频且字段较少的查询,别拿它当万能药。
3.5 分页深翻页问题
订单列表分页也是一个隐藏雷区。很多系统一开始用:
SELECT ... FROM t_order WHERE user_id = 100123 ORDER BY create_time DESC LIMIT 100000, 20;即使有索引,MySQL 也需要先扫描并且跳过前面 100000 条符合条件的记录,然后取 20 条返回。扫描的 100000 条记录如果都在索引里还好,一旦还要回表,那代价就是 100000 次回表,速度一下子就上去了。
比较实用的优化方案是延迟关联,或者叫“先查主键再查详情”。思路是先用覆盖索引查出当前页的主键 ID,再拿这些 ID 去关联订单表:
SELECT t.* FROM t_order t INNER JOIN ( SELECT id FROM t_order WHERE user_id = 100123 ORDER BY create_time DESC LIMIT 100000, 20 ) tmp ON t.id = tmp.id;因为子查询只访问索引,不会发生大量回表,外层再按主键去查真实数据时,最多只需要回表 20 条。实际效果上,深分页场景能减少 97% 以上的回表开销,速度自然能快很多。
如果业务上允许,更彻底的办法是改成游标分页,用create_time < 上一页最后一条记录的 create_time来取下一页。这种方案不需要深偏移,不过需要业务前端配合。
4. 添加索引后的验证与执行计划对比
4.1 同样的 SQL,两种执行计划
我习惯在加索引前后各保存一份 EXPLAIN 结果,方便对比。下面用前面“用户订单列表”的例子来演示。
加索引前:
id | select_type | table | type | key | rows | Extra 1 | SIMPLE | t_order| ALL | NULL | 2100000 | Using where; Using filesort加索引后:
id | select_type | table | type | key | rows | Extra 1 | SIMPLE | t_order| ref | idx_user_createtime | 389 | NULLrows 从 210 万降到了 389,执行时间上,原本可能要 500 毫秒以上,现在基本在 10 毫秒以内,甚至更快。对于订单场景来说,这已经足以满足绝大多数接口的响应时间要求了。
4.2 查看实际优化后的耗时
执行计划只能说明“可能”的行为,实际效果还是要看真实执行。我通常会在测试库或者线上只读实例上跑一下 SQL,看耗时统计:
SELECT order_id, order_no, order_status, create_time FROM t_order WHERE user_id = 100123 ORDER BY create_time DESC LIMIT 20;没有索引时,这条查询在千万行表上的耗时大概在几百毫秒到一秒多不等,尤其是碰到冷数据占多数、buffer pool 里没有对应数据页时会更慢。加上(user_id, create_time)索引后,常见的情况是稳定在 5 到 20 毫秒,属于量级上的提升,而不是百分比上的小优化。
4.3 排查索引失效的常见场景
索引设计好之后,还要小心使用习惯导致索引失效。最常见的情况包括:
- 对索引字段使用函数,比如
WHERE DATE(create_time) = '2024-01-01',这会让索引失效。 - 隐式类型转换,比如
WHERE order_no = 100123,而 order_no 是字符串类型,可能导致索引无法正常使用。 - 前导模糊匹配,比如
WHERE order_no LIKE '%SN100%',如果通配符在最前面,索引也难以走完。 - OR 条件连接了不同的索引字段,有时会让优化器放弃索引。
说一个我自己的习惯:凡是在索引字段上做任何“加工”的,都先想清楚它会不会破坏索引的排序或匹配能力。能不用函数就不用,能用等值匹配就不用范围匹配,能避免隐式转换就避免。这里没有悬念,细节决定慢查询会不会复发。
5. 索引维护和避坑心得
5.1 冗余索引的危害
很多人觉得索引越多越好,查询快就行了。实际上,每多一个索引,写入时都要多维护一颗 B+ 树,插入、更新、删除的代价都会上升。尤其像订单表这种写入频繁的表,如果为了省一次查询的时间加了五六个冗余索引,整体写入性能会被拖累。
检查冗余索引有一个小技巧:查看所有索引,如果某个索引的最左侧字段和另一个索引的最左侧字段相同,但覆盖范围更小,那它很可能就是冗余的。比如,已经有(user_id, create_time),又建了(user_id),这个单字段索引就可以删掉。
5.2 索引碎片与统计信息
订单表删除和更新频繁后,B+ 树会产生碎片。索引碎片会导致扫描效率下降,也让统计信息不准确。日常维护中,我建议按业务周期做一次表分析和索引碎片整理。
查看索引碎片情况:
SELECT * FROM information_schema.tables WHERE table_schema = 'your_db' AND table_name = 't_order';或者可以用CHECK TABLE检查表状态。如果需要整理碎片,可以使用ALTER TABLE ... ENGINE=InnoDB来重建表,但这属于比较重的操作,必须放在维护窗口执行,避免影响线上业务。
补充一下:不是一看到碎片就马上处理,碎片率一般在 30% 以上时再考虑比较合理。订单表的核心还是要保证统计信息够新,这样优化器才能选对索引。
5.3 联合索引到底要不要带状态字段
很多订单表都有状态字段,通常值不多,比如 0 待支付、1 已支付、2 已发货、3 已完成、4 已取消。查询里经常要加order_status条件,但它的区分度其实很低。
像这种低区分度的字段,不建议放在复合索引的第一位,否则过滤能力不够强,对用户维度为主的查询来说帮助不大。更合理的做法是把状态字段放在 user_id 或 create_time 后面。比如查询是“某个用户的某个状态下订单”,索引可以设计成(user_id, order_status, create_time),这样既保证用户维度筛选最强,又兼顾了状态和时间排序。
5.4 什么时候用 force index
有时候优化器并不会选出你以为最优的索引。原因可能是统计信息不准确,或者优化器的估算逻辑和实际数据分布不匹配。遇到这种情况,不推荐一上来就改代码,先试着用FORCE INDEX验证是不是索引选择问题:
SELECT order_id, order_no FROM t_order FORCE INDEX (idx_user_createtime) WHERE user_id = 100123 ORDER BY create_time DESC LIMIT 20;如果确实快很多,那问题的根源可能是统计信息过旧,先做一次ANALYZE TABLE,让优化器重新评估。如果重新分析后还是选错索引,再考虑通过优化 SQL 写法来纠正,FORCE INDEX可以作为临时兜底方案,但不要长期写在代码里,毕竟后续索引变更时很容易埋雷。
5.5 我踩过的几个坑
第一个坑是给 order_no 建了普通索引而不是唯一索引。结果就是业务上同一订单号存在重复记录,后续排查数据问题时花费了不少功夫。如果业务上要求订单号唯一,那一开始就要用唯一索引,不要自己骗自己说“先这样,后面再补”。
第二个坑是复合索引字段顺序写反。我当时把 create_time 放在第一列,想着时间范围查询最重要。结果用户订单列表的查询虽然也能走索引,却始终有 filesort,而且因为 create_time 的区分度很高,它排在第一列反而干扰了 user_id 的快速定位。后来把顺序换成(user_id, create_time)才真正解决。
第三个坑是后台列表查询依赖一个非常复杂的动态条件,今天加这个字段明天加那个字段,索引怎么建都覆盖不全。最后我的处理方式是把必选条件剥离出来,比如时间范围,作为索引的第一列,其余条件只是等值筛选,尽量通过 SQL 重构减少对索引的依赖,而不是一直堆索引。
这些坑并不是什么高深理论,但每一条背后都是真实的线上问题。我希望写出来,能让你在设计订单表索引时少走一点弯路。
写在最后
索引设计这件事,很多人觉得是调优阶段才需要考虑的。但订单表一旦量级上来,再回头调索引的成本就高很多了。我个人在做任何订单表结构设计时,都会先列出高频查询清单,再对着清单设计索引,而不是等慢 SQL 报警了才去救火。
这篇里讲的订单表索引设计流程,基本就是我平时排查问题的真实路径:先找慢查询日志,再分析查询模式,然后设计复合索引,加完后用 EXPLAIN 和实际耗时验证,最后日常维护时留意冗余索引和统计信息。这套流程不复杂,但每一步都有它存在的意义。
如果你正在处理订单表慢查询,建议从最核心的一条用户订单列表 SQL 开始,把 EXPLAIN 结果保存下来,加上索引后再对比一次。你自己亲自动手走一遍,会比看十篇文章更有效。