Как оптимизировать таблицу с нагрузкой сто тысяч записей в день для ускорения недельных выборок\
Комментарии (1)
Ответ сгенерирован нейросетью и может содержать ошибки
# Оптимизация таблицы с нагрузкой 100 тысяч записей в день для ускорения недельных выборок
При нагрузке 100K записей/день таблица растёт очень быстро (3М+ записей в месяц). Чтобы недельные выборки оставались быстрыми, нужна стратегическая оптимизация на уровне индексов, партиционирования, архивирования и кэширования.
Проблема
Без оптимизации недельный запрос будет сканировать миллионы строк:
-- Медленно: сканирует ВСЁ из таблицы
SELECT COUNT(*) FROM events
WHERE created_at >= NOW() - INTERVAL '7 days';
-- Time: 5-30 секунд
Решение 1: Индекс на timestamp
Основной инструмент оптимизации — индекс на колонку с датой:
-- Простой индекс
CREATE INDEX idx_events_created_at ON events(created_at DESC);
-- Или составной индекс (если часто фильтруешь по user_id)
CREATE INDEX idx_events_user_date ON events(user_id, created_at DESC);
Теперь запрос будет использовать индекс:
SELECT COUNT(*) FROM events
WHERE created_at >= NOW() - INTERVAL '7 days';
-- Time: 10-100ms (в зависимости от выборки за неделю)
Решение 2: Партиционирование таблицы
Разбиваем таблицу на меньшие части по дате. Postgres автоматически сканирует только нужные партиции:
-- Создание партиционированной таблицы
CREATE TABLE events (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
action VARCHAR(100),
created_at TIMESTAMPTZ NOT NULL
) PARTITION BY RANGE (EXTRACT(EPOCH FROM created_at));
-- Партиция за день
CREATE TABLE events_2024_03_20 PARTITION OF events
FOR VALUES FROM (1710950400) TO (1711036800); -- 2024-03-20
CREATE TABLE events_2024_03_21 PARTITION OF events
FOR VALUES FROM (1711036800) TO (1711123200); -- 2024-03-21
-- Индекс на каждой партиции
CREATE INDEX idx_events_2024_03_20_user ON events_2024_03_20(user_id);
Преимущества партиционирования:
- При недельном запросе сканируются только 7 партиций вместо всей таблицы
- DELETE старых партиций очень быстрый (DROP PARTITION)
- Автоматическое распределение нагрузки
-- Быстро благодаря партиционированию
SELECT COUNT(*) FROM events
WHERE created_at >= NOW() - INTERVAL '7 days';
-- Time: 50-200ms
Решение 3: Архивирование старых данных
Основная таблица хранит только последние 30 дней. Старые данные архивируются:
@Configuration
public class DataArchivingConfig {
@Scheduled(cron = "0 2 * * *") // Каждый день в 02:00
public void archiveOldData() {
// Переносим данные старше 30 дней в архив
String sql = "INSERT INTO events_archive SELECT * FROM events " +
"WHERE created_at < NOW() - INTERVAL '30 days'";
jdbcTemplate.update(sql);
// Удаляем из основной таблицы
String deleteSql = "DELETE FROM events " +
"WHERE created_at < NOW() - INTERVAL '30 days'";
jdbcTemplate.update(deleteSql);
}
}
Исходная таблица остаётся компактной:
- events: 3-10M записей (30 дней × 100-300K/день)
- events_archive: всё старое
- Недельные запросы работают с 700K-2M записей
Решение 4: Материализованное представление (Materialized View)
Предвычисляем агрегированные данные:
-- Материализованное представление с агрегированными данными по дню
CREATE MATERIALIZED VIEW events_daily_summary AS
SELECT
DATE(created_at) as event_date,
user_id,
action,
COUNT(*) as event_count,
COUNT(DISTINCT user_id) as unique_users
FROM events
WHERE created_at >= NOW() - INTERVAL '30 days'
GROUP BY DATE(created_at), user_id, action;
-- Индекс на представлении
CREATE INDEX idx_events_daily_user ON events_daily_summary(user_id, event_date);
-- Обновляем каждый час
REFRESH MATERIALIZED VIEW CONCURRENTLY events_daily_summary;
Теперь запросы намного быстрее:
-- Вместо сканирования млн строк, сканируем тысячи предвычисленных строк
SELECT event_date, COUNT(*) FROM events_daily_summary
WHERE user_id = ?
AND event_date >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY event_date;
-- Time: 1-10ms
Решение 5: Read Replica и кэширование
Для высоконагруженных систем используем отдельный Read Replica:
@Service
public class EventReportService {
@Autowired
@Qualifier("readReplicaJdbcTemplate")
private JdbcTemplate readJdbcTemplate;
// Недельные выборки идут на Replica
public List<EventSummary> getWeeklySummary(String userId) {
String sql = "SELECT DATE(created_at) as event_date, " +
"COUNT(*) as count, action " +
"FROM events " +
"WHERE user_id = ? AND created_at >= NOW() - INTERVAL '7 days' " +
"GROUP BY DATE(created_at), action";
return readJdbcTemplate.query(sql, new Object[]{userId}, rowMapper);
}
}
Добавляем Redis кэш для ещё большей скорости:
@Service
@CacheConfig(cacheNames = "weeklySummary")
public class EventReportService {
@Cacheable(key = "#userId")
public List<EventSummary> getWeeklySummary(String userId) {
// Первый вызов: идёт в БД
// Второй вызов: из кэша (Redis)
return jdbcTemplate.query(
"SELECT ... WHERE user_id = ? AND created_at >= ...",
new Object[]{userId}
);
}
@CacheEvict(key = "#userId")
public void invalidateCache(String userId) {
// Инвалидируем кэш после новых данных
}
}
Решение 6: Полная стратегия оптимизации
-- 1. Создание партиционированной таблицы
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL,
action VARCHAR(50) NOT NULL,
data JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);
-- 2. Суточные партиции
CREATE TABLE events_2024_03_20 PARTITION OF events
FOR VALUES FROM ('2024-03-20') TO ('2024-03-21');
CREATE TABLE events_2024_03_21 PARTITION OF events
FOR VALUES FROM ('2024-03-21') TO ('2024-03-22');
-- 3. Индексы
CREATE INDEX idx_events_2024_03_20_user_date ON events_2024_03_20(user_id, created_at DESC);
CREATE INDEX idx_events_2024_03_20_action ON events_2024_03_20(action) WHERE action IN ('click', 'view');
-- 4. Материализованное представление
CREATE MATERIALIZED VIEW events_daily_stats AS
SELECT
DATE(created_at) as event_date,
action,
user_id,
COUNT(*) as count
FROM events
WHERE created_at >= NOW() - INTERVAL '30 days'
GROUP BY DATE(created_at), action, user_id;
CREATE INDEX idx_daily_stats_date ON events_daily_stats(event_date DESC);
-- 5. Функция автоматического создания партиций
CREATE OR REPLACE FUNCTION create_partition_for_date(partition_date DATE)
RETURNS void AS $$
BEGIN
EXECUTE format('CREATE TABLE IF NOT EXISTS events_%s PARTITION OF events FOR VALUES FROM (%L) TO (%L)',
to_char(partition_date, 'YYYY_MM_DD'),
partition_date,
partition_date + INTERVAL '1 day'
);
END;
$$ LANGUAGE plpgsql;
Java код для оптимизированных запросов
@Repository
public class EventRepository {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
@Qualifier("cacheTemplate")
private CacheTemplate cacheTemplate;
// Недельная статистика с кэшем
@Cacheable(cacheNames = "weeklyStats", key = "#userId + ':' + #instant.toString()")
public WeeklyStats getWeeklyStats(String userId, Instant instant) {
String sql = "SELECT action, COUNT(*) as count " +
"FROM events " +
"WHERE user_id = ? AND created_at >= ? " +
"GROUP BY action";
Instant weekAgo = instant.minus(Duration.ofDays(7));
return jdbcTemplate.queryForObject(sql,
new Object[]{userId, weekAgo},
(rs, rowNum) -> new WeeklyStats(
rs.getString("action"),
rs.getLong("count")
)
);
}
// Использование материализованного представления
public List<EventSummary> getSummaryFromMaterialized(String userId) {
String sql = "SELECT event_date, action, count FROM events_daily_stats " +
"WHERE user_id = ? AND event_date >= CURRENT_DATE - INTERVAL '7 days' " +
"ORDER BY event_date DESC";
return jdbcTemplate.query(sql, new Object[]{userId},
(rs, rowNum) -> new EventSummary(
rs.getDate("event_date"),
rs.getString("action"),
rs.getLong("count")
)
);
}
}
Мониторинг и профилирование
-- Объём данных за день
SELECT pg_size_pretty(sum(pg_total_relation_size(tablename)))
FROM pg_tables
WHERE tablename LIKE 'events%';
-- Медленные запросы
SELECT query, calls, mean_time
FROM pg_stat_statements
WHERE query LIKE '%events%'
ORDER BY mean_time DESC;
-- Использование индексов
SELECT indexrelname, idx_scan, idx_tup_read, idx_tup_fetch
FROM pg_stat_user_indexes
WHERE relname = 'events'
ORDER BY idx_scan DESC;
Чеклист оптимизации
- Индексы — idx_created_at, idx_user_created_at
- Партиционирование — по дате (суточные партиции)
- Архивирование — старые данные за пределы 30 дней
- Материализованные представления — предвычисленная статистика
- Read Replica — для недельных отчётов
- Кэширование — Redis для часто запрашиваемых данных
- Мониторинг — pg_stat_statements для выявления узких мест
Результат
- До: недельный запрос — 5-30 секунд
- После: недельный запрос — 10-100ms
- Ускорение: в 50-300 раз
Ключ к успеху — комбинация индексов, партиционирования и кэширования.