*на самом деле мы напишем только прототип протокола.
Возможно, вы встречались с подобной ситуацией – сидите в любимом мессенджере, переписываетесь с друзьями, заходите в лифт/тоннель/вагон, и интернет вроде ещё ловит, но отправить ничего не получается? Или иногда ваш провайдер связи неправильно конфигурирует сеть и 50% пакетов пропадает, и тоже ничего не работает. Возможно, вы думали в этот момент — ну ведь можно же наверное как-то сделать, чтобы при плохой связи всё равно можно было отправить тот маленький кусочек текста, который вы хотите? Вы не одни.
В этой статье я расскажу про свою идею для реализации протокола на основе UDP, который может помочь в этой ситуации.
Проблемы TCP/IP
Когда у нас плохое (мобильное) соединение, то начинает теряться большой процент пакетов (или ходить с очень большой задержкой), и протокол TCP/IP может воспринимать это как сигнал о том, что сеть перегружена, и всё начинает работать оооочень медленно, если работает вообще. Не добавляет радости тот факт, что установление соединения (особенно TLS) требует отправки и приема нескольких пакетов, и даже небольшие потери сказываются на его работе очень плохо. Также часто требуется обращение к DNS перед тем, как установить соединение — ещё пара лишних пакетов.
Итого, проблемы типичного REST API, основанного на TCP/IP при плохом соединении:
- Плохая реакция на потери пакетов (резкое уменьшение скорости, большие таймауты)
- Установление соединения требует обмена пакетами (+3 пакета)
- Часто нужен «лишний» DNS-запрос, чтобы узнать IP сервера (+2 пакета)
- Часто нужен TLS (+2 пакета минимум)
Суммарно это означает, что только для соединения с сервером нам нужно послать 3-7 пакетов, и при высоком проценте потерь соединение может занять существенное количество времени, а мы ещё даже ничего не отправили.
Идея реализации
Идея состоит в следующем: нам требуется всего-лишь отправить один UDP-пакет на заранее зашитый IP-адрес сервера с необходимыми данными авторизации и с текстом сообщения, и получить на него ответ. Все данные можно дополнительно зашифровать (этого в прототипе нет). Если ответ в течение секунды не пришел, то считаем, что запрос потерялся и пробуем отправить его заново. Сервер должен уметь убирать дубли сообщений, поэтому повторная отправка не должна создать проблем.
Возможные подводные камни для production-ready реализации
Ниже перечислены (далеко не все) вещи, которые нужно продумать перед тем, как использовать что-либо подобное в «боевых» условиях:
- UDP может «резаться» провайдером — нужно уметь работать и по TCP/IP
- UDP плохо дружит с NAT — обычно есть мало (~30 сек) времени, чтобы ответить клиенту на его запрос
- Сервер должен быть устойчив к атакам усиления — нужно гарантировать, что пакет с ответом будет не больше пакета с запросом
- Шифрование — это сложно, и если вы не эксперт по безопасности, у вас мало шансов реализовать его корректно
- Если выставить интервал перепосылок неправильно (например, вместо того, чтобы пробовать заново раз в секунду, пробовать заново без остановки), то можно сделать намного хуже, чем TCP/IP
- На ваш сервер может начать приходить больше трафика из-за отсутствия обратной связи в UDP и бесконечных повторных попыток отправки
- IP-адресов у сервера может быть несколько, и они могут меняться со временем, поэтому кеш нужно уметь обновлять (у Telegram хорошо получается :))
Реализация
Напишем сервер, который будет отдавать ответ по UDP и присылать в ответе номер запроса, который к нему пришел (запрос выглядит как «request-ts текст сообщения»), а также timestamp получения ответа:
// Это Go.
// Обработка ошибок убрана для краткости
buf := make([]byte, maxUDPPacketSize)
// Начинаем слушать UDP
addr, _ := net.ResolveUDPAddr("udp", fmt.Sprintf("0.0.0.0:%d", serverPort))
conn, _ := net.ListenUDP("udp", addr)
for {
// Читаем из UDP, нам обязательно нужен обратный адрес
n, uaddr, _ := conn.ReadFromUDP(buf)
req := string(buf[0:n])
parts := strings.SplitN(req, " ", 2)
// Высчитываем время на сервере по сравнению с временем клиента
curTs := time.Now().UnixNano()
clientTs, _ := strconv.Atoi(parts[0])
// Тут можно сходить в базу или куда-нибудь ещё и непосредственно сохранить сообщение
// Отправляем ответ
conn.WriteToUDP([]byte(fmt.Sprintf("%d %d", curTs, clientTs)), uaddr)
}
Теперь сложная часть — клиент. Мы будем отправлять сообщения по одному и дожидаться ответа сервера перед тем, как послать следующее. Слать будем текущий timestamp и кусок текста — timestamp будет служить идентификатором запроса.
// Создаем сокеты
addr, _ := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", serverIP, serverPort))
conn, _ := net.DialUDP("udp", nil, addr)
// В UDP запись и чтение будут идти независимо, поэтому используем канал для удобства.
resCh := make(chan udpResult, 10)
go readResponse(conn, resCh)
for i := 0; i < numMessages; i++ {
requestID := time.Now().UnixNano()
send(conn, requestID, resCh)
}
Код функций:
func send(conn *net.UDPConn, requestID int64, resCh chan udpResult) {
for {
// Отправляем пакет до тех пор, пока не получим ответ на своё сообщение.
conn.Write([]byte(fmt.Sprintf("%d %s", requestID, testMessageText)))
if waitReply(requestID, time.After(time.Second), resCh) {
return
}
}
}
// Ждем свой ответ, или таймаут.
// В сети пакеты могут как теряться, так и дублироваться, поэтому нужно
// проверять, что присланный ответ действительно относится к тому сообщению,
// которое мы посылали.
func waitReply(requestID int64, timeout <-chan time.Time, resCh chan udpResult) (ok bool) {
for {
select {
case res := <-resCh:
if res.requestTs == requestID {
return true
}
case <-timeout:
return false
}
}
}
// Распарсенный ответ сервера
type udpResult struct {
serverTs int64
requestTs int64
}
// Функция для чтения ответа из соединения и засовывания ответа в канал.
func readResp(conn *net.UDPConn, resCh chan udpResult) {
buf := make([]byte, maxUDPPacketSize)
for {
n, _, _ := conn.ReadFromUDP(buf)
respStr := string(buf[0:n])
parts := strings.SplitN(respStr, " ", 2)
var res udpResult
res.serverTs, _ = strconv.ParseInt(parts[0], 10, 64)
res.requestTs, _ = strconv.ParseInt(parts[1], 10, 64)
resCh <- res
}
}
Также я реализовал то же самое на основе (более-менее) стандартного REST: с помощью HTTP POST посылаем те же requestTs и текст сообщения и дожидаемся ответа, после чего переходим к следующему. Обращение делалось по доменному имени, кеширование DNS в системе не запрещалось. HTTPS не использовался, чтобы сравнение было более честным (в прототипе шифрования нет). Таймаут был выставлен в 15 секунд: в TCP/IP уже есть перепосылки потерянных пакетов, а сильно больше 15 секунд пользователь, скорее всего, ждать не станет.
Тестирование, результаты
При тестировании прототипа измерялись следующие вещи (всё в миллисекундах):
- Время ответа на первый запрос (first)
- Среднее время ответа (avg)
- Максимальное время ответа (max)
- H/U — соотношение «время HTTP» / «время UDP» — во сколько раз меньше задержка при использовании UDP
Делалось 100 серий по 10 запросов — симулируем ситуацию, когда нужно послать буквально несколько сообщений и после этого уже становится доступен нормальный интернет (например Wi-Fi в метро, или 3G/LTE на улице).
Протестированные виды связи:
- Профиль «Very Bad Network» (10% потерь, 500 мс latency, 1 мбит/сек) в Network Link Conditioner — «Very Bad»
- EDGE, телефон в холодильнике («лифте») — fridge
- EDGE
- 3G
- LTE
- Wi-Fi
Результаты (время в миллисекундах):
Выводы
Вот, какие выводы можно сделать из получившихся результатов:
- Если не считать аномалию с LTE, то разница при посылке первого сообщения тем больше, чем хуже связь (в среднем в 2-3 раза быстрее)
- Последующая отправка сообщений в HTTP не сильно медленней — в среднем в 1,3 раза медленней, а на стабильном Wi-Fi вообще разницы нет
- Время ответа на основе UDP намного стабильнее, что косвенно видно по максимальному времени ожидания — оно тоже меньше в 1,4-1,8 раз
Другими словами, в соответствующих («плохих») условиях наш протокол будет работать намного лучше, особенно при посылке первого сообщения (часто это всё, что необходимо отправить).
Реализация прототипа
Прототип выложен на github: github.com/YuriyNasretdinov/instant-im. Не используйте его в продакшене!
Автор: youROCK