Рекомендации по использованию поллинга
Проблема
Использование поллинга (периодического опроса сервера для получения обновлений) в продуктах QPalette может приводить к серьёзным проблемам производительности из-за вкладочной архитектуры приложения.
Основные риски
- Множественные вкладки — архитектура QPalette позволяет открывать потенциально бесконечное количество вкладок. Если каждая вкладка запускает свой поллинг, количество запросов к серверу растёт линейно с количеством вкладок.
- Сайд-эффекты на загрузку данных — на обработчики поллинг-запросов могут быть установлены сайд-эффекты (обновление состояния, вызов других сервисов, перерисовка UI), которые снижают производительность системы.
- Утечки памяти — незавершённые подписки на поллинг-запросы могут привести к утечкам памяти и накоплению неиспользуемых таймеров.
- Избыточная нагрузка на сервер — десятки одновременно работающих вкладок могут генерировать сотни запросов в минуту, создавая значительную нагрузку на бэкенд.
Пример проблемы
import { Component, OnInit } from '@angular/core';
import { interval } from 'rxjs';
import { switchMap } from 'rxjs/operators';
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html'
})
export class DashboardComponent implements OnInit {
data: any[] = [];
constructor(private dataService: DataService) {}
ngOnInit(): void {
// ❌ ПРОБЛЕМА: Поллинг запускается при каждом открытии вкладки
// и не останавливается при её закрытии
interval(5000) // Запрос каждые 5 секунд
.pipe(switchMap(() => this.dataService.getData()))
.subscribe(data => {
this.data = data;
// Сайд-эффекты могут снижать производительность
this.updateCharts();
this.recalculateStatistics();
this.notifyOtherServices();
});
}
// Множественные сайд-эффекты
updateCharts(): void { /* ... */ }
recalculateStatistics(): void { /* ... */ }
notifyOtherServices(): void { /* ... */ }
}Сценарий: Пользователь открывает 10 вкладок Dashboard → 10 × 12 запросов/мин = 120 запросов в минуту + множественные сайд-эффекты.
Решения
Решение 1: Отказ от поллинга в пользу Server-Sent Events (SSE)
Рекомендуется: Использовать SseService из библиотеки @qpalette/sse-client для получения обновлений в реальном времени без постоянных HTTP-запросов.
Преимущества SSE
- Одно соединение вместо множества запросов.
- Мгновенные обновления при изменении данных на сервере.
- Меньшая нагрузка на сервер и клиент.
- Автоматическое переподключение при обрыве связи.
Пример использования SseService
import { Component, OnInit, OnDestroy } from '@angular/core';
import { SseService } from '@qpalette/sse-client';
import { Subject, takeUntil } from 'rxjs';
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html'
})
export class DashboardComponent implements OnInit, OnDestroy {
data: any[] = [];
private destroy$ = new Subject<void>();
constructor(private sseService: SseService) {}
ngOnInit(): void {
// ✅ РЕШЕНИЕ: Использование SSE для получения обновлений
this.sseService.open({
url: '/api/dashboard/events',
method: 'GET'
})
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (message) => {
// Обработка события с сервера
if (message.event === 'data-update') {
this.data = JSON.parse(message.data);
}
},
error: (error) => {
console.error('SSE connection error:', error);
}
});
}
ngOnDestroy(): void {
// Автоматическое закрытие соединения при уничтожении компонента
this.destroy$.next();
this.destroy$.complete();
}
}📚 Документация: Подробнее о
SseServiceсм. в разделе Библиотеки → SseService.
Решение 2: Ограничение поллинга по времени
Если использование SSE невозможно, ограничьте время работы поллинга.
import { Component, OnInit, OnDestroy } from '@angular/core';
import { interval, Subject, timer } from 'rxjs';
import { switchMap, takeUntil, take } from 'rxjs/operators';
@Component({
selector: 'app-notifications',
templateUrl: './notifications.component.html'
})
export class NotificationsComponent implements OnInit, OnDestroy {
notifications: any[] = [];
private destroy$ = new Subject<void>();
constructor(private notificationService: NotificationService) {}
ngOnInit(): void {
// ✅ Поллинг работает только 5 минут
const maxDuration$ = timer(5 * 60 * 1000); // 5 минут
interval(10000) // Каждые 10 секунд
.pipe(
takeUntil(maxDuration$), // Остановка через 5 минут
takeUntil(this.destroy$), // Остановка при уничтожении компонента
switchMap(() => this.notificationService.getNotifications())
)
.subscribe(notifications => {
this.notifications = notifications;
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}Решение 3: Ограничение по количеству запросов
import { Component, OnInit, OnDestroy } from '@angular/core';
import { interval, Subject } from 'rxjs';
import { switchMap, takeUntil, take } from 'rxjs/operators';
@Component({
selector: 'app-status-monitor',
templateUrl: './status-monitor.component.html'
})
export class StatusMonitorComponent implements OnInit, OnDestroy {
status: string = 'pending';
private destroy$ = new Subject<void>();
constructor(private statusService: StatusService) {}
ngOnInit(): void {
// ✅ Максимум 30 запросов (5 минут × 6 запросов/мин)
interval(10000)
.pipe(
take(30), // Остановка после 30 запросов
takeUntil(this.destroy$),
switchMap(() => this.statusService.getStatus())
)
.subscribe(status => {
this.status = status;
// Остановка поллинга при получении финального статуса
if (status === 'completed' || status === 'failed') {
this.destroy$.next();
}
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}Решение 4: Завершение поллинга при закрытии вкладки
Критически важно: Всегда завершайте поллинг в ngOnDestroy и используйте оператор takeUntil.
import { Component, OnInit, OnDestroy } from '@angular/core';
import { interval, Subject } from 'rxjs';
import { switchMap, takeUntil } from 'rxjs/operators';
@Component({
selector: 'app-data-viewer',
templateUrl: './data-viewer.component.html'
})
export class DataViewerComponent implements OnInit, OnDestroy {
data: any;
private destroy$ = new Subject<void>();
constructor(private dataService: DataService) {}
ngOnInit(): void {
interval(5000)
.pipe(
// ✅ Завершение при уничтожении компонента
takeUntil(this.destroy$),
switchMap(() => this.dataService.getData())
)
.subscribe(data => {
this.data = data;
});
}
ngOnDestroy(): void {
// ✅ Обязательное завершение подписки
this.destroy$.next();
this.destroy$.complete();
console.log('Polling stopped');
}
}Решение 5: Остановка предыдущего поллинга при старте нового
Используйте сервис-синглтон для управления поллингом и предотвращения множественных экземпляров.
import { Injectable, OnDestroy } from '@angular/core';
import { interval, Subject, Observable } from 'rxjs';
import { switchMap, takeUntil, shareReplay } from 'rxjs/operators';
@Injectable({
providedIn: 'root'
})
export class PollingService implements OnDestroy {
private destroy$ = new Subject<void>();
private pollingStream$: Observable<any> | null = null;
constructor(private dataService: DataService) {}
/**
* Запускает поллинг. Если поллинг уже активен, останавливает предыдущий.
*
* @param intervalMs - Интервал поллинга в миллисекундах.
* @returns Observable с данными поллинга.
*/
startPolling(intervalMs: number = 5000): Observable<any> {
// ✅ Остановка предыдущего поллинга
this.stopPolling();
// Создание нового destroy$ для нового поллинга
this.destroy$ = new Subject<void>();
// Создание общего потока для всех подписчиков
this.pollingStream$ = interval(intervalMs).pipe(
takeUntil(this.destroy$),
switchMap(() => this.dataService.getData()),
shareReplay(1) // Один запрос для всех подписчиков
);
return this.pollingStream$;
}
/**
* Останавливает активный поллинг.
*/
stopPolling(): void {
if (this.destroy$) {
this.destroy$.next();
this.destroy$.complete();
}
this.pollingStream$ = null;
}
ngOnDestroy(): void {
this.stopPolling();
}
}Использование PollingService
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PollingService } from './polling.service';
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html'
})
export class DashboardComponent implements OnInit, OnDestroy {
data: any;
private componentDestroy$ = new Subject<void>();
constructor(private pollingService: PollingService) {}
ngOnInit(): void {
// ✅ Поллинг управляется централизованно
// Если другая вкладка уже запустила поллинг, он будет остановлен
this.pollingService.startPolling(5000)
.pipe(takeUntil(this.componentDestroy$))
.subscribe(data => {
this.data = data;
});
}
ngOnDestroy(): void {
this.componentDestroy$.next();
this.componentDestroy$.complete();
// Поллинг останавливается в сервисе
}
}Решение 6: Минимизация сайд-эффектов
Тщательно контролируйте сайд-эффекты в подписках на поллинг-запросы.
import { Component, OnInit, OnDestroy } from '@angular/core';
import { interval, Subject } from 'rxjs';
import { switchMap, takeUntil, distinctUntilChanged, tap } from 'rxjs/operators';
@Component({
selector: 'app-optimized-dashboard',
templateUrl: './optimized-dashboard.component.html'
})
export class OptimizedDashboardComponent implements OnInit, OnDestroy {
data: any[] = [];
private destroy$ = new Subject<void>();
constructor(
private dataService: DataService,
private analyticsService: AnalyticsService
) {}
ngOnInit(): void {
interval(5000)
.pipe(
takeUntil(this.destroy$),
switchMap(() => this.dataService.getData()),
// ✅ Обновление только при реальном изменении данных
distinctUntilChanged((prev, curr) =>
JSON.stringify(prev) === JSON.stringify(curr)
),
// ✅ Лёгкие сайд-эффекты в tap
tap(data => {
// Только логирование, без тяжёлых операций
console.log('Data updated', data.length);
})
)
.subscribe(data => {
this.data = data;
// ✅ Минимальная обработка в subscribe
// Тяжёлые операции вынесены в отдельные методы с debounce
});
}
// ✅ Тяжёлые операции вызываются по событиям пользователя,
// а не на каждый поллинг
onUserInteraction(): void {
this.recalculateStatistics();
this.analyticsService.trackEvent('user_interaction');
}
private recalculateStatistics(): void {
// Тяжёлые вычисления
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
}Checklist: Безопасное использование поллинга
При внедрении поллинга проверьте:
- Рассмотрена альтернатива SSE — можно ли использовать
SseServiceвместо поллинга? - Установлен
takeUntilсdestroy$— поллинг останавливается при уничтожении компонента? - Реализован
ngOnDestroy— вызываютсяdestroy$.next()иdestroy$.complete()? - Ограничение по времени или количеству — есть ли автоматическая остановка поллинга?
- Контроль множественных экземпляров — предотвращено ли создание нескольких поллингов для одной задачи?
- Минимизированы сайд-эффекты — используется ли
distinctUntilChangedи минимальная обработка вsubscribe? - Оптимальный интервал — не слишком ли частые запросы (рекомендуется не чаще 1 раза в 5-10 секунд)?
- Условная остановка — останавливается ли поллинг при достижении целевого состояния?
- Обработка ошибок — есть ли корректная обработка ошибок сети?
- Мониторинг в production — отслеживается ли количество активных поллингов?
Сравнение подходов
| Характеристика | Поллинг | SSE (SseService) |
|---|---|---|
| Количество соединений | N запросов/минуту × M вкладок | 1 соединение на приложение |
| Задержка обновления | До интервала поллинга (5-30 сек) | Мгновенно (<1 сек) |
| Нагрузка на сервер | Высокая | Низкая |
| Нагрузка на клиент | Средняя-Высокая | Низкая |
| Сложность реализации | Простая | Средняя |
| Управление жизненным циклом | Требует внимания | Автоматическое |
| Поддержка переподключения | Требует реализации | Встроенная |
| Использование памяти | Растёт с количеством подписок | Стабильное |
Рекомендации по интервалам поллинга
| Тип данных | Рекомендуемый интервал | Обоснование |
|---|---|---|
| Уведомления | 30-60 секунд | Некритичные обновления, лучше использовать SSE |
| Статус фоновой задачи | 5-10 секунд | Ограничить по количеству запросов или завершению задачи |
| Дашборд с метриками | 10-30 секунд | Данные не требуют мгновенного обновления |
| Критичные данные | Не рекомендуется | Использовать SSE или WebSocket |
Антипаттерны
❌ Поллинг без takeUntil
// ПЛОХО: Подписка не завершается
ngOnInit(): void {
interval(5000)
.pipe(switchMap(() => this.service.getData()))
.subscribe(data => this.data = data);
}❌ Множественные поллинги в одном компоненте
// ПЛОХО: 3 независимых поллинга
ngOnInit(): void {
interval(5000).pipe(switchMap(() => this.service.getData1()))...
interval(5000).pipe(switchMap(() => this.service.getData2()))...
interval(5000).pipe(switchMap(() => this.service.getData3()))...
}Решение: Объединить запросы или использовать разные интервалы.
❌ Тяжёлые сайд-эффекты в subscribe
// ПЛОХО: Тяжёлые операции на каждый поллинг
.subscribe(data => {
this.data = data;
this.recalculateEverything(); // Тяжёлая операция
this.updateAllCharts(); // Перерисовка всех графиков
this.notifyAllServices(); // Множественные вызовы
});❌ Слишком частый поллинг
// ПЛОХО: Запросы каждую секунду
interval(1000) // 60 запросов/минуту на вкладку!Миграция с поллинга на SSE
Шаг 1: Установите библиотеку
npm install @qpalette/sse-clientШаг 2: Замените поллинг на SSE
До (поллинг):
interval(5000)
.pipe(
takeUntil(this.destroy$),
switchMap(() => this.http.get('/api/notifications'))
)
.subscribe(data => this.notifications = data);После (SSE):
this.sseService.open({ url: '/api/notifications/stream' })
.pipe(takeUntil(this.destroy$))
.subscribe(message => {
if (message.event === 'notification') {
this.notifications = JSON.parse(message.data);
}
});Шаг 3: Адаптируйте бэкенд
Реализуйте SSE endpoint на сервере, который отправляет события при изменении данных вместо ответа на каждый запрос.
Заключение
Поллинг — удобный, но ресурсоёмкий способ получения обновлений. В условиях вкладочной архитектуры QPalette необходимо:
- Предпочитать SSE — использовать
SseServiceдля критичных и часто обновляемых данных. - Ограничивать поллинг — по времени, количеству запросов и жизненному циклу компонента.
- Управлять жизненным циклом — обязательно завершать подписки в
ngOnDestroy. - Минимизировать сайд-эффекты — избегать тяжёлых операций в обработчиках поллинга.
- Мониторить производительность — отслеживать количество активных поллингов в production.
Правильное использование поллинга или его замена на SSE значительно снижает нагрузку на систему и улучшает пользовательский опыт.