Administrator
发布于 2020-11-24 / 1249 阅读
33

SQL 优化实战:从 8 秒到 200 毫秒

运营说:这个报表等到花儿都谢了

十一月底,运营同学提了个工单:"销售明细报表要等 8 秒以上,导出的时候更是直接超时。"

我看了下 slow log,那条查询平均 8.4 秒,最慢的一次 21 秒:

# Time: 2020-11-24T14:22:31.882113+08:00
# User@Host: appuser[appuser] @  [10.20.1.31]  Id: 88213
# Query_time: 21.441208  Lock_time: 0.000214 Rows_sent: 20  Rows_examined: 48213422
SET timestamp=1606198951;
SELECT o.order_no, o.create_time, u.nick_name, u.phone,
       (SELECT SUM(oi.amount) FROM t_order_item oi WHERE oi.order_no = o.order_no) AS total_amount,
       (SELECT COUNT(*) FROM t_order_item oi WHERE oi.order_no = o.order_no) AS item_count,
       (SELECT MAX(p.pay_time) FROM t_payment p WHERE p.order_no = o.order_no) AS last_pay_time
FROM t_order o
LEFT JOIN t_user u ON o.user_id = u.id
WHERE o.status = 'FINISHED'
  AND o.create_time >= '2020-10-01'
  AND o.create_time <  '2020-11-01'
ORDER BY o.create_time DESC
LIMIT 0, 20;

关键信息:Rows_sent: 20Rows_examined: 48213422返回 20 行,扫描了 4821 万行。这个比例说明优化空间巨大。

这个 SQL 是我自己两个月前写的,当时数据量小,跑了 300ms 就上线了。

第一步:EXPLAIN

mysql> EXPLAIN SELECT ... \G
*************************** 1. row ***************************
           id: 1
  select_type: PRIMARY
        table: o
   partitions: NULL
         type: ALL                      # 全表扫描!
possible_keys: idx_status, idx_create_time
          key: NULL                     # 一个索引都没用上
      key_len: NULL
          ref: NULL
         rows: 3821442
     filtered: 1.00
        Extra: Using where; Using filesort
*************************** 2. row ***************************
           id: 1
  select_type: PRIMARY
        table: u
         type: eq_ref
possible_keys: PRIMARY
          key: PRIMARY
      key_len: 8
         ref: shop.o.user_id
         rows: 1
     filtered: 100.00
        Extra: NULL
*************************** 3. row ***************************
           id: 2
  select_type: DEPENDENT SUBQUERY       # 相关子查询!
        table: oi
   partitions: NULL
         type: ALL                      # 又是全表扫描
possible_keys: NULL
          key: NULL
         rows: 12431122
     filtered: 10.00
        Extra: Using where

三个问题一目了然:

  1. 主表 o 走全表扫描,possible_keys 里有 idx_statusidx_create_time,但没用上。
  2. Using filesort,排序没走索引。
  3. 三个 DEPENDENT SUBQUERY,每个都是全表扫描 1243 万行的 t_order_item

先解决相关子查询

这是最大的问题。DEPENDENT SUBQUERY 的意思是子查询依赖外层查询的每一行的值。外层扫出多少行,子查询就执行多少次。

主表过滤后大概有 38 万行(10 月份的已完成订单),每行要跑 3 个子查询,每个子查询全表扫 1243 万行:

380000 × 3 × 12431122 ≈ 1.4 × 10^13 次行扫描

不过 Rows_examined 显示的是 4821 万,说明优化器做了一点优化(子查询里有 LIMIT 或者提前终止),但依然是个天文数字。

改成 JOIN + GROUP BY,子查询只执行一次:

SELECT o.order_no, o.create_time, u.nick_name, u.phone,
       agg.total_amount,
       agg.item_count,
       pay.last_pay_time
FROM t_order o
LEFT JOIN t_user u ON o.user_id = u.id
LEFT JOIN (
    SELECT order_no,
           SUM(amount)    AS total_amount,
           COUNT(*)       AS item_count
    FROM t_order_item
    GROUP BY order_no
) agg ON agg.order_no = o.order_no
LEFT JOIN (
    SELECT order_no, MAX(pay_time) AS last_pay_time
    FROM t_payment
    GROUP BY order_no
) pay ON pay.order_no = o.order_no
WHERE o.status = 'FINISHED'
  AND o.create_time >= '2020-10-01'
  AND o.create_time <  '2020-11-01'
