Node.js — Transaction, xatolarni boshqarish

~20 daq · MySQL · MySQL va Dasturlash Tillari

Muammodan boshlaylik

O'tgan darsdagi API ga buyurtma berish endpointi kerak:

app.post('/api/buyurtmalar', async (req, res) => {
  const [b] = await pool.execute('INSERT INTO buyurtmalar (mijoz_id) VALUES (?)', [mijozId]);
  for (const q of qatorlar) {
    await pool.execute('INSERT INTO buyurtma_qatorlari ...', [...]);
    await pool.execute('UPDATE mahsulotlar SET soni = soni - ? WHERE id = ?', [...]);
  }
  res.json({ id: b.insertId });
});

Uchinchi mahsulotda xato chiqdi (omborda yo'q yoki tarmoq uzildi). Natija: buyurtma yaratildi, ikkita mahsulot ombordan ayirildi, uchinchisi — yo'q. Baza yarim-tayyor holatda (36-dars).

Buni Node.js da to'g'ri tranzaksiya bilan qanday yozish kerak?


Asosiy qoida: bitta ulanish

Tranzaksiya — ulanishga bog'liq. pool.execute() har safar pooldan istalgan bo'sh ulanishni oladi:

// ❌ Har bir so'rov boshqa ulanishda bo'lishi mumkin
await pool.query('START TRANSACTION');   // ulanish #3
await pool.execute('INSERT ...');         // ulanish #7 — tranzaksiyadan tashqarida!
await pool.query('COMMIT');               // ulanish #2 — hech narsani commit qilmaydi

To'g'risi — pooldan bitta ulanishni olib, butun tranzaksiyani shu ulanishda bajarish:

const conn = await pool.getConnection();
try {
  await conn.beginTransaction();
  // ... barcha so'rovlar conn orqali
  await conn.commit();
} catch (err) {
  await conn.rollback();
  throw err;
} finally {
  conn.release();
}

Uchta majburiy element:

  1. try — beginTransaction ... commit
  2. catch — rollback va xatoni yuqoriga uzatish
  3. finally — release(), har qanday holatda (54-dars: leak)

Buyurtma berish — to'liq misol

import { pool } from './db.js';

export class OmbordaYetarliEmas extends Error {
  constructor(mahsulotId) {
    super(`Omborda yetarli emas: mahsulot #${mahsulotId}`);
    this.mahsulotId = mahsulotId;
  }
}

export async function buyurtmaBerish(mijozId, qatorlar) {
  // Deadlock xavfini kamaytirish: doim bir xil tartibda qulflash (38-dars)
  const saralangan = [...qatorlar].sort((a, b) => a.mahsulotId - b.mahsulotId);
  const ids = saralangan.map(q => q.mahsulotId);

  const conn = await pool.getConnection();
  try {
    await conn.beginTransaction();

    // 1. Mahsulotlarni qulflab o'qish — eng yangi narx va son
    const [mahsulotlar] = await conn.query(
      'SELECT id, narx, soni FROM mahsulotlar WHERE id IN (?) ORDER BY id FOR UPDATE',
      [ids]
    );
    const xarita = new Map(mahsulotlar.map(m => [m.id, m]));

    for (const q of saralangan) {
      const m = xarita.get(q.mahsulotId);
      if (!m || m.soni < q.soni) {
        throw new OmbordaYetarliEmas(q.mahsulotId);
      }
    }

    // 2. Buyurtma
    const [b] = await conn.execute(
      'INSERT INTO buyurtmalar (mijoz_id) VALUES (?)',
      [mijozId]
    );
    const buyurtmaId = b.insertId;

    // 3. Qatorlar — bitta INSERT da
    const values = saralangan.map(q => [buyurtmaId, q.mahsulotId, q.soni, xarita.get(q.mahsulotId).narx]);
    await conn.query(
      'INSERT INTO buyurtma_qatorlari (buyurtma_id, mahsulot_id, soni, narx) VALUES ?',
      [values]
    );

    // 4. Ombordan ayirish
    for (const q of saralangan) {
      await conn.execute(
        'UPDATE mahsulotlar SET soni = soni - ? WHERE id = ?',
        [q.soni, q.mahsulotId]
      );
    }

    await conn.commit();
    return buyurtmaId;
  } catch (err) {
    await conn.rollback();
    throw err;
  } finally {
    conn.release();
  }
}

Muhim jihatlar:

  • FOR UPDATE — tekshiruv va ayirish orasida boshqa so'rov oxirgi donani "o'g'irlay olmaydi" (38-dars)
  • Narx bazadan olinadi, mijoz yuborgan qiymatdan emas — aks holda kimdir narx: 1 deb yuboradi
  • VALUES ? + ichma-ich massiv — mysql2 ko'p qatorli INSERT ni o'zi yasaydi (query bilan, execute emas)
  • O'z xato classi — biznes xatosini texnik xatodan ajratish uchun

Endpoint:

app.post('/api/buyurtmalar', async (req, res, next) => {
  try {
    const id = await buyurtmaBerish(req.user.id, req.body.qatorlar);
    res.status(201).json({ data: { id } });
  } catch (err) {
    if (err instanceof OmbordaYetarliEmas) {
      return res.status(409).json({ xato: err.message, mahsulotId: err.mahsulotId });
    }
    next(err);
  }
});

Qayta ishlatiladigan yordamchi

Har safar try/catch/finally ni yozish — takrorlanish va xato xavfi. Bir marta yozib qo'yamiz:

export async function tranzaksiya(fn) {
  const conn = await pool.getConnection();
  try {
    await conn.beginTransaction();
    const natija = await fn(conn);
    await conn.commit();
    return natija;
  } catch (err) {
    await conn.rollback();
    throw err;
  } finally {
    conn.release();
  }
}

Ishlatish:

const buyurtmaId = await tranzaksiya(async (conn) => {
  const [b] = await conn.execute('INSERT INTO buyurtmalar (mijoz_id) VALUES (?)', [mijozId]);
  await conn.execute('UPDATE mijozlar SET oxirgi_buyurtma = NOW() WHERE id = ?', [mijozId]);
  return b.insertId;
});

commit, rollback, release ni unutish endi imkonsiz.


Deadlock — qayta urinish

Yuqori yuklamada deadlock (38-dars) vaqti-vaqti bilan bo'ladi. Uni xato sifatida qaytarish o'rniga — avtomatik qayta urinish:

const QAYTA_URINILADIGAN = new Set(['ER_LOCK_DEADLOCK', 'ER_LOCK_WAIT_TIMEOUT']);

export async function tranzaksiyaQaytaUrinish(fn, urinishlar = 3) {
  for (let i = 1; ; i++) {
    try {
      return await tranzaksiya(fn);
    } catch (err) {
      if (!QAYTA_URINILADIGAN.has(err.code) || i >= urinishlar) throw err;
      const kutish = 50 * 2 ** i + Math.random() * 50;   // 100, 200, 400 ms + tasodifiy
      await new Promise(r => setTimeout(r, kutish));
    }
  }
}

Tasodifiy qo'shimcha (jitter) — to'qnashgan ikki so'rov bir vaqtda qayta urinib, yana to'qnashmasligi uchun.

[!WARNING] Qayta urinish faqat butun tranzaksiya qayta bajarilganda xavfsiz. Agar fn ichida tashqi amal bo'lsa (email yuborish, to'lov API) — u ikki marta bajariladi. Tashqi amallarni tranzaksiyadan keyin bajaring (36-dars).


