Привет, Хабр.
Я Java-разработчик и в основном работаю с backend: Spring Boot, базы данных, интеграции, авторизация, WebSocket — всё то, что обычно находится за интерфейсом.
В какой-то момент я поймал себя на мысли: я каждый день пользуюсь мессенджерами, но плохо понимаю, как они устроены внутри. Окей, JWT, WebSocket, PostgreSQL, Redis — это понятно. Но что технически означает фраза “end-to-end encryption”? Как сервер доставляет сообщения, если он не должен их читать? Где живут ключи? Что хранится в базе? Что происходит, если у пользователя два устройства?
Решил разобраться через практику. Написал мессенджер с нуля. Назвал Chaos Messenger.
Сразу честно: криптографическую часть я изучал вместе с Claude и ChatGPT — читал спецификации X3DH и Double Ratchet, разбирал примеры, задавал вопросы, пока не сложилась цельная картина. Frontend тоже делался с активной помощью ChatGPT: я backend-разработчик, React для меня не основная среда. Но архитектура, backend, интеграция WebCrypto, модель конвертов, хранение сообщений и принципиальные решения — мои.
Для меня AI здесь был не заменой понимания, а инструментом — примерно как документация, Stack Overflow и ревью коллег. Без понимания threat model и архитектуры такой проект всё равно не собрать.
В статье расскажу, как работает E2EE изнутри: как устанавливается сессия через X3DH, как каждое сообщение получает отдельный ключ через Symmetric Ratchet, почему сервер хранит только зашифрованные конверты, и какие ошибки я допустил по дороге.
Стек: Spring Boot 3, React 18, WebCrypto API, PostgreSQL, Redis, WebSocket/STOMP, Prometheus, Grafana.
Важная оговорка про web-E2EE
Когда я говорю, что сервер не может прочитать сообщения, я имею в виду backend, базу данных, WebSocket-слой и уже сохранённые ciphertext-конверты. У них нет ключей и plaintext.
Но у web-E2EE есть отдельная проблема: frontend-код тоже приходит с сервера. Теоретически скомпрометированный сервер может отдать изменённый JavaScript, который украдёт ключи или plaintext до шифрования. Это ограничение не конкретно моего проекта, а браузерной модели в целом.
Поэтому корректная формулировка такая: backend не получает ключи и не может расшифровать уже переданные или сохранённые сообщения. Защита от подмены клиентского кода — отдельный слой безопасности: подпись сборок, независимая верификация клиента, desktop/mobile-приложения, reproducible builds.

Почему обычный подход не работает
Большинство "мессенджеров" на GitHub выглядят примерно так:
message.setContent(request.getText()); messageRepository.save(message);
Сервер знает всё. Видит каждое сообщение. Если БД утекла — утекла вся переписка. Если сервер взломали — читай что хочешь. Если завтра компания решит продать данные — технически ничего не мешает.
E2EE решает это радикально: backend не получает ключи и не хранит plaintext. Сообщение шифруется на устройстве отправителя до отправки в сеть, а расшифровывается только на устройстве получателя.
Это уже не вопрос политики конфиденциальности в стиле “мы обещаем не читать”. Это архитектурное ограничение: если у сервера нет ключа, он не может превратить ciphertext обратно в текст.
Звучит как магия. На самом деле — два протокола и немного WebCrypto.
Главная идея: конверты
Представь что Алиса хочет написать Бобу. Вместо того чтобы положить письмо на стол и надеяться что никто не прочитает — она кладёт его в запечатанный конверт. Конверт может открыть только Боб своим ключом. Сервер просто передаёт конверт не заглядывая внутрь.
Именно так это работает в коде. В базе данных у меня это выглядит так:
messages.content = '[encrypted]' -- сервер не знает что внутри message_envelopes.ciphertext = 'qzgHSg7zbwU6h8j8...' -- зашифровано AES-GCM
Когда я впервые увидел [encrypted] в своей БД вместо текста — стало понятно, что модель наконец работает правильно: сервер создал сообщение, доставил его, сохранил метаданные, но так и не узнал содержимое.
А вот что сервер возвращает при запросе списка чатов через API:
{ "chatId": 32, "lastMessage": "[encrypted]", "lastMessageAt": "2026-04-28T22:27:35.537016" }
Не ***. Не [скрыто]. Буквально [encrypted] — потому что у сервера нет другого значения для возврата. Позже расскажу какой баг из этого вытек.

