【问题标题】:MySQL optimize query for counting scheduled items over time periodsMySQL优化查询以计算一段时间内的计划项目
【发布时间】:2014-10-17 14:11:09
【问题描述】:

在我正在开发的调度应用程序中,我正在处理一个相当复杂的数据库架构,以便描述在 时间段上分配给 的一系列 孩子 在某些日期。现在在这个模式中,我想查询数据库在某个日期范围的某个时间段内,某个组的预定孩子的数量。

数据库架构

  • 时隙:时隙具有特定的开始和结束时间(例如 13:00 - 18:00)。时间可以以 15 分钟为单位变化。在我们的应用程序中,我们希望在此时间段内安排一个孩子加入一个小组。
  • 时间片:在 24 小时内每 15 分钟存在一个时间片记录 (96)。 15 分钟是最小的计划单位。一个时隙被分配给在其开始和结束时间之间覆盖的每个片(例如,时间片 13:00-18:00 将有一条记录指向时间片 [13:00, 13:15, 13:30...17 :45])。这样就可以计算在任何给定时间和日期有多少孩子“占用”同一时间片。
  • 孩子:孩子只是被安排的实体
  • 组:组是具有特定容量的物理位置的表示
  • GroupAssignment:组分配是有时间限制的。在日期 1 和 2 之间可能是 A 组,在日期 2 和 3 之间可能是 B 组。
  • 占用:主要调度记录。这有一个timeslot_id、kid_id、开始和结束日期。 注意:一个孩子被安排在开始日期和之后的每 7 天,直到结束日期。

数据库架构 SQL

记录数可以粗略地从auto_increment值推导出来。如果不存在,我会手动提及它们。

CREATE TABLE `group_assignment_caches` (
  `group_id` int(11) DEFAULT NULL,
  `occupancy_id` int(11) DEFAULT NULL,
  `start` date DEFAULT NULL,
  `end` date DEFAULT NULL,
  KEY `index_group_assignment_caches_on_occupancy_id` (`occupancy_id`),
  KEY `index_group_assignment_caches_on_group_id` (`group_id`),
  KEY `index_group_assignment_caches_on_start_and_end` (`start`,`end`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
/* (~1500 records) */

CREATE TABLE `kids` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(255) DEFAULT NULL,
  `archived` tinyint(1) NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=592 DEFAULT CHARSET=utf8;

CREATE TABLE `occupancies` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `kid_id` int(11) DEFAULT NULL,
  `timeslot_id` int(11) DEFAULT NULL,
  `start` date DEFAULT NULL,
  `end` date DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `index_occupancies_on_kid_id` (`kid_id`),
  KEY `index_occupancies_on_timeslot_id` (`timeslot_id`),
  KEY `index_occupancies_on_start_and_end` (`start`,`end`)
) ENGINE=InnoDB AUTO_INCREMENT=2675 DEFAULT CHARSET=utf8;

CREATE TABLE `time_slices` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `start` time DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `index_time_slices_on_start` (`start`)
) ENGINE=InnoDB AUTO_INCREMENT=97 DEFAULT CHARSET=latin1;

