Реалізація опитувань в реальному часі на веб-сайті
Опитування в реальному часі — це не просто «змінити колір кнопки після натиснення». Сутність у тому, що всі учасники бачать зміни результатів одночасно без перезавантаження сторінки. Вебсайти конференцій, інтерактивне навчання, прямі трансляції, корпоративні голосування — скрізь одна технічна потреба: трансляція змін усім підключеним клієнтам.
Вибір транспорту
Для голосувань підходять два механізми: Server-Sent Events (SSE) та WebSocket. Третій варіант — polling — не розглядаємо: 1 запит за секунду на 500 одночасних користувачів = 500 req/s навантаження лише для перевірки «нічого не змінилось».
SSE — однонаправленний потік від сервера до клієнта. Для опитувань цього достатньо: голос відправляється звичайним POST, результат приходить через SSE-потік.
WebSocket — двонаправленний канал. Виправданий, якщо потрібне миттєве зворотне повідомлення (анімація «ваш голос прийнято» без нового HTTP-запиту) або додаткові інтерактивні елементи в тій же сесії.
Для більшості вебсайтів з опитуваннями SSE простіший у реалізації та дешевший в інфраструктурі — не потрібні sticky session або окремий WebSocket-сервер.
Схема даних
CREATE TABLE polls (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(500) NOT NULL,
is_multiple BOOLEAN NOT NULL DEFAULT false,
is_active BOOLEAN NOT NULL DEFAULT true,
ends_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE poll_options (
id BIGSERIAL PRIMARY KEY,
poll_id BIGINT NOT NULL REFERENCES polls(id) ON DELETE CASCADE,
label VARCHAR(255) NOT NULL,
position SMALLINT NOT NULL DEFAULT 0
);
CREATE TABLE poll_votes (
id BIGSERIAL PRIMARY KEY,
option_id BIGINT NOT NULL REFERENCES poll_options(id),
user_id BIGINT REFERENCES users(id),
ip INET,
voted_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(option_id, user_id) -- один голос на варіант на користувача
);
Агрегація розраховується через materialized view або прямим COUNT — залежить від частоти голосувань. При піковому навантаженні (пряма трансляція, 5000+ учасників) краще зберігати лічильники окремо та збільшувати через Redis:
HINCRBY poll:42:counts 1 1 # варіант 1 отримав +1 голос
SSE-ендпоінт на Laravel
Route::get('/api/polls/{poll}/stream', function (Poll $poll) {
return response()->stream(function () use ($poll) {
while (true) {
if (connection_aborted()) break;
$counts = PollVote::selectRaw('option_id, COUNT(*) as votes')
->whereIn('option_id', $poll->options->pluck('id'))
->groupBy('option_id')
->pluck('votes', 'option_id');
$data = json_encode(['counts' => $counts, 'ts' => now()->timestamp]);
echo "data: {$data}\n\n";
ob_flush();
flush();
sleep(2);
}
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'X-Accel-Buffering' => 'no', // вимикає буферизацію в Nginx
]);
});
X-Accel-Buffering: no — обов'язковий заголовок при використанні Nginx як proxy, інакше дані накопичуватимуться в буфері та не будуть надіслані клієнту в реальному часі.
Клієнтська частина
const pollId = 42;
const source = new EventSource(`/api/polls/${pollId}/stream`);
source.onmessage = (event) => {
const { counts } = JSON.parse(event.data);
updateBars(counts);
};
source.onerror = () => {
// Браузер автоматично переімкнеться через 3 сек — це поведінка EventSource за замовчуванням
console.warn('SSE reconnecting...');
};
function updateBars(counts) {
const total = Object.values(counts).reduce((a, b) => a + Number(b), 0);
document.querySelectorAll('[data-option-id]').forEach(el => {
const id = el.dataset.optionId;
const pct = total > 0 ? Math.round((counts[id] || 0) / total * 100) : 0;
el.querySelector('.bar').style.width = pct + '%';
el.querySelector('.label').textContent = pct + '%';
});
}
Відправлення голосу
async function vote(optionId) {
const resp = await fetch(`/api/polls/${pollId}/vote`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ option_id: optionId }),
});
if (resp.status === 409) {
showMessage('Ви вже голосували');
}
}
Дублювання голосів запобігається на двох рівнях: UNIQUE(option_id, user_id) у базі даних та перевірка в контролері перед вставкою.
Анонімні голосування
Коли користувачі не авторизовані — захист через IP + fingerprint. Fingerprint генерується на фронтенді (бібліотека fingerprintjs) та передається у заголовку. Це не абсолютний захист, але достатній для більшості випадків.
$fingerprint = $request->header('X-Client-Fingerprint');
$alreadyVoted = PollVote::where('poll_id', $poll->id)
->where(function ($q) use ($request, $fingerprint) {
$q->where('ip', $request->ip())
->orWhere('fingerprint', $fingerprint);
})->exists();
Масштабування під час піків
PHP-приложення з SSE утримує з'єднання відкритим на час потокування. 1000 одночасних користувачів = 1000 PHP-воркерів. Це дорого.
Рішення: винести broadcast через Pusher або Laravel Echo Server (socket.io). Тоді SSE-контролер більше не потрібен — клієнт підписується на канал, сервер публікує подію poll.updated у Redis, Laravel Echo трансляє всім підписувачам.
// Після запису голосу
broadcast(new PollUpdated($poll->id, $counts))->toOthers();
Echo.channel(`poll.${pollId}`)
.listen('PollUpdated', ({ counts }) => updateBars(counts));
Така архітектура утримує сотні тисяч з'єднань на одному процесі Node.js.
Терміни
- Базове голосування (SSE, авторизовані користувачі): 2–3 дні
- Анонімні голосування з anti-duplicate: +1 день
- Багатоваріантні опитування + історія голосувань: +1 день
- Масштабована версія через Pusher/Echo: +2 дні
- Адміністративний інтерфейс управління опитуваннями: 2–3 дні







