Всем привет! Меня зовут Сергей, я занимаюсь backend-разработкой уже больше 15 лет, а последние несколько лет разрабатываю объектное хранилище для ваших файлов в облаке Сloud.ru. Мы пишем свое собственное распределенное хранилище данных с нуля.

В этой статье я хочу рассказать про грабли, которые часто вижу в проектах и на которые периодически наступаю сам. Рассказываю, как их избежать, чтобы сделать ваши сервисы более стабильными и предсказуемыми. Статья будет полезна junior- и middle-разработчикам.

Содержание

Ошибки, связанные с распределением нагрузки и устойчивостью системы

В этом разделе я собрал ошибки, которые могут положить часть сервисов. Их не очень сложно обнаружить (особенно в проде под нагрузкой ;) ), но очень желательно предупредить.

Совместимость внутреннего API

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

Несоблюдение этого принципа приведет к кратковременной поломке пользовательских запросов. Вы ее, скорее всего, даже не заметите. Пользователь получит ошибку, повторит запрос и наверняка на этот раз все закончится успешно. Но недообработанные запросы могут оставить после себя мусор в данных: висящие промежуточные данные, которые могут приводить к отказу в обслуживании части запросов.

Такую ситуацию проще проиллюстрировать на небольшом примере. У вас есть два сервиса: Order Service для обработки пользовательских заказов и Inventory Service для резервации товаров. Когда пользователь хочет совершить покупку, Order Service совершает вызов /reserve в Inventory Service и, если все успешно, вызывает /commit для этой резервации. Допустим, в какой-то момент команда решает изменить ответ от Inventory Service на вызов /reserve, где вместо reservation_id возвращает сложный объект с новым полем reservation_token. Что происходит дальше:

  • новый Order Service деплоится первым;

  • он делает вызов /reserve к старому Inventory Service, ожидая получить объект с reservation_token. Но старый Inventory Service возвращает ему только reservation_id;

  • Order Service возвращает ошибку пользователю, не находя в ответе ожидаемого поля;

  • для заказов, которые выполнялись во время деплоя, в резерве остаются товары без выкупа.

Таким образом получаем ошибку с высоким business value :)

Отмечу, что ошибки с совместимостью внутреннего API могут привести не только к висящим промежуточным данным, но и к кое-чему пострашнее. Например, если два сервиса находятся в циклической зависимости друг от друга, то ошибки совместимости могут привести к тому, что один из них просто не сможет подняться.

Флуд запросов

Что делать, если запрос к сервису вернул временную ошибку, например timeout или 429 too many requests? Первый наш порыв — сразу повторить запрос. И он неправильный. Ведь если таких сервисов несколько (а то и десятки) и все хотят повторить запрос, то это может сработать как DDOS своих собственных сервисов. Порой под таким шквалом запросов они даже не могут перезагрузиться нормально: после перезагрузки кеши холодные, запросы обрабатываются дольше обычного и все опять идет по кругу. Получаем каскадное падение.

Простым и рабочим решением здесь будет повторение запроса через паузу, чтобы дать обработчику немного остыть. Запрос можно повторять через равные промежутки времени (обязательно настраивайте их через конфиг!) или увеличивать интервал вдвое после каждой неудачной попытки.

Более продвинутая стратегия — Circuit Breaker.

Скрытый текст

Circuit Breaker — это паттерн проектирования для повышения отказоустойчивости распределенных систем. Он работает как автоматический выключатель в электрических цепях, предотвращая повторные вызовы неудачно работающего сервиса. Таким образом, Circuit Breaker защищает систему от каскадных сбоев.

Паттерн имеет три состояния:

  • closed (закрыт) — запросы проходят нормально;

  • open (разомкнут) — все запросы блокируются, чтобы дать сервису время на восстановление;

  • half-open (полуразомкнут) — ограниченное количество запросов пропускается для проверки восстановления сервиса.

Статья на Вики и поиск на Хабре дают много информации по теме.

Еще один момент, который хотелось бы добавить по этому пункту. Отмечайте такие случаи где-нибудь в графане — это сэкономит много времени, когда что-то пойдет не так.

Несгруппированные запросы

Хорошим примером здесь будет биллинг, где каждое совершаемое пользователем действие, как правило, генерирует сразу множество событий. Например, пользователь совершил PUT-запрос, использовал N МБ inbound-трафика и т.д., и все их нужно зарегистрировать. А ваш сервис обрабатывает запросы сразу от многих пользователей. Если отправлять их в биллинг по одному, то мы быстро его положим. В этот момент становится очевидным, что логично будет немного притормозить, накопить запросы и отправить пачкой. Этот простой прием позволит довольно сильно снизить нагрузку на сервисы.

