763 lines
23 KiB
Markdown
763 lines
23 KiB
Markdown
# План внедрения ClickHouse Dictionaries и агрегатов для AWatch-rus / DetMir
|
||
|
||
Дата: `2026-06-23`
|
||
|
||
Статус: рабочий архитектурный план.
|
||
|
||
## 1. Общая цель
|
||
|
||
Цель внедрения ClickHouse Dictionaries и Materialized Views - подготовить
|
||
AWatch-rus / DetMir к росту объема данных и числу пользователей отчетности.
|
||
|
||
Сырые события ActivityWatch остаются в fact-таблицах. Отчеты и Grafana не
|
||
должны постоянно выполнять тяжелые `JOIN` и агрегации по миллионам или
|
||
миллиардам строк. Обогащение и схлопывание данных нужно переносить на этап
|
||
записи, backfill или scheduled aggregation.
|
||
|
||
Базовая схема:
|
||
|
||
```text
|
||
raw ActivityWatch events
|
||
-> dimension tables
|
||
-> ClickHouse Dictionaries
|
||
-> Materialized Views / aggregate tables
|
||
-> Grafana / reports
|
||
```
|
||
|
||
Dictionaries не являются источником истины. Источник истины - обычные таблицы
|
||
измерений, импорт из NetBox/XLS/инвентаризации или утвержденные ручные
|
||
справочники.
|
||
|
||
## 2. Спринт 1. Оргструктура и слепые зоны
|
||
|
||
### Цель
|
||
|
||
Связать сырые события AWatch-rus с оргструктурой компании и выявить слепые зоны
|
||
в привязке рабочих мест, пользователей и подразделений.
|
||
|
||
### Решение
|
||
|
||
Создаются:
|
||
|
||
- `dim_workstation_user` - dimension/current snapshot;
|
||
- `dict_workstation_user` - словарь для lookup по паре `host_name + user_login`.
|
||
|
||
Так как ключ составной, используется `COMPLEX_KEY_HASHED()`.
|
||
|
||
### Source table
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS dim_workstation_user
|
||
(
|
||
host_name String,
|
||
user_login String,
|
||
|
||
employee_name String,
|
||
department String,
|
||
branch String,
|
||
position String,
|
||
|
||
source LowCardinality(String),
|
||
is_active UInt8 DEFAULT 1,
|
||
updated_at DateTime DEFAULT now()
|
||
)
|
||
ENGINE = ReplacingMergeTree(updated_at)
|
||
ORDER BY (host_name, user_login);
|
||
```
|
||
|
||
### Dictionary
|
||
|
||
```sql
|
||
CREATE DICTIONARY IF NOT EXISTS dict_workstation_user
|
||
(
|
||
host_name String,
|
||
user_login String,
|
||
|
||
employee_name String,
|
||
department String,
|
||
branch String,
|
||
position String,
|
||
is_active UInt8
|
||
)
|
||
PRIMARY KEY host_name, user_login
|
||
SOURCE(CLICKHOUSE(TABLE 'dim_workstation_user'))
|
||
LAYOUT(COMPLEX_KEY_HASHED())
|
||
LIFETIME(MIN 3600 MAX 86400);
|
||
```
|
||
|
||
### Отчет по слепым зонам
|
||
|
||
```sql
|
||
SELECT
|
||
host_name,
|
||
user_login,
|
||
count() AS events
|
||
FROM aw_raw_events
|
||
WHERE dictGetStringOrDefault(
|
||
'dict_workstation_user',
|
||
'employee_name',
|
||
(host_name, user_login),
|
||
''
|
||
) = ''
|
||
GROUP BY host_name, user_login
|
||
ORDER BY events DESC
|
||
LIMIT 100;
|
||
```
|
||
|
||
### Definition of Done
|
||
|
||
- создана таблица `dim_workstation_user`;
|
||
- создан словарь `dict_workstation_user`;
|
||
- словарь находится в статусе `LOADED`;
|
||
- есть стартовая загрузка данных;
|
||
- есть отчет unknown `host_name + user_login`;
|
||
- есть первый обогащенный запрос по сырым событиям;
|
||
- документирован источник истины для оргструктуры.
|
||
|
||
## 3. Спринт 2. Продуктивность и классификация desktop software
|
||
|
||
### Цель
|
||
|
||
Уйти от анализа миллионов строк процессов к понятным бизнес-метрикам по
|
||
использованию ПО на рабочих местах.
|
||
|
||
Сырые события должны обогащаться не только `process_name`, а нормализованным
|
||
именем приложения, категорией, признаком продуктивности и уровнем риска.
|
||
|
||
### Решение
|
||
|
||
Создаются:
|
||
|
||
- `dim_application_category`;
|
||
- `dict_application_category`;
|
||
- отчет top unknown processes;
|
||
- первый отчет продуктивности по отделам и сотрудникам.
|
||
|
||
### Source table
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS dim_application_category
|
||
(
|
||
process_name String,
|
||
|
||
application_name String,
|
||
vendor String,
|
||
category LowCardinality(String),
|
||
productivity_class LowCardinality(String),
|
||
risk_level LowCardinality(String),
|
||
|
||
is_system UInt8 DEFAULT 0,
|
||
is_active UInt8 DEFAULT 1,
|
||
source LowCardinality(String),
|
||
comment String,
|
||
updated_at DateTime DEFAULT now()
|
||
)
|
||
ENGINE = ReplacingMergeTree(updated_at)
|
||
ORDER BY process_name;
|
||
```
|
||
|
||
### Dictionary
|
||
|
||
```sql
|
||
CREATE DICTIONARY IF NOT EXISTS dict_application_category
|
||
(
|
||
process_name String,
|
||
|
||
application_name String,
|
||
vendor String,
|
||
category String,
|
||
productivity_class String,
|
||
risk_level String,
|
||
is_system UInt8,
|
||
is_active UInt8
|
||
)
|
||
PRIMARY KEY process_name
|
||
SOURCE(CLICKHOUSE(TABLE 'dim_application_category'))
|
||
LAYOUT(HASHED())
|
||
LIFETIME(MIN 3600 MAX 86400);
|
||
```
|
||
|
||
### Базовые классы продуктивности
|
||
|
||
| Значение | Смысл |
|
||
|---|---|
|
||
| `productive` | рабочее приложение |
|
||
| `neutral` | системное или вспомогательное приложение |
|
||
| `non_productive` | явно нерабочее использование |
|
||
| `risky` | потенциальный риск ИБ или нежелательное ПО |
|
||
| `unknown` | нет классификации |
|
||
|
||
### Отчет unknown processes
|
||
|
||
```sql
|
||
SELECT
|
||
process_name,
|
||
count() AS events,
|
||
sum(duration_sec) AS duration_sec
|
||
FROM aw_window_events
|
||
WHERE dictGetStringOrDefault(
|
||
'dict_application_category',
|
||
'category',
|
||
process_name,
|
||
''
|
||
) = ''
|
||
GROUP BY process_name
|
||
ORDER BY duration_sec DESC
|
||
LIMIT 100;
|
||
```
|
||
|
||
### Definition of Done
|
||
|
||
- создана таблица `dim_application_category`;
|
||
- создан словарь `dict_application_category`;
|
||
- загружен стартовый список известных процессов;
|
||
- есть top unknown processes;
|
||
- есть отчет продуктивности в разрезе отдела/сотрудника;
|
||
- классификация не используется как дисциплинарный вывод без ручной проверки;
|
||
- назначен владелец справочника классификации ПО.
|
||
|
||
## 4. Спринт 3. Веб-аналитика и глубинная фильтрация
|
||
|
||
### Цель
|
||
|
||
Перейти от анализа заголовков окон браузера и полных URL к нормальной
|
||
веб-аналитике: домены, категории сайтов, рабочая/нерабочая активность,
|
||
рисковые ресурсы и unknown domains.
|
||
|
||
### Решение
|
||
|
||
Создаются:
|
||
|
||
- нормализация URL в `domain_name`;
|
||
- `dim_domain_category`;
|
||
- `dict_domain_category`;
|
||
- top unknown domains;
|
||
- browser productivity report;
|
||
- задел под path-level правила.
|
||
|
||
### Нормализация URL
|
||
|
||
```sql
|
||
SELECT
|
||
url,
|
||
lowerUTF8(
|
||
parseURL(
|
||
if(position(url, '://') = 0, concat('http://', url), url),
|
||
'host'
|
||
)
|
||
) AS domain_name
|
||
FROM aw_browser_events;
|
||
```
|
||
|
||
Если в данных уже есть корректный полный URL, можно использовать `domain(url)`.
|
||
Если URL спрятан только в title, regex parsing допускается как fallback, но не
|
||
как основной путь.
|
||
|
||
### Source table
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS dim_domain_category
|
||
(
|
||
domain String,
|
||
|
||
site_name String,
|
||
category LowCardinality(String),
|
||
productivity_class LowCardinality(String),
|
||
risk_level LowCardinality(String),
|
||
business_allowed UInt8 DEFAULT 0,
|
||
|
||
source LowCardinality(String),
|
||
comment String,
|
||
is_active UInt8 DEFAULT 1,
|
||
updated_at DateTime DEFAULT now()
|
||
)
|
||
ENGINE = ReplacingMergeTree(updated_at)
|
||
ORDER BY domain;
|
||
```
|
||
|
||
### Dictionary
|
||
|
||
```sql
|
||
CREATE DICTIONARY IF NOT EXISTS dict_domain_category
|
||
(
|
||
domain String,
|
||
|
||
site_name String,
|
||
category String,
|
||
productivity_class String,
|
||
risk_level String,
|
||
business_allowed UInt8,
|
||
is_active UInt8
|
||
)
|
||
PRIMARY KEY domain
|
||
SOURCE(CLICKHOUSE(TABLE 'dim_domain_category'))
|
||
LAYOUT(HASHED())
|
||
LIFETIME(MIN 3600 MAX 86400);
|
||
```
|
||
|
||
### Начальные категории доменов
|
||
|
||
| Категория | Смысл |
|
||
|---|---|
|
||
| `internal_service` | внутренние корпоративные сервисы |
|
||
| `banking` | банковские и финансовые ресурсы |
|
||
| `government` | государственные сервисы |
|
||
| `work_service` | рабочие SaaS/порталы/документация |
|
||
| `developer` | Git, документация, package registry |
|
||
| `mail` | почтовые сервисы |
|
||
| `messenger` | web-мессенджеры |
|
||
| `cloud_storage` | облачные хранилища |
|
||
| `search` | поисковые системы |
|
||
| `news` | новости |
|
||
| `social` | социальные сети |
|
||
| `media` | видео/аудио/стриминг |
|
||
| `shopping` | покупки и маркетплейсы |
|
||
| `job_search` | сайты поиска работы |
|
||
| `ai_service` | внешние AI-сервисы |
|
||
| `unknown` | не классифицировано |
|
||
| `risky` | рискованный или нежелательный ресурс |
|
||
|
||
### Path-level правила
|
||
|
||
Классификация по домену не всегда достаточна. Для `github.com`, `youtube.com`,
|
||
`docs.google.com`, `mail.ru` может потребоваться учет path или контекста.
|
||
|
||
Задел под будущую детализацию:
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS dim_url_rule
|
||
(
|
||
rule_id String,
|
||
domain String,
|
||
path_pattern String,
|
||
|
||
category LowCardinality(String),
|
||
productivity_class LowCardinality(String),
|
||
risk_level LowCardinality(String),
|
||
|
||
priority UInt16 DEFAULT 100,
|
||
is_active UInt8 DEFAULT 1,
|
||
comment String,
|
||
updated_at DateTime DEFAULT now()
|
||
)
|
||
ENGINE = ReplacingMergeTree(updated_at)
|
||
ORDER BY (domain, priority, rule_id);
|
||
```
|
||
|
||
На спринте 3 `dim_url_rule` можно вести как backlog правил и применять точечно.
|
||
|
||
### Definition of Done
|
||
|
||
- создана таблица `dim_domain_category`;
|
||
- создан словарь `dict_domain_category`;
|
||
- есть нормализация URL в `domain_name`;
|
||
- есть стартовая классификация top domains;
|
||
- есть top unknown domains;
|
||
- есть browser productivity report;
|
||
- ограничения доменной модели и необходимость path-level правил задокументированы.
|
||
|
||
## 5. Спринт 4. Enterprise-масштабирование и стабильность
|
||
|
||
### Цель
|
||
|
||
Гарантировать, что система не ляжет при росте компании и объема событий.
|
||
Grafana не должна при каждом открытии dashboard на лету агрегировать миллиарды
|
||
сырых строк.
|
||
|
||
### Ключевое архитектурное правило
|
||
|
||
Materialized Views должны сразу складывать в агрегированные таблицы уже
|
||
обогащенные измерения, полученные через dictionaries.
|
||
|
||
То есть Grafana должна читать не `user_id` и не `process_name`, требующие
|
||
дальнейшего JOIN, а готовые бизнес-срезы:
|
||
|
||
```text
|
||
Дата / Час
|
||
Отделение
|
||
Отдел
|
||
Сотрудник
|
||
Категория продуктивности
|
||
Категория приложения или домена
|
||
Сумма секунд
|
||
Количество событий
|
||
```
|
||
|
||
Это переносит CPU-нагрузку с момента открытия dashboard на момент insert/backfill
|
||
и практически убирает тяжелые вычисления из пользовательских запросов Grafana.
|
||
|
||
### Общая схема
|
||
|
||
```text
|
||
raw events
|
||
-> Materialized View с dictGet* enrichment
|
||
-> SummingMergeTree / AggregatingMergeTree aggregate table
|
||
-> Grafana читает готовые агрегаты
|
||
```
|
||
|
||
Raw tables остаются для расследований и drill-down. Штатные dashboards должны
|
||
читать агрегаты.
|
||
|
||
### Выбор движка
|
||
|
||
| Движок | Где использовать |
|
||
|---|---|
|
||
| `SummingMergeTree` | суммы duration/count, основные dashboards |
|
||
| `AggregatingMergeTree` | uniq, quantile, topK, сложные агрегатные состояния |
|
||
|
||
Рекомендация: начинать с `SummingMergeTree`. `AggregatingMergeTree` подключать
|
||
только при доказанной необходимости.
|
||
|
||
### Desktop hourly aggregate
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS agg_aw_desktop_hourly
|
||
(
|
||
date Date,
|
||
hour DateTime,
|
||
|
||
branch String,
|
||
department String,
|
||
employee_name String,
|
||
host_name String,
|
||
user_login String,
|
||
|
||
application_name String,
|
||
app_category String,
|
||
productivity_class String,
|
||
risk_level String,
|
||
|
||
duration_sec UInt64,
|
||
event_count UInt64
|
||
)
|
||
ENGINE = SummingMergeTree()
|
||
PARTITION BY toYYYYMM(date)
|
||
ORDER BY
|
||
(
|
||
date,
|
||
hour,
|
||
branch,
|
||
department,
|
||
employee_name,
|
||
productivity_class,
|
||
app_category,
|
||
application_name,
|
||
host_name,
|
||
user_login
|
||
);
|
||
```
|
||
|
||
```sql
|
||
CREATE MATERIALIZED VIEW IF NOT EXISTS mv_aw_desktop_hourly
|
||
TO agg_aw_desktop_hourly
|
||
AS
|
||
SELECT
|
||
toDate(event_time) AS date,
|
||
toStartOfHour(event_time) AS hour,
|
||
|
||
dictGetStringOrDefault('dict_workstation_user', 'branch', (host_name, user_login), 'unknown') AS branch,
|
||
dictGetStringOrDefault('dict_workstation_user', 'department', (host_name, user_login), 'unknown') AS department,
|
||
dictGetStringOrDefault('dict_workstation_user', 'employee_name', (host_name, user_login), 'unknown') AS employee_name,
|
||
|
||
host_name,
|
||
user_login,
|
||
|
||
dictGetStringOrDefault('dict_application_category', 'application_name', process_name, process_name) AS application_name,
|
||
dictGetStringOrDefault('dict_application_category', 'category', process_name, 'unknown') AS app_category,
|
||
dictGetStringOrDefault('dict_application_category', 'productivity_class', process_name, 'unknown') AS productivity_class,
|
||
dictGetStringOrDefault('dict_application_category', 'risk_level', process_name, 'unknown') AS risk_level,
|
||
|
||
sum(duration_sec) AS duration_sec,
|
||
count() AS event_count
|
||
FROM aw_window_events
|
||
GROUP BY
|
||
date,
|
||
hour,
|
||
branch,
|
||
department,
|
||
employee_name,
|
||
host_name,
|
||
user_login,
|
||
application_name,
|
||
app_category,
|
||
productivity_class,
|
||
risk_level;
|
||
```
|
||
|
||
### Browser hourly aggregate
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS agg_aw_browser_hourly
|
||
(
|
||
date Date,
|
||
hour DateTime,
|
||
|
||
branch String,
|
||
department String,
|
||
employee_name String,
|
||
host_name String,
|
||
user_login String,
|
||
|
||
domain_name String,
|
||
site_name String,
|
||
domain_category String,
|
||
productivity_class String,
|
||
risk_level String,
|
||
|
||
duration_sec UInt64,
|
||
event_count UInt64
|
||
)
|
||
ENGINE = SummingMergeTree()
|
||
PARTITION BY toYYYYMM(date)
|
||
ORDER BY
|
||
(
|
||
date,
|
||
hour,
|
||
branch,
|
||
department,
|
||
employee_name,
|
||
productivity_class,
|
||
domain_category,
|
||
domain_name,
|
||
host_name,
|
||
user_login
|
||
);
|
||
```
|
||
|
||
```sql
|
||
CREATE MATERIALIZED VIEW IF NOT EXISTS mv_aw_browser_hourly
|
||
TO agg_aw_browser_hourly
|
||
AS
|
||
WITH
|
||
lowerUTF8(
|
||
parseURL(
|
||
if(position(url, '://') = 0, concat('http://', url), url),
|
||
'host'
|
||
)
|
||
) AS domain_name
|
||
SELECT
|
||
toDate(event_time) AS date,
|
||
toStartOfHour(event_time) AS hour,
|
||
|
||
dictGetStringOrDefault('dict_workstation_user', 'branch', (host_name, user_login), 'unknown') AS branch,
|
||
dictGetStringOrDefault('dict_workstation_user', 'department', (host_name, user_login), 'unknown') AS department,
|
||
dictGetStringOrDefault('dict_workstation_user', 'employee_name', (host_name, user_login), 'unknown') AS employee_name,
|
||
|
||
host_name,
|
||
user_login,
|
||
domain_name,
|
||
|
||
dictGetStringOrDefault('dict_domain_category', 'site_name', domain_name, domain_name) AS site_name,
|
||
dictGetStringOrDefault('dict_domain_category', 'category', domain_name, 'unknown') AS domain_category,
|
||
dictGetStringOrDefault('dict_domain_category', 'productivity_class', domain_name, 'unknown') AS productivity_class,
|
||
dictGetStringOrDefault('dict_domain_category', 'risk_level', domain_name, 'unknown') AS risk_level,
|
||
|
||
sum(duration_sec) AS duration_sec,
|
||
count() AS event_count
|
||
FROM aw_browser_events
|
||
WHERE domain_name != ''
|
||
GROUP BY
|
||
date,
|
||
hour,
|
||
branch,
|
||
department,
|
||
employee_name,
|
||
host_name,
|
||
user_login,
|
||
domain_name,
|
||
site_name,
|
||
domain_category,
|
||
productivity_class,
|
||
risk_level;
|
||
```
|
||
|
||
### Daily aggregate для управленческих отчетов
|
||
|
||
Daily layer лучше строить из hourly aggregates, чтобы не дублировать enrichment
|
||
логику и не перечитывать raw events.
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS agg_aw_activity_daily
|
||
(
|
||
date Date,
|
||
branch String,
|
||
department String,
|
||
source_type LowCardinality(String),
|
||
productivity_class String,
|
||
category String,
|
||
duration_sec UInt64,
|
||
event_count UInt64
|
||
)
|
||
ENGINE = SummingMergeTree()
|
||
PARTITION BY toYYYYMM(date)
|
||
ORDER BY
|
||
(
|
||
date,
|
||
branch,
|
||
department,
|
||
source_type,
|
||
productivity_class,
|
||
category
|
||
);
|
||
```
|
||
|
||
Пример загрузки из desktop hourly:
|
||
|
||
```sql
|
||
INSERT INTO agg_aw_activity_daily
|
||
SELECT
|
||
date,
|
||
branch,
|
||
department,
|
||
'desktop' AS source_type,
|
||
productivity_class,
|
||
app_category AS category,
|
||
sum(duration_sec) AS duration_sec,
|
||
sum(event_count) AS event_count
|
||
FROM agg_aw_desktop_hourly
|
||
WHERE date = yesterday()
|
||
GROUP BY
|
||
date,
|
||
branch,
|
||
department,
|
||
source_type,
|
||
productivity_class,
|
||
category;
|
||
```
|
||
|
||
### Почему обогащение нужно делать до Grafana
|
||
|
||
Если Grafana читает raw tables и выполняет `dictGet*` или `JOIN` при каждом
|
||
открытии панели, CPU ClickHouse будет расходоваться на одни и те же вычисления.
|
||
При росте компании это станет узким местом.
|
||
|
||
Если Materialized View уже положила в агрегат строки вида:
|
||
|
||
```text
|
||
2026-06-23 / Сыктывкар / ОТ / productive / office / 18420 sec
|
||
```
|
||
|
||
то Grafana выполняет простой `SELECT sum(duration_sec) ... GROUP BY ...` по
|
||
маленькой таблице. Это дает кратный выигрыш и делает dashboard стабильным.
|
||
|
||
### Backfill и rebuild
|
||
|
||
Materialized View обрабатывает только новые вставки после создания MV.
|
||
Исторические данные нужно пересчитать отдельно.
|
||
|
||
Порядок:
|
||
|
||
1. создать aggregate table;
|
||
2. создать materialized view для новых данных;
|
||
3. выполнить `INSERT INTO aggregate SELECT ... FROM raw WHERE ...` за историю;
|
||
4. сверить суммы raw vs aggregate;
|
||
5. переключить Grafana на aggregate;
|
||
6. документировать rebuild-период, если справочники изменились.
|
||
|
||
Важно: если словари поменялись после агрегации, старые агрегаты сами не
|
||
пересчитаются. Для строгой актуальности нужен rebuild затронутого периода или
|
||
snapshot/history dimensions.
|
||
|
||
### Coverage aggregate для качества справочников
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS agg_aw_dictionary_coverage_daily
|
||
(
|
||
date Date,
|
||
source_type LowCardinality(String),
|
||
unknown_type LowCardinality(String),
|
||
unknown_key String,
|
||
events UInt64,
|
||
duration_sec UInt64
|
||
)
|
||
ENGINE = SummingMergeTree()
|
||
PARTITION BY toYYYYMM(date)
|
||
ORDER BY (date, source_type, unknown_type, unknown_key);
|
||
```
|
||
|
||
Эта таблица нужна, чтобы видеть:
|
||
|
||
- unknown workstations;
|
||
- unknown processes;
|
||
- unknown domains;
|
||
- динамику качества справочников.
|
||
|
||
### Grafana policy
|
||
|
||
После спринта 4 штатные dashboards должны читать:
|
||
|
||
- `agg_aw_desktop_hourly`;
|
||
- `agg_aw_browser_hourly`;
|
||
- `agg_aw_activity_daily`;
|
||
- `agg_aw_dictionary_coverage_daily`.
|
||
|
||
Raw tables допустимы только для расследований, drill-down и технической
|
||
диагностики.
|
||
|
||
### Monitoring
|
||
|
||
Минимальные проверки:
|
||
|
||
```sql
|
||
SELECT
|
||
table,
|
||
sum(rows) AS rows,
|
||
formatReadableSize(sum(bytes_on_disk)) AS size
|
||
FROM system.parts
|
||
WHERE active
|
||
GROUP BY table
|
||
ORDER BY rows DESC;
|
||
```
|
||
|
||
```sql
|
||
SELECT
|
||
name,
|
||
status,
|
||
element_count,
|
||
last_exception
|
||
FROM system.dictionaries
|
||
WHERE name LIKE 'dict_%';
|
||
```
|
||
|
||
### Retention
|
||
|
||
Начальная рекомендация:
|
||
|
||
| Слой | Retention |
|
||
|---|---|
|
||
| raw events | 90-180 дней или по политике проекта |
|
||
| hourly aggregates | 12-24 месяца |
|
||
| daily aggregates | 3-5 лет или по требованиям отчетности |
|
||
| dictionary coverage | 12 месяцев |
|
||
|
||
TTL включать только после согласования требований отчетности и хранения.
|
||
|
||
### Definition of Done
|
||
|
||
Спринт 4 считается завершенным, когда:
|
||
|
||
- создана `agg_aw_desktop_hourly`;
|
||
- создана `mv_aw_desktop_hourly`;
|
||
- создана `agg_aw_browser_hourly`;
|
||
- создана `mv_aw_browser_hourly`;
|
||
- создан daily aggregate layer;
|
||
- создан coverage aggregate для unknown значений;
|
||
- выполнен backfill хотя бы за один исторический период;
|
||
- сверены суммы raw vs aggregate;
|
||
- минимум один Grafana dashboard переключен на aggregate table;
|
||
- Grafana не выполняет штатные отчеты по raw events;
|
||
- документирован rebuild/backfill runbook;
|
||
- есть мониторинг таблиц, словарей и unknown coverage.
|
||
|
||
### Риски
|
||
|
||
| Риск | Митигация |
|
||
|---|---|
|
||
| MV не обработала исторические данные | обязательный backfill |
|
||
| Ошибка классификации попала в агрегаты | rebuild затронутого периода |
|
||
| Слишком высокая кардинальность aggregate | не тащить URL/title в основные агрегаты |
|
||
| Grafana продолжает читать raw tables | dashboard review и policy запрета raw для штатных панелей |
|
||
| Словари изменились после агрегации | rebuild или snapshot/history dimensions |
|
||
| Слишком много parts | контролировать batch insert, partitioning, merge health |
|
||
| Сложность `AggregatingMergeTree` | начинать с `SummingMergeTree` |
|