Mir ist kein Titel eingefallen und es sollte kein Clickbait sein - gut ich ich habe habe nichts von den Klicks. SQL von Ahnungslosen wäre auch ein guter Titel.

Ausgangspunkt

Eine Webanwendung, welche ein Java Backend hat. Als Datenbank wird PostgreSQL 10 benutzt. Die Anwendung ist im produktiven Einsatz.

CREATE TABLE foo (
    special_id1  BIGINT,
    special_id2  VARCHAR(255),
    date_        DATE,
    time_        TIME WITHOUT TIME ZONE,
    data         TEXT,
    PRIMARY KEY  (special_id1, special_id2, date_, time_)
);
CREATE INDEX idx_foo ON foo (date_);
CREATE INDEX idx_bar ON foo (time_);

Die problematische Anfrage sieht wie folgt aus:

SELECT
    (date_ + time_) AS date_,
    CAST (data AS INTEGER) AS data
FROM
    foo
WHERE
    '2019-10-16 13:45'::timestamp < (date_ + time_) AND
    (date_ + time_) < '2019-10-23 20:15'::timestamp AND
    CAST (data AS INTEGER) <> -2147483648 AND
    special_id2 = 'diese ID ganz toll'
;

Das ganze sieht noch nicht so gefährlich aus, obwohl es mir weh tut, dass data vom Typ text ist, obwohl nur integer gespeichert werden. Es gab auch keinen Grund, weswegen date_ und time_ separat gespeichert werden. Die Indizes idx_foo und idx_bar sind vollkommen nutzlos (auch für die anderen Anfragen) und der Primärschlüssel ist einfach nur extrem riesg und nutzlos. Er ist ca. 30% größer als die Tabelle. Dabei hat er noch fachliche Fehler. special_id2 muss eindeutig sein.

Die Tabelle foo hat über eine Millarde Zeilen. Ein gibt ein paar 100 special_id1 und special_id2 Kombinationen, für jede Kombination wird in unterschiedlichen Intervallen ein neuer Datenpunkt erzeugt. Das Intervall ist zwischen wenigen Sekunden und unter 10 Minuten. Man hat einen bunten Blumenstrauß an Datensätzen. Es kam der Punkt an dem die Platten der Datenbank voll wurden. Die Anwendung hat zwischen 5 und 30 Minuten für die Anfragen benötigt1. Man war sich einig, dass der Datenbankserver das Problem ist. Das ganze lässt sich nur mit Partionierung und mehr Hardware lösen.

Lösung

Ich möchte die Größe der Tabelle reduzieren, zweckmäßige Datentypen verwenden, sinnvolle Indizes einführen und die fachlichen Anforderungen sicherstellen. Das ganze ist relativ einfach, denn man muss die Tabelle nur normalisieren.

CREATE TABLE id_table (
    id              INT GENERATED BY DEFAULT AS IDENTITY,
    special_id1     BIGINT,
    special_id2     VARCHAR(40),
    PRIMARY         KEY(id),
    UNIQUE(special_id2)
);

CREATE TABLE better_foo (
    data            INT NULL,
    id_table_id     INT NOT NULL REFERENCES id_table(id),
    date_           TIMESTAMP  NOT NULL,
    PRIMARY KEY(id_table_id, date_)
);

Durch die Aufteilung der Daten in 2 Tabellen speichert man viel weniger Daten und die Indizes sind viel kleiner. Während der Arbeiten am neuen Schema kam heraus, dass die Daten in der Anwendung über Teilintervalle aggegiert werden. Es ist eine ganze schlechte Idee Tonnen an Daten per Netzwerk zu übertragen, um dann nur wenige Aggregate zu berechnen.

SELECT
    q.date_,
    q.data
FROM (
    SELECT
        width_bucket(
            extract(epoch from date_),
            extract(epoch from '2019-10-16 13:45'::timestamp),
            extract(epoch from '2019-10-23 20:15'::timestamp),
            100                                             -- maximum rows in the result set
        ) AS bucket_no,
        MIN(date_) + ((MAX(date_)-MIN(date_))/2) AS date_,  -- middle of the date range
        AVG(data) as data
    FROM
        better_foo
    WHERE
        '2019-10-16 13:45'::timestamp < date_ AND
        date_ < '2019-10-23 20:15'::timestamp AND
        data  <> -2147483648 AND
        id_table_id = (
            SELECT id FROM id_table WHERE special_id2 = 'diese ID ganz toll'
        )
    GROUP BY
        bucket_no
    ORDER BY
        bucket_no
    ) AS q
;

Erklärung

  • extract(epoch from '2019-10-23 20:15'::timestamp) man erhält einen Integer (Unix Timestamp)
  • width_bucket(v, min_value, max_value, n) teilt ein Intervall [min_value, max_value] in n äquidistante Teilintervale. Rückgabewert ist das Bucket/Intervall in dem v einsortiert wird.

Die Idee ist, dass man den Datumsbereich z.B. in 100 gleiche Intervalle aufteilt. Für jeden Datensatz, welcher in der Intervall fällt und die passende id_table_id hat, wird eine Bucket-Nummer bestimmt. Alles Datensätze mit der gleichen Nummer werden aggregiert. Die Mitte von dem Intervall ist der neue Zeitpunkt. Das äußere SELECT ist nur nötig, damit die Ergebnismenge gleich aussieht. In der Bedingung wird werden die Spalten id_table_id und date_ verwendet. Dafür haben wir den Primärschlüssel2.

Fazit

Durch den Umbau schrumpfte der Platzbedarf für Indizes und Tabellen auf ca. 25% und die maximale Anfragezeit liegt bei ca. 10 Sekunden. Wenn die Datenbank warm ist, dann sind Zeiten weiter unter einer Sekunde keine Seltenheit. Die Anwendung braucht mit den Daten nichts mehr zu machen. Die Entwickler haben geflucht, weil meine kreative Anfrage nicht vom OR Mapper vorgesehen war.

Seit SQL ‘92 hat sich eine Menge geändert. Jeder, der eine SQL Datenbank nutzt, sollte sich mit seiner Datenbank und den Anfragen auseinander setzen. Die OR Mapper nehmen einen das nachdenken nicht ab. Wenn man eine gute Anfrage hat, dann ist es egal wie groß die Datenbank ist. Ich hätte nicht mit so einer großen Verbesserung gerechnet. Deswegen werde ich mich nicht intensiver mit Partionierung bei PostgreSQL beschäftigen. Es gibt für mich kein Problem3, welches ich damit lösen kann.


  1. Zeiten wurden in der Anwendung gemessen. Die reine Anfrage lief zwischen 5 und 20 Minuten. [return]
  2. Je nach Verteilung der Daten kann auch ein BRIN- bzw. partieller Index vorteilthaft sein. [return]
  3. Man benötigt Partionierung für schlechte Anfragen, das war das Fazit nach einer Diskussion mit einem Bekannten. [return]