Համապատասխանություն՝ տրանզակցիաներ, idempotency, outbox և saga-ներ
Ինչպես պահել տվյալները ճիշտ, երբ աշխատանքը ընդգրկում է տվյալների բազաներ, հերթեր և երրորդ կողմի API-ներ։
30 վայրկյանում
Մեկ տվյալների բազայի ներսում տրանզակցիաները պահում են տվյալները համապատասխան (consistent)։ Հենց որ աշխատանքը հատում է ցանցային սահման (հերթ, վճարային պրովայդեր, այլ ծառայություն), մեկ ընդհանուր տրանզակցիա այլևս չկա։ Տվյալները ճիշտ պահող գործիքներն են idempotency key-երը, transactional outbox-ը, saga-ները փոխհատուցող քայլերով և զգույշ reconciliation (համադրում)։ Նպատակը ոչ թե ձախողումներից խուսափելն է, այլ յուրաքանչյուր ձախողում անվտանգ դարձնելը կրկնակի փորձի համար։
Ինչու եմ օգտագործում
- Օգտագործում եմ այս պատերնները, որովհետև վճարումները, պատվերները և պաշարը հենց այն տեղերն են, որտեղ կրկնված կամ կորած հաղորդագրությունները իրական գումար են արժենում։
- Ցանցերը ձախողվում են ամենավատ ձևով․ հարցումը կարող է հաջողվել, իսկ պատասխանը՝ կորչել։ Idempotency-ն կրկնակի փորձը դարձնում է անվնաս։
- Outbox պատերնը վերացնում է բագերի մի ամբողջ դաս, երբ տվյալների բազան ասում է մի բան, իսկ հերթը՝ այլ բան։
- Eventual consistency-ն ազնվորեն բացատրելը օգնում է հաճախորդներին ընդունել հուսալի նախագծեր, այլ ոչ թե այնպիսիք, որոնք միայն պարզ են թվում։
Ինչպես է աշխատում
01ACID և isolation-ի մակարդակներ
Տվյալների բազայի տրանզակցիան խմբավորում է փոփոխությունները, որպեսզի դրանք միասին հաջողվեն կամ ձախողվեն։ ACID-ը նկարագրում է խոստումը․ Atomicity (ամեն ինչ կամ ոչինչ), Consistency (սահմանափակումները պահպանվում են), Isolation (զուգահեռ տրանզակցիաները չեն տեսնում միմյանց կիսատ աշխատանքը) և Durability (commit արված տվյալները գոյատևում են crash-ը)։ Isolation-ն ունի մակարդակներ։ Read committed-ը՝ PostgreSQL-ի լռելյայնը, ցույց է տալիս միայն commit արված տվյալները, բայց թույլ է տալիս, որ արժեքը փոխվի նույն տրանզակցիայի երկու կարդալների միջև։ Repeatable read-ը տալիս է կայուն snapshot, և դա MySQL InnoDB-ի լռելյայնն է։ Serializable-ը ստիպում է զուգահեռ տրանզակցիաներին վարվել այնպես, կարծես դրանք կատարվել են մեկը մյուսից հետո՝ կոնֆլիկտների դեպքում կրկնակի փորձերի գնով։ Դասական անոմալիաներն են lost update-ները (երկու օգտատեր միաժամանակ նվազեցնում են պաշարը) և write skew-ը։ Կանխիր դրանք row lock-երով (SELECT ... FOR UPDATE), ատոմար թարմացումներով (SET stock = stock - 1 WHERE stock > 0), unique constraint-ներով կամ serializable isolation-ով՝ կրկնակի փորձով։
02Ինչու են բաշխված տրանզակցիաները դժվար
Երբ պատվերը դիպչում է քո տվյալների բազային, վճարային պրովայդերին և հաղորդագրությունների հերթին, ոչ մի տրանզակցիա չի ծածկում երեքը միասին։ Two-phase commit-ը (2PC) փորձում է․ կոորդինատորը յուրաքանչյուր մասնակցի խնդրում է պատրաստվել, հետո՝ commit անել։ Այն աշխատում է որոշ փակ համակարգերում, բայց արգելափակվում է, երբ կոորդինատորը ձախողվում է, lock-եր է պահում ցանցային round-trip-ների ընթացքում և չի աջակցվում հերթերի մեծ մասի և ցանկացած երրորդ կողմի HTTP API-ի կողմից։ Հիմնարար խնդիրը dual write-ն է․ պահպանում ես պատվերը, հետո հրապարակում «order created»։ Եթե պրոցեսը crash է լինում այդ երկուսի միջև, կամ իրադարձությունը կորչում է, կամ, եթե նախ հրապարակես, գոյություն ունի իրադարձություն մի պատվերի համար, որը երբեք չի պահպանվել։ Կրկնակի փորձն ավելացնում է ևս մեկ խնդիր․ timeout-ը չի ասում, թե արդյոք մյուս կողմը կատարել է աշխատանքը։ Այս ձախողումները չես կարող վերացնել, ուստի նախագծում ես դրանց համար։
- Timeout-ը նշանակում է «անհայտ», ոչ թե «ձախողված»։
- Սահմանը հատող յուրաքանչյուր քայլ պետք է կրկնվի առանց կողմնակի էֆեկտների։
- Պահիր reconciliation-ի ուղի այն վիճակների համար, որոնք չես կարող ավտոմատ որոշել։
03Idempotency key-եր
Գործողությունը idempotent է, երբ այն երկու անգամ կատարելը նույն էֆեկտն ունի, ինչ մեկ անգամ կատարելը։ GET-ը, PUT-ը և DELETE-ը idempotent են ըստ սահմանման, իսկ POST-ը, որը ստեղծում է բաներ կամ գանձում քարտերից, idempotent չէ։ Լուծումը idempotency key-ն է․ կլիենտը յուրաքանչյուր տրամաբանական գործողության համար գեներացնում է եզակի ID (UUID) և ուղարկում այն, սովորաբար Idempotency-Key header-ում։ Սերվերը պահում է key-ը արդյունքի հետ նույն տրանզակցիայում, ինչ աշխատանքը՝ պաշտպանված tenant-ով կամ օգտատիրոջով սահմանափակված unique constraint-ով։ Նույն key-ով կրկնակի փորձը վերադարձնում է պահված արդյունքը՝ գործողությունը կրկնելու փոխարեն, իսկ զուգահեռ կրկնօրինակը բախվում է unique constraint-ին և սպասում կամ մաքուր ձախողվում է։ Key-երի ժամկետը պետք է լրանա խելամիտ պատուհանից հետո, իսկ այլ request body-ով կրկին օգտագործված key-ը պետք է մերժվի։ Նույն գաղափարը կիրառիր consumer-ների համար․ գրանցիր մշակված հաղորդագրությունների ID-ները։
CREATE TABLE idempotency_keys (
tenant_id uuid NOT NULL,
key text NOT NULL,
request_hash text NOT NULL,
response jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, key)
);
-- Inside the same transaction as the business write:
INSERT INTO idempotency_keys (tenant_id, key, request_hash)
VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, key) DO NOTHING
RETURNING key;
-- No row returned? It is a retry: load and return the stored response.
04Transactional outbox
Outbox պատերնը լուծում է dual write-ը։ Բրոքերում ուղղակի հրապարակելու փոխարեն իրադարձությունը ավելացնում ես outbox աղյուսակում՝ նույն տվյալների բազայի տրանզակցիայում, ինչ բիզնես փոփոխությունը։ Կամ երկուսն էլ commit են արվում, կամ ոչ մեկը։ Հետո առանձին relay պրոցես կարդում է չհրապարակված տողերը, հրապարակում դրանք հերթում կամ բրոքերում և նշում որպես ուղարկված։ Եթե relay-ը crash է լինում հրապարակելուց հետո, բայց նշելուց առաջ, իրադարձությունը կրկին է հրապարակվում, ուստի առաքումը at-least-once է, և consumer-ները պետք է idempotent լինեն։ Օգտագործիր FOR UPDATE SKIP LOCKED, որպեսզի մի քանի relay worker կարողանան կիսել աշխատանքը առանց բախումների, և մաքրիր հին հրապարակված տողերը։ Այլընտրանքային relay է change data capture-ը (օրինակ՝ Debezium-ը, որը կարդում է տվյալների բազայի log-ը), որը խուսափում է polling-ից։ Հակառակ պատերնը՝ inbox-ը, պահում է մուտքային հաղորդագրությունների ID-ները՝ consumer-ի կողմում կրկնօրինակները հեռացնելու համար։
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = $1;
INSERT INTO outbox (aggregate_id, type, payload)
VALUES ($1, 'order.paid', $2);
COMMIT;
-- Relay worker, in a loop:
SELECT id, type, payload
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
-- publish each row, then:
UPDATE outbox SET published_at = now() WHERE id = ANY($1);
05Saga-ներ՝ orchestration ընդդեմ choreography
Saga-ն երկար բիզնես տրանզակցիա է՝ բաժանված լոկալ քայլերի, որոնցից յուրաքանչյուրն ունի փոխհատուցող գործողություն, որը իմաստային առումով չեղարկում է այն։ Պատվերի ձևակերպումը կարող է լինել՝ ամրագրել պաշարը, գանձել քարտից, ստեղծել առաքում։ Եթե գանձումը ձախողվում է, saga-ն ազատում է պաշարը։ Փոխհատուցումը rollback չէ․ ուղարկված email-ը չես կարող «չուղարկել», ուստի ուղարկում ես ուղղում, գանձված վճարումը վերադարձվում է, ոչ թե ջնջվում։ Choreography-ում յուրաքանչյուր ծառայություն լսում է իրադարձությունները և արձակում հաջորդը («StockReserved»-ը գործարկում է վճարումը)։ Այն թույլ կապակցված է, բայց դժվար է հետևել, երբ քայլերը շատ են։ Orchestration-ում մեկ կոմպոնենտ՝ բացահայտ state machine, յուրաքանչյուր մասնակցի ասում է՝ ինչ անել, և գրանցում առաջընթացը։ Այն ավելի հեշտ է մոնիթորինգ անել, թեստավորել և փոխել, և դա իմ լռելյայն ընտրությունն է երկու-երեքից ավելի քայլ ունեցող հոսքերի համար։ Ամեն դեպքում պահպանիր saga-ի վիճակը, յուրաքանչյուր քայլ դարձրու idempotent և խրված saga-ները տեսանելի դարձրու օպերատորներին։
06Eventual consistency, exactly-once-ի առասպելներ և CAP
Eventual consistency-ն նշանակում է, որ եթե նոր փոփոխություններ չեն գալիս, տվյալների բոլոր պատճենները կհամընկնեն, բայց կարճ ժամանակով կարող են տարբերվել։ Գործնականում սա լավ է լոյալության միավորների համար, որոնք հայտնվում են մեկ վայրկյան ուշ, բայց լավ չէ այնպիսի հաշվեկշիռ ցույց տալու համար, որը օգտատերը կարող է գերծախսել։ Օգտատիրոջ փորձառությունը նախագծիր դրա համար․ ցույց տուր «մշակվում է» վիճակներ, քո իսկ գրառումները կարդա primary-ից և պարբերաբար համադրիր ճշմարտության աղբյուրի հետ, օրինակ՝ վճարային պրովայդերի հաշվետվության։ Ցանցով «exactly-once delivery»-ն առասպել է․ այն, ինչ կարող ես կառուցել, exactly-once էֆեկտն է՝ at-least-once առաքում գումարած idempotent մշակում։ CAP-ը պարզ բառերով․ երբ ցանցը համակարգդ բաժանում է երկու մասի, յուրաքանչյուր կողմ պետք է կամ մերժի որոշ հարցումներ (ընտրի consistency), կամ շարունակի պատասխանել հնարավոր հնացած տվյալներով (ընտրի availability)։ Առանց partition-ի իրական փոխզիջումը latency-ն ընդդեմ consistency-ի է։
Օգտագործեք, երբ
- Ցանկացած workflow, որը գրում է տվյալների բազա և հրապարակում հաղորդագրություն կամ կանչում արտաքին API։
- Վճարումներ, վերադարձներ, պատվերներ և պաշարի շարժեր, որտեղ կրկնօրինակները կամ կորուստները գումար են արժենում։
- Հանրային կամ գործընկերային API-ներ, որոնց կլիենտները կարող են timeout-ներից հետո կրկնել հարցումները։
- Բազմաքայլ բիզնես պրոցեսներ ծառայությունների կամ երրորդ կողմերի միջև։
Մի՛ օգտագործեք, երբ
- Աշխատանք, որը տեղավորվում է մեկ տվյալների բազայի տրանզակցիայում․ օգտագործիր տրանզակցիան և պահիր պարզ։
- Լիարժեք saga ֆրեյմվորք երկքայլ հոսքի համար, որը արդեն ծածկում են outbox-ը և idempotent handler-ը։
- Բաշխված two-phase commit HTTP ծառայությունների միջև․ այն չի համապատասխանում և արգելափակումներ է առաջացնում։
Այլընտրանքներ
- Մեկ տվյալների բազայի տրանզակցիա
Ամենապարզ և ամենաուժեղ տարբերակը, երբ բոլոր տվյալներն ապրում են մեկ տվյալների բազայում։
- Change data capture
Stream արա տվյալների բազայի log-ը (օրինակ՝ Debezium-ով)՝ outbox աղյուսակը polling անելու փոխարեն։
- Workflow engine-ներ
Temporal-ը և նմանատիպ գործիքները պահպանում են saga-ի վիճակը և կրկնակի փորձերը երկար workflow-ների համար։
- Two-phase commit
Հնարավոր է այն աջակցող տվյալների բազաների միջև փակ համակարգերում. վեբ հավելվածների համար հազվադեպ է արժե։
Լավագույն պրակտիկաներ
- Timeout-ին վերաբերվիր որպես անհայտ արդյունքի և լուծիր այն ստուգելով, ոչ թե գուշակելով։
- Պահանջիր idempotency key-եր յուրաքանչյուր ոչ idempotent հանրային endpoint-ի վրա։
- Իրադարձությունները հրապարակիր outbox-ի միջոցով, որը գրվում է նույն տրանզակցիայում, ինչ փոփոխությունը։
- Յուրաքանչյուր consumer դարձրու idempotent՝ մշակված ID-ների աղյուսակներով, unique constraint-ներով կամ upsert-ներով։
- Բազմաքայլ saga-ների համար նախընտրիր orchestration՝ պահպանված վիճակով և տեսանելի խրված հոսքերով։
- Գումարի և պաշարի համար գործարկիր պլանավորված reconciliation ճշմարտության աղբյուրի դեմ։
Տարածված սխալներ
- Commit-ից անմիջապես հետո ուղղակի հրապարակել հերթում և crash-ների ժամանակ կորցնել իրադարձությունները։
- Վճարման հարցումները կրկնել առանց idempotency key-երի և հաճախորդներից երկու անգամ գանձել։
- Timeout-ից հետո գործողությունը նշել «ձախողված», երբ իրականում այն հաջողվել է։
- Ենթադրել, որ բրոքերի «exactly-once» կարգավորումը ծածկում է տվյալների բազայի գրառումները և email-ները։
- Գրել փոխհատուցումներ, որոնք իրենք idempotent չեն, այնպես որ կրկնակի փորձերը երկու անգամ վերադարձ են անում։
Խորը վերլուծություն
07MVCC, serializable isolation և retry ցիկլեր
PostgreSQL-ը և MySQL InnoDB-ն օգտագործում են multi-version concurrency control․ գրողները ստեղծում են տողերի նոր տարբերակներ, իսկ ընթերցողները տեսնում են snapshot, այնպես որ ընթերցումները չեն արգելափակում գրառումները։ Ահա թե ինչու են անոմալիաներ առաջանում․ երկու տրանզակցիա կարող են յուրաքանչյուրը կարդալ snapshot, որոշում կայացնել և գրել՝ առանց միմյանց տեսնելու։ PostgreSQL-ի serializable մակարդակն օգտագործում է Serializable Snapshot Isolation, որը հետևում է read-write կախվածություններին և վտանգավոր պատերն ձևավորվելիս ընդհատում է տրանզակցիաներից մեկը SQLSTATE 40001-ով (serialization_failure)։ Այն տալիս է ամենաուժեղ երաշխիքը՝ առանց ամեն տող lock անելու, բայց միայն եթե հավելվածը 40001-ի դեպքում retry է անում ամբողջ տրանզակցիան, իդեալում՝ փոքր backoff-ով և retry-ների սահմանով։ Serializable տրանզակցիաները պահիր կարճ, դրանց ներսում խուսափիր արտաքին կանչերից և օգտագործիր դրանք մի քանի տող ընդգրկող ինվարիանտների համար, օրինակ՝ «առնվազն մեկ բժիշկ հերթապահ», որտեղ պարզ row lock-ը բավարար չէ։
async function withSerializableRetry<T>(fn: (tx: Tx) => Promise<T>, attempts = 5): Promise<T> {
for (let i = 1; ; i++) {
try {
return await db.transaction(fn, { isolationLevel: "serializable" });
} catch (err: any) {
const retryable = err?.code === "40001" || err?.code === "40P01"; // serialization / deadlock
if (!retryable || i >= attempts) throw err;
await new Promise((r) => setTimeout(r, Math.random() * 50 * i));
}
}
}
08Իդեմպոտենտությունը խորությամբ․ ընթացքի մեջ գտնվող հարցումներ և fingerprint-ներ
Իդեմպոտենտության պարզունակ աղյուսակն ունի բաց․ առաջին հարցումը դեռ աշխատում է, երբ retry-ը հասնում է։ Key-ի հետ պահիր վիճակ․ in_progress, հետո completed՝ պատասխանով, կամ failed՝ retryable դրոշակով։ Retry-ը, որը գտնում է in_progress, ստանում է 409 Conflict (կամ կարճ սպասում է), բայց երբեք երկրորդ կատարում։ Հարցման համար ստեղծիր fingerprint (method, path, body-ի կանոնական hash) և մերժիր key-ի կրկնակի օգտագործումը այլ fingerprint-ով։ Որոշիր, թե ինչ պահել․ վերջնական հաջողությունը և դետերմինիստիկ վալիդացիայի սխալները վերարտադրվում են, իսկ անցողիկ սխալները, օրինակ՝ downstream timeout-ը, չեն քեշավորվում, որպեսզի կլիենտը կարողանա retry անել։ Key-երը սահմանափակիր ըստ account-ի՝ բախումներից խուսափելու համար, ժամկետանց դարձրու դրանք կլիենտի retry-ի պատուհանից հետո, և երբ գործողությունը կանչում է երրորդ կողմի, downstream փոխանցիր դրանից ստացված idempotency key, որպեսզի ամբողջ շղթան անվտանգ լինի սկզբից մինչև վերջ։
- Վիճակներ․ in_progress, completed (պատասխանով), failed։
- Նույն key + այլ body = 422, ոչ թե replay։
- Ստացված key-ը փոխանցիր վճարային provider-ներին և այլ API-ներին։
09Outbox մեծ մասշտաբով․ հերթականություն, CDC և replication slot-եր
SKIP LOCKED-ով polling relay-ը պարզ է, բայց կարող է խառնել իրադարձությունների հերթականությունը, որովհետև մի քանի worker հրապարակում են զուգահեռ։ Եթե consumer-ներին պետք է հերթականություն յուրաքանչյուր էնտիտիի համար, աշխատանքը բաժանիր ըստ aggregate ID-ի կամ օգտագործիր մեկ relay յուրաքանչյուր partition-ի համար, և ամեն իրադարձության մեջ ներառիր aggregate-ի տարբերակը, որպեսզի consumer-ները կարողանան մերժել հնացածները։ Change data capture գործիքները, օրինակ՝ Debezium-ը, կարդում են write-ahead log-ը logical replication slot-ի միջոցով՝ polling-ի փոխարեն, տալով ցածր latency և commit-ի հերթականություն։ Օպերացիոն թակարդը․ replication slot-ը WAL-ը պահում է primary-ի վրա, մինչև consumer-ը հաստատի այն։ Եթե CDC connector-ը կանգնում է, WAL-ը կուտակվում է և կարող է լցնել դիսկը։ Մոնիթորինգ արա slot-ի lag-ը, դիր max_slot_wal_keep_size (PostgreSQL 13+)՝ որպես անվտանգության սահման, և ջնջիր լքված slot-երը։ Outbox աղյուսակը պարբերաբար մաքրիր, իդեալում՝ ըստ partition-ի, որպեսզի այն մնա փոքր և արագ։
-- How much WAL each replication slot is holding back
SELECT slot_name, active,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained
FROM pg_replication_slots;
-- postgresql.conf: never let a stuck slot fill the disk
-- max_slot_wal_keep_size = '20GB'
10Saga-ի դիզայն․ pivot քայլեր և isolation-ի հակաքայլեր
Saga-ները հրաժարվում են isolation-ից․ այլ տրանզակցիաներ կարող են տեսնել միջանկյալ վիճակներ, օրինակ՝ ամրագրված պաշար պատվերի համար, որը չեղարկվելու է։ Քայլերը դասավորիր գիտակցված։ Սկզբում գալիս են փոխհատուցելի քայլերը (պաշարի ամրագրում, գումարի hold), հետո pivot քայլը՝ անվերադարձ կետը, օրինակ՝ վճարման capture-ը, հետո retriable քայլերը, որոնք վերջիվերջո պետք է հաջողվեն (առաքման ստեղծում, անդորրագրի ուղարկում) և retry են արվում, ոչ թե փոխհատուցվում։ Անոմալիաները սահմանափակելու համար օգտագործիր հակաքայլեր։ Semantic lock-ը գրառումները նշում է որպես pending, որպեսզի այլ հոսքերը զգույշ վարվեն դրանց հետ։ Commutative թարմացումները (մնացորդների գումարում և հանում) հերթականությունը դարձնում են անկարևոր։ Արժեքը թարմացումից առաջ նորից կարդալը հայտնաբերում է, որ այն փոխվել է։ Version դաշտերը մերժում են հնացած գրառումները։ Saga-ի վիճակը պահպանիր՝ ամեն քայլի համար timeout-ներով, որպեսզի կորած պատասխանի անվերջ սպասող saga-ն դառնա տեսանելի, գործողության ենթակա տարր, ոչ թե ամրագրված պաշարի լուռ արտահոսք։
Իրական իրավիճակներ
Վճարային gateway-ը checkout-ի ժամանակ timeout է տալիս
Խնդիր
Հավելվածը կանչում է gateway-ը վճարումը capture անելու համար, և 30 վայրկյան անց հարցումը timeout է ստանում։ Հաճախորդը տեսնում է սխալ, կրկին փորձում է, և նրանից երկու անգամ է գանձվում։
Ինչպես լուծել
Timeout-ը դիտարկիր որպես անհայտ արդյունք։ Gateway-ը կանչելուց առաջ պահպանիր վճարման փորձը pending կարգավիճակով և գեներացված idempotency key-ով, և այդ key-ը ուղարկիր հարցման հետ։ Timeout-ի դեպքում կարգավիճակը պահիր outcome_unknown և հաճախորդին ցույց տուր «վճարումը հաստատվում է»։ Background job-ը gateway-ին հարցում է անում այդ key-ով կամ reference-ով և որոշում վիճակը․ նույն key-ով retry-ը չի կարող երկու անգամ գանձել։ Վճարման կոճակն անջատիր, քանի դեռ վճարումը pending է, և ամեն օր գործարկիր reconciliation gateway-ի settlement հաշվետվության դեմ՝ չլուծված մնացած ամեն ինչի համար։
Դեպլոյների ժամանակ կորչող իրադարձություններ
Խնդիր
Պահեստները երբեմն այդպես էլ չեն իմանում վճարված պատվերների մասին։ Կոդը commit է անում պատվերը, հետո հրապարակում հերթում, և կորուստները համընկնում են դեպլոյի restart-ների հետ։
Ինչպես լուծել
Սա dual write-ն է։ Outbox տող ավելացրու նույն տրանզակցիայում, ինչ կարգավիճակի փոփոխությունը, և թող relay-ը հրապարակի ու նշի տողերը։ Ստացող կողմում ավելացրու inbox աղյուսակ՝ եզակի message ID-ով, որպեսզի կրկնակի առաքումներն անտեսվեն։ Բաց թողնված իրադարձությունները վերականգնիր՝ համեմատելով վճարված պատվերները պահեստի հաստատումների հետ տուժած ժամանակահատվածում։ Կարգավորիր graceful shutdown, որպեսզի worker-ները ավարտեն ընթացիկ batch-ը մինչև դուրս գալը, և alert դիր մի քանի րոպեից ավելի հին outbox տողերի վրա, ինչը ազդանշում է կախված relay-ի մասին։
Ավելցուկային վաճառք flash sale-ի ժամանակ
Խնդիր
Սահմանափակ ապրանքը վաճառվեց 130 միավոր, մինչդեռ գոյություն ուներ միայն 100։ Կոդը կարդում է պաշարը, ստուգում այն հավելվածի կոդում, հետո գրում նոր արժեքը։
Ինչպես լուծել
Read-check-write-ը փոխարինիր մեկ ատոմար հրամանով․ UPDATE products SET stock = stock - $2 WHERE id = $1 AND stock >= $2, և զրո փոփոխված տողը դիտարկիր որպես «սպառված է»։ Րոպեներ տևող checkout հոսքերի համար նույն տրանզակցիայում ստեղծիր ժամկետով ամրագրումներ և ազատիր դրանք պլանավորված job-ով, եթե վճարումը չավարտվի։ Մեկ տողի վրա շատ բարձր մրցակցության դեպքում մտածիր պաշարը մի քանի counter-ի բաժանելու կամ հերթի մասին, որը այդ ապրանքի պատվերները հաջորդականացնում է։ Ավելացրու թեստ, որը գործարկում է զուգահեռ գնումներ և ստուգում, որ պաշարը երբեք բացասական չի դառնում։
Արագ ուղեցույց
- Պաշարի ատոմար նվազեցում
UPDATE products SET stock = stock - 1 WHERE id = $1 AND stock > 0- Տողի lock տրանզակցիայի ընթացքում
SELECT ... FROM accounts WHERE id = $1 FOR UPDATE- Զուգահեռ relay worker-ներ
SELECT ... FROM outbox WHERE published_at IS NULL LIMIT 100 FOR UPDATE SKIP LOCKED- Մեկ անգամ insert՝ կրկնօրինակների դեմ
INSERT INTO inbox (message_id) VALUES ($1) ON CONFLICT DO NOTHING- Serializable կոնֆլիկտների retry
Ամբողջ տրանզակցիայի retry SQLSTATE 40001 / 40P01-ի դեպքում- Idempotency header
Idempotency-Key: 7f1c2d9e-3b4a-4c55-9a77-1e2f3a4b5c6d- Գտնել կախված CDC slot-երը
SELECT slot_name, active FROM pg_replication_slots;- Սահմանափակել slot-երի պահած WAL-ը
max_slot_wal_keep_size = '20GB'- Saga-ի քայլերի հերթականություն
Փոխհատուցելի քայլեր -> pivot -> retriable քայլեր
Իմ իրական նախագծերում
- POS SaaS-ում դոմեյն իրադարձությունները transactional outbox-ով անցնում են BullMQ, հետո՝ handler-ներին, իսկ ելքային webhook-ները HMAC-ով ստորագրված են և ուղարկվում են հերթով։
- Այնտեղ գործընկերային պատվերներն օգտագործում են idempotency key-եր, իսկ մասնաճյուղերի դրամարկղերից offline sync հրամանները կրկնօրինակազերծվում են ըստ տերմինալի։
- Նրա վերադարձներն ունեն բացահայտ «արդյունքն անհայտ է» վիճակ և reconciliation job՝ gateway-ի timeout-ից հետո գուշակելու փոխարեն։
- Մարզասրահի CRM-ում SMS-ների ուղարկումը կրկնվում է միայն կապի ձախողման դեպքում, որպեսզի ոչ մի հաղորդագրություն երկու անգամ չուղարկվի, իսկ կենդանիների խանութի հավելվածը կրկնում է միայն idempotent HTTP մեթոդները։
Հարցեր և պատասխաններ
Q1Ի՞նչ է transactional outbox պատերնը և ինչու՞ օգտագործել այն։
Այն լուծում է dual-write խնդիրը․ տվյալների պահպանումը և հաղորդագրության հրապարակումը երկու համակարգ են, ուստի նրանց միջև crash-ը դրանք թողնում է անհամապատասխան վիճակում։ Outbox-ի դեպքում իրադարձությունը ավելացնում ես աղյուսակում նույն տվյալների բազայի տրանզակցիայում, ինչ բիզնես փոփոխությունը, այնպես որ երկուսն էլ commit են արվում միասին կամ ընդհանրապես չեն արվում։ Հետո relay պրոցեսը կարդում է չհրապարակված տողերը, ուղարկում բրոքերին և նշում որպես հրապարակված։ Եթե այն ընթացքում crash է լինում, կրկին է հրապարակում, ուստի առաքումը at-least-once է, և consumer-ները պետք է idempotent լինեն։ Արդյունքում ոչ մի իրադարձություն երբեք չի կորչում, և ոչ մեկը չի հրապարակվում rollback արված տվյալների համար։
Q2Ինչպե՞ս են աշխատում idempotency key-երը վճարային API-ում։
Կլիենտը յուրաքանչյուր տրամաբանական գործողության համար, օրինակ՝ checkout-ի մեկ փորձի, գեներացնում է եզակի key և ուղարկում այն հարցման հետ։ Սերվերը պահում է key-ը, հարցման hash-ը և արդյունքը նույն տրանզակցիայում, ինչ աշխատանքը, հաշվով սահմանափակված unique constraint-ով։ Եթե կլիենտը timeout է ստանում և կրկնում նույն key-ով, սերվերը վերադարձնում է պահված արդյունքը՝ կրկին գանձելու փոխարեն։ Զուգահեռ կրկնօրինակը բախվում է constraint-ին։ Key-ը այլ body-ով կրկին օգտագործելը մերժվում է։ Key-երի ժամկետը լրանում է որոշակի պատուհանից հետո, հաճախ մոտ մեկ օր՝ կախված retry քաղաքականությունից։
Q3Orchestration, թե՞ choreography saga-ների համար։
Choreography-ն աշխատում է կարճ հոսքերի համար․ յուրաքանչյուր ծառայություն արձագանքում է իրադարձությանը և արձակում հաջորդը՝ առանց կենտրոնական կոմպոնենտի և թույլ կապակցվածությամբ։ Քայլերի աճին զուգընթաց հոսքը դառնում է անբացահայտ և ցրված ծառայությունների միջև, ուստի ոչ ոք չի կարող տեսնել, թե որտեղ է խրվել պատվերը, կամ դատել փոխհատուցումների մասին։ Orchestration-ը հոսքը դնում է մեկ բացահայտ state machine-ի մեջ, որը հրամայում է յուրաքանչյուր քայլ, պահպանում առաջընթացը և գործարկում փոխհատուցումները։ Այն ավելի հեշտ է թեստավորել, մոնիթորինգ անել և փոխել՝ ևս մեկ կոմպոնենտի գնով, որը գիտի ամբողջ պրոցեսը։ Երկու-երեք քայլից ավելի ցանկացած բանի համար, հատկապես երբ գումար է ներգրավված, նախընտրում եմ orchestration։
Q4Հնարավո՞ր է exactly-once delivery։
Ոչ որպես ցանցի ընդհանուր երաշխիք։ Ուղարկողը չի կարող իմանալ, թե կորած acknowledgement-ը նշանակում է, որ հաղորդագրությունը չի մշակվել, թե՞ միայն պատասխանն է կորել, ուստի նա պետք է կամ ռիսկի դիմի այն կորցնելու, կամ կրկին ուղարկելու։ Համակարգերն ընտրում են at-least-once և հետո մշակումը դարձնում idempotent, ինչը տալիս է exactly-once էֆեկտ։ Kafka-ի exactly-once սեմանտիկան իրական է, բայց սահմանափակված է Kafka topic-ների տրանզակցիոն կարդալով և գրելով։ Հենց որ handler-ը դիպչում է տվյալների բազային, email պրովայդերին կամ վճարային API-ին, քեզ պետք է կրկնօրինակազերծում այդ սահմանին՝ unique key-եր, upsert-ներ կամ մշակված հաղորդագրությունների աղյուսակ։
Q5Բացատրիր CAP թեորեմը գործնական լեզվով։
CAP-ն ասում է, որ network partition-ի ժամանակ, երբ բաշխված համակարգի մասերը չեն կարող խոսել միմյանց հետ, պետք է ընտրես consistency-ի և availability-ի միջև։ Consistency ընտրելը նշանակում է, որ որոշ հարցումներ մերժվում են կամ սպասում են, մինչև հանգույցները համաձայնեն, այնպես որ ոչ ոք հնացած տվյալներ չի կարդում։ Availability ընտրելը նշանակում է, որ յուրաքանչյուր հանգույց շարունակում է պատասխանել, հնարավոր է՝ հնացած տվյալներով, և կոնֆլիկտները համադրվում են ավելի ուշ։ Իրական ցանցերում partition-ները կամընտիր չեն, ուստի ընտրությունն իրականում այն է, թե ինչ անել, երբ դրանք տեղի են ունենում։ Շատ թիմեր PACELC-ն ավելի օգտակար են համարում․ նույնիսկ առանց partition-ների դու փոխզիջում ես latency-ն consistency-ի դիմաց, օրինակ՝ սինխրոն ընդդեմ ասինխրոն ռեպլիկացիայի դեպքում։
Q6Ինչպե՞ս կանխել, որ երկու օգտատեր գնեն պահեստում մնացած վերջին ապրանքը։
Որոշումը դարձրու ատոմար տվյալների բազայում։ Ամենապարզ տարբերակը պայմանական թարմացումն է․ նվազեցրու պաշարը, որտեղ քանակը դեռ դրական է, և ստուգիր ազդված տողերի քանակը. զրոն նշանակում է, որ վաճառված է։ Այլընտրանքով՝ տրանզակցիայի ներսում lock արա տողը SELECT FOR UPDATE-ով կամ պահիր ամրագրումների աղյուսակ՝ constraint-ներով։ Բարձր մրցակցությամբ ապրանքների համար լավ են աշխատում կարճաժամկետ ամրագրումները ժամկետով․ ապրանքը պահվում է checkout-ի ընթացքում և ազատվում է, եթե վճարումը չի ավարտվում։ Երբեք մի ստուգիր պաշարը հավելվածի կոդում և հետո թարմացրու առանձին քայլով՝ առանց lock-ի. դա դասական race condition է։
Սենիոր մակարդակի հարցեր
S1Ե՞րբ կներդնեիր workflow engine՝ ձեռքով գրված saga-ների փոխարեն։
Երբ workflow-ները դառնում են երկարատև, բազմաթիվ կամ դժվար դիտարկելի։ Ձեռքով գրված orchestrator-ը նորմալ է մի քանի հոսքի համար՝ մի բուռ քայլերով․ վիճակի աղյուսակ, outbox և իդեմպոտենտ handler-ներ։ Հենց որ քեզ պետք են օրերով տևող timer-ներ, մարդկային հաստատումներ, workflow-ի տարբերակավորված սահմանումներ, ըստ քայլի քաղաքականությամբ retry-ներ և UI՝ կախված ինստանսները ուսումնասիրելու համար, դու արդեն workflow engine ես վերակառուցում։ Temporal-ի նման գործիքները պահպանում են workflow-ի իրադարձությունների պատմությունը և կոդը վերարտադրում դետերմինիստիկ, այնպես որ crash-ից հետո աշխատանքը շարունակվում է ճիշտ այնտեղից, որտեղ կանգնել էր։ Փոխզիջումները․ կրիտիկական ինֆրաստրուկտուրայի նոր մաս, որը պետք է գործարկել կամ վճարել, ծրագրավորման մոդել՝ դետերմինիզմի կանոններով, և ուսուցման կոր։ Ես այն ներդնում եմ, երբ բիզնես տրամաբանությունը, ոչ թե «սանտեխնիկան», դառնում է կոդի ավելի մեծ մասը։
S2Ինչպե՞ս ես ապացուցում, որ գումար տեղափոխող համակարգը ժամանակի ընթացքում ճիշտ է աշխատում։
Նախագծիր ստուգելիության, ոչ միայն ճշտության համար։ Օգտագործիր double-entry ledger․ ամեն շարժում գրառումների անփոփոխ զույգ է, որոնց գումարը զրո է, այնպես որ մնացորդները հաշվարկվում են, երբեք տեղում չեն խմբագրվում։ Ամեն արտաքին գործողություն թող կրի reference, որը կարող ես համադրել provider-ի հետ։ Reconciliation գործարկիր մի քանի մակարդակով․ ներքին ինվարիանտներ (ledger-ի գումարը զրո է, բացասական մնացորդներ չկան) և արտաքին համադրումներ gateway-ի և բանկի հաշվետվությունների հետ՝ ստանալով անհամապատասխանությունների ցանկ՝ պատասխանատուներով։ Գումարները պահիր ամբողջ թվով փոքր միավորներով՝ բացահայտ արժույթներով։ Alert դիր չհամադրված տարրերի վրա՝ ըստ տարիքի և արժեքի։ Փոխզիջումը ավելի շատ աղյուսակներ և job-եր են, քան պարզ balance սյունակը, բայց սա «կարծում ենք, որ ճիշտ է»-ն վերածում է ապացույցի, որը ընդունում են աուդիտորներն ու ֆինանսական թիմերը։
S3Strong, թե՞ eventual consistency ֆիչի համար․ ինչպե՞ս ես որոշում բիզնեսի հետ։
Ես այն ձևակերպում եմ որպես կարճ ժամանակով սխալ լինելու գինը՝ ավելի դանդաղ կամ պակաս հասանելի լինելու գնի դեմ։ Տվյալների յուրաքանչյուր մասի համար հարցնում եմ՝ ինչ կլինի, եթե օգտատերը տեսնի մի քանի վայրկյան հին արժեք։ Loyalty միավորներ, analytics և որոնման արդյունքներ․ անվնաս է, ուստի eventual consistency-ն ասինխրոն թարմացումներով նորմալ է և էժան է մասշտաբավորվում։ Հաշվի մնացորդներ, տեղերի ամրագրումներ, պաշարը checkout-ի պահին․ հնացած ընթերցումը կարող է հանգեցնել գերծախսի կամ կրկնակի ամրագրման, ուստի որոշման կետը պետք է կարդա primary-ից՝ lock-ի կամ պայմանական գրառման ներքո։ Հաճախ պատասխանը խառն է․ դիտարկում՝ eventually consistent տվյալներով, հետո խիստ վալիդացիա պարտավորության պահին։ Այդ որոշումը ամեն ֆիչի համար գրավոր ամրագրելը կանխում է պատահական երաշխիքներ, որոնց մասին ոչ ոք չի պայմանավորվել։
Բառարան
- Dual write
- Գրառում երկու համակարգում առանց ընդհանուր տրանզակցիայի, ինչի պատճառով crash-ը կարող է դրանք թողնել անհամաձայն վիճակում։
- Idempotency key
- Կլիենտի կողմից գեներացված ID, որը թույլ է տալիս սերվերին ճանաչել և անվտանգ վերարտադրել կրկնված հարցումը։
- Outbox
- Իրադարձությունների աղյուսակ, որը գրվում է բիզնես տրանզակցիայում և ավելի ուշ հրապարակվում relay-ի կողմից։
- Inbox
- Մշակված հաղորդագրությունների ID-ների աղյուսակ, որը consumer-ն օգտագործում է կրկնօրինակներն անտեսելու համար։
- Փոխհատուցող գործողություն
- Բիզնես քայլ, որը իմաստային առումով չեղարկում է նախորդը, օրինակ՝ գումարի վերադարձը։
- Pivot transaction
- Saga-ի քայլ, որից հետո հոսքն այլևս հնարավոր չէ փոխհատուցել, միայն ավարտել։
- MVCC
- Տողերի մի քանի տարբերակների պահպանում, որպեսզի ընթերցողները տեսնեն համաձայնեցված snapshot՝ առանց գրողներին արգելափակելու։
- Write skew
- Երկու տրանզակցիա կարդում են համընկնող տվյալներ և գրում տարբեր տողեր՝ միասին խախտելով կանոնը։
- Reconciliation
- Քո գրառումների պարբերական համեմատում ճշմարտության աղբյուրի հետ և տարբերությունների շտկում։
Շարունակեք սովորել
Պրոդուկտի գաղափար ունե՞ք։Եկեք ստեղծենք այն։
Պատմեք ձեր գաղափարի, պրոդուկտի կամ թիմի մասին։ Սովորաբար պատասխանում եմ մեկ աշխատանքային օրվա ընթացքում։