Реализация 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

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

Опросы в реальном времени — это не просто «покрасить кнопку после клика». Суть в том, что все участники видят изменение результатов одновременно, без перезагрузки страницы. Веб-сайт конференции, интерактивное обучение, прямой эфир, корпоративное голосование — везде одна техническая потребность: броадкаст изменений всем подключённым клиентам.

Выбор транспорта

Для голосований подходят два механизма: 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 — зависит от частоты голосований. При пиковой нагрузке (прямой эфир, 5 000+ участников) лучше хранить счётчики отдельно и инкрементировать через 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 держит соединение открытым на время стрима. 1 000 одновременных пользователей = 1 000 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 дня