⚡️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 релизов получаем безопасную миграцию схемы данных. Важно, что здесь при неудачном релизе мы всегда можем безопасно его откатить. Конечно, такие жесткие гарантии требуются не всегда, и зачастую происходит слияние каких-то шагов, если допустим короткий даунтайм
Шаги в виде картинок в комментах
Когда вам нужно мигрировать приложение на новую схему данных, у вас есть два пути
Первый вариант — за один релиз задеплоить новый код и миграцию схемы и самих данных. Если у вас данных не очень много и/или вы можете позволить себе даунтайм, это прекрасно
Второй вариант — страдания
Для таких миграций без даунтайма есть весьма понятный подход, называемый 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() выглядит так
Что произойдет если два потока почти одновременно не найдут данные в кеше? Пойдут загружать из БД
А если 100 потоков?
Понять масштабы можно на таком примере:
- В кеш может поступать до 300 запросов в секунду
- Время загрузки из бд — 2 секунды
И получается, если ключ в кеше протухнет, то за первые две секунды в базу попрутся 600 параллельных тяжелых запросов. Учитывая, что из-за этих запросов БД может деградировать, запросы могут начать выполняться еще дольше
---
Что с этим делать?
1. Локи
В примере выше хорошо бы зашел double-checked locking
Можно конечно сразу лочить, но тогда для подавляющего большинства запросов добавится лишний оверхед. А в случае дабл чека будет лочиться только когда кеш протух
2. Прогрев кеша / фоновые апдейты
Если уверены, что значение скорее всего понадобится, то просто заранее его туда положите и в фоне обновляйте. Если будет всплеск нагрузки, в кеше уже будет все что нужно
3. Вероятностные рефреши
Есть еще техника, когда кеш обновляется с некоторой вероятностью, даже если значение еще не протухло. Это может быть полезно, если полностью прогреть кеш невозможно, но вероятность 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?
Ставьте 👍, если подписались
Если вы ищете практический канал про Highload, БД, System Design, то вам точно стоит заглянуть в канал к Алексею — ex-CTO Badoo, а сейчас основателю R&D-платформы devhands.io и сооснователю софта для автоматизации перфоманс ревью teamwork360.io
Он делится на своём канале уникальными мыслями и опытом работы с реальными высоконагруженными системами, сравнивает перфоманс БД, описывает некоторые теоретические аспекты
Список постов которые лично мне понравились у него:
- Простое объяснение CAP теоремы
- Про блокировки в СУБД
- Почему важно замерять P99
- Один день из жизни CTO
- 1.000.000 RPS на PostgreSQL и MySQL
- Нужен ли BFF?
Ставьте 👍, если подписались
Telegram
System Design & Highload (Alexey Rybak)
Архитектура больших проектов и управление продуктово-инженерными организациями; статьи, выступления по теме управление и разработка больших IT-проектов. Https://DevHands.io - хайлоад-прокачка бекендеров. ЛС: @alexeyrybak.
⚡️Почему из-за долгих транзакций могут тормозить другие запросы
Представьте, что у вас есть табличка с колонкой created_ts. И на эту таблицу есть некоторый TTL — фоновый воркер в порядке created_ts удаляет записи, которые старше 7 дней
Посмотрим, как это заафектит производительность запроса
1. Сначала все хорошо, запрос идет по индексу на created_ts, берет первую запись
2. Далее начинается долгая транзакция с участием tbl
3. Далее блокируется автовакуум tbl
4. Параллельно с этим работает фоновая удалялка. Но поскольку автовакуум заблокирован, удаленные записи просто продолжают висеть как dead tuples
5. Спустя какое-то время набирается несколько тысяч dead tuple-ов
И далее интересный момент — поскольку удалялка удаляет записи в порядке created_ts, то в начале индекса будет огромная пачка ссылок, которые ведут на мертвые кортежи
И чтобы выполнить запрос
Нам нужно будет сначала пройти все эти ссылки на мертвые кортежи, и только потом взять первую активную запись. Если таких кортежей наберется под миллион — будет очень больно (основано на реальных событиях)
Представьте, что у вас есть табличка с колонкой 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: Потеря сообщений критична, порядок критичен
Потеря сообщений критична => обязательно нужны ретраи
Порядок критичен => обрабатываем все сообщение либо одним батчем, либо последовательно в один поток. Можем ретраить только синхронно, ибо ретраи в фоне могут нарушить порядок
На самом деле это тоже простой случай, потому что ограничения нас вгоняют в жесткие рамки. Здесь возможен единственный вариант — обрабатываем сообщения последовательно (или батчем). Синхронно ретраим. Все ретраи упали — останавливаем вычитку, поджигаем алерт, идем вручную разбираться
Ставьте 👍, если было полезно
Когда консюмер топика пытается обработать очередную пачку сообщений, но получает ошибку — каждый раз возникает вопрос "а че делать?"
Предлагаю такой фреймворк
Отвечаем на два вопроса:
- Критична ли потеря сообщений?
- Критичен ли порядок обработки сообщений?
Сценарий 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, можно использовать идею с "виртуальными бакетами"
Следующая часть
Ставьте 👍, если нужен пост про виртуальные бакеты в шардировании
Одна из основных проблем шардирования — решардинг, то есть когда при добавлении нового шарда нужно перераскидать данные между шардами. Почему так случается?
Представьте есть 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 избавляет от неповторяющегося чтения — ситуации, когда одна и та же строка запрашивается дважды в рамках транзакции, но результаты чтения получаются разными
Как это работает в 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. В транзакции работаем только с версиями строк, где
Хорошая статья по теме https://mbukowicz.github.io/databases/2020/05/01/snapshot-isolation-in-postgresql.html
Изоляция 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-чик даст идемпотентность
Встречали когда нибудь такие проверки?
Такая конструкция очевидно рушится race condition-ом, который рано или поздно произойдет — когда два почти одновременных запроса получат отрицательный результат на db.exists(idempotency_key) и пойдут выполнять логику
2. Слишком широкие границы транзакций
Это ситуация, когда в транзакцию оборачивают не только работу с базой, а вообще всю логику обработки, включая запросы во внешние системы, кеши и т.д. Итог: долгие транзакции, висящие блокировки и вот это все
3. Пагинация через offset + limit
Довольно закономерное желание сделать пагинацию через такой запрос, база же поддерживает
Но увы несмотря на наличие индекса на (created_at), база не может за log(offset) найти место, откуда нужно стартовать, и будет честно проходить и скипать эти 100000 строк
🔥 — если нужен пост про "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 строк
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
Атомарность — либо оба insert-а выполнятся, либо никакой
Ограничение unique constraint — insert в таблицу с ключами выполняется не более одного раза
Складывая эти факты, получаем что то что нужно: insert в таблицу article выполняется не более одного раза
Вариации:
1.1. Ключ идемпотентности может лежать не в отдельной таблице, а просто как колонка в article
1.2. Можно делать insert on conflict do nothing, чтобы обойтись без эксепшнов
2. Через оптимистические блокировки
В запрос на добавление article добавляется параметр ticket_version
В той же транзакции, где делаем insert into article, мы проверяем что в бд лежит действительно та версия ticket, которую мы хотим обновить. Если это не так, то кидаем ошибку
Атомарность — либо и insert, и update версии выполнятся, либо не выполнится ничего
Обновление версии — если в бд лежит ticket с ticket_version = 1, то из двух параллельных запросов на обновление версии выполнится только один. Просто потому что бд гарантирует, что не будет аномалии lost update
И снова складывая эти факты, получаем требуемое
В продолжение к 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
И снова складывая эти факты, получаем требуемое
Telegram
Microservices Thoughts
⚡️3 как не надо
В отпуске совершенно лень писать посты про что-то умное, поэтому держите пост про 3 рандомные ошибки, которые я относительно часто встречаю
1. Надежда, что простой if-чик даст идемпотентность
Встречали когда нибудь такие проверки?
def…
В отпуске совершенно лень писать посты про что-то умное, поэтому держите пост про 3 рандомные ошибки, которые я относительно часто встречаю
1. Надежда, что простой if-чик даст идемпотентность
Встречали когда нибудь такие проверки?
def…
⚡️Шардирование без решардирования (pt. 2)
В посте https://t.me/MicroservicesThoughts/138 разобрали, что для шардирования без необходимости решардинга нужно персистентное хранилище маппингов entity_id => shard
Суть в том, что при добавлении сущности в бд мы сразу записываем, к какому шарду она относится, и эту запись больше никогда не трогаем. Соотв-но если добавится новый шард, то это не принесет никаких проблем — шард для сущности уже зафиксирован
Очевидная проблема такого подхода — жирная таблица с маппингами, которая к тому же никогда не чистится. Соотв-но с какого-то момента полностью закешировать такое станет невозможно => будет много кеш миссов => походов в базу с маппингами (которая к тому же является spof-ом)
---
И далее идут нюансы
Если у вас autoincremented ids, то эту проблему можно решить достаточно просто — давайте хранить маппинги не для каждой entity_id, а для какого-то ренжа этих entity_id
Получается примерно такая схема (aka range-based mapping)
Правила:
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.: пост предполагался про виртуальные бакеты, но чуть не туда понесло
Предыдущая часть
Следующая часть
В посте 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.: пост предполагался про виртуальные бакеты, но чуть не туда понесло
Предыдущая часть
Следующая часть
Telegram
Microservices Thoughts
⚡️Шардирование без решардирования
Одна из основных проблем шардирования — решардинг, то есть когда при добавлении нового шарда нужно перераскидать данные между шардами. Почему так случается?
Представьте есть 3 шарда => shard_count = 3
Шард выбирается как…
Одна из основных проблем шардирования — решардинг, то есть когда при добавлении нового шарда нужно перераскидать данные между шардами. Почему так случается?
Представьте есть 3 шарда => shard_count = 3
Шард выбирается как…
⚡️Шардирование без решардирования (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 заказа (чтобы вообще понять в каком шарде лежат эти дочерние сущности). Подход выше такую проблему нивелирует, потому что и для дочерних сущностей сразу будет понятно, в каком шарде они лежат
Предыдущая часть
🔥 — если было полезно
Возьмем максимально наглые требования
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.: сам я этот канал читаю уже больше года, поэтому могу с чистой совестью рекомендовать его как тимлидам, так и амбициозным разработчикам, нацеленным на рост
🔥 — если подписались
А что значит "расти"? Я бы выделил два направления:
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 годовой давности. Если фейспалмите, то все хорошо
Занимательный способ, как проверить растете вы или нет — вновь прочитать статью, которую вы не до конца понимали пару лет назад
У меня так внезапно получилось с 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/, где каждый из типов выше можно просто описать текстом и зарендерить прямо в браузере
У некоторых людей кубики и стрелочки в миро — основной инструмент для визуалиации архитектуры. В простых случаях с этим нет вообще никаких проблем
Но когда нужно описать что-то более менее объемное и/или сложное, зачастую приходим к двум ключевым проблемам:
- На одной диаграмме есть вообще все, и ее трудно осознать
- Неочевиден порядок, в котором взаимодействуют компоненты этой диаграммы
В таких случаях бывает удобно подробить одну большую диаграмму на несколько меньших
Имхо, джентльменский набор, которого хватает для большинства случаев:
C4
Разбивает диаграмму на 4 слоя: системы, контейнеры, компоненты, код (обычно не рисуют, тк часто меняется). Переход с n-го на n+1-ый уровень это "зум" в какой-то кусочек диаграммы. Например, на диаграмме контейнеров выбираем контейнер, смотрим диаграмму компонентов по этому контейнеру
Разумеется, не всегда нужны все 4 слоя, это просто удобный способ обозначить, элементы какого уровня абстракции мы хотим видеть на конкретной диаграмме
Sequence diagram
Это как раз про порядок взаимодействий. На "плоской" C4 диаграмме зачастую сложно понять в какой последовательности кто кого вызывает. Диаграммки последовательностей прекрасно закрывают эту потребность
State diagram
Часто в рамках приложения есть какая-то сущность с каким-то жизненным циклом. Чтобы ответить на вопрос, какие бывают статусы у сущности, и кто инициирует переходы между ними, можно воспользоваться диаграммой состояния. Это буквально визуализация конечного автомата
---
Если вы как и я не любите собирать диаграммы в визуальном редакторе, то есть https://plantuml.com/ru/, где каждый из типов выше можно просто описать текстом и зарендерить прямо в браузере
Прохладная история про то, как легко положить приложение
Есть некоторая запись в базе, запрос на обновление выглядит так:
Такая транзакция выполняется ~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, если не получается сразу взять лок на сущность
Есть некоторая запись в базе, запрос на обновление выглядит так:
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 во время инцидентов: проблема с какой-то бизнес функцией => увидели на каком стыке проблема => посмотрели дашборд по конкретному сервису => нашли проблему
---
Рассказывайте в комментах, какая у вас структура мониторингов, будет интересно почитать)
В продакт менеджменте есть концепция дерева метрик — грубо говоря когда метрики выстраиваются в иерархию, и метрика-родитель зависит от метрик-детей
В технических же мониторингах такой структуры зачастую нет, и на один дашборд навалено всё что можно. Но на самом деле ничего не мешает перенести эту идею с иерархией и на технические мониторинги
Пример, как такое можно устроить:
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
Такое крутится либо постоянно в фоне (условно раз в секунду удаляется небольшая устаревшая пачка) либо запускается по крону и в while (true) удаляется за раз большое количество пачек
2. Партицирование и drop partition
Таблица партицируется по created_at. И вместо удаления отдельных записей удаляется целая партиция
---
В чем трейдофф?
Первый вариант легко реализовать, но образуется bloat при удалении из-за MVCC
Второй вариант реализовать сложнее, но никакого bloat-а нет. Удаление партиции — это просто удаление физического файла
Встроенной функциональности, чтобы удалять записи по истечении некоторого срока, в постгресе нет. Поэтому такое делается вручную
Есть два подхода со своим трейдоффом
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
При обновлениях и удалениях строк 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
Telegram
Microservices Thoughts
⚡️Принцип работы snapshot isolation (aka repeatable read) в postgres
Изоляция repeatable read избавляет от неповторяющегося чтения — ситуации, когда одна и та же строка запрашивается дважды в рамках транзакции, но результаты чтения получаются разными
begin;…
Изоляция repeatable read избавляет от неповторяющегося чтения — ситуации, когда одна и та же строка запрашивается дважды в рамках транзакции, но результаты чтения получаются разными
begin;…