mnemo_cards/mnemo_cards_backend/DATABASE_ANALYSIS.md
2026-01-03 16:14:27 +03:00

791 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 🔍 Анализ базы данных Mnemo Cards
> **Дата анализа:** 14 декабря 2025
> **Версия схемы:** 1
> **База данных:** PostgreSQL 16 + Drift ORM
---
## 📊 Общая оценка
| Категория | Оценка | Комментарий |
|-----------|--------|-------------|
| **Структура схемы** | ⭐⭐⭐⚪⚪ | Хорошая основа, но есть проблемы с нормализацией |
| **Производительность** | ⭐⭐⭐⭐⚪ | Индексы созданы, но можно оптимизировать |
| **Целостность данных** | ⭐⭐⭐⚪⚪ | Есть deprecated поля и проблемы с NULL |
| **Масштабируемость** | ⭐⭐⭐⚪⚪ | Денормализация может стать проблемой |
| **Безопасность** | ⭐⭐⚪⚪⚪ | Отсутствует audit trail и RLS |
**Общая оценка: 3.2/5** - База данных функциональна, но требует улучшений для production-ready состояния.
---
## 🚨 Критические проблемы (приоритет: ВЫСОКИЙ)
### 1. Использование TEXT вместо UUID для ID
**Проблема:**
```dart
// Текущая реализация
TextColumn get id => text()
.withDefault(const CustomExpression('gen_random_uuid()::text'))();
```
**Почему это плохо:**
- ❌ UUID хранится как TEXT (36 байт) вместо нативного UUID (16 байт) — потеря 55% места
- ❌ Медленнее индексирование и сравнение
- ❌ Нет встроенной валидации UUID формата
- ❌ Больший размер индексов
**Рекомендация:**
```dart
// Использовать нативный UUID тип PostgreSQL
import 'package:postgres/postgres.dart' show PgDataType;
class Users extends Table {
Column<PgUuid> get id => customType(PgTypes.uuid)
.withDefault(const CustomExpression('gen_random_uuid()'))
.clientDefault(generateUuid)();
// ...
}
```
**Воздействие:** Экономия ~40% места в индексах, ускорение JOIN на ~20-30%
---
### 2. Денормализация в UserDatas
**Проблема:**
```dart
// UserDatas содержит большие JSON массивы
TextColumn get words => text()
.withDefault(const Constant('[]'))
.map(const JsonListConverter())(); // Может быть огромным!
TextColumn get packProgress => text()
.withDefault(const Constant('[]'))
.map(const JsonListConverter())(); // Дублирует данные
TextColumn get achievements => text()
.withDefault(const Constant('[]'))
.map(const JsonListConverter())(); // Уже есть таблица UserAchievements!
```
**Почему это плохо:**
- ❌ Невозможно индексировать элементы внутри JSON
- ❌ Сложно делать JOIN и агрегации
- ❌ Большой размер строки → медленные UPDATE/SELECT
- ❌ Дублирование данных (achievements уже в отдельной таблице)
**Рекомендация:**
#### 2.1 Создать таблицу WordStatistics
```dart
class WordStatistics extends Table {
TextColumn get id => text()
.withDefault(const CustomExpression('gen_random_uuid()::text'))();
TextColumn get userId => text()
.references(Users, #id, onDelete: KeyAction.cascade)();
TextColumn get cardId => text()
.references(GameCards, #id, onDelete: KeyAction.cascade)();
IntColumn get correctAnswers => integer().withDefault(const Constant(0))();
IntColumn get incorrectAnswers => integer().withDefault(const Constant(0))();
RealColumn get mastery => real().withDefault(const Constant(0.0))();
Column<PgDateTime> get lastReviewed => customType(PgTypes.timestampWithTimezone)
.nullable()();
Column<PgDateTime> get nextReview => customType(PgTypes.timestampWithTimezone)
.nullable()();
@override
Set<Column> get primaryKey => {id};
}
```
#### 2.2 Удалить дублирующиеся JSON поля
```dart
class UserDatas extends Table {
// УДАЛИТЬ:
// TextColumn get words => ...
// TextColumn get achievements => ... // Уже есть UserAchievements таблица!
// ОСТАВИТЬ только то, что действительно нужно как JSON:
TextColumn get categoryMinutes => text() // Может быть JSON для гибкости
.withDefault(const Constant('{}'))
.map(const JsonMapConverter())();
}
```
**Воздействие:**
- Ускорение SELECT на ~50% (меньше данных)
- Возможность эффективных запросов по словам
- Правильная нормализация
---
### 3. Дублирование связей Pack ↔ Cards
**Проблема:**
```dart
// GameCards уже имеет packId
class GameCards extends Table {
TextColumn get packId => text()
.references(CardPacks, #id, onDelete: KeyAction.cascade)();
// ...
}
// Но также есть отдельная таблица many-to-many
class CardPackCards extends Table {
TextColumn get packId => text()
.references(CardPacks, #id, onDelete: KeyAction.cascade)();
TextColumn get cardId => text()
.references(GameCards, #id, onDelete: KeyAction.cascade)();
// ...
}
```
**Почему это плохо:**
- ❌ Избыточность данных
- ❌ Возможность рассинхронизации (packId в GameCards != packId в CardPackCards)
- ❌ Усложнение логики обновления
**Рекомендация:**
**Вариант A:** Если карточка всегда принадлежит одному паку (что похоже на правду):
```dart
// УДАЛИТЬ таблицу CardPackCards
// ОСТАВИТЬ только packId в GameCards
class GameCards extends Table {
TextColumn get packId => text()
.references(CardPacks, #id, onDelete: KeyAction.cascade)();
// ...
}
```
**Вариант B:** Если карточка может быть в нескольких паках (share):
```dart
// УДАЛИТЬ packId из GameCards
// ОСТАВИТЬ только CardPackCards
class GameCards extends Table {
// Убрать packId отсюда
// ...
}
class CardPackCards extends Table {
TextColumn get packId => text()
.references(CardPacks, #id, onDelete: KeyAction.cascade)();
TextColumn get cardId => text()
.references(GameCards, #id, onDelete: KeyAction.cascade)();
IntColumn get order => integer().withDefault(const Constant(0))();
@override
Set<Column> get primaryKey => {packId, cardId};
}
```
**Рекомендуется Вариант A**, если карточки не переиспользуются между паками.
---
### 4. Отсутствие CHECK constraints для enum полей
**Проблема:**
```dart
// Статус хранится как произвольный string
TextColumn get status => text()(); // PaymentStatus
// В БД может попасть что угодно: "completed", "COMPLETED", "Completed", "invalid123"
```
**Почему это плохо:**
- ❌ Нет валидации на уровне БД
- ❌ Возможны опечатки и некорректные значения
- ❌ Усложняется отладка
**Рекомендация:**
**Вариант A:** Использовать PostgreSQL ENUM (рекомендуется):
```sql
-- Создать enum типы в миграции
CREATE TYPE payment_status AS ENUM (
'created', 'pending', 'processing',
'succeeded', 'cancelled', 'failed', 'unknown'
);
CREATE TYPE payment_system AS ENUM (
'yookassa', 'google', 'rustore',
'promo_code', 'ad_view', 'unknown'
);
```
```dart
// В Drift:
class Payments extends Table {
// Использовать custom type
TextColumn get status => text()
.customConstraint('payment_status NOT NULL DEFAULT \'created\'')();
TextColumn get paymentSystem => text()
.customConstraint('payment_system NOT NULL')();
// ...
}
```
**Вариант B:** CHECK constraint (проще, но менее строго):
```dart
class Payments extends Table {
TextColumn get status => text()();
@override
List<String> get customConstraints => [
'CONSTRAINT valid_payment_status CHECK (status IN (\'created\', \'pending\', \'processing\', \'succeeded\', \'cancelled\', \'failed\', \'unknown\'))',
];
}
```
---
## ⚠️ Важные проблемы (приоритет: СРЕДНИЙ)
### 5. Deprecated поля в Payments
**Проблема:**
```dart
class Payments extends Table {
// Deprecated fields (для обратной совместимости)
TextColumn get packs => text()
.withDefault(const Constant('[]'))
.map(const StringListConverter())();
BoolColumn get subscription => boolean()
.withDefault(const Constant(false))();
}
```
**Рекомендация:**
1. Создать миграцию для удаления deprecated полей
2. Убедиться, что все клиенты используют поле `products` вместо `packs`
```sql
-- Миграция v2
ALTER TABLE payments DROP COLUMN IF EXISTS packs;
ALTER TABLE payments DROP COLUMN IF EXISTS subscription;
```
---
### 6. Отсутствие партиционирования для больших таблиц
**Проблема:**
Таблицы `StudySessions` и `Payments` растут со временем без ограничений.
**Рекомендация:**
Использовать партиционирование по дате для старых записей:
```sql
-- Партиционирование StudySessions по месяцам
CREATE TABLE study_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
start_time TIMESTAMP WITH TIME ZONE NOT NULL,
-- ...
) PARTITION BY RANGE (start_time);
-- Создать партиции
CREATE TABLE study_sessions_2025_12 PARTITION OF study_sessions
FOR VALUES FROM ('2025-12-01') TO ('2026-01-01');
CREATE TABLE study_sessions_2026_01 PARTITION OF study_sessions
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
-- И так далее (можно автоматизировать)
```
**Альтернатива:** Периодическое архивирование старых данных в отдельную таблицу.
---
### 7. Недостаточно индексов для частых запросов
**Проблема:**
Не все часто используемые запросы оптимизированы индексами.
**Рекомендация:**
#### 7.1 Composite индексы для JOIN запросов
```sql
-- Для запросов "получить паки пользователя"
CREATE INDEX idx_user_packs_composite ON user_packs(user_id, pack_id);
-- Для запросов "получить карточки пака"
CREATE INDEX idx_card_pack_cards_composite ON card_pack_cards(pack_id, "order");
-- Для получения активных подписок
CREATE INDEX idx_user_subscriptions_active ON user_subscriptions(user_id, finish)
WHERE finish > NOW();
```
#### 7.2 Covering индексы (INCLUDE)
```sql
-- Для запросов по email часто нужны id и name
CREATE INDEX idx_users_email_covering ON users(email)
INCLUDE (id, name, admin)
WHERE email IS NOT NULL AND is_deleted = false;
-- Для токенов часто нужен userId
CREATE INDEX idx_tokens_token_covering ON tokens(token)
INCLUDE (user_id, expires);
```
#### 7.3 GIN индексы для JSON полей
```sql
-- Если все же оставляем JSON в UserDatas
CREATE INDEX idx_user_datas_category_minutes ON user_datas
USING GIN (category_minutes jsonb_path_ops);
-- Для поиска по purchases
CREATE INDEX idx_users_purchases ON users
USING GIN (purchases jsonb_path_ops);
```
---
### 8. Отсутствие soft delete для всех таблиц
**Проблема:**
Не все критичные таблицы поддерживают soft delete (CardPacks, GameCards есть, но Payments, StudySessions нет).
**Рекомендация:**
Добавить `is_deleted` и `deleted_at` во все таблицы, где важна история:
```dart
// Для всех таблиц добавить:
BoolColumn get isDeleted => boolean()
.withDefault(const Constant(false))
.customConstraint('')();
Column<PgDateTime> get deletedAt => customType(PgTypes.timestampWithTimezone)
.nullable()();
// И соответствующие индексы
CREATE INDEX idx_{table}_not_deleted ON {table}(is_deleted)
WHERE is_deleted = false;
```
---
## 💡 Рекомендации по улучшению (приоритет: НИЗКИЙ)
### 9. Добавить audit trail таблицу
**Рекомендация:**
Создать таблицу для логирования всех изменений критичных данных:
```dart
class AuditLog extends Table {
TextColumn get id => text()
.withDefault(const CustomExpression('gen_random_uuid()::text'))();
TextColumn get tableName => text()();
TextColumn get recordId => text()();
TextColumn get action => text()(); // 'INSERT', 'UPDATE', 'DELETE'
TextColumn get userId => text().nullable()();
TextColumn get oldData => text().nullable()(); // JSON
TextColumn get newData => text().nullable()(); // JSON
TextColumn get ipAddress => text().nullable()();
TextColumn get userAgent => text().nullable()();
Column<PgDateTime> get createdAt => customType(PgTypes.timestampWithTimezone)
.withDefault(now())();
@override
Set<Column> get primaryKey => {id};
}
```
Затем создать PostgreSQL триггеры для автоматического логирования:
```sql
-- Пример триггера для Payments
CREATE OR REPLACE FUNCTION audit_payment_changes()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'UPDATE' THEN
INSERT INTO audit_log (table_name, record_id, action, old_data, new_data, created_at)
VALUES ('payments', NEW.id, 'UPDATE',
row_to_json(OLD)::text,
row_to_json(NEW)::text,
NOW());
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER audit_payments_trigger
AFTER UPDATE ON payments
FOR EACH ROW EXECUTE FUNCTION audit_payment_changes();
```
---
### 10. Добавить Row Level Security (RLS)
**Рекомендация:**
Использовать PostgreSQL RLS для дополнительной безопасности:
```sql
-- Включить RLS для критичных таблиц
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE payments ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_subscriptions ENABLE ROW LEVEL SECURITY;
-- Политика: пользователь может видеть только свои данные
CREATE POLICY user_isolation_policy ON users
FOR ALL
USING (id = current_setting('app.current_user_id')::text OR
(SELECT admin FROM users WHERE id = current_setting('app.current_user_id')::text));
-- Политика для платежей
CREATE POLICY payment_isolation_policy ON payments
FOR ALL
USING (user_id = current_setting('app.current_user_id')::text OR
(SELECT admin FROM users WHERE id = current_setting('app.current_user_id')::text));
```
Затем в коде устанавливать `current_user_id` при каждом запросе:
```dart
await db.customStatement(
'SET LOCAL app.current_user_id = ?',
[userId],
);
```
---
### 11. Использовать JSONB вместо TEXT для JSON
**Проблема:**
```dart
// Сейчас JSON хранится как TEXT
TextColumn get settings => text().nullable()();
TextColumn get products => text()
.withDefault(const Constant('[]'))
.map(const JsonListConverter())();
```
**Рекомендация:**
PostgreSQL имеет нативный тип JSONB с индексацией и операторами:
```dart
import 'package:postgres/postgres.dart' show PgDataType;
class Payments extends Table {
// Использовать JSONB
Column<Map<String, dynamic>> get products => customType(PgTypes.jsonb)
.withDefault(const Constant('[]'))();
}
```
**Преимущества:**
- ✅ Валидация JSON на уровне БД
- ✅ Возможность индексации GIN
- ✅ Операторы для работы с JSON (@>, ?, ?|, ?&)
---
### 12. Добавить материализованные представления для аналитики
**Рекомендация:**
Создать materialized views для часто запрашиваемой аналитики:
```sql
-- Статистика по пользователям
CREATE MATERIALIZED VIEW user_statistics AS
SELECT
u.id,
u.name,
u.email,
COUNT(DISTINCT up.pack_id) as packs_count,
COUNT(DISTINCT p.id) as payments_count,
SUM(p.amount::numeric) as total_spent,
ud.total_study_time_minutes,
ud.total_cards,
ud.total_tests
FROM users u
LEFT JOIN user_packs up ON u.id = up.user_id
LEFT JOIN payments p ON u.id = p.user_id AND p.status = 'succeeded'
LEFT JOIN user_datas ud ON u.id = ud.user_id
WHERE u.is_deleted = false
GROUP BY u.id, ud.id;
-- Индекс для быстрого поиска
CREATE INDEX idx_user_stats_id ON user_statistics(id);
-- Обновлять раз в час (или через cron job)
REFRESH MATERIALIZED VIEW CONCURRENTLY user_statistics;
```
---
## 📈 Рекомендации по оптимизации производительности
### 13. Connection Pooling
**Рекомендация:**
Использовать PgBouncer для connection pooling:
```yaml
# docker-compose.yml
pgbouncer:
image: pgbouncer/pgbouncer:latest
environment:
DATABASES_HOST: postgres
DATABASES_PORT: 5432
DATABASES_DBNAME: mnemo_cards
DATABASES_USER: mnemo_user
PGBOUNCER_POOL_MODE: transaction
PGBOUNCER_MAX_CLIENT_CONN: 1000
PGBOUNCER_DEFAULT_POOL_SIZE: 25
ports:
- "6432:5432"
```
**Воздействие:** Уменьшение overhead создания подключений на ~70%
---
### 14. Настройка PostgreSQL параметров
**Рекомендация:**
Оптимизировать настройки PostgreSQL для вашей нагрузки:
```ini
# postgresql.conf
# Memory
shared_buffers = 256MB # 25% от RAM
effective_cache_size = 1GB # 50-75% от RAM
work_mem = 4MB # Для сортировок
maintenance_work_mem = 64MB # Для VACUUM, CREATE INDEX
# Checkpoints
checkpoint_completion_target = 0.9
wal_buffers = 16MB
max_wal_size = 1GB
min_wal_size = 80MB
# Query Planning
random_page_cost = 1.1 # Для SSD
effective_io_concurrency = 200 # Для SSD
# Logging
log_min_duration_statement = 500 # Логировать медленные запросы (>500ms)
log_line_prefix = '%t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h '
log_checkpoints = on
log_connections = on
log_disconnections = on
log_lock_waits = on
```
---
### 15. Регулярный VACUUM и ANALYZE
**Рекомендация:**
Настроить автоматический vacuum:
```sql
-- Включить autovacuum (должен быть включен по умолчанию)
ALTER TABLE payments SET (autovacuum_vacuum_scale_factor = 0.05);
ALTER TABLE study_sessions SET (autovacuum_vacuum_scale_factor = 0.05);
-- Ручной VACUUM для больших таблиц раз в неделю (в cron job)
VACUUM ANALYZE payments;
VACUUM ANALYZE study_sessions;
VACUUM ANALYZE user_datas;
```
---
## 🔧 План реализации улучшений
### Этап 1: Критичные исправления (1-2 недели)
1.**Исправить NULL в is_blacklisted** (уже есть SQL скрипт)
```bash
psql -h localhost -U mnemo_user -d mnemo_cards -f fix_is_blacklisted_nulls.sql
```
2. 🔄 **Удалить deprecated поля из Payments**
- Создать миграцию v2
- Убедиться, что все клиенты используют `products`
- Применить миграцию
3. 🔄 **Исправить дублирование Pack ↔ Cards**
- Определить: нужна ли many-to-many связь?
- Если нет → удалить CardPackCards
- Обновить DAO и бизнес-логику
4. 🔄 **Добавить CHECK constraints для enum**
- Создать PostgreSQL ENUM типы
- Обновить таблицы
- Обновить Drift схемы
### Этап 2: Улучшение структуры (2-3 недели)
5. 🔄 **Денормализация UserDatas**
- Создать таблицу WordStatistics
- Мигрировать данные из JSON
- Удалить старые JSON поля
- Обновить DAO и бизнес-логику
6. 🔄 **Миграция на UUID тип**
- Создать новые таблицы с UUID
- Мигрировать данные
- Переключить код на новые таблицы
- Удалить старые таблицы
7. 🔄 **Добавить композитные индексы**
- Создать covering индексы
- Создать GIN индексы для JSON
- Замерить производительность
### Этап 3: Безопасность и мониторинг (1-2 недели)
8. 🔄 **Добавить audit trail**
- Создать таблицу AuditLog
- Создать триггеры для критичных таблиц
- Настроить ротацию логов
9. 🔄 **Настроить RLS**
- Включить RLS для пользовательских данных
- Создать политики
- Обновить код для установки current_user_id
10. 🔄 **Настроить мониторинг**
- Включить pg_stat_statements
- Настроить алерты на медленные запросы
- Dashboard для метрик БД
### Этап 4: Оптимизация (1 неделя)
11. 🔄 **Connection pooling**
- Развернуть PgBouncer
- Обновить connection string
12. 🔄 **Оптимизация PostgreSQL**
- Применить рекомендованные настройки
- Настроить autovacuum
- Создать cron jobs для обслуживания
---
## 📊 Ожидаемые результаты
После реализации всех улучшений:
| Метрика | Сейчас | После | Улучшение |
|---------|--------|-------|-----------|
| **Размер индексов** | 100% | ~60% | -40% |
| **Скорость JOIN** | 100% | ~130% | +30% |
| **Размер UserDatas** | 100% | ~30% | -70% |
| **SELECT по userId** | 100% | ~200% | +100% |
| **Безопасность** | ⭐⭐⚪⚪⚪ | ⭐⭐⭐⭐⚪ | +2 |
**Общее улучшение производительности: ~40-60%**
---
## 🧪 Скрипты для тестирования
### Проверка размера таблиц
```sql
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size,
pg_size_pretty(pg_indexes_size(schemaname||'.'||tablename)) AS index_size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;
```
### Проверка медленных запросов
```sql
SELECT
query,
mean_exec_time,
calls,
total_exec_time
FROM pg_stat_statements
WHERE mean_exec_time > 100 -- запросы медленнее 100ms
ORDER BY mean_exec_time DESC
LIMIT 20;
```
### Проверка неиспользуемых индексов
```sql
SELECT
schemaname,
tablename,
indexname,
idx_scan,
idx_tup_read,
idx_tup_fetch,
pg_size_pretty(pg_relation_size(indexrelid)) as index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC;
```
### Проверка bloat (раздутых таблиц)
```sql
SELECT
current_database(),
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as total_size,
pg_size_pretty(pg_relation_size(schemaname||'.'||tablename)) as table_size,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename) - pg_relation_size(schemaname||'.'||tablename)) as index_size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC
LIMIT 20;
```
---
## 📝 Заключение
База данных Mnemo Cards имеет хорошую основу, но требует серьезных улучшений для production-ready состояния. Основные проблемы:
1. **Денормализация** - JSON поля вместо нормализованных таблиц
2. **Неоптимальные типы** - TEXT вместо UUID
3. **Отсутствие безопасности** - нет audit trail и RLS
4. **Недостаточная оптимизация** - можно добавить больше индексов
Рекомендуется реализовать улучшения поэтапно, начиная с критичных проблем.
**Приоритет реализации:**
1. 🔴 Критичные (1-4) - **немедленно**
2. 🟡 Важные (5-8) - **в течение месяца**
3. 🟢 Улучшения (9-12) - **опционально**
---
## 📚 Полезные ресурсы
- [PostgreSQL Performance Tuning](https://wiki.postgresql.org/wiki/Performance_Optimization)
- [Drift Documentation](https://drift.simonbinder.eu/)
- [PostgreSQL Indexing Best Practices](https://www.postgresql.org/docs/current/indexes.html)
- [Database Normalization](https://en.wikipedia.org/wiki/Database_normalization)
---
**Подготовлено:** AI Code Analyzer
**Дата:** 14 декабря 2025