Реалізація Real-Time голосування/опитувань на сайті

Наша компанія займається розробкою, підтримкою та обслуговуванням сайтів будь-якої складності. Від простих односторінкових сайтів до масштабних кластерних систем, побудованих на мікро сервісах. Досвід розробників підтверджено сертифікатами від вендорів.

Розробка та обслуговування будь-яких видів сайтів:

Інформаційні сайти або веб-програми
Сайти візитки, landing page, корпоративні сайти, онлайн каталоги, квіз, промо-сайти, блоги, ресурси новин, інформаційні портали, форуми, агрегатори
Сайти або веб-програми електронної комерції
Інтернет-магазини, B2B-портали, маркетплейси, онлайн-обмінники, кешбек-сайти, біржі, дропшиппінг-платформи, парсери товарів
Веб-програми для управління бізнес-процесами
CRM-системи, ERP-системи, корпоративні портали, системи управління виробництвом, парсери інформації
Сайти або веб-програми електронних послуг
Дошки оголошень, онлайн-школи, онлайн-кінотеатри, конструктори сайтів, портали надання електронних послуг, відеохостинги, тематичні портали

Це лише деякі з технічних типів сайтів, з якими ми працюємо, і кожен із них може мати свої специфічні особливості та функціональність, а також бути адаптованим під конкретні потреби та цілі клієнта.

Пропоновані послуги
Показано 1 з 1 послугУсі 2065 послуг
Реалізація Real-Time голосування/опитувань на сайті
Середня
~3-5 робочих днів
Часті питання

Наші компетенції:

Етапи розробки

Останні роботи

  • image_website-b2b-advance_0.png
    Розробка сайту компанії B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    874
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Розробка веб-сайту для компанії ФІКСПЕР
    851

Реалізація опитувань в реальному часі на веб-сайті

Опитування в реальному часі — це не просто «змінити колір кнопки після натиснення». Сутність у тому, що всі учасники бачать зміни результатів одночасно без перезавантаження сторінки. Вебсайти конференцій, інтерактивне навчання, прямі трансляції, корпоративні голосування — скрізь одна технічна потреба: трансляція змін усім підключеним клієнтам.

Вибір транспорту

Для голосувань підходять два механізми: 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 дні