统计事件 table 中的行数,按时间范围分组,很多

Counting rows in event table, grouped by time range, a lot

假设我有一个像这样的 table:

CREATE TABLE `Alarms` (
    `AlarmId` INT UNSIGNED NOT NULL AUTO_INCREMENT
        COMMENT "32-bit ID",

    `Ended` BOOLEAN NOT NULL DEFAULT FALSE
        COMMENT "Whether the alarm has ended",

    `StartedAt` TIMESTAMP NOT NULL DEFAULT 0
        COMMENT "Time at which the alarm was raised",

    `EndedAt` TIMESTAMP NULL
        COMMENT "Time at which the alarm ended (NULL iff Ended=false)",

    PRIMARY KEY (`AlarmId`),

    KEY `Key4` (`StartedAt`),
    KEY `Key5` (`Ended`, `EndedAt`)
) ENGINE=InnoDB;

现在,对于 GUI,我想制作:

目的是为用户提供一个下拉框,他们可以从中选择一个日期来查看当天任何活动的警报(在此之前或期间开始,并在此期间或之后结束)。所以像这样:

+-----------------------------------+
| Choose day                      ▼ |
+-----------------------------------+
|   2017-12-03 (3 started)          |
|   2017-12-04 (1 started, 2 ended) |
|   2017-12-05 (2 ended)            |
|   2017-12-16 (1 started, 1 ended) |
|   2017-12-17 (1 started)          |
|   2017-12-18                      |
|   2017-12-19                      |
|   2017-12-20                      |
|   2017-12-21 (1 ended)            |
+-----------------------------------+

我可能会对警报施加年龄限制,以便它们在 archived/removed 之后,比方说,一年。这就是我们正在使用的规模。

我预计每天会有零到数万个警报。

我的第一个想法相当简单:

(
    SELECT
        COUNT(`AlarmId`) AS `NumStarted`,
        NULL AS `NumEnded`,
        DATE(`StartedAt`) AS `Date`
    FROM `Alarms`
    GROUP BY `Date`
)
UNION
(
    SELECT
        NULL AS `NumStarted`,
        COUNT(`AlarmId`) AS `NumEnded`,
        DATE(`EndedAt`) AS `Date`
    FROM `Alarms`
    WHERE `Ended` = TRUE
    GROUP BY `Date`
);

这使用了我的两个索引,连接类型 ref 和引用类型 const,我对此很满意。我可以遍历结果集,将找到的非 NULL 值转储到 C++ std::map<boost::gregorian::date, std::pair<size_t, size_t>> 中(然后 "filling the gaps" 用于没有警报开始或结束但从前几天开始活跃的日子) .

我要解决的问题是列表应该考虑基于位置的时区,但是只有我的应用程序知道时区。出于后勤原因,MySQL 会话是故意 SET time_zone = '+00:00' 以便时间戳在 UTC 中全部被踢出。 (然后使用各种其他工具对历史时区执行任何必要的特定于位置的更正,同时考虑 DST 和诸如此类的东西。)对于应用程序的其余部分,这很好,但是对于这个特定的查询,它打破了日期 GROUPing.

也许我可以(在我的应用程序中)预先计算一个时间范围列表,并生成一个巨大的 2n UNIONed 查询(其中 n = 要检查的 "days" 的数量)并得到 NumStartedNumEnded 计数:

-- Example assuming desired timezone is -05:00
-- 
-- 3rd December
(
    SELECT
        COUNT(`AlarmId`) AS `NumStarted`,
        NULL AS `NumEnded`,
        '2017-12-03' AS `Date`
    FROM `Alarms`
    -- Alarm started during 3rd December UTC-5
    WHERE `StartedAt` >= '2017-12-02 19:00:00'
      AND `StartedAt` <  '2017-12-03 19:00:00'
    GROUP BY `Date`
)
UNION
(
    SELECT
        NULL AS `NumStarted`,
        COUNT(`AlarmId`) AS `NumEnded`,
        '2017-12-03' AS `Date`
    FROM `Alarms`
    -- Alarm ended during 3rd December UTC-5
    WHERE `EndedAt` >= '2017-12-02 19:00:00'
      AND `EndedAt` <  '2017-12-03 19:00:00'
    GROUP BY `Date`
)
UNION

