Wie verwende ich Zeitreihen mit Sqlite mit schnellen Zeitbereichsabfragen?
Angenommen, wir protokollieren Ereignisse in einer SQLite-Datenbank mit einer Unix-Zeitstempelspalte ts
:
CREATE TABLE data(ts INTEGER, text TEXT); -- more columns in reality
und dass wir schnell nach Datums- / Uhrzeitbereichen suchen möchten, zum Beispiel:
SELECT text FROM data WHERE ts BETWEEN 1608710000 and 1608718654;
So EXPLAIN QUERY PLAN
gibt es SCAN TABLE data
was schlecht ist, also besteht eine naheliegende Lösung darin , einen Index mit zu erstellenCREATE INDEX dt_idx ON data(ts)
.
Dann ist das Problem gelöst, aber es ist eher eine schlechte Lösung , einen Index für eine bereits zunehmende Sequenz / bereits sortierte Spalte pflegen zu müssen, ts
für die wir eine B-Baum-Suche in O (log n) direkt verwenden könnten . Intern ist dies der Index:
ts rowid
1608000001 1
1608000002 2
1608000012 3
1608000077 4
Dies ist eine Verschwendung von DB-Speicherplatz (und CPU, wenn eine Abfrage zuerst im Index suchen muss).
Um es zu umgehen:
(1) Wir könnten
ts
als verwendenINTEGER PRIMARY KEY
, sots
wäre dasrowid
selbst. Dies schlägt jedoch fehl, dats
es nicht eindeutig ist: 2 Ereignisse können in derselben Sekunde (oder sogar in derselben Millisekunde) auftreten.Siehe zum Beispiel die Informationen in SQLite Autoincrement .
(2) Wir könnten
rowid
als Zeitstempel verwenden, derts
mit einer zunehmenden Anzahl verkettet ist. Beispiel:16087186540001 16087186540002 [--------][--] ts increasing number
Dann
rowid
ist es einzigartig und nimmt streng zu (vorausgesetzt, es gibt weniger als 10.000 Ereignisse pro Sekunde), und es wäre kein Index erforderlich. Eine AbfrageWHERE ts BETWEEN a AND b
würde einfach werdenWHERE rowid BETWEEN a*10000 AND b*10000+9999
.Aber gibt es eine einfache Möglichkeit, Sqlite zu
INSERT
einem Artikel zu fragen, dessenrowid
Wert größer oder gleich einem bestimmten Wert ist? Angenommen, der aktuelle Zeitstempel ist1608718654
und zwei Ereignisse werden angezeigt:CREATE TABLE data(ts_and_incr INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT); INSERT INTO data VALUES (NEXT_UNUSED(1608718654), "hello") #16087186540001 INSERT INTO data VALUES (NEXT_UNUSED(1608718654), "hello") #16087186540002
Allgemeiner gesagt, wie kann man mit Sqlite Zeitreihen optimal erstellen, um schnelle Abfragen zu haben WHERE timestamp BETWEEN a AND b
?
Antworten
Erste Lösung
Die in der Frage beschriebene Methode (2) scheint gut zu funktionieren. In einem Benchmark erhielt ich:
- naive Methode ohne Index: 18 MB Datenbank, 86 ms Abfragezeit
- naive Methode mit Index: 32 MB Datenbank, 12 ms Abfragezeit
- Methode (2): 18 MB Datenbank, 12 ms Abfragezeit
Der entscheidende Punkt ist hier die Verwendung dt
als ein INTEGER PRIMARY KEY
, so wird es der Zeilen - ID selbst sein (siehe auch ein Index für einen Primärschlüssel in SQLite benötigt? ), Einen B-Baum verwendet wird , und es wird nicht ein anderer versteckt sein rowid
Spalt. Daher vermeiden wir einen zusätzlichen Index, der eine Entsprechung ergeben würde dt => rowid
: Hier dt
ist die Zeilen-ID.
Wir verwenden auch, AUTOINCREMENT
die intern eine sqlite_sequence
Tabelle erstellt, die die zuletzt hinzugefügte ID verfolgt. Dies ist beim Einfügen nützlich: Da es möglich ist, dass zwei Ereignisse in Sekunden den gleichen Zeitstempel haben (es wäre sogar mit Millisekunden- oder Mikrosekunden-Zeitstempeln möglich, dass das Betriebssystem die Genauigkeit abschneidet), verwenden wir das Maximum zwischen timestamp*10000
und last_added_ID + 1
, um sicherzustellen, dass es eindeutig ist ::
MAX(?, (SELECT seq FROM sqlite_sequence) + 1)
Code:
import sqlite3, random, time
db = sqlite3.connect('test.db')
db.execute("CREATE TABLE data(dt INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT);")
t = 1600000000
for i in range(1000*1000):
if random.randint(0, 100) == 0: # timestamp increases of 1 second with probability 1%
t += 1
db.execute("INSERT INTO data(dt, label) VALUES (MAX(?, (SELECT seq FROM sqlite_sequence) + 1), 'hello');", (t*10000, ))
db.commit()
# t will range in a ~ 10 000 seconds window
t1, t2 = 1600005000*10000, 1600005100*10000 # time range of width 100 seconds (i.e. 1%)
start = time.time()
for _ in db.execute("SELECT 1 FROM data WHERE dt BETWEEN ? AND ?", (t1, t2)):
pass
print(time.time()-start)
Verwenden einer WITHOUT ROWID
Tabelle
Hier ist eine andere Methode, mit WITHOUT ROWIDder eine Abfragezeit von 8 ms angegeben wird. Wir müssen selbst eine automatisch inkrementierende ID implementieren, da AUTOINCREMENT bei Verwendung nicht verfügbar ist WITHOUT ROWID
.
WITHOUT ROWID
ist nützlich, wenn wir eine verwenden PRIMARY KEY(dt, another_column1, another_column2, id)
und vermeiden möchten, eine zusätzliche rowid
Spalte zu haben . Anstatt einen B-Baum für rowid
und einen B-Baum für zu haben (dt, another_column1, ...)
, haben wir nur einen.
db.executescript("""
CREATE TABLE autoinc(num INTEGER); INSERT INTO autoinc(num) VALUES(0);
CREATE TABLE data(dt INTEGER, id INTEGER, label TEXT, PRIMARY KEY(dt, id)) WITHOUT ROWID;
CREATE TRIGGER insert_trigger BEFORE INSERT ON data BEGIN UPDATE autoinc SET num=num+1; END;
""")
t = 1600000000
for i in range(1000*1000):
if random.randint(0, 100) == 0: # timestamp increases of 1 second with probabibly 1%
t += 1
db.execute("INSERT INTO data(dt, id, label) VALUES (?, (SELECT num FROM autoinc), ?);", (t, 'hello'))
db.commit()
# t will range in a ~ 10 000 seconds window
t1, t2 = 1600005000, 1600005100 # time range of width 100 seconds (i.e. 1%)
start = time.time()
for _ in db.execute("SELECT 1 FROM data WHERE dt BETWEEN ? AND ?", (t1, t2)):
pass
print(time.time()-start)
Grob sortierte UUID
Im Allgemeinen hängt das Problem damit zusammen, dass IDs nach Datum / Uhrzeit "grob sortiert" sind. Mehr dazu:
- ULID (Universally Unique Lexicographically Sortable Identifier)
- Schneeflocke
- MongoDB ObjectId
Alle diese Methoden verwenden eine ID:
[---- timestamp ----][---- random and/or incremental ----]
Ich bin kein Experte für SqlLite, habe aber mit Datenbanken und Zeitreihen gearbeitet. Ich hatte zuvor eine ähnliche Situation und würde meine konzeptionelle Lösung teilen.
Sie haben einen Teil der Antwort in Ihrer Frage, aber nicht die Art und Weise, wie Sie dies tun.
So wie ich es gemacht habe, 2 Tabellen zu erstellen, protokolliert eine Tabelle (main_logs) die Zeit in Sekundeninkrement als Datum als Ganzzahl als Primärschlüssel und die anderen Tabellenprotokolle enthalten alle Protokolle (main_sub_logs), die in dieser bestimmten Zeit erstellt wurden, die in Ihrem Fall möglich ist Es können bis zu 10000 Protokolle pro Sekunde vorhanden sein. Die main_sub_logs verweisen auf main_logs und enthalten für jede Protokollsekunde und X Anzahl der Protokolle, die zu dieser Sekunde gehören, mit einer eigenen Zähler-ID, die von vorne beginnt.
Auf diese Weise beschränken Sie Ihre Zeitreihen-Suche auf Sekunden von Ereignisfenstern anstelle aller Protokolle an einem Ort.
Auf diese Weise können Sie diese beiden Tabellen verbinden. Wenn Sie zwischen zwei bestimmten Zeiten von der ersten Tabelle nachschlagen, werden alle Protokolle dazwischen angezeigt.
Also hier ist, wie ich meine 2 Tabellen erstellt habe:
CREATE TABLE IF NOT EXISTS main_logs (
id INTEGER PRIMARY KEY
);
CREATE TABLE IF NOT EXISTS main_sub_logs (
id INTEGER,
ref INTEGER,
log_counter INTEGER,
log_text text,
PRIMARY KEY (id),
FOREIGN KEY (ref) REFERENCES main_logs(id)
)
Ich habe einige Dummy-Daten eingefügt:

Jetzt können Sie alle Protokolle zwischen 1608718655 und 1608718656 abfragen
SELECT * FROM main_logs AS A
JOIN main_sub_logs AS B ON A.id == B.Ref
WHERE A.id >= 1608718655 AND A.id <= 1608718656
Wird dieses Ergebnis erhalten:
