IlmHamroh
JavaScript Full-stack/29-qism. Backend integratsiyalar: fayl, to'lov, bot, realtime50/69-dars18 daqiqa
Mundarija (22)

Transactional outbox: bazaga yozish va xabar yuborish birga

Qisqacha: "Vazifani bazaga yoz, keyin navbatga hodisa qo'y" — ikki xil tizimga ikki yozuv (dual write). Ular orasida jarayon o'lsa, vazifa bor, hodisa yo'q: eslatma va webhook hech qachon ketmaydi. Kanonda 100 vazifadan 10 tasining hodisasi yo'qoldi. Outbox naqshi: hodisani navbatga emas, o'sha bazadagi outbox jadvaliga, vazifa bilan bitta tranzaksiyada yozing. Alohida relay jarayoni jadvaldan o'qib, navbatga tarqatadi va belgilaydi. Natija — 100/100. Kafolat at-least-once: iste'molchi tomonda inbox jadvali bilan takrorni ushlang.

Bu darsda

  • Dual write muammosini o'lchov bilan ko'rasiz.
  • outbox jadvali va hodisaYoz — hodisani ma'lumot bilan bitta tranzaksiyada yozasiz.
  • Relay qanday ishlashini, nega FOR UPDATE SKIP LOCKED kerakligini bilasiz.
  • Yetkazish kafolatlarini (at-most, at-least, exactly-once) ajratasiz.
  • Inbox jadvali bilan idempotent iste'molchi yozasiz va CDC muqobilini bilasiz.

Oldin bilishingiz kerak: Tranzaksiya asoslari, SKIP LOCKED bilan DB-asosli navbat, BullMQ ishonchlilik, Nega navbat kerak.

Oldingi darsda broker'larni solishtirdik. Qaysi broker bo'lmasin, bitta savol qoladi: bazaga yozish va brokerga yuborish — ikkalasi birga bo'ladimi?

1. Nega bu kerak?

Malika vazifa yaratdi. API ikki ish qiladi:

js
await db.query("INSERT INTO vazifalar …");        // 1. bazaga
await navbat.add("vazifa.yaratildi", { id });   // 2. navbatga

Ikki qator — ikki xil tizim (Postgres va Redis). Ular orasida nima bo'lishi mumkin:

  • 1 bo'ldi, 2 bo'lmadi — jarayon o'ldi, Redis vaqtincha javob bermadi, deploy. Vazifa bor, lekin eslatma, bildirishnoma, webhook — hech biri ketmaydi. Hech kim bilmaydi.
  • Tartibni almashtirsak (avval navbat, keyin baza) — teskari: hodisa ketdi, vazifa esa ROLLBACK bo'ldi. Worker yo'q vazifa haqida email yuboradi.

Bu dual write (ikki joyga yozish) muammosi. Ikki tizimga atomik yozishning oddiy yo'li yo'q: Postgres tranzaksiyasi Redis'ni qamrab olmaydi.

2. Kanon o'lchovi: 10 ta hodisa yo'qoldi

Kanon 100 ta vazifa yaratdi. Har o'ninchisida (i % 10 === 3) "jarayon o'ldi" — COMMIT dan keyin, navbatga yozishdan oldin. Keyin xuddi shu ssenariy outbox bilan. Har qatorda bazadagi va navbatdagi sonni solishtiring:

text
bazada 100 vazifa, navbatda 90 hodisa — 10 tasi YO'QOLDI (eslatma/webhook hech qachon ketmaydi)
relay tarqatdi: 100, navbatda: 100 — yo'qotish 0
o'sha hodisa qayta yuborildi (at-least-once) → navbatda baribir 100 (jobId = hodisa_id)

Nima bo'ldi? Uchta qator — butun dars:

  1. Dual write: 10 % yo'qotish.
  2. Outbox: 0 yo'qotish.
  3. Relay bir hodisani ikki marta yuborsa ham, jobId = hodisa_id tufayli navbatda bitta.

Mantiqni modelda ko'raylik:

js
// Kanon ssenariysi modeli: 100 vazifa, har 10-chisida (i % 10 === 3)
// jarayon COMMIT'dan keyin, navbatga yozishdan OLDIN o'ladi.
function dualWrite() {
  const baza = [];
  const navbat = [];
  for (let i = 0; i < 100; i++) {
    baza.push({ id: i });                 // COMMIT bo'ldi
    if (i % 10 === 3) continue;           // jarayon o'ldi
    navbat.push({ vazifaId: i });         // navbatga
  }
  return `bazada ${baza.length}, navbatda ${navbat.length}`;
}

function outbox() {
  const baza = { vazifalar: [], outbox: [] };
  for (let i = 0; i < 100; i++) {
    // bitta tranzaksiya: vazifa va hodisa birga;
    // shu yerda jarayon o'lsa ham hodisa bazada
    baza.vazifalar.push({ id: i });
    baza.outbox.push({ hodisaId: `h-${i}`, tarqatildi: false });
  }
  const navbat = new Map();               // jobId = hodisaId
  for (const h of baza.outbox.filter((x) => !x.tarqatildi)) {
    navbat.set(h.hodisaId, h);            // relay
    h.tarqatildi = true;
  }
  navbat.set("h-0", baza.outbox[0]);      // qayta yuborildi
  return `bazada ${baza.vazifalar.length}, navbatda ${navbat.size}`;
}

console.log("dual write:", dualWrite());
console.log("outbox:    ", outbox());

Konsolda:

text
dual write: bazada 100, navbatda 90
outbox:     bazada 100, navbatda 100

3. Outbox naqshi

sequenceDiagram
  participant API
  participant DB as Postgres
  participant R as Relay
  participant Q as Navbat (BullMQ)
  participant W as Worker
  API->>DB: BEGIN
  API->>DB: INSERT vazifalar
  API->>DB: INSERT outbox (hodisa)
  API->>DB: COMMIT - ikkalasi yoki hech biri
  loop har N soniya
    R->>DB: SELECT ... FOR UPDATE SKIP LOCKED
    R->>Q: add (jobId = hodisa_id)
    R->>DB: UPDATE tarqatilgan_vaqt = now()
  end
  Q->>W: ish
  W->>DB: inbox + biznes amali (bitta tranzaksiya)

G'oya: navbatga yozish tranzaksiyadan tashqariga chiqariladi. API faqat bitta tizimga — Postgres'ga — yozadi, bitta tranzaksiyada. Hodisani navbatga yetkazish — alohida, qayta urinib bo'ladigan ish.

3.1 outbox jadvali