Однако, не все типы запросов поддаются такой нехитрой оптимизации. Чтобы это сработало, запросы должны быть:

  • некритичными ко времени обработки;

  • желательно работать асинхронно и не требовать ответа.

Более того, если ответ на такую пачку предполагает наличие ошибки (а это большинство случаев), то стоит внимательно подумать и определиться, что делать в случае ее появления. Будет ли это означать, что вся пачка не применилась? Можно ли отправить пакет повторно? Но об этом чуть ниже.

Неправильный порядок выбора реплики из пула

Если у вас есть несколько сервисов или реплик, к которым вы можете отправить свой запрос, то выбирайте реплику случайно, а не первую в списке. Это распределит нагрузку между ними более равномерно.

Помню как я получал список ip-адресов от нашего Service Discovery и обращался к ним по порядку. Сейчас мне уже очевидно, что это вызывает перекос в нагрузке. Необходимость готовить срочный патч помогла быстро усвоить этот урок.

Хеджирование запросов

Иногда время ответа от какого-то из ваших сервисов может выглядеть примерно так:

Картинка взята из интернета, у нас в Сloud.ru такого нет ;)
Картинка взята из интернета, у нас в Сloud.ru такого нет ;)

Здесь присутствует длинный хвост в распределении времени ответов. То есть большинство запросов укладывается в приемлемое время, но несколько процентов из них могут обрабатываться очень долго.

Если вам важно, чтобы время ответа сервисов было как можно более предсказуемым (глядя на рисунок выше, хочется применить паттерн «оптимизация» ;) ), то здесь иногда прибегают к хеджированию запросов. Суть довольно проста: вы посылаете запрос не на одну реплику, а сразу на несколько, используете тот ответ, который пришел быстрее. В таком случае, если какая-то из реплик залипнет, то вы получите ответ от другой.

Ну и здесь без нюансов не обходится. Во-первых, убедитесь, что если вы умножите таким образом нагрузку, то это не станет для вас проблемой. Во-вторых, не ко всем запросам это можно применить. Запросы не должны иметь сайд-эффектов, то есть не влиять друг на друга. Например, для GET это подойдет, а для POST уже нет.

Проблемы, связанные с обработкой ошибок

Этот раздел может показаться не столь существенным, однако коварство ошибок в том, что они склонны мстить за недостаточное внимание.

Таймаут не означает, что запрос не выполнился

О том, что все сетевые запросы должны идти с таймаутом, даже говорить не стоит. Но важно то, что если запрос завершился по таймауту, это не означает, что он не выполнен. Это очень важно и не всегда очевидно. Если это какой-то не изменяющий состояние системы GET-запрос, то проблемы нет. А если запрос изменяет состояние системы (PUT, POST, DELETE), то здесь важно подумать, как сделать так, чтобы повторные запросы не привели систему в неконсистентное состояние.

Как добиться этого — зависит от архитектуры. Спектр решений здесь довольно широкий, можно:

  • поддержать инварианты на уровне схемы данных — повторный PUT/POST/DELETE возвращает ошибку «такой объект уже есть/удален»;

  • версионировать данные — в запросе передается версия изменяемого объекта, запрос применяется только если версия совпадает с текущей версией объекта;

  • сделать так, чтобы этот запрос был идемпотентным.

Если с первым методом все понятно, то про второй и третий стоит рассказать подробнее.

Версионирование данных. У этого подхода есть более распространенное название — optimistic concurrency control. В общем случае его применяют для предотвращения гонок данных, но в некоторых случаях он прекрасно применим и к retry-логике. Перейдем к примеру. В нашей системе есть сущность Leftover, описывающая, сколько конкретного товара осталось на складе. Что обычно происходит при оформлении заказа:

  • мы читаем количество остатков из Leftover;

  • вычитаем из остатков нужное количество для заказа;

  • отправляем запрос на изменение остатков: UpdateLeftover(left=42);

  • в обработчике UpdateLeftover обновляем остатки.

Понятно, что если мы повторяем запрос, то можем зарезервировать товар дважды для одного заказа. Чтобы избежать этой ситуации, добавим к Leftover поле version и будем увеличивать его на единицу при каждом изменении. Тогда наш обновленный алгоритм будем выглядеть так:

  • мы читаем количество остатков из Leftover и текущую версию, например, version = 37;

  • вычитаем из остатков нужное количество для заказа;

  • отправляем запрос с номером версии, полученным ранее: UpdateLeftover(left=42, version=37);

  • в обработчике UpdateLeftover проверяем, что актуальная version по-прежнему равна 37 и, если да, то атомарно обновляем остаток товара и увеличиваем version на единицу. Если версия уже изменилась, то возвращаем ошибку.

