MBL
Go / avito / Повторение / HTTP, REST, JWT/OAuth2, TCP vs UDP
Go средний

HTTP, REST, JWT/OAuth2, TCP vs UDP

networking

6 тем про сети и веб: HTTP-протокол, эволюция HTTP/1.1→HTTP/3, REST, JWT/OAuth2, TCP vs UDP, и цепочка событий от ввода URL до ответа браузера. Везде, где факт можно проверить конкретным примером — данные, вывод программы, реальная последовательность событий — вместо абстрактного утверждения.

HTTP-методы, статус-коды, идемпотентность, кэш

HTTP-сообщение — обычный текст поверх TCP, четыре части через \r\n: стартовая строка (МЕТОД ПУТЬ ВЕРСИЯ у запроса / ВЕРСИЯ КОД ПРИЧИНА у ответа), заголовки, пустая строка-разделитель, тело.

Идемпотентность — это не абстрактное свойство метода, а конкретный вопрос "что случится, если этот же запрос уйдёт на сервер дважды". Возьмём заказ на маркетплейсе:

  • POST /orders {"item": "book", "qty": 1} — КАЖДЫЙ вызов создаёт НОВЫЙ заказ с новым ID. Отправили дважды (например, клиент не дождался ответа и автоматически повторил) — получили ДВА заказа и списали деньги дважды. POST не идемпотентен.
  • PUT /orders/42 {"qty": 3} — заменяет заказ 42 целиком полем qty=3. Отправили дважды подряд — после первого раза qty уже 3, после второго — снова 3. Результат одинаковый при любом числе повторов. PUT идемпотентен.
  • PATCH /orders/42 {"qty": "+1"} — если PATCH применяет ДЕЛЬТУ (а не абсолютное значение), два повтора дадут qty += 2, а не += 1. PATCH идемпотентен только если тело — абсолютное значение, а не инкремент; при инкременте — нет.

Именно из этого рассуждения следует практическое правило: автоматически повторять неудавшийся POST при таймауте нельзя — сеть могла не потерять запрос, а потерять только ОТВЕТ, и сервер уже создал заказ; слепой повтор создаёт дубликат. GET/PUT/DELETE повторять безопасно именно потому, что их повтор либо ничего не меняет, либо приводит к тому же результату.

Статус-коды тоже стоит различать по вопросу, который они отвечают, а не по номеру:

  • 401 Unauthorized — сервер не знает, кто ты (аутентификация не прошла или отсутствует).
  • 403 Forbidden — сервер знает, кто ты, но тебе конкретно сюда нельзя (авторизация).
  • 400 Bad Request — сам запрос синтаксически сломан (невалидный JSON, отсутствует обязательное поле).
  • 422 Unprocessable Entity — запрос синтаксически верный, но семантически невозможно обработать (например, отрицательное количество товара).

Cache-Control: max-age=300 говорит клиенту: следующие 300 секунд не переспрашивай сервер вообще, считай ответ свежим. ETag + If-None-Match — механизм для случая "а что если 300 секунд прошло, но данные всё равно не менялись": клиент присылает старый ETag, сервер сверяет с текущим и, если совпал, отвечает 304 Not Modified БЕЗ тела — экономит передачу данных, хотя формально кэш уже "протух" по времени.

HTTP/1.1 → HTTP/2 → HTTP/3 (QUIC)

HTTP/1.1 держит одно TCP-соединение и отвечает на запросы строго по порядку — если первый запрос в очереди тормозит (медленная выборка из БД), второй и третий, даже уже готовые, ждут своей очереди позади него. Это называется head-of-line blocking, и раньше браузеры обходили это грубо — просто открывали НЕСКОЛЬКО параллельных TCP-соединений к одному серверу (обычно 6), чтобы запросы не стояли в одной очереди.