Kanon sxemasi (204-outbox.sql, ustunlar orasidagi bo'shliqlarni qisqartirdik):

sql
CREATE TABLE outbox (
  id               bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  hodisa_id        uuid NOT NULL DEFAULT gen_random_uuid() UNIQUE,
  tur              text        NOT NULL,
  foydalanuvchi_id bigint      NOT NULL,
  malumot          jsonb       NOT NULL,
  yaratilgan_vaqt  timestamptz NOT NULL DEFAULT now(),
  tarqatilgan_vaqt timestamptz,
  urinishlar       int         NOT NULL DEFAULT 0
);
-- faqat tarqatilmaganlar — relay so'rovi uchun kichik qisman indeks
CREATE INDEX outbox_navbat_idx ON outbox (id)
  WHERE tarqatilgan_vaqt IS NULL;

-- ilova roli faqat yoza oladi; o'qish/belgilash — relay
GRANT INSERT ON outbox TO vazifalar_ilova;
GRANT USAGE ON SEQUENCE outbox_id_seq TO vazifalar_ilova;
REVOKE SELECT, UPDATE, DELETE ON outbox FROM vazifalar_ilova;
Ustun Nima uchun
id relay tartibi (ORDER BY id)
hodisa_id hodisaning global nomi — iste'molchi dedup'i va BullMQ jobId
tur vazifa.yaratildi, vazifa.bajarildi — biznes ma'nosi
malumot kichik JSON (id'lar, Nega navbat kerak darsidagi qoida)
tarqatilgan_vaqt NULL — hali yetkazilmagan
urinishlar nechta urinish bo'ldi — kuzatuv uchun

Qisman indeks (Partial, expression va covering indekslar) faqat tarqatilmagan qatorlarni o'z ichiga oladi: jadvalda millionta eski hodisa bo'lsa ham relay so'rovi kichik indeksni o'qiydi.

Trigger emas, ilova yozadi — kanon izohi: hodisa nomi biznes ma'nosini beradi ("vazifa bajarildi"), "qatordagi holat ustuni o'zgardi" emas.

3.2 Bitta tranzaksiya — haqiqatan

Biz o'z PostgreSQL 17.6 klasterimizda kanon sxemasini (001–204) qo'llab tekshirdik. Avval tuzilma:

sql
SELECT column_name, data_type, column_default
FROM information_schema.columns
WHERE table_name = 'outbox' ORDER BY ordinal_position;
text
   column_name    |        data_type         |  column_default
------------------+--------------------------+-------------------
 id               | bigint                   |
 hodisa_id        | uuid                     | gen_random_uuid()
 tur              | text                     |
 foydalanuvchi_id | bigint                   |
 malumot          | jsonb                    |
 yaratilgan_vaqt  | timestamp with time zone | now()
 tarqatilgan_vaqt | timestamp with time zone |
 urinishlar       | integer                  | 0
(8 rows)

Vazifa va hodisa yoziladi, keyin ROLLBACK — "keyingi qadamda xato" bo'lgandek:

sql
BEGIN;
INSERT INTO vazifalar (egasi_id, matn)
VALUES (1, 'Hisobot tayyorlash');
INSERT INTO outbox (tur, foydalanuvchi_id, malumot)
VALUES ('vazifa.yaratildi', 1, '{"matn": "Hisobot tayyorlash"}');
ROLLBACK;
SELECT
  (SELECT count(*) FROM vazifalar
    WHERE matn = 'Hisobot tayyorlash') AS vazifa,
  (SELECT count(*) FROM outbox) AS hodisa;
text
BEGIN
INSERT 0 1
INSERT 0 1
ROLLBACK
 vazifa | hodisa
--------+--------
      0 |      0
(1 row)

Ikkalasi ham yo'q. Endi COMMIT:

sql
BEGIN;
INSERT INTO vazifalar (egasi_id, matn)
VALUES (1, 'Hisobot tayyorlash');
INSERT INTO outbox (tur, foydalanuvchi_id, malumot)
VALUES ('vazifa.yaratildi', 1, '{"matn": "Hisobot tayyorlash"}');
COMMIT;
SELECT tur, malumot, tarqatilgan_vaqt IS NULL AS kutmoqda, urinishlar
FROM outbox;
text
BEGIN
INSERT 0 1
INSERT 0 1
COMMIT
       tur        |            malumot             | kutmoqda | urinishlar
------------------+--------------------------------+----------+------------
 vazifa.yaratildi | {"matn": "Hisobot tayyorlash"} | t        |          0
(1 row)

Ikkalasi bor, hodisa kutmoqda — relay olishini kutadi. hodisa_id va vaqtni ataylab chiqarmadik — ular har safar boshqa.

Va ilova roli: yoza oladi, o'qiy olmaydi:

sql
BEGIN;
SET LOCAL ROLE vazifalar_ilova;
INSERT INTO outbox (tur, foydalanuvchi_id, malumot)
VALUES ('vazifa.bajarildi', 1, '{}');
SELECT count(*) FROM outbox;
ROLLBACK;
text
BEGIN
SET
INSERT 0 1
ERROR:  permission denied for table outbox
ROLLBACK

Nega? API buzilsa (masalan, SQL injection — so'rovga begona SQL qo'shish hujumi, 28-qismda batafsil), hujumchi outbox'dan boshqa foydalanuvchilarning hodisalarini o'qiy olmaydi va "tarqatildi" deb belgilab, hodisalarni yo'qota olmaydi. Relay alohida roldagi alohida jarayon.

4. Relay

4.1 Polling relay

Relay — har bir necha soniyada tarqatilmagan hodisalarni olib, navbatga qo'yadigan jarayon. Kanon modulidan (formatlab; Qator turini alohida e'lon qildik — mazmun bir xil):

ts
export async function relay(
  pool: Pool,
  tarqat: (h: OutboxHodisa) => Promise<void>,
  partiya = 100,
): Promise<{ tarqatildi: number; xato: number }> {
  const c = await pool.connect();
  try {
    await c.query("BEGIN");
    const r = await c.query<Qator>(
      "SELECT id, hodisa_id, tur, foydalanuvchi_id, malumot, " +
      "yaratilgan_vaqt FROM outbox WHERE tarqatilgan_vaqt IS NULL " +
      "ORDER BY id LIMIT $1 FOR UPDATE SKIP LOCKED",
      [partiya],
    );
    const ok: string[] = [];
    const yomon: string[] = [];
    for (const q of r.rows) {
      try {
        await tarqat({
          id: Number(q.id), hodisaId: q.hodisa_id, tur: q.tur,
          foydalanuvchiId: Number(q.foydalanuvchi_id),
          malumot: q.malumot, yaratilgan: q.yaratilgan_vaqt,
        });
        ok.push(q.id);
      } catch {
        yomon.push(q.id);
      }
    }
    if (ok.length) {
      await c.query(
        "UPDATE outbox SET tarqatilgan_vaqt = now(), " +
        "urinishlar = urinishlar + 1 WHERE id = ANY($1::bigint[])",
        [ok],
      );
    }
    if (yomon.length) {
      await c.query(
        "UPDATE outbox SET urinishlar = urinishlar + 1 " +
        "WHERE id = ANY($1::bigint[])",
        [yomon],
      );
    }
    await c.query("COMMIT");
    return { tarqatildi: ok.length, xato: yomon.length };
  } catch (e) {
    await c.query("ROLLBACK");
    throw e;
  } finally {
    c.release();
  }
}

Uch qaror:

  1. FOR UPDATE SKIP LOCKED — SKIP LOCKED darsidagi naqsh. Ikki relay nusxasi parallel ishlasa, biri olgan qatorlarni ikkinchisi o'tkazib yuboradi. Kanon testi: 200 hodisa, ikki relay — har biri aynan bir marta.
  2. Hodisa bo'yicha xato — bitta hodisa yiqilsa (tarqat xato otdi), faqat u qoladi, qolganlari belgilanadi. Partiya butunlay qaytmaydi.
  3. tarqat tashqaridan beriladi — relay BullMQ'ni bilmaydi. Kanon tajribasida: (h) => q.add(h.tur, h.malumot, { jobId: h.hodisaId }). Ertaga RabbitMQ yoki webhook bo'lsa ham relay o'zgarmaydi.

4.2 Nega at-least-once

Relay q.add qildi, keyin UPDATE … tarqatilgan_vaqt dan oldin o'ldi. Tranzaksiya ROLLBACK — hodisa hali "tarqatilmagan". Keyingi relay uni yana yuboradi. Hodisa yo'qolmaydi, lekin ikki marta ketishi mumkin.

Birinchi himoya — jobId = hodisa_id: BullMQ ikkinchi nusxani qabul qilmaydi (kanon: "navbatda baribir 100"). Lekin bu faqat ish navbatda turganda ishlaydi (BullMQ ishonchlilik darsidagi removeOnComplete eslatmasi). To'liq himoya — iste'molchi tomonda.

Tekshirib ko'ring: Relay tarqat ni tranzaksiya ichida chaqiradi — SELECT … FOR UPDATE dan keyin, COMMIT dan oldin. Nega bu muhim?

Javob

Qatorlar qulflangan holda tarqatiladi — boshqa relay ularni ololmaydi (SKIP LOCKED). Agar SELECT va COMMIT qilib, keyin tarqatib, keyin alohida UPDATE qilsak, oraliqda ikkinchi relay o'sha qatorlarni olib, ikkinchi marta yuboradi. Narxi — tranzaksiya tarqatish davomida ochiq turadi, shuning uchun partiya kichik (100) va tarqat tez bo'lishi kerak.

4.3 CDC — ikkinchi yo'l

Polling o'rniga Postgres'ning WAL jurnalidan (baza har o'zgarishni avval yozib qo'yadigan ichki jurnal) o'qish mumkin: Debezium kabi vositalar outbox jadvaliga tushgan har qatorni darhol Kafka'ga uzatadi (Change Data Capture). Afzalligi — kechikish kichik, bazaga so'rov yuki yo'q. Kamchiligi — yana bir infratuzilma (Kafka Connect). Kanon CDC'ni sinamagan; batafsil — CDC va ma'lumot sinxronlash. Kichik va o'rta loyihaga polling relay yetarli.

5. Yetkazish kafolatlari

Kafolat Ma'nosi Qachon bo'ladi
At-most-once ko'pi bilan bir marta, yo'qolishi mumkin avval ack, keyin ish; Redis Pub/Sub (Pub/Sub)
At-least-once kamida bir marta, takrorlanishi mumkin avval ish, keyin ack; outbox, BullMQ, RabbitMQ
Exactly-once aniq bir marta tarmoq orqali kafolatlab bo'lmaydi

Amalda "aniq bir marta ta'sir" = at-least-once yetkazish + idempotent iste'molchi. Iste'molchini idempotent qilishning ishonchli yo'li — inbox.

6. Inbox: iste'molchi tomonda

Iste'molchi har hodisani qayta ishlashdan oldin inbox jadvaliga hodisa_id ni yozadi. PRIMARY KEY takrorni rad etadi. Muhimi — inbox yozuvi va biznes amali bitta tranzaksiyada. Kanon tajribasi (formatlab):

js
await pool.query("CREATE TABLE inbox (" +
  "hodisa_id uuid PRIMARY KEY, " +
  "ishlangan timestamptz NOT NULL DEFAULT now())");

const iste = async (hodisaId) => {
  const c = await pool.connect();
  try {
    await c.query("BEGIN");
    const r = await c.query(
      "INSERT INTO inbox (hodisa_id) VALUES ($1) " +
      "ON CONFLICT DO NOTHING", [hodisaId]);
    if (r.rowCount) amal++; // biznes amali shu yerda
    await c.query("COMMIT");
  } finally { c.release(); }
};
for (let i = 0; i < 3; i++) await iste(birinchi.hodisa_id);
text
3 marta yetkazildi → biznes amali 1 marta (amal va inbox yozuvi BIR tranzaksiyada)

Nega bitta tranzaksiya: inbox yozuvi bo'lib, amal yiqilsa — keyingi yetkazishda "takror" deb o'tkazib yuboriladi, amal hech qachon bajarilmaydi. Amal bo'lib, inbox yozilmasa — keyingi safar ikkinchi marta bajariladi. Faqat birga — to'g'ri.

Biznes amali tashqi xizmat bo'lsa (email, to'lov API), uni Postgres tranzaksiyasiga qo'shib bo'lmaydi. U holda tashqi xizmatning idempotentlik kalitiga hodisa_id ni bering (Idempotency-Key) yoki holatni bazada qadam-baqadam saqlang — Durable workflow'lar darsi.