Еще раз повторюсь, что эта техника обычно используется для предотвращения одновременного изменения данных. Но она также прекрасно подходит и для retry-запросов как частного случая.

Сделать запрос идемпотентным. В дикой природе На практике этот подход встречается очень часто. Обычно он реализуется путем генерации ключей идемпотентности. Суть заключается в том, что при выполнении запроса сервис на какое-то время кеширует ключ идемпотентности и результат его выполнения. При получении запроса с таким же ключом, возвращается закешированный результат. Сами ключи в большинстве случаев генерируются случайно с помощью UUID или подходов с timestamp.

Скрытый текст

Идемпотентность — это свойство, при котором многократное выполнение одного и того же запроса приводит к одинаковому результату без побочных эффектов. Например:

  • GET /users/123 — всегда возвращает одни и те же данные пользователя;

  • PUT /users/123 — многократное обновление с одними данными оставляет пользователя в одинаковом состоянии.

В отличие от неидемпотентных операций, где каждый новый запрос совершает действие. Например, POST /users — каждый вызов создает нового пользователя с разными ID.

Идемпотентность критически важна для безопасного повторения запросов при сетевых сбоях, тайм-аутах или retry-логике.

Частичное применение запроса

Вернемся к примеру с биллингом. Клиент прислал вам пачку событий, которые вы должны у себя зарегистрировать. А может ли случиться так, что на обработке какого-то из них вы столкнетесь с ошибкой? Разумеется. 9 из 10 событий вы успешно записали в свою БД, а на 10-м получили ошибку. Что делать в таком случае? Безусловно, клиенту надо сообщить о ней. Но что ему предпринять в такой ситуации?

В зависимости от природы данных, я встречал три подхода к решению этой проблемы. Первый — возвращать клиенту результат операции над каждым элементом в пачке. Так, например, поступает AWS S3 в ответе на DeleteObjects.

Второй — ревертнуть пачку запросов полностью, если БД поддерживает транзакционность. В случае с биллингом это было бы возможно, в случае с DeleteObjects — вряд ли.

Третий — стараться делать так, чтобы повторное применение запроса не кораптило вашу систему. Тут все сильно индивидуально. В случае с биллингом, как вариант, можно попробовать присвоить каждому событию уникальный ID или вычислить его на месте по данным и использовать этот ID как уникальный ключ в вашей БД.

Утечки внутренней информации через ошибки

Это не то, что может нарушить работу, но способно привести к раскрытию нежелательной информации о инфраструктуре и компрометации каких-либо данных. Не rocket science, но сталкиваться приходится довольно часто.

Можно рассмотреть короткий синтетический пример:

func ProcessDataHandler(w http.ResponseWriter, r *http.Request) {
	// . . .

    err := redisClient.Set("key", data, 0).Err()
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    
    // . . .
}

Обработка ошибки кажется довольно интуитивной, но err.Error() на практике может выглядеть так: dial tcp 172.16.13.15:6379: i/o timeout

В таком виде ошибки нельзя показывать пользователю (раскрывает детали внутренней инфраструктуры), их обязательно нужно прятать за Internal error. Хоть пример и упрощенный, но нечто подобное встречается нередко.

Согласованность и порядок событий

Эта часть, наверное, одна из самых сложных и неочевидных. Лучше всего ее можно иллюстрировать так: «ваши ожидания — ваши проблемы» (с). Дело в том, что в распределенной системе вы не можете полагаться на порядок событий. То есть, если вы ожидаете, что два события должны следовать друг за другом, то сильно удивитесь, когда они придут в обратном порядке. Или какое-то из них придет слишком поздно. Еще один неочевидный момент — событие может прийти дважды. Помните пункт про таймауты? Отправитель может решить, что запрос не удался и повторить его.

Вернемся к примеру с биллингом. Допустим, вы кешируете статистику в следующую структуру:

type UserStatistics struct {
	// . . . 
	BytesUsed uint64
}

Пользователь загружает и сразу удаляет объект. Вы думаете, что вам сначала придет событие +100 байт, а потом -100 байт? Как бы не так! -100 байт может прийти первым и сделать вам integer underflow. Или какое-то из этих событий может прийти дважды. Да, здесь никому верить нельзя.

Скрытый текст

