☰
从表设计到预汇总:让销售报表查询从4.8秒降到350毫秒
2026/10/19 5:06:43 网站建设 项目流程

接到这个需求的时候,运营同学就发来一句话:把近30天每个商品每天卖了多少单、多少钱、多少人买,按天拉出来,放到报表页面。听起来简单。但当我看到订单表数据量的时候——订单主表540多万行,明细表1600万行——我知道,这条SQL要想变得高效,光靠改语句肯定不够,问题一定出在更靠前的表设计上。

这篇文章记录的就是一个真实查询需求从表设计走到高效SQL的完整链路。内容包括:需求里那些容易被忽略的边界条件、最初表结构为什么会让查询注定慢、慢查询日志和EXPLAIN的完整排查过程、通过冗余字段和复合索引把4.8秒压到350毫秒的实操方案,以及把这类固定报表升级为预汇总表架构的最终选择。适合运营后台、报表系统、数据仓库方向的读者参考。

1. 一条销售统计需求背后的隐藏约束

1.1 需求原文与页面表象

先还原一下需求原文。运营同学的需求卡片是这样写的:

  • 报表名称:商品每日销售统计
  • 统计口径:近30天,按天、按商品维度
  • 指标:订单数量、销售金额、购买人数
  • 筛选条件:商品分类ID,可选
  • 页面交互:翻页、按日期升序、金额降序

看上去平平无奇。凡是做过报表系统的都知道,这类需求的核心矛盾不在前端,而在数据查询。你每次点击“查询”按钮,背后都是一条聚合SQL在跑。如果这条SQL不能在一个可接受的时间范围内返回结果,页面做得再漂亮都没有意义。

1.2 需求里没写出来的三个约束

要把这个需求变成能上线的SQL,有三件事在需求卡片上没写清楚,但它们直接决定了后面的技术方案究竟是“调SQL”还是“动表结构”。

第一,数据量级。当时订单主表大约540万行,订单明细表1600万行,商品表4万行左右。这个量级不算特别巨大,但已经过了“随便JOIN都能秒回”的阶段。两个千万级大表做关联聚合,索引踩不准的话,慢查询就会准时出现。而且报表页面不是一次性取数,是运营每天高频反复使用的入口,性能的上限要求比“临时跑一次”高得多。

第二,查询频率和交互模式。报表挂在后台页面上,运营会反复切换分类、拖动时间范围去看数据。每切换一次,就是一次同样的聚合SQL被执行。如果页面加载超过三秒,体验就很难看。这意味着我们不能指望“写一条能跑通的SQL”就交差,必须考虑让这条语句在高频场景下也稳定。

第三,统计口径。比如“购买人数”到底是什么?同一个用户一天买了三个商品,算三个人还是一个人?“销售金额”算不算取消订单?是用户实付分摊后的金额,还是商品标价?这些需求原文里一个都没写。拿到需求后,我第一时间找运营确认了口径:金额按用户实付分摊后的金额统计,订单状态必须是已支付且未取消,购买人数按去重用户数计算,取消的订单不进入统计。

这个确认过程至关重要。因为口径一旦定错,后面SQL写得再高效,跑出来的数字也是错的。性能问题和数据正确性问题叠在一起时,优先解决数据正确性,这是报表开发的第一法则。

2. 表结构建错了方向:为什么这条查询从一开始就注定慢

2.1 当时的表结构长什么样

先看当时的表结构,这是理解后面所有优化的基础。

orders表(订单主表)字段:id、order_no、user_id、status、total_amount、created_at。主键id,普通索引在user_id和created_at上。

order_items表(订单明细表)字段:id、order_id、product_id、product_name、quantity、price、amount。主键id,普通索引在order_id和product_id上。

products表(商品表)字段:id、product_name、category_id、status。

这个结构在写入端非常正常。订单明细依附订单主表,product_id关联商品表,数据冗余控制得很规范。但站在统计查询的角度,问题一眼就能看出来:时间字段只存在于orders表,而明细表order_items才是真正按商品维度展开数据的地方。

2.2 三个字段缺口让查询注定慢

