Представьте: конференция на 10 000 участников, организаторы запускают голосование, а результаты обновляются раз в 30 секунд через обычный AJAX-polling. Пользователи жалуются на задержки, сервер падает от 10 000 запросов в секунду. Знакомая ситуация? Выбирая real-time голосование, вы решаете две задачи: мгновенная доставка результатов и снижение нагрузки на сервер. Мы реализовали такие системы для 30+ проектов — от корпоративных опросов до масштабных прямых эфиров.
Почему SSE, а не polling?
Polling — это N запросов в секунду, где N — количество пользователей. При 500 пользователях — 500 req/s впустую. SSE или WebSocket дают одно соединение на пользователя, через которое сервер отправляет данные только при изменении. Для голосований подходят два механизма: SSE (Server-Sent Events) и WebSocket. Третий вариант — polling — не рассматриваем: 1 запрос в секунду на 500 одновременных пользователей — это 500 req/s нагрузки только ради проверки «ничего не изменилось». SSE — однонаправленный поток от сервера к клиенту, описанный в спецификации EventSource. Для опросов этого достаточно: голос отправляется обычным POST, результат прилетает через SSE. WebSocket оправдан, если нужна немедленная обратная связь (анимация «ваш голос принят») или дополнительные интерактивные элементы.
| Параметр | SSE | WebSocket |
|---|---|---|
| Направление | Однонаправленный (сервер → клиент) | Двунаправленный |
| Сложность реализации | Низкая (нативный EventSource) | Средняя (требуется WebSocket-сервер) |
| Инфраструктура | Не требует sticky sessions | Требует sticky session или отдельный сервер |
| Производительность | До 10 000 соединений на один PHP-воркер | До 100 000 соединений на Node.js |
| Поддержка браузеров | Все современные, кроме IE | Все современные |
Как предотвратить дублирование голосов?
Для авторизованных пользователей — unique constraint (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();
Дополнительно можно использовать Redis-блокировки: Cache::lock('vote:'.$poll->id.':'.$userId, 10)->get() — это предотвратит одновременную отправку с одного аккаунта.
Как масштабировать голосование на тысячи участников?
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. Для мониторинга используйте Laravel Horizon: он показывает количество активных SSE-воркеров и время отклика. Это экономит до 70% затрат на серверную инфраструктуру по сравнению с прямыми SSE-воркерами.
Схема данных и оптимизация
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.
Клиентская часть и отправка голоса
const pollId = 42;
const source = new EventSource(`/api/polls/${pollId}/stream`);
source.onmessage = (event) => {
const { counts } = JSON.parse(event.data);
updateBars(counts);
};
source.onerror = () => {
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('Вы уже голосовали');
}
}
Как реализовать 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',
]);
});
X-Accel-Buffering: no — обязательный заголовок при использовании Nginx как proxy, иначе данные будут накапливаться в буфере.
Как тестировать real-time голосование?
Используйте инструменты: Postman для отправки голосов, k6 для нагрузочного тестирования, встроенную консоль браузера для проверки SSE-соединений. Основные сценарии: 1) проверка получения обновлений после голосования; 2) симуляция одновременных голосов от тысячи пользователей; 3) проверка устойчивости при обрыве соединения (SSE автоматически переподключается).
Типичные ошибки и их решение
- N+1 запросы: при получении списка голосов с подгрузкой опций через lazy loading. Используйте
with('options')в Eloquent. - Отсутствие
X-Accel-Buffering: без этого заголовка Nginx буферизует SSE-поток, и пользователи видят данные пачками. - Игнорирование
connection_aborted(): без этого PHP-воркеры продолжают висеть, потребляя память. - Неиспользование Redis для счётчиков: прямой COUNT из БД при каждом обновлении создаёт нагрузку. Redis инкременты в разы быстрее.
Сроки разработки
| Этап | Время |
|---|---|
| Базовое голосование (SSE, авторизованные) | 2–3 дня |
| Анонимное голосование + anti-duplicate | +1 день |
| Многовариантные опросы + история | +1 день |
| Масштабирование через Pusher/Echo | +2 дня |
| Административный интерфейс | 2–3 дня |
Мы гарантируем отсутствие багов в течение 30 дней после сдачи, предоставляем документацию и обучаем вашу команду. Для оценки вашего проекта свяжитесь с нами — получите детальный план и точные сроки. Закажите консультацию, чтобы убедиться, что ваше голосование будет работать без сбоев при любой нагрузке.