-- 4th December
(
    SELECT
        COUNT(`AlarmId`) AS `NumStarted`,
        NULL AS `NumEnded`,
        '2017-12-04' AS `Date`
    FROM `Alarms`
    -- Alarm started during 4th December UTC-5
    WHERE `StartedAt` >= '2017-12-03 19:00:00'
      AND `StartedAt` <  '2017-12-04 19:00:00'
    GROUP BY `Date`
)
UNION
(
    SELECT
        NULL AS `NumStarted`,
        COUNT(`AlarmId`) AS `NumEnded`,
        '2017-12-04' AS `Date`
    FROM `Alarms`
    -- Alarm ended during 4th December UTC-5
    WHERE `EndedAt` >= '2017-12-03 19:00:00'
      AND `EndedAt` <  '2017-12-04 19:00:00'
    GROUP BY `Date`
)
UNION

-- 5th December
-- [..]

但是,当然,即使我将数据库限制为一年的历史警报,也最多可达 730 UNIONd SELECTs。我敏锐的直觉告诉我这是一个非常糟糕的主意。

我还能如何生成这些按时间分组的统计信息?还是这真的很愚蠢,我应该考虑解决阻止我将 tzinfo 与 MySQL 一起使用的问题?

必须在 MySQL 5.1.73 (CentOS 6) 和 MariaDB 5.5.50 (CentOS 7) 上工作。

UNION 方法实际上离可行的解决方案不远;您可以通过招募临时 table:

来实现相同的目的,而无需灾难性的大查询
CREATE TEMPORARY TABLE `_ranges` (
   `Start` TIMESTAMP NOT NULL DEFAULT 0,
   `End`   TIMESTAMP NOT NULL DEFAULT 0,
   PRIMARY KEY (`Start`, `End`)
);

INSERT INTO `_ranges` VALUES
   -- 3rd December UTC-5
   ('2017-12-02 19:00:00', '2017-12-03 19:00:00'),
   -- 4th December UTC-5
   ('2017-12-03 19:00:00', '2017-12-04 19:00:00'),
   -- 5th December UTC-5
   ('2017-12-04 19:00:00', '2017-12-05 19:00:00'),
   -- etc.
;

-- Now the queries needed are simple and also quick:

SELECT
   `_ranges`.`Start`,
   COUNT(`AlarmId`) AS `NumStarted`
FROM `_ranges` LEFT JOIN `Alarms`
  ON `Alarms`.`StartedAt` >= `_ranges`.`Start`
  ON `Alarms`.`StartedAt` <  `_ranges`.`End`
GROUP BY `_ranges`.`Start`;

SELECT
   `_ranges`.`Start`,
   COUNT(`AlarmId`) AS `NumEnded`
FROM `_ranges` LEFT JOIN `Alarms`
  ON `Alarms`.`EndedAt` >= `_ranges`.`Start`
  ON `Alarms`.`EndedAt` <  `_ranges`.`End`
GROUP BY `_ranges`.`Start`;

DROP TABLE `_ranges`;

(此方法的灵感来自 a DBA.SE post。)

请注意有两个 SELECT——原来的 UNION 不再可能,因为 temporary tables cannot be accessed twice in the same query。然而,由于我们已经引入了额外的语句(CREATEINSERTDROP),在这种情况下这似乎是一个没有实际意义的问题。

在这两种情况下,每一行代表我们请求的一个周期,第一列等于周期的 "start" 部分(以便我们可以在结果集中识别它)。

请务必根据需要在您的代码中使用异常处理,以确保 _ranges 在您的例程 returns 之前被 DROP 处理;虽然临时 table 是 MySQL 会话的本地,但如果您之后继续使用该会话,那么您可能需要一个干净的状态,特别是如果将再次使用此功能。

如果这仍然太重,例如因为你有很多时间段并且 CREATE TEMPORARY TABLE 本身会因此变得太大,或者因为多个语句不适合你的调用代码,或者因为你的用户没有创建和删除临时 table 的权限,您将不得不退回到简单的 GROUP BY 而不是 DAY(Date),并确保您的用户 运行 mysql_tzinfo_to_sql 每当系统的 tzdata 更新时。