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

Posted

技术标签:

【中文标题】优化两个日期之间的工作日统计查询【英文标题】:Optimize the query of weekday statistics between two dates 【发布时间】:2020-08-20 07:01:09 【问题描述】:

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

表格:工作日

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 表有大约 200000 行数据。

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

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

如何优化?还是有更好的解决方案?

【问题讨论】:

出于兴趣,为什么count(0)而不是标准表达式? 能分享一下索引详情和执行计划吗?可能因为trunc 而无法使用索引,这似乎没有必要。有多少workdays.id 值的时间分量不是00:00:00 你的 oracle 版本是什么?是 12 岁以上吗? 什么是第3天和第5天,ERP_SJ中有多少天? 【参考方案1】:

首先,优化你的功能: 1.添加pragma udf(为了在sql中更快执行 2. 添加确定性子句(用于缓存) 3. 将 count(0) 替换为 count(*) (以允许 cbo 优化计数) 4. 将返回数替换为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

【讨论】:

【参考方案2】:

WORKDAY_STATUSES 表(仅出于完整性考虑,以下未使用):

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 表: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 表包含 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 上的语句级触发器中执行此操作,或者如果更新非常不频繁并且ERP_SJ 不是那么大以至于索引重建不切实际,则可以通过计划的IT 维护过程来执行此操作。如果索引已分区,则可以选择重建受影响的分区。

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

【讨论】:

我不建议添加虚拟列并根据依赖于另一个表中数据的函数在其上创建索引。 Oracle 不会将 FBI 与该表中的更改同步。 这不是 FBI,而是 VC 的索引。我刚刚跑了update erp_sj set end_date = start_date;,计数正确返回0。 我的意思是workdays表,在函数get_workday_count中使用。尝试从工作日中删除行。 VC 上的索引在功能上确实是 FBI :) 啊,这很有趣。您必须重建索引。谢谢:)【参考方案3】:

我知道 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 上的索引。

【讨论】:

【参考方案4】:

可以试试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

【讨论】:

【参考方案5】:

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

一些最佳实践表明您应该这样做

    摆脱 od 函数 - 避免上下文切换(这可以通过 UDF pragma 以某种方式缓解,但是如果你不需要它,为什么要使用函数呢?)

    摆脱索引 - 几行快速;大量记录缓慢

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

所以问题的数据仓库方法包含两个步骤

扩展工作日表

工作日表可以通过一个小查询扩展,新列 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

加入基表

现在您可以简单地将您的基表与start_day 上的扩展workday 表连接起来,并通过将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

这将导致大型表执行预期的HASH JOIN 执行计划。

请注意,我假设 1) 工作日表是完整的(否则您不能使用内连接)并且 2) 包含足够的未来数据(最后 5 行显然不可用)。

【讨论】:

以上是关于优化两个日期之间的工作日统计查询的主要内容,如果未能解决你的问题,请参考以下文章

SQL工作日查询

oracle: 给定起始日期,按月份统计两个日期间每个月份的工作日(非周六周天)的天数,谢谢

CakePHP 查找两个日期之间查询的条件

Excel 两个日期之间间隔的工作日数,非头尾,大家帮我看看我这样做,结果不对呀?问题出在哪了?不甚感激!

优化两个数百万行表之间的内部联接

查询两个日期之间的数据