ORDER BY o.create_time DESC
LIMIT 0, 20;

但这样写更糟。因为派生表(FROM 里的子查询)会先物化成临时表,它要先对 1243 万行的 t_order_item 做全量 GROUP BY,再跟外表 JOIN。实测耗时 34 秒,比原来还慢。

正确的做法是先过滤主表,再用过滤后的结果去 JOIN。用延迟关联(deferred join)的思路:

SELECT o.order_no, o.create_time, u.nick_name, u.phone,
       agg.total_amount, agg.item_count, pay.last_pay_time
FROM t_order o
LEFT JOIN t_user u ON o.user_id = u.id
LEFT JOIN (
    SELECT oi.order_no,
           SUM(oi.amount) AS total_amount,
           COUNT(*)       AS item_count
    FROM t_order_item oi
    INNER JOIN t_order o2 ON oi.order_no = o2.order_no
    WHERE o2.status = 'FINISHED'
      AND o2.create_time >= '2020-10-01'
      AND o2.create_time <  '2020-11-01'
    GROUP BY oi.order_no
) agg ON agg.order_no = o.order_no
LEFT JOIN t_payment pay ON pay.order_no = o.order_no
WHERE o.status = 'FINISHED'
  AND o.create_time >= '2020-10-01'
  AND o.create_time <  '2020-11-01'
ORDER BY o.create_time DESC
LIMIT 0, 20;

这个版本跑出来 5.2 秒,快了 60%。但还不够,主表的全表扫描和 filesort 还在。

第二步:解决主表的索引和排序

为什么现有索引没用上

表上有两个单列索引:

mysql> SHOW INDEX FROM t_order;
+---------+------------+------------------+--------------+-------------+-----------+
| Table   | Non_unique | Key_name         | Seq_in_index | Column_name | Cardinality |
+---------+------------+------------------+--------------+-------------+-----------+
| t_order |          1 | idx_status       |            1 | status      |          6 |
| t_order |          1 | idx_create_time  |            1 | create_time |    2841132 |
+---------+------------+------------------+--------------+-------------+-----------+

idx_status 的 Cardinality 只有 6(订单一共 6 种状态),区分度极差。用它要回表 38 万次,优化器评估下来不如直接全表扫描。

idx_create_time 区分度高(284 万),但 status 这个条件用不上,还是得回表过滤。

MySQL 5.6 之后有 index condition pushdown(ICP),能把 status 的过滤下推到存储引擎层,减少回表次数。但ICP 只对二级索引生效,而且要求过滤条件是索引的一部分。这里 status 不在 idx_create_time 里,ICP 帮不上忙。

建联合索引

查询条件是 WHERE status = ? AND create_time BETWEEN ? AND ?,排序是 ORDER BY create_time DESC。建一个 (status, create_time) 的联合索引:

ALTER TABLE t_order
  ADD INDEX idx_status_createtime (status, create_time);

-- MySQL 8.0 支持降序索引,ORDER BY create_time DESC 能直接用上
ALTER TABLE t_order
  ADD INDEX idx_status_createtime_desc (status, create_time DESC);

加完之后 EXPLAIN:

*************************** 1. row ***************************
           id: 1
  select_type: PRIMARY
        table: o
   partitions: NULL
         type: range                    # 从 ALL 变成 range
possible_keys: idx_status_createtime
          key: idx_status_createtime
      key_len: 87
          ref: NULL
         rows: 381422
     filtered: 100.00
        Extra: Using index condition; Using where    # filesort 消失了

Using filesort 消失了——因为 (status, create_time) 这个索引本身就有序,取到的数据天然按 create_time 排好序,不需要额外排序。

耗时从 5.2 秒降到 1.8 秒。

为什么 DESC 索引没带来额外收益

我特意试了 MySQL 8.0 的降序索引 (status, create_time DESC),结果跟普通索引耗时一样(1.78s vs 1.81s)。原因是MySQL 的 B+ 树索引支持双向扫描——叶子节点之间有双向链表,正序倒序扫都可以,只是扫描方向不同。降序索引真正的价值在混合排序场景,比如 ORDER BY a ASC, b DESC,这种情况下普通索引无论如何都要 filesort。

第三步:覆盖索引,消除回表

现在主表的扫描是 38 万行,每行都要回表取 order_nouser_id 这些字段。如果把这些字段都放进索引,就能避免回表。

