Постановка задачи
Пусть мы пишем систему слежения за НЛО. В SQLite-базе хранятся периоды наблюдения за воздушным пространством и отчёты о наблюдении НЛО. Интересовать нас будет среди прочего статистика по количеству наблюдаемых НЛО в каждый период.
Для этого понадобится выполнять запрос на поиск пересекающихся отрезков, для ускорения которого нужен специальный индекс.
Решение «в лоб»
Для хранения периодов наблюдения заведём таблицы observations и events, дату будем хранить как строку:
create table observations (
id integer primary key,
start text not null,
end text not null
);
create table events (
id integer primary key,
start text not null,
end text not null
);
Следующий очевидный запрос выдаст количество наблюдений НЛО за каждый период. Для удобства сравнения дата конвертируется в юлианский день, т.е. в число с плавающей точкой.
select
o.id,
count(e.id)
from
observations o,
events e
where
julianday(o.start) < julianday(e.end)
and julianday(o.end) > julianday(e.start)
Как видно по плану запроса, запрос проходит по всем записям из observations и по всем из events:
selectid | order | from | detail |
---|---|---|---|
0 | 0 | 0 | SCAN TABLE observations AS o |
0 | 1 | 1 | SCAN TABLE events AS e |
Стоит базе хоть немного подрасти (4000 записей в observations и 65000 записей в events), как этот запрос начинает выполняться очень медленно (по 3 минуты). Очевидно, нас не устраивает подобное положение вещей.
R*Tree-индексы
Создание индекса
В SQLite есть специальный тип индексов R * Tree предназначенный для ускорения запросов вида «попадает ли точка в заданный k-мерный отрезок?». На каждую проиндексированную таблицу создаётся виртуальная таблица с индексом, в которой хранятся записи с первичным ключом, равным rowid записи из основной таблицы и пары чисел, хранящие начальную и конечную координаты соответствующей размерности многомерного отрезка. При этом если 32-битного числа с плавающей точкой не хватает для точного представления координаты, то начальная координата округляется вниз, а конечная - вверх. Таким образом, интервал в индексе полностью включает в себя интервал исходной записи с небольшим запасом.
В нашем случае удобно будет хранить в индексе начало и конец интервала как юлианскую дату. Однако на момент написания этой статьи (17 сентября 2016) эта дата составляет 2457648.91398646, что приведёт к большой погрешности при составлении индекса. Поэтому при сохранении данных в индекс вычтем из даты какую-нибудь константу, близкую к сегодняшнему моменту. Например, 2450000 (9 октября 1995).
Создадим индексы:
create virtual table observations_index using rtree (id, start, end);
create virtual table events_index using rtree (id, start, end);
Обновление индекса
SQLite не обновляет RTree-индекс автоматически. Можно, конечно, заполнять таблицу вручную, но тут легко допустить ошибку и получить индекс, не синхронный с состоянием основной таблицы. Поэтому будем обновлять его по триггеру.
create trigger observations_insert_trigger
after insert on observations
begin
insert into observations_index
(id, start, end)
values
(new.id, julianday(new.start) - 2450000, julianday(new.end) - 2450000)
;
end;
create trigger observations_update_trigger
after update on observations
begin
update observations_index
set
start = julianday(new.start) - 2450000,
end = julianday(new.end) - 2450000
where
id = new.id
;
end;
create trigger observations_delete_trigger
after delete on observations
begin
delete from observations_index
where id = old.id
;
end;
Аналогичным образом создадим индексы для таблицы events.
Использование индекса
В запросе сначала будем фильтровать пересекающиеся интервалы по RTree-индексам, а затем уже уточнять по значениям из основных таблиц.
select
o.id,
(
select
count(e.id)
from
events e
join events_index ei on (e.id = ei.id)
where
oi.start < ei.end
and oi.end > ei.start
and julianday(o.start) < julianday(e.end)
and julianday(o.end) > julianday(e.start)
)
from
observations o
join observations_index oi on (o.id = oi.id)
По плану запроса видно, что используется созданный нами индекс:
selectid | order | from | detail |
---|---|---|---|
0 | 0 | 1 | SCAN TABLE observations_index AS oi VIRTUAL TABLE INDEX 2: |
0 | 1 | 0 | SEARCH TABLE observations AS o USING INTEGER PRIMARY KEY (rowid=?) |
0 | 0 | 0 | EXECUTE CORRELATED SCALAR SUBQUERY 1 |
1 | 0 | 1 | SCAN TABLE events_index AS ei VIRTUAL TABLE INDEX 2:C0E1 |
1 | 1 | 0 | SEARCH TABLE events AS e USING INTEGER PRIMARY KEY (rowid=?) |
Выполняется такой запрос за 388 мс, что в пятьсот раз быстрее, чем запрос без индекса.