mnemo_cards/mnemo_cards_backend/DATABASE_ANALYSIS.md

792 lines
26 KiB
Markdown
Raw Normal View History

2026-01-03 13:14:27 +00:00
# 🔍 Анализ базы данных 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