优化查询两个日期之间的工作日统计

Optimize the query of weekday statistics between two dates

我有一个包含两个字段的 table:start_dateend_date。现在我想统计加班的总人数。我创建了一个新的日历table来维护日期的工作日状态。

table:工作日

id                  status
2020-01-01          4
2020-01-02          1
2020-01-03          1
2020-01-04          2

4:节假日,1:工作日,2:周末

我创建了一个函数来计算两个日期之间的工作日(不包括周末、节假日)。

create or replace function get_workday_count (start_date in date, end_date in date)
return number is
    day_count int;
begin
    select count(0) into day_count from WORKDAYS
    where TRUNC(ID) >= TRUNC(start_date)
    and TRUNC(ID) <= TRUNC(end_date)
    and status in (1, 3, 5);
    return day_count;
end;

当我执行下面的查询语句时,大约需要5分钟才能显示结果,erp_sj table大约有200000行数据。

select count(0) from ERP_SJ GET_WORKDAY_COUNT(start_date, end_date) > 5;

查询语句中使用的字段被索引。

如何优化?或者有更好的解决方案吗?

我知道列 IDstatus 上有索引(不是 TRUNC(ID) 上的功能索引)。所以使用这个查询

SELECT count(0)
  INTO day_count
  FROM WORKDAYS
 WHERE ID BETWEEN TRUNC(start_date) AND TRUNC(end_date)
   AND status in (1, 3, 5);

为了能够利用日期列 ID 上的索引。

可以试试Scalar Subquery Caching

