[MySQL] 2時間毎にGROUP BYする方法

先日、タイムスタンプを含むデータをSQLで2時間毎に集計したいことがあった。

idtsvalue
ID_0012022-04-01 00:00:011
ID_0012022-04-01 00:05:012
ID_0012022-04-01 00:10:013
ID_0012022-04-01 00:15:014
ID_0012022-04-01 00:20:015
.........
ID_0022022-04-01 00:00:022
ID_0022022-04-01 00:05:023
ID_0022022-04-01 00:10:024
ID_0022022-04-01 00:15:025
ID_0022022-04-01 00:20:026
.........

こういうデータがあり、GROUP BYでid毎、2時間毎(00:00:00-, 02:00:00-, 04:00:00-, ...)に集計したいとする。
TIMESTAMP型のデータからHOUR()で時分秒の時だけ取り出せるらしいので、次のようにしてみたら、望み通りの集計はできたのだが、結果のタイムスタンプが2時間毎に切り下げられない(時分秒の時が偶数かつ分秒が00:00にならない)のが不満だった。

●コード
/* This doesn't return round timestamp */
SELECT id, ts, avg(value)
FROM testdata
GROUP BY id, DATE(ts), HOUR(ts) DIV 2;
●実行結果
idtsavg(value)
ID_0012022-04-01 00:00:0112.5000
ID_0012022-04-01 02:00:0136.5000
ID_0012022-04-01 04:00:0160.5000
ID_0012022-04-01 06:00:0184.5000
ID_0022022-04-01 00:00:0213.5000
ID_0022022-04-01 02:00:0237.5000
ID_0022022-04-01 04:00:0261.5000
ID_0022022-04-01 06:00:0285.5000
.........

Webで探すと、例えば30分毎のGROUP BYならUNIX_TIMESTAMPを30分毎に切り下げるという方法がよく知られているようである。

●コード
/* Group by 30 min */
SELECT
  id,
  FROM_UNIXTIME((UNIX_TIMESTAMP(ts) DIV (30*60)) * 30*60) AS ts_new,
  avg(value)
FROM testdata
GROUP BY id, ts_new;
●実行結果
idts_newavg(value)
ID_0012022-04-01 00:00:003.5000
ID_0012022-04-01 00:30:009.5000
ID_0012022-04-01 01:00:0015.5000
ID_0012022-04-01 01:30:0021.5000
ID_0012022-04-01 02:00:0027.5000
.........

しかし、同じ方法で2時間毎にすると、例えばMySQLのタイムゾーン設定がJST(日本時間)だと、時分秒の時に奇数が加えられてしまうので、うまくいかない。

●コード
/* This doesn't work well because of odd time zone +09:00 */
SELECT
  id,
  FROM_UNIXTIME((UNIX_TIMESTAMP(ts) DIV (2*60*60)) * 2*60*60) as ts_new,
  avg(value)
FROM testdata
GROUP BY id, ts_new;
●実行結果
idts_newavg(value)
ID_0012022-03-31 23:00:006.5000
ID_0012022-04-01 01:00:0024.5000
ID_0012022-04-01 03:00:0048.5000
ID_0012022-04-01 05:00:0072.5000
ID_0012022-04-01 07:00:0090.5000
ID_0022022-03-31 23:00:007.5000
ID_0022022-04-01 01:00:0025.5000
ID_0022022-04-01 03:00:0049.5000
ID_0022022-04-01 05:00:0073.5000
ID_0022022-04-01 07:00:0091.5000
.........

結局、元の案でタイムスタンプを2時間単位に切り下げたものに作り直すようにした。

●コード
SELECT
  id,
  DATE_ADD(DATE(ts),
  INTERVAL HOUR(ts) DIV 2 * 2 HOUR) as ts,
  avg(value)
FROM testdata
GROUP BY id, DATE(ts), HOUR(ts) DIV 2;
●実行結果
idtsavg(value)
ID_0012022-04-01 00:00:0012.5000
ID_0012022-04-01 02:00:0036.5000
ID_0012022-04-01 04:00:0060.5000
ID_0012022-04-01 06:00:0084.5000
ID_0022022-04-01 00:00:0013.5000
ID_0022022-04-01 02:00:0037.5000
ID_0022022-04-01 04:00:0061.5000
ID_0022022-04-01 06:00:0085.5000
.........

●テストデータ作成用コード
DROP TABLE IF EXISTS testdata;
CREATE TABLE testdata (
  id CHAR(10),
  ts TIMESTAMP,
  value INT,
  PRIMARY KEY (id, ts)
);

DROP PROCEDURE IF EXISTS make_testdata;
DELIMITER //
CREATE PROCEDURE make_testdata(IN id_num INT, data_num INT)
BEGIN
  DECLARE i INT DEFAULT 1;
  DECLARE j INT DEFAULT 0;

  /* stored procedures with a lot of INSERT without this is 20x slower */
  SET autocommit = 0;

  /* make test data */
  SET i = 1;
  WHILE i <= id_num DO
    SET j = 0;
    WHILE j < data_num DO
      INSERT INTO testdata VALUES(
        CONCAT('ID_', LPAD(i, 3, '0')),
        DATE_ADD('2022-04-01 00:00:00', INTERVAL i + j*300 SECOND), /* every 5 minuts */
        i + j
      );
      SET j = j + 1;
    END WHILE;
  SET i = i + 1;
  END WHILE;

  SET autocommit = 1;
END
//
DELIMITER ;

CALL make_testdata(10, 96);