
Гонки в данных — тихий убийца денег и репутации. Две вкладки с одним профилем клиента, два менеджера правят одну сделку, два воркера пересчитывают остатки — в итоге последняя запись «затирает» предыдущую. Теряются поля, счета уходят не теми суммами, пользователи видят неожиданные результаты. Пессимистические блокировки (держать «замок» на строке) гасят часть проблем, но тормозят весь поток: сессии ждут друг друга, таймауты, рост задержек.
Оптимистическая блокировка решает это мягче: никого не стопорим заранее, но перед записью проверяем, что мы редактируем ту же версию данных. Если нет — честно сообщаем о конфликте. В бизнес‑терминах: меньше простоя, меньше инцидентов, предсказуемая производительность.
Правильная система использует оба подхода там, где они уместны.
Добавляем целочисленное поле version и обновляем строку только если версия совпала. Если обновление прошло — версию инкрементим.
-- Пример: карточки товара
CREATE TABLE product (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
price_cents BIGINT NOT NULL CHECK (price_cents >= 0),
stock BIGINT NOT NULL CHECK (stock >= 0),
version BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX product_version_idx ON product (id, version);
-- Клиент прочитал product с version = :prev_version и предлагает изменения
UPDATE product
SET name = $2,
price_cents = $3,
updated_at = now(),
version = version + 1
WHERE id = $1 AND version = $4
RETURNING id, name, price_cents, stock, version, updated_at;
Если обновлено 0 строк — у нас конфликт версий. Возвращаем 409 Conflict или 412 Precondition Failed (см. ниже про ETag).
Когда меняем величину инкрементом/декрементом, лучше вообще не читать текущее значение на приложении:
-- Продать 1 единицу, не уходя в минус
UPDATE product
SET stock = stock - 1,
version = version + 1,
updated_at = now()
WHERE id = $1 AND stock >= 1
RETURNING id, stock, version;
Так мы избегаем «потери обновления» при параллельных продажах.
ETag — это «ярлык версии» ресурса. Клиент получает его при GET и обязан прислать обратно в If-Match при изменении. Сервер сравнивает и либо применяет правку, либо возвращает 412 Precondition Failed.
Правила:
Пример обмена:
GET /api/products/42
→ 200 OK
ETag: "42:3"
{
"id": 42, "name": "Подписка", "price_cents": 9900, "stock": 15, "version": 3
}
PUT /api/products/42
If-Match: "42:3"
{
"name": "Подписка PRO", "price_cents": 12900
}
→ 200 OK
ETag: "42:4"
{ ... "version": 4 }
PUT /api/products/42
If-Match: "42:3"
{ "price_cents": 10900 }
→ 412 Precondition Failed
{
"error": "version_conflict",
"message": "Ресурс изменился. Обновите данные и повторите."
}
Ниже минимальный, но рабочий сервер с двумя эндпоинтами: GET и PUT. Он:
// package.json (важные части)
// {
// "type": "module",
// "dependencies": { "express": "^4.18.2", "pg": "^8.11.3" }
// }
import express from 'express';
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const app = express();
app.use(express.json());
function makeETag(id, version) {
return `"${id}:${version}"`; // кавычки — как в HTTP-спеке
}
function parseIfMatch(h) {
if (!h) return null;
// ожидаем формат "id:version"
const m = /^"(\d+):(\d+)"$/.exec(h.trim());
if (!m) return null;
return { id: Number(m[1]), version: Number(m[2]) };
}
app.get('/api/products/:id', async (req, res) => {
const id = Number(req.params.id);
if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad_id' });
const { rows } = await pool.query(
'SELECT id, name, price_cents, stock, version, updated_at FROM product WHERE id = $1',
[id]
);
if (rows.length === 0) return res.sendStatus(404);
const r = rows[0];
res.set('ETag', makeETag(r.id, r.version));
res.json(r);
});
app.put('/api/products/:id', async (req, res) => {
const id = Number(req.params.id);
if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad_id' });
const ifMatch = parseIfMatch(req.get('If-Match'));
if (!ifMatch || ifMatch.id !== id) {
return res.status(428).json({ error: 'precondition_required', message: 'Нужен корректный If-Match' });
}
const { name, price_cents } = req.body ?? {};
if (name !== undefined && typeof name !== 'string') return res.status(400).json({ error: 'bad_name' });
if (price_cents !== undefined && (!Number.isInteger(price_cents) || price_cents < 0)) return res.status(400).json({ error: 'bad_price' });
const fields = [];
const values = [id];
let i = 2;
if (name !== undefined) { fields.push(`name = $${i++}`); values.push(name); }
if (price_cents !== undefined) { fields.push(`price_cents = $${i++}`); values.push(price_cents); }
if (fields.length === 0) return res.status(400).json({ error: 'no_changes' });
fields.push('updated_at = now()');
fields.push('version = version + 1');
values.push(ifMatch.version); // $i
const sql = `UPDATE product SET ${fields.join(', ')} WHERE id = $1 AND version = $${i} RETURNING id, name, price_cents, stock, version, updated_at`;
try {
const { rows } = await pool.query(sql, values);
if (rows.length === 0) {
return res.status(412).json({ error: 'version_conflict', message: 'Ресурс изменился. Обновите данные и повторите.' });
}
const r = rows[0];
res.set('ETag', makeETag(r.id, r.version));
res.json(r);
} catch (e) {
console.error(e);
res.sendStatus(500);
}
});
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`API on :${port}`));
Замечания:
Оптимистическую блокировку и пессимистическую можно комбинировать: сначала пытаемся по‑оптимистичному, при высокой конкуренции переходим к узкому критическому участку с блокировкой.
Оптимистическая блокировка и ETag — это простой, но мощный каркас для большинства бизнес‑приложений. Он сохраняет данные, снижает накладные расходы на блокировки и делает поведение системы предсказуемым даже при высокой конкуренции. Внедрив этот паттерн однажды, вы перестанете латать «странные» баги гонок и начнёте быстрее выпускать ценные фичи.