Откуда берутся ключи: X3DH
Главный вопрос: как Алиса и Боб получают общий секрет, если они никогда раньше не общались? И как сделать это так, чтобы сервер только помог передать публичные данные, но сам не смог вычислить итоговый ключ?
Для этого используется X3DH — Extended Triple Diffie-Hellman, протокол из экосистемы Signal. Его задача — установить общий секрет между двумя устройствами, используя долгосрочные и временные ключи.
Что хранится на сервере
Когда пользователь регистрирует устройство, он загружает на сервер пакет публичных ключей:
// crypto-engine.js — генерация ключей при регистрации устройства async function buildNewDeviceBundle() { const identity = await generateX25519KeyPair(); // долгосрочный ключ устройства const signedPreKey = await generateX25519KeyPair(); // должен ротироваться периодически const oneTimePreKeys = []; for (let i = 0; i < 50; i++) { const kp = await generateX25519KeyPair(); oneTimePreKeys.push({ preKeyId: 1000 + i, publicKey: await exportRawPublicKey(kp.publicKey), privateKeyPkcs8: await exportPkcs8PrivateKey(kp.privateKey) }); } // ... }
На сервер уходят только публичные части. Приватные ключи сериализуются и хранятся локально в браузере — и никогда не покидают устройство в сеть.
Здесь важно сказать честно: хранение приватных ключей в localStorage — это компромисс, а не идеальная криптографическая модель.
localStorage доступен JavaScript-коду страницы. Если в приложении появится XSS-уязвимость или если пользователь получит подменённый frontend-код, приватные ключи можно украсть. Это не ломает X3DH или AES-GCM, но ломает клиентскую среду, в которой эти алгоритмы выполняются.
Более строгий вариант — использовать Web Crypto API с extractable: false, чтобы приватный ключ жил внутри браузерного crypto runtime и его нельзя было экспортировать в байты. Но у этого подхода есть практическая сложность: ключи нужно переживать между перезагрузками страницы, синхронизировать с IndexedDB, аккуратно восстанавливать состояние устройства и не сломать UX.
В браузерных E2EE-приложениях обычно приходится выбирать между несколькими вариантами:
Сериализуемые ключи в
localStorageили IndexedDB — проще реализовать, но нужно очень серьёзно относиться к XSS и целостности frontend-кода.extractable: false+ IndexedDB — безопаснее, но сложнее в реализации и восстановлении состояния.Нативное secure storage вроде Android Keystore или iOS Secure Enclave — лучший вариант для мобильных клиентов, но он недоступен обычному web-приложению.
В текущей версии Chaos Messenger используется первый вариант. Это осознанный компромисс для pet/open-source проекта и удобного запуска в браузере. Переход на non-extractable ключи и более строгую модель хранения стоит в roadmap.
Ключевой момент: backend всё равно не получает приватные ключи и не может расшифровать сохранённые ciphertext-конверты. Но защита ключей на клиенте — отдельная задача, и её нельзя честно замалчивать.
Установка сессии
Когда Алиса открывает переписку с Бобом впервые, происходит следующее:
// crypto-engine.js — X3DH со стороны инициатора async function createInitiatorSessionWrapped(localBundle, targetDevice) { const identityPrivate = await importPkcs8PrivateKey(localBundle.identity.privateKeyPkcs8); const ephemeral = await generateX25519KeyPair(); // одноразовый ключ только для этой сессии const remoteIdentityPub = await importRawPublicKey(targetDevice.identityPublicKey); const remoteSignedPreKeyPub = await importRawPublicKey(targetDevice.signedPreKey.publicKey); // X3DH использует несколько DH-операций. // DH4 выполняется, если у получателя есть one-time prekey. const dh1 = await derive32(identityPrivate, remoteSignedPreKeyPub); // IK_alice · SPK_bob const dh2 = await derive32(ephemeral.privateKey, remoteIdentityPub); // EK_alice · IK_bob const dh3 = await derive32(ephemeral.privateKey, remoteSignedPreKeyPub); // EK_alice · SPK_bob const parts = [dh1, dh2, dh3]; if (remoteOneTimePub) { const dh4 = await derive32(ephemeral.privateKey, remoteOneTimePub); // EK_alice · OPK_bob parts.push(dh4); } const combined = concat(...parts); // Из combined через HKDF выводим rootKey и chainKey const { rootKey, chainKey } = await deriveRootAndChainKey(combined); // ... }
В классическом X3DH четвёртая DH-операция с one-time prekey опциональна: она выполняется, если сервер выдал доступный OPK получателя. В моей реализации устройство публикует набор one-time prekeys при регистрации, поэтому первое сообщение обычно использует DH4. Если OPK закончились, сессию всё равно можно установить через остальные DH-компоненты, но это уже менее сильный вариант.
Боб, получив конверт с эфемерным публичным ключом Алисы, повторяет те же операции со своими приватными ключами и получает тот же самый combined. Математика симметрична.
Сервер в этот момент видит только публичные ключи и зашифрованный конверт. Он помогает устройствам найти друг друга, но не участвует в вычислении секрета.
Получить combined только из публичных ключей практически невозможно: безопасность здесь опирается на свойства Diffie-Hellman на Curve25519. Поэтому сервер может хранить и отдавать prekey bundle, но не может вывести тот же shared secret, что получили устройства.