CREATE TABLE `timeslot_slices` (
  `timeslot_id` int(11) DEFAULT NULL,
  `time_slice_id` int(11) DEFAULT NULL,
  KEY `index_timeslot_slices_on_timeslot_id` (`timeslot_id`),
  KEY `index_timeslot_slices_on_time_slice_id` (`time_slice_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;
/* (~1500 records) */

CREATE TABLE `timeslots` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `start` time DEFAULT NULL,
  `end` time DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=91 DEFAULT CHARSET=utf8;

当前解决方案

到目前为止,我已经设计了以下查询来将它们联系在一起。虽然它确实有效,但它的扩展性很差。使用 1 个日期、1 个时隙和 1 个组运行查询大约需要 50 毫秒。但是,对于 100 个日期,这将变为 1000 毫秒,并且当您开始添加组和时隙时,这会在几秒内迅速呈指数增长。我注意到运行时间高度依赖于时隙的大小。似乎当一个特定的时隙覆盖更多的时间片时,它会在运行时迅速升级!

SELECT subq.date, subq.group_id, subq.timeslot_id, MAX(subq.spots) AS max_spots
FROM (
    SELECT  di.date, 
            ts.start, 
            gac.group_id AS group_id, 
            tss2.timeslot_id AS timeslot_id, 
            COUNT(*) AS spots
    FROM date_intervals di, 
    timeslot_slices tss2,
    occupancies o
        JOIN timeslots t ON o.timeslot_id = t.id
        JOIN group_assignment_caches gac ON o.id = gac.occupancy_id
        JOIN timeslot_slices tss1 ON t.id = tss1.timeslot_id
        JOIN time_slices ts ON tss1.time_slice_id = ts.id
        JOIN kids k ON o.kid_id = k.id
    WHERE di.date BETWEEN gac.start AND gac.end
    AND di.date BETWEEN o.start AND o.end
    AND MOD(DATEDIFF(di.date, o.start),7)=0
    AND k.archived = 0
    AND tss1.time_slice_id = tss2.time_slice_id
    AND gac.group_id IN (3) AND tss2.timeslot_id IN (5)
    GROUP BY ts.start, di.date, group_id, timeslot_id
) subq
GROUP BY subq.date, subq.group_id, subq.timeslot_id

请注意,单独运行派生子查询所花费的时间相同。这将产生 1 条记录,其中包含给定时间段中给定组的每个时间片(15 分钟)的占用次数。这非常适合调试。显然,我只对整个时间段的最大入住人数感兴趣。

Date_intervals 未在架构中描述。这是我在此过程调用开始时使用 REPEAT 语句填充的临时表。它唯一的列是“日期”,在大多数情况下,它通常填充 10-300 个日期。查询应该能够处理这个问题。

如果我解释这个查询,我会得到以下结果。我不确定如何从这里走得更远。可以忽略派生表的第一行,因为执行子查询需要相同的时间。唯一不使用索引的其他表是 date_intervals di,它是一个包含 122 条记录的小型临时表。

+----+-------------+------------+--------+----------------------------------------------------------------------------------------------------------------------------------------+-----------------------------------------------+---------+----------------------------+------+------------------------------------------------+
| id | select_type | table      | type   | possible_keys                                                                                                                          | key                                           | key_len | ref                        | rows | Extra                                          |
+----+-------------+------------+--------+----------------------------------------------------------------------------------------------------------------------------------------+-----------------------------------------------+---------+----------------------------+------+------------------------------------------------+
|  1 | PRIMARY     | <derived2> | ALL    | NULL                                                                                                                                   | NULL                                          | NULL    | NULL                       | 5124 | Using temporary; Using filesort                |
|  2 | DERIVED     | tss2       | ref    | index_timeslot_slices_on_timeslot_id,index_timeslot_slices_on_time_slice_id                                                            | index_timeslot_slices_on_timeslot_id          | 5       |                            |   42 | Using where; Using temporary; Using filesort   |
|  2 | DERIVED     | ts         | eq_ref | PRIMARY                                                                                                                                | PRIMARY                                       | 4       | ookidoo.tss2.time_slice_id |    1 |                                                |
|  2 | DERIVED     | tss1       | ref    | index_timeslot_slices_on_timeslot_id,index_timeslot_slices_on_time_slice_id                                                            | index_timeslot_slices_on_time_slice_id        | 5       | ookidoo.tss2.time_slice_id |    6 | Using where                                    |
|  2 | DERIVED     | o          | ref    | PRIMARY,index_occupancies_on_timeslot_id,index_occupancies_on_kid_id,index_occupancies_on_start_and_end                                | index_occupancies_on_timeslot_id              | 5       | ookidoo.tss1.timeslot_id   |    6 | Using where                                    |
|  2 | DERIVED     | k          | eq_ref | PRIMARY                                                                                                                                | PRIMARY                                       | 4       | ookidoo.o.kid_id           |    1 | Using where                                    |
|  2 | DERIVED     | gac        | ref    | index_group_assignment_caches_on_occupancy_id,index_group_assignment_caches_on_start_and_end,index_group_assignment_caches_on_group_id | index_group_assignment_caches_on_occupancy_id | 5       | ookidoo.o.id               |    1 | Using where                                    |
|  2 | DERIVED     | di         | range  | PRIMARY                                                                                                                                | PRIMARY                                       | 3       | NULL                       |    1 | Range checked for each record (index map: 0x1) |
|  2 | DERIVED     | t          | eq_ref | PRIMARY                                                                                                                                | PRIMARY                                       | 4       | ookidoo.o.timeslot_id      |    1 | Using where; Using index                       |
+----+-------------+------------+--------+----------------------------------------------------------------------------------------------------------------------------------------+-----------------------------------------------+---------+----------------------------+------+------------------------------------------------+

当前结果

上述查询产生以下结果(122 条记录,缩写)

date       group_id   timeslot_id max_spots            
+------------+----------+-------------+-----------+
| date       | group_id | timeslot_id | max_spots |
+------------+----------+-------------+-----------+
| 2012-08-20 |        3 |           5 |        12 |
| 2012-08-27 |        3 |           5 |        12 |
| 2012-09-03 |        3 |           5 |        12 |
| 2012-09-10 |        3 |           5 |        12 |
+------------+----------+-------------+-----------+
| 2014-11-24 |        3 |           5 |        15 |
| 2014-12-01 |        3 |           5 |        15 |
| 2014-12-08 |        3 |           5 |        15 |
| 2014-12-15 |        3 |           5 |        15 |
+------------+----------+-------------+-----------+

结束

我想知道一种方法来重组我的查询甚至我的数据库架构,以减少查询这些信息的时间。我无法想象这是不可能的,考虑到该数据库中存在的记录相对较少(大多数表为 10-1000 条)

【问题讨论】:

  • 尝试“解释”查询并为 date_intervals.date 引入索引
  • 并为 date_intervals.date、occupancies.start、occupancies.end、kids.archived、timeslot_slices.time_slice_id、timeslot_slices.timeslot_id 引入索引
  • 感谢您的快速回复!仍在添加原始帖子中未包含的 EXPLAIN 日志的过程中。不过,我已经尝试过大多数索引选项: date_intervals 索引并没有真正改变任何东西。 Kids.archived 是一个布尔值,对索引没有多大用处(基数低,无论如何都需要使用主 ID 索引)。 timeslot_slices 索引已经存在于两个字段中。添加了占用开始/结束,这似乎加快了一点,但我仍然停留在 1000 毫秒左右,大约 100 个日期

标签: mysql sql database query-optimization


【解决方案1】:

任何足够复杂的问题都可能使计算机瘫痪。其实,做一个复杂的问题很容易,很难把一个复杂的问题简单化。

您的单个​​查询非常复杂。它遍历整个数据库。那有必要吗?例如,如果你将它限制在一个日期会发生什么?它的扩展性更好吗?

如您所见,仅使用单个查询来执行复杂任务通常非常有效,但并非总是如此。我经常发现打破执行任务所需的指数时间的唯一方法是将其拆分为多个步骤。例如,一次约会一次。也许您并不总是需要它们?

在某些情况下,我使用驻留在内存中的中间 SQLite 数据库。对内存中的小型(!)临时数据库的操作非常快。它是这样工作的:

$SQLiteDB = new PDO("sqlite::memory:");
$SQLiteDB->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$SQL = "<any valid sqlite query>";
$SQLiteDB->query($SQL);

首先检查您是否安装了 sqlite PHP 模块。阅读手册:

http://www.sqlite.org

使用它时,您首先在新数据库中创建表,然后用所需数据填充它们。如果必须复制多行,可以使用准备好的语句。

棘手的一点是拆分您的单个复杂查询。您将如何做到这一点取决于您要回答的确切问题。艺术是限制您必须使用的数据量。不要复制整个数据库,而是做出明智的选择。

采取多个小步骤的一大优势是您的代码可能会变得更加可读和易懂。我不想成为十年后不得不改变你的 SQL 查询的人,因为你继续做其他事情。

【讨论】:

  • 感谢您的精心回复!我的问题的用例是:“给我第一个日期,在某个时间段内,在任何这些组的某个工作日,有一个孩子有空位”。为了做到这一点,我至少需要我现在正在抓取的表格中的信息。只查询 1 个日期,然后在 PHP / RoR 中对此进行评估,恐怕会产生比查询本身更多的开销。也许如果我要在 MySQL 存储过程中编写一个循环。一旦找到合适的日期就会退出......至少我不必评估请求范围内的所有日期......
  • 日期只是一个例子,我没有说你必须使用它。可能还有其他更好的东西。无论如何,无论您尝试什么,很明显您已经创建了一个尽可能灵活的模式来捕获所有可能的组合等等,但不考虑使用它的效率。可能太灵活了!你必须引入更多的限制。您正在寻找某种类型的免费时间段:每 7 天一次,可能在同一个组中,等等。这不会反映在您的数据库架构中。 IOW:你建模的是俄罗斯方块方块,而不是俄罗斯方块及其规则。
【解决方案2】:

我找到了适合我的特定用例的解决方案。

我创建了一个具有以下结构的中间表或“缓存”表:

CREATE TABLE `occupancy_caches` (
  `occupancy_id` int(11) DEFAULT NULL,
  `kid_id` int(11) DEFAULT NULL,
  `group_id` int(11) DEFAULT NULL,
  `client_id` int(11) DEFAULT NULL,
  `date` date DEFAULT NULL,
  `timeslot_id` int(11) DEFAULT NULL,
  `start` int(11) DEFAULT NULL,
  `end` int(11) DEFAULT NULL,
  KEY `index_occupancy_caches_on_date_and_client_id` (`date`,`client_id`),
  KEY `index_occupancy_caches_on_date_and_group_id` (`date`,`group_id`),
  KEY `index_occupancy_caches_on_occupancy_id` (`occupancy_id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;

这使我可以完全消除 group_assignment_caches 表,并且不再需要使用计算列 (MOD(DATEDIFF...)) 来搜索日期。此外,我只需要在时间片上进行一次连接,而不是 2。

然而,缺点是我现在必须为原始占用记录所涵盖的每周创建一个 occupancy_caches 记录。在大多数情况下,这些占用时间为 4 年。这意味着对于每个占用记录,我现在必须创建 400 个(!)记录......由于记录的数量只会线性增长,因此正确使用索引应该可以防止在系统增长时失去控制。

时间会证明一切……

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2023-04-01
    • 1970-01-01
    • 1970-01-01
    • 2012-04-26
    • 2016-06-08
    • 1970-01-01
    • 2011-05-22
    • 2013-04-16
    相关资源
    最近更新 更多