Анна и Борис открыли один и тот же чат. Анна пишет «Выходим в 18:00», нажимает отправку, и у себя на экране сразу видит своё сообщение. Борис молчит. Не потому, что он занят: его браузер держит соединение с другим процессом приложения, а процесс Анны ничего не знает о существовании этого соединения. Для пользователя это выглядит как «чат сломался». Для разработчика это самый ранний и самый поучительный урок про Django Channels: WebSocket живёт внутри конкретного процесса, а разговор людей обычно шире любого одного процесса.
Эта статья про то, как несколько процессов договариваются о доставке сообщения, что в этой договорённости делает channel layer, чего он не делает, и почему история чата должна жить в другом месте. Мы пройдём путь одного сообщения: от кнопки отправки до экрана собеседника, а затем посмотрим, что произойдёт, если по дороге перезапустится обработчик.
Один разговор, два разных времени жизни
Обычный HTTP-запрос короткий. Браузер спрашивает, сервер отвечает, соединение завершает свою работу. Любой процесс из пула может обработать любой запрос, потому что между запросами в памяти процесса ничего не должно оставаться: состояние лежит в базе, в сессии, в кеше. Именно поэтому обычное Django-приложение так спокойно масштабируется горизонтально: добавили ещё один рабочий процесс, и балансировка не требует от него никаких знаний о прошлом.
WebSocket устроен иначе. Клиент открывает соединение и держит его минуты, часы, иногда сутки. Всё это время соединение привязано к одному конкретному процессу, который держит сокет открытым и ждёт событий. Если у вас два рабочих процесса, клиенты распределяются между ними, и в каждый момент у вас есть набор «островков»: в одном процессе Анна, в другом Борис. Прямого пути между островками нет, это обычные независимые процессы операционной системы со своей памятью.
Отсюда два разных времени жизни в одном разговоре. Сообщение как данные должно жить долго: его хотят перечитать завтра. Соединение как канал доставки живёт ровно столько, сколько выдержит сеть, мобильный оператор, прокси и ваш процесс. Путать эти две вещи опасно: если считать, что «сообщение было отправлено в сокет, значит, оно существует», то после первого же перезапуска вы обнаружите, что часть разговора исчезла.
Как это раскладывается по процессам
В типичной схеме Channels приложение делится на две роли. Обычные HTTP-запросы обслуживает привычный WSGI или ASGI-сервер, а WebSocket-соединения принимает ASGI-сервер, который держит их открытыми. Подробный разбор того, как поднимать обычную часть Django под gunicorn, есть в статье про VPS для Django на production; здесь мы сосредоточимся на том, что добавляется поверх: долгоживущие соединения и обмен между процессами.
Практическое следствие простое. Пока вы запускаете один процесс на своём ноутбуке, всё работает: Анна и Борис просто оказываются в одном процессе. Проблема проявляется ровно в тот момент, когда на VPS вы запускаете несколько воркеров ради устойчивости или загрузки процессора. Тогда баг «сообщение видит только половина участников» выглядит перемежающимся и поэтому особенно коварным: чат то работает, то нет, в зависимости от того, как распределились клиенты.
Что такое channel layer и зачем он процессам
Чтобы Анна могла достучаться до Бориса, процессам нужен общий посредник. В Channels эту роль играет channel layer. По документации, channel layers обеспечивают обмен между экземплярами приложения, а интерфейс отправки и получения у них асинхронный. Это и есть та самая «почтовая служба» между островками.
Представим механику на нашем примере. Когда Борис подключается, его экземпляр приложения вступает в группу, скажем, «чат номер 7». Когда Анна отправляет сообщение, её обработчик не пытается найти сокет Бориса: он ничего о нём не знает. Он отправляет событие в группу через channel layer. Слой доставляет это событие всем участникам группы, где бы они ни находились, и каждый экземпляр приложения у себя на месте пересылает его в свой открытый WebSocket.
Вот минимальный набросок потребителя, чтобы видеть форму этой схемы. Это иллюстрация подхода, а не готовый к запуску проект:
import json
from channels.generic.websocket import AsyncWebsocketConsumer
class ChatConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.room_id = self.scope["url_route"]["kwargs"]["room_id"]
self.group = f"chat_{self.room_id}"
await self.channel_layer.group_add(self.group, self.channel_name)
await self.accept()
async def disconnect(self, code):
await self.channel_layer.group_discard(self.group, self.channel_name)
async def receive(self, text_data=None, bytes_data=None):
data = json.loads(text_data)
await self.channel_layer.group_send(
self.group,
{"type": "chat.message", "text": data["text"]},
)
async def chat_message(self, event):
await self.send(text_data=json.dumps({"text": event["text"]}))
Обратите внимание на структуру: метод receive не пишет в сокет собеседника, он только бросает событие в группу. А метод chat_message вызывается у каждого участника группы и уже сам решает, что отправить своему клиенту. Это разделение и есть вся суть: приём и доставка развязаны через посредника.
Почему in-memory слой не подходит
В документации есть важное предупреждение. In-memory layer создаёт независимый слой в каждом процессе и не подходит для межпроцессной доставки в production. Перечитайте это как диагноз нашей истории с Анной и Борисом. Если в настройках стоит in-memory слой, то у каждого процесса появляется собственная «почтовая служба», в которой сидит только он сам. Анна отправляет сообщение в свою службу, и в ней находятся только клиенты её процесса. Борис в другой службе, и письмо до него не доходит.
Коварство в том, что код при этом полностью корректен. Ошибки в исключениях нет, group_send отрабатывает без жалоб. Просто группа у каждого процесса своя. Поэтому такая конфигурация приятна на разработке с одним процессом и опасна на VPS с несколькими.
Для production документация называет channels_redis официально поддерживаемым Django backend. Настройка выглядит примерно так:
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [("127.0.0.1", 6379)],
},
},
}
Redis при этом должен быть запущен отдельным процессом на том же сервере или на соседнем. На одном VPS это обычная схема: приложение, ASGI-серверы и Redis делят ресурсы машины, и вам важно следить, чтобы память и процессор не упирались в потолок одновременно. Какой тариф подходит, зависит от замеров на вашей нагрузке, а не от количества vCPU на бумаге.
Что слой сам по себе не хранит
Теперь самое важное для надёжности. Channel layer — это механизм передачи между процессами, а не журнал. Он не обещает, что событие будет лежать и ждать, пока получатель вернётся. Мы не будем приписывать ему гарантии, которых нет в источнике: документация описывает обмен между экземплярами приложения, и на этом наше знание о его семантике заканчивается. Всё остальное — хранение, повторную доставку, упорядочивание на уровне разговора — проектируете вы.
Полезно держать в голове такую модель: channel layer — это громкая связь в офисе. Если вы объявили в динамик, те, кто сейчас рядом, услышали. Тот, кто вышел в этот момент, не услышит, и объявление его не догонит. Для чата это значит, что любое сообщение, которое важно не потерять, обязано быть записано до того, как вы его объявили.
Проследим сообщение через перезапуск
Разберём сценарий по шагам. Пусть есть два процесса, A и B. Анна подключена к A, Борис к B. Обработчик B перезапускается, например при выкатке новой версии.
- Процесс B останавливается. Его WebSocket-соединения закрываются, клиент Бориса видит обрыв.
- Пока B не вернулся, Анна отправляет сообщение «Выходим в 18:00». Процесс A принимает его и отправляет в группу.
- Бориса в группе уже нет: его соединение закрыто, и он не может принять событие. Сообщение до его экрана не дошло.
- Процесс B поднимается. Клиент Бориса переподключается и снова вступает в группу.
- Новые сообщения снова будут доходить. Но то, что было отправлено в паузу, оперативным путём уже не вернётся.
Если бы мы ничего больше не делали, Борис остался бы с дырой в переписке и не узнал бы об этом. Чат формально работает, но разговор для двоих выглядит по-разному. Это и есть главная ловушка: отсутствие ошибок принимается за отсутствие потерь.
Отдельно стоит упомянуть, что граница между «успели» и «не успели» не всегда такая чёткая, как на схеме. Сообщение могло оказаться в момент обрыва на полпути. Из-за этого нельзя строить логику на предположении «доставилось ровно один раз». Мы не опираемся ни на «ровно один раз», ни на «ни разу не потеряется», а проектируем чат так, чтобы клиент мог выяснить, чего ему не хватает, и безопасно принять одно сообщение дважды.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Три разные задачи, которые легко принять за одну
В разговоре о «доставке сообщений» смешиваются три отдельные вещи. Если их разделить, архитектура становится гораздо понятнее.
| Задача | Вопрос | Где решается |
|---|---|---|
| Сохранение истории | Будет ли сообщение существовать завтра? | База данных приложения |
| Оперативная доставка | Увидят ли его те, кто онлайн прямо сейчас? | WebSocket и channel layer |
| Подтверждение получения | Дошло ли оно до конкретного человека? | Протокол между клиентом и сервером, который вы придумываете сами |
Первая задача — про память. Вторая — про скорость. Третья — про знание отправителя о результате. Channels и channel layer помогают со второй. С первой и третьей придётся разобраться самостоятельно.
Сначала запись, потом объявление
Порядок действий в обработчике определяет всё. Разумная последовательность выглядит так: проверить права, записать сообщение в базу и получить его идентификатор, и только затем отправить событие в группу, уже с этим идентификатором внутри. Тогда любое сообщение, которое кто-то увидел «вживую», уже существует в истории. Обратный порядок даёт призраков: сообщение показано на экранах, но его нет в базе, и после перезагрузки страницы оно пропадает.
Запись из асинхронного потребителя в синхронную Django ORM требует аккуратности: ORM-вызовы нужно выполнять способом, совместимым с асинхронным кодом, например через database_sync_to_async из Channels. Набросок:
from channels.db import database_sync_to_async
@database_sync_to_async
def save_message(room_id, user_id, text):
msg = Message.objects.create(room_id=room_id, author_id=user_id, text=text)
return {"id": msg.id, "text": msg.text}
async def receive(self, text_data=None, bytes_data=None):
data = json.loads(text_data)
saved = await save_message(self.room_id, self.scope["user"].id, data["text"])
await self.channel_layer.group_send(
self.group,
{"type": "chat.message", **saved},
)
Здесь идентификатор из базы становится частью события. Именно он потом позволит клиенту понять, какое сообщение он уже видел.
Подтверждение: что именно мы подтверждаем
Слово «доставлено» в мессенджерах означает разное, и в своём чате нужно выбрать смысл заранее. Можно подтверждать, что сервер принял и записал сообщение: тогда клиент Анны получает ответ с идентификатором и снимает с сообщения значок «отправляется». Это дёшево и полезно: если ответа нет, клиент знает, что надо повторить отправку. Можно подтверждать, что сообщение показано на экране собеседника: тогда нужен обратный сигнал от клиента Бориса, и его тоже придётся хранить, например как последний прочитанный идентификатор в комнате.
Первый вид подтверждения почти бесплатен и закрывает самую частую проблему обрыва на стороне отправителя. Второй вид добавляет трафик и состояние, поэтому вводите его, когда продукту действительно нужны отметки о прочтении.
Повторная отправка при отсутствии ответа порождает новую проблему: если сервер на самом деле принял сообщение, но ответ потерялся по дороге, повтор создаст дубль. Простой способ защититься — клиент присваивает сообщению собственный временный идентификатор, и сервер при записи проверяет, не было ли уже сообщения с таким идентификатором от этого автора в этой комнате. Но учтите: сам по себе новый идентификатор при каждой попытке ничего не защищает. Защищает именно повторное использование одного и того же идентификатора на всех повторах одного сообщения вместе с проверкой на стороне сервера.
Возвращение клиента: как получить пропущенное
Вернёмся к Борису. Его клиент переподключился к процессу B. Что он должен сделать, чтобы дыра в разговоре закрылась? Ответ вытекает из разделения задач: за историей надо идти в историю.
Клиент хранит у себя идентификатор последнего сообщения, которое он успешно показал. После переподключения он запрашивает всё, что новее этого идентификатора. Запрос можно выполнить обычным HTTP-эндпоинтом, и это хороший пример того, как короткие запросы и долгоживущие соединения дополняют друг друга. WebSocket отвечает за «сейчас», HTTP — за «догнать».
Порядок на клиенте важен, и в нём есть тонкость. Если сначала загрузить историю, а потом подписаться на живые события, то в зазоре между этими двумя действиями можно снова что-то пропустить. Если подписаться первым и начать буферизовать приходящие события, а затем загрузить историю и слить обе ленты, зазора не будет. Слияние делается по идентификатору сообщения: то, что пришло и вживую, и из истории, показывается один раз.
- Клиент открывает WebSocket и начинает складывать входящие события в буфер.
- Клиент запрашивает у сервера сообщения новее своего последнего идентификатора.
- Клиент объединяет историю и буфер, отбрасывая повторы по идентификатору.
- Дальше новые события показываются сразу.
Эта схема не требует от channel layer ничего сверх обмена между процессами. Она не рассчитывает ни на повторную доставку, ни на упорядоченность событий на уровне транспорта. Она устойчива именно потому, что источником правды является база, а живые события — это ускоритель.
Заодно стоит подумать об упорядочивании. Показывать сообщения по идентификатору из базы надёжнее, чем по порядку прихода событий: две близкие отправки из разных процессов могут прийти собеседнику в любом порядке, а сортировка по серверному идентификатору выровняет ленту.
Границы группы и право входа
Теперь о том, кто вообще попадает в группу. В нашем наброске название группы строится из номера комнаты, который клиент передал в адресе. Если больше ничего не проверять, любой, кто подставит чужой номер, попадёт в чужой чат и начнёт получать чужие сообщения. Это не экзотика, а прямое следствие того, что группа — просто имя.
Channel layer не знает, кто имеет право на комнату. Для него группа — это адрес, на который можно подписаться и в который можно писать. Проверка прав — ваша задача, и делать её нужно в момент входа, до group_add и до accept:
async def connect(self):
user = self.scope["user"]
self.room_id = self.scope["url_route"]["kwargs"]["room_id"]
if not user.is_authenticated or not await is_member(user.id, self.room_id):
await self.close()
return
self.group = f"chat_{self.room_id}"
await self.channel_layer.group_add(self.group, self.channel_name)
await self.accept()
Тут есть три смысловых момента. Первое: пользователь в scope появляется благодаря аутентификации на уровне ASGI-приложения, и проверять нужно именно его, а не данные, пришедшие в самом сообщении. Второе: членство проверяется по базе, а не по тому, что прислал клиент. Третье: проверка один раз при подключении не вечна. Если человека исключили из комнаты, а его соединение живёт сутками, он продолжит получать сообщения, пока соединение не закроется. Поэтому при исключении нужно либо закрывать его соединения, либо проверять членство и при отправке сообщений, либо делать и то и другое.
Заодно полезно проверять права и на исходящую сторону. Участник группы, который может читать, не обязательно может писать: например, в комнате только для чтения. Эту проверку тоже делаем в receive, до записи в базу.
Имя группы лучше строить из внутреннего идентификатора, а не из пользовательского ввода как есть. Допустимые символы в именах групп ограничены, и произвольная строка из адреса может оказаться недопустимой; надёжнее подставлять числовой идентификатор комнаты из вашей базы.
Как это раскладывается на одном VPS
Теперь соберём картину физически. На одном сервере живут несколько процессов: ASGI-серверы с приложением, Redis для channel layer, база данных и обратный прокси, который принимает внешний трафик и проксирует WebSocket-соединения. Все они делят процессор, память и диск, и каждая роль нагружает ресурсы по-своему.
- ASGI-процессы держат открытые соединения. Каждое соединение стоит немного памяти, и их число растёт вместе с аудиторией, а не с количеством сообщений.
- Redis как посредник нагружается потоком событий. Ему важна оперативная память и быстрый отклик, а не объём диска.
- База принимает запись каждого сообщения и отдаёт историю при переподключениях. После массового обрыва, например при перезапуске, переподключаются сразу все клиенты, и база получает всплеск запросов на догон.
Последний пункт стоит запомнить: перезапуск обработчика создаёт не только паузу, но и «эхо» нагрузки. Тысячи клиентов реконнектятся почти одновременно, и каждый запрашивает историю. Клиенту стоит добавлять случайную задержку перед повторным подключением, а запрос истории делать лёгким, по индексу на комнату и идентификатор.
Сколько соединений выдержит выбранный сервер, заранее не скажет ни число vCPU, ни объём памяти из описания тарифа. Это нужно измерять на своём приложении: открыть реалистичное число соединений, послать поток сообщений и смотреть на память процессов, загрузку процессора и задержку доставки. На VPS MAATRIX доступны root-доступ и NVMe-диски, а линейка тарифов начинается с Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) и доходит до Djed (16 vCPU, 32 ГБ, 320 ГБ). Для учебного чата или небольшого внутреннего сервиса обычно разумно стартовать с малого плана и смотреть на замеры; рост по ресурсам тут вопрос не веры, а наблюдений. Root-доступ нужен, чтобы поставить Redis, настроить сервисы и прокси так, как требует ваша схема, а выбор локации из UK, US, RU и FR стоит делать исходя из того, где находятся ваши пользователи.
Если вы готовите схему из нескольких процессов, пригодится одно полезное упражнение на стенде: запустите два ASGI-процесса на разных портах, подключите Анну и Бориса к разным, и убедитесь, что сообщение доходит. Затем намеренно переключите настройки на in-memory слой и воспроизведите исходную неисправность. Увидев своими глазами, что «код правильный, а Борис молчит», вы запомните разницу лучше, чем из любой документации.
Как проверить, что всё сошлось
Сценарий проверки повторяет нашу историю с двумя собеседниками, и в нём стоит пройти несколько конкретных случаев:
- Разные процессы. Анна и Борис подключены к разным процессам, оба видят сообщения друг друга.
- Остановка обработчика. Остановите процесс Бориса, отправьте несколько сообщений от Анны, запустите процесс снова. Клиент Бориса должен переподключиться и дополучить пропущенное из истории.
- Повтор отправки. Оборвите ответ сервера на отправку и повторите отправку с тем же клиентским идентификатором. В истории должно появиться одно сообщение.
- Чужая комната. Пользователь без членства пытается подключиться и получает закрытое соединение.
- Исключённый участник. После удаления из комнаты человек перестаёт получать новые сообщения.
Каждый пункт проверяет одно конкретное допущение. Если какой-то не проходит, это не «баг сокетов», а указание на то, какую из трёх задач (история, оперативная доставка, подтверждение) вы пока не развели.
Пропущенное сообщение возвращается
Вернёмся к Борису. Его процесс перезапустился, пока Анна писала «Выходим в 18:00». Живой путь этого сообщения закрыт: Бориса в тот момент не было в группе. Но у Анны в базе лежит запись с идентификатором, скажем, 412, а у Бориса на экране последнее сообщение имеет идентификатор 411.
Клиент Бориса переподключается, открывает WebSocket и начинает буферизовать события. Затем он спрашивает у сервера: «что новее 411?» В ответ приходит сообщение 412. Клиент проверяет, что в буфере его нет, показывает его в ленте на своём месте, и дальше живые события продолжают поступать как ни в чём не бывало. Борис ничего не заметил, кроме короткой паузы, и читает: «Выходим в 18:00».
Channel layer в этой истории выполнил свою работу: пока соединение жило, он быстро разносил события между процессами. Но дыру закрыла не доставка, а память, то есть история в базе. Именно такое разделение ролей и делает чат пригодным для работы за пределами демонстрации.