7. Loyiha qadami: outbox.ts va 204-outbox.sql

Kanon modulining qolgan qismi (formatlab):

ts
// outbox.ts — transactional outbox (sql/204): `hodisaYoz` —
// chaqiruvchining tranzaksiyasi ichida; `relay` — tarqatilmaganlarni
// partiyalab olib, tarqatuvchiga beradi.
import type { Pool, PoolClient } from "pg";

export type OutboxHodisa = {
  id: number; hodisaId: string; tur: string;
  foydalanuvchiId: number; malumot: unknown; yaratilgan: Date;
};

/** MUHIM: `c` — vazifani yozayotgan o'sha tranzaksiya mijozi.
 * COMMIT bo'lmasa hodisa ham yo'q, ROLLBACK bo'lsa — ikkalasi ham. */
export async function hodisaYoz(
  c: PoolClient, tur: string,
  foydalanuvchiId: number, malumot: unknown,
): Promise<string> {
  const r = await c.query<{ hodisa_id: string }>(
    "INSERT INTO outbox (tur, foydalanuvchi_id, malumot) " +
    "VALUES ($1, $2, $3) RETURNING hodisa_id",
    [tur, foydalanuvchiId, malumot],
  );
  const id = r.rows[0]?.hodisa_id;
  if (!id) throw new Error("outbox INSERT natijasiz");
  return id;
}