当时的统计需求要按天和按商品两个维度聚合。商品ID在明细表上有,但时间必须通过JOIN orders表才能拿到。于是,一条看似简单的聚合SQL,被迫先做一次明细表和订单主表的大规模关联,然后再做分组聚合。大表JOIN大表,光是中间结果集就够优化器喝一壶的。

订单状态也只存在于orders表。为了排除取消订单,查询还得继续依赖orders.status字段。这等于把“过滤条件”也压在了JOIN上。更要命的是,购买人数统计需要user_id,这个字段同样只在orders表里。三个查询必需字段全部集中在主表上,明细表在查询表达式里几乎是“半残疾”状态。

为什么说这是设计方向的错误?因为从查询模型出发,order_items应该承担“事实表”的角色。所有统计要用的维度和过滤字段,都应该能在这个表上独立完成过滤。订单创建时间、用户ID、订单状态,虽然从业务逻辑上属于订单主表,但在统计查询语境下,它们扮演的是“明细行的属性”,不是“订单行的属性”。把这些字段冗余到明细表,查询时就不需要再回头找主表。

最初设计表结构的人没想错,他们遵循的是范式思维:订单数据放订单表,明细数据放明细表,各存各的字段。但这套范式思维放在高频报表查询上,代价就是每次统计都要做无谓的JOIN。范式设计照顾了写入端的一致性,却牺牲了查询端的性能。真正的工程问题是:写入要范式和规范,查询要尽量一把梭,最好让WHERE、GROUP BY、ORDER BY都能在一个表上完成。两者冲突时,靠冗余字段和索引来调和,而不是靠SQL去硬扛。

3. 第一版SQL实测:4.8秒慢查询和EXPLAIN里的三个警报

3.1 第一版SQL和它的4.8秒

在改表之前,我按当时的表结构把第一版SQL写了出来,目的是跑出性能基线。这条语句也代表了很多人的第一直觉写法:需要什么就JOIN什么,过滤条件放在最外层。

SELECT DATE(o.created_at) AS stat_date, p.id AS product_id, p.product_name, COUNT(DISTINCT oi.order_id) AS order_cnt, SUM(oi.amount) AS sale_amount, COUNT(DISTINCT o.user_id) AS user_cnt FROM order_items oi INNER JOIN orders o ON oi.order_id = o.id INNER JOIN products p ON oi.product_id = p.id WHERE o.created_at >= '2024-01-01' AND o.created_at < '2024-02-01' AND p.category_id = 3 AND o.status = 1 GROUP BY DATE(o.created_at), p.id, p.product_name ORDER BY stat_date ASC, sale_amount DESC;

实测下来,耗时稳定在4.8秒左右,慢查询日志直接捞到。我第一次跑完就知道,这条语句不可能直接上线。但为了搞清楚慢在哪个环节,我用EXPLAIN看了一眼执行计划,结果非常典型。

3.2 EXPLAIN里的三个警报

优化器的执行计划大概长这样:

id select_type table type key rows Extra 1 SIMPLE p ref idx_category_id 42 Using index condition 1 SIMPLE oi ref idx_product_id 118234 Using where 1 SIMPLE o eq_ref PRIMARY 1 Using where

第一眼就能看到三个警报。

第一个警报是rows=118234。优化器估算order_items表要扫描大约12万行。原因是idx_product_id只用了product_id一个列,WHERE里还有个oi.order_id = o.id的关联条件,关联时要回表确认created_at是否在时间范围内。时间范围过滤不在明细表上,优化器根本没有办法在明细表上用范围过滤,只能把满足product_id的大量明细行从磁盘拉出来,一批一批匹配,这是很大的I/O开销。

第二个警报是Using temporary。GROUP BY里有p.product_name这个字符串字段,还有DATE(o.created_at)这个函数表达式。分组键既不是索引列,也不是字段本身的原始类型,MySQL只能把中间结果放到临时表里去做分组。数据量一大,临时表甚至可能落到磁盘,耗时会急剧上升。

第三个警报是Using filesort。ORDER BY stat_date, sale_amount DESC,两个排序字段都不是真正的索引顺序。加上GROUP BY本身也会触发排序,MySQL必须把完整结果集搬进内存或临时表,再做一次文件排序。排序的数据量等同于满足条件的所有明细行数,开销非常可观。