Savepoint — ichma-ich amallar

await tranzaksiya(async (conn) => {
  for (const [i, qator] of importQatorlari.entries()) {
    await conn.query(`SAVEPOINT q${i}`);
    try {
      await conn.execute('INSERT INTO mahsulotlar (nom, narx) VALUES (?, ?)', [qator.nom, qator.narx]);
      await conn.query(`RELEASE SAVEPOINT q${i}`);
    } catch (err) {
      await conn.query(`ROLLBACK TO SAVEPOINT q${i}`);
      xatolar.push({ qator: i + 1, xato: err.message });
    }
  }
});

Xato qatorlar tashlab ketiladi, qolganlari bitta tranzaksiyada saqlanadi (39-dars).


Global xatolar

// Pool darajasidagi xatolar (ulanish uzilishi)
pool.on('connection', conn => {
  conn.on('error', err => console.error('MySQL ulanish xatosi:', err.code));
});

// Ushlanmagan Promise xatolari — jarayon jimgina o'lmasin
process.on('unhandledRejection', err => {
  console.error('Ushlanmagan xato:', err);
  process.exit(1);   // jarayon menejeri (pm2, Docker) qayta ishga tushiradi
});

// To'g'ri to'xtash — ochiq ulanishlarni yopish
process.on('SIGTERM', async () => {
  await pool.end();
  process.exit(0);
});

Nima uchun shunday?

Nima uchun Node.js da tranzaksiya uchun alohida ulanish olish kerak, PHP da esa $pdo->beginTransaction() yetarli?

PHP-FPM da har bir HTTP so'rov — alohida jarayon va bitta ulanish. Bitta so'rov ichidagi barcha amallar avtomatik bitta ulanishda. Node.js esa bitta jarayon ichida minglab so'rovni bir vaqtda (asinxron) bajaradi va ular bitta pool ni bo'lishadi. await qilingan har bir nuqtada boshqa so'rov ishlashi mumkin. Agar tranzaksiya qadamlari turli ulanishlarga tushsa — ular boshqa so'rovlarning tranzaksiyalari bilan aralashib ketadi. Shuning uchun Node.js da ulanishni aniq "egallab olish" kerak.


Xulosa

Eslab qoling:

  • Tranzaksiya — bitta ulanishda: pool.getConnection()
  • try { begin...commit } catch { rollback; throw } finally { release }
  • SELECT ... FOR UPDATE — tekshirish va yozish orasidagi poygani to'xtatish
  • Narxlarni bazadan oling, mijoz yuborganidan emas
  • tranzaksiya(fn) yordamchisi — commit/rollback/release ni unutib bo'lmaydi
  • Deadlock / lock timeout — qayta urinish, exponential backoff + jitter
  • Tashqi amallar (email, to'lov) — tranzaksiyadan keyin
  • O'z xato classlari — biznes xatolarini HTTP 409/422 ga aylantirish

Keyingi dars

57-dars: Sequelize ORM — model, migration, association