
Даже идеально написанное API начнёт врать, когда два клиента одновременно меняют одну и ту же сущность. Примеры:
Рынок не прощает таких ошибок: возвраты, ручные разборы, падение доверия. Хорошая новость: контролировать конкурентный доступ можно несколькими отработанными приёмами.
Пусть есть таблица остатков:
CREATE TABLE stock (
sku TEXT PRIMARY KEY,
qty INTEGER NOT NULL
);
INSERT INTO stock(sku, qty) VALUES ('SKU1', 1);
Два потока читают qty=1 и оба решают «резервировать 1». Если оба делают UPDATE без координации, итоговый qty может стать 0, хотя один из заказов должен был быть отклонён. Ещё хуже — конечное значение может быть пересчитано неправильно, если логика сложнее простого вычитания.
Ключевая идея — сравнение‑и‑обновление (compare‑and‑set, CAS): мы обновляем строку только если она не изменилась с момента чтения.
Добавим версионность:
ALTER TABLE stock ADD COLUMN version BIGINT NOT NULL DEFAULT 0;
Чтение: получаем qty и version. Обновление: меняем qty, если version совпадает, и инкрементим version.
-- попытка зарезервировать 1 единицу
WITH cur AS (
SELECT qty, version FROM stock WHERE sku = 'SKU1'
)
UPDATE stock s
SET qty = s.qty - 1,
version = s.version + 1
FROM cur
WHERE s.sku = 'SKU1' AND s.version = cur.version AND s.qty >= 1
RETURNING s.qty, s.version;
Если другая транзакция уже изменила строку, RETURNING ничего не вернёт — конфликт, клиенту нужно перечитать и повторить. Важно: такая операция атомарна и не требует явной блокировки.
package main
import (
"context"
"database/sql"
"fmt"
_ "github.com/lib/pq"
"log"
"time"
)
type Stock struct {
SKU string
Qty int64
Version int64
}
func reserveOne(ctx context.Context, db *sql.DB, sku string) (Stock, error) {
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil { return Stock{}, err }
defer func() { _ = tx.Rollback() }()
var s Stock
if err := tx.QueryRowContext(ctx, "SELECT sku, qty, version FROM stock WHERE sku=$1 FOR SHARE", sku).
Scan(&s.SKU, &s.Qty, &s.Version); err != nil {
return Stock{}, err
}
// CAS-обновление по версии
row := tx.QueryRowContext(ctx, `
UPDATE stock SET qty = qty - 1, version = version + 1
WHERE sku = $1 AND version = $2 AND qty >= 1
RETURNING sku, qty, version
`, sku, s.Version)
if err := row.Scan(&s.SKU, &s.Qty, &s.Version); err != nil {
if err == sql.ErrNoRows {
return Stock{}, fmt.Errorf("conflict or not enough stock")
}
return Stock{}, err
}
if err := tx.Commit(); err != nil { return Stock{}, err }
return s, nil
}
func main() {
db, err := sql.Open("postgres", "postgres://user:pass@localhost:5432/app?sslmode=disable")
if err != nil { log.Fatal(err) }
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
s, err := reserveOne(ctx, db, "SKU1")
if err != nil { log.Println("reserve failed:", err); return }
log.Printf("reserved: %+v\n", s)
}
Здесь SELECT ... FOR SHARE защищает от удаления строки, но не блокирует на запись — это нормально: контроль делает WHERE version = ...
Для API с редкими конфликтами удобно использовать ETag (хэш содержимого) и заголовок If-Match. Клиент читает ресурс, получает ETag, отправляет PUT/PATCH с If-Match:
// Пример на Node.js (express + pg)
const express = require('express');
const { Pool } = require('pg');
const crypto = require('crypto');
const app = express();
app.use(express.json());
const pool = new Pool({ connectionString: process.env.DSN });
function etagOf(obj) {
return 'W/"' + crypto.createHash('sha1').update(JSON.stringify(obj)).digest('hex') + '"';
}
app.get('/profile/:id', async (req, res) => {
const { rows } = await pool.query('SELECT id, name, address, version FROM profile WHERE id=$1', [req.params.id]);
if (!rows[0]) return res.sendStatus(404);
const etag = etagOf(rows[0]);
res.set('ETag', etag).json(rows[0]);
});
app.patch('/profile/:id', async (req, res) => {
const ifMatch = req.header('If-Match');
if (!ifMatch) return res.status(428).send('Precondition Required');
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows: curRows } = await client.query('SELECT id, name, address, version FROM profile WHERE id=$1 FOR SHARE', [req.params.id]);
if (!curRows[0]) return res.sendStatus(404);
const cur = curRows[0];
const curEtag = etagOf(cur);
if (curEtag !== ifMatch) {
await client.query('ROLLBACK');
return res.status(412).send('Precondition Failed');
}
const { name, address } = req.body;
const { rows: upd } = await client.query(
'UPDATE profile SET name=$1, address=$2, version=version+1 WHERE id=$3 AND version=$4 RETURNING id, name, address, version',
[name ?? cur.name, address ?? cur.address, cur.id, cur.version]
);
await client.query('COMMIT');
const etag = etagOf(upd[0]);
res.set('ETag', etag).json(upd[0]);
} catch (e) {
await client.query('ROLLBACK');
res.status(500).send('error');
} finally {
client.release();
}
});
app.listen(3000);
Плюсы: минимум блокировок, отличная масштабируемость. Минус: нужно уметь корректно обработать конфликт на стороне клиента (показать обновлённые данные, предложить повтор).
Когда конкуренция высокая (например, воркер‑пул берёт задания из очереди, сотни потоков резервируют одно и то же SKU), безопаснее «запирать» запись на время операции.
BEGIN;
SELECT * FROM jobs WHERE status = 'pending' ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 10;
-- обрабатываем и помечаем
UPDATE jobs SET status='done' WHERE id IN (...);
COMMIT;
Для запасов:
BEGIN;
SELECT qty FROM stock WHERE sku='SKU1' FOR UPDATE;
-- строка захвачена, никто параллельно не поменяет qty
UPDATE stock SET qty = qty - 1 WHERE sku='SKU1' AND qty >= 1;
COMMIT;
Важно: держите транзакции максимально короткими — никакой сети, HTTP‑запросов и тяжёлой логики между SELECT FOR UPDATE и COMMIT. Иначе застрянете в очередях ожидания и словите дедлоки.
Вывод: изоляция не заменяет прикладную стратегию блокировок. Она лишь меняет вероятность конфликтов и типы аномалий.
Иногда нужно синхронизироваться по ключу, который не совпадает с одной строкой: например, вы пересчитываете агрегат по нескольким таблицам или обновляете сразу набор SKU.
Postgres даёт лёгкие «советующие» блокировки:
-- Берём лок по 64‑битному ключу (например, хэш customer_id)
SELECT pg_advisory_xact_lock(1234567890123456789);
-- В рамках транзакции никто другой не возьмёт этот же лок
Они не привязаны к строкам и живут до конца транзакции. Отлично подходят для сериализации по бизнес‑ключу, когда явный SELECT FOR UPDATE «не к чему» приложить. Но будьте осторожны с картой ключей: важна стабильная хеш‑функция и отсутствие коллизий для горячих сущностей.
Иногда лучший «лок» — это гарантия уникальности. Пример: не допустить два активных резерва на одну и ту же корзину.
CREATE TABLE reservations (
id BIGSERIAL PRIMARY KEY,
cart_id BIGINT NOT NULL,
sku TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX uniq_active ON reservations(cart_id, sku) WHERE cancelled_at IS NULL;
Попытка создать второй активный резерв упадёт с нарушением уникальности. Дальше — идемпотентный повтор с тем же ключом или корректный возврат 409 Conflict из API.
ALTER SYSTEM SET log_lock_waits = on;
ALTER SYSTEM SET deadlock_timeout = '200ms';
SELECT pg_reload_conf();
SELECT pid, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE state = 'active' AND wait_event IS NOT NULL;
SELECT locktype, mode, granted, pid, relation::regclass AS rel, virtualxid, transactionid
FROM pg_locks
ORDER BY granted, locktype;
Бизнес‑требование: не допустить продажи сверх остатка. Есть веб‑витрина (редкие конфликты) и батч‑процессы (высокая конкуренция). Решение — гибридная стратегия.
-- одной командой списываем при неизменной версии и достаточном остатке
UPDATE stock
SET qty = qty - $1, version = version + 1
WHERE sku = $2 AND version = $3 AND qty >= $1
RETURNING qty, version;
Если вернулась пустота — показываем пользователю обновлённый остаток и предлагаем изменить количество.
BEGIN;
-- забираем партию позиций на сборку, исключая конфликтующие
WITH batch AS (
SELECT id FROM order_items WHERE status='to_reserve'
ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 100
)
UPDATE order_items oi
SET status='reserving'
FROM batch b
WHERE oi.id = b.id;
COMMIT;
Далее каждый элемент резервируется с короткой транзакцией и блокировкой конкретного SKU:
BEGIN;
SELECT qty FROM stock WHERE sku=$1 FOR UPDATE;
UPDATE stock SET qty = qty - $2 WHERE sku=$1 AND qty >= $2;
UPDATE order_items SET status='reserved' WHERE id=$3; -- только если предыдущее UPDATE изменило строку
COMMIT;
Плюс уникальный индекс на активные резервы для страховки от дублей.
Технические практики:
Гонки — не «редкая дичь», а повседневность любой нагруженной системы. Правильный выбор между оптимистичной и пессимистичной блокировкой позволяет одновременно защитить деньги и сохранить скорость. Думайте от бизнес‑рисков и профиля нагрузки: где конфликты редки — используйте CAS/ETag; где горячо — берите локи короткими транзакциями и распределяйте работу. Не забывайте про мониторинг: как только появятся ожидания и дедлоки, бизнес почувствует это быстрее всех. С приведёнными приёмами и кусками кода у вас есть готовый каркас, чтобы закрыть тему гонок без лишней боли и вкладок с инцидентами.