ALTER TABLE t_order
  DROP INDEX idx_status_createtime,
  ADD INDEX idx_status_ct_cover (status, create_time, order_no, user_id);
         type: range
          key: idx_status_ct_cover
      key_len: 87
         rows: 381422
        Extra: Using where; Using index       # 出现 Using index,覆盖索引生效

Using index 说明所有需要的字段都在索引里,不需要回表查聚簇索引。耗时从 1.8 秒降到 1.1 秒。

但覆盖索引有代价:索引变大了,占用的内存和磁盘更多,也会拖慢写入。我们这个索引从原来的 87 字节/行涨到 121 字节/行,索引大小从 220MB 涨到 306MB。这是可以接受的——报表查询是高频操作,而这张表的写入 QPS 只有 200。

第四步:解决 LIMIT 20 却扫 38 万行的问题

现在还有个浪费:我们只需要 20 行,却要把 38 万行全部处理完才能排序取前 20。虽然排序已经走索引了,但 JOIN 和聚合还是要把 38 万行都算一遍。

改成延迟关联:先在主表里用覆盖索引找出这 20 个 order_no,再拿这 20 个去 JOIN 其他表:

SELECT o.order_no, o.create_time, u.nick_name, u.phone,
       agg.total_amount, agg.item_count, pay.last_pay_time
FROM (
    -- 子查询只扫索引,且只取 20 行
    SELECT id, order_no, user_id, create_time
    FROM t_order
    WHERE status = 'FINISHED'
      AND create_time >= '2020-10-01'
      AND create_time <  '2020-11-01'
    ORDER BY create_time DESC
    LIMIT 0, 20
) o
LEFT JOIN t_user u ON o.user_id = u.id
LEFT JOIN (
    SELECT order_no, SUM(amount) AS total_amount, COUNT(*) AS item_count
    FROM t_order_item
    WHERE order_no IN ( /* 上面那 20 个 order_no */ )
    GROUP BY order_no
) agg ON agg.order_no = o.order_no
LEFT JOIN (
    SELECT order_no, MAX(pay_time) AS last_pay_time
    FROM t_payment
    WHERE order_no IN ( /* 同样 20 个 */ )
    GROUP BY order_no
) pay ON pay.order_no = o.order_no
ORDER BY o.create_time DESC;

需要在代码里分两步查:先查主表拿 20 个 order_no,再查明细。这样反而更简单,也不用在 SQL 里写那个 IN

public List<ReportRow> query(ReportQuery query) {
    // 第一步:只查主表,走覆盖索引,0.2 秒
    List<OrderBrief> briefs = orderMapper.queryBriefPage(query);
    if (briefs.isEmpty()) return Collections.emptyList();

    List<String> orderNos = briefs.stream()
            .map(OrderBrief::getOrderNo)
            .collect(Collectors.toList());

    // 第二步:批量查明细,20 个 order_no 走索引,0.03 秒
    Map<String, ItemAgg> itemAgg = itemMapper.aggByOrderNos(orderNos).stream()
            .collect(Collectors.toMap(ItemAgg::getOrderNo, Function.identity()));
    Map<String, LocalDateTime> payTime = paymentMapper.lastPayTimeByOrderNos(orderNos).stream()
            .collect(Collectors.toMap(PayTime::getOrderNo, PayTime::getLastPayTime));
    Map<Long, UserVO> users = userClient.batchGet(
            briefs.stream().map(OrderBrief::getUserId).collect(Collectors.toList()));

    // 第三步:内存里组装
    return briefs.stream().map(b -> assemble(b, itemAgg, payTime, users))
            .collect(Collectors.toList());
}
<!-- 第一步的 SQL,可以用覆盖索引 -->
<select id="queryBriefPage" resultType="OrderBrief">
    SELECT id, order_no, user_id, create_time
    FROM t_order
    WHERE status = 'FINISHED'
      AND create_time >= #{startTime}
      AND create_time <  #{endTime}
    ORDER BY create_time DESC
    LIMIT #{offset}, #{size}
</select>

拆成两步之后,第一趟 0.21 秒,第二趟 0.03 秒,加上组装总共 0.26 秒

第五步:给子表加索引

顺手确认一下子表的索引情况:

mysql> SHOW INDEX FROM t_order_item;
+---------------+------------+----------+--------------+-------------+
| Table         | Non_unique | Key_name | Seq_in_index | Column_name |
+---------------+------------+----------+--------------+-------------+
| t_order_item  |          1 | idx_ono  |            1 | order_no    |
+---------------+------------+----------+--------------+-------------+

t_order_itemidx_ono,OK。但 t_payment 没有:

mysql> SHOW INDEX FROM t_payment;
Empty set (0.00 sec)

一张表一个索引都没有,只有主键。EXPLAIN 里能看到它是全表扫描(type=ALL,rows=892341)。加索引:

ALTER TABLE t_payment ADD INDEX idx_ono_paytime (order_no, pay_time);

(order_no, pay_time) 这个联合索引对 SELECT order_no, MAX(pay_time) ... GROUP BY order_no 是覆盖索引,连回表都省了。

索引重建:别忘了 ANALYZE

加完索引之后,统计信息可能还是旧的,优化器会用错索引。跑一次:

mysql> ANALYZE TABLE t_order, t_order_item, t_payment;
+------------------+---------+----------+----------+
| Table            | Op      | Msg_type | Msg_text |
+------------------+---------+----------+----------+
| shop.t_order     | analyze | status   | OK       |
+------------------+---------+----------+----------+

mysql> SHOW INDEX FROM t_order;
-- 重新看 Cardinality,确认统计信息更新了

另外 t_order 表因为之前有大量 UPDATE,索引碎片率不低。查一下:

mysql> SELECT table_name, data_length, index_length,
              data_free,
              ROUND(data_free / (data_length + index_length) * 100, 2) AS frag_pct
       FROM information_schema.tables
       WHERE table_schema = 'shop' AND table_name = 't_order';
+------------+-------------+--------------+-----------+----------+
| table_name | data_length | index_length | data_free | frag_pct |
+------------+-------------+--------------+-----------+----------+
| t_order    |  2147483648 |   1207959552 | 734003200 |    21.88 |
+------------+-------------+--------------+-----------+----------+

碎片率 21.88%(data_free 是碎片空间)。MySQL 8.0 用这条命令重建表并整理碎片:

-- MySQL 8.0 推荐,Online DDL,不阻塞 DML
ALTER TABLE t_order FORCE, ALGORITHM=INPLACE, LOCK=NONE;

-- 或者通用的
OPTIMIZE TABLE t_order;

OPTIMIZE TABLE 在 8.0 里是 online 的,但会重建表并重建所有索引,耗时较长(我们这张 2GB 的表跑了 8 分钟)。要在低峰期做,而且要注意它会让主从延迟上涨。我们是在凌晨两点执行的,从库延迟峰值 4 分钟。

重建之后碎片率降到 0.8%,索引大小从 1.2GB 降到 890MB。查询再快了 30ms。

优化结果

阶段耗时扫描行数关键动作
原始 SQL8420ms4821 万
改 JOIN(错误改法)34100ms更糟派生表全量物化
改 JOIN(延迟关联)5210ms1243 万子查询只跑一次
加联合索引1810ms38 万消除 filesort
覆盖索引1120ms38 万消除回表
拆两步查260ms20 + 20LIMIT 提前
子表加索引 + 重建203ms20 + 20消除子表全扫

最终 203ms,比原来的 8420ms 快 41 倍。运营的反馈是"点下去就出来了"。

几个方法论

这次优化过程中反复用到的判断标准,写下来给自己备忘:

  • Rows_examined / Rows_sent 的比值。这个比值越大,优化空间越大。超过 1000 就值得动手。
  • EXPLAIN 三个字段优先看type(至少要到 range,最好 ref/eq_ref,ALL 和 index 都要警惕)、key(是否用上了预期的索引)、ExtraUsing filesortUsing temporary 是大忌)。
  • 看到 DEPENDENT SUBQUERY 先警觉。它意味着子查询要跑 N 遍。能改成 JOIN 就改。
  • 派生表(FROM 里的子查询)会物化。不要指望优化器帮你把外层条件下推进去(MySQL 8.0 有一定优化但不保证)。拆分到应用层查两次往往更快。
  • 联合索引的顺序:等值条件在前,范围条件在后,排序列跟在后面。(status, create_time)(create_time, status) 好,因为 status= 是等值,create_time 是范围,范围之后的列用不上索引。
  • 覆盖索引不是免费的。索引变大、写入变慢。只在读远大于写的场景用。

下篇预告

这篇先把《SQL 优化实战:从 8 秒到 200 毫秒》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考