HTTP/2 решает ту же проблему изнутри протокола: несколько независимых логических потоков мультиплексируются в ОДНОМ TCP-соединении, поэтому не нужно открывать 6 соединений — сервер может отвечать на второй запрос, не дожидаясь ответа на первый, в рамках одного и того же соединения.

Дополнительно HTTP/2 сжимает заголовки через HPACK — заголовки вроде User-Agent, Cookie, Accept в реальном трафике почти не меняются от запроса к запросу, и вместо их полной передачи каждый раз пересылается ссылка на уже известную обеим сторонам таблицу.

Но у HTTP/2 остаётся ОДНА нерешённая проблема на уровень ниже: TCP гарантирует порядок БАЙТ на уровне всего соединения. Если в сети потерялся один TCP-пакет, TCP обязан дождаться его повторной доставки, прежде чем отдать приложению ЛЮБЫЕ следующие байты — даже если эти байты принадлежат совершенно другому, никак не связанному HTTP/2-потоку. То есть head-of-line blocking переехал с уровня HTTP на уровень TCP, а не исчез.

HTTP/3 решает это, меняя сам транспорт: вместо TCP используется QUIC, построенный поверх UDP. QUIC реализует надёжность (повторную доставку потерянных данных) и порядок отдельно для КАЖДОГО потока — если пакет одного потока потерялся, ждать приходится только этот один поток, остальные продолжают доставляться немедленно.

Заодно QUIC несёт в себе TLS 1.3 (шифрование включено с самого начала протокола, а не надстройка поверх) и переживает смену IP-адреса клиента (например, переключение Wi-Fi → мобильная сеть) без обрыва соединения — то, что было невозможно для TCP-соединения, привязанного к паре IP-адресов.

REST: ресурсы, пагинация, версионирование

URL в REST называет РЕСУРС существительным (/users, /orders/42), а не действие глаголом (/getUser, /createOrder) — само действие уже кодирует HTTP-метод (GET /users/42 = получить, DELETE /orders/42 = удалить). Вложенность в пути отражает реальную иерархию владения (/users/42/orders — заказы пользователя 42); если вложенность уходит глубже 2-3 уровней (/users/42/orders/7/items/3/reviews), это обычно сигнал, что нужен отдельный top-level ресурс (/reviews/99), а не бесконечная вложенность в пути.

Пагинация нужна потому что список из миллионов строк физически нельзя отдать одним ответом. Разница между двумя подходами видна именно на сценарии параллельной записи:

Представь: список из 10 элементов, отсортированных по дате, страница = 5 элементов. Клиент запрашивает страницу 1 (OFFSET 0 LIMIT 5), получает элементы 1-5. Пока клиент читает страницу 1, кто-то добавляет новый элемент в начало списка (например, новый заказ, самый свежий).

Клиент запрашивает страницу 2 (OFFSET 5 LIMIT 5) — но список сдвинулся на одну позицию, и то, что было элементом 5 (последним на странице 1), теперь стало элементом 6 и попадёт СНОВА на страницу 2 — клиент увидит один и тот же элемент дважды, а какой-то другой элемент вообще пропустит.

Cursor-based пагинация не хранит абсолютную позицию, а спрашивает "дай мне записи, идущие ПОСЛЕ вот этого конкретного id/timestamp" (WHERE id > :last_seen_id ORDER BY id LIMIT 5). Вставка нового элемента в начало списка не сдвигает позиции существующих элементов относительно друг друга — курсор остаётся валиден. Цена — нельзя запросить "сразу страницу 50", можно только последовательно идти вперёд от последнего известного курсора.

Версионирование API обычно кладут прямо в URL (/v1/users, /v2/users) — не самый "чистый" с архитектурной точки зрения подход (версия — не часть идентичности ресурса), зато предельно явный и заметный любому, кто смотрит на запрос; версия в заголовке (Accept: application/vnd.api.v2+json) архитектурно чище, но легко теряется из виду при отладке.

JWT, OAuth2, сессии vs токены