/** Eski tarqatilganlarni tozalash (jadval cheksiz o'smasin) —
 * rejali ish. */
export async function outboxTozala(
  pool: Pool, saqlashKun = 7,
): Promise<number> {
  const r = await pool.query(
    "DELETE FROM outbox WHERE tarqatilgan_vaqt < " +
    "now() - make_interval(days => $1)",
    [saqlashKun],
  );
  return r.rowCount ?? 0;
}

hodisaYoz Pool emas, PoolClient oladi — bu ataylab. pool.query har safar boshqa ulanishni olishi mumkin, demak boshqa tranzaksiyani. Funksiya imzosi chaqiruvchini "tranzaksiyadagi mijozingni ber" deyishga majbur qiladi. outboxTozala — Cron darsidagi tizim navbatining rejali ishi.

7.1 Testlar — biz ishga tushirdik

Kanon test/outbox.test.ts da 4 ta test yozdi: ROLLBACK, xato bergan hodisa keyingi relay'da, ikki parallel relay (200 hodisa — har biri bir marta), tozalash va ilova roli. Bu testlarga faqat Postgres kerak — Docker'siz, o'z PostgreSQL 17.6 klasterimizda kanon sxemasi bilan yurgizdik (modul nusxasi formatlangan, tsc va eslint 0 xato):

