DDD
дай дорогу дураку
дай дорогу дураку
29.03.2024
dddкак инструмент
29.03.2024
ddd2важно при разработке
14.03.2024
trustкто заберет детей из школы
12.03.2024
negotiateВсем привет, меня зовут XXX, и в XXX мы занимаемся XXX.
Когда мы начинали, команды и отделы ещё не были собраны и только намечался запуск XXX, культура разработки, а тем более собеседований, ещё не стабилизировалась. Многие из интервьюверов, а также руководители разработки, понимали проблему: нанимать нужно быстро и качественно. Пришедшие из бигтехов понимали, что их привычный долгий процесс найма не годится в нашем стартапе. Но могли рассказать, как повысить качество проводимых собеседований. Пришедшие ребята из небольших компаний и стартапов, где обычно голое поле, а все делают всё сразу, также делились своим опытом собеседований. Отсюда вырос наш немного особенный вид задач, которому подходит название “дизайн сервиса”.
…
Дизайн сервиса очень похож по названию на устоявшийся тип задач на собеседовании System Design. И это не просто так, многие моменты и структуру мы взяли из него. Но основное отличие, почему мы спрашиваем, как проектировать сервисы, а не системы, это наша потребность в инженерах, которые могут сразу после выхода на работу начать создавать сервисы с бизнес-логикой. Мы не делаем много микросервисов, а скорее стараемся придерживаться тактики проектирования именно сервисов - единиц выполнения бизнес-логики. Когда внутренняя функциональность сервиса начинает разрастаться, или сложность функциональности начинает мешать хорошему темпу разработки, мы начинаем процесс отделения частей сервиса в отдельный микросервис. В дальнейшем этот микросервис опять начинает разрастаться до нормальных объемов. Как раз умение проектировать конкретные сервисы в ограниченное время мы и ценим.
Нам не очень интересно, как быстро разработчик спроектирует очередную соцсеть или агрегатор такси. В наших реалиях проектирование таких объемных систем занимает многие дни, несколько команд и набор человеческих процессов (в том числе архитектурное ревью, аналог ADR). А вот оценить способность кандидата разбираться, развивать и “хлопотать” над его частью системы нам намного интереснее. Это как раз та деятельность, которую мы ожидаем от разработчиков на повседневной основе.
Сначала даются некоторые функциональные требования, что сервис должен делать. Так обычно формулируются задачи system design-а. Можем прикладывать некоторые нефункциональные требования, которые важно соблюдать. Наше любимое — нельзя терять пользовательские данные. Далее начинаем проектировать сервис. Либо мы любим давать уже готовый сервис, некоторую важную часть API, существующие таблицы данных. И некоторый набор “проблем”, которые сейчас есть в нём. Далее интервьюером задается стандартный вопрос “что нам делать?”.
Далее кандидат обычно начинает собирать какие-то требования, проектировать и так далее. Всё как в случае с системным дизайном, ведь другие компании спрашивают. Из-за маленького размера изначальной задачи, а также похожести на системный дизайн, бывает можно достаточно быстро ответить “вот сюда поставим кеш, а здесь нужен брокер”. Но нам очень интересны детали. Что кандидат будет делать вот прямо сейчас, если выйдет в команду, и вокруг него окажется такая ситуация или задача.
Вот тут мы начинаем лезть вглубь ответа, спрашиваем, как то или иное решение в системе повлияет на функциональные и нефункциональные требования.
Не просто так я выше написал про наше любимое нефункциональное (или всё же функциональное?) требование — не терять данные. Работа XXX системы накладывает это важное требование, на которое обычно никто не обращает внимание. Это кажется само собой разумеющимся. Но за эти годы мы испытали все прелести жизни при большом бигтехе — перерезания ковшом линий между дата-центрами, недоступности инфраструктурных компонентов (Vault, etcd), массовые изменения конфигов PostgreSQL, породившие включение автовакуума на всём кластере одновременно — что по сути вылилось в недоступность мастера базы данных. После всех пережитых инцидентов мы выработали некоторый набор практик, правил и библиотек, поддерживающих наличие данных.
Кандидат добавляет таблицу в базу данных — мы просим описать в деталях структуру данных, а лучше DDL. Добавление или изменение API всегда должно сопровождаться схемой запросов и ответов. Нужно прокомментировать, как пользоваться этим API, чтобы все стороны обмена сообщений имели возможность работать эффективно и корректно. Когда кандидат добавляет кеш перед сервисом — мы спрашиваем, как этот кеш инвалидировать. Когда нужно обратиться консистентно к нескольким сервисам, и хочется добавить “двухфазный коммит” — мы спрашиваем, как его будешь реализовывать. Когда добавляется брокер, мы спрашиваем, кто будет писать в него, а кто читать. Как писать в брокер, чтобы не терять данные. Как читать из брокера, чтобы не терять данные. А также интересен план Б — что делать, если вторая сторона, может быть, и рада перейти на получение данных из предложенного брокера, но у них уже есть пять задач других команд, которые тоже попросили перейти на брокер?
Когда все основные моменты были обсуждены, время добавить новые вводные. Ведь разработчики у нас не только пишут новый код, но и развивают его дальше. Одним из самых сложных вводных, которые вообще встречаются в проектировании сервисов и систем, является ввод в эксплуатацию. Сервис уже крутится в проде, там есть пользователи и трафик. База данных начинает наполняться событиями. Приложения пользователей уже обновились на новую версию, внутри которой зашит спроектированный API. Всё происходит ровно по ранее обсуждённым схемам и требованиям.
Либо не менее интересная вводная — а как сделать эту часть системы быстрее, чтобы запустить фичи быстрее или разблокировать работу других команд. Как сделать не просто идеальную систему, а ещё запустить её по Agile и за несколько майлстоунов?
Если останется время, можно поговорить, какие метрики и алерты кандидат бы хотел собирать и настроить, чтобы мониторить запускаемый сервис.
Писать ли в тексте про конкретные технологии/слова?
Есть уже запущенный и несколько лет работающий микросервис cashbacksvc, который считает кешбэк за покупки. За каждую операцию начисляется 1% кешбэка от изначальной суммы.
У сервиса есть работающее API:
service Cashbacksvc {
// Показываем список начисленного кешбэка по операциям.
rpc ListCashback(ListCashbacks) returns (Cashbacks);
// Делает новую запись о начислении кешбэка.
rpc CreateCashbackOperation(CreateCashback) returns (CreateCashbackResponse);
}
message ListCashbacks {
repeated int32 operation_ids = 1;
}
message Cashbacks {
repeated Cashback cashbacks = 1;
}
message Cashback {
int32 operation_id = 1;
int32 user_id = 2;
int32 amount = 3;
}
message CreateCashback {
int32 operation_id = 1;
int32 user_id = 2;
int64 amount = 3;
}
message CreateCashbackResponse {
}
И полный DDL таблиц с данными в базе данных (PostgreSQL):
CREATE TABLE cashbacks (
operation_id integer PRIMARY KEY,
created_at timestamp,
user_id integer,
amount integer
)
База данных скоро перестанет влезать на сервер, установка более мощного железа на сервер не представляется возможной. Бизнес хочет добавлять новую функциональность, но постоянные технические проблемы с сервисом сильно мешают дальнейшему внедрению фичей. Нам дают время навести порядок. Что будем делать?
Для начала, можно поработать с данными.
Проверить теорию, что из-за большого объёма запросы работают не так эффективно, как могли бы. Рассмотреть возможность разбиения таблицы на партиции по времени. После разбиения по времени можно переложить старые данные в другую базу, а код приложения поправить на использование двух баз — горячей и холодной. Организовать крон по перекладыванию горячих данных в холодное хранилище. Возможно, сделать несколько инстансов БД для холодного хранения.
Разбиваем данные. Можно начать складывать данные в новую базу данных. Ввести на уровне приложения какой-то граничный timestamp, начиная с которого данные будут обслуживаться новой БД. В таблицу cashbacks заводим сразу с партициями по created_at. Для читающих запросов поднять несколько реплик, которые будут обслуживать читающую нагрузку. В методе ListCashbacks нет идентификатора времени. Поэтому в код добавим обращение к новой БД, валидацию полноты данных, и если найдены не все ID, делаем фолбек на холодный инстанс. Далее организовываем партиции на холодном хранилище, а также перекладывание из горячего в холодное.
Заводим партиции:
ALTER TABLE cashbacks RENAME TO cashbacks_old;
CREATE TABLE cashbacks (LIKE cashbacks_old INCLUDING ALL) PARTITION BY RANGE (created_at);
ALTER TABLE cashbacks ATTACH PARTITION cashbacks_old FOR VALUES FROM (...) TO (...);
Далее организуем поставку горячих данных в холодное хранилище.
Можно через логическую репликацию на PostgreSQL. По крону запускаем на PostgreSQL логическую репликацию партиции таблицы cashbacks
. По прошествии какого-то времени или какого-то критерия отцепляем партицию, отключаем репликацию, удаляем таблицу на горячем хранилище, подключаем партицию на холодном хранилище. Под каждое действие можем попросить написать конкретные команды.
Альтернативно, можно через уровень приложения, если вы не искусный DBA. В этом случае нам нужно на уровне приложения завести крон, который селектит с горячего, вставляет в холодное. И вроде можно расходиться, но не совсем. Всё ещё нужно организовать создание партиций, удаление партиций, сверку данных. В условиях задачи нет намёков, что строчки могут обновляться — это нам экономит время на внедрение такого перекладчика. Также при перекладывании из базы в базу больших объёмов данных можно обсудить эффективные способы это делать на уровне приложения. Здесь хотелось бы услышать про курсорную пагинацию или использование COPY TO/FROM
и как их использовать. А также обязательно спросим про идемпотентность каждой из выполняемых операций, будь то создание таблиц, индексов на них или отслеживание курсора для курсорной пагинации.
Более сложным решением будет шардирование базы данных. Здесь нам нужно научиться работать с несколькими шардами нашей базы данных, или даже приложения. Так как количество действий над данными в условиях мало, то будет несложно переписать код для использования нескольких шардов. Будем шардировать только на уровне базы данных. Чтобы ввести шарды пораньше и аккуратно, воспользуемся наработками выше — выберем таймстемп, начиная с которого будем вводить новые шарды, здесь можно поступить аккуратно и вводить шарды не все сразу, а по-одному, выбрав для каждого шарда свой собственный таймстемп. Явно обозначим, что нам нужен ключ шардирования и алгоритм, обеспечивающий равномерное распределение данных. Без каких-то инсайтов про сами данные, ключом проще всего выбрать user_id и fnv алгоритм.
Вводим шарды по одному, начиная с указанного для этого шарда времени. Далее нужно решить, что делать со старыми данными — перенести в шардированную базу данных и избавиться от старой, или оставить где лежат. С точки зрения сложности кода, стратегически было бы проще перенести все данные в одно место с одной схемой хранения. Тут также воспользуемся наработками при партиционировании, но теперь данные нужно перенести обратно.
Можно изначально взять распределённую базу данных, что возможно упростит общую ситуацию и сложность решения, но из-за ограниченного времени на внедрение, скорее всего, не будет времени протестировать пригодность того или иного решения под наш профиль нагрузки, а также обучить разработчиков работать с ней.
Нужно не забыть про API, добавить сначала необязательный параметр user_id в ListCashback метод. Параллельно найти всех потребителей и убедить их начать передавать идентификатор пользователя. Если забыть, то миграция на шардированную базу данных может затянуться на неопределённое время.
Что ещё можно сделать с сервисом, чтобы он перестал тратить на свою поддержку время разработки, стал самовосстанавливающимся при разного рода инцидентах?
Пора посмотреть внимательно на API.
В дальний беклог можно положить задачку на приведение API в соответствие с хоть каким-то стайл-гайдом, например от Google Cloud. Но переход на новые методы может занять время у окружающих команд. И добавить хорошие комментарии к API, чтобы по вопросам работы методов можно было не обращаться в разработку.
Примерно в среднесрочный беклог можно было сложить вопрос об int32 amount
. И вопросы:
Но что важно, и над чем можно поработать сразу — как к нам попадают данные. По условиям новые записи появляются через вызов метода сервиса CreateCashbackOperation. Для начала стоит проверить, какой ключ идемпотентности в методе, а также какое поведение будет в случае нескольких запросов с одинаковыми ключами, но разными данными внутри (в нашем случае — amount). Возможно, здесь стоит заложить какой-то идентификатор версии, чтобы извне была возможность поменять некорректные данные, в случае ошибок в вызывающих сервисах.
Также стоит сначала обсудить с продуктом (а на собеседовании — с собеседующим) вопрос перевода механизма начисления кешбэка на асинхронное API, или вообще избавиться от метода и перевести всё в брокер сообщений, чтобы сервису быть устойчивее к бёрстам пишущей нагрузки. Если внешнее API не может писать в брокер, по каким-то причинам (например, потому что это древняя вендорная коробка), то можно метод перевести на самостоятельную запись в брокер, а потом вычитывание операций из него. При добавлении брокера обязательно упомянуть про гарантии доставки сообщений, а также как планируется гарантировать идемпотентность обрабатываемых данных.
Отдельно, в процессе рассуждения, можно упоминать о метриках и алертах, которые потребуются для поддержания сервиса в таком стабильном состоянии. И обязательно рассказать, чем 99-й квантиль отличается от 50-го.
Нужно приоритизировать всё предложенное выше, сколько займёт реализация. Так как статья заранее заготовлена, то порядок выполнения будет соответствовать тексту. А оценки по времени выполнения предлагаю сделать самостоятельно.
Зачем мы спрашиваем у разработчика на собеседовании, сколько ему делать все задачи? Потому что это постоянный рабочий вопрос, с которым разработчик высоких грейдов будет сталкиваться постоянно. А также наши ожидания, что разработчик может самостоятельно организовать свой рабочий процесс в выделенном ему сервисе.
Нужно сделать выплаты накопленного кешбэка клиентам. Релиз должен быть раньше, чем закончится разработка по оптимизации работы с данными.
Всем клиентам выплачиваем накопленный кешбэк одной транзакцией в расчётный день клиента, но через месяц после совершения операции. Расчётный день клиента нужно придумать, но выплаты должны быть равномерно распределены по дням месяца. Для перевода клиенту денег в другом сервисе подготовлен API PayCashback:
service PaymentsGateway {
// Переводит накопленный кешбэк клиенту.
// При ответе OK деньги уже будут на счету клиента.
rpc PayCashback(PayCashbackRequest) returns (PayCashbackResponse);
}
message PayCashbackRequest {
// Ключ идемпотентности для предотвращения дублирования запросов.
string idempotency_key = 1;
// Идентификатор клиента, которому будет начислено amount кешбэка в рублях.
int32 user_id = 2;
// Сумма кешбэка в рублях.
int64 amount = 3;
}
message PayCashbackResponse {
// Идентификатор транзакции в сервисе PaymentsGateway.
string transaction_id = 1;
}
Здесь создаётся конфликт приоритетов между технической и продуктовой задачами:
После этих рассуждений, взвешивания рисков и составления планов, можно прийти к выводу, что это тот момент, когда нужно приоритизировать продуктовую задачу. Или техническую. Или пытаться делать то одно, то другое, балансируя между оптимизациями и реализацией новой функциональности. Но конечно же, как можно было догадаться, нужно начать с эскалации этой проблемы к заказчикам. Предупредить их о рисках и предложить варианты последовательности запуска задач. В рамках текста всё же выберем, что продуктовая задача важнее оптимизации.
Также предположим, что все дополнительные вопросы по формулировке новой задачи уже заданы. Оставим список этих вопросов и ответов загадкой, ответ на которую можно узнать у нас на собеседовании.
Базовой реализацией будет создание какого-то крон-механизма, который раз в день будет запускать процесс выплаты. У нас всё ещё единая база данных, поэтому решением в лоб будет крон примерно с таким кодом:
func PayCashback() {
payments := db.Query("
SELECT user_id,
sum(amount)
FROM cashbacks
WHERE created_at < NOW() - INTERVAL '1 month'
AND created_at >= NOW() - INTERVAL '2 month'
AND user_id % 28 = EXTRACT(DAY FROM now())
GROUP BY user_id
")
for _, payment := range payments {
paymentsGateway.PayCashback(payment.user_id + "_" + time.Format("YYYY-MM-DD"), payment.user_id, payment.amount)
}
}
Далее нужно решить вопросы по транзакционности, идемпотентности, ретраям и другим аспектам работы сервиса в распределённой системе. А также решить те же вопросы, но уже для процесса на шардированной базе данных. Стоит не забыть про очевидные оптимизации. Например, использовать хоть какой-то индекс на created_at.
11.09.2025
service-design-interviewи проблем в зависимости от уровня
29.03.2024
grades