Microservices Thoughts – Telegram
Microservices Thoughts
7.75K subscribers
32 photos
55 links
Вопросы и авторские статьи по микросервисам, архитектуре, БД

Сотрудничество: t.me/qsqnk
Download Telegram
Почему батчевые update/delete не безопасны

Конкурентно исполняющиеся стейтменты вида

update entity set … where id in (1, 2)


могут приводить к дедлокам.

Почему так происходит? Чтобы произвести обновление или удаление, обычно производится следующий набор операций:

1. Выборка записей, подходящих под условие where
2. Блокировка записей
3. Повторная проверка условия + исполнение самого update/delete

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

tx1: lock(1) _____ lock(2) _____
tx2: _____ lock(2) ______ lock(1)


Наиболее простым решением будет явное взятие блокировок в нужном порядке перед апдейтом:

begin;
select * from entity where id in (1, 2) order by id for update;
update entity set … where id in (1, 2);
commit;
👍50🔥15🤯1
Sharing data between services

Проблема:

Чтобы сервису X выполнить запрос, ему нужно получить данные из сервиса Y, который нестабильно/долго отвечает.

Решение:

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

Плюсы:
- уменьшается связность между сервисами, поскольку в случае отказа сервиса Y, сервис X продолжит работать

Минусы:
- сервис Y обязан публиковать события о своих обновлениях
- “лаг репликации” - в локальном view какое-то время могут быть неактуальные данные
👍34🔥5
Conflict-free replicated data type

Проблема:

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

Решение:

CRDT - структура данных, реплицированная на несколько узлов, обладающая следующими свойствами:

- приложение может обновлять реплику без координации с другими репликами
- в случае конфликтов структура данных гарантированно умеет их разрешать
- в конечном счете все реплики будут согласованы

Разделяют operation-based и state-based типы: в одном случае рассылаются лишь обновления структуры данных, в другом - цельное состояние структуры.

Рассмотрим operation-based.

Принцип работы следующий:

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

Ограничения на функцию update:

- либо гарантировать порядок доставки update-ов, либо гарантировать, что update_1(update_2(state)) = update_2(update_1(state)), иначе говоря, функции update_1 и update_2 должны коммутировать
- если update может быть доставлен несколько раз, то он должен быть идемпотентным, то есть update(state) = update(update(state))

Собственно, именно подобные ограничения и вызывают сложности. По многим типовым структурам данных написаны пейперы, про json, например, это.
👍13🔥5
Saga Orchestration

Saga - паттерн, позволяющий проводить “eventually-атомарные” распределенные транзакции. То есть, в конце концов части транзакции либо выполнятся на всех сервисах, либо нигде.

Saga Choreography - разновидность, где нет централизованного компонента управления, и сервисы координируют работу друг друга с помощью событий.

Saga Orchestration - наоборот, координирует выполнение транзакции с помощью одного централизованного компонента. Обычно это реализуется так:

- В окестратор прилетает запрос на исполнение транзакции

- В базе оркестратора создается стейт-машина, описывающая какие шаги были выполнены

- После выполнения очередного шага, записываем результат в базу

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

- Если отмена невозможна, загорается мониторинг, и человек вручную разбирается

Из плюсов:

- Простота такого подхода сильно выше нежели у хореографии

- Хорошо подходит для сложных сценариев

- Сервисы могут совсем не знать друг про друга

Минусы:

- Оркестратор является единой точкой отказа
🔥28👍7
Reverse Proxy

Тип прокси-сервера, который стоит перед группой серверов, и обеспечивает, что все запросы, адресованные этим серверам, проходят через него.

Возможные применения:

- Балансировка нагрузки: прокси будет равномерно распределять нагрузку на стоящие за ним серверы

- Rate-limiting: например, ограничения общего числа запросов в секунду (1k RPS) и ограничение числа запросов от одного ip адреса (20 RPS)

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

- Производить TLS шифрование/дешифрование, снимая нагрузку с целевых серверов

Без reverse-proxy пришлось бы дублировать всю эту логику на каждом сервере, а также раскрывать внутреннюю структуру сети, чтобы клиенты могли делать запросы напрямую целевым серверам.
👍30🔥4💅3
Asynchronous Request-Reply pattern