bash
DATABASE_URL=postgres://postgres@127.0.0.1:5517/vazifalar \
  node --test test/outbox.test.ts

Chiqishda har test oldidagi ✔ belgisiga va pastdagi pass / fail sonlariga qarang:

text
▶ transactional outbox (Postgres)
  ✔ ROLLBACK — vazifa ham hodisa ham yo'q; COMMIT — ikkalasi (108.2804ms)
  ✔ relay: tarqatadi, xato bergani qoladi (urinishlar++), keyingi safar yetkaziladi (25.4604ms)
  ✔ ikki relay parallel — har hodisa bir marta (SKIP LOCKED) (1790.2847ms)
  ✔ tozalash va ilova roli o'qiy olmaydi (7.237ms)
✔ transactional outbox (Postgres) (5178.8458ms)
ℹ tests 4
ℹ suites 1
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 6786.2436

Nima bo'ldi? To'rttala test o'tdi, skipped 0 — hech biri o'tkazib yuborilmadi. Vaqtlar har safar boshqacha. DATABASE_URL dagi trust ulanish — faqat lokal sinov klasteri uchun, parolsiz. Kanon PostgreSQL 18.6 da 82/82 o'tkazgan; biz 17.6 da 4/4 — sxema ikkala versiyada ishladi.

Commit:

bash
git commit -m "feat(navbat): transactional outbox — hodisa va vazifa birga"

vazifalar-api ga ulanganda vazifa yozadigan har tranzaksiya hodisaYoz ni chaqiradi; relay alohida jarayonda hodisalarni BullMQ'ga, SSE'ga va chiquvchi webhooklarga tarqatadi.

8. Ko'p uchraydigan xatolar

Belgi Sabab Davosi
Ba'zi hodisalar yo'qoladi dual write outbox
Hodisa bor, ma'lumot yo'q navbatga tranzaksiyadan oldin yozilgan outbox — COMMIT bilan birga
hodisaYoz tranzaksiyadan tashqarida yozdi pool.query ishlatilgan tranzaksiya mijozi (PoolClient)
Hodisa ikki marta ishlandi at-least-once inbox + jobId = hodisa_id
Relay sekinlashdi outbox jadvali katta, indeks yo'q qisman indeks, outboxTozala
Ikki relay bir hodisani yubordi SKIP LOCKED yo'q FOR UPDATE SKIP LOCKED
Hodisalar tartibi buzildi ko'p relay nusxasi bitta relay yoki kalit bo'yicha tartib (kanon izohi: global tartib kafolatlanmaydi)

