Microservices Thoughts
6.51K subscribers
25 photos
36 links
Вопросы и авторские статьи по микросервисам, архитектуре, БД

По сотрудничеству: t.me/qsqnk

Контент по Kotlin/Java: t.me/KotlinThoughts
Download Telegram
⚡️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 релизов получаем безопасную миграцию схемы данных. Важно, что здесь при неудачном релизе мы всегда можем безопасно его откатить. Конечно, такие жесткие гарантии требуются не всегда, и зачастую происходит слияние каких-то шагов, если допустим короткий даунтайм

Шаги в виде картинок в комментах
⚡️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 хочется снизить

---

Пишите в комментах, что из этого используете)
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?

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

Представьте, что у вас есть табличка с колонкой 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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Представьте есть 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, можно использовать идею с "виртуальными бакетами"

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

Ставьте 👍, если нужен пост про виртуальные бакеты в шардировании
⚡️Принцип работы 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
⚡️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
⚡️Пара способов, как обеспечить идемпотентность

В продолжение к https://t.me/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

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

В посте https://t.me/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.: пост предполагался про виртуальные бакеты, но чуть не туда понесло

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

Следующая часть
⚡️Шардирование без решардирования (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
Как расти разработчику в компании

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

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

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

---

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

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

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

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

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

---

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

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

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

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

---

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

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

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

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

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

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

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

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

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

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

C4

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

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

Sequence diagram

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

State diagram

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

---

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

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

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, если не получается сразу взять лок на сущность
⚡️Как мы мониторим приложения

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

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

Пример, как такое можно устроить:

1 уровень

5хх/тайминги на балансере или api gateway — основная метрика

2 уровень

Выписываем основные бизнес-функции, и для каждой строим dataflow — как данные ходят между сервисами. На каждый такой межсервисный стык заводим график

Например, если данные ходят a -> b -> c, то заводим два графика

a -> b
b -> c

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

3 уровень

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

---

Такое разделение позволяет

1. Очень компактно разместить графики. Можно сделать один дашборд, где будут метрики 1 и 2 уровня, а также ссылки на подробные дашборды 3-го уровня

2. Быстро искать root cause во время инцидентов: проблема с какой-то бизнес функцией => увидели на каком стыке проблема => посмотрели дашборд по конкретному сервису => нашли проблему

---

Рассказывайте в комментах, какая у вас структура мониторингов, будет интересно почитать)
⚡️Data retention в постгресе

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

Есть два подхода со своим трейдоффом

1. Обычное удаление старых записей через delete


delete from tbl
where id in (
select id
from tbl
where created_at < now() - interval '7 days'
order by created_at
limit 1000
);


Такое крутится либо постоянно в фоне (условно раз в секунду удаляется небольшая устаревшая пачка) либо запускается по крону и в while (true) удаляется за раз большое количество пачек

2. Партицирование и drop partition

Таблица партицируется по created_at. И вместо удаления отдельных записей удаляется целая партиция

---

В чем трейдофф?

Первый вариант легко реализовать, но образуется bloat при удалении из-за MVCC

Второй вариант реализовать сложнее, но никакого bloat-а нет. Удаление партиции — это просто удаление физического файла
Привет. Мне тут закинули фидбек, что порой в постах мало деталей, из-за чего могут упускаться важные риски/границы применимости/...

В этой связи у меня к вам вопрос — какой формат постов более предпочтителен?
⚡️Про bloat в pg и как с ним бороться

При обновлениях и удалениях строк postgres физически не удаляет/изменяет старую версию строки, а просто создает новую. У каждой такой версии есть поля:

1. xmin — номер транзакции, который создал версию строки
2. xmax — номер транзакции, который удалил версию строки

Чтобы проникнуться идеей xmin/xmax, можно почитать пост, как это позволяет обеспечить snapshot isolation

---

Окей, мы пообновляли строку, у нас появилось несколько версий строки со своими xmin/xmax. Интуитивно кажется, что нам не нужны все эти версии, ведь мы хотим видить только последнее, актуальное состояние строки. Так и есть — если xmax != 0 (версия строки кем-то удалена) и xmax < минимальный xid, среди активных транзакций (версия строки не видна ни для одной живой транзакции), то эта версия больше не нужна и ее можно удалить. Такие версии строк называются dead tuples

"Удалением" dead tuples занимается autovacuum: он помечает, что фрагменты страниц, где раньше лежали dead tuples, можно переиспользовать для записи новых данных. Важно отметить, что автовакуум никак не "двигает" живые данные и не освобождает физическое место. Он просто говорит, что в текущих страницах есть вот такие дырки, куда теперь можно что-то записать

К слову, минимальный xid, среди активных транзакций называется горизонтом базы. То есть автовакуум может удалять только те версии строк, которые "старше" горизонта базы. Это еще один аргумент, почему долгие транзакции — зло: из-за них автовакуум встает, так как такие транзакции долго держат горизонт

---

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

Несмотря на то, что у нас есть "дырки" в страницах, куда можно записать новые данные, этого не всегда хватает. Например, "дырка" может быть слишком маленькой, чтобы туда записать версию строки. Либо таких дырок в моменте может быть недостаточно (например, при массовых апдейтах/удалениях) — все это приводит к тому, что постгрес вынужден аллоцировать новые страницы => растет физический размер таблицы

---

Суммаризируя, table bloating — это ситуация, когда физический размер таблицы существенно превосходит размер датасета. Это происходит из-за:

1. Накопления dead tuples
2. Фрагментации таблицы, когда текущих "дырок" не хватает для записи новых данных и приходится выделять новые страницы

Для борьбы с фрагментацией у постгреса есть vacuum full — он берет эксклюзивную блокировку на таблицу и полностью ее перезаписывает в новый файл "без дырок". Однако на практике он редко применим, поскольку он буквально вызывает даунтайм сервиса (возможно на несколько часов, если таблица большая)

Для борьбы с фрагментацией без даунтайма есть утилита pg_repack

👍 — если нужен пост про принцип работы pg_repack