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

Сотрудничество: t.me/qsqnk
Download Telegram
⚡️Tail latency и hedged запросы

Некоторым системам важно иметь почти реалтаймовые ответы (~100мс). Например, саджесты в поиске: когда вы набираете очередной символ, то ожидаете очень быстро получить новый список вариантов

При этом зачастую происходит так:
- Среднее время запроса — 100мс
- Время запроса в p99 — 1 секунда

Эти задержки на больших перцентилях (p95, p99, p99.9) называются tail latency. Они могут происходить абсолютно по разным причинам от конкуренции за ресурсы на определенной машинке до сетевых проблем

---

Один из способов борьбы с такими задержками — техника hedged requests

Пусть у нашей системы p90 = 100ms, p99 = 200ms

На больших данных, считаем что
- Вероятность ответа >100ms = 0.1
- Вероятность ответа >200ms = 0.01

1. Посылаем первый запрос
2. Если спустя 100мс не получили ответ, посылаем второй запрос


0ms 100ms 200ms
rq1 |-------------|-------------->|
rq2 | |-------------->|


Итого получаем (считая что запросы независимые), вероятность, что такая совокупность не уложится в 200ms = (вероятность, что первый не уложится в 200ms) * (вероятность, что второй не уложится в 100ms) = 0.01 * 0.1 = 0.001

Таким образом, вероятность ответить дольше 200ms стала 0.001, а значит p99.9 = 200ms

А вероятность, что придется отправлять второй запрос = вероятность, что первый отвечал >100ms = 0.1. То есть в 10% случаев придется слать два запроса

---

Таким образом, увеличив нагрузку на систему на 10% мы смогли перейти от p99 = 200ms до p99.9 = 200ms, то есть уменьшили "вероятность таймаутнуться" в 10 раз
🔥76👍16🤔113🤯3💅11
⚡️Как жить с нестабильной интеграцией

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

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

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

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

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

2. Если ваш сервис только отправляет данные во внешнюю систему

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

3. Если ваш сервис отправляет данные во внешнюю систему и ожидает чего-то в ответ

Пример: создание сущности во внешней системе и получение id в ответе

Пишите в комментах, какие варианты повышения стабильности здесь видите
👍28🔥9💅32
⚡️Загадка про постгрес

1. Есть табличка с колонками a, b

create table tbl
(
a bigint,
b timestamp
);


2. Есть 10 млн записей, примерно у половины записей в колонке a лежит null

3. Есть btree индекс на (a, b)

Угадайте план запроса
select *
from tbl
where a is null
order by b
limit 1;


Верно, секскан + сортировка

---

Как выяснилось (после инцидента), постгрес не умеет в фильтрацию по null/is null + сортировку одновременно. Если только фильтрация — будет ок. Поэтому для такого кейса нужно создавать отдельный частичный индекс on tbl (b) where a is null;

proof: https://dbfiddle.uk/Bgob-orb
🤯52👍34🤔7💅33
⚡️Какие-то мысли про Redis Streams

Недавно возникла необходимость в простенькой очереди, чтобы на запрос быстро отвечать двухсоткой и далее асинхронно обрабатывать. Решили поэкспериментировать с redis streams, тк тащить что-то кафкоподобное казалось оверкиллом, а редис можно поднять за полчаса, и он устраивал нас по гарантиям/нагрузке/etc

Как оно работает (см картинку):

1. Сообщение добавляется в стрим через XADD
2. Читаем сообщение из стрима и "назначаем" на определенного консюмера из определенной группы консюмеров через XGROUPREAD
3. После того как сообщение прочитано оно попадает в PEL (Pending Entries List) — список прочитанных, но не обработанных сообщений консюмера
4. Консюмер делает XACK на сообщение, оно помечается обработанным, т.е. удаляется из PEL

Что уже можно сказать — стрим больше похож не на топик в кафке, а на конкретную партицию. При том в redis stream из этой партиции могут конкурентно брать сообщения несколько консюмеров, т.е. порядок не обязательно будет соблюдаться

Общее правило такое:

- 1 stream, 1 consumer: соблюдается порядок
- 1 stream, n consumers: не соблюдается порядок, конкурентная обработка
- n stream, n consumers: "масштабированный" первый вариант. Для каждого из n стримов есть консюмер, который в одиночку из него вычитывает. Т.е. некоторые изобретение партицированного топика на коленке

Обработка ошибок:

После чтение консюмер может по разным причинам умереть и недообработать сообщение. Эта проблема решается поллингом PEL с помощью XPENDING и переназначением зависших сообщений на другого консюмера через XCLAIM. Что мне показалось странным с точки зрения дизайна — нельзя просто вернуть сообщение в "общую очередь", нужно переназначить его именно на конкретного консюмера

---

Overall кажется, что redis streams хорошо подходят для ситуаций, где нужна
- небольшая и быстрая in memory очередь
- сообщения редко могут теряться (специфика редиса, см пост)
- порядок обработки либо не важен, либо вы готовы заморочиться и вручную реализовать партицирование
👍223💅11
⚡️Про что подумать, когда пишете свой outbox

Вместо того чтобы делать

dbUpdate()
sendToBroker()


Мы сохраняем сообщение в бд в рамках транзакции

tx {
dbUpdate()
dbSaveMessage()
}


И фоновый воркер допушит это сообщение до брокера

---

Что мы обычно хотим от аутбокса:
- Низкое latency проброса от бд до брокера
- Сохранение порядка сообщений для бизнес-сущностей
- Возможность масштабировать пропускную способность

Оговорка: можно взять готовый CDC типа Debezium, который читает лог базы. Однако надо помнить, что это дополнительный компонент в вашей инфре, который надо поддерживать + дополнительная точка отказа + нужна доп экспертиза "а че там может сломаться". Иногда это ок, иногда не ок

---

Далее посмотрим, как можно рассуждать на пути к целевому решению, если вы делаете аутбокс сами

Идея 1: несколько воркеров поллят записи через select for update skip locked

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

Идея 2: однотредово забираем пачку записей с order by

Поскольку у предыдущего решения нет упорядоченности, давайте ее добавим. Теперь нарушение порядка невозможно, потому что записи достает только один тред. Масштабируемость и низкое latency — спорно. При рейте в 10к+ сообщений в секунду один тредик может перестать справляться

Идея 3: попробуем распараллелить предыдущий вариант

Давайте при сохранении сообщения в базу сразу указывать "виртуальную партицию" так, чтобы два эвента по одной бизнес-сущности были в одной партиции (entity_id % partition_number). Так мы сможем запустить предыдущую однотредовую логику для каждой партиции, чем добьемся масштабируемости

Но: представим, что мы захотим увеличить число виртуальных партиций с 5 до 7. Сначала сущность с id = 123 попадала в партицию 123 % 5 = 3, после увеличения будет попадать в 123 % 7 = 4. То есть в момент масштабирования сообщения по одной бизнес-сущности могут начать обрабатываться параллельно в партициях 3 и 4, из-за чего нарушится порядок

Идея 4: добавляем маппинг entity_id <-> partition_id

Берем идею 3 и добавляем явный маппинг между id бизнес сущности и id партиции. Это позволит бизнес-сущность "прилеплять" к определенной партиции. И в момент масштабирования числа партиций у нас ничего не поломается, потому что у нас было зафиксировано, что сообщения по entity_id = 123 попадают в партицию 3

Здесь также надо предусмотреть удаление маппингов по ttl. Скажем, если неделю по одной бизнес-сущности не поступало сообщений, то консюмеры предыдущие сообщения скорее всего обработали, и маппинг можно безопасно дропнуть

В итоге такое решение удовлетворяет всем требованиям
- Низкое latency
- Сохранение порядка
- Возможность безопасно масштабироваться

---

Вывод: написать хороший аутбокс — задача явно не простая. При этом такие изощрения точно нужны не всегда. Зачастую либо нагрузка низкая (<100 сообщений в секунду), либо порядок не требуется. Эти вводные могут существенно упростить итоговое решение
👍46🔥14💅22
⚡️Ускорение индексации денормализованных документов