9. Mashqlar

Mashqlar kurs/mashqlar/29/50-outbox/ da.

1-mashq (oson): bo'shliqlarni to'ldiring

  • Ikki tizimga alohida yozish muammosi — write
  • Hodisa ma'lumot bilan bitta da yoziladi
  • Outbox'dan o'qib tarqatuvchi jarayon —
  • Parallel relay'lar bir qatorni olmasligi uchun — FOR UPDATE
  • Iste'molchi tomondagi dedup jadvali —
Yechim
  • dual — Postgres va Redis orasida atomiklik yo'q.
  • tranzaksiya — COMMIT — ikkalasi, ROLLBACK — hech biri.
  • relay — polling yoki CDC.
  • SKIP LOCKED — band qatorlarni o'tkazib yuboradi.
  • inbox — hodisa_id PRIMARY KEY, amal bilan bitta tranzaksiyada.

2-mashq (o'rta): relay modeli

Beshta hodisali outbox berilgan. relay(partiya) funksiyasini yozing: tarqatilmaganlarni oladi, har biriga urinishlar++, tarqat(h) muvaffaqiyatli bo'lsa — tarqatildi = true, xato bo'lsa — qoldiradi; { tarqatildi, xato } qaytaradi. tarqat 3-hodisada birinchi safar xato otsin ("navbat yo'q"). Relay'ni uch marta chaqiring (birinchisidan keyin navbat tiklanadi), navbat tartibini va 3-hodisaning urinishlarini chiqaring.

Yechim
js
const outbox = [1, 2, 3, 4, 5].map((id) => ({
  id, tur: "vazifa.yaratildi", tarqatildi: false, urinishlar: 0,
}));

let navbatIshlaydi = false;
const navbat = [];
function tarqat(h) {
  if (h.id === 3 && !navbatIshlaydi) throw new Error("navbat yo'q");
  navbat.push(h.id);
}

function relay(partiya = 100) {
  const olingan = outbox
    .filter((h) => !h.tarqatildi)
    .slice(0, partiya);
  let ok = 0;
  let xato = 0;
  for (const h of olingan) {
    h.urinishlar++;
    try {
      tarqat(h);
      h.tarqatildi = true;
      ok++;
    } catch {
      xato++;
    }
  }
  return { tarqatildi: ok, xato };
}

console.log(relay());
navbatIshlaydi = true;
console.log(relay());
console.log(relay());
console.log("navbat:", navbat.join(", "));
console.log("3-hodisa urinishlari:", outbox[2].urinishlar);

Konsolda:

text
{ tarqatildi: 4, xato: 1 }
{ tarqatildi: 1, xato: 0 }
{ tarqatildi: 0, xato: 0 }
navbat: 1, 2, 4, 5, 3
3-hodisa urinishlari: 2

Kanon testi xuddi shu ketma-ketlikni tekshiradi (u yerda 6 ta hodisa: avval 5+1, keyin 1, keyin 0). E'tibor bering: navbatda tartib buzildi — 3 eng oxirida. Xato bergan hodisa keyingilarini to'xtatmaydi. Agar iste'molchi uchun tartib muhim bo'lsa (masalan, "yaratildi" "o'chirildi" dan oldin kelsin), bunday relay yetmaydi — hodisada versiya raqami yoki vaqt bo'lishi va iste'molchi eskisini rad etishi kerak.

3-mashq (qiyin): inbox va tranzaksiya

To'lov hodisasi ({ id: "t-1", summa: 50_000 }) iste'molchiga uch marta yetkazildi; birinchi safar jarayon amal o'rtasida o'ldi. "Baza"ni { inbox: Set, balans } bilan modellashtiring: tranzaksiya(fn) o'zgarishlarni nusxada qiladi va faqat xatosiz tugasa qo'llaydi. tolovQabul(hodisa, yiqilsinmi) — inbox'da bo'lsa o'tkazib yuborsin, bo'lmasa inbox'ga yozib balansni oshirsin. Uch yetkazishdan keyin balans qancha?

Yechim
js
// "Baza": tranzaksiyadagi o'zgarishlar faqat COMMIT'da qo'llanadi.
const baza = { inbox: new Set(), balans: 0 };

function tranzaksiya(fn) {
  const nusxa = { inbox: new Set(baza.inbox), balans: baza.balans };
  fn(nusxa); // xato otsa — nusxa tashlanadi (ROLLBACK)
  baza.inbox = nusxa.inbox; // COMMIT
  baza.balans = nusxa.balans;
}

function tolovQabul(hodisa, yiqilsinmi = false) {
  try {
    tranzaksiya((tx) => {
      if (tx.inbox.has(hodisa.id)) return; // takror
      tx.inbox.add(hodisa.id);
      tx.balans += hodisa.summa;
      if (yiqilsinmi) throw new Error("jarayon o'ldi");
    });
    return `${hodisa.id}: balans ${baza.balans}`;
  } catch (e) {
    const b = baza.balans;
    return `${hodisa.id}: ${e.message} — ROLLBACK, balans ${b}`;
  }
}

const h = { id: "t-1", summa: 50_000 };
console.log(tolovQabul(h, true)); // birinchi yetkazish — yiqildi
console.log(tolovQabul(h));       // qayta yetkazildi
console.log(tolovQabul(h));       // yana — takror

Konsolda:

text
t-1: jarayon o'ldi — ROLLBACK, balans 0
t-1: balans 50000
t-1: balans 50000

Birinchi yetkazishda inbox yozuvi ham, balans ham bekor bo'ldi — chunki ular bitta tranzaksiyada. Shuning uchun ikkinchi yetkazish "takror" deb o'tkazilmadi va pul tushdi. Uchinchisi — haqiqiy takror, balans o'zgarmadi. Inbox'ni alohida (tranzaksiyadan tashqarida) yozsangiz, birinchi yetkazishdan keyin t-1 inbox'da qolardi va pul hech qachon tushmasdi — buni o'zgartirib sinab ko'ring.

10. Real ishda

  • Mikroservislar orasida hodisa — outbox eng keng tarqalgan naqsh: buyurtma servisi "buyurtma yaratildi"ni outbox orqali Kafka/RabbitMQ'ga uzatadi.
  • Webhook'lar — "hodisa yuz berdi" yozuvi outbox'da, yetkazuvchi qayta urinadi (Chiquvchi webhook'lar).
  • Debezium + Kafka — katta tizimlarda CDC orqali outbox relay.
  • pg-boss — ish ma'lumot bilan bitta bazada bo'lgani uchun outbox'siz ham atomik (Boshqa xabar broker'lari).
  • Intervyuda: "Dual write muammosi nima?", "Outbox naqshini tushuntiring", "Exactly-once mumkinmi?", "Idempotent consumer qanday quriladi?"

Xulosa

  • Dual write — ikki tizimga alohida yozish; kanonda 100 dan 10 hodisa yo'qoldi.
  • Outbox: hodisa outbox jadvaliga ma'lumot bilan bitta tranzaksiyada; relay keyin tarqatadi — 100/100.
  • Relay: FOR UPDATE SKIP LOCKED, partiya, hodisa bo'yicha xato; ikki relay — har hodisa bir marta (testlar 4/4, biz PG 17.6 da yurgizdik).
  • Kafolat at-least-once; jobId = hodisa_id navbatdagi takrorni, inbox iste'molchidagi takrorni ushlaydi (kanon: 3 yetkazish → 1 amal).
  • Inbox va amal — bitta tranzaksiyada.
  • Ilova roli outbox'ga faqat yozadi; CDC (Debezium) — polling'ning muqobili.

Keyingi dars: Durable workflow'lar — ko'p bosqichli, kunlab davom etadigan jarayonlar: saga, kompensatsiya, qadam jurnali va Temporal, Inngest, Trigger.dev.

Manbalar

Ulashish:Telegram'da

Izohlar (0)

Izoh yozish uchun kiring.

  • Hozircha izoh yo'q. Birinchi bo'ling!