我额外做了一个对照实验:把ORDER BY去掉,同样的查询跑到3.1秒;再把GROUP BY里的product_name去掉,降到2.2秒。排序和分组字符串字段,是这条慢查询的两个大头。

3.3 业务口径比性能更早翻车

还有一个不是性能问题、但比性能问题更致命的问题。第一次写SQL时,我漏掉了o.status = 1这个条件,统计出来的销售额比上线口径高出不少。运营同学看到数字第一反应就是“不对”。这说明在性能优化之前,业务口径必须先行。如果一个查询的结果本身是错的,你优化的只是一个错误答案的执行速度,等于白干。

在这条SQL上,口径错误来源于当初表结构里订单状态不在明细表上,写SQL时容易忽略。这也再次印证了前面的判断:把过滤字段放到正确的位置,不只是为了性能,也是在降低写错SQL的概率。字段缺在哪里,坑就在哪里。

4. 冗余字段与复合索引:把4.8秒压到350毫秒的整个重构链路

4.1 第一步:千万级明细表加冗余字段

确认口径、确认性能基线之后,接下来就是动表了。这一步必须有充分把握,因为生产环境改表不是小事,尤其order_items是千万级数据量表。

我按三步走的方案重构,每一步都值得单独说说。

第一步,给order_items冗余三个查询关键字段:created_at、user_id、order_status。这三兄弟正是前面分析出的“查询必需但不在明细表上”的字段。

ALTER TABLE order_items ADD COLUMN created_at DATETIME NOT NULL DEFAULT '1970-01-01 00:00:00' COMMENT '订单创建时间(冗余自orders)', ADD COLUMN user_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '用户ID(冗余自orders)', ADD COLUMN order_status TINYINT NOT NULL DEFAULT 0 COMMENT '订单状态(冗余自orders)';

这里有一个非常容易踩的坑:ALTER TABLE在千万级表上直接执行,会长时间锁表,业务写入会被卡死。当时线上的做法是分批执行,或者借助在线DDL工具,在业务低峰期操作。如果你们团队没有成熟的在线DDL方案,一定要先跟DBA确认可执行窗口,不要贸然在生产库上跑大表ALTER。

字段加完之后,要回填数据。回填SQL大致是这样:

UPDATE order_items oi INNER JOIN orders o ON oi.order_id = o.id SET oi.created_at = o.created_at, oi.user_id = o.user_id, oi.order_status = o.status WHERE oi.id BETWEEN ? AND ?;

这种大表回填必须按主键范围分批更新,一次别带几十万行,否则事务日志和锁开销都会很难看。我当时的批大小是5000行,配合低峰期执行,整体跑完之后,抽样校验了几个时间段的COUNT,确认和orders表一致才继续下一步。

需要特别强调一点:回填只是补救。真正的正解是在下单写入流程里同步写入这三个字段,让新产生的数据天然带着冗余。应用侧务必同步改写入逻辑,否则改完表,新单还是默认值,等于白改。数据一致性靠应用层保证,而不靠查询时实时JOIN,这是这类冗余方案的基本前提。

4.2 第二步:复合索引的取舍

字段冗余做完,接下来是索引。这里要把需求里的查询模式拆开看:WHERE是p.category_id = 3 AND oi.created_at在一个时间区间内,GROUP BY是日期和商品ID。核心矛盾在于:是先按商品找、再按时间过滤,还是先按时间找、再按商品分组。

我当时设计了两个候选索引:

-- 方案A:以商品ID打头,适合“分类商品少、时间范围大”的场景 ALTER TABLE order_items ADD KEY idx_product_created_at (product_id, created_at, amount, quantity); -- 方案B:以时间打头,适合“时间窗口窄、商品多”的场景 ALTER TABLE order_items ADD KEY idx_created_at_product (created_at, product_id);

实际效果上,因为商品分类3下面的商品就几十个,每个商品在一个月内的明细量中等,方案A的效果更稳定。更重要的是,这个索引把amount、quantity两个聚合字段一起包进来,变成了覆盖索引。

