- Основные принципы
- Мы отвечаем за работоспособность своей системы
- Ваша система – это ответственность надолго
- Мы начинаем реализацию с проектирования и учитываем текущую архитектуру
- Мы избегаем ненужной сложности
- Мы используем готовые инструменты и не изобретаем велосипеды
- Мы относимся к чужому коду с уважением
- Если что-то становится ненужным, мы это удаляем
- Проектирование
- Интерфейсы
- Документация
- Работа с данными
- Мы не пишем бизнес-логику на стороне базы
- Мы используем шину данных только для уведомления о событиях
- Мы кэшируем данные там, где это необходимо
- У данных есть только один источник правды
- Мы не храним данные в stateless-компонентах, а используем персистентные хранилища
- Мы соблюдаем обратную совместимость миграций
- Взаимодействие сервисов
- Тестирование
- Эксплуатация
Мы катнули сервис в прод и ушли на обед. В этот момент с сервисом что-то случилось. Не нужно рассчитывать, что его кто-то за вас починит — разработчик сервиса отвечает за него. Нужно реагировать на алерты и предпринимать меры для устранения проблем. То же самое может произойти и ночью.
Чтобы меньше просыпаться ночью, мы заботимся о стабильности сервисов заранее. Мы следим за тем, чтобы наш сервис работал во всех окружениях, не только в продакшене.
Некоторые наши системы живут невероятно долго, поэтому мы заранее рассчитываем на то, чтобы облегчить себе (и другим) жизнь через 3 года. Мы поддерживаем работоспособность нашей системы до последнего использования. И даже если сервис не менялся давно, но в используемой библиотеке нашли уязвимость, мы делаем исправления.
Допустим, мы решили добавить новую функцию поиска объявления по настроению пользователя. Мы могли бы просто взять и создать новый сервис для реализации такой функциональности. Далее сделать поход из монолита и всех затрагиваемых этой функциональностью сервисов. Но мы так не делаем. Правильно сначала запросить или построить самостоятельно текущую архитектуру (если нет готовой), и посмотреть в какое место системы лучше добавить новую возможность. Вполне вероятно, что для этого найдется уже готовый сервис и не нужно делать новый. Следует помнить, что мы стараемся избегать распределенных транзакций и не создаем новые сервисы на любую новую фичу.
Мы избегаем ненужной сложности, сохраняя решения простыми. Мы проектируем систему так, чтобы не нужно было делать одно и то же изменение в нескольких местах. Это относится к коду, схемам данных, тестам и даже документации. Если вдруг вы ловите себя на том, что этот код вы уже писали/встречали раньше, остановитесь, подумайте и не повторяйте себя.
Решая задачу, мы всегда рассматриваем альтернативы или существующие решения. И только если они нам не подходят, разрабатываем своё.
Мы обсуждаем большие изменения с владельцем кода/репозитория перед тем, как их вносить. Если мы видим, что можно улучшить текущий код, то мы стараемся это делать. Когда нас просят сделать код-ревью пулл-реквеста, мы делаем это как можно быстрее. Если мы видим проблемы в пулл-реквесте, мы оставляем понятные комментарии, что нужно исправить и почему. Если нам нужно сделать быстрое временное решение, то мы обязуемся привести его потом в порядок.
Мы не храним “на всякий случай” то, что уже устарело или просто стало ненужным. Это относится и к неиспользуемому коду, и к сервисам, и к базам данных.
Из основных принципов следуют дополнительные. Их больше и они более конкретные.
Как мы не делаем: допустим, мы делаем новую фичу по определению настроения нашего пользователя. Мы можем реализовать ее в отдельном сервисе, а за данными о пользователе пойти в базу данных или кэш сервиса пользователей. Это приводит к жесткой связанности. Мы не сможем менять оба этих сервиса изолированно друг от друга, так как их реализация пересекается.
По тому же принципу мы действуем при проектировании системы. Мы не делаем сервисы, которые нужно выкатывать синхронно.
Другим примером жесткой связанности будет необходимость синхронно оповестить N сервисов при создании нового объявления. Вместо синхронных вызовов лучше отправить сообщение в шину данных, на которое каждый сервис сможет отреагировать тогда, когда ему удобно. То есть интеграция получается с событием, а не с конкретным сервисом.
Чтобы разрабатывать независимо, мы не завязываемся на особенности реализации систем, которые используем. Например, мы можем знать, что определенное поле в сервисе монотонно возрастает из-за того, что используется mongodb. Мы можем завязаться на это поле, используя его как ключ идемпотентности. Но в будущем у нас могут возникнуть проблемы, когда этот сервис изменит эту реализацию. Поэтому мы так не делаем.
В первую очередь необходимо продумать как будет выглядеть внешний интерфейс. Реализацию в будущем можно менять, клиенты ее не видят.
Разработчик сервиса обязан поддерживать свой интерфейс обратно совместимым и реализующим такую же функциональность, которую он предоставлял в момент создания. Важно, что эта поддержка не ограничивается несколькими месяцами, её нужно обеспечивать до того момента, пока интерфейс хоть кто-то использует. Например, при создании сервиса, endpoint проведения транзакции также выполнял её подтверждение. Но в последующих версиях, подтверждение убрали. Однако, на такое поведение, которое было явно определено, уже успели завязаться другие сервисы. Поэтому логику мы делаем также обратно совместимой.
То есть, добавляя новый endpoint doMagic(), мы не можем удалить его или изменить какие-то обязательные поля, равно как и добавить новое обязательное поле. Все клиенты нашего сервиса должны работать всегда, без необходимости делать какие-то изменения со своей стороны.
Если у нас есть необходимость сделать обратно несовместимое изменение, мы делаем новую версию, оставляя старую работоспособной.
Для интеграции с вашей системой клиенты не должны тратить большое количество времени.
Мы документируем интерфейсы, особенности работы системы, нефункциональные требования — и держим их в актуальном состоянии
Мы делаем это для того, чтобы новый инженер быстро вошел в курс дела или чтобы при проблемах мы могли быстро разобраться в чем дело, не вызывая автора из отпуска.
Часть логики технически можно реализовать прямо в хранимых процедурах на стороне базы данных, например, в PostgreSQL. Но мы так не делаем, потому что тестирование, отладка и развязывание инцидентов в проде с такой схемой работы сервиса становятся очень сложными. Поэтому мы реализуем всю бизнес-логику на стороне кода самого сервиса.
При создании новой сущности кажется удобным отправлять сразу весь созданный state в шину данных. Но мы так не делаем, потому что шина данных создана для обмена уведомлениями, а не состояниями.
Допустим, для определения настроения пользователя нам нужно получить тексты описания объявлений. Когда мы хотим получить данные, например, по определенным объявлениям из нашего сервиса, мы не ходим за данными каждый раз. Вполне нормально закэшировать необходимые тексты у себя в сервисе и затем использовать их. Однако важно помнить об инвалидации кэша в тех случаях, где это критично.
Мы не поддерживаем несколько мастер-копий данных в разных системах. Это усложняет поддерживаемость и стабильность разработанной системы. Задача синхронизации копий данных трудоемка.
Для того, чтобы обрабатывать запросы пользователя в фоне, мы сделали сохранение текущего состояния обновления в файлике на диске прямо в сервисе. Все работает, но однажды, в момент обновления, сервис падает, и мы теряем это состояние. Поэтому, мы так никогда не делаем. Мы считаем, что у сервисов нет никакого состояния и не сохраняем ничего на файловую систему.
Также, мы не делаем механизмов обмена внутренними состояниями между экземплярами сервиса, так как эта схема является хрупкой. Мы не можем гарантировать консистентность между инстансами.
Выкатка сервиса в любое окружение не проходит мгновенно, а иногда может занимать часы. Поэтому мы всегда пишем миграции для баз данных так, чтобы текущая и новая версия сервиса могли работать и с текущим и новым состоянием базы.
Например, мы написали сервис, который имеет особенную схему шардирования в базу данных. Такие особенности мы всегда описываем в README.md в сервисе.
Общение сервисов лучше делать асинхронным, так как это снимает необходимость ожидания со стороны клиента. Асинхронное взаимодействие также позволяет не делать сложной логики перепосылки запросов, реализации graceful degradation. Можно развязывать большое количество прямых связей. Поэтому, там где возможно, мы отдаем предпочтение асинхронному взаимодействию. Однако в некоторых случаях синхронное взаимодействие необходимо (например, когда пользователю нужно отдать результат сразу).
Мы не рассчитываем, что сервис, с которым мы интегрируемся, будет всегда надежно работать. Если он упадет, наш сервис продолжит работу. Мы продумываем полную вероятную деградацию своей системы и пути восстановления. Пример. Мы занимаемся отрисовкой страницы объявления. Мы идем в сервис избранного, и он нам не отвечает. Вместо того, чтобы отдать ошибку сразу на все, мы просто игнорируем и не рисуем плашку с избранным и отправляем метрику, чтобы понять что что-то пошло не так. Так мы поступаем везде. Даже в случае отсутствия нужной информации для формирования ответа, мы можем отдать заглушку. Посоветоваться о том, как лучше реализовать такую логику, можно с продуктовым менеджером.
Допустим, нам нужны описания объявлений для определенного пользователя, чтобы узнать его настроение. Мы могли бы запросить полный набор параметров объявления (их может быть несколько десятков). Но мы так не делаем, чтобы в будущем сервис объявлений мог проще обновлять свой интерфейс и не прокачивать по сети полные объекты.
Чем больше проверок мы доверяем роботам, тем больше у нас времени на действительно полезные вещи. Если какое-то изменение нельзя протестировать автоматически, нужно переделать реализацию, это нормально. Например, мы можем реализовать какую-нибудь жесткую связку с внешним API, которую невозможно будет толком проверить. Это может обойтись нам слишком дорого в отладке и при последующих изменениях. Поэтому мы думаем о том, как это протестировать ещё до того, как начнем писать код.
Мы делаем все, чтобы наши пользователи были счастливы, используя Авито. Мы проверяем все наши изменения перед выкаткой, и на большую часть изменений пишем тесты, чтобы не утонуть в бесконечных фиксах и жалобах.
Мы всегда знаем, какие проблемы у нас есть, и не копим их в бэклоге. По каждому багу мы сразу принимаем решение: чиним сейчас, в порядке очереди или никогда (закрываем задачу), если это вообще не баг — превращаем его в задачу.
Разработчик знает как работает сервис, настраивает на него dashboard’ы и ставит алерты на потенциальные деградации. В итоге разработчик всегда может сказать, все ли хорошо в данный момент с сервисом.