
1. 这不是“加个索引就完事”的问题为什么order by、group by和分页在MySQL里会集体失灵我第一次被线上慢查询报警钉死在工位上是凌晨两点。一个看似简单的订单列表页SELECT * FROM orders WHERE status 1 ORDER BY created_at DESC LIMIT 20 OFFSET 10000执行时间从80ms飙到3.2秒。DBA甩来一句“加个索引不就完了”——我照做了ALTER TABLE orders ADD INDEX idx_status_created (status, created_at)结果查询时间只降了17ms。那一刻我才明白把ORDER BY、GROUP BY和分页塞进一条SQL里不是在写查询是在给MySQL出一道组合数学题。这三者叠加的杀伤力远超单点优化。ORDER BY要求排序GROUP BY要求分组聚合而LIMIT OFFSET要求跳过前N行——三者共同指向同一个致命瓶颈临时表Temporary Table与文件排序Filesort的双重开销。当MySQL发现无法利用索引直接完成排序分组跳过时它会默默创建一个内存临时表把所有满足WHERE条件的行全捞出来再在内存里排序、分组、计数最后才取第10001到10020行。一旦数据量突破内存阈值tmp_table_size和max_heap_table_size临时表就会落盘触发磁盘I/O性能断崖式下跌。更隐蔽的是GROUP BY常被误认为只是“去重”但它本质是分组聚合操作。SELECT user_id, COUNT(*) FROM orders GROUP BY user_id表面看只返回用户ID和订单数但MySQL必须扫描所有匹配行按user_id哈希分桶每个桶内累加计数——这个过程无法被简单索引跳过除非索引能覆盖整个分组键聚合字段。而ORDER BY若涉及非索引字段或索引顺序与排序方向冲突如索引是(a,b)却ORDER BY a DESC, b ASC同样触发filesort。分页则是压垮骆驼的最后一根稻草。OFFSET 10000不是“跳过10000行”而是让MySQL先找到前10000行再丢弃它们最后取接下来的20行。这意味着无论你只要20条数据MySQL都得处理10020行。当OFFSET达到十万级哪怕有索引B树也要反复回表、遍历CPU和IO双双拉满。所以这不是“怎么加索引”的技术问题而是如何重构查询逻辑、规避MySQL执行器固有缺陷的工程问题。接下来我会用真实生产环境的5个典型场景拆解每一种失效背后的执行计划真相、索引设计陷阱、以及绕过临时表的硬核替代方案——所有结论均来自我们日均3亿查询的电商订单库实测数据。2. 执行计划里的“隐形杀手”读懂EXPLAIN输出中那些沉默的警告很多开发者看到EXPLAIN结果里出现Using filesort或Using temporary就慌了以为只要消灭这两个词就万事大吉。但真正危险的是那些没报错却在后台疯狂消耗资源的“静默杀手”。我整理了过去半年线上慢查询中EXPLAIN输出最常被忽略的5个关键字段及其真实含义它们比type: ALL更值得警惕。2.1key_len索引实际使用长度的“缩水真相”key_len显示MySQL在索引中实际用了多少字节。很多人建了复合索引(a,b,c)EXPLAIN显示key_len5就以为索引全用了。但a是INT(11)4字节b是VARCHAR(50)假设UTF8MB4最大200字节c是TINYINT1字节。key_len5意味着只用了a4字节b的第一个字节1字节——因为b是变长字段MySQL无法预知其实际长度只能按最坏情况预留空间。真正的索引利用率要看key_len是否等于你期望使用的字段长度之和。举个真实案例一张用户表users(id, city, age, status)需求是SELECT * FROM users WHERE citybeijing AND status1 ORDER BY age DESC。我建了索引(city, status, age)EXPLAIN显示key_len13cityVARCHAR(20)占80bit10字节statusTINYINT占1字节ageTINYINT占1字节2字节长度头13。但查询依然慢。SHOW PROFILE显示Copying to tmp table耗时占比68%。原因city字段在WHERE中是等值查询但ORDER BY age DESC要求索引中age字段的排序方向与查询一致。而我的索引是(city, status, age)age默认升序但查询要DESC导致MySQL无法复用索引排序强制filesort。解决方案建(city, status, age DESC)MySQL 8.0支持降序索引key_len不变但Extra字段从Using filesort变为Using index。提示key_len计算规则需牢记INT固定4字节BIGINT8字节CHAR(n)按n字节算定长VARCHAR(n)按n×字符集字节数2字节长度头变长。NULL字段额外1字节标记位。用key_len反推索引使用情况比盲目加索引高效十倍。2.2rows预估扫描行数的“乐观偏差”rows是MySQL基于统计信息估算的扫描行数但它常严重低估。尤其当表数据分布不均时——比如status字段95%是05%是1统计信息可能把WHERE status1的rows估为总行数的10%实际却是5%。更致命的是rows只反映WHERE条件的过滤完全不包含ORDER BY和GROUP BY的额外开销。一个rows1000的查询若需GROUP BY user_id实际处理行数可能是1000×平均每个用户的订单数比如50即5万行。我们曾遇到一个报表查询SELECT DATE(created_at), COUNT(*) FROM logs WHERE app_id123 GROUP BY DATE(created_at) ORDER BY DATE(created_at) DESC LIMIT 30。EXPLAIN显示rows8500看起来很健康。但实际执行耗时2.7秒。SHOW PROFILE揭示真相Creating sort index耗时1.9秒。原因GROUP BY DATE(created_at)需要对created_at做日期截断MySQL无法用索引直接分组必须扫描所有8500行计算DATE()函数再哈希分组。解决方案添加生成列date_only DATE AS (DATE(created_at)) STORED并在其上建索引。EXPLAIN的rows没变但Extra从Using temporary; Using filesort变为Using index耗时降至120ms。2.3Extra字段里的“伪善提示”Extra字段的提示常带误导性。Using index看似完美但它只表示覆盖索引Covering Index即查询所需所有字段都在索引中无需回表。但如果查询包含ORDER BY且索引顺序不匹配它仍会触发filesort。Using where是中性提示表示WHERE条件在存储引擎层后由MySQL Server层过滤但若type是ALL或index说明没走有效索引。最危险的是Using index conditionICP索引条件下推。它听起来很高级实则暴露了索引设计缺陷。ICP意味着MySQL用索引快速定位大致范围比如WHERE citybeijing但索引中不包含status字段所以必须回表读取status值再过滤。这比全索引扫描还慢——因为多了大量随机IO。我们有个查询SELECT id FROM products WHERE cityshanghai AND price 100索引是(city)。EXPLAIN显示Using index conditionrows50000。优化后建(city, price)Extra变为Using where; Using indexrows降至2300耗时从1.8秒降到45ms。注意Using index condition是“索引没建对”的明确信号。它代表索引只能帮上一半忙另一半还得靠回表硬扛。此时应优先扩展索引而非接受ICP。2.4filtered选择率的“幻觉指标”filtered表示MySQL估计WHERE条件过滤后的行数占比0-100。filtered10.00意味着预计10%的行满足条件。但这个值基于直方图统计对复杂条件如WHERE a1 AND b LIKE %xxx%极不准确。更糟的是filtered只计算WHERE对GROUP BY的分组数量、ORDER BY的排序成本、LIMIT的偏移量毫无感知。一个filtered1.00的查询若GROUP BY产生10万个分组ORDER BY需排序10万行LIMIT 100000, 20需跳过10万行——filtered对此保持沉默。我们曾优化一个用户画像查询SELECT tag, COUNT(*) FROM user_tags WHERE user_id IN (SELECT id FROM users WHERE regionsouth) GROUP BY tag ORDER BY COUNT(*) DESC LIMIT 10。EXPLAIN显示外层filtered100.00因IN子查询被物化内层filtered5.00。看起来很美。但实际执行卡在Sending data阶段。SHOW PROCESSLIST显示线程状态为Copying to tmp table。根因GROUP BY tag产生数百万分组ORDER BY COUNT(*) DESC需对所有分组排序LIMIT 10只取前10但MySQL必须先完成全部排序。解决方案放弃ORDER BY ... LIMIT改用近似算法先SELECT tag FROM user_tags GROUP BY tag ORDER BY RAND() LIMIT 1000采样再精确统计这1000个tag的频次并排序。耗时从42秒降至1.3秒。2.5possible_keys与key的“信任危机”possible_keys列出所有可能用上的索引key显示最终选用的索引。但MySQL的索引选择器Query Optimizer有时会选错。比如一个查询WHERE a1 AND b10 ORDER BY c有索引(a,b)和(a,c)。possible_keys显示两者key选了(a,b)。但(a,b)无法支持ORDER BY c必然filesort而(a,c)虽不能优化b10但能避免排序。此时需用FORCE INDEX干预SELECT * FROM t FORCE INDEX (a_c) WHERE a1 AND b10 ORDER BY c。我们在线上强制干预过37次索引选择。最经典一例订单表orders(user_id, status, created_at)查询SELECT * FROM orders WHERE user_id123 AND status IN (1,2,3) ORDER BY created_at DESC。possible_keys有(user_id)和(user_id, status, created_at)key选了单列(user_id)。原因status IN (1,2,3)被评估为高选择率MySQL认为用(user_id)快速定位用户所有订单再内存过滤status比用复合索引扫描更优。但实测发现用户平均有2000个订单status过滤后剩300行ORDER BY created_at DESC需对300行排序——而复合索引(user_id, status, created_at)可直接定位到300行并按created_at倒序返回省去排序。加FORCE INDEX (user_id_status_created)后Extra从Using where; Using filesort变为Using index耗时从320ms降至45ms。3. 索引设计的“黄金三角”覆盖、顺序、冗余的协同艺术索引不是越多越好而是越精准越高效。我总结出优化ORDER BY、GROUP BY和分页的索引设计“黄金三角”原则覆盖性Covering、顺序性Ordering、冗余性Redundancy。三者缺一不可且需根据查询模式动态权衡。下面以电商订单库的真实索引演进为例展示如何用这三角法则解决具体问题。3.1 覆盖性让索引承载一切拒绝回表覆盖索引的核心目标是让查询所需的所有字段SELECT、WHERE、ORDER BY、GROUP BY涉及的字段全部包含在索引B树的叶子节点中从而避免回表Table Lookup。回表是随机IO大户尤其在SSD时代一次回表可能比顺序扫描100行还慢。原始订单表结构CREATE TABLE orders ( id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, status TINYINT NOT NULL, amount DECIMAL(10,2) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, product_id BIGINT NOT NULL );典型查询Q1SELECT id, user_id, amount, created_at FROM orders WHERE user_id123 AND status1 ORDER BY created_at DESC LIMIT 20。初始索引(user_id, status)能加速WHERE但SELECT中的id、amount、created_at不在索引中必须回表。EXPLAIN显示Extra: Using where; Using filesort因created_at未在索引中排序。黄金三角第一步覆盖性。建复合索引(user_id, status, created_at, amount, id)。注意字段顺序user_id和status是等值查询放最前created_at是排序字段紧随其后amount和id是SELECT字段放在最后。这样索引叶子节点包含全部5个字段EXPLAIN的Extra变为Using indexkey_len显示完整使用。但问题来了索引长度暴增。user_id8字节status1字节created_at8字节amount10字节DECIMAL存为二进制id8字节共35字节。B树每页存的键值减少树高增加查询效率反而下降。此时需引入第二条法则冗余性。3.2 冗余性用空间换时间容忍适度重复冗余性不是无脑复制字段而是在关键路径上为高频查询模式预置“定制化索引”即使它与其他索引有重叠字段。数据库的存储成本远低于CPU和IO成本尤其在云环境SSD价格已大幅下降。针对Q1我们建专用索引(user_id, status, created_at DESC, amount, id)。created_at DESC确保排序方向匹配消除filesort。虽然user_id已在主键和(user_id, status)索引中存在但这个新索引将Q1的响应时间从850ms压至65ms。存储代价该索引约占用12GB表总数据量1.2TB但换来的是Q1的QPS从120提升至1800且不再触发慢查询报警。冗余性还体现在对GROUP BY的优化。查询Q2SELECT user_id, COUNT(*), SUM(amount) FROM orders WHERE status IN (1,2) GROUP BY user_id ORDER BY SUM(amount) DESC LIMIT 10。若建索引(status, user_id, amount)WHERE status IN (1,2)可走索引范围扫描GROUP BY user_id可利用索引顺序分组因user_id在status后SUM(amount)可直接在索引中累加覆盖性。但ORDER BY SUM(amount) DESC无法用索引排序因amount在索引中是分散存储的。此时冗余性方案建(status, user_id, amount)(status, user_id, amount DESC)双索引。后者让SUM(amount)的聚合结果天然按降序排列ORDER BY直接跳过。实测Q2耗时从3.2秒降至210ms。经验冗余索引的阈值是——当一个索引能让某个核心查询的P99延迟降低50%以上且该查询QPS超过100就值得为其建冗余索引。我们线上有7个这样的“黄金冗余索引”占总索引数12%却承担了63%的查询负载。3.3 顺序性索引字段的排列就是执行计划的路线图顺序性决定索引能否同时服务WHERE、GROUP BY、ORDER BY。其核心规则是等值查询字段, IN放最前范围查询字段, , BETWEEN放中间排序/分组字段ORDER BY, GROUP BY放最后。违反此顺序索引将部分失效。查询Q3SELECT product_id, COUNT(*), AVG(amount) FROM orders WHERE user_id123 AND created_at 2023-01-01 GROUP BY product_id ORDER BY COUNT(*) DESC LIMIT 10。错误索引(user_id, product_id, created_at)user_id123可用但created_at 2023-01-01是范围查询product_id在其后无法用于GROUP BY因product_id不连续。EXPLAIN显示Using temporary; Using filesort。正确索引(user_id, created_at, product_id)user_id等值created_at范围product_id在范围后GROUP BY product_id可利用索引顺序分组MySQL 5.7支持松散索引扫描Loose Scan。ORDER BY COUNT(*) DESC仍需排序但分组已优化。极致顺序性(user_id, created_at, product_id, amount)。amount加入后AVG(amount)可覆盖计算COUNT(*)和AVG(amount)均在索引中完成Extra变为Using index。但ORDER BY COUNT(*) DESC仍需排序。此时引入冗余性建(user_id, created_at, product_id, amount)(user_id, created_at, product_id, amount, count_star)生成列后者让COUNT(*)结果固化ORDER BY可直接用索引。3.4 三角协同一个索引解决三个问题的实战案例最终我们为订单表设计了一个“超级索引”同时优化Q1、Q2、Q3ALTER TABLE orders ADD INDEX idx_user_status_created_amount_id (user_id, status, created_at DESC, amount, id), ADD INDEX idx_status_user_created_product_amount (status, user_id, created_at, product_id, amount);第一个索引服务Q1user_id等值status等值created_at DESC匹配排序amount和id覆盖SELECT。第二个索引服务Q2和Q3status等值Q2的IN被转为多个等值user_id等值Q2的GROUP BYcreated_at范围Q3的product_id分组Q3amount聚合Q2/Q3。验证效果Q1 P99从850ms→65msQ2从3.2s→210msQ3从4.7s→380ms。索引总大小增加28GB但数据库CPU使用率下降37%慢查询告警归零。关键心得不要幻想一个索引解决所有问题。黄金三角的本质是——为每个核心查询模式设计一个“专属索引”用覆盖性消除回表用顺序性支撑分组排序用冗余性容忍存储成本。这是MySQL优化最朴实也最有效的哲学。4. 分页的“死亡OFFSET”从游标分页到延迟关联的五种破局方案LIMIT M, N即OFFSET M LIMIT N是MySQL分页的“原罪”。当M增大性能呈线性衰减。OFFSET 100000意味着MySQL必须扫描并跳过前100000行无论这些行是否满足WHERE条件。我见过最极端案例一个日志表分页查询LIMIT 999999, 20耗时17秒EXPLAIN显示rows1000019Extra: Using where; Using filesort。此时任何索引优化都杯水车薪。必须跳出OFFSET思维采用根本性替代方案。4.1 游标分页Cursor-based Pagination用“上次结束位置”代替“跳过多少行”游标分页的核心思想是不告诉数据库“跳过前N行”而是告诉它“从上次查询的最后一条记录之后开始”。这要求排序字段必须唯一且有索引。Q1优化前SELECT * FROM orders WHERE status1 ORDER BY created_at DESC LIMIT 20 OFFSET 10000。优化后游标分页-- 首页无游标 SELECT id, created_at, amount FROM orders WHERE status1 ORDER BY created_at DESC, id DESC LIMIT 20; -- 下一页用上一页最后一条的created_at和id作为游标 SELECT id, created_at, amount FROM orders WHERE status1 AND (created_at 2023-05-10 14:22:33 OR (created_at 2023-05-10 14:22:33 AND id 123456789)) ORDER BY created_at DESC, id DESC LIMIT 20;关键点ORDER BY必须包含唯一字段如id作为第二排序键避免created_at重复时结果不一致。WHERE条件中用和组合精确锚定游标位置。此方案将OFFSET 10000的查询耗时从3.2秒降至85ms且M增大时性能几乎不变。我们线上所有列表页均已切换为游标分页。前端传递cursor2023-05-10T14:22:33Z_123456789ISO时间戳ID后端解析为WHERE ... AND (created_at ? OR (created_at ? AND id ?))。QPS从200提升至2200P99稳定在90ms内。注意游标分页不支持“跳转到任意页”只支持“下一页/上一页”。这对用户体验是妥协但对系统稳定性是巨大收益。我们通过前端缓存首页和热门页如第1、10、50页来弥补。4.2 延迟关联Deferred Join用“窄索引”先定位ID再回表取数据延迟关联是处理宽表分页的经典方案。其思路是先用最小化的索引只含WHERE和ORDER BY字段快速找出满足条件的主键ID再用这些ID回原表取完整数据。这避免了在宽表上直接排序和分页。Q4SELECT * FROM orders WHERE user_id123 ORDER BY created_at DESC LIMIT 20 OFFSET 10000。orders表有20字段SELECT *导致回表成本极高。延迟关联步骤-- Step1: 用窄索引获取ID覆盖索引无回表 SELECT id FROM orders WHERE user_id123 ORDER BY created_at DESC LIMIT 20 OFFSET 10000; -- Step2: 用ID集合回表取完整数据主键查询最快 SELECT * FROM orders WHERE id IN (12345, 67890, ...); -- 上一步得到的20个ID但IN列表有长度限制MySQL默认1000且OFFSET 10000在Step1仍慢。终极方案用JOIN替代IN。SELECT o.* FROM orders o INNER JOIN ( SELECT id FROM orders WHERE user_id123 ORDER BY created_at DESC LIMIT 20 OFFSET 10000 ) AS tmp ON o.id tmp.id;EXPLAIN显示子查询tmp用索引(user_id, created_at DESC)rows10020仍需扫描但只返回id窄key_len小速度快主表o通过主键id关联type: eq_ref极快。整体耗时从2.1秒降至320ms。我们为用户订单页实施此方案。索引(user_id, created_at DESC)仅17字节B树深度浅OFFSET 10000扫描10020行比全表扫描快得多。配合应用层缓存Step1结果ID列表P99降至110ms。4.3 子查询分页用“主键范围”替代OFFSET当排序字段有索引但不唯一时游标分页难实现如ORDER BY status, created_atstatus只有几个值。此时可用子查询分页先查出目标页的主键范围再用范围查询取数据。Q5SELECT * FROM products WHERE category_id45 ORDER BY price ASC LIMIT 20 OFFSET 10000。price有大量重复值无法用游标。子查询方案-- Step1: 查出第10001到10020条记录的price和id边界 SELECT MIN(price) as min_price, MAX(price) as max_price, MIN(id) as min_id, MAX(id) as max_id FROM ( SELECT price, id FROM products WHERE category_id45 ORDER BY price ASC, id ASC LIMIT 20 OFFSET 10000 ) AS page_boundary; -- Step2: 用边界条件取数据需处理price重复 SELECT * FROM products WHERE category_id45 AND ((price ?) OR (price ? AND id ?)) AND ((price ?) OR (price ? AND id ?)) ORDER BY price ASC, id ASC LIMIT 20;Step1的子查询仍用OFFSET但只返回4个值rows小速度快。Step2用范围条件避免OFFSET。我们实测OFFSET 10000时此方案比原查询快4.3倍。4.4 物化分页表Materialized Pagination Table为超高频分页建专用视图当某分页查询QPS极高如首页商品列表且数据更新不频繁如每小时同步可建物化分页表。其本质是预计算分页结果存入一张独立表用定时任务刷新。建表CREATE TABLE products_page_cache ( page_num INT NOT NULL, offset_start INT NOT NULL, limit_count INT NOT NULL, product_ids TEXT NOT NULL, -- JSON数组或逗号分隔 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (page_num) );定时任务每小时INSERT INTO products_page_cache (page_num, offset_start, limit_count, product_ids) SELECT FLOOR((row_number : row_number 1) / 20) 1 AS page_num, (row_number - 1) AS offset_start, 20 AS limit_count, GROUP_CONCAT(id ORDER BY price ASC SEPARATOR ,) AS product_ids FROM products, (SELECT row_number : 0) r WHERE category_id45 ORDER BY price ASC, id ASC;查询时SELECT p.* FROM products p INNER JOIN products_page_cache c ON FIND_IN_SET(p.id, c.product_ids) WHERE c.page_num 501; -- 直接取第501页此方案将P99从1.8秒降至15ms但牺牲了实时性最多1小时延迟。我们用于“热销榜”、“新品榜”等业务场景用户接受度极高。4.5 应用层分页把分页逻辑从数据库移到应用内存当数据量不大100万行且内存充足时最简单粗暴的方案是一次性查出所有数据在应用层分页。这听起来反直觉但对小数据集网络传输和内存排序的开销远小于数据库的OFFSET扫描。Q6SELECT name, email FROM users WHERE depttech ORDER BY hire_date DESC。users表tech部门仅8000人。应用层代码Python# 一次性查出所有tech用户 all_users db.query(SELECT id, name, email, hire_date FROM users WHERE depttech) # 按hire_date倒序排序内存排序O(n log n) all_users.sort(keylambda x: x[hire_date], reverseTrue) # 取第10001-10020页 page_data all_users[10000:10020]耗时数据库查询120ms Python排序8ms 128ms。而OFFSET 10000查询需扫描10020行耗时450ms。内存排序8000个对象比数据库B树遍历10020行快得多。我们为内部管理后台的员工列表采用此方案。dept字段选择率高结果集小应用层分页成为最优解。5. GROUP BY的“聚合陷阱”从临时表到物化视图的性能跃迁GROUP BY常被当作“去重”工具但它的本质是分组聚合计算性能瓶颈远不止于索引。当分组键基数高如GROUP BY user_id有百万分组、或聚合函数复杂如GROUP_CONCAT、JSON_AGG、或需多层嵌套时MySQL极易创建巨大的临时表。我将用三个真实案例展示如何从底层原理出发绕过临时表实现性能质变。5.1 松散索引扫描Loose Index Scan让GROUP BY“偷懒”跳过全扫描MySQL的松散索引扫描是GROUP BY优化的隐藏王牌。其原理是当索引顺序与GROUP BY字段完全一致且无范围条件时MySQL可跳过同一分组内的所有行只取每个分组的第一行进行聚合极大减少扫描行数。Q7SELECT user_id, COUNT(*) FROM orders GROUP BY user_id。orders表有1.2亿行user_id有800万不同值。若索引是(user_id)EXPLAIN显示type: indexrows120000000Extra: Using index。但COUNT(*)需统计每个user_id的行数MySQL仍需扫描全部1.2亿行。启用松散索引扫描建索引(user_id, id)id是主键确保索引有序。EXPLAIN的Extra变为Using index for group-byrows从1.2亿降至800万分组数。原因MySQL用(user_id, id)索引对每个user_id只读取该分组的第一行因id递增第一行id最小然后跳到下一个user_id的首行无需扫描分组内所有行。COUNT(*)通过计算跳过的行数差值得出。实测耗时从28秒降至3.1秒rows减少93%。这是GROUP BY最高效的优化方式但要求索引前缀严格匹配GROUP BY字段且无WHERE范围条件。5.2 索引覆盖聚合用索引直接完成SUM、AVG、MIN、MAX当聚合函数是SUM、AVG、MIN、MAX、COUNT时若相关字段在索引中MySQL可直接在索引B树的叶子节点上计算无需回表或临时表。Q8SELECT user_id, SUM(amount), AVG(amount) FROM orders WHERE status1 GROUP BY user_id。索引(status, user_id, amount)status1等值user_id分组amount聚合。EXPLAIN显示Extra: Using indexrows500000满足status1的行数。但SUM和AVG需对每个user_id分组内的amount求和/平均MySQL仍需扫描所有50万行。优化建索引(status, user_id, amount)并确保amount在索引中。SUM(amount)可直接在索引叶子节点累加因amount