ActionCable: координация WebSocket’ов между процессами
Предпосылки: Клиенты и соединения, Sub.
Rails-приложение на Puma запускает несколько процессов (воркеров). Каждый процесс держит свой набор WebSocket-соединений. Процессы изолированы — у них нет общей памяти. Когда Sidekiq-джоб завершает обработку заказа и хочет отправить уведомление пользователю через WebSocket, он не знает, к какому Puma-процессу подключён этот пользователь, и не может напрямую достучаться до чужого сокета.
Это тоже задача выбора между Sub: сообщение должно попасть во все Puma-процессы, а уже каждый процесс локально решает, есть ли у него нужный WebSocket.
Отправить сообщение напрямую в конкретный процесс нельзя: у Sidekiq нет информации о том, какой Puma-воркер обслуживает нужный WebSocket, а между процессами нет shared memory. Вариант с общей базой (PostgreSQL LISTEN/NOTIFY) добавляет лишний hop и не предназначен для высокочастотных уведомлений.
ActionCable решает проблему через Redis Pub/Sub. Каждый Puma-процесс при старте создаёт отдельный поток, который подписывается на Redis-канал:
# Под капотом ActionCable делает примерно следующее:
subscriber = Redis.new(url: ENV['REDIS_URL'])
subscriber.subscribe("action_cable/some_channel") do |on|
on.message do |channel, message|
# ActionCable определяет, какие WebSocket'ы в этом процессе
# подписаны на channel, и рассылает им message
end
endActionCable использует subscribe на конкретные каналы (с префиксом action_cable/), подписывая и отписывая каналы динамически по мере подключения клиентов. Вызов subscribe блокирует поток — он только слушает. Поэтому ActionCable выделяет для подписки отдельный поток, не занимая потоки Puma, которые обрабатывают HTTP-запросы и WebSocket I/O.
Когда код публикует сообщение в канал ActionCable (например, ActionCable.server.broadcast("chat_42", data)), происходит PUBLISH в Redis. Все Puma-процессы получают это сообщение через свои потоки подписки. Каждый процесс проверяет, есть ли у него WebSocket-соединения, подписанные на этот канал, и отправляет данные только нужным клиентам.
Это publish/subscribe, а не очередь задач: здесь не нужен acknowledgment, повторное чтение истории или гарантия «получил ровно один consumer». Нужна быстрая рассылка всем живым процессам.
Итого на каждый Puma-процесс: 5 потоков Puma (HTTP + WebSocket I/O) + 1 поток Redis-подписки. Этот дополнительный поток держит одно выделенное TCP-соединение к Redis, которое не входит в connection_pool приложения.
Конфигурация ActionCable:
# config/cable.yml
production:
adapter: redis
url: <%= ENV.fetch('REDIS_URL') %>
channel_prefix: myapp_productionchannel_prefix добавляет префикс ко всем каналам, чтобы несколько приложений могли разделять один Redis без коллизий имён. Подробнее о механике подписок: Sub.