2016 年,北京一位游戏公司的朋友说以下 SQL 最慢的时候要执行 40 分钟,最快的时候只需要几秒至十来秒就可以执行完毕。

    1. SELECT COUNT(DISTINCT IDFA)
    2. FROM SYS_ACTIVATION_SDK_IOS T1
    3. WHERE CREATE_TIME >= TRUNC(sysdate)
    4. AND CREATE_TIME < TRUNC(sysdate) + 1
    5. AND GAME_ID = 153
    6. AND NOT EXISTS (SELECT /*+ hash_aj */ IDFA
    7. FROM SYS_ACTIVATION_SDK_IOS T2
    8. WHERE CREATE_TIME < TRUNC(sysdate)-1
    9. AND T2.GAME_ID = 153
    10. AND T1.IDFA = T2.IDFA) ;

    执行计划如下。

    1. Execution Plan
    2. ------------------------------------------------------------------------
    3. Plan hash value: 3686453232
    4. ------------------------------------------------------------------------
    5. | Id | Operation | Name | Rows| Bytes |
    6. -------------------------------------------------------------------------
    7. | 0 | SELECT STATEMENT | | 1 | 76 |
    8. | 1 | SORT GROUP BY | | 1 | 76 |
    9. |* 2 | FILTER | | | |
    10. |* 3 | HASH JOIN ANTI | | 93 | 7068 |
    11. |* 4 | INDEX RANGE SCAN| SYS_ACTIVATION_SDK_IOS_IDX1 | 304 | 11552 |
    12. |* 5 | INDEX RANGE SCAN| SYS_ACTIVATION_SDK_IOS_IDX1 | 888K| 32M |
    13. -------------------------------------------------------------------------
    14. Predicate Information (identified by operation id):
    15. ---------------------------------------------------
    16. 2 - filter(TRUNC(SYSDATE@!)<TRUNC(SYSDATE@!)+1)
    17. 3 - access("T1"."IDFA"="T2"."IDFA")
    18. 4 - access("GAME_ID"=153 AND "CREATE_TIME">=TRUNC(SYSDATE@!) AND
    19. "CREATE_TIME"<TRUNC(SYSDATE@!)+1)
    20. 5 - access("T2"."GAME_ID"=153 AND "CREATE_TIME"<TRUNC(SYSDATE@!)-1)
    21. filter("T2"."IDFA" IS NOT NULL)

    该 SQL 是一个自关联,SQL 语句里面有 HASH: HASH_AJ 提示 SQL 采用 HASH ANTI JOIN 进行关联。该 SQL 的确走的是 HASH ANTI JOIN,而且都是通过同一个索引访问数据,没有回表。表 SYS_ACTIVATION_SDK_IOS 有 14G,索引 SYS_ACTIVATION_SDK_IOS_IDX1 有 2.5G,根据(game_id,create_time,idfa)创建。

    两表关联,我们要搞清楚表大小以及表过滤之后返回的行数。这里表大小已经清楚。

    查看 T1 返回行数。

    1. SELECT COUNT(DISTINCT IDFA)
    2. FROM SYS_ACTIVATION_SDK_IOS T1
    3. WHERE CREATE_TIME >= TRUNC(sysdate)
    4. AND CREATE_TIME < TRUNC(sysdate) + 1
    5. AND GAME_ID = 153;

    T1 返回 11 799 行数据。我们查看 T2 返回行数。

    1. select count(*)
    2. from (SELECT IDFA
    3. FROM SYS_ACTIVATION_SDK_IOS T2
    4. WHERE CREATE_TIME < TRUNC(sysdate) - 1
    5. AND T2.GAME_ID = 153);

    T2 返回 1 251 009 行数据。现在我们得到信息,小表 T1(11 799)与较大表 T2(1 251 009)进行关联。一般情况下,小表与大表关联,可以让小表作为 NL 驱动表,大表走连接列索引。在确定能否走 NL 之前,要先检查两个表之间的关系,同时检查表连接列的数据分布,于是我们执行如下 SQL。

    1. SELECT IDFA, COUNT(*)
    2. FROM SYS_ACTIVATION_SDK_IOS
    3. GROUP BY IDFA
    4. ORDER BY 2 DESC;

    我们发现 IDFA 基数很低,数据分布不均衡。因为 IDFA 基数很低,所以不能让 T1 与 T2 走嵌套循环,只能走 HASH 连接。执行计划中,T1 与 T2 本来就是走的 HASH 连接,连接方式是正确的,所以问题只能出现在访问路径上。T1 走的是 INDEX RANGE SCAN,返回了 11 799 行数据,T2 走的也是 INDEX RANGE SCAN,返回了 1 251 009 行数据。INDEX RANGE SCAN 是单块读,一般用于返回少量数据,这里返回 1 251 009 行数据显然不合适,因为 INDEX RANGE SCAN 没有回表,所以应该让其走 INDEX FAST FULL SCAN。

    1. SELECT COUNT(DISTINCT IDFA)
    2. FROM SYS_ACTIVATION_SDK_IOS T1
    3. WHERE CREATE_TIME >= TRUNC(sysdate)
    4. AND CREATE_TIME < TRUNC(sysdate) + 1
    5. AND GAME_ID = 153
    6. AND NOT EXISTS (SELECT /*+ hash_aj index_ffs(t2) */
    7. IDFA
    8. FROM SYS_ACTIVATION_SDK_IOS T2
    9. WHERE CREATE_TIME < TRUNC(sysdate) - 1
    10. AND T2.GAME_ID = 153
    11. AND T1.IDFA = T2.IDFA);

    最终该 SQL 可以在 1 分钟内执行完毕。该 SQL 跑得慢根本原因就是 INDEX RANGE SCAN 是单块读。

    为什么该 SQL 有时要执行 40 多分钟,而有时只需要执行几秒至十来秒呢?原因在于 buffer cache 缓存。当 buffer cache 缓存了索引 SYS_ACTIVATION_SDK_IOS_IDX1,SQL 就能在几秒至十几秒执行完毕;如果 buffer cache 没有缓存 SYS_ACTIVATION_SDK_IOS_IDX1,执行计划中 Id=5 走的是 INDEX RANGE SCAN,导致大量单块读,所以会执行 40 分钟左右。更正了执行计划之后,该 SQL 最慢可以在 1 分钟内执行完毕。