JWT состоит из трёх частей, разделённых точками: header.payload.signature, каждая — отдельно закодирована в base64url. Ключевой и часто упускаемый факт: payload ЗАКОДИРОВАН, а не ЗАШИФРОВАН — раскодировать его может кто угодно, у кого есть сам токен, без всякого секретного ключа.

на play.golang.org

Никакого секретного ключа в этом коде нет вообще — sub, name, exp читаются просто декодированием base64. Подпись (третья часть) защищает не от ЧТЕНИЯ, а от ПОДДЕЛКИ — сервер, зная секрет, может проверить, что header+payload не были изменены после подписания; изменить payload и пересчитать корректную подпись без знания секрета невозможно, а вот прочитать исходный payload может кто угодно, например простым jwt.io или кодом выше. Практический вывод: никогда не класть в payload то, что не должно быть видно клиенту (пароли, внутренние ID из других систем, если это чувствительно).

Отозвать JWT ДОСРОЧНО (до истечения exp) структурно сложно именно потому, что сервер stateless и ничего не хранит про выданные токены — простой способ "удалить токен из базы" не работает, потому что токена в базе и нет. Практические обходные пути: чёрный список отозванных токенов (частично возвращает stateful-хранение, которого JWT должен был избежать) или короткий exp (минуты) + отдельный refresh_token для получения нового access-токена — тогда "отзыв" сводится к тому, чтобы просто не выдавать новый access-токен по скомпрометированному refresh-токену.

Сессии vs токены — где физически живёт состояние. Сессия хранит состояние на СЕРВЕРЕ (обычно в Redis или БД) — клиент несёт только идентификатор сессии в cookie. Отозвать сессию тривиально — удалить запись на сервере. Но нужно централизованное хранилище, доступное всем инстансам приложения. Токен (JWT) несёт состояние на КЛИЕНТЕ — любой инстанс сервера проверяет подпись сам, без обращения к общему хранилищу, что упрощает горизонтальное масштабирование, но именно поэтому отзыв и сложен (см. выше).

OAuth2 формализует три роли: Resource Owner (ты, владелец данных), Client (стороннее приложение, которое хочет получить доступ), Authorization/Resource Server (Google, который хранит данные и решает, кому их отдать). Классический сценарий "войти через Google": пароль ты вводишь НА СТРАНИЦЕ Google, а не в приложении-Client — Client физически никогда не видит пароль. После успешного входа Google выдаёт приложению одноразовый authorization code (живёт секунды, передаётся через редирект браузера), и уже сервер Client-приложения обменивает этот code на настоящий токен отдельным запросом сервер-сервер, минуя браузер пользователя.

Три вида токенов в этом потоке решают разные задачи: access_token — короткоживущий, предъявляется на каждый запрос к API Google от имени пользователя. refresh_token — долгоживущий, используется РЕДКО, только чтобы получить новый access_token без повторного участия пользователя (без повторного ввода пароля).

id_token — это уже надстройка OpenID Connect поверх голого OAuth2, и содержит данные о самой личности пользователя (имя, email) — потому что OAuth2 в чистом виде отвечает на вопрос "что этому клиенту разрешено делать" (авторизация), а не "кто именно этот пользователь" (аутентификация); эти два вопроса на первый взгляд похожи, но формально это разные протокольные слои.

TCP vs UDP, three-way handshake

В терминах OSI-модели TCP и UDP живут на уровне 4 (Transport), IP — уровнем ниже, на уровне 3 (Network). IP отвечает только за маршрутизацию пакета от одного адреса к другому, ничего не гарантируя про доставку — вся надёжность (или её отсутствие) добавляется уже на транспортном уровне.

