Backend VK Hub
رفتن به کانال در Telegram
Комьюнити VK для бэкендеров. Cамые хардовые кейсы, дискуссии в кругу своих и прямой доступ к нашим экспертам 😎
نمایش بیشتر1 132
مشترکین
-124 ساعت
-27 روز
-430 روز
آرشیو پست ها
1 132
+5
Redis — это не только строки. Шесть структур данных, которые закрывают целые классы задач
Большинство используют Redis как key-value кеш: SET, GET, EXPIRE. При этом внутри есть Sorted Sets для рейтингов, HyperLogLog для приблизительного подсчёта уникальных значений, Bitmaps для аналитики, GEO для геопоиска, Hash для объектов и Sets для тегов.
Каждая структура закрывает свой класс задач, где обычные строки либо не тянут по производительности, либо требуют кучи хаков. Пройдёмся по одной штуке — с реальным use case и командами.
#backendvkhub #redis
1 132
Считанные минуты остаются до старта двух основных треков выступления. А пока делимся закулисьем атмосферы на нашем митапе ⬇️
1 132
Как правильно делать параллельные задачи с отменой в Go
Знакомая картина в хендлере Go-сервиса: пришёл запрос с
userID, надо параллельно сходить в БД за пользователем, в Redis за сессией и во внешний API за биллингом. Первый рефлекс — три горутины и sync.WaitGroup.
Работает, пока одна не падает: тогда WaitGroup ждёт остальные, они молотят вхолостую и возвращают никому не нужный результат. У sync.WaitGroup нет знания ни про ошибки, ни про отмену.
Правильный инструмент — golang.org/x/sync/errgroup. Обёртка над sync.WaitGroup, работающая с контекстом: первая ошибка отменяет остальные горутины через общий ctx, Wait() возвращает её.
func fetchUserData(ctx context.Context, userID int64) (*UserData, error) {
g, ctx := errgroup.WithContext(ctx)
var user *User
var session *Session
var billing *Billing
g.Go(func() (err error) { user, err = db.QueryUser(ctx, userID); return })
g.Go(func() (err error) { session, err = redis.GetSession(ctx, userID); return })
g.Go(func() (err error) { billing, err = billingAPI.GetBilling(ctx, userID); return })
if err := g.Wait(); err != nil {
return nil, err
}
return &UserData{user, session, billing}, nil
}
errgroup.WithContext возвращает Group и производный ctx. Первая g.Go, вернувшая ошибку, отменяет этот ctx. Остальные горутины видят отмену через свой ctx и завершаются — правильные библиотеки типа database/sql, net/http, redis уважают контекст, ошибки быстро всплывают. g.Wait() возвращает первую пришедшую ошибку.
Второй сценарий — batch processing с ограничением параллелизма. Загрузить тысячу файлов не по одному, но и не все сразу — по десять в параллель:
func processFiles(ctx context.Context, paths []string) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10)
for _, path := range paths {
g.Go(func() error {
return processOne(ctx, path)
})
}
return g.Wait()
}
SetLimit(10) разрешает не больше десяти одновременных горутин (в x/sync v0.1 с конца 2022 года). g.Go при заполненном пуле блокируется до освобождения слота, g.TryGo возвращает false без блокировки. С Go 1.22 переменная цикла path живёт per-iteration автоматически.
Пара нюансов
Отмена контекста не убивает горутины — они должны сами проверять ctx.Done() или использовать библиотеки, уважающие ctx. Бесконечный цикл без проверки не остановится. Панику errgroup не ловит — пробрасывает наверх и валит процесс.
Если функция может паниковать (парсинг чужого JSON, недоверенный ввод), оборачивайте тело самой горутины в recover(). Результаты пишутся в переменные без синхронизации — это норм, пока каждая горутина пишет в свою, а вот если несколько пишут в один слайс или map, нужен sync.Mutex.
Если в проекте есть sync.WaitGroup, плюс переменная-ошибка под sync.Mutex, плюс ручная отмена через context.CancelFunc — почти наверняка это errgroup.WithContext, лишний код уходит.
#backendvkhub #go1 132
Как запретить пересечения периодов без триггеров в PostgreSQL
Стандартная задача из SaaS: календарь бронирования созвонов. Одна комната не должна быть занята двумя бронированиями сразу. Пользователь A ставит на 14:00–15:00, пользователь B — на 14:30–15:30, оба жмут «Сохранить» одновременно, в базе появляются оба. Классические подходы —
SERIALIZABLE-транзакция с ловлей отказов, advisory lock на комнату или BEFORE-триггер. Все три работают, при этом легко ломаются при UPDATE, каскадах или под нагрузкой.
В PostgreSQL для этого есть EXCLUDE constraint — родной механизм, делающий то же самое на уровне базы через индекс. Пишется так:
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE bookings (
id serial PRIMARY KEY,
room_id int NOT NULL,
period tstzrange NOT NULL,
EXCLUDE USING gist (
room_id WITH =,
period WITH &&
)
);
Читается как «не давать двум строкам иметь одновременно одинаковый room_id и пересекающийся period». && — оператор пересечения для range-типов, = — обычное равенство. btree_gist нужен, потому что room_id — обычный int, а GiST по умолчанию понимает только geometric и range.
Пробуем:
INSERT INTO bookings (room_id, period)
VALUES (1, '[2026-08-06 14:00, 2026-08-06 15:00)');
-- OK
INSERT INTO bookings (room_id, period)
VALUES (1, '[2026-08-06 14:30, 2026-08-06 15:30)');
-- ERROR: conflicting key value violates exclusion constraint
Ни триггеров, ни блокировок, ни SERIALIZABLE. Под капотом PostgreSQL проверяет пересечения через GiST-индекс, автоматически создающийся с констрейнтом. При UPDATE та же семантика. Race condition не пролезет, проверка идёт под нужными блокировками.
Дальше интереснее — EXCLUDE работает не только с временными диапазонами. Тот же паттерн закроет пересечение IP-диапазонов (inet WITH &&), непересекающиеся 2D-области (box WITH &&), уникальные тарифы на интервалы [valid_from, valid_to) для одной услуги. Один раз добавили — забыли про самописную валидацию.
Часто используют partial EXCLUDE с WHERE. Отменённые бронирования не должны блокировать новые:
ALTER TABLE bookings ADD EXCLUDE USING gist (
room_id WITH =, period WITH &&
) WHERE (status = 'active');
status = 'cancelled' строки в проверке не участвуют, освобождённое окно можно перебронировать.
При всём этом GiST-индекс дороже btree на point-lookup, если поиск по room_id частый, заведите отдельный btree, GiST оставьте под EXCLUDE.
Если в проекте есть триггер BEFORE INSERT/UPDATE с проверкой пересечений или app-код с SELECT ... FOR UPDATE вокруг вставки — он почти наверняка заменяется одним EXCLUDE constraint, из репозитория уходят сотни строк.
#backendvkhub #postgresql1 132
📢 Неделя до «Митапа с двойной начинкой»!
26 августа собираем Go- и Java-разработчиков на мероприятие от VK Just Tech.
Вас ждут два трека выступлений, общение и, конечно, афтепати с квизом, шавермой и мастер-классом✨
➡️ В треке Go разберёмся в нестандартной компиляции для ускорения выкатки фичи, расскажем, как в VK выстроили единую платформу для разработки Go-сервисов и как устроена доставка информации об адресах через сетевую инфраструктуру.
➡️ В треке Java поговорим о перформанс-трюках в поисковой платформе, разберём архитектуру современного платёжного движка и обсудим переход от федерации кластеров хранения к мультитенантному решению.
📍 26 августа, Санкт‑Петербург, Крестовский остров, ресторан Royal Beach
Встречаемся в 17:00, начало в 18:00.
Участие бесплатное, регистрация обязательна.
Зарегистрироваться
Ждём вас!
#backendvkhub #meetup #go #java #митап
1 132
+5
OneDWH VK: как консолидировали 0,5+ EB данных из 35 бизнес-вертикалей
В VK исторически сложилась децентрализованная модель управления данными: каждая из 35 бизнес-вертикалей развивала аналитику самостоятельно и выбирала технологии независимо от соседей. К моменту решения о переезде в облако это выросло в 10+ железных кластеров под Airflow и Hadoop, 0,5+ EB аналитических данных и тысячи кросс-запросов в месяц между командами.
Пока команд было около десяти, расхождения снимались прямыми договорённостями. Когда специалистов стало больше сотни, подход перестал масштабироваться, и в компании начали строить единое хранилище.
В карточках — чем именно мешала разрозненная инфраструктура, какие принципы легли в основу новой платформы, как проходила миграция 30 тысяч объектов и что получилось на выходе.
➡️ Полная версия статьи
#backendvkhub #dwh #обзорстатьи
1 132
Gel — TypeScript для PostgreSQL
Запрос с тремя уровнями вложенности через ORM — это не один SQL, а 5–10 отдельных запросов. Prisma делает отдельные запросы для каждого уровня, SQLAlchemy требует
joinedload() на каждую связь, Hibernate — FETCH JOIN. Если про один уровень забыли — N+1.
Gel (бывший EdgeDB) решает проблему на уровне СУБД. Это графово-реляционная БД, которая работает как слой поверх PostgreSQL. Создатели описывают соотношение так: «Gel to Postgres is what TypeScript is to JavaScript».
Что такое Gel
Вместо таблиц — объектные типы с наследованием и миксинами. Вместо внешних ключей — first-class links. Связи могут быть single или multi, required или optional, с exclusive-ограничением. Обратные ссылки (backlinks) позволяют навигировать в обе стороны без явных JOIN.
Пример схемы:
abstract type Person {
required name: str { constraint exclusive; };
}
type Hero extending Person {
secret_identity: str;
multi link villains := .<nemesis[is Villain];
}
type Movie {
required title: str;
multi characters: Person;
}
Схема описывается в .esdl-файлах. При изменении схемы gel migration create сам анализирует разницу и генерирует миграцию. Без рассинхронизации между ORM-моделями и реальной схемой.
EdgeQL: один запрос вместо N+1
EdgeQL-запрос с тремя уровнями вложенности компилируется в один SQL с JOIN.
select Movie {
title,
actors: { name, awards: { title, year } },
director: { name, birthplace }
} filter .title = "Inception"
Эквивалент в SQL потребовал бы 4–5 JOIN, а результат был бы плоским списком строк, который нужно собирать в объект на стороне приложения. ORM на такой глубине сделают 5–10 отдельных запросов. В EdgeQL нет ключевого слова JOIN — связи обходятся через links: Movie.actors.name.
Нет NULL
В EdgeQL нет NULL — только пустые множества {}. NULL = NULL → UNKNOWN, NULL + 5 → NULL, агрегаты игнорируют NULL — в SQL это источник постоянных багов. В EdgeQL каждый тип — это множество значений, и пустое множество явно обозначает отсутствие данных. Устраняет целый класс ошибок, связанных с трёхзначной логикой.
AI на уровне БД
Расширение ext::ai добавляет векторный поиск и RAG: embedding-индексы на основе OpenAI/Anthropic моделей, поиск по similarity, HTTP-эндпойнт /ai/rag. Поиск и генерация в одной транзакции, без отдельного векторного хранилища. Deferred index пересчитывается автоматически при изменении данных.
Ограничения
Gel требует изучения EdgeQL, и экосистема пока меньше, чем у Prisma или Drizzle. EdgeQL не поддерживает оконные функции и рекурсивные CTE (хотя в 6.0 можно использовать SQL напрямую). Serializable isolation only — узкое место при высокой конкурентности. Нет кеширования исполняемых планов запросов.
Попробуйте Gel на новом проекте со сложной моделью данных. Для всего остального есть PostgreSQL.
#backendvkhub #postgresql #gel1 132
Инвалидация кеша: скорость доступа vs согласованность данных
Кеш и источник данных не могут быть строго консистентны. Если бы кеш при каждом чтении ходил в основное хранилище, сам смысл кеширования был бы утрачен. Поэтому любая стратегия инвалидации — компромисс между задержкой чтения, нагрузкой на источник и окном, где клиент видит устаревшие данные. Надо решать не «как сделать правильно», а «какое окно рассинхронизации вы готовы терпеть».
Это окно складывается из двух вещей, которые часто путают между собой: как кеш узнаёт, что данные устарели, и что он делает после того, как узнал. Первое определяет, сколько вы вообще будете жить с устаревшими данными. Второе — как быстро от них избавитесь, когда обнаружите.
➡️ Как кеш узнаёт, что данные устарели
TTL. Кеш считает запись невалидной через фиксированное время после записи. TTL не проверяет, изменились ли данные на самом деле — это просто таймер. Плюс: TTL не завязан на запись, живёт в cache-aside, не требует встраивать кеш куда-либо. Минус: окно неконсистентности задаёт произвольное число, а не реальное событие.
Write-through. Приложение пишет в кеш, а кеш сам синхронно сохраняет данные в источник. Точка входа для записи — кеш, а не база. Данные актуальны почти всегда. Почти — потому что кеш может уже обновиться, когда запись в источник ещё не подтвердилась. Минус: каждая запись становится медленнее на постоянную величину, даже если этот элемент никому в кеше не нужен.
Event-driven (Pub/Sub). Источник публикует событие об изменении, кеш подписан и обновляется асинхронно. Такой подход разводит запись и инвалидацию во времени, но добавляет риски, о которых часто забывают:
🔵брокер может потерять событие, если не даёт гарантию at-least-once
🔵нужно гарантировать сохранение порядка событий. Если придут не по порядку — старое перезапишет свежие данные
🔵пока событие создаётся и пересылается, кеш отдаёт устаревшие данные и не знает об этом
➡️ Что кеш делает после инвалидации
Триггер инвалидации не определяет, что происходит с данными дальше. Их обновление — это отдельная, независимая ось.
Purge. Кеш точечно удаляет элемент по известному ключу. Данные вернутся в кеш, только когда клиент их запросит.
Refresh. Кеш сам идёт в источник за свежим значением сразу после инвалидации — не ждёт запроса клиента.
Ban. В отличие от purge, кеш инвалидирует не один ключ, а пачку данных по паттерну или диапазону — даже не зная точных ключей. Запись может ещё какое-то время отдаваться как есть (grace period) и обновиться при следующем обращении. TTL по умолчанию ведёт себя как Ban: невалидная запись просто ждёт следующего запроса. Чтобы получить refresh, добавьте его поверх TTL явно.
➡️ Что выбрать
Смотрите на три вещи:
🔵насколько критична консистентность — допустимо ли показать пользователю данные пятисекундной давности
🔵как часто источник меняет данные
🔵какой у вас профиль нагрузки — read-heavy или write-heavy
На практике стратегии комбинируют. Например: берёте TTL как страховочный потолок на случай, если событие потерялось, и добавляете event-driven — для быстрой инвалидации горячих ключей. Так надёжнее, чем полагаться на что-то одно.
#backendvkhub #кеш #архитектура
1 132
Вам доклады по Go или Java? VK приглашает на «Митап с двойной начинкой»
📢 26 августа собираем Go- и Java-инженеров, чтобы прокачать знания в разработке и архитектуре высоконагруженных сервисов.
💙 Вас ждут: летний Санкт-Петербург, любимый стритфуд, феерический закат, два трека докладов от ведущих инженеров VK и приглашённых экспертов, квиз, мастер-класс и вечеринка!
Трек Go
🔹«Компиляция Go в динамическую либу, или Как мы ускорили выкатку фичи в два раза, но нам не понравилось», Михаил Кичигин, бэкенд-разработчик, Avito Tech
🔹«Go-платформа VK: как мы построили единую основу для создания Go-сервисов», Александр Апанасенко, руководитель команды SDK Backend, VK
🔹«Почтальон от Kubernetes до Palo Alto. Как осуществляется доставка информации об адресах», Дарья Кулькина, бэкенд-разработчик, Точка Банк
Трек Java
🔹«Перформанс-трюки в поисковой платформе Ozon», Пётр Портнов, ведущий разработчик среднего поиска, Ozon
🔹«Архитектура современного движка платёжной системы и переход к новой архитектуре», Игорь Крикунов, технический лидер разработки DBC, Альфа-Банк
🔹«Переход от федерации S3-кластеров в пользу коммунального и мультитенантного кластера», Кирилл Боблак, ведущий разработчик One-cloud, VK
💙 Афтепати
🔹Квиз «Что? Где? Кебаб?», чтобы окончательно прожарить мозги на технических вопросах
🔹Фуд-корнер
🔹Мастер‑класс, который сделает вас звездой любой вечеринки
📍 26 августа, Санкт‑Петербург, Крестовский остров, ресторан Royal Beach
Встречаемся в 17:30, начало в 18:00.
Участие бесплатное, регистрация обязательна.
💙 Ждём вас!
#backendvkhub #meetup #go #java #митап
1 132
🔵PostgreSQL 19: Beta 2
Июль в бэкенде — история про PostgreSQL. 16 июля вышла Beta 2 версии 19: Feature freeze означает, что финальный список фич зафиксирован, от RC до GA API уже не поменяется, и то, что тестируется сейчас, реально поедет в прод в сентябре-октябре.
🔵Графовые запросы и REPACK
Из вышедшего самое громкое — SQL/PGQ, Property Graph Queries. MATCH-паттерны для графового обхода прямо в SQL. Раньше для такого приходилось либо гонять WITH RECURSIVE с массивом посещённых вершин от циклов, либо тащить отдельную графовую БД типа Neo4j. Теперь запрос вроде «Найди все узлы, до которых от A можно дойти через N связей типа friend» пишется декларативно через MATCH. За компанию —
REPACK как встроенная SQL-команда: zero-downtime реорганизация таблиц становится частью базы, pg_repack extension постепенно уходит на пенсию.
🔵Autovacuum, планировщик и auto-scaling AIO
Плюс parallel autovacuum через autovacuum_max_parallel_workers (на терабайтных таблицах эффект будет заметный), pg_plan_advice для явного контроля планов и auto-scaling AIO через io_min_workers и io_max_workers. В версии 18 io_method=worker появился, в 19 количество workers само подстраивается под нагрузку.
🔵Что проверить на стейджинге перед GA
Feature freeze — как раз то состояние, когда стоит гонять реальный workload по стейджингу. В pg_dumpall, кастомных TOAST-настройках, RADIUS-auth и btree_gist на inet/cidr были правки, поэтому лучше словить регрессии сейчас, чем через два месяца после GA.
🔵Минорные релизы
Параллельно вышли минорки всех веток — 18.4, 17.10, 16.14, 15.18, 14.23. Суммарно закрыли 11 проблем с безопасностью и 60+ багов. Стоит обратить внимание: EOL ветки 14 — 12 ноября 2026 года, до конца поддержки полгода. Планировать миграцию стоит начинать сейчас.
🔵Redis
Redis провёл июль тихо. Software 7.2.4-154 (1 июля) — внутренние фиксы без громких заголовков. Из общей тенденции: 8.x продолжает интересно развиваться — Vector Sets как native-тип для similarity search (не workaround через ZSET плюс hashes), dev vs production database mode с type-to-confirm при destructive-операциях на проде. Не строго июльская новость, но контекст важный.
Новая статья от инженеров VK на Хабре
🔵Сериализация one-nio: от истоков к поддержке JDK 25
#backendvkhub #дайджест1 132
VK и JUG Ru Group изучили, какими инструментами сейчас пользуются Java-разработчики. Главный вывод: команды активно внедряют ИИ, но не спешат менять проверенный стек.
Что показало исследование
➡️ Java 21 стала основной версией языка — её используют две трети разработчиков
➡️ 95% специалистов работают с ИИ, больше половины — каждый день
➡️ Spring Boot, PostgreSQL и Kafka остаются основой большинства проектов
➡️ Агентные ИИ-инструменты пока не стали массовыми
Получается интересная картина: новые инструменты быстро входят в ежедневную практику, а фундамент технологического стека остаётся прежним.
🚀 Java меняется, но без революций.
Полные результаты исследования
#backendvkhub #java #исследование
1 132
Нативные lazy objects в PHP
Ленивая загрузка в PHP до 8.4 жила на генерации кода. Doctrine отдавала из репозитория не сущность, а сгенерированного наследника, и за это приходилось платить:
final на сущность не поставить, get_class() возвращает имя прокси, а в деплое живёт отдельный шаг, который эти классы порождает. В 8.4 всё это переехало в объектную модель.
Точек входа две, обе на ReflectionClass.
Первая создаёт ghost — объект нужного класса, у которого свойства пустые до первого обращения.
$reflector = new ReflectionClass(User::class);
$user = $reflector->newLazyGhost(function (User $user) use ($id, $persister): void {
$persister->loadById(['id' => $id], $user);
});
Возвращается настоящий User, так что instanceof и get_class() отвечают честно, а инициализатор при первом обращении заполняет тот же самый экземпляр.
Вторая точка входа делает proxy. Реальный объект создаёт фабрика, а прокси переадресует на него обращения. Применимо там, где экземпляр приходит из чужого кода и собрать его по месту нельзя.
$user = $reflector->newLazyProxy(
fn (User $user): User => $repository->find($id)
);
Объект просыпается от чтения или записи ленивого свойства, а также от ReflectionProperty::getValue() и setValue(). Вызов метода сам по себе триггером не считается, но внутри метода почти всегда читается свойство, и оно-то инициализацию и запускает. Из-за этого обычный getId() сходил бы в базу за значением, которое и так известно. Обходится это записью в обход ленивости:
$reflector->getProperty('id')
->setRawValueWithoutLazyInitialization($user, $id);
Doctrine раскладывает первичный ключ ровно так, сразу после создания ghost, и getId() после этого отвечает без похода в базу. Рядом лежат два флага для $options: SKIP_INITIALIZATION_ON_SERIALIZE не даёт сериализации разбудить объект, SKIP_DESTRUCTOR отменяет деструктор у экземпляра, который так и не проснулся. Плюс resetAsLazyGhost() и resetAsLazyProxy(), возвращающие в ленивое состояние уже существующий объект.
Ограничения простые: ленивым делается пользовательский класс или stdClass, на любом другом внутреннем классе прилетит Error. Объект без свойств ленивым не станет вовсе, откладывать в нём нечего.
В экосистеме всё уже на месте. Doctrine ORM умеет нативные объекты с версии 3.4, вышедшей летом 2025, а ORM 4.0 потребует PHP 8.4 и будет построена на них целиком. В Symfony LazyGhostTrait и LazyProxyTrait устарели с 7.3 и удалены в 8.0. На практике переключение выглядит как одна строчка в конфиге:
doctrine:
orm:
enable_native_lazy_objects: true
Старый ключ enable_lazy_ghost_objects при этом надо убрать.
#backendvkhub #php1 132
HOT updates и FILLFACTOR — как избежать bloat на UPDATE-heavy таблицах
В PostgreSQL каждый UPDATE из-за MVCC создаёт новую версию строки, а старая помечается как dead и живёт до VACUUM. Плюс обновляются все индексы, даже те, где изменённое поле не участвует. На UPDATE-heavy таблицах это даёт bloat и постоянную работу autovacuum. Есть механизм, пропускающий эту работу, — HOT update, heap-only tuple. Работает не всегда, но настраивается одним параметром.
Условий два: новая версия помещается на ту же страницу, что и старая, и ни одно индексируемое поле не менялось. При этом PostgreSQL пишет новую версию рядом со старой на той же странице и ставит указатель со старой на новую. Индексы остаются — они по-прежнему указывают на «голову цепочки», живая версия достаётся за один hop. VACUUM собирает dead tuples внутри страницы без полного прохода.
Первое условие обеспечивает FILLFACTOR — процент заполнения страницы при вставке. По умолчанию 100, страницы заполняются под пробку. Для read-only это оптимально, для UPDATE-heavy ломает HOT: свободного места нет, новая версия уходит на другую страницу, индексы правятся.
Настраивается при создании таблицы или через ALTER.
CREATE TABLE user_stats (
user_id bigint PRIMARY KEY,
last_seen timestamptz,
view_count int
) WITH (fillfactor = 80);
ALTER TABLE user_stats SET (fillfactor = 80);
После ALTER существующие страницы перезаполнятся при VACUUM FULL. На проде без окна обслуживания — pg_repack, без блокировки.
Проверяется через pg_stat_user_tables:
SELECT relname, n_tup_upd, n_tup_hot_upd,
round(100.0 * n_tup_hot_upd / NULLIF(n_tup_upd, 0), 1) AS hot_ratio
FROM pg_stat_user_tables
WHERE n_tup_upd > 0
ORDER BY n_tup_upd DESC;
hot_ratio показывает долю UPDATE, прошедших как HOT. Целевое значение на UPDATE-heavy таблицах — 80% и выше. При низком ratio первое, что стоит проверить, — не FILLFACTOR, а второе условие: какие индексы задевают изменяемые поля.
Второе условие ломается чаще. Типичный пример — составной индекс (user_id, updated_at) на таблице, где UPDATE меняет updated_at. Каждый UPDATE трогает индекс, HOT не работает, hot_ratio близок к нулю. Варианта два: убрать updated_at из индекса, если он не даёт выигрыша на чтении, либо разнести схему — собрать часто меняющиеся поля в отдельную таблицу без лишних индексов. Второй подход — стандартный для горячих счётчиков.
FILLFACTOR понижают на таблицах с регулярными UPDATE: счётчики, статистика, presence, состояние сессий. Значения 70–80 покрывают большинство случаев. На read-heavy таблицах менять не нужно.
Порядок действий на таблице с подозрением на bloat: посмотреть hot_ratio, при низком — проверить индексы на изменяемых полях, при необходимости почистить лишние и понизить FILLFACTOR. Работы немного, эффект на write-нагрузку и autovacuum обычно ощутимый.
А вы у себя FILLFACTOR настраивали на UPDATE-heavy таблицах?
#backendvkhub #postgresql1 132
+5
Типы индексов в PostgreSQL — не только btree
PostgreSQL знает шесть типов индексов. Большинство разработчиков всю жизнь работает с одним — btree. Остальные пять закрывают целые классы задач, где btree либо не работает, либо съедает в разы больше места.
В карточках пройдёмся по каждому — от базового btree до BRIN, который сжимает индекс на терабайтную таблицу до десятков килобайт.
#backendvkhub #postgresql
1 132
Очередь задач в PostgreSQL без Redis и гонок
Пять воркеров разгребают задачи. Первое, что приходит: SELECT с
pending, потом UPDATE в processing. Работает, пока воркеры не начинают хватать одну строку. Задача выполняется дважды, метрики летят, начинается ад.
Классика — Redis, RabbitMQ, Kafka. Но если PostgreSQL в стеке, всё закрывается одной конструкцией из PG 9.5: SELECT ... FOR UPDATE SKIP LOCKED. На ней построены Sidekiq, PG-boss, GoodJob, Solid Queue, River.
Таблица jobs:
CREATE TABLE jobs (
id bigserial PRIMARY KEY,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now()
);
Воркер забирает следующую задачу так:
BEGIN;
WITH next AS (
SELECT id FROM jobs
WHERE status = 'pending'
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE jobs
SET status = 'processing'
WHERE id IN (SELECT id FROM next)
RETURNING *;
COMMIT;
Основная пара — FOR UPDATE SKIP LOCKED. Первое берёт row-level lock, второе — если строка заблокирована, не ждём, берём следующую. Воркеры не толкаются, каждый забирает свою задачу. CTE с UPDATE атомарно вытаскивает строку и меняет статус в одной короткой транзакции, иначе lock снимется только на COMMIT.
Если задачи лёгкие, меняем LIMIT 1 на LIMIT 10 — воркер забирает пачку, сеть до базы окупается на пачке.
О чём часто забывают
Без индекса SELECT превращается в seq scan, и очередь встаёт на минимальной нагрузке. Partial index на pending — то, что нужно:
CREATE INDEX idx_jobs_pending
ON jobs (id)
WHERE status = 'pending';
Индекс маленький — только необработанные задачи. После смены статуса строка выпадает из индекса, bloat не копится.
Второе — воркер может упасть посреди работы. Задача останется в processing навсегда. Решение — lease pattern: колонка taken_at и крон, возвращающий застрявшее в pending:
UPDATE jobs
SET status = 'pending', taken_at = NULL
WHERE status = 'processing'
AND taken_at < now() - interval '10 minutes';
Retry живёт в колонке attempts с лимитом попыток.
Транзакция с FOR UPDATE держит lock до COMMIT, закрываем быстро. Работа с payload идёт после, финальный статус фиксируем отдельным UPDATE. Приоритеты через ORDER BY, отложенные задачи через WHERE scheduled_at <= now().
Где это перестаёт работать? На миллионах задач в час PostgreSQL страдает от write amplification: несколько UPDATE на задачу, autovacuum не успевает, копится bloat. Тогда пора на Kafka. Но для сотен тысяч в час SKIP LOCKED работает отлично: транзакционность, retry, приоритеты, delayed jobs — всё в одной базе рядом с прикладными данными.
Если PostgreSQL в стеке и объёмы разумные, SKIP LOCKED закрывает большинство сценариев без брокера. Не зря на нём построены Sidekiq, PG-boss, GoodJob, Solid Queue, River и куча самописных очередей, которые работают годами.
#backendvkhub #postgresql