覆盖索引的含义是:查询需要的列都存在于索引页里,不需要回表去拿数据行。GROUP BY和SUM的字段都能从索引里直接取出来,I/O再省一大截。这也是一个通用经验:在设计复合索引时,能顺手覆盖聚合用到的列,就尽量覆盖。

4.3 第三步:SQL重写与效果对比

字段冗余和索引到位之后,重写SQL就有了底气。最终版本把JOIN和过滤全部改成能落在一个表上的形式:

SELECT DATE_FORMAT(oi.created_at, '%Y-%m-%d') AS stat_date, p.id AS product_id, MAX(p.product_name) AS product_name, COUNT(DISTINCT oi.order_id) AS order_cnt, SUM(oi.amount) AS sale_amount, COUNT(DISTINCT oi.user_id) AS user_cnt FROM order_items oi INNER JOIN products p ON oi.product_id = p.id WHERE oi.created_at >= '2024-01-01' AND oi.created_at < '2024-02-01' AND p.category_id = 3 AND oi.order_status = 1 GROUP BY DATE_FORMAT(oi.created_at, '%Y-%m-%d'), p.id ORDER BY stat_date ASC, sale_amount DESC;

这里每个改动点都有明确目的。

created_at现在在明细表上,时间范围的过滤不再依赖任何JOIN。订单状态冗余在明细表上,排除取消单也不需要回头JOIN orders。GROUP BY里彻底去掉了product_name字符串字段,只保留p.id,product_name用MAX()带出来。分组键是int,临时表和排序的压力都小很多。orders表从整个查询里消失,少了一个表关联,优化器的工作量直线下降。

优化后的执行计划变成了:

id select_type table type key rows Extra 1 SIMPLE p ref idx_category_id 42 1 SIMPLE oi ref idx_product_created_at 38920 Using index condition

没有Using temporary,没有Using filesort,rows从11万降到3.9万。实测耗时稳定在350毫秒左右。对比最初的4.8秒,整整压掉了93%。

这个结果也反过来验证了前面的判断:让过滤、分组、排序字段都落在同一张表上,SQL优化的空间立刻打开。技巧层面其实没什么高深的东西,真正的瓶颈是数据模型,不是SQL语法。

5. 报表查询的终极选择:从每次跑明细到预汇总表

5.1 为什么350毫秒还是不满足

到这里,单条查询的优化已经做得比较到位。350毫秒在传统查询场景下也是可接受的水平。但真正上线前我又犹豫了一下:报表不是只跑一次,运营每次切换分类、拖动时间范围,背后都是一次同样的聚合。如果并发上来,或者时间范围从30天放大到90天,350毫秒会变成1秒多,体验还是会劣化。

更本质的问题是:这类统计需求是典型的“固定维度的固定指标”。无论运营怎么切换页面,底层的计算逻辑完全相同——扫描满足条件的明细、按日期和商品分组、去重和求和。每次都在重复做同一件事,为什么不把结果提前算好?这就是预汇总表的思路。

5.2 汇总表结构和更新策略

我设计的汇总表结构很直接:

CREATE TABLE stats_daily_product ( stat_date DATE NOT NULL COMMENT '统计日期', product_id BIGINT NOT NULL COMMENT '商品ID', product_name VARCHAR(128) NOT NULL COMMENT '商品名', category_id INT NOT NULL COMMENT '分类ID', order_cnt INT NOT NULL COMMENT '有效订单数', sale_amount DECIMAL(14,2) NOT NULL COMMENT '销售金额', user_cnt INT NOT NULL COMMENT '去重购买人数', PRIMARY KEY (stat_date, product_id), KEY idx_category_date (category_id, stat_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品每日销售汇总';

更新策略选了T+1的定时任务:每天凌晨2点,把前一天从明细表聚合出来,写入汇总表。核心的生成SQL如下:

INSERT INTO stats_daily_product (stat_date, product_id, product_name, category_id, order_cnt, sale_amount, user_cnt) SELECT DATE(oi.created_at), p.id, MAX(p.product_name), MAX(p.category_id), COUNT(DISTINCT oi.order_id), SUM(oi.amount), COUNT(DISTINCT oi.user_id) FROM order_items oi INNER JOIN products p ON oi.product_id = p.id WHERE oi.created_at >= '2024-01-01' AND oi.created_at < '2024-02-01' AND oi.order_status = 1 GROUP BY DATE(oi.created_at), p.id ON DUPLICATE KEY UPDATE product_name = VALUES(product_name), order_cnt = VALUES(order_cnt), sale_amount = VALUES(sale_amount), user_cnt = VALUES(user_cnt);

汇总表建好之后,报表页面的查询SQL就变得非常简单:

SELECT stat_date, product_id, product_name, order_cnt, sale_amount, user_cnt FROM stats_daily_product WHERE category_id = 3 AND stat_date >= '2024-01-01' AND stat_date < '2024-02-01' ORDER BY stat_date ASC, sale_amount DESC LIMIT 20 OFFSET 0;

这个查询走的是idx_category_date复合索引,加上主键的后续访问,基本稳定在毫秒级。报表页面怎么翻都不怕,也不会因为业务量增长而劣化到不可用。

5.3 实时性权衡与适用边界

做这个选择时也想过实时性问题。运营团队看的是日报趋势,T+1的数据完全够用。如果真有业务场景需要看到当天实时数据,可以再加一条增量统计任务,每30分钟把新增订单聚合一次,丢进同一个汇总表,用ON DUPLICATE KEY UPDATE合并,一样可行。报表要的是稳定和快,不是秒级新鲜度,这是这个场景下的客观权衡。

当然,预汇总不是银弹。如果业务是灵活的探索式分析,查询维度随时在变,预汇总表就无法覆盖,只能回到明细查询配合索引优化的路线。但这次的需求是典型固定报表,维度、指标、筛选都明确,预汇总表就是性价比最高的解法。判断标准也很简单:同一套查询会不会被反复执行,如果是,就值得预汇总。

6. 从这次需求沉淀下来的四条表结构设计规范

6.1 查询模型要反向传递到表结构

写SQL的人抱怨“这个字段怎么还要JOIN来JOIN去”,问题往往不是SQL写得差,而是表设计时没有考虑查询模型。凡是报表查询里常用的过滤字段、分组字段、排序字段,在没有更新一致性风险的前提下,默认把它们冗余到明细表上。这次需求里的created_at、user_id、order_status,就是活生生的案例。

6.2 复合索引要跟着真实SQL走

不要只想着每个列加一个单列索引。单列索引再多,也顶不住一个范围过滤加分组加聚合的复合查询。设计复合索引时,从最频繁执行的SQL出发,把WHERE等值列、范围列、GROUP BY列、ORDER BY列按顺序放进去,能覆盖聚合字段就尽量覆盖。同时记住最左前缀原则,字段顺序决定了索引能不能被命中。

6.3 统计口径比执行计划更值得先确认

购买人数是去重用户数,不是COUNT(user_id)。销售金额是用户实付分摊,不是订单原价总和。订单状态要不要过滤,过滤哪个状态,这个必须跟业务方对清楚。口径错了,SQL调得再快也没有意义。上线之前,拿一个已知事实的小数据集,把SQL输出和手工核算结果核对一遍,永远是值得的。

6.4 稳定报表需求直接上预汇总

不要迷恋“这条SQL已经到350毫秒了”。如果这是一个每天被查询几百次的固定报表,350毫秒和3毫秒的差距,就是用户体验和资源开销的双重差距。把“跑明细”变成“查结果”,是报表系统的标配玩法。先确认维度是否固定、指标是否稳定,如果都满足,预汇总表越早建越好。

这次需求做下来,我最深的体会是:真正决定SQL效率的,往往不是SQL本身,而是SQL落地之前那几张表长什么样。如果表结构一开始就把查询模型考虑进去,所谓的高效SQL很多不过是顺手写出来而已。现在我每次接到查询需求,第一件事就是先问三个问题:这个查询多久跑一次、数据量级是多少、统计口径是什么。把这三个问题摸清,再去讨论要不要建索引、要不要建汇总表,思路就会清晰很多。希望这篇记录也能帮你少走几步弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询