Привет, многоуважаемые читатели Хабра!
Как нынешний аналитик БД с прошлым пятилетним опытом в B2B и B2C сегментах телеком-провайдеров, я хотел бы осветить одну из "болей" абонентов - «архивные» тарифы. Абоненты годами сидят на устаревших и дорогих тарифах, не зная, что в их же доме уже давно доступны более выгодные предложения от конкурентов.
Чтобы решить эту проблему и обеспечить открытость рынка тарифов, я решил объединить свой опыт в телекоме с навыками программирования. Так родилась идея проекта по автоматизированному парсингу тарифов провайдеров.
В первой части цикла статей я расскажу об архитектуре базы данных, которую я спроектировал для этой задачи.
Зачем это нужно?
Миссия проекта — создать инструмент, который автоматически мониторит провайдеров, избавляя пользователей от ручного сравнения и помогая им находить оптимальные условия по тарифу.
Архитектура БД: схема «Звезда» на PostgreSQL

Проектирование я начал со структуры на PostgreSQL, используя схему «Звезда».
Таблица фактов: Таблица
город_провайдерсо связью города с провайдером.Таблицы измерений: Таблицы с информацией о тарифах (
тарифы), городах (города) и провайдерах (провайдеры).
Таблицы измерений (Dimension Tables)
-- Города
CREATE TABLE IF NOT EXISTS города (
id BIGINT GENERATED ALWAYS AS IDENTITY,
city_name VARCHAR (100) NOT NULL,
CONSTRAINT pk_города_id PRIMARY KEY (id)
);
COMMENT ON TABLE города IS 'Таблица с городами России';
COMMENT ON COLUMN города.id IS 'Уникальный идентификатор города';
COMMENT ON COLUMN города.city_name IS 'Название города';
-- Провайдеры
CREATE TABLE IF NOT EXISTS провайдеры (
id BIGINT GENERATED ALWAYS AS IDENTITY,
provider_name VARCHAR (100),
start_date DATE DEFAULT CURRENT_DATE,
end_date DATE DEFAULT NULL,
CONSTRAINT pk_провайдеры_id PRIMARY KEY (id)
);
COMMENT ON TABLE провайдеры IS 'Таблица с именами провайдеров';
COMMENT ON COLUMN провайдеры.id IS 'Уникальный идентификатор провайдера';
COMMENT ON COLUMN провайдеры.provider_name IS 'Название провайдера';
COMMENT ON COLUMN провайдеры.start_date IS 'Дата начала действия провайдера';
COMMENT ON COLUMN провайдеры.end_date IS 'Дата окончания действия провайдера';Таблица фактов (Fact Table)
-- Тарифы
CREATE TABLE IF NOT EXISTS тарифы(
id BIGINT GENERATED ALWAYS AS IDENTITY,
город_id BIGINT,
провайдер_id BIGINT,
tariff_name VARCHAR(100) NOT NULL, -- название тарифа
internet_speed INT, -- скорость интернета
tv_channels INT, -- количество каналов
has_cinema VARCHAR(255), -- онлайн кинотеатр
has_mobile VARCHAR(255), -- мобильная связь
other VARCHAR(255), -- прочее
connection_cost DECIMAL(10, 2) DEFAULT 0, -- цена за подключение
monthly_cost DECIMAL(10, 2) NOT NULL, -- ежемесячная оплата
discount_cost DECIMAL(10, 2), -- цена со скидкой
discount_description VARCHAR(255), -- скидочные предложения, описание
start_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
end_at TIMESTAMP DEFAULT NULL,
CONSTRAINT pk_tariffs_id PRIMARY KEY (id),
CONSTRAINT fk_tariffs_city_providers_id FOREIGN KEY (city_providers_id) REFERENCES city_providers (id)
);Для удобства дальнейшей работы все первичные и внешние ключи созданы в виде именованных ограничений.
CONSTRAINT ограничение_для_pk_id PRIMARY KEY (id),
CONSTRAINT ограничение_для_fk_id FOREIGN KEY (id) REFERENCES (id),
CONSTRAINT uq_город_провайдер UNIQUE (город_id, провайдер_id) -- уникальность значений для таблицы связейУправление историчностью и автоматизация
Чтобы отслеживать изменения данных (например, когда провайдер деактивирует старый тариф), в таблицах связей, провайдерах и тарифах ведется историчность изменений с помощью автоматических триггеров.
Каждая сущность (кроме городов) имеет временные метки start_at и end_at для отслеживания актуальности записи.
CREATE TABLE IF NOT EXISTS сущности (
-- ...
start_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- для сущности связи
end_at TIMESTAMP DEFAULT NULL, -- NULL значит «действует до сих пор»
is_active BOOLEAN DEFAULT TRUE
);Функции автоматически управляют этими полями при обновлениях:
CREATE OR REPLACE FUNCTION функция_реагирующая_на_изменения()
RETURNS TRIGGER AS $$
BEGIN
-- При любом изменение менять дату updated_at
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;Реализована каскадная деактивация: при деактивации провайдера автоматически деактивируются все связанные с ним тарифы.
Базовые индексы для ускорения чтения:
CREATE INDEX CONCURRENTLY idx_город_провайдер_город_id ON город_провайдер (город_id);
CREATE INDEX CONCURRENTLY idx_ город_провайдер_провайдер_id ON город_провайдер (провайдер_id);
CREATE INDEX CONCURRENTLY idx_тарифы_город_провайдер_id ON тарифы (город_провайдеры_id); -- таблица с тарифамиЧто дальше?
В следующей части я планирую рассказать о парсинге на Python vs Selenium.
Буду признателен за критику моей архитектуры БД и кода. Какие дополнения вы бы внесли в структуру SQL (ограничения, улучшение триггера, дополнительные индексы)?
Однажды наткнулся на статью о том, как кто-то реализовал игру DOOM полностью на SQL, стало интересно какие возможности этого языка. Насколько реально перевести весь бэкенд на SQL. Планирую применять Python для парсинга и записи данных в базу. Постепенно планирую заменять некоторые функции Python на SQL. Интересно, насколько можно заменить Python на SQL в реальных задачах пишите ваше мнение.