Как шифруется каждое сообщение: Symmetric Ratchet
X3DH даёт нам стартовый chainKey. Но использовать один и тот же ключ для всех сообщений — плохая идея. Если использовать один ключ для всей переписки, компрометация этого ключа сразу открывает весь поток сообщений.
Решение — симметричный ratchet. После каждого сообщения цепочка ключей продвигается вперёд:
// crypto-engine.js — один шаг рatchet async function ratchetStep(chainKeyBytes) { const key = await crypto.subtle.importKey( 'raw', chainKeyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); // messageKey — уникальный ключ для шифрования этого конкретного сообщения const mkBits = await crypto.subtle.sign('HMAC', key, new Uint8Array([0x01])); // nextChainKey — стартовый ключ для следующего сообщения const ckBits = await crypto.subtle.sign('HMAC', key, new Uint8Array([0x02])); const messageKey = await crypto.subtle.importKey( 'raw', new Uint8Array(mkBits), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'] ); return { messageKey, nextChainKey: new Uint8Array(ckBits) }; }
Визуально это выглядит так:
chainKey₀ ──HMAC(·,0x02)──► chainKey₁ ──HMAC(·,0x02)──► chainKey₂ ──► │ │ │ HMAC(·,0x01) HMAC(·,0x01) HMAC(·,0x01) │ │ │ ▼ ▼ ▼ messageKey₁ messageKey₂ messageKey₃ (AES-GCM msg #1) (AES-GCM msg #2) (AES-GCM msg #3)
messageKey используется для шифрования одного сообщения через AES-GCM, после чего уничтожается. Если атакующий компрометирует messageKey₂ — он прочитает только второе сообщение. chainKey₀ вывести из него невозможно — HMAC-SHA256 необратим.
В рамках такой симметричной цепочки это даёт forward secrecy назад по цепочке: зная текущий или отдельный messageKey, нельзя восстановить старые ключи. Но это ещё не полный Double Ratchet — об этом ниже.

Само шифрование сообщения:
async function encryptWithRatchet(session, plainText) { const chainKeyBytes = b64ToBytes(session.sendingChainKey); const { messageKey, nextChainKey } = await ratchetStep(chainKeyBytes); // Продвигаем цепочку вперёд — старый chainKey больше не хранится session.sendingChainKey = bytesToB64(nextChainKey); session.sendingIndex++; // Шифруем AES-GCM с уникальным nonce const encrypted = await aesEncryptWithKey(plainText, messageKey); return { encrypted, messageIndex: session.sendingIndex - 1 }; }
А вот что уходит на сервер — живой пример из DevTools:
{ "envelope": { "ciphertext": "qzgHSg7zbwU6h8j8RqCPUYBWHJLi78eR9C0tj9I=", "nonce": "6KPcVjbpM4FUB0Vz", "senderIdentityPublicKey": "B4pERe0xKmSdiQPR+kLWWmI0nloC8Za3RBTg+occHF0=", "targetDeviceId": "device-2aa3ae0e-ee08-4261-aa09-7d8f800b61e9", "messageType": "PREKEY_WHISPER", "messageIndex": 0 } }
Сервер получает ciphertext и nonce. Расшифровать без messageKey — невозможно.

Важная оговорка: это ещё не полный Double Ratchet
В этом проекте реализован Symmetric Ratchet — цепочка, где из chainKey для каждого сообщения выводится отдельный messageKey, а сама цепочка продвигается вперёд.
Это защищает прошлые сообщения: если атакующий узнает текущий ключ или отдельный messageKey, он не сможет откатить HMAC назад и получить старые ключи.
Но это не полный Double Ratchet из Signal Protocol.
В полном Double Ratchet есть ещё DH ratchet step: стороны периодически выполняют новый Diffie-Hellman обмен и обновляют root key. Это даёт break-in recovery — возможность восстановить безопасность будущих сообщений после компрометации части состояния.
В моей реализации DH ratchet step пока нет. Если атакующий получит актуальное состояние сессии на устройстве и сможет продолжать его читать, он сможет расшифровывать будущие сообщения до переустановки сессии. Это честное ограничение текущей версии, и оно стоит первым пунктом в roadmap.
Мультиустройство: один пользователь, несколько конвертов
Первый неочевидный момент: в E2EE сообщение адресуется не просто пользователю, а конкретным устройствам пользователя.
Если у Боба два устройства — телефон и ноутбук — нужен отдельный encrypted envelope для каждого устройства. Сервер не может взять один конверт, расшифровать его и “переупаковать” для второго устройства: у него нет ключей и он не знает plaintext.
Значит при отправке сообщения нужно зашифровать его отдельно для каждого устройства каждого участника чата.
// crypto-engine.js — fanout на все устройства async function buildFanoutRequest(api, chatId, plainText) { const localBundle = await ensureDeviceRegistered(api); // Получаем список всех устройств всех участников чата const resolved = await api('/api/crypto/resolve-chat-devices/' + chatId, { method: 'POST' }); const envelopes = []; for (const targetDevice of resolved.targetDevices) { // Для своего устройства — особое шифрование (SELF_WHISPER) if (targetDevice.deviceId === localBundle.deviceId) { const encrypted = await encryptSelfEnvelope(localBundle, plainText); envelopes.push({ ...encrypted, messageType: 'SELF_WHISPER' }); continue; } // Для чужого устройства — X3DH + Ratchet let session = getSession(localBundle.deviceId, targetDevice.deviceId); let ephemeralPublicKey = null; if (!session) { // Первое сообщение — устанавливаем X3DH сессию const created = await createInitiatorSessionWrapped(localBundle, targetDevice); session = created.session; ephemeralPublicKey = created.ephemeralPublicKey; } const { encrypted, messageIndex } = await encryptWithRatchet(session, plainText); storeSession(localBundle.deviceId, targetDevice.deviceId, session); envelopes.push({ targetDeviceId: targetDevice.deviceId, ciphertext: encrypted.ciphertext, nonce: encrypted.nonce, messageIndex, ephemeralPublicKey, // null если сессия уже была messageType: ephemeralPublicKey ? 'PREKEY_WHISPER' : 'WHISPER' }); } return { chatId, envelopes }; }
Для чата где у каждого по 2 устройства — 4 конверта на одно сообщение. Для группы из 10 человек — потенциально 20 конвертов. Это нормально, это цена безопасности.
Сервер: хранение и доставка конвертов
На сервере сообщение создаётся с контентом [encrypted], а конверты сохраняются отдельно:
// MessageService.java message.setContent("[encrypted]"); // сервер не знает что внутри messageRepository.save(message); // Каждый конверт — для конкретного устройства Map<String, MessageEnvelope> byDevice = persistEnvelopes(message, sender, request.getEnvelopes());
После сохранения — fanout по WebSocket. Каждое устройство получает свой конверт и только его:
// MessageService.java — per-device доставка private void fanoutCreatedEvent(Message message, Map<String, MessageEnvelope> byDevice) { byDevice.forEach((deviceId, envelope) -> messagingTemplate.convertAndSend( "/topic/devices/" + deviceId + "/chats/" + message.getChatId(), toDeviceEvent("MESSAGE_CREATED", message, envelope, envelope.getTargetUserId()) ) ); }
Это важное отличие от обычного WebSocket-чата. В обычном чате сервер рассылает одно и то же событие всем участникам. В E2EE-чате сервер рассылает разные события разным устройствам: payload для каждого устройства содержит свой ciphertext, зашифрованный под отдельную сессию.
Топик /topic/devices/{deviceId}/chats/{chatId} — строго персональный. Устройство А не получает конверт устройства Б. Никакого broadcast — только адресная доставка.
Архитектура целиком
Браузер ├── React 18 + Vite ├── crypto-engine.js ← X3DH · Symmetric Ratchet · AES-GCM · WebCrypto ├── local device bundle ← identity key · signed prekey · one-time prekeys ├── REST /api/* ← auth · profile · chats · devices · prekeys └── WebSocket /ws ← per-device STOMP topics Spring Boot Backend ├── auth/ ← phone OTP · email · JWT · refresh tokens ├── crypto/ ← device registry · prekey bundles · envelope fanout ├── chat/ ← chats · participants · message metadata ├── message/ ← encrypted envelopes · receipts · events ├── infra/ws/ ← WebSocket · JWT auth · device routing └── infra/presence/ ← online status · typing PostgreSQL └── users · devices · chats · messages([encrypted]) · envelopes(ciphertext, nonce) Redis └── refresh tokens · online presence · SMS rate limits Observability └── Actuator · Prometheus · Grafana

Баг который долго не замечал
В панели чатов показывается превью последнего сообщения. Я реализовал это через ChatService.getMyChats() — загружаю последнее сообщение из БД и отдаю клиенту.
Запускаю — в списке чатов у всех написано [encrypted].
Конечно. Сервер же не знает что там написано.
Я полчаса думал как решить это на сервере. Потом дошло: нельзя решить это на сервере — у него нет ключей. Решение только на клиенте.
После того как пользователь открыл чат и сообщения расшифровались — кешируем последнее в памяти:
// После расшифровки сообщений в useMessages.js previewCache.set(chatId, decryptedText.slice(0, 60)); // В компоненте ChatList — используем кеш const preview = previewCache.get(chatId) ?? '? Зашифровано';
Это хороший пример того, как E2EE меняет привычное мышление backend-разработчика. В обычном приложении preview — это поле в SQL-запросе. В E2EE-приложении preview — это локальное клиентское состояние, потому что только клиент видел plaintext.
Простое решение. Но чтобы к нему прийти нужно было полностью принять идею что сервер здесь просто не при делах — и перестать пытаться решить задачу на его стороне.
Rate limiting: дыра которую легко не заметить
Эндпоинт /api/auth/send-code отправляет SMS с кодом. Без защиты любой скрипт может дёргать его тысячи раз — это называется SMS pumping fraud, SMS стоят реальных денег.
Redis у нас уже был для хранения онлайн-статусов. Добавил rate limiting поверх него:
// SmsRateLimiter.java public void checkAndIncrement(String phone) { // Не более 3 SMS за 10 минут checkLimit("sms:rate:short:" + phone, 3, Duration.ofMinutes(10)); // Не более 10 SMS за 24 часа checkLimit("sms:rate:day:" + phone, 10, Duration.ofHours(24)); } private void checkLimit(String key, int maxAttempts, Duration window) { Long count = redisTemplate.opsForValue().increment(key); if (count == 1) { redisTemplate.expire(key, window); } if (count > maxAttempts) { long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS); throw new RateLimitException("Too many requests", ttl); } }
При превышении — HTTP 429 с заголовком Retry-After. Клиент знает через сколько секунд можно повторить.
Важный нюанс: в текущей реализации, если Redis недоступен, сервис не блокирует авторизацию полностью. Для pet-проекта это приемлемый компромисс: лучше рискнуть одним лишним SMS, чем положить вход в приложение.
В production я бы сделал строже: fallback in-memory лимит на инстанс, отдельные лимиты по IP и телефону, антифрод-логику и алерты на всплески отправки кодов.
Авторизация WebSocket
Отдельная история — авторизация WebSocket соединений. HTTP-эндпоинты защищены Spring Security автоматически, но WebSocket — другое дело. STOMP-соединение устанавливается один раз, и нужно проверять JWT при каждом подключении.
// WebSocketAuthChannelInterceptor.java @Override public Message<?> preSend(Message<?> message, MessageChannel channel) { StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message); if (StompCommand.CONNECT.equals(accessor.getCommand())) { String token = accessor.getFirstNativeHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new AuthException("Missing WebSocket auth token"); } // Валидируем JWT и устанавливаем principal Authentication auth = jwtAuthProvider.authenticate(token.substring(7)); accessor.setUser(auth); } return message; }
Отдельно важно не только проверить JWT, но и связать WebSocket-соединение с конкретным устройством. Пользователь может быть один, но устройств у него несколько, а encrypted envelope адресован именно deviceId.
Поэтому при подключении я проверяю не только токен, но и X-Device-Id: устройство должно быть зарегистрировано и принадлежать текущему пользователю. Иначе легко случайно превратить per-device E2EE-доставку обратно в обычный broadcast по пользователю.
Что получилось — живые скрины


Что реализовано:
E2EE-модель с per-device encrypted envelopes
X3DH session setup + Symmetric Ratchet + AES-GCM
Мультиустройство
Личные и групповые чаты
Realtime доставка через WebSocket/STOMP
Статусы SENT → DELIVERED → READ
Редактирование и soft delete сообщений
Online presence, typing indicator
Фото-вложения
Поиск пользователей
Rate limiting на SMS через Redis
Prometheus метрики + Grafana дашборд
Swagger UI с JWT авторизацией
24 backend-теста на Testcontainers, 12 frontend на Vitest, E2E на Playwright
GitHub Actions CI
Что ещё не сделано:
Полный Double Ratchet с DH ratchet step и break-in recovery
Ротация signed prekey и аккуратное пополнение one-time prekeys
Более строгая модель хранения приватных ключей на клиенте: non-extractable CryptoKey + IndexedDB
Защита от подмены frontend-кода: подпись сборок, независимая верификация клиента, reproducible builds
Android-клиент с Android Keystore
Реальный SMS-провайдер вместо кода в backend-логах
Push-уведомления без утечки содержимого сообщений
Более строгая metadata-модель для групповых чатов
Главный инсайт
E2EE — это архитектурное решение, а не библиотека.
Нельзя взять обычный Spring Boot чат и просто “включить шифрование”. Нужно с самого начала проектировать систему так, чтобы backend не был участником доверенной зоны: он не должен получать plaintext, не должен иметь ключи и не должен уметь пересобирать сообщение из данных в базе.
Это меняет почти всё:
структуру БД — вместо текста появляются encrypted envelopes;
API — сервер отдаёт [encrypted], а не preview сообщения;
WebSocket — доставка идёт не по пользователю, а по конкретному устройству;
мультиустройство — одно сообщение превращается в несколько ciphertext-конвертов;
frontend — становится полноценной криптографической частью системы, а не просто UI.
Второй инсайт: мессенджер — это не “чат с WebSocket”. В E2EE-модели это система доставки зашифрованных конвертов с адресацией по устройствам. Как только это принимаешь, многие странные на первый взгляд решения становятся логичными.
Репозиторий
Код открыт: github.com/vaazhen/chaos-messenger
В репозитории есть README на русском и английском, диаграммы, скриншоты, security audit, Docker Compose и запуск одной командой.
Проект не претендует на уровень production-криптомессенджера вроде Signal. Это учебный и инженерный open-source прототип, цель которого — показать, как E2EE меняет архитектуру backend, frontend и realtime-доставки.
Если вы делали что-то похожее — особенно интересно сравнить подходы к ротации prekey-ов, хранению non-extractable ключей в браузере и реализации DH ratchet step. Вопросы и критика приветствуются.
Комментарии (5)

Snaret
03.05.2026 15:49Привет!
За завтраком полистал код на гитхабе.
Действительно проект явно учебный. Я пока не дошел до шифрования, да и не претендую на понимание WebCrypto, но множество нездоровых моментов вынужден подсветить:private void notifySharedChatsAboutProfileUpdate(Long updatedUserId) { Set<Long> chatIds = new LinkedHashSet<>(); participantRepository.findByUserId(updatedUserId) .forEach(participant -> chatIds.add(participant.getChatId())); for (Long chatId : chatIds) { participantRepository.findByChatId(chatId).forEach(participant -> userRepository.findById(participant.getUserId()).ifPresent(user -> messagingTemplate.convertAndSend( "/topic/users/" + user.getUsername() + "/chats", Map.of( "chatId", chatId, "reason", "profile_updated", "updatedUserId", updatedUserId, "timestamp", System.currentTimeMillis() ) ) ) ); } }Вот код уведомления юзеров.
Вы явно тут пропустили N+1 проблему. Тут - participantRepository.findByChatId(chatId) - Для КАЖДОГО чата делается выборка ВСЕХ участников. Если у пользователя 100 чатов, будет 101 запрос (1 на получение его чатов + 100 на участников каждого чата).
Кроме того если пользователь состоит в разных чатах, он получит уведомление из каждого. Стоит пересмотреть и сэкономить на количестве отправок (чем больше чатов тем больше будет заметна разница).
В итоге ваш чат упадет на реальной нагрузке с >100 чатами и >50 участниками.@Transactional public void markChatAsRead(String username, Long chatId) { User user = requireUser(username); UserDevice currentDevice = currentDeviceOrNull(); String deviceId = deviceIdOrFallback(currentDevice); requireParticipant(chatId, user.getId()); unreadService.reset(user.getId(), chatId); List<Message> messages = messageRepository.findByChatIdAndSenderIdNot(chatId, user.getId()); for (Message message : messages) { markReceiptRead(message, user.getId(), deviceId); updateAggregateStatus(message); sendStatusToSenderDevices(message, message.getStatus().name()); } incrementCounter("messages_read_total", messages.size()); notifyChatListUpdated(chatId, "chat_read"); }Или вот другой метод. Он делает слишком много. Если в чате 10 000 сообщений, этот метод
Достанет их все из БД
Пройдёт по каждому в цикле
Пошлёт 10 000 WebSocket уведомлений
И ожидаемо ляжет под нагрузкой.
Кроме того куча элементарного: куча логики в контроллерах (и даже создание дто КАРЛ!), возвращение Map<String, Object> вместо объекта, повсеместная конкатенация строк (String key = "unread:" + userId + ":" + chatId;) которая тоже по капле сожрет вашу память, сортировка в памяти (.sorted((a, b) -> { ... })), что также не даст пользоваться проектом большому количеству людей - все это либо сознательное упущение в пользу учебного проекта, либо Вам есть куда учиться.
Так или иначе я бы не стал заявлять столь громогласно о готовности)))
Я посмотрел не все. Проект большой и очень амбициозный. И я понимаю, что основная концентрация была скорее всего на E2EE, но в остальном данный проект лучше не запускать в большой мир - утонете в багах.
Будет желание - пишите, могу помочь с подсветкой подобных проблем. Могу порекомендовать неплохое видео по пагинации (не реклама).
ProTechSpec
Мне понравилось, довольно хорошо описано. Механизм шифрования понятный, envelope описан ясно. Желаю успехов в развитии данного проекта, ведь тема таких мессенджеров сейчас как никогда актуальна.
grokfrog Автор
Спасибо!