Mundarija (22)
- Bu darsda
- 1. Nega bu kerak?
- 2. Kanon o'lchovi: 10 ta hodisa yo'qoldi
- 3. Outbox naqshi
- 3.1 outbox jadvali
- 3.2 Bitta tranzaksiya — haqiqatan
- 4. Relay
- 4.1 Polling relay
- 4.2 Nega at-least-once
- 4.3 CDC — ikkinchi yo'l
- 5. Yetkazish kafolatlari
- 6. Inbox: iste'molchi tomonda
- 7. Loyiha qadami: outbox.ts va 204-outbox.sql
- 7.1 Testlar — biz ishga tushirdik
- 8. Ko'p uchraydigan xatolar
- 9. Mashqlar
- 1-mashq (oson): bo'shliqlarni to'ldiring
- 2-mashq (o'rta): relay modeli
- 3-mashq (qiyin): inbox va tranzaksiya
- 10. Real ishda
- Xulosa
- Manbalar
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
outboxjadvaliga, 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.
outboxjadvali vahodisaYoz— hodisani ma'lumot bilan bitta tranzaksiyada yozasiz.- Relay qanday ishlashini, nega
FOR UPDATE SKIP LOCKEDkerakligini 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:
await db.query("INSERT INTO vazifalar …"); // 1. bazaga
await navbat.add("vazifa.yaratildi", { id }); // 2. navbatgaIkki 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
ROLLBACKbo'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:
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:
- Dual write: 10 % yo'qotish.
- Outbox: 0 yo'qotish.
- Relay bir hodisani ikki marta yuborsa ham,
jobId = hodisa_idtufayli navbatda bitta.
Mantiqni modelda ko'raylik:
// 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:
dual write: bazada 100, navbatda 90
outbox: bazada 100, navbatda 1003. 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):
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:
SELECT column_name, data_type, column_default
FROM information_schema.columns
WHERE table_name = 'outbox' ORDER BY ordinal_position; 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:
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;BEGIN
INSERT 0 1
INSERT 0 1
ROLLBACK
vazifa | hodisa
--------+--------
0 | 0
(1 row)Ikkalasi ham yo'q. Endi COMMIT:
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;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:
BEGIN;
SET LOCAL ROLE vazifalar_ilova;
INSERT INTO outbox (tur, foydalanuvchi_id, malumot)
VALUES ('vazifa.bajarildi', 1, '{}');
SELECT count(*) FROM outbox;
ROLLBACK;BEGIN
SET
INSERT 0 1
ERROR: permission denied for table outbox
ROLLBACKNega? 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):
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:
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.- Hodisa bo'yicha xato — bitta hodisa yiqilsa (
tarqatxato otdi), faqat u qoladi, qolganlari belgilanadi. Partiya butunlay qaytmaydi. tarqattashqaridan 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
tarqatni tranzaksiya ichida chaqiradi —SELECT … FOR UPDATEdan keyin,COMMITdan 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):
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);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):
// 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):
DATABASE_URL=postgres://postgres@127.0.0.1:5517/vazifalar \
node --test test/outbox.test.tsChiqishda har test oldidagi ✔ belgisiga va pastdagi pass / fail sonlariga qarang:
▶ 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.2436Nima 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:
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_idPRIMARY 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
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:
{ tarqatildi: 4, xato: 1 }
{ tarqatildi: 1, xato: 0 }
{ tarqatildi: 0, xato: 0 }
navbat: 1, 2, 4, 5, 3
3-hodisa urinishlari: 2Kanon 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
// "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 — takrorKonsolda:
t-1: jarayon o'ldi — ROLLBACK, balans 0
t-1: balans 50000
t-1: balans 50000Birinchi 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
outboxjadvaliga 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_idnavbatdagi 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
- Chris Richardson — Transactional outbox pattern: https://microservices.io/patterns/data/transactional-outbox.html
- Chris Richardson — Idempotent consumer: https://microservices.io/patterns/communication-style/idempotent-consumer.html
- PostgreSQL 18 — SELECT, The Locking Clause (
SKIP LOCKED): https://www.postgresql.org/docs/18/sql-select.html#SQL-FOR-UPDATE-SHARE - Debezium — Outbox Event Router: https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html — sinalmagan
- Kanon tajribasi (IlmHamroh, 2026-10-27): PostgreSQL 18.6, Redis 8.10.2 (Docker), bullmq 6.3.19, pg 8.23.1, Node 24.21.0. SQL bloklari va
outbox.test.ts— biz ishga tushirdik (2026-10-28, PostgreSQL 17.6, ICU + UTC klaster, Node 24.21.0).
Izohlar (0)
Izoh yozish uchun kiring.
- Hozircha izoh yo'q. Birinchi bo'ling!