en
Feedback
Backend VK Hub

Backend VK Hub

Open in Telegram

Комьюнити VK для бэкендеров. Cамые хардовые кейсы, дискуссии в кругу своих и прямой доступ к нашим экспертам 😎

Show more
1 132
Subscribers
-124 hours
-27 days
-430 days
Posts Archive
Redis — это не только строки. Шесть структур данных, которые закрывают целые классы задач Большинство используют Redis как ke
+5
Redis — это не только строки. Шесть структур данных, которые закрывают целые классы задач Большинство используют Redis как key-value кеш: SET, GET, EXPIRE. При этом внутри есть Sorted Sets для рейтингов, HyperLogLog для приблизительного подсчёта уникальных значений, Bitmaps для аналитики, GEO для геопоиска, Hash для объектов и Sets для тегов. Каждая структура закрывает свой класс задач, где обычные строки либо не тянут по производительности, либо требуют кучи хаков. Пройдёмся по одной штуке — с реальным use case и командами. #backendvkhub #redis

Video message00:22

Video message00:36

Video message00:15

Мы начинаем 🚀

Video message00:14

Считанные минуты остаются до старта двух основных треков выступления. А пока делимся закулисьем атмосферы на нашем митапе ⬇️

Как правильно делать параллельные задачи с отменой в Go Знакомая картина в хендлере Go-сервиса: пришёл запрос с userID, надо
Как правильно делать параллельные задачи с отменой в Go Знакомая картина в хендлере Go-сервиса: пришёл запрос с userID, надо параллельно сходить в БД за пользователем, в Redis за сессией и во внешний API за биллингом. Первый рефлекс — три горутины и sync.WaitGroup.  Работает, пока одна не падает: тогда WaitGroup ждёт остальные, они молотят вхолостую и возвращают никому не нужный результат. У sync.WaitGroup нет знания ни про ошибки, ни про отмену. Правильный инструмент — golang.org/x/sync/errgroup. Обёртка над sync.WaitGroup, работающая с контекстом: первая ошибка отменяет остальные горутины через общий ctxWait() возвращает её.
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/sqlnet/httpredis уважают контекст, ошибки быстро всплывают. 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 #go

Как запретить пересечения периодов без триггеров в PostgreSQL Стандартная задача из SaaS: календарь бронирования созвонов. Од
Как запретить пересечения периодов без триггеров в 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 #postgresql

📢 Неделя до «Митапа с двойной начинкой»! 26 августа собираем Go- и Java-разработчиков на мероприятие от VK Just Tech. Вас жд
📢 Неделя до «Митапа с двойной начинкой»! 26 августа собираем Go- и Java-разработчиков на мероприятие от VK Just Tech. Вас ждут два трека выступлений, общение и, конечно, афтепати с квизом, шавермой и мастер-классом✨ ➡️ В треке Go разберёмся в нестандартной компиляции для ускорения выкатки фичи, расскажем, как в VK выстроили единую платформу для разработки Go-сервисов и как устроена доставка информации об адресах через сетевую инфраструктуру. ➡️ В треке Java поговорим о перформанс-трюках в поисковой платформе, разберём архитектуру современного платёжного движка и обсудим переход от федерации кластеров хранения к мультитенантному решению. 📍 26 августа, Санкт‑Петербург, Крестовский остров, ресторан Royal Beach Встречаемся в 17:00, начало в 18:00.  Участие бесплатное, регистрация обязательна. Зарегистрироваться Ждём вас! #backendvkhub #meetup #go #java #митап

OneDWH VK: как консолидировали 0,5+ EB данных из 35 бизнес-вертикалей В VK исторически сложилась децентрализованная модель уп
+5
OneDWH VK: как консолидировали 0,5+ EB данных из 35 бизнес-вертикалей В VK исторически сложилась децентрализованная модель управления данными: каждая из 35 бизнес-вертикалей развивала аналитику самостоятельно и выбирала технологии независимо от соседей. К моменту решения о переезде в облако это выросло в 10+ железных кластеров под Airflow и Hadoop, 0,5+ EB аналитических данных и тысячи кросс-запросов в месяц между командами. Пока команд было около десяти, расхождения снимались прямыми договорённостями. Когда специалистов стало больше сотни, подход перестал масштабироваться, и в компании начали строить единое хранилище. В карточках — чем именно мешала разрозненная инфраструктура, какие принципы легли в основу новой платформы, как проходила миграция 30 тысяч объектов и что получилось на выходе. ➡️ Полная версия статьи #backendvkhub #dwh #обзорстатьи

Gel — TypeScript для PostgreSQL Запрос с тремя уровнями вложенности через ORM — это не один SQL, а 5–10 отдельных запросов. P
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 #gel

Инвалидация кеша: скорость доступа vs согласованность данных Кеш и источник данных не могут быть строго консистентны. Если бы
Инвалидация кеша: скорость доступа 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 #кеш #архитектура

Вам доклады по Go или Java? VK приглашает на «Митап с двойной начинкой» 📢 26 августа собираем Go- и Java-инженеров, чтобы пр
Вам доклады по 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 #митап

🔵PostgreSQL 19: Beta 2 Июль в бэкенде — история про PostgreSQL. 16 июля вышла Beta 2 версии 19: Feature freeze означает, что
🔵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 #дайджест

VK и JUG Ru Group изучили, какими инструментами сейчас пользуются Java-разработчики. Главный вывод: команды активно внедряют
VK и JUG Ru Group изучили, какими инструментами сейчас пользуются Java-разработчики. Главный вывод: команды активно внедряют ИИ, но не спешат менять проверенный стек. Что показало исследование ➡️ Java 21 стала основной версией языка — её используют две трети разработчиков ➡️ 95% специалистов работают с ИИ, больше половины — каждый день ➡️ Spring Boot, PostgreSQL и Kafka остаются основой большинства проектов ➡️ Агентные ИИ-инструменты пока не стали массовыми Получается интересная картина: новые инструменты быстро входят в ежедневную практику, а фундамент технологического стека остаётся прежним. 🚀 Java меняется, но без революций. Полные результаты исследования #backendvkhub #java #исследование

Нативные lazy objects в PHP Ленивая загрузка в PHP до 8.4 жила на генерации кода. Doctrine отдавала из репозитория не сущност
Нативные 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 #php

HOT updates и FILLFACTOR — как избежать bloat на UPDATE-heavy таблицах В PostgreSQL каждый UPDATE из-за MVCC создаёт новую ве
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 #postgresql

Типы индексов в PostgreSQL — не только btree PostgreSQL знает шесть типов индексов. Большинство разработчиков всю жизнь работ
+5
Типы индексов в PostgreSQL — не только btree PostgreSQL знает шесть типов индексов. Большинство разработчиков всю жизнь работает с одним — btree. Остальные пять закрывают целые классы задач, где btree либо не работает, либо съедает в разы больше места. В карточках пройдёмся по каждому — от базового btree до BRIN, который сжимает индекс на терабайтную таблицу до десятков килобайт. #backendvkhub #postgresql

Очередь задач в PostgreSQL без Redis и гонок Пять воркеров разгребают задачи. Первое, что приходит: SELECT с pending, потом U
Очередь задач в 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