Коротко — нужно либо сделать все изменения в транзакции, либо выполнять атомарные условные обновления (и при неудаче откатывать уже сделанные изменения). Важно сначала сгруппировать позиции запроса по id (чтобы учесть несколько одинаковых позиций), затем проверять/декрементировать запасы атомарно. Ниже — несколько вариантов с примерами кода.
1) Рекомендация (если у вас БД поддерживает транзакции): выполнить все проверки и уменьшения в одной транзакции. Пример для MongoDB + Mongoose:
```javascript
// Express handler
app.post('/buy', async (req, res) => {
const items = req.body.items; // [{ productId, qty }, ...]
// сгруппировать по productId
const counts = {};
for (const it of items) counts[it.productId] = (counts[it.productId] || 0) + it.qty;
const session = await mongoose.startSession();
try {
await session.withTransaction(async () => {
// для каждой позиции попытаться снизить stock на qty,
// условие в фильтре гарантирует, что не уйдем в минус
for (const [productId, qty] of Object.entries(counts)) {
const updated = await Product.findOneAndUpdate(
{ _id: productId, stock: { $gte: qty } },
{ $inc: { stock: -qty } },
{ session, new: true }
);
if (!updated) {
// если хоть один товар недоступен — выбросим ошибку, транзакция отменится
throw new Error(`Insufficient stock for product ${productId}`);
}
}
// здесь можно создать запись заказа и т.п.
});
res.status(200).json({ ok: true });
} catch (err) {
await session.abortTransaction(); // на всякий случай
res.status(409).json({ ok: false, message: err.message });
} finally {
session.endSession();
}
});
```
Примечания:
- MongoDB транзакции работают только на реплика сете; если у вас standalone — транзакций нет.
- findOneAndUpdate с фильтром stock: {$gte: qty} делает проверку и декремент атомарно (в пределах транзакции — дополнительно защищает от конкурирующих транзакций).
2) Если транзакций нет (или хотите избежать), можно делать атомарные условные обновления по одному и при неудаче откатывать предыдущие изменения (ручной «rollback»). Этот подход сложнее и менее устойчив к сбоям, но иногда приемлем:
```javascript
app.post('/buy', async (req, res) => {
const counts = /* сгруппировать как выше */;
const updatedIds = [];
try {
for (const [productId, qty] of Object.entries(counts)) {
// попробуем уменьшить атомарно; если фильтр не прошел — null
const updated = await Product.findOneAndUpdate(
{ _id: productId, stock: { $gte: qty } },
{ $inc: { stock: -qty } },
{ new: true }
);
if (!updated) {
// откатываем все, что уже уменьшили
for (const u of updatedIds) {
await Product.updateOne({ _id: u.id }, { $inc: { stock: u.qty } });
}
return res.status(409).json({ ok: false, message: 'Insufficient stock' });
}
updatedIds.push({ id: productId, qty });
}
// создать заказ
res.json({ ok: true });
} catch (err) {
// попытаться откатить при ошибке
for (const u of updatedIds) {
try { await Product.updateOne({ _id: u.id }, { $inc: { stock: u.qty } }); } catch (e) {}
}
res.status(500).json({ ok: false });
}
});
```
3) SQL (Postgres/MySQL): использовать транзакцию + SELECT ... FOR UPDATE или UPDATE с проверкой в транзакции:
Пример псевдокода с mysql2/await:
```javascript
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
// получаем и блокируем строки
for (const [id, qty] of Object.entries(counts)) {
const [rows] = await conn.query('SELECT stock FROM products WHERE id = ? FOR UPDATE', [id]);
if (!rows.length || rows[0].stock < qty) {
await conn.rollback();
return res.status(409).json({ ok: false, message: 'Insufficient stock' });
}
}
// если всё ок, делаем update
for (const [id, qty] of Object.entries(counts)) {
await conn.query('UPDATE products SET stock = stock - ? WHERE id = ?', [qty, id]);
}
// создать запись заказа
await conn.commit();
res.json({ ok: true });
} catch (err) {
await conn.rollback();
res.status(500).json({ ok: false });
} finally {
conn.release();
}
```
Рекомендации и замечания
- Всегда группируйте одинаковые productId (иначе можно перепродать при суммировании).
- Предпочтительны транзакции БД — они проще и надежнее для консистентности при параллельных запросах.
- Возвращайте 409 Conflict или 400 с понятным сообщением при нехватке товара.
- Логируйте и тестируйте конкурентные сценарии (несколько покупок одного товара одновременно).
- Если используете MongoDB и транзакции — ваша БД должна быть в replica set.
Если покажете, какую именно БД и ORM/драйвер используете (MongoDB/Mongoose, Postgres/knex, MySQL/mysql2 и т.д.), дам точный пример, подгоненный под ваш стек.