TCP устанавливает соединение явным рукопожатием, прежде чем передать хоть один байт полезных данных: клиент шлёт SYN ("хочу соединиться"), сервер отвечает SYN-ACK ("принимаю, и тоже хочу"), клиент подтверждает ACK ("договорились") — только после этих трёх сообщений начинается передача данных. Дальше TCP гарантирует доставку (потерянный пакет пересылается повторно) и порядок байт — ценой этих гарантий становятся дополнительные round-trip'ы и накладные расходы на подтверждения.

UDP соединения не устанавливает вообще — пакет просто отправляется, без гарантии, что он дойдёт, и без гарантии порядка относительно других пакетов. Взамен — минимальные накладные расходы и минимальная задержка перед первым байтом полезных данных.

Выбор между ними — не "TCP лучше/UDP хуже", а вопрос, что дороже потерять: TCP там, где критична каждая единица данных и порядок (HTTP, передача файлов, email — потерять байт файла недопустимо).

UDP там, где важнее низкая задержка, а потеря отдельных данных приемлема или обрабатывается на уровне приложения: видеозвонок — лучше пропустить один кадр, чем ждать его повторной пересылки и создавать заикание для всех последующих кадров; DNS — короткий запрос-ответ, где накладные расходы на полноценное TCP-соединение были бы избыточны относительно размера самого запроса (и именно поэтому QUIC, о котором шла речь выше, строится поверх UDP, а не поверх TCP — ему нужен контроль над надёжностью на уровне отдельных потоков, а не TCP-шная надёжность на уровне всего соединения).

Что происходит при вводе URL в браузере

Классический системный вопрос (~23% интервью по статистике roadmap), который проверяет не отдельные факты, а умение связать их в правильную последовательность:

(1) DNS — превращение домена в IP-адрес, через каскад кэшей: сначала браузер проверяет свой собственный кэш, затем кэш ОС, затем идёт к DNS-резолверу (обычно провайдера), который при необходимости обходит корневые → TLD (.com, .ru) → авторитативные сервера конкретного домена.

(2) TCP handshake — три сообщения SYN → SYN-ACK → ACK с полученным на предыдущем шаге IP-адресом, устанавливающие соединение (см. раздел выше).

(3) TLS handshake (только для HTTPS) — происходит ПОСЛЕ установления TCP-соединения, поверх него: стороны согласуют алгоритмы шифрования и обмениваются/проверяют сертификат сервера. Порядок именно такой — TLS не заменяет TCP-рукопожатие, а надстраивается сверху уже установленного соединения.

(4) HTTP-запрос/ответ — сам запрос уходит уже через установленное и защищённое (если HTTPS) соединение.

(5) Рендеринг — зона фронтенда (построение DOM, применение CSS, выполнение JS); для бэкенд-собеседования обычно достаточно упомянуть, что этот этап существует, не углубляясь.

Порядок DNS → TCP → TLS → HTTP жёсткий и важен именно в таком виде — частая ошибка на собесе сказать что-то вроде "сначала TLS, потом TCP", но TLS физически не может начаться раньше, чем есть установленное TCP-соединение, через которое стороны вообще могут обмениваться байтами.

Повторный визит на тот же сайт обычно заметно быстрее первого — многое кэшируется на разных уровнях этой же цепочки: DNS-запись остаётся в кэше некоторое время (TTL записи), TCP/TLS могут переиспользовать уже установленное соединение через keep-alive (для TCP) или session resumption (для TLS, без полного повторного согласования шифрования), статический контент отдаётся из локального кэша браузера через уже знакомые Cache-Control/ETag.

Самопроверка 0 / 4
Понимаю, почему автоповтор POST при таймауте небезопасен, а PUT/GET/DELETE — можно повторять
Могу объяснить, почему offset-пагинация может пропустить/продублировать записи при параллельной записи, а cursor-based — нет
Знаю, что payload JWT закодирован, а не зашифрован, и почему досрочный отзыв JWT сложен
Могу назвать порядок DNS → TCP → TLS → HTTP и объяснить, почему TLS идёт поверх TCP, а не вместо него
Как усвоено?