Проблема:

На бэкенде есть асинхронное апи запуска какой-то долгой операции, а клиент хочет получить результат этой операции. Делать апи синхронным - не вариант с точки зрения архитектуры.

Решение:

Использовать поллинг на клиенте:

- Клиент идет в апи запуска операции

- Бэкенд отдает operationId

- Клиент раз в какое-то время идет в эндпоинт проверки статуса операции по operationId

- Когда операция завершится, бэкенд отдает uri, по которому можно найти результат операции (либо клиент сам знает, куда сходить)

Это один из самых простых вариантов решения проблемы на чистом http в случае если, например, в проект не хочется затаскивать вебсокеты.
👍33🔥3💅3
Поддерживаете ли вы порядок отправки сообщений из outbox таблицы?

Если да, то будет классно, если в комментах напишите, как у вас это реализовано
Anonymous Poll
40%
Да
60%
Нет
🔥6🤔4💅3
Soft-delete pattern

Паттерн удаления, при котором происходит не удаление записи из БД, а проставление метки deleted_at = now() (либо status = ‘INACTIVE’):

delete from entity
where id = 1;

Заменяется на

update entity
set deleted_at = now()
where id = 1;


Обычно этот паттерн позиционируется как способ “более безопасного” удаления, тк мы можем легко восстановить записи, но зачастую на практике такого применения он не находит.

Более реальные юзкейсы:

- Отмена операции удаления: например, в UI после совершения удаления в течение 5 секунд можно отменить операцию. В случае c soft delete - это будет просто изменением статуса

- Историчность какой либо сущности: вместо update делаем soft delete старой версии и создание новой

Недостатки:

- Большинство запросов обычно оперирует с активными сущностями, поэтому придется во всех них добавлять условие на deleted_at is null

- Сложнее поддерживать согласованность данных. В случае дефолтного удаления мы можем ее гарантировать с помощью внешних ключей. В случае мягкого удаления у нас могут возникать ситуации, когда ACTIVE сущность ссылается на INACTIVE. Это решается либо более сложными внешними ключами, включающими статус, либо гарантиями со стороны приложения
👍35🔥4💅3🤔1
Keyset pagination

Проблема:

Пагинация через order by + limit + offset

select *
from events
order by id desc
limit 10 offset 1000;


Начинает работать медленно при большом оффсете, поскольку мы будем по-честному проходить по этой тысяче записей, прежде чем выбрать 10, которые нам нужны.

Решение:

Использовать Keyset pagination, смысл которой заключается в том, что offset заменяется на условие

id < <последний id предыдущей страницы>

Рассмотрим на примере:

Первая страница:
select *
from events
order by id desc
limit 10
Допустим, последний id был 253, тогда вторая страница:
select *
from events
where id < 253
order by id desc
limit 10
Третья страница:
select *
from events
where id < 243
order by id desc
limit 10

И так далее.

Запрос, сформированный таким образом, позволяет базе данных с помощью индекса на id сразу найти первую запись, где id < <последний id предыдущей страницы> и далее просто пройтись по индексу, взяв первые 10 записей.
🔥40👍11🤯8🤔1💅1
Structured logging

Проблема:

По логам в виде plain текста тяжело осуществлять поиск, фильтрацию.

Решение:

Использовать идею Structured logging:

Вместо того, чтобы записывать лог в виде plain текста

[info] [Friday, 20-Jan-23 11:17:55 UTC] The application has started.


Будем записывать его в виде структуры определенного формата

{
"timestamp": "Friday, 20-Jan-23 11:17:55 UTC",
"level": "info",
"message": "The application has started."
}


Это позволит складывать логи в какую-нибудь БД, например, Elasticsearch, индексировать и эффективно производить поиск и фильтрацию.
🔥26💅6👍4🤯3🤔1
Durable executions

Представим, что есть некоторая задача

workflow {
firstCall()
sleep(1 hour)
secondCall()
sleep(1 hour)
thirdCall()
}


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

Именно на этом примере можно описать концепцию Durable executions:

После того, как мы сделали firstCall(), мы не будем ждать, а сделаем следующее:

- Сохраним в состояние задачи, что мы уже сделали firstCall()
- Зашедулим ее на +1 час

Спустя час какой-то другой воркер сможет взять эту задачу и продолжить с прыдыдущего “чекпоинта”. То есть мы сохраняем прогресс по определенным частям задачи, что позволяет переживать отказы воркеров и позволяет исполнять задачу по частям разными воркерами.

И сейчас существует довольно много Workflow engines, которые строятся на этой концепции.
👍28🔥8💅6
Temporal

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

Temporal cluster состоит из следующих компонентов:

- Frontend gateway: авторизация, rate limit

- History subsystem: хранит состояние задач в БД

- Matching subsystem: управляет очередьми задач

- Worker Service: исполняет внутренние (не пользовательские) фоновые процессы

❗️Пользовательские workflow исполняются не temporal кластером, а внешними пользовательскими воркерами. Temporal лишь координирует их работу.

Возможности:

- Обработка распределенных транзакций
- Запуск задач по крону
- Исполнение стейт машин
- И еще некоторые вещи

Как описываются задачи:

Activity - некоторое атомарное действие, например, вызов API или запрос в базу. Это действие должно быть идемпотентным, чтобы, например, в случае ретраев из-за сетевых проблем не сделать некоторое действие дважды.

Workflow состоят из вызовов activity и некоторой дополнительной логики. И как раз в местах вызова activity сохраняются промежуточные результаты workflow.

Всё это описывается с помощью SDK на стороне приложения, на текущий момент существуют реализации для Go, Java, PHP, Python, Typenoscript, .NET. Посмотреть пример описания workflow можно тут.
👍14🔥8💅5
Caching patterns

При работе с кешом возникает вопрос “в какой момент нужно синхронизировать данные из бд и кеша?”. Рассмотрим три часто встречающихся паттерна:

1. Read-aside caching

Наиболее простой и часто используемый паттерн.

Как происходит чтение:
- Пробуем достать данные из кеша
- Если не получилось, идем в БД, складываем в кеш

Как происходит запись:
- Просто пишем в БД

2. Write-aside caching

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

Как происходит чтение:
- Пробуем достать данные из кеша
- Если не получилось, идем в БД, складываем в кеш

Как происходит запись:
- Пишем в БД
- Пишем в кеш

3. Full caching

Кеш (обычно по крону) сам себя обновляет, подгружая все необходимые данные из БД. Подходит для случаев, когда хотим добиться ~100% hit rate, и данные целиком влезают в кеш.

Как происходит чтение:
- Просто читаем из кеша

Как происходит запись:
- Просто пишем в БД
👍35🔥7💅32
Пара подходов к описанию деревьев в реляционной БД

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

1. id + parent_id

create table folders
(
id bigserial not null,
parent_id bigint null references folders (id),
data jsonb not null
);


Самый простой подход, при котором храним идентификатор родительской сущности (либо null, если сущность и так корневая).

Плюсы:
- Простота модели
- Простая вставка
- Простой перенос поддерева

Минусы:
- “Дерево” может стать не деревом - МД не запрещает циклические ссылки
- Рекурсивные запросы, чтобы доставать поддерево по id корня

2. id, path + parent_id, parent_path

Помимо id добавляется path - путь по айдишникам до текущей сущности. Это нам позволяет сделать более интересные констрейнты и гарантировать, что мы имеем дело реально с деревом.

create table folders
(
id bigserial not null,
path varchar not null,
parent_id bigint null,
parent_path varchar null,
data jsonb not null

constraint ck__folders__path
check (path = coalesce(parent_path, '/') || id || '/'),

-- нужен для FK
constraint uc__folders__path__id
unique (path, id),

constraint fk__folders__parent_id__parent_path
foreign key (parent_path, parent_id) references folders (path, id)
match full
);


Здесь мы гарантируем, что
1) path корневой папки - это /<id>/
2) path некорневой папки - это <parent_path><id>/

Что позволяет обеспечить отсутствие циклов

Плюсы:
- Гарантия отсутствия циклов
- Простой запрос поддерева - where path like ‘/1/2/3/%’

Минусы:
- Сложность модели
- Более сложная вставка
- Сложный перенос поддерева
👍34🔥33🤔21
Token bucket rate limiting

Один из алгоритмов для ограничения числа входящих запросов. На данный момент используется во многих системах, например, AWS.

Принцип работы:

Есть ограниченный набор токенов, наличие токена = право исполнить запрос.

Когда приходит входящий запрос, сначала проверяем, есть ли свободный токен, если есть - забираем его и исполняем запрос, если нет - отдаем 429.

И поскольку токены назад не возвращаются, их надо как-то восстанавливать. Этим занимается фоновый процесс, который по крону (обычно раз в секунду) восстанавливает число свободных токенов до максимума.
👍33🔥72💅1
Матчится ли контент с названием канала?)
Anonymous Poll
60%
Да
40%
Нет
🔥5221
Continuous profiling

Continuous profiling - техника постоянного сбора данных о ресурсах, затрачиваемых приложением

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

Постоянно профилирование позволяет строить графики и анализировать ретроспективные данные по затратам CPU, RAM и т.п. И одна из наиболее полезных вещей - построение flame graphs, которые позволяют посмотреть, на что именно приложение тратило память или процессор за определенный промежуток времени (например, во время какого-то инцидента)

Из популярных тулов для этих целей существует Grafana Pyroscope, который умеет запускаться как из приложения, так и отдельным агентом. Далее он собирает метрики и отливает их в графану, которая уже строит красивые графики
🔥17💅5👍4111
Каскадное удаление за один запрос

Нет, речь не про on delete cascade

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

Но как тогда быть, если у нас достаточно глубокая иерархия сущностей, например

a <- b <- c

и хочется по a_id удалить все связанные b и c?

create table a (
id bigserial not null primary key
);

create table b (
id bigserial not null primary key,
a_id bigint not null references a (id)
);

create table c (
id bigserial not null primary key,
b_id bigint not null references b (id)
);


Наивное решение:

- поселектить из b по a_id
- удалить записи из c по полученным b_id
- удалить записи из b
- удалить записи из a

В итоге имеем 4 раудтрипа до базы и сложную логику, которая с ростом иерархии будет выглядить еще более громоздкой

Решение через CTE:

with a_deleted as (
delete from a where id = <a_id> returning *
), b_deleted as (
delete from b where a_id in (select id from a_deleted) returning *
) delete from c where b_id in (select id from b_deleted)


Почему это работает? CTE трактуется как единый стейтмент, соответственно все проверки констрейнтов будут осуществляться только после его полного выполнения

Это нам позволяет удалить все связанные с таблицей a сущности за один раундтрип до базы, и при необходимости легко масштабировать этот подход, просто добавляя новые строки в CTE, если иерархия вырастет

Ставьте 🔥 на этот пост, если нужен рассказ про еще один интересный вариант применения CTE в контексте внешних ключей
🔥201👍3💅1
Взаимно-рекурсивные внешние ключи

Самый частый юзкейс:

- Есть главная сущность
- Есть дочерние сущности
- Главная сущность ссылается на конкретную дочернюю

Пример: у вопроса может быть несколько ответов, но лишь один корректный:

create table questions (
id bigserial not null primary key,
correct_answer_id bigint not null,
question varchar not null
);

create table answers (
id bigserial not null primary key,
question_id bigint not null,
answer varchar not null
);

alter table questions
add foreign key (correct_answer_id) references answers (id);

alter table answers
add foreign key (question_id) references questions (id);


Как тут могут помочь CTE:

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

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

with question_id as (select nextval('questions_id_seq')),
answer_id as (
insert
into answers (answer, question_id)
values ('Some answer', (select * from question_id)) returning id)
insert
into questions (id, correct_answer_id, question)
values ((select * from question_id), (select * from answer_id), 'Some question’);


Как вы считаете, стоит ли усложнять модель данных в БД взаимно-рекурсивными ключами, или подобные инварианты должны поддерживаться бизнес-логикой?
🔥21🤯10👍7💅333