Integer underflow возникает, когда результат арифметической операции с целыми числами становится меньше минимального значения для данного типа данных. Например, при вычитании 1 из 0 в переменной типа uint8 (диапазон 0-255) вместо ожидаемого -1 мы получим 255. Это может привести к критическим уязвимостям и неожиданному поведению программы.

Подходы к решению проблемы могут быть разными в зависимости от ситуации. Если данные не должны быть сильно точными (например, счетчики просмотров), то большой проблемы нет. Если ошибку нужно минимизировать, то можно придумать, как генерировать последовательные номера для отправляемых событий. Тогда вы сможете притормозить принятие каких-то событий, если они пришли вне очереди. Однако для некоторых систем генерация таких номеров для событий может стать отдельным вызовом.

Observabilty

Кажется, об этом было говорено уже много раз. Но знать это и прочувствовать на себе — сильно разные вещи.

Недостаточно логов

Есть два разных мнения на этот счет — «логов лишних не бывает» и «у нас нет столько места, чтобы их хранить». Приходится искать компромисы. Но какая бы политика хранения логов у вас не применялась, с уверенностью можно сказать — логируйте все пути происхождения ошибок!

Совет, быть может, и скучный, но после того, как прочитаете случай из моей практики поймете, почему я решил им поделиться.

Как-то раз ко мне пришли ребята из QA и спросили, почему клиент в ответ на запрос получает ошибку Permission denied. (пишется именно так, с точкой на конце). В той части системы логов было мало, их не хватило для отладки этой проблемы. Пришлось анализировать вообще все случаи, когда мы отказывали в доступе. Это заняло у меня целый день. Знаете, что помогло? То, что во всех других случаях ошибка отображалась как Permission denied (без точки)!

Реквесты без идентификаторов

Здесь я бы хотел говорить не о внедрении distributed tracing, а о вещах более приземленных и доступных. Внедрение систем трассировки требует подготовки инфраструктуры, не все проекты готовы к этому. Нам же порой нужно отвечать на гораздо более простые вопросы, желательно, не тратя на это много времени.

Бывало у вас, что к вам прибегает менеджер и говорит: «У клиента такого-то такой-то запрос завершается ошибкой, в чем дело?». В подобной ситуации очень выручает, если на входе в API Gateway, где-нибудь в middleware, для каждого запроса генерировать уникальный ID и отдавать его пользователю в заголовке ответа. При этом этот ID надо везде носить с собой (на golang, к примеру, его можно прикрепить к контексту). Если запрос уходит дальше на другой сервис, то его также надо прикреплять к запросу. Лучше делать это через HTTP-заголовки.

Скрытый текст

В Go для GRPC на стороне клиента это делается так:

type requestIDKey struct{}

func appendRequestIDToMetadata(baseCtx context.Context) context.Context {
	id, ok := baseCtx.Value(requestIDKey{}).(string)
	if !ok {
		return baseCtx
	}
	
	return metadata.AppendToOutgoingContext(ctx, "x-request-id", id)
}

func goToAnotherService(baseCtx context.Context) error {
  ctx := appendRequestIDToMetadata(baseCtx)
  // . . .
  response, err := client.YourRPCMethod(ctx, request)
  // . . . 
}

На стороне сервера извлекаем так:

func (s *YourServer) YourRPCMethod(ctx context.Context, req *pb.YourRequest) (*pb.YourResponse, error) {
	md, ok := metadata.FromIncomingContext(ctx)
    if ok {
	    // для простоты предположим, что он точно есть в заголовках
	    requestID := md.Get("x-request-id")[0]
        ctx = context.WithValue(ctx, requestIDKey{}, requestID)
    }
	// . . .
}

Обязательно логируйте этот ID при возникновении ошибок. В таком случае вы сможете попросить у клиента этот request ID, погрепать по нему логи и проследить весь путь запроса от клиента до конечной ошибки. Это реально выручает, экономит порой пару часов.

Заключение

Любая распределенная система — это компромисс между скоростью, надежностью и здравым смыслом. Приведенный список ошибок, собранный мною за годы практики, — не исчерпывающий учебник, а отражение тех проблем, с которыми сталкиваешься чаще всего. И этот список продолжает расти.

Главный урок в том, что не существует универсальных решений. Устойчивость системы строится на внимании ко множеству деталей: от идемпотентности и грамотных повторов запросов до продуманной observability. Учиться на чужих ошибках — продуктивнее, чем на своих.

А с какими ошибками чаще всего приходится сталкиваться вам? Пополним этот список?

Комментарии (0)