В посте про подходы к поиску иерархичных данных был небольшой рассказ про денормализацию — это когда есть родительский документ { ticket } и есть дочерние { article_1 }, { article_2 }, { article_3 }. И далее мы уплощаем эту структуру в три документа, где child-ы обогащены данными от родителя

Псевдокод:
{ ticket, article_1 },
{ ticket, article_2 },
{ ticket, article_3 }


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

Какие есть способы ускорить такую индексацию в elasticsearch:

0. Наивный неэффективный способ

Получили эвент обновления ticket => запрашиваем все артиклы => строим n документов для обновления артиклов => делаем n запросов в эластик

Если у тикета 500 артиклов, то к примеру при изменении статуса тикета, нам нужно будет сделать 500 запросов на переиндексацию — звучит крайне неэффективно

Псевдокод:
query 1: { ticket: { all fields }, article { all fields }
query 2: { ticket: { all fields }, article { all fields }
query 3: { ticket: { all fields }, article { all fields }
...


1. Использование bulk update + partial update

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

Вторая проблема — что приходится передавать полный документ, хотя у него изменился маленький кусочек. Эластик поддерживает partial update, и поэтому можно передать только тот кусок документа, который изменился

Псевдокод:
query: [
{ article_id: 1, ticket: { changed fields } }
{ article_id: 2, ticket: { changed fields } }
{ article_id: 3, ticket: { changed fields } }
...
]


Важное НО: эластик делает shallow partial update, т.е. вложенные объекты обновляются целиком сразу, а не частично. К примеру, если бы мы захотели обновить вложенное поле ticket.custom_fields.some_field, то нам мы пришлось полностью передать объект ticket. В противном случае мы бы потеряли данные

2. Использование update_by_query + noscript

Нивелируем проблемы предыдущего решения, а именно: shallow апдейты и то, что мы можем заранее не знать id-шники всех дочерних документов

Для этой цели есть update_by_query, который позволяет обновить документы, которые соответствуют некоторому условию. И обновить их с помощью скрипта

Псевдокод:
query: {
condition: ticket_id = 123,
noscript: ticket.custom_fields.some_field = "abc"
}


Такой подход идеально подходит для переиндексации дочерних документов, и позволяет делать максимально компактные запросы и обеспечивать гибкость с помощью кастомных скриптов (по дефолту на внутреннем языке эластика — painless)
👍17
⚡️Expand and contract (zero downtime migrations)

Когда вам нужно мигрировать приложение на новую схему данных, у вас есть два пути

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

Второй вариант — страдания

Для таких миграций без даунтайма есть весьма понятный подход, называемый expand and contract

Посмотрим на примере:
Есть — одна колонка в бд содержащая и имя, и фамилию пользака user = "Walter Black"
Хочется — две отдельные колонки name = "Walter", surname = "Black"

1. Добавляем новые нуллабельные колонки

Отдельный релиз — накатываем миграцию схемы данных

2. Начинаем писать и в старую, и в новую схему

Отдельный релиз — при апдейтах/инсертах user = "Walter Black" также проставляем name = "Walter", surname = "Black"

3. Мигрируем старые данные

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

4. Начинаем читать из новой схемы

Отдельный релиз — в коде приложения начинаем читать данные не из старой колонки, а из новых

5. Выпиливаем запись в старую схему

Отдельный релиз — перестаем писать в старую колонку

6. Выпиливаем старую схему

Отдельный релиз — миграция, которая дропает старую колонку user

---

Итого: за 5-6 релизов получаем безопасную миграцию схемы данных. Важно, что здесь при неудачном релизе мы всегда можем безопасно его откатить. Конечно, такие жесткие гарантии требуются не всегда, и зачастую происходит слияние каких-то шагов, если допустим короткий даунтайм

Шаги в виде картинок в комментах
🔥41👍27💅21
⚡️Cache stampede

Представим, код метода getData() выглядит так

def getData():
dataFromCache = getFromCache()
if dataFromCache:
return dataFromCache
dataFromDb = getFromDb()
updateCache(dataFromDb)
return dataFromDb


Что произойдет если два потока почти одновременно не найдут данные в кеше? Пойдут загружать из БД

А если 100 потоков?

Понять масштабы можно на таком примере:
- В кеш может поступать до 300 запросов в секунду
- Время загрузки из бд — 2 секунды

И получается, если ключ в кеше протухнет, то за первые две секунды в базу попрутся 600 параллельных тяжелых запросов. Учитывая, что из-за этих запросов БД может деградировать, запросы могут начать выполняться еще дольше

---

Что с этим делать?

1. Локи

В примере выше хорошо бы зашел double-checked locking

def getData():
dataFromCache = getFromCache()
if dataFromCache:
return dataFromCache
withLock {
dataFromCache = getFromCache()
if dataFromCache:
return dataFromCache
dataFromDb = getFromDb()
updateCache(dataFromDb)
return dataFromDb
}


Можно конечно сразу лочить, но тогда для подавляющего большинства запросов добавится лишний оверхед. А в случае дабл чека будет лочиться только когда кеш протух

2. Прогрев кеша / фоновые апдейты

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

3. Вероятностные рефреши

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

---

Пишите в комментах, что из этого используете)
👍43🔥71🤔1
System Design & Highload (Alexey Rybak)

Если вы ищете практический канал про Highload, БД, System Design, то вам точно стоит заглянуть в канал к Алексею — ex-CTO Badoo, а сейчас основателю R&D-платформы devhands.io и сооснователю софта для автоматизации перфоманс ревью teamwork360.io

Он делится на своём канале уникальными мыслями и опытом работы с реальными высоконагруженными системами, сравнивает перфоманс БД, описывает некоторые теоретические аспекты

Список постов которые лично мне понравились у него:

- Простое объяснение CAP теоремы
- Про блокировки в СУБД
- Почему важно замерять P99
- Один день из жизни CTO
- 1.000.000 RPS на PostgreSQL и MySQL
- Нужен ли BFF?

Ставьте 👍, если подписались
👍31🤔3
⚡️Почему из-за долгих транзакций могут тормозить другие запросы

Представьте, что у вас есть табличка с колонкой created_ts. И на эту таблицу есть некоторый TTL — фоновый воркер в порядке created_ts удаляет записи, которые старше 7 дней

Посмотрим, как это заафектит производительность запроса

select * from tbl
order by created_ts
limit 1


1. Сначала все хорошо, запрос идет по индексу на created_ts, берет первую запись

2. Далее начинается долгая транзакция с участием tbl

3. Далее блокируется автовакуум tbl

4. Параллельно с этим работает фоновая удалялка. Но поскольку автовакуум заблокирован, удаленные записи просто продолжают висеть как dead tuples

5. Спустя какое-то время набирается несколько тысяч dead tuple-ов

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

И чтобы выполнить запрос

select * from tbl
order by created_ts
limit 1


Нам нужно будет сначала пройти все эти ссылки на мертвые кортежи, и только потом взять первую активную запись. Если таких кортежей наберется под миллион — будет очень больно (основано на реальных событиях)
👍50🔥13💅3🤔2
⚡️Обработка ошибок в консюмерах

Когда консюмер топика пытается обработать очередную пачку сообщений, но получает ошибку — каждый раз возникает вопрос "а че делать?"

Предлагаю такой фреймворк

Отвечаем на два вопроса:
- Критична ли потеря сообщений?
- Критичен ли порядок обработки сообщений?

Сценарий 1: Потеря сообщений НЕ критична, порядок НЕ критичен

Потери не критичны => можем вообще не ретраить

Порядок не критичен => можем обрабатывать сообщения параллельно, можем ретраить в любом удобном формате

В общем, самый простой случай — делайте что хотите)

Сценарий 2: Потеря сообщений НЕ критична, порядок критичен

Потеря сообщений не критична => можем вообще не ретраить

Порядок критичен => обрабатываем все сообщение либо одним батчем, либо последовательно в один поток. Можем ретраить только синхронно, ибо ретраи в фоне могут нарушить порядок

Тоже простой случай — обрабатываем сообщения последовательно (или батчем). Опиционально можем поретраить, но синхронно

Сценарий 3: Потеря сообщений критична, порядок НЕ критичен

Потеря сообщений критична => обязательно нужны ретраи

Порядок не критичен => можем обрабатывать сообщения параллельно, можем ретраить в любом удобном формате

Здесь на что у вас хватит фантазии. Варианты (можно комбинировать):
- Синхронные ретраи
- Retry-topics + DLQ — в случае ошибки в основном топике переписываем сообщение в retry-topic-1. Из него с некоторой задержкой пытаемся обработать, не получилось — пишем в retry-topic-2 и т.д. Если все ретраи не увенчались успехом — пишем в dead letter queue и поджигаем алерт
- Очередь на БД — тоже вполне удобная вещь. В случае ошибки пишем сообщение в очередь на БД и спустя некоторое время обрабатываем. Из плюсов — не надо плодить топики + легко настроить кастомные задержки

Сценарий 4: Потеря сообщений критична, порядок критичен

Потеря сообщений критична => обязательно нужны ретраи

Порядок критичен => обрабатываем все сообщение либо одним батчем, либо последовательно в один поток. Можем ретраить только синхронно, ибо ретраи в фоне могут нарушить порядок

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

Ставьте 👍, если было полезно
👍118🤔3
⚡️Шардирование без решардирования

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

Представьте есть 3 шарда => shard_count = 3

Шард выбирается как entity_id % shard_count

1. Для entity_id = 7 получаем шард 7 % 3 = 1
2. Добавляем новый шард => shard_count = 4
3. Для entity_id = 7 получаем шард 7 % 4 = 3

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

Один из способов уменьшить количество "перераспределений" — consistent hashing. Но можно ли вообще обойтись без решардирования?

---

Функция выбора шарда определяется как

f: (shard_key, shard_count) -> shard_number

Которая по ключу шарда и текущему кол-ву шардов в системе отдает чиселку — номер шарда, где должна лежать сущность

Чтобы не было необходимости в решардинге, что мы хотим от этой функции? Чтобы при добавлении нового шарда значение не изменялось, то есть для любого shard_key и shard_count

f(shard_key, shard_count) = f(shard_key, shard_count + 1)

Что в сущности означает, что f(shard_key, shard_count) = f(shard_key, shard_count + 1) = f(shard_key, shard_count + 2) = ...

То есть как будто функция вообще никак не зависит от shard_count, что странно

---

Возможно ли такое, если функция чистая (т.е. не делает сайд-эффектов и использует только переданные аргументы shard_key и shard_count)?

Возьмем f(shard_key, 1), который всегда константно равен 0 (потому что shard_count = 1)

Но отсюда следует, что 0 = f(shard_key, 1) = f(shard_key, 2) = f(shard_key, 3) = ... То есть такое возможно, если функция всегда будет отдавать 0, что очевидно нам не подходит

---

Значит функция должна делать какие-то сайд-эффекты

И самый простой сайд-эффект, который позволяет этого достичь — это getOrPut в хранилище маппингов entity_id <=> shard_number

Пусть снова shard_count = 3

1. Пусть entity_id = 7 => shard_number = mappings.getOrPut(7, 7 % 3) = 1
2. Добавляем новый шард => shard_count = 4
3. Для entity_id = 7 получаем шард mappings.getOrPut(7, 7 % 4) = 1 (а не 3, потому что в mappings уже есть запись с таким ключом)

Таким образом это позволяет "прилипать" сущности к определенному шарду, что убирает необходимость решардинга при добавлении новых шардов

p.s.: в качестве оптимизации, чтобы не хранить маппинги для всех entity_id, можно использовать идею с "виртуальными бакетами"

Следующая часть

Ставьте 👍, если нужен пост про виртуальные бакеты в шардировании
👍160🔥2
⚡️Принцип работы snapshot isolation (aka repeatable read) в postgres

Изоляция repeatable read избавляет от неповторяющегося чтения — ситуации, когда одна и та же строка запрашивается дважды в рамках транзакции, но результаты чтения получаются разными

begin;

select * from t where id = 1 <- отдает одно значение

-- другая транзакция обновляет запись

select * from t where id = 1 <- отдает уже другое значение
...


Как это работает в postgres:

- Каждой транзакции присваивается xid — монотонно возрастающий идентификатор транзакции
- MVCC: одновременно поддерживаются несколько версий строк
- У каждой версии строки есть два системных поля: xmin, xmax

xmin — идентификатор транзакции, который создал версию строки
xmax — идентификатор транзакции, который удалил версию строки (т.е. сделал update либо delete)

---

Отсюда возникает довольно логичная концепция — при начале repeatable read транзакции "берем снапшот":

1. Назначаем текущей транзакции некоторый xid
2. В транзакции работаем только с версиями строк, где
- либо xmin < xid < xmax — версия строки создана до текущей транзакции, а удалена уже после начала текущей
- либо xmin < xid && xmax = 0 — версия строки создана до текущей транзакции, но еще никем не удалена

---

Однако возникает следующая проблема — на момент взятия снапшота может быть активная транзакция с xid меньшим, чем у снапшота. Когда она закоммитится, то для новосозданных строк будет выполняться условие xmin < xid && xmax = 0, и мы в текущей repeatable read транзакции увидим эту версию строки. Хотя при взятии снапшота этой версии еще не было — снова можем получить неповторяющееся чтение

Это решается следующим образом:

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

Таким образом, условие "видимости записей" будет таким

1. Берем снапшот: xid + active_xids
2. В транзакции работаем только с версиями строк, где

(xmin < xid < xmax || xmin < xid && xmax = 0)
&&
(xmin not in active_xids)


Хорошая статья по теме https://mbukowicz.github.io/databases/2020/05/01/snapshot-isolation-in-postgresql.html
👍64🔥23
⚡️3 как не надо

В отпуске совершенно лень писать посты про что-то умное, поэтому держите пост про 3 рандомные ошибки, которые я относительно часто встречаю

1. Надежда, что простой if-чик даст идемпотентность

Встречали когда нибудь такие проверки?

def do_something(idempotency_key) {
if db.exists(idempotency_key) {
return
}
...
}


Такая конструкция очевидно рушится race condition-ом, который рано или поздно произойдет — когда два почти одновременных запроса получат отрицательный результат на db.exists(idempotency_key) и пойдут выполнять логику

2. Слишком широкие границы транзакций

def do_something() {
transaction {
get_from_cache()
call_first_service()
call_second_service()
update_db()
...
}
}


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

3. Пагинация через offset + limit

Довольно закономерное желание сделать пагинацию через такой запрос, база же поддерживает

select * from orders
order by created_at desc
limit 50
offset 100000


Но увы несмотря на наличие индекса на (created_at), база не может за log(offset) найти место, откуда нужно стартовать, и будет честно проходить и скипать эти 100000 строк

🔥 — если нужен пост про "3 как надо"
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥528💅61
⚡️Пара способов, как обеспечить идемпотентность

В продолжение к https://news.1rj.ru/str/MicroservicesThoughts/144

Возьму за пример предметную область, где щас работаю:

1. Есть ticket — обращение пользователя в поддержку
2. Есть article — одно сообщение в рамках обращения

Хочется идемпотентно выполнять операцию addArticle(ticketId), чтобы в переписке не было дублей сообщений из-за сетевых проблем и т.п.

1. Через ключ идемпотентности + unique constraint

В запрос на добавление article добавляется параметр idempotency_key

В той же транзакции, где делаем insert into article, мы делаем insert в таблицу с ключами идемпотентности — у этой таблицы должен быть unique constraint

begin;

insert into idempotency_keys; // ошибка если уже существует

insert into article;

commit;


Атомарность — либо оба insert-а выполнятся, либо никакой
Ограничение unique constraint — insert в таблицу с ключами выполняется не более одного раза

Складывая эти факты, получаем что то что нужно: insert в таблицу article выполняется не более одного раза

Вариации:
1.1. Ключ идемпотентности может лежать не в отдельной таблице, а просто как колонка в article
1.2. Можно делать insert on conflict do nothing, чтобы обойтись без эксепшнов

2. Через оптимистические блокировки

В запрос на добавление article добавляется параметр ticket_version

В той же транзакции, где делаем insert into article, мы проверяем что в бд лежит действительно та версия ticket, которую мы хотим обновить. Если это не так, то кидаем ошибку

begin

insert into article;

update ticket
set version = version + 1
where version = {version}
returning *; // из приложения кидаем ошибку, если не смогли произвести апдейт

commit;


Атомарность — либо и insert, и update версии выполнятся, либо не выполнится ничего
Обновление версии — если в бд лежит ticket с ticket_version = 1, то из двух параллельных запросов на обновление версии выполнится только один. Просто потому что бд гарантирует, что не будет аномалии lost update

И снова складывая эти факты, получаем требуемое
👍34🔥8💅7
⚡️Шардирование без решардирования (pt. 2)

В посте https://news.1rj.ru/str/MicroservicesThoughts/138 разобрали, что для шардирования без необходимости решардинга нужно персистентное хранилище маппингов entity_id => shard

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

Очевидная проблема такого подхода — жирная таблица с маппингами, которая к тому же никогда не чистится. Соотв-но с какого-то момента полностью закешировать такое станет невозможно => будет много кеш миссов => походов в базу с маппингами (которая к тому же является spof-ом)

---

И далее идут нюансы

Если у вас autoincremented ids, то эту проблему можно решить достаточно просто — давайте хранить маппинги не для каждой entity_id, а для какого-то ренжа этих entity_id

Получается примерно такая схема (aka range-based mapping)


[10000..19999] -> shard 2
[20000..29999] -> shard 1
...


Правила:
1. Ренжи не пересекаются
2. Если для сущности нет подходящего ренжа, то создается новый

Btw, из приложения можно корректировать, как размазывать данные между шардами просто с помощью длины ренжей. К примеру, для шарда 1 ренжи создаются длиной 5000, а для шарда 2 — длиной 10000. Соотв-но нагрузка будет распределяться примерно как 1:2

---

Пара доводов, почему это может быть ок подходом (или не ок в некоторых случаях):

1. С помощью длины ренжа можно балансировать трейдоф между "стоимость хранения ренжей" и "насколько мы не хотим грузить конкретный шард"

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

Пример 2: сущность долгоживущая. Ренжи по 10к. В таком случае нагрузка уже будет достаточно мягко распределяться по шардам, и не будет burst-ов на конкретный шард

2. Такие ренжи легко закешировать

К примеру, если у вас 1млрд сущностей и ренжи по 10к, то это выльется в 100000 маппингов, которые займут ~5мб, что легко влезает в оперативку приложения

---

А теперь не про autoincremented ids

Тут уже скорее всего без решардинга не обойтись, и все выльется в те самые виртуальные бакеты + решардинг именно между этими виртуальными бакетами (однако еще есть способ с вшиванием id шарда в id сущности)

p.s.: пост предполагался про виртуальные бакеты, но чуть не туда понесло

Предыдущая часть

Следующая часть
👍35💅1
⚡️Шардирование без решардирования (pt. 3)

Возьмем максимально наглые требования

1. Хотим шардирование
2. Логика шардирования описывается внутри приложения и может меняться
3. Добавление новых шардов происходит без решардирования
4. Нет spof-а в виде базы с маппингами entity_id -> shard

---

И несмотря на противоречивость, это вполне себе достижимо

Возьмем отсюда шаги

1. Нам приходит запрос по entity_id
2. Мы идем в хранилище маппингов, выясняем в каком shard лежит этот entity_id

И склеим их в один — внутри идентификатора сущности entity_id уже будет номер шарда, где она лежит

Например, для идентификатора entity_id = 1_765
Номер шарда = 1
Локальный id в рамках шарда = 765

Локальный — в том смысле, что можно использовать локальный для шарда сиквенс, т.е. могут быть айдишники 1_765 (в первом шарде) и 2_765 (во втором шарде)

Всё это убирает необходимость где-то отдельно хранить маппинги — они уже вклеены в id сущности

---

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

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

- Нет spof-а в виде базы с маппингами
- Нет промежуточного шага с выяснением шарда
- Нет решардинга
- Можно делать логику шардирования любой сложности и менять ее в любой момент
- Вполне себе скейлится на огромные объемы

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

Предыдущая часть

🔥 — если было полезно
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥61👍1
Как расти разработчику в компании

А что значит "расти"? Я бы выделил два направления:

1. Рост по хард скиллам
2. Рост по карьере внутри компании

И на практике оказывается, что первое не всегда влечет второе

---

Почему так происходит?

У каждого разработчика есть своя зона ответственности — полянка, за которую он отвечает: в рамках нее задачи делаются в срок и с приемлемым уровнем качества, не плодится техдолг, и в целом "полянка" работает стабильно

И рост по карьере коррелирует именно с размером и сложностью этой зоны ответственности. Понять это можно на таком примере

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

Кого из них повысят, думаю, очевидно

---

Так в чем же проблема просто взять и расширить зону ответственности? А в том, что начинают возникать ситуации, которых раньше не было

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

2. Появляется много мелких задач, которые физически нельзя переварить за один день — здесь поможет навык приоритизации

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

---

И к сожалению, таким вещам обычно не учат — у кого-то они получаются сами по себе, а кому не повезло — не получаются. Закрыть пробелы по таким скиллам поможет канал Андрея — Head of Product Development в Яндекс Лавке. Он рассказывает про то, как себя вести в подобных "менеджерских" ситуациях

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

🔥 — если подписались
🔥44👍5🤔4
Полу-оффтоп к посту выше

Занимательный способ, как проверить растете вы или нет — вновь прочитать статью, которую вы не до конца понимали пару лет назад

У меня так внезапно получилось с https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html, на которую я натыкался еще будучи стажером. Btw рекомендую почитать, статья из серии "как еще больше бояться программировать"

Для владельцев тг каналов есть еще один способ — почитайте свои посты 1-2 годовой давности. Если фейспалмите, то все хорошо
🔥27😁10👍6
⚡️Немного про визуализацию архитектуры

У некоторых людей кубики и стрелочки в миро — основной инструмент для визуалиации архитектуры. В простых случаях с этим нет вообще никаких проблем

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

В таких случаях бывает удобно подробить одну большую диаграмму на несколько меньших

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

C4

Разбивает диаграмму на 4 слоя: системы, контейнеры, компоненты, код (обычно не рисуют, тк часто меняется). Переход с n-го на n+1-ый уровень это "зум" в какой-то кусочек диаграммы. Например, на диаграмме контейнеров выбираем контейнер, смотрим диаграмму компонентов по этому контейнеру

Разумеется, не всегда нужны все 4 слоя, это просто удобный способ обозначить, элементы какого уровня абстракции мы хотим видеть на конкретной диаграмме

Sequence diagram

Это как раз про порядок взаимодействий. На "плоской" C4 диаграмме зачастую сложно понять в какой последовательности кто кого вызывает. Диаграммки последовательностей прекрасно закрывают эту потребность

State diagram

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

---

Если вы как и я не любите собирать диаграммы в визуальном редакторе, то есть https://plantuml.com/ru/, где каждый из типов выше можно просто описать текстом и зарендерить прямо в браузере
👍58🔥6💅3
Прохладная история про то, как легко положить приложение

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

begin;
-- че то поделали 50мс
update;
commit;


Такая транзакция выполняется ~50мс, ничего аномального

И представим, что такие транзакции бьются в одну и ту же сущность
1. rps = 100
2. размер connection pool-а = 500
3. таймаут на получение connection-а = 10с

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

Пропускная способность получается 1000 / 50 = 20 транзакций в секунду => ежесекундно "очередь на блокировку" будут увеличиваться на 100 - 20 = 80 транзакций

То есть наш пул в 500 соединений через 500 / 80 = 6.25 секунд полностью забьется (даже не дошли до connection timeout-а) => приложение будет пятисотить / дольше отвечать, ожидая коннекшна

---

Че с этим делать? (пункты не упорядочены)
- Смотреть на бизнес логику, какого хера по одной сущности идет 100rps
- Тюнить connection timeout
- Ставить нормальную очередь перед апдейтами
- Распред локи (чтобы ждали лока в условном редисе, а не занимали конекшн)
- rps-лимитер
- отдавать 4xx, если не получается сразу взять лок на сущность
👍74💅7