(如果有很多 erp_sj 记录具有相同的 start_dateend_date

select count(0) from ERP_SJ where
 (select GET_WORKDAY_COUNT(start_date, end_date) from dual) > 5

首先,优化你的功能: 1.adding pragma udf(为了在 sql 中更快地执行 2. 添加确定性子句(用于缓存) 3. 将 count(0) 替换为 count(*)(以允许 cbo 优化计数) 4. 将 return number 替换为 int

create or replace function get_workday_count (start_date in date, end_date in date)
return int deterministic is
    pragma udf;
   day_count int;
begin
    select count(*) into day_count from WORKDAYS w
    where w.ID >= TRUNC(start_date)
    and w.ID <= TRUNC(end_date)
    and status in (1, 3, 5);
    return day_count;
end; 

那么在 (end_date - start_date) < 所需天数的情况下,您无需调用您的函数。此外,理想情况下是使用标量子查询而不是函数:

select count(*) 
from ERP_SJ 
where 
case 
   when trunc(end_date) - trunc(start_date) > 5 
      then GET_WORKDAY_COUNT(trunc(start_date) , trunc(end_date)) 
   else 0
 end > 5

或使用子查询:

select count(*) 
from ERP_SJ e
where 
case 
   when trunc(end_date) - trunc(start_date) > 5 
      then (select count(*) from WORKDAYS w
    where w.ID >= TRUNC(e.start_date)
    and w.ID <= TRUNC(e.end_date)
    and w.status in (1, 3, 5)) 
   else 0
 end > 5

WORKDAY_STATUSES table(为了完整起见,以下未使用):

create table workday_statuses
( status number(1) constraint workday_statuses_pk primary key
, status_name varchar2(10) not null constraint workday_status_name_uk unique );

insert all
    into workday_statuses values (1, 'Weekday')
    into workday_statuses values (2, 'Weekend')
    into workday_statuses values (3, 'Unknown 1')
    into workday_statuses values (4, 'Holiday')
    into workday_statuses values (5, 'Unknown 2')
select * from dual;

WORKDAYS table:一行代表 2020 年的每一天:

create table workdays
( id date constraint workdays_pk primary key 
, status references workday_statuses not null )
organization index;

insert into workdays (id, status)
select date '2019-12-31' + rownum
     , case
           when to_char(date '2019-12-31' + rownum, 'Dy', 'nls_language = English') like 'S%' then 2
           when date '2019-12-31' + rownum in
                ( date '2020-01-01', date '2020-04-10', date '2020-04-13'
                , date '2020-05-08', date '2020-05-25', date '2020-08-31'
                , date '2020-12-25', date '2020-12-26', date '2020-12-28' ) then 4
           else 1
       end
from   xmltable('1 to 366')
where  date '2019-12-31' + rownum < date '2021-01-01';

ERP_SJ table 包含 30K 行随机数据:

create table erp_sj
( id          integer generated always as identity
, start_date  date not null
, end_date    date not null
, filler      varchar2(100) );

insert into erp_sj (start_date, end_date, filler)
select dt, dt + dbms_random.value(0,7), dbms_random.string('x',100)
from   ( select date '2019-12-31' + dbms_random.value(1,366) as dt
         from   xmltable('1 to 30000') );

commit;

get_workday_count() 函数:

create or replace function get_workday_count
    ( start_date in date, end_date in date )
    return integer
    deterministic    -- Cache some results
    parallel_enable  -- In case you want to use it in parallel queries
as
    pragma udf;      -- Tell compiler to optimise for SQL
    day_count integer;
begin
    select count(*) into day_count
    from   workdays w
    where  w.id between trunc(start_date) and end_date
    and    w.status in (1, 3, 5);

    return day_count;
end;

请注意,您不应截断 w.id,因为所有值的时间都已经为 00:00:00。 (我假设如果 end_date 落在一天中间的某个地方,你想计算那一天,所以我没有截断 end_date 参数。)

测试:

select count(*) from erp_sj
where  get_workday_count(start_date, end_date) > 5;

COUNT(*)
--------
    1302

结果在大约 1.4 秒内返回。

函数内查询的执行计划:

select count(*)
from   workdays w
where  w.id between trunc(sysdate) and sysdate +10
and    w.status in (1, 3, 5);

--------------------------------------------------------------------------------------------
| Id  | Operation          | Name        | Starts | E-Rows | A-Rows |   A-Time   | Buffers |
--------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT   |             |      1 |        |      1 |00:00:00.01 |       1 |
|   1 |  SORT AGGREGATE    |             |      1 |      1 |      1 |00:00:00.01 |       1 |
|*  2 |   FILTER           |             |      1 |        |      7 |00:00:00.01 |       1 |
|*  3 |    INDEX RANGE SCAN| WORKDAYS_PK |      1 |      7 |      7 |00:00:00.01 |       1 |
--------------------------------------------------------------------------------------------

现在尝试将函数添加为虚拟列并为其编制索引:

create index erp_sj_workday_count_ix on erp_sj(workday_count);

select count(*) from erp_sj
where  workday_count > 5;

在 0.035 秒内得到相同的结果。计划:

-------------------------------------------------------------------------------------------------------
| Id  | Operation         | Name                    | Starts | E-Rows | A-Rows |   A-Time   | Buffers |
-------------------------------------------------------------------------------------------------------
|   0 | SELECT STATEMENT  |                         |      1 |        |      1 |00:00:00.01 |       5 |
|   1 |  SORT AGGREGATE   |                         |      1 |      1 |      1 |00:00:00.01 |       5 |
|*  2 |   INDEX RANGE SCAN| ERP_SJ_WORKDAY_COUNT_IX |      1 |   1302 |   1302 |00:00:00.01 |       5 |
-------------------------------------------------------------------------------------------------------

在 19.0.0 中测试。

编辑: 正如 Sayan 所指出的,如果 WORKDAYS 中有任何变化,虚拟列上的索引将不会自动更新,因此有一个这种方法有错误结果的风险。但是,如果性能至关重要,您可以通过在每次更新 WORKDAYS 时在 ERP_SJ 上重建索引来解决这个问题。也许您可以在 WORKDAYS 上的 statement-level 触发器中执行此操作,或者如果更新非常不频繁并且 ERP_SJ 不是太大以至于索引重建不切实际,则可以通过计划的 IT 维护流程执行此操作。如果索引已分区,重建受影响的分区可能是一个选项。

或者,没有索引并接受 1.4 秒的查询执行时间。

您正在处理 数据仓库 查询(不是 OLTP 查询)。

一些最佳实践表明您应该

  1. 摆脱 od 函数 - 避免 contenxt 开关(这可以通过 UDF pragma 以某种方式缓解,但如果你不这样做,为什么要使用函数不需要吗?)

  2. 摆脱索引 - 快速处理几行;大量记录速度慢

  3. 摆脱缓存 - 缓存基本上是重复相同事情的解决方法

因此,数据仓库方法 包含两个步骤

延长工作日Table

工作日 table 可以用一个新列 MIN_END_DAY 扩展一个小查询,该列为每个(开始)日定义达到 5 个工作日限制的最小阈值。

查询使用 LEAD 聚合函数获取第 4 个工作日(检查 PARTITION BY 区分工作日和其他日期的子句。

对于非工作日,您只需使用下一个工作日的LAST_VALUE

例子

with wd as (
select ID, STATUS,
case when status in (1, 3, 5) then
lead(id,4) over (partition by case when status in (1, 3, 5) then 'workday' end order by id)  /* 4 working days ahead */
end as min_stop_day
from workdays),
wd2 as (
select ID, STATUS, 
last_value(MIN_STOP_DAY) ignore nulls over (order by id desc) MIN_END_DAY
from wd)
select ID, STATUS, MIN_END_DAY 
from wd2
order by 1;

ID,             STATUS, MIN_END_DAY
01.01.2020 00:00:00 4   08.01.2020 00:00:00
02.01.2020 00:00:00 1   08.01.2020 00:00:00
03.01.2020 00:00:00 1   09.01.2020 00:00:00
04.01.2020 00:00:00 2   10.01.2020 00:00:00
05.01.2020 00:00:00 2   10.01.2020 00:00:00
06.01.2020 00:00:00 1   10.01.2020 00:00:00

加入基地Table

现在您可以简单地将基础 table 与 start_day 上的扩展 workday table 连接起来,并通过比较 end_dayMIN_END_DAY

查询

with wd as (
select ID, STATUS,
case when status in (1, 3, 5) then
lead(id,4) over (partition by case when status in (1, 3, 5) then 'workday' end order by id) 
end as min_stop_day
from workdays),
wd2 as (
select ID, STATUS, 
last_value(MIN_STOP_DAY) ignore nulls over (order by id desc) MIN_END_DAY
from wd)
select count(*) from erp_sj 
join wd2
on trunc(erp_sj.start_date) = wd2.ID
where trunc(end_day) >= min_end_day

这将导致 table 秒达到预期的 HASH JOIN 执行计划。

请注意,我假设 1) 工作日 table 已完成(否则您无法使用内部联接)和 2) 包含足够的未来数据(最后 5 行显然不可用)。