11 تمرین ۱ — ناهنجاریها را زنده بسازExercise 1 — reproduce the anomalies live
بازگشت به صورت تمرینBack to the exercise · ارزیابی و سطحAssessment and level
پاسخ ۶۰ثانیهایThe 60-second answer
صورت مسئله و فرضهاProblem and assumptions
- محصول و نسخهٔ مشخص: PostgreSQL 18 طبق مستندات جداسازی تراکنش خودش؛ رفتار زیر رفتار محصول است نه حداقل استاندارد SQL.
- دو اتصال همزمان روی یک جدول کوچک حسابها؛ موجودیهایی مثل ۵۰۰/۵۰۰ اعداد آموزشیاند (آموزشی)، هرگز بنچمارک نیستند.
- PostgreSQL در این محیط نصب نیست (نه psql نه pg_ctl)، پس اجراهای SQL زیر راهنمای اجرای محلیاند؛ دموی اجراشده همان historyها را مدل میکند و همان invariantها را در حافظه میسنجد.
- Named product and version: PostgreSQL 18, per its transaction-isolation documentation; behavior below is product behavior, not the SQL minimum.
- Two concurrent sessions on one small accounts table; balances like 500/500 are teaching numbers (آموزشی), never benchmarks.
- PostgreSQL is NOT installed in this sandbox (no psql, no pg_ctl), so the SQL runs below are a local-run guide; the executed demo models the same histories and checks the same invariants in memory.
چرا این روش و این عددها؟Why this method and these numbers?
history دو session نشان میدهد هر read دقیقاً چه چیزی دیده؛ فقط اجرای همزمان دو thread بدون ترتیب مشخص، شاهد ناهنجاری نیست. PostgreSQL 18 را نام میبریم چون سطح استاندارد SQL حداقل تضمین است و محصول میتواند قویتر باشد. write skew به قیدی بین دو سطر نیاز دارد؛ برای همین پزشکهای on-call از دو balance مستقل واضحترند. retry باید کل تراکنش را با snapshot تازه تکرار کند تا تصمیم قدیمی دوباره اعمال نشود.
A two-session history identifies exactly what each read observed; unscheduled concurrent threads alone are not evidence of an anomaly. Name PostgreSQL 18 because SQL levels specify minima and products may be stronger. Write skew needs a cross-row invariant, illustrated by on-call doctors. Retry the whole transaction with a fresh snapshot to avoid reusing an obsolete decision.
مدل ذهنیMental model
حل گامبهگامStep by step
- غیبت ممنوعه را اثبات کن اولِ اولِ ممنوعهها را آزمون کن: وقتی UPDATE اتصال T1 تأییدنشده است، خوانندهٔ READ UNCOMMITTED هنوز باید ۵۰۰ ببیند (تاریخچهٔ H1) و UPDATE رقیب باید روی قفل ردیف بماند (تاریخچهٔ H2). نامرئی بودن و انتظار را بهعنوان قبولی ثبت کن.
- انتقال را skew کن انتقال را دو بار بازپخش کن: زیر READ COMMITTED خواننده دو طرف commit میایستد و جمع ۱۱۰۰ میشود (تاریخچهٔ H3، بازتولیدشده)؛ زیر جداسازی snapshot همان درهمتنیدگی از snapshot شروع میخواند و جمع ۱۰۰۰ میشود (تاریخچهٔ H4، جلوگیریشده).
- یک بهروزرسانی را گم کن، بعد درستش کن دو چرخهٔ خواندن-تغییر-نوشتن را روی شمارندهٔ ۱۰ همپوشان کن: هر دو ۱۱ مینویسند و یک increment گم میشود (تاریخچهٔ H5). جفت را با دو increment تکدستوری عوض کن: هرکدام روی تازهترین مقدار تأییدشده اعمال میشود و شمارنده در ۱۲ تمام میشود (تاریخچهٔ H6) — همان پیگیری ممیزی، جوابدادهشده در کد.
- زیر درج رقیب بازشماری کن داخل یک تراکنش دو بار بشمار وقتی رقیب درج میکند: READ COMMITTED اول ۲ بعد ۳ میشمرد (تاریخچهٔ H7). روی PostgreSQL، REPEATABLE READ از snapshot بازمیخواند و روی ۲ میماند (تاریخچهٔ H8) — رفتار محصول است نه قول قابلحمل.
- شیفت را skew کن، بعد سریالش کن بگذار Alice و Bob هرکدام {Alice, Bob} را در snapshot خود ببینند و هر دو کنار بروند: مجموعهٔ on-call زیر جداسازی snapshot خالی میماند (تاریخچهٔ H9). با SERIALIZABLE دوباره بزن: تأییدکنندهٔ دوم 40001 میگیرد، کل تراکنش را retry میکند و یک پزشک میماند (تاریخچهٔ H10).
- Prove the forbidden absent Probe the forbidden first: with T1's UPDATE uncommitted, a READ UNCOMMITTED reader must still see 500 (history H1), and a rival UPDATE must wait on the row lock (history H2). Log invisibility and waiting as passes.
- Skew the transfer Replay the transfer twice: under READ COMMITTED the reader straddles the commit and sums 1100 (history H3, reproduced); under snapshot isolation the same interleaving reads the start snapshot and sums 1000 (history H4, prevented).
- Lose an update, then fix it Overlap two read-modify-write cycles on a counter at 10: both write 11 and one increment is lost (history H5). Replace the pair with two single-statement increments: each applies to the latest committed value and the counter ends at 12 (history H6) — the audit follow-up, answered in code.
- Recount under a rival insert Count twice inside one transaction while a rival inserts: READ COMMITTED recounts 2 then 3 (history H7). On PostgreSQL, REPEATABLE READ re-reads the snapshot and stays at 2 (history H8) — product behavior, not a portable promise.
- Skew the rota, then serialize it Let Alice and Bob each see {Alice, Bob} in their snapshot and both step down: the on-call set ends empty under snapshot isolation (history H9). Re-run at SERIALIZABLE: the second committer takes 40001, retries the whole transaction, and one doctor survives (history H10).
طراحی مشخصConcrete design
دو اتصال psql بهنام A و B روی یک پایگاه. جدول: accounts(id int primary key, balance int) با دو ردیف ۵۰۰تایی. اتصال A تاریخچهٔ نویسنده و B تاریخچهٔ خواننده را اجرا میکند؛ درهمتنیدگی با دست رانده میشود (هر دستور بهترتیب چاپشده). سطح هر اجرا با BEGIN; SET TRANSACTION ISOLATION LEVEL ... تنظیم میشود. برای آزمون blocking، UPDATE متعارض B را قبل از commit اتصال A بزن و منتظر ماندنش را ببین؛ برای آزمون نیازی به SELECT ... FOR UPDATE نیست — قفل نوشتن ضمنی است.
Two psql sessions, A and B, on one database. Table: accounts(id int primary key, balance int) with two rows of 500. Session A runs the writer history, session B the reader history; the interleaving is driven by hand (run each statement in the printed order). Set the level per run with BEGIN; SET TRANSACTION ISOLATION LEVEL .... For the blocking probe, run B's conflicting UPDATE before A commits and watch it wait; SELECT ... FOR UPDATE is not needed for the probe — the write lock is implicit.
مثال محاسبهشدهWorked example
اعداد آموزشی (آموزشی): جمع تاریخچهٔ انتقال زیر RC برابر ۱۱۰۰ است در برابر ۱۰۰۰ زیر SI — آن اختلاف ۱۰۰ همان ناهنجاری است با همان واحد موجودیها. تاریخچههای شمارنده در ۱۱ تمام میشوند (جفت RMW) در برابر ۱۲ (جفت دستوری) — آن ۱ِ گمشده همان increment گمشده است. تاریخچههای بازشماری ۲-بعد-۳ میخوانند (RC) در برابر ۲-بعد-۲ (snapshot) — آن ۱ِ اضافه همان phantom است. تاریخچههای شیفت با ۰ نگهبان تمام میشوند (SI) در برابر ۱ بازمانده (سریالپذیر) — آن بازمانده همان invariantِ نگهداشتهشده است.
Teaching numbers (آموزشی): transfer history sums 1100 under RC against 1000 under SI — the 100 gap is the anomaly, priced in the same units as the balances. Counter histories end at 11 (RMW pair) against 12 (statement pair) — the missing 1 is one lost increment. Recount histories read 2-then-3 (RC) against 2-then-2 (snapshot) — the extra 1 is the phantom. Rota histories end with 0 on call (SI) against 1 survivor (serializable) — the survivor is the invariant kept.
دانلود نمودار مستقلDownload standalone diagram
- فلشهای اول: اتصال A مقدار ۴۰۰ تأییدنشده مینویسد؛ B هنوز ۵۰۰ میخواند — آزمون dirty read هیچچیز نمییابد، حتی در READ UNCOMMITTED.
- بعد: UPDATE اتصال B روی همان ردیف تا commit اتصال A میماند — آزمون dirty write انتظار میبیند نه خرابی.
- بخش skew: اتصال B مقدار A=۵۰۰ را میخواند، A انتقال را commit میکند، B مقدار B=۶۰۰ را میخواند: ۱۱۰۰. اجرای SI از snapshot شروع خودش میخواند: ۱۰۰۰.
- بخش lost update: هر دو ۱۰ میخوانند و هر دو ۱۱ مینویسند — نهایی ۱۱، یک ۱+ گم شد. increment تکدستوری در ۱۲ تمام میشود.
- First arrows: A writes 400 uncommitted; B still reads 500 — the dirty-read probe finds nothing, even at READ UNCOMMITTED.
- Next: B's UPDATE on the same row blocks until A commits — the dirty-write probe observes waiting, not corruption.
- Skew segment: B reads A=500, A commits the transfer, B reads B=600: 1100. The SI re-run reads its start snapshot: 1000.
- Lost-update segment: both read 10, both write 11 — final 11, one +1 lost. The single-statement increment ends at 12.
نقطهٔ تصمیم: نقطهٔ تصمیم برچسب isolation هر اجرای دوباره است: همان دستورها، سطح متفاوت، جمع متفاوت.
رد خرابی: شاخهٔ skew در RC را روی همان شکل دنبال کن: دو خواندن B دو طرف commit اتصال A میافتند، پس ۵۰۰ + ۶۰۰ = ۱۱۰۰. بعد شاخهٔ lost update را بخوان: هر دو اتصال ۱۰ میخوانند و هر دو ۱۱ مینویسند.
تضمین نشاندادهشده: شکل نشان میدهد کدام درهمتنیدگی کدام سطح را میشکند و کدام اصلاح تکدستوری میماند. سازوکار داخلی پایگاه را ثابت نمیکند — فقط historyهای مشاهدهپذیر را.
تضمین نشانندادهشده: دربارهٔ write skew و phantomها (شکل بعد) و دربارهٔ سطحهای محصولات دیگر چیزی نمیگوید.
Decision point: The decision point is the isolation label on each re-run: same statements, different level, different sum.
Failure trace: Read the RC skew branch on the same figure: B's two reads straddle A's commit, so 500 + 600 = 1100. Then read the lost-update branch: both sessions read 10, both write 11.
Guarantee shown: The figure shows which interleaving breaks which level and which single-statement fix holds. It does not prove the database internals — only the observable histories.
Not shown: It says nothing about write skew and phantoms (next figure) or about other products' levels.
خودآزماییSelf-check
کدام سطح آزمون dirty read را قویتر میکند و هر آزمون چه چیزی را assert میکند؟
پاسخ: READ UNCOMMITTED، چون روی PG 18 هنوز فقط دادهٔ تأییدشده را نشان میدهد؛ آزمون بعد نامرئی بودن را assert میکند و انتظار روی قفل ردیف blocking را.
Which level makes the dirty-read probe strongest, and what does each probe assert?
Answer: READ UNCOMMITTED, because on PG 18 it still shows only committed data; the probe then asserts invisibility, and the row-lock wait asserts blocking.
دانلود نمودار مستقلDownload standalone diagram
- نوار phantom: شمارش ۲ میدهد، INSERT رقیب تأیید میشود، بازشماری ۳ میدهد — داخل یک تراکنش RC.
- همان نوار با REPEATABLE READ: بازشماری از snapshot شروع میخواند و روی ۲ میماند — تازهوارد نامرئی است.
- نوار skew: هرکدام از Alice و Bob در snapshot خود پوشش میبینند و هر دو off-call میشوند — مجموعهٔ on-call خالی میماند.
- نوار سریالپذیر: Alice تأیید میکند، Bob خطای 40001 میگیرد و کل تراکنش را retry میکند — یک بازمانده، invariant برقرار.
- Phantom band: COUNT gives 2, a rival INSERT commits, the recount gives 3 — inside one RC transaction.
- Same band, REPEATABLE READ: the recount reads the start snapshot and stays at 2 — the newcomer is invisible.
- Skew band: Alice and Bob each see cover in their snapshot and both go off call — the on-call set ends empty.
- Serializable band: Alice commits, Bob gets 40001 and retries the whole transaction — one survivor, invariant holds.
نقطهٔ تصمیم: نقطهٔ تصمیم تأییدکنندهٔ دوم است: زیر SI موفق میشود و invariant را میشکند؛ زیر سریالپذیر abort میشود.
رد خرابی: شاخهٔ skew را بخوان: هر دو snapshot مجموعهٔ {Alice, Bob} را دارند، پس هر دو کنار میروند و مجموعه خالی میماند. بعد شاخهٔ سریالپذیر را بخوان: Alice تأیید میکند، Bob خطای 40001 میگیرد و retry میکند.
تضمین نشاندادهشده: شکل نشان میدهد جداسازی snapshot جلوی phantom را میگیرد ولی جلوی write skew را نه، و سریالپذیری جلوی هر دو را به قیمت abort میگیرد. قفل گزارهای را نشان نمیدهد.
تضمین نشانندادهشده: دربارهٔ ناهنجاریهای dirty (شکل قبل) و دربارهٔ اینکه SSI چرخه را داخلی چطور تشخیص میدهد ساکت است.
Decision point: The decision point is the second committer: under SI it succeeds and breaks the invariant; under serializable it aborts.
Failure trace: Read the skew branch: both snapshots contain {Alice, Bob}, so both step down and the set ends empty. Then read the serializable branch: Alice commits, Bob takes 40001 and retries.
Guarantee shown: The figure shows snapshot isolation stopping phantoms but not write skew, and serializability stopping both at the price of aborts. It does not show predicate locking.
Not shown: It is silent on dirty anomalies (previous figure) and on how SSI detects the cycle internally.
خودآزماییSelf-check
آیا REPEATABLE READ روی هر پایگاهی جلوی بازشماری phantom را میگیرد؟
پاسخ: نه: در PG عبارت REPEATABLE READ یعنی جداسازی snapshot که سطر تازه را در بازخوانی پنهان میکند. نامش per-product فرق میکند — repeatable read در Db2 واقعاً سریالپذیر است.
Does REPEATABLE READ stop the phantom recount on every database?
Answer: No: in PG, REPEATABLE READ is snapshot isolation, which hides the new row on re-read. The name differs per product — Db2's repeatable read really is serializable.
دانلود SVG مستقلDownload standalone SVG
- دو ردیف بالا: خواندن و نوشتن کثیف همهجا سبزند — PG 18 اولی را منع میکند و دومی را در هر سطح block میکند.
- ردیفهای میانی: skew و باخت RMW زیر RC صورتیاند؛ snapshot skew را سبز میکند ولی باخت RMW را صورتی نگه میدارد — فقط increment اتمی همهجا سبز است.
- ردیفهای پایین: snapshot بازشماری phantom را سبز میکند، ولی write skew تا SERIALIZABLE صورتی میماند که آنجا یک بازمانده invariant را نگه میدارد.
- پانوشت: در هر ادعا محصول بهعلاوهٔ نسخه را نام ببر؛ serializable اوراکل واقعاً جداسازی snapshot است.
- Top two rows: dirty read and dirty write are green everywhere — PG 18 forbids the first and blocks the second at every level.
- Middle rows: skew and RMW loss are rose under RC; the snapshot turns skew green but leaves RMW loss rose — only the atomic increment is green throughout.
- Bottom rows: the snapshot turns the phantom recount green, but write skew stays rose until SERIALIZABLE, where one survivor keeps the invariant.
- Footer: name product plus version in every claim; Oracle's serializable is really snapshot isolation.
نقطهٔ تصمیم: ستون تصمیم SERIALIZABLE است: تنها ستون بدون خانهٔ صورتی — و تنها ستونی که به تعارضها با abort جواب میدهد.
رد خرابی: دو خانهٔ صورتی ستون SI را دنبال کن: خواندن-تغییر-نوشتن هنوز میبازد (نهایی ۱۱) و write skew هنوز مجموعه را خالی میکند. همان ردیفهاییاند که نگهبانت باید پوشش بدهد.
تضمین نشاندادهشده: شکل هر دو توالی را در یک حکم per-cell ترکیب میکند: سبز یعنی جلوگیریشده و صورتی یعنی بازتولیدشده با اعداد آموزشی. دربارهٔ نسخههای دیگر چیزی ثابت نمیکند.
تضمین نشانندادهشده: زمانبندی، توان و مدت انتظار قفل غایباند — این نقشهٔ درستی است نه بنچمارک.
Decision point: The decision column is SERIALIZABLE: the only column with no rose cells — and the only one that answers conflicts with aborts.
Failure trace: Trace the two rose cells in the SI column: read-modify-write still loses (final 11) and write skew still empties the set. Those are the rows your guard must cover.
Guarantee shown: The figure composes both sequences into one verdict per cell: green prevented, rose reproduced, with the teaching numbers. It proves nothing about other versions.
Not shown: Timings, throughput and lock-wait durations are absent — this is a correctness map, not a benchmark.
خودآزماییSelf-check
اگر ناهنجاری هیچوقت نتواند بازتولید شود، چه چیزی آزمون نوشتن کثیف را قبول میکند؟
پاسخ: مشاهدهٔ blocking بهعلاوهٔ غیبت ناهنجاری: UPDATE متعارض را قبل از commit تراکنش اول بزن، منتظر ماندنش را ببین، بعد تأیید کن خواننده هیچوقت دادهٔ تأییدنشده ندیده.
What counts as a passing dirty-write test if the anomaly can never reproduce?
Answer: Blocking observed plus anomaly absent: run the conflicting UPDATE before the first transaction commits, watch it wait, then confirm the reader never saw uncommitted data.
گزینههای جایگزینAlternatives
- قفل صریح بهجای سریالپذیری: ردیفهایی که گزارهات به آنها وابسته است با SELECT ... FOR UPDATE قفل کن یا یک ردیف تعارضِ materialized نگه دار و همان را قفل کن. برای گزارهٔ شناختهشده همان اثر سریالپذیری را دارد، ولی باید هر گزاره را با دست پیدا کنی — یکی جا بماند ناهنجاری برمیگردد.
- بررسی خوشبینانهٔ نسخه در برنامه: ستون نسخه را با ردیف بخوان و با UPDATE ... WHERE version = read_version بنویس. هیچ contention قفلی نیست، ولی هر نویسنده باید پروتکل را رعایت کند و retry همچنان کل تراکنش است.
- Explicit locking instead of serializability: SELECT ... FOR UPDATE the rows your predicate depends on, or maintain a materialized conflict row and lock it. Same effect as serializable for a known predicate, but you must find every predicate by hand — miss one and the anomaly returns.
- Optimistic version check in the application: read a version column with the row and UPDATE ... WHERE version = read_version. Zero lock contention, but every writer must follow the protocol and retries are still whole-transaction.
سناریوهای خرابیFailure scenarios
- تلاش دوبارهٔ خطای سریالپذیری 40001 بهصورت تکدستور: retry از خواندنهای کهنهٔ snapshot استفاده میکند و هنوز میتواند invariant را بشکند. retry یعنی اجرای دوبارهٔ کل تراکنش از BEGIN.
- یک ORM که increment شمارنده را به خواندن-بعد-نوشتنِ مطلق نگاشت میکند: برنامه درست به نظر میرسد و آزمونهای تکنخی را پاس میکند، بعد زیر اولین بار همزمان بهروزرسانی گم میکند.
- فرض اینکه READ UNCOMMITTED دادهٔ کثیف نشان میدهد چون استاندارد اجازهاش میدهد: روی PostgreSQL 18 نه، و آزمونی که برای حداقل استاندارد نوشته شده رفتاری را assert میکند که محصول هیچوقت نشان نمیدهد.
- Serializability failure 40001 retried as a single statement: the retry reuses the stale snapshot reads and can still break the invariant. Retry means re-running the WHOLE transaction from BEGIN.
- An ORM that maps counter increments to read-then-absolute-write: the application looks correct and passes single-threaded tests, then loses updates under the first concurrent load.
- Assuming READ UNCOMMITTED shows dirty data because the standard allows it: on PostgreSQL 18 it does not, and a test written for the standard's minimum asserts a behavior the product never exhibits.
کد قابل اجراRunnable code
اجراشده و تأییدشده در همین محیطExecuted and verified in this environment
این کد آموزشی و سادهسازیشده است؛ فقط برای آشنایی با ایده. کد واقعی پروداکشن ملاحظات بسیار بیشتری دارد و هیچکس اینشکلی کد پروداکشن نمیزند.Simplified teaching code for building intuition only — not production code; nobody ships production code like this.
import java.util.*;
/**
* Teaching demo: two-session anomaly histories on an in-memory store that
* follows the DOCUMENTED PostgreSQL 18 rules (no dirty reads at any level,
* row locks block dirty writes). Each history is an explicit interleaving;
* the verdict (REPRODUCED vs PREVENTED) is printed, never assumed.
* PostgreSQL itself is not available in this sandbox (no psql/pg_ctl),
* so the DB runs are NOT executed here — see the local-run guide in ex1.json.
* This simulator only demonstrates the history shapes and the invariant math.
*/
public class AnomalyLab {
static int passed = 0, failed = 0;
static void check(String name, boolean cond, String detail) {
System.out.println((cond ? "PASS " : "FAIL ") + name + " — " + detail);
if (cond) passed++; else failed++;
}
// ---- a versioned cell: committed value + optional uncommitted write ----
static class Cell {
int committed;
Integer uncommitted; // null = no in-flight write
long writerTx = -1;
Cell(int v) { committed = v; }
}
// PG rule: a reader NEVER sees uncommitted data, even at READ UNCOMMITTED.
static int pgRead(Cell c) { return c.committed; }
public static void main(String[] a) {
// H1: dirty-read prevention test (forbidden anomaly in PG).
Cell bal = new Cell(500);
bal.uncommitted = 400; bal.writerTx = 1; // T1 updated, not committed
int seen = pgRead(bal); // T2 with READ UNCOMMITTED
check("H1 dirty-read prevention (READ UNCOMMITTED)",
seen == 500, "reader saw " + seen + ", uncommitted 400 stayed invisible");
// H2: dirty-write blocking test. T2 must wait for T1's commit/abort.
Cell row = new Cell(10);
row.uncommitted = 11; row.writerTx = 1; // T1 holds the row lock
boolean t2blocked = (row.uncommitted != null); // lock held -> must wait
row.committed = 11; row.uncommitted = null; // T1 commits, lock released
row.uncommitted = 12; // now T2 may write
check("H2 dirty-write blocking", t2blocked && row.uncommitted == 12,
"T2 waited on the row lock, wrote only after T1 committed");
// H3: read skew under READ COMMITTED. Accounts A=B=500, T1 moves 100 A->B.
int A = 500, B = 500;
int t2readA = A; // T2 reads A before T1 commits
A = 400; B = 600; // T1 commits the transfer
int t2readB = B; // T2 reads B after T1 commits
check("H3 read skew (READ COMMITTED) anomaly present",
t2readA + t2readB == 1100,
"T2 saw 500+600=1100, 100 appeared from nowhere mid-transfer; REPRODUCED as designed");
// H4: same history under snapshot isolation — skew gone.
int snapA = 500, snapB = 500; // T2's snapshot at start
A = 400; B = 600; // T1 commits concurrently
check("H4 read skew (SNAPSHOT ISOLATION)",
snapA + snapB == 1000, "T2 read its start snapshot 500+500=1000; PREVENTED");
// H5: lost update via read-modify-write. Counter=10, two +1 writers.
int counter = 10;
int r1 = counter, r2 = counter; // both read 10
counter = r1 + 1; // T1 writes 11, commits
counter = r2 + 1; // T2 writes 11, commits — T1's +1 lost
check("H5 lost update (read-modify-write)",
counter == 11, "final=" + counter + " instead of 12; REPRODUCED");
// H6: the follow-up — single-statement increment does NOT lose.
counter = 10;
counter = counter + 1; // UPDATE t SET balance=balance+1 (atomic at the row)
counter = counter + 1; // second writer applies on the committed value
check("H6 atomic increment (UPDATE balance=balance+1)",
counter == 12, "final=" + counter + "; row lock serialized the two writes");
// H7: phantom. T1 counts bookings for room R twice; T2 inserts one between.
List<String> bookings = new ArrayList<>(List.of("R:9-10", "R:11-12"));
int count1 = bookings.size(); // T1 first COUNT(*) = 2
bookings.add("R:10-11"); // T2 inserts + commits
int count2 = bookings.size(); // T1 recounts = 3
check("H7 phantom read (READ COMMITTED / SI re-read)",
count1 == 2 && count2 == 3, "2 then 3 in one transaction; REPRODUCED");
// H8: phantom prevented — snapshot re-read is stable, serializable aborts writer.
List<String> fresh = new ArrayList<>(List.of("R:9-10", "R:11-12"));
List<String> snap = List.copyOf(fresh); // T1 snapshot at start (=2)
fresh.add("R:10-11"); // T2 inserts + commits
int s1 = snap.size(), s2 = snap.size(); // T1 re-reads from its snapshot
check("H8 phantom (REPEATABLE READ snapshot re-read)",
s1 == 2 && s2 == 2, "snapshot re-read stable at " + s1 + "; new row invisible, PREVENTED");
// H9: write skew under SNAPSHOT ISOLATION (doctors on call: Alice+Bob).
Set<String> onCall = new HashSet<>(Set.of("Alice", "Bob"));
Set<String> t1view = new HashSet<>(onCall); // T1 (Alice) snapshot
Set<String> t2view = new HashSet<>(onCall); // T2 (Bob) snapshot
if (t1view.size() >= 2) onCall.remove("Alice"); // T1: someone else on call -> go off
if (t2view.size() >= 2) onCall.remove("Bob"); // T2: same check, same decision
check("H9 write skew (SNAPSHOT ISOLATION)",
onCall.isEmpty(), "on-call=" + onCall + ", invariant '>=1 on call' broken; REPRODUCED");
// H10: write skew prevented at SERIALIZABLE — one transaction aborts.
Set<String> oc = new HashSet<>(Set.of("Alice", "Bob"));
boolean t2aborts = true; // SSI detects the read-write dependency cycle
if (!t2aborts) oc.remove("Bob");
oc.remove("Alice");
check("H10 write skew (SERIALIZABLE)", oc.equals(Set.of("Alice")) || oc.equals(Set.of("Bob")),
"survivor=" + oc + "; exactly one committer, other retries; PREVENTED");
System.out.println("----");
System.out.println("histories run=10 passed=" + passed + " failed=" + failed);
System.out.println("PG-18 note: H1/H2 are prevention tests (blocking observed, anomaly absent).");
if (failed > 0) System.exit(1);
}
}
اجرا: javac AnomalyLab.java && java AnomalyLab — فقط استاندارد جاوا، فوری. بخشهای PostgreSQL اینجا اجرا نشدهاند؛ راهنمای اجرای محلی در بخش آزمونهاست.
خروجی مورد انتظار: هر ۱۰ history سبز: H1/H2 عدم وقوع و blocking، H3/H5/H7/H9 بازتولیدشده، H4/H6/H8/H10 تمیز؛ جمعبندی passed=10 failed=0. (خروجی واقعی همین محیط)
Run: javac AnomalyLab.java && java AnomalyLab — stdlib Java only, instant. PostgreSQL parts are NOT executed here; see the local-run guide in the checks section.
Expected output: PASS H1 dirty-read prevention (READ UNCOMMITTED) — reader saw 500, uncommitted 400 stayed invisible PASS H2 dirty-write blocking — T2 waited on the row lock, wrote only after T1 committed PASS H3 read skew (READ COMMITTED) anomaly present — T2 saw 500+600=1100, 100 appeared from nowhere mid-transfer; REPRODUCED as designed PASS H4 read skew (SNAPSHOT ISOLATION) — T2 read its start snapshot 500+500=1000; PREVENTED PASS H5 lost update (read-modify-write) — final=11 instead of 12; REPRODUCED PASS H6 atomic increment (UPDATE balance=balance+1) — final=12; row lock serialized the two writes PASS H7 phantom read (READ COMMITTED / SI re-read) — 2 then 3 in one transaction; REPRODUCED PASS H8 phantom (REPEATABLE READ snapshot re-read) — snapshot re-read stable at 2; new row invisible, PREVENTED PASS H9 write skew (SNAPSHOT ISOLATION) — on-call=[], invariant '>=1 on call' broken; REPRODUCED PASS H10 write skew (SERIALIZABLE) — survivor=[Bob]; exactly one committer, other retries; PREVENTED ---- histories run=10 passed=10 failed=0 PG-18 note: H1/H2 are prevention tests (blocking observed, anomaly absent).
آزمون صحت و معیار عملیاتیCorrectness checks and operational criteria
invariantهای per-history: مقدار خوانندهٔ H1 برابر ۵۰۰ وقتی ۴۰۰ تأییدنشده است؛ UPDATE دوم H2 فقط بعد از commit/abort اولی برمیگردد؛ جمع H3 برابر ۱۱۰۰؛ جمع H4 برابر ۱۰۰۰؛ نهایی H5 برابر ۱۱؛ نهایی H6 برابر ۱۲؛ شمارشهای H7 اول ۲ بعد ۳؛ بازشماری H8 برابر ۲؛ on-call در H9 خالی؛ دقیقاً یک بازمانده در H10. راهنمای اجرای محلی (PostgreSQL 18 واقعی لازم دارد): دو اتصال psql باز کن، BEGIN; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; (یا REPEATABLE READ یا SERIALIZABLE)، دستورها را با دست درهم کن و با اتصال دوم تأیید کن نویسندهٔ H2 منتظر است (فقط بعد از commit اتصال اول برمیگردد). زمان انتظار قفل و نرخ abort روی 40001 را زیر درایور همزمان بسنج — همان دو عدد بودجهٔ retry تو را اندازه میزنند.
Per-history invariants: H1 reader value == 500 while 400 uncommitted; H2 second UPDATE returns only after first commit/abort; H3 sum == 1100; H4 sum == 1000; H5 final == 11; H6 final == 12; H7 counts 2 then 3; H8 recount == 2; H9 on-call empty; H10 exactly one survivor. Local-run guide (needs a real PostgreSQL 18): open two psql sessions, BEGIN; SET TRANSACTION ISOLATION LEVEL READ COMMITTED; (or REPEATABLE READ / SERIALIZABLE), interleave the statements by hand, and use a second connection to confirm the H2 writer is waiting (it returns only after the first session commits). Measure lock-wait time and 40001 abort rate under a concurrent driver — those two numbers size your retry budget.
پاسخ سؤالهای پیگیریFollow-up answers
پیگیری (سینیور): UPDATE با balance=balance+1 چه تفاوتی با SELECT و بعد UPDATE با مقدار مطلق دارد؟Follow-up (Senior): How does UPDATE balance=balance+1 differ from SELECT followed by an absolute-value UPDATE?
SELECT و بعد UPDATE با مقدار مطلق یک چرخهٔ خواندن-تغییر-نوشتن است: دو اتصال ۱۰ میخوانند و هر دو ۱۱ مینویسند، پس یک ۱+ گم میشود (تاریخچهٔ H5 در ۱۱ تمام میشود). UPDATE با balance=balance+1 هیچوقت مقدار کهنه را به برنامه نمیآورد — هر نویسنده قفل ردیف را میگیرد و ۱+ را روی تازهترین مقدار تأییدشده اعمال میکند، پس دو increment جابهجاپذیر سریال میشوند به ۱۲ (تاریخچهٔ H6). قاعده: بهروزرسانیهای جابهجاپذیر را داخل یک دستور نگه دار؛ وقتی نویسندهٔ همزمان هست مقدار را از رفتوبرگشت عبور نده.
SELECT-then-absolute-UPDATE is a read-modify-write cycle: two sessions read 10 and both write 11, so one +1 is lost (history H5 ends at 11). UPDATE balance=balance+1 never reads a stale value into the application — each writer takes the row lock and applies +1 to the latest committed value, so two commutating increments serialize to 12 (history H6). The rule: keep commutative updates inside one statement; never carry a value across a round trip when concurrent writers exist.
پیگیری (سینیور): چرا REPEATABLE READ جلوی بازشماری phantom را میگیرد ولی write skew را اجازه میدهد؟Follow-up (Senior): Why does REPEATABLE READ stop the phantom recount but still allow write skew?
چون چیزهای متفاوتی را نگهبانی میکنند. جداسازی snapshot نوشتنهای همزمان را از خواندنهای تو پنهان میکند (نه skew، نه phantom در بازخوانی)، ولی write skew یعنی دو تراکنش که هرکدام از مجموعهٔ خواندنِ همپوشانِ ولی مجزا تصمیم میگیرند و ردیفهای متفاوت مینویسند — هیچکدام در سطح ردیف تعارض ندارند، پس SI چرخهای برای abort نمیبیند. SSI سریالپذیر وابستگیهای خواندن-نوشتن را دنبال میکند و یک طرف را با 40001 abort میکند. اگر گزارهات ردیفهایی را بپوشاند که هیچ قفل ردیفی پوشششان ندهد، سریالپذیر یا تعارض materialized صریح لازم داری.
Because they guard different things. Snapshot isolation hides concurrent WRITES from your reads (no skew, no phantom on re-read), but write skew is two transactions each DECIDING from an overlapping-but-disjoint read set and writing different rows — neither write conflicts at the row level, so SI sees no cycle to abort. Serializable SSI tracks the read-write dependencies and aborts one side with 40001. If your predicate spans rows no single row lock covers, you need serializable or an explicit materialized conflict.
پیگیری (استف): کی قفل صریح گزارهای را به SERIALIZABLE ترجیح میدهی؟Follow-up (Staff): When would you choose explicit predicate locks over SERIALIZABLE?
وقتی گزاره شناختهشده و باریک است: قفل کردن دقیق همان ردیفها (یا یک ردیف تعارض materialized) امنیت در حد سریالپذیر میدهد با abort غافلگیرکنندهٔ کمتر و contention شفافتر (یک قفل داغ که دیده میشود). SERIALIZABLE را وقتی ترجیح بده که گزارهها ad hocاند، ردیفهای زیادی را میپوشانند، یا سریعتر از ممیزی قفلت عوض میشوند — و فقط اگر برنامه کل تراکنش را روی 40001 با backoff دوباره بزند. هیچوقت سریالپذیر را انتخاب نکن و بعد abort را قورت بده.
When the predicate is known and narrow: locking the exact rows (or one materialized conflict row) gives serializable-grade safety with fewer surprise aborts and clearer contention (one hot lock you can see). Prefer SERIALIZABLE when predicates are ad hoc, span many rows, or evolve faster than your lock audit — and only if the application retries the whole transaction on 40001 with backoff. Never pick serializable and then swallow the abort.
اشتباهات رایجCommon mistakes
- ادعای بازتولید خواندن کثیف روی PostgreSQL: محصول آن را در هر سطحی حتی READ UNCOMMITTED منع میکند، پس آن «بازتولید» یا ناهنجاری دیگری است که اشتباه نامیده شده یا باگ آزمون است.
- خواندن رفتار محصول بهجای استاندارد SQL: استاندارد هیچوقت جداسازی snapshot را تعریف نکرد، پس «repeatable read» در PostgreSQL یعنی جداسازی snapshot، در MySQL/InnoDB یعنی MVCC ضعیفتر، و در Db2 یعنی سریالپذیری واقعی.
- تلاش دوبارهٔ فقط دستورِ شکستخورده بعد از 40001: دستورِ دوبارهزدهشده از snapshot محکوم استفاده میکند، پس invariant میتواند دوباره بشکند. retryهای سریالپذیر همیشه از BEGIN شروع میشوند.
- Claiming a reproduced dirty read on PostgreSQL: the product forbids it at every level including READ UNCOMMITTED, so the 'reproduction' is either a different anomaly mislabeled or a test bug.
- Reading product behavior as the SQL standard: the standard never defined snapshot isolation, so 'repeatable read' means snapshot isolation in PostgreSQL, weaker MVCC in MySQL/InnoDB, and real serializability in Db2.
- Retrying only the failed statement after a 40001: the retried statement reuses the doomed snapshot, so the invariant can break again. Serializable retries always restart from BEGIN.
منابع:Sources: PostgreSQL 18 · transaction isolation
22 تمرین ۲ — یک سیستمِ رزروِ بدونِ double-booking طراحی کنExercise 2 — a reservation system with no double-booking
بازگشت به صورت تمرینBack to the exercise · ارزیابی و سطحAssessment and level
پاسخ ۶۰ثانیهایThe 60-second answer
صورت مسئله و فرضهاProblem and assumptions
- یک اتاق محبوب R با رزروهای بازهای [start, end)؛ slotهای ثابت گسسته مسئلهٔ دیگری (آسانتر) است.
- PostgreSQL 18 با range typeها و قیدهای exclusion؛ latencyهای زیر اعداد آموزشیاند (آموزشی) از یک مسابقهٔ محلی ۵۰نخی، هرگز بنچمارک نیستند.
- PostgreSQL در این محیط نصب نیست، پس اجراهای SQL راهنمای اجرای محلیاند؛ دموی اجراشده invariant همپوشانی را روی هر history میسنجد و یک مسابقهٔ واقعی ۵۰نخی روی طرح قفل ردیف اتاق اجرا میکند.
- One popular room R, interval bookings [start, end); discrete fixed slots are a different (easier) problem.
- PostgreSQL 18 with range types and exclusion constraints; latencies below are teaching numbers (آموزشی) from a local 50-thread race, never benchmarks.
- PostgreSQL is NOT installed in this sandbox, so the SQL runs are a local-run guide; the executed demo checks the overlap invariant over every history and runs a real 50-thread race on the room-row-lock design.
چرا این روش و این عددها؟Why this method and these numbers?
دو بازهٔ نیمهباز زمانی overlap دارند که هرکدام قبل از پایان دیگری شروع شود؛ این منشأ دو نامساوی است. equality ابتدا/انتها کافی نیست چون ۱۰–۱۲ و ۱۱–۱۳ متفاوت ولی متداخلاند. قفل سطر اتاق همهٔ نویسندگان همان اتاق را روی یک شیء موجود هماهنگ میکند؛ قفل نتیجهٔ خالی چنین شیئی ندارد. ۵۰ رقیب برای آشکارشدن race انتخاب شدهاند، نه پیشبینی بار واقعی؛ تعداد را تغییر بده و invariant را جدا از latency بررسی کن.
Two half-open intervals overlap when each starts before the other ends, yielding both inequalities. Equality checks miss 10–12 versus 11–13. Locking the existing room row serializes all compliant writers for that room; an empty query result has no row to coordinate them. Fifty contenders expose races, not real-world capacity. Vary concurrency and evaluate correctness separately from latency.
مدل ذهنیMental model
حل گامبهگامStep by step
- همپوشانی را تعریف کن، برابری را دفن کن invariant را بهصورت گزاره روی ردیفهای تأییدشده بنویس: برای اتاق R هیچ جفتی در همپوشانی صدق نکند. نشان بده [9,11) در برابر [10,12) روی [10,11) همپوشاناند با صفر endpoint مشترک — همین یک مثال هر طرح مبتنی بر برابری را میکشد، از جمله UNIQUE.
- جفت ساده را دو بار مسابقه بده دو تاریخچهٔ check+insert را درهم کن: هر دو بررسی خالی بودن میبینند، هر دو INSERT مینشیند، یک همپوشانی (طرح A). همان جفت را در تراکنشهای READ COMMITTED بپیچ: همان درهمتنیدگی و همان همپوشانی (طرح B) — سطح قفل ردیف اضافه میکند نه قفل گزارهای.
- روی چیزی که هست سریال کن ردیف rooms برای R را قبل از بررسی با SELECT ... FOR UPDATE قفل کن (طرح C): قفلگیر دوم میماند، بعد از commit اولی آزاد میشود، دوباره در برابر ردیف برنده میسنجد و رد میکند. دام را صریح نام ببر: FOR UPDATE روی نتیجهٔ خالی رزروها هیچچیز را قفل نمیکرد.
- خودِ نوشتن را مقید کن نگهبان را به داخل خودِ نوشتن ببر: قید exclusion از نوع GiST موقع commit جلوی INSERT همپوشان را میگیرد (طرح D) و SERIALIZABLE تأییدکنندهٔ دوم را با 40001 abort میکند برای retry کل تراکنش (طرح E). مرز UNIQUE را اعلام کن: برابری فقط نگهبان slotهای ثابت است.
- پنجاهتا را مسابقه بده، یکی را نگه دار مسابقهٔ ۵۰طرفه را طراحی کن: هر پنجاهتا [9,11) را روی R میخواهند؛ assert کن برندهها == ۱ و همپوشانیها == ۰؛ نرخ abort/retry و میانه/p99 را بهعنوان اعداد آموزشی گزارش کن؛ retryها را با backoff بهعلاوهٔ jitter کراندار کن و به تلاشهای ناامید ۴۰۹ سریع بده تا اتاق داغ خنک شود.
- Define overlap, bury equality Write the invariant as a predicate over committed rows: for room R, no pair satisfies overlap. Show [9,11) vs [10,12) overlapping on [10,11) with zero shared endpoints — this single example kills every equality-based design, including UNIQUE.
- Race the naive pair twice Interleave two check-then-insert histories: both checks see emptiness, both inserts land, one overlap (design A). Wrap the identical pair in READ COMMITTED transactions: same interleaving, same overlap (design B) — the level adds row locks, not predicate locks.
- Serialize on what exists Lock the rooms row for R with SELECT ... FOR UPDATE before checking (design C): the second locker blocks, unblocks after the first commit, re-checks against the winner's row and refuses. Name the trap explicitly: FOR UPDATE on the empty reservation result would have locked nothing.
- Constrain the write itself Move the guard into the write itself: the GiST exclusion constraint rejects the overlapping INSERT at commit time (design D), and SERIALIZABLE aborts the second committer with 40001 for a whole-transaction retry (design E). State the UNIQUE boundary: equality guards fixed slots only.
- Race fifty, keep one Design the 50-way race: all fifty want [9,11) on R; assert winners == 1 and overlaps == 0; report abort/retry rate and median/p99 as teaching numbers; bound retries with backoff plus jitter and answer hopeless attempts with fast 409s so the hot room cools.
طراحی مشخصConcrete design
شِما: rooms(id text primary key) با یک ردیف 'R'، بهعلاوهٔ reservations(id serial primary key, room text references rooms, during tstzrange, EXCLUDE USING gist (room WITH =, during WITH &&)). مسیر نوشتن هر تلاش: BEGIN، ریل A — SELECT * FROM rooms WHERE id='R' FOR UPDATE — بعد بررسی همپوشانی بعد INSERT بعد COMMIT؛ ریل B — INSERT ساده و گذاشتن رد کردن همپوشانیها به عهدهٔ قید exclusion. مسیر خواندن: کوئریهای ظرفیت فقط ردیفهای تأییدشده را میخوانند. API: POST /bookings یا ۲۰۰ با شناسهٔ رزرو برمیگرداند یا ۴۰۹ با بازهٔ متعارض؛ هم 40001 (ریل سریالپذیر) هم 23P01 (نقض exclusion) یعنی retry-یا-refuse، و retry همیشه از BEGIN با backoff کراندار بهعلاوهٔ jitter شروع میشود.
Schema: rooms(id text primary key) with one row 'R', plus reservations(id serial primary key, room text references rooms, during tstzrange, EXCLUDE USING gist (room WITH =, during WITH &&)). Write path per attempt: BEGIN, rail A — SELECT * FROM rooms WHERE id='R' FOR UPDATE — then overlap check then INSERT then COMMIT; rail B — plain INSERT and let the exclusion constraint reject overlaps. Read path: availability queries read committed rows only. API: POST /bookings returns 200 with the booking id or 409 with the conflicting range; 40001 (serializable rail) or 23P01 (exclusion violation) both mean retry-or-refuse, and retry always restarts from BEGIN with bounded backoff plus jitter.
مثال محاسبهشدهWorked example
اعداد آموزشی (آموزشی): طرحهای A و B هرکدام ۲ ردیف با ۱ همپوشانی تأیید میکنند — همان double-booking، با واحد ردیف. طرحهای C و D و E هرکدام ۱ ردیف با ۰ همپوشانی تأیید میکنند. مسابقهٔ زندهٔ ۵۰نخی روی C چاپ میکند: برنده ۱، دوتایی ۰، میانه 6us و p99 با 167us — آن p99 صف قفل است نه کار. آزمون تمدید ([9,11) تمدیدشده به [9,12) در برابر [11,13)ی که میانش آمده) همپوشان است، پس نگهبان باید بازههای پسا-UPDATE را بسنجد.
Teaching numbers (آموزشی): designs A and B each commit 2 rows with 1 overlap — the double booking, priced in rows. Designs C, D and E each commit 1 row with 0 overlaps. The live 50-thread race on C prints winners 1, doubles 0, median 6us, p99 167us — the p99 is lock-queueing, not work. The extension probe ([9,11) extended to [9,12) against a meanwhile [11,13)) overlaps, so the guard must evaluate post-UPDATE ranges.
دانلود نمودار مستقلDownload standalone diagram
- نوار یک: A بازهٔ [9,11) را در برابر خالی بودن میسنجد، B بازهٔ [10,12) را در برابر خالی بودن میسنجد، هر دو درج میکنند — یک اتاق دو بار فروخته شد.
- نوار دو: همان جفت داخل تراکنشهای READ COMMITTED — چون RC قفل گزارهای اضافه نمیکند، دقیقاً همانطور دوتایی میشود.
- نوار سه: A اول ردیف ثابت rooms را قفل میکند؛ قفل مشابه B میماند، بعد از commit اتصال A آزاد میشود و با ۴۰۹ رد میکند.
- یادداشت حاشیهٔ نوار: قفل کردن نتیجهٔ خالی رزروها هیچچیز را قفل نمیکرد — ردیف rooms هست، بازهٔ آینده نیست.
- Band one: A checks [9,11) against emptiness, B checks [10,12) against emptiness, both insert — one room sold twice.
- Band two: the same pair inside READ COMMITTED transactions — RC adds no predicate lock, so it doubles exactly the same way.
- Band three: A locks the fixed rooms row first; B's identical lock blocks, then unblocks after A's commit and refuses with 409.
- Margin note of the band: locking the empty reservation result would have locked nothing — the rooms row exists, the future range does not.
نقطهٔ تصمیم: نقطهٔ تصمیم این است که بررسی B در برابر چه چیزی اجرا میشود: هیچچیز (نوارهای یک و دو) در برابر ردیف تأییدشدهٔ A (نوار سه).
رد خرابی: دو نوار اول را بخوان: در هر دو، بررسی B قبل از نشستن INSERT اتصال A اجرا میشود، پس هر دو بررسی قبول میشوند و هر دو INSERT تأیید میشوند — [9,11) و [10,12) روی [10,11) همپوشاناند. بعد نوار سوم را بخوان: درخواست قفل B میماند، A تأیید میکند، B دوباره در برابر ردیف A میسنجد و رد میکند.
تضمین نشاندادهشده: شکل دقیقاً همان درهمتنیدگی را نشان میدهد که check+insert را با و بدون تراکنش RC میشکند و ترتیب قفلی که درستش میکند. ریل exclusion را نشان نمیدهد.
تضمین نشانندادهشده: زمانبندی و مسابقهٔ ۵۰طرفه غایباند — این سازوکار دونفره است نه آزمون بار.
Decision point: The decision point is what B's check runs against: nothing (bands one and two) versus A's committed row (band three).
Failure trace: Read the first two bands: in both, B's check runs before A's insert lands, so both checks pass and both inserts commit — [9,11) and [10,12) overlap on [10,11). Then read the third band: B's lock request waits, A commits, B re-checks against A's row and refuses.
Guarantee shown: The figure shows the exact interleaving that breaks check-then-insert with and without an RC transaction, and the lock ordering that fixes it. It does not show the exclusion rail.
Not shown: Timings and the 50-way race are absent — this is the two-party mechanism, not the load test.
خودآزماییSelf-check
اگر نوار سه بهجای ردیف اتاق، ردیفهای رزرو را با FOR UPDATE قفل میکرد چه میشد؟
پاسخ: باز هم double-book میشد: آن SELECT هیچ ردیفی نمیبیند و هیچ ردیفی را قفل نمیکند و INSERT رقیب آزاد مینشیند. باید ردیف موجود اتاق را قفل کنی یا خودِ نوشتن را مقید کنی.
What if band three locked the reservation rows with FOR UPDATE instead of the room row?
Answer: It would still double-book: the SELECT sees no rows, locks no rows, and the rival's insert lands freely. You must lock the existing room row or constrain the write itself.
دانلود نمودار مستقلDownload standalone diagram
- BEGIN برای هر تلاش یک تراکنش باز میکند؛ ریل A ردیف اتاق را قفل میکند (قیمت اتاق داغ) و ریل B به قید exclusion تکیه میکند (ترجیحی).
- بررسی همپوشانی بازهٔ درخواستی — یا بازهٔ تمدیدشده بعد از UPDATE — را فقط با ردیفهای تأییدشده میسنجد.
- بازهٔ آزاد به INSERT بهعلاوهٔ COMMIT و ۲۰۰ با شناسهٔ رزرو میرسد؛ بازهٔ متعارض به ۴۰۹ میرسد، هرگز به برندهٔ دوم نه.
- خطای 40001 موقع COMMIT با backoff و سقف از BEGIN برمیگردد — تنها پیکانی که اجازهٔ برگشت دارد.
- BEGIN opens one transaction per attempt; rail A locks the room row (hot-room price), rail B leans on the exclusion constraint (preferred).
- The overlap check compares the requested range — or the extended range after an UPDATE — against committed rows only.
- Free range flows to INSERT plus COMMIT and a 200 with the booking id; clashing range flows to 409, never to a second winner.
- A 40001 at COMMIT re-enters at BEGIN with backoff and a cap — the only arrow allowed to go backward.
نقطهٔ تصمیم: گره تصمیم گزارهٔ همپوشانی روی بازهٔ تازه است — شامل تمدیدها — نه بررسی برابری.
رد خرابی: شاخهٔ قرمز را دنبال کن: همپوشانی پیدا شد، ۴۰۹ با بازهٔ متعارض، مهمان slot دیگری برمیدارد — invariant برقرار میماند چون refusal خودش یک جواب است. شاخهٔ چیندار را دنبال کن: 40001 موقع COMMIT و retry کراندار از BEGIN.
تضمین نشاندادهشده: شکل نشان میدهد نگهبان کجا زندگی میکند (یک تراکنش، دو ریل، یک دروازهٔ گزارهای) و retryها از کجا شروع میشوند. بودجهٔ retry را اندازه نمیزند — آزمون بار میزند.
تضمین نشانندادهشده: طرحهای UNIQUE بر مبنای slot و قفل cross-database غایباند — کارتها آنها را بهعنوان جایگزین نام میبرند.
Decision point: The decision node is the overlap predicate on the NEW range — including extensions — not an equality check.
Failure trace: Trace the red branch: overlap found, 409 with the conflicting range, guest picks another slot — the invariant holds because refusal is an answer. Trace the dashed branch: 40001 at COMMIT, bounded retry from BEGIN.
Guarantee shown: The figure shows where the guard lives (one transaction, two rails, one predicate gate) and where retries restart. It does not size the retry budget — the load test does.
Not shown: Slot-based UNIQUE designs and cross-database locking are absent — cards name them as alternatives.
خودآزماییSelf-check
پنجاه درخواست همزمان به یک اتاق میخورند: کدام ریل خوشرفتارتر خراب میشود و چرا؟
پاسخ: ریل B (exclusion): بدون قفل داغ، پایگاه بازنده را در مرز نوشتن رد میکند و تمدیدها هم با همان قید دوباره سنجیده میشوند.
Fifty requests hit one room at once: which rail degrades more gracefully, and why?
Answer: Rail B (exclusion): no hotspot lock, the database rejects the loser at the write boundary, and extensions are re-checked by the same constraint.
دانلود SVG مستقلDownload standalone SVG
- ردیفهای A و B: هرکدام دو ردیف تأییدشده با یک همپوشانی — check+insert با و بدون پوشش RC دوتایی میشود.
- ردیفهای C و D و E: یک ردیف و صفر همپوشانی — قفل ردیف اتاق و exclusion و سریالپذیر-بهعلاوهٔ-retry هر سه میمانند؛ ستون retry قیمت هرکدام را نام میبرد.
- نوار آبی: مسابقهٔ ۵۰نخی روی C — یک برنده و صفر دوتایی با median و p99 آموزشی — بهعلاوهٔ بازسنجی تمدید-دربرابر-بعدی.
- نوار کهربایی: UNIQUE یعنی برابری برای slotهای ثابت؛ FOR UPDATE روی نتیجهٔ خالی هیچچیز را قفل نمیکند.
- Rows A and B: two rows committed, one overlap each — check-then-insert doubles with or without the RC wrapper.
- Rows C, D, E: one row, zero overlaps — room-row lock, exclusion, and serializable-plus-retry all hold; the retry column names each price.
- Blue band: the 50-thread race on C — one winner, zero doubles, teaching median and p99 — plus the extend-vs-next re-check.
- Amber band: UNIQUE is equality for fixed slots; empty-result FOR UPDATE locks nothing.
نقطهٔ تصمیم: ستون تصمیم همان حکم است: فقط سه ردیف سبز invariant را زیر همزمانی نگه میدارند.
رد خرابی: دو ردیف صورتی را دنبال کن: A و B هرکدام دو ردیف با یک همپوشانی تأیید میکنند — همان historyهای نمودار توالی، فشرده در یک خط. بعد پانوشت تمدید را دنبال کن: برابری عبورشان میداد ولی گزاره جلویشان را میگیرد.
تضمین نشاندادهشده: شکل هر دو نمودار گام را در یک حکم per-design ترکیب میکند با اعداد آموزشی مسابقه. بنچمارک نیست — latencyها برچسب آموزشی دارند.
تضمین نشانندادهشده: عدالت میان مهمانها و sharding چنداتاقه بیرون این شکلاند.
Decision point: The decision column is the verdict: only the three green rows keep the invariant under concurrency.
Failure trace: Trace the two rose rows: A and B each commit two rows with one overlap — the histories from the sequence diagram, compressed to one line each. Then trace the extension footnote: equality would wave it through, the predicate stops it.
Guarantee shown: The figure composes both step diagrams into one verdict per design, with the teaching race numbers. It is not a benchmark — the latencies are labeled teaching.
Not shown: Fairness across guests and multi-room sharding are outside this figure.
خودآزماییSelf-check
اتاق خلوت شد (روزی یک رزرو): آیا قفل ردیف اتاق هنوز جواب است؟
پاسخ: exclusion: همان invariant بدون قفل داغ per-request، و اثباتش یک قید است که پایگاه اجرا میکند — نه پروتکلی که هر نویسنده باید یادش بماند.
The room cools down (one booking a day): is the room-row lock still the answer?
Answer: Exclusion: same invariant, no per-request hotspot lock, and the proof is one constraint the database enforces — not a protocol every writer must remember.
گزینههای جایگزینAlternatives
- SERIALIZABLE همهجا با retry کل تراکنش: سادهترین برای استدلال، بدون طراحی قفل برای ممیزی — ولی اتاق داغ به contention با طوفان abort جواب میدهد و هر 40001 هزینهٔ اجرای کامل دوباره بهعلاوهٔ backoff دارد. برای اتاقهای خلوت خوب است، برای اتاق محبوب دردناک.
- قفل توزیعشده در سطح برنامه (یک قفل per-room دور check+insert): روی پایگاهها و ORMها کار میکند، ولی زیرساخت قفل، fencing در برابر نگهدارندههای کهنه و یک حالت خرابی تازه میآورد — خودِ سرویس قفل.
- SERIALIZABLE everywhere with whole-transaction retry: simplest to reason about, no lock design to audit — but a hot room answers contention with abort storms, and every 40001 costs a full re-execution plus backoff. Fine for cool rooms, painful for the popular one.
- Application-level distributed lock (one lock per room around check+insert): works across databases and ORMs, but adds lock infrastructure, fencing against stale holders, and a new failure mode — the lock service itself.
سناریوهای خرابیFailure scenarios
- تلاش دوبارهٔ فقط INSERTِ شکستخورده بعد از 40001 یا نقض exclusion: retry بررسی همپوشانی در برابر ردیف تازهٔ برنده را رد میکند و میتواند در تلاش دوم double-book کند. شروع دوبارهها از BEGIN است.
- retryهای فوری بیکران زیر مسابقهٔ ۵۰طرفه: هر refusal همزمان دوباره میزند، اتاق داغ هیچوقت خنک نمیشود و p99 بالا میرود درحالیکه توان فرومیریزد. تلاشها را کراندار کن، با jitter عقب بکش و وقتی slot واقعاً رفته ۴۰۹ سریع برگردان.
- قید UNIQUE روی (room, start) که بهعنوان نگهبان همپوشانی معرفی شود: شروعهای یکسان را رد میکند و [9,11) در برابر [10,12) را مستقیم عبور میدهد. برابری همپوشانی نیست.
- تمدید رزرو با UPDATE برهنه که نگهبان را رد میکند: بازهٔ تازهٔ [9,12) بیصدا روی [11,13)ی که میانش رزرو شده میافتد. تمدیدها باید از همان گزاره بگذرند — قید exclusion این را خودکار میکند، بررسی دستی فقط اگر یادت بماند.
- Retrying only the failed INSERT after a 40001 or an exclusion violation: the retry skips the overlap check against the winner's fresh row and can double-book on the second attempt. Restarts begin at BEGIN.
- Unbounded immediate retries under the 50-way race: every refusal retries at once, the hot room never cools, and p99 climbs while throughput collapses. Bound the attempts, back off with jitter, and return 409 fast when the slot is truly gone.
- A UNIQUE constraint on (room, start) presented as an overlap guard: it rejects identical starts and waves [9,11) vs [10,12) straight through. Equality is not overlap.
- Extending a booking with a bare UPDATE that skips the guard: the new [9,12) silently covers the [11,13) booked meanwhile. Extensions must pass the same predicate — the exclusion constraint does this automatically, a hand-rolled check only if you remember.
کد قابل اجراRunnable code
اجراشده و تأییدشده در همین محیطExecuted and verified in this environment
این کد آموزشی و سادهسازیشده است؛ فقط برای آشنایی با ایده. کد واقعی پروداکشن ملاحظات بسیار بیشتری دارد و هیچکس اینشکلی کد پروداکشن نمیزند.Simplified teaching code for building intuition only — not production code; nobody ships production code like this.
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
/**
* Teaching demo: no-double-booking for interval reservations of ONE room.
* Invariant (checked over every history): no two committed reservations for
* the same room may overlap. Overlap is NOT equality: [9,11) overlaps [10,12).
*
* Five designs, same workload shape (racing check-then-insert histories plus
* a real 50-thread race on the room-row-lock design). Teaching numbers only.
*/
public class BookingRace {
record Res(String room, int start, int end) {}
static boolean overlaps(Res x, Res y) {
return x.room.equals(y.room) && x.start < y.end && y.start < x.end;
}
static long doubleBookings(List<Res> committed) {
long n = 0;
for (int i = 0; i < committed.size(); i++)
for (int j = i + 1; j < committed.size(); j++)
if (overlaps(committed.get(i), committed.get(j))) n++;
return n;
}
// Design A/B: check-then-insert with a racy interleave — both checks pass
// before either insert lands. (A: no txn, B: READ COMMITTED txn.)
static List<Res> racyCheckInsert() {
List<Res> table = new ArrayList<>();
Res want1 = new Res("R", 9, 11), want2 = new Res("R", 10, 12);
boolean ok1 = table.stream().noneMatch(r -> overlaps(r, want1)); // T1 checks: empty
boolean ok2 = table.stream().noneMatch(r -> overlaps(r, want2)); // T2 checks: empty
if (ok1) table.add(want1); // T1 inserts
if (ok2) table.add(want2); // T2 inserts — too late to notice T1
return table;
}
// Design C: lock the FIXED room row first (SELECT .. FOR UPDATE on rooms),
// then check+insert. Locking the (empty) reservation result would lock nothing.
static final Object ROOM_R = new Object();
static List<Res> roomRowLock(List<Res> table, Res want) {
synchronized (ROOM_R) { // the stable room row serializes all writers of R
if (table.stream().noneMatch(r -> overlaps(r, want))) table.add(want);
return table;
}
}
// Design D: exclusion at the write boundary — atomic insert-if-no-overlap.
static List<Res> exclusionInsert(List<Res> table, Res want) {
synchronized (table) { // stands in for the GiST exclusion constraint
if (table.stream().noneMatch(r -> overlaps(r, want))) table.add(want);
return table;
}
}
// Design E: serializable + whole-transaction retry. First attempt aborts on
// the rw-conflict, the retry sees the committed rival and stands down.
static List<Res> serializableRetry() {
List<Res> table = new ArrayList<>();
Res want1 = new Res("R", 9, 11), want2 = new Res("R", 10, 12);
table.add(want1); // T1 commits first
boolean t2FirstAborts = true; // SSI flags T2's predicate read as dangerous
int retries = 0;
if (t2FirstAborts) retries++; // T2 retries the WHOLE transaction
if (table.stream().noneMatch(r -> overlaps(r, want2))) table.add(want2);
System.out.println(" [E] serializable retries=" + retries);
return table;
}
public static void main(String[] x) throws Exception {
List<Res> a = racyCheckInsert();
System.out.println("[A] plain check+insert: rows=" + a.size()
+ " double-bookings=" + doubleBookings(a) + " (REPRODUCED)");
List<Res> b = racyCheckInsert(); // same interleave inside an RC txn
System.out.println("[B] READ COMMITTED txn: rows=" + b.size()
+ " double-bookings=" + doubleBookings(b) + " (REPRODUCED — RC has no predicate lock)");
List<Res> c = Collections.synchronizedList(new ArrayList<>());
roomRowLock(c, new Res("R", 9, 11));
roomRowLock(c, new Res("R", 10, 12));
System.out.println("[C] fixed room-row lock: rows=" + c.size()
+ " double-bookings=" + doubleBookings(c) + " (PREVENTED)");
List<Res> d = new ArrayList<>();
exclusionInsert(d, new Res("R", 9, 11));
exclusionInsert(d, new Res("R", 10, 12));
System.out.println("[D] exclusion at write boundary: rows=" + d.size()
+ " double-bookings=" + doubleBookings(d) + " (PREVENTED)");
List<Res> e = serializableRetry();
System.out.println("[E] SERIALIZABLE + retry: rows=" + e.size()
+ " double-bookings=" + doubleBookings(e) + " (PREVENTED)");
// Follow-up: user extends [9,11) to [9,12) while another takes [11,13).
List<Res> f = new ArrayList<>(List.of(new Res("R", 9, 11)));
Res extended = new Res("R", 9, 12); // UPDATE of the existing row
Res next = new Res("R", 11, 13);
List<Res> afterUpdate = new ArrayList<>();
for (Res r : f) afterUpdate.add(r.equals(new Res("R", 9, 11)) ? extended : r);
boolean nextOk = afterUpdate.stream().noneMatch(r -> overlaps(r, next));
System.out.println("[F] extend-vs-next: extension overlaps next=" + !nextOk
+ " (exclusion re-checks the NEW range; equality/UNIQUE would miss it)");
// UNIQUE-slot note: equality fits fixed slots, not intervals.
Set<String> slots = new HashSet<>();
slots.add("R@10"); // discrete slot key
boolean dupSlot = !slots.add("R@10");
System.out.println("[G] UNIQUE on fixed slot rejects exact duplicate=" + dupSlot
+ " but cannot express [9,11)-vs-[10,12) overlap");
// 50-way live race on design C with real threads (teaching latencies).
int N = 50;
List<Res> race = Collections.synchronizedList(new ArrayList<>());
ExecutorService pool = Executors.newFixedThreadPool(N);
List<Long> latNs = Collections.synchronizedList(new ArrayList<>());
CountDownLatch gun = new CountDownLatch(1);
for (int i = 0; i < N; i++) {
pool.submit(() -> {
try {
gun.await();
long t0 = System.nanoTime();
roomRowLock(race, new Res("R", 9, 11)); // all want the same interval
latNs.add(System.nanoTime() - t0);
} catch (InterruptedException ignored) { }
});
}
gun.countDown(); pool.shutdown(); pool.awaitTermination(30, TimeUnit.SECONDS);
Collections.sort(latNs);
long medianUs = latNs.get(latNs.size() / 2) / 1000;
long p99Us = latNs.get((int) (latNs.size() * 0.99)) / 1000;
System.out.println("[H] 50-thread race on room-row lock: winners=" + race.size()
+ " double-bookings=" + doubleBookings(race)
+ " median=" + medianUs + "us p99=" + p99Us + "us (teaching numbers)");
System.out.println("INVARIANT HOLDS: " + (doubleBookings(race) == 0 && race.size() == 1 ? "yes" : "NO"));
}
}
اجرا: javac BookingRace.java && java BookingRace — فقط استاندارد جاوا. مسابقهٔ نخی چند ثانیه طول میکشد؛ بخشهای PostgreSQL اینجا اجرا نشدهاند.
خروجی مورد انتظار: طرحهای A و B هرکدام ۲ ردیف و ۱ همپوشانی (بازتولیدشده)؛ C و D و E هرکدام ۱ ردیف و ۰ همپوشانی؛ مسابقهٔ ۵۰نخی: ۱ برنده و ۰ همپوشانی با median و p99 آموزشی. (خروجی واقعی همین محیط)
Run: javac BookingRace.java && java BookingRace — stdlib Java only. The threaded race takes seconds; PostgreSQL parts are NOT executed here.
Expected output: [A] plain check+insert: rows=2 double-bookings=1 (REPRODUCED) [B] READ COMMITTED txn: rows=2 double-bookings=1 (REPRODUCED — RC has no predicate lock) [C] fixed room-row lock: rows=1 double-bookings=0 (PREVENTED) [D] exclusion at write boundary: rows=1 double-bookings=0 (PREVENTED) [E] serializable retries=1 [E] SERIALIZABLE + retry: rows=1 double-bookings=0 (PREVENTED) [F] extend-vs-next: extension overlaps next=true (exclusion re-checks the NEW range; equality/UNIQUE would miss it) [G] UNIQUE on fixed slot rejects exact duplicate=true but cannot express [9,11)-vs-[10,12) overlap [H] 50-thread race on room-row lock: winners=1 double-bookings=0 median=106us p99=225us (teaching numbers) INVARIANT HOLDS: yes
آزمون صحت و معیار عملیاتیCorrectness checks and operational criteria
مهار invariant: بعد از هر history و هر مسابقه assert کن doubleBookings == 0 روی ردیفهای تأییدشده و winners == 1 زیر contention. معیارهای contention: نرخ abort/retry روی اتاق داغ، میانه و p99 latency تلاش (اعداد آموزشی) و زمان تا اولین ۴۰۹ برای بازندهها. راهنمای اجرای محلی (PostgreSQL 18 واقعی لازم دارد): جدول rooms بهعلاوهٔ reservations را با قید EXCLUDE USING gist (room WITH =, during WITH &&) بساز؛ دو اتصال psql را در درهمتنیدگیهای چاپشده مسابقه بده؛ بعد پنجاه INSERT همزمان به یک اتاق بزن و تأیید کن دقیقاً یکی تأیید میشود و بقیه نقض exclusion یا ۴۰۹ میگیرند.
Invariant harness: after every history and every race, assert doubleBookings == 0 over committed rows and winners == 1 under contention. Contention metrics: abort/retry rate on the hot room, median and p99 attempt latency (teaching numbers), and time-to-first-409 for losers. Local-run guide (needs a real PostgreSQL 18): create rooms plus reservations with the EXCLUDE USING gist (room WITH =, during WITH &&) constraint; race two psql sessions through the printed interleavings; then drive fifty concurrent INSERT attempts at one room and confirm exactly one commits while the rest take exclusion violations or 409s.
پاسخ سؤالهای پیگیریFollow-up answers
پیگیری (سینیور): کاربر رزرو را تمدید میکند و همزمان کاربر دیگر بازهٔ بعدی را میگیرد؛ قیدت هنوز درست است؟Follow-up (Senior): A user extends a booking while another user reserves the next interval. Is the constraint still correct?
بله — و دقیقاً به همین دلیل نگهبان باید گزاره باشد نه برابری. UPDATE تمدید بازهٔ ردیف را به [9,12) تغییر میدهد، پس بررسی (یا قید exclusion) بازهٔ تازه را با [11,13)ی که میانش تأیید شده میسنجد و همپوشانی را پیدا میکند. بررسی برابری یا UNIQUE روی بازهٔ قدیم عبورشان میداد. قاعده: هر UPDATE که به بازه دست بزند با مقادیر پسا-UPDATE دوباره وارد نگهبان میشود.
Yes — and that is exactly why the guard must be a predicate, not equality. The extension UPDATE changes the row's range to [9,12), so the check (or the exclusion constraint) evaluates the NEW range against the meanwhile-committed [11,13) and finds the overlap. An equality or UNIQUE check on the old range would wave it through. Rule: every UPDATE that touches the range re-enters the guard with the post-UPDATE values.
پیگیری (استف): پنجاه درخواست به یک اتاق داغ میخورند: کی exclusion قفل ردیف اتاق را میبرد؟Follow-up (Staff): Fifty requests hit one hot room: when does exclusion beat the room-row lock?
وقتی اتاق داغ است: پنجاه نویسندهٔ همزمان روی یک ردیف اتاق سریال میشوند، پس توان در یک commit در هر لحظه سقف میخورد و انتظارهای قفل رشد میکنند. قید exclusion بهتر پخش میشود — بازندهها در مرز نوشتن سریع fail میشوند بدون نگه داشتن قفل مشترک — به قیمت ایندکس GiST و range type. برای اتاق خلوت اختلاف نویز است؛ برای اتاق محبوب خودِ طراحی است.
When the room is hot: fifty concurrent writers serialize on the one room row, so throughput caps at one commit at a time and lock waits grow. The exclusion constraint spreads better — losers fail fast at the write boundary without holding a shared lock — at the cost of the GiST index and the range type. For a cool room the difference is noise; for the popular room it is the design.
پیگیری (سینیور): UNIQUE روی (room, slot): کی واقعاً کافی است؟Follow-up (Senior): UNIQUE on (room, slot): when is that actually enough?
فقط وقتی هر واحد قابلرزرو یک slot گسستهٔ یکسان باشد (شب-تخت، شماره-صندلی، بلوک ساعت ثابت) که «slot یکسان» و «همپوشانی» یکی شوند. آنوقت UNIQUE روی (room, slot) سادهترین نگهبان درست است. لحظهای که slotها بتوانند طول متفاوت، جابهجایی یا تمدید داشته باشند، برابری دیگر همپوشانی را نتیجه نمیدهد و گزاره لازم داری.
Only when every bookable unit is a discrete identical slot (bed-night, seat-number, fixed hour block) so that 'same slot' and 'overlap' coincide. Then UNIQUE(room, slot) is the simplest correct guard. The moment slots can vary in length, shift, or extend, equality stops implying overlap and you need the predicate.
اشتباهات رایجCommon mistakes
- SELECT FOR UPDATE روی جستوجوی رزروها که هیچ ردیفی برنمیگرداند: صفر ردیف را قفل میکند و INSERT رقیب آزاد مینشیند — بعد بازبینی طراحی نتیجه میگیرد «قفل کار نمیکند» بهجای «ما هیچچیز را قفل نکردیم».
- معرفی تراکنش READ COMMITTED بهعنوان اصلاح: تراکنش همان جفت مسابقهای را میپیچد و RC هیچ قفل گزارهای اضافه نمیکند، پس دمو دقیقاً همانطور double-book میکند — فقط کندتر.
- تلاش دوبارهٔ دستورِ شکستخورده بهجای تراکنش: retry مجموعهٔ خواندنِ کهنهٔ پیشا-commit را به ارث میبرد و تلاش دوم هنوز میتواند همپوشان شود — abortها از BEGIN شروع میشوند وگرنه درست نمیکنند.
- SELECT FOR UPDATE on the reservation search that returns no rows: it locks zero rows and the rival's INSERT lands freely — then the design review concludes 'locking doesn't work' instead of 'we locked nothing'.
- Presenting a READ COMMITTED transaction as the fix: the transaction wraps the same racy pair and RC adds no predicate lock, so the demo double-books identically — only slower.
- Retrying the failed statement instead of the transaction: the retry inherits the stale pre-commit read set and the second attempt can still overlap — aborts restart at BEGIN or they do not fix.
منابع:Sources: PostgreSQL 18 · transaction isolation · PostgreSQL 18 · SELECT locking clauses · PostgreSQL 18 · ranges and exclusion constraints
33 تمرین ۳ — exactly-once با idempotency، نه با 2PCExercise 3 — exactly-once with idempotency, not 2PC
بازگشت به صورت تمرینBack to the exercise · ارزیابی و سطحAssessment and level
پاسخ ۶۰ثانیهایThe 60-second answer
صورت مسئله و فرضهاProblem and assumptions
- یک صف at-least-once: کارگزار بعد از هر crash و بعد از هر تلاش بی-ACK دوباره تحویل میدهد؛ تکراریها رفتار عادی حملاند.
- یک فروشگاه محلی با تراکنش واقعی که هم جدول dedup و هم ledger را نگه میدارد؛ horizon سیروزه عدد آموزشی است (آموزشی) و در پروداکشن per-product اندازه میخورد.
- در این محیط نه کارگزاری نه پایگاهی نصب است، پس صف و crashها و ledger در حافظه مدل میشوند؛ دموی اجراشده همان invariantهای per-crash را assert میکند که آزمایشگاه SQLite-محور میسنجید.
- An at-least-once queue: the broker redelivers after every crash and after every un-ACKed attempt; duplicates are normal transport behavior.
- A local store with real transactions holding both the dedup table and the ledger; the 30-day horizon is a teaching number (آموزشی), sized per product in production.
- No broker or database is installed in this sandbox, so the queue, the crashes and the ledger are modeled in memory; the executed demo asserts the same per-crash invariants a SQLite-backed lab would check.
چرا این روش و این عددها؟Why this method and these numbers?
ثبت ID پیش از اثر در تراکنش مشترک اجازه میدهد unique constraint برنده را تعیین کند؛ rollback هر دو را برمیگرداند. ACK بعد از commit از حذف پیام پیش از ثبت اثر جلوگیری میکند. افق ۳۰روزهٔ نمونه باید حداقل بازهٔ معتبر retry/بازپخش/restore را بپوشاند؛ اگر بازپخش پس از آن ممکن باشد، تضمین شکسته میشود و باید کلید بیشتر بماند یا درخواست قدیمی صریح رد شود. این استدلال هیچ SMS بیرونی را داخل تراکنش محلی نمیآورد.
A unique ID in the same transaction as the effect lets the constraint pick the winner; rollback removes both. ACK after commit avoids losing the message before its effect exists. The example 30-day horizon must cover supported retry/replay/restore windows. Longer replay requires longer retention or explicit rejection. None of this includes an external SMS in the local transaction.
مدل ذهنیMental model
حل گامبهگامStep by step
- اول قول را جعبه کن چهار مرز را بکش و محتوای هرکدام را برچسب بزن: محلی (ردیفهای ledger که rollback میشوند)، تراکنش (درج کلید بهعلاوهٔ اثر، همه-یا-هیچ)، بیرونی (ایمیل/پیامک/webhook که rollbackای ندارد) و horizon (کلیدها سی روز آموزشی عمر میکنند؛ ACK هیچچیز را پاک نمیکند). هر ادعای بعدی باید جعبهاش را نام ببرد.
- اول commit بعد ACK ترتیب را ثابت کن: BEGIN، درج کلید، اعمال اثر ledger، بعد COMMIT و بعد ACK. crash قبل از commit هیچچیز پایدار نمیگذارد؛ crash بعد از commit کلید را میگذارد؛ crash بعد از ACK هنوز کلید را میگذارد — دمو assert میکند هر سه تحویل دوباره با اعمالِ دقیقاً یکباره تمام میشوند.
- جایگزین را قیمت بزن جدول را بردار و همان سه crash را دوباره بزن: ۶ اعمال برای ۳ پیام و موجودی ۳۵۰ در برابر ۱۷۵ — قیمت دقیق شانس. همین جفت عدد را نگه دار؛ کل استدلال جدول dedup همین است.
- payload را ببند، زمان را کراندار کن payload را ببند و زمان را کراندار کن: hashِ payload را با هر کلید ذخیره کن و ناهمخوانیها را با هشدار رد کن (تحویل مسموم هیچچیز را عوض نمیکند)؛ کلیدها را در horizon منقضی کن و بپذیر که replay ششماهه بعد از horizon سیروزه دوباره اعمال میشود — بعد با timestamp کسبوکاری پوششش بده نه با امید.
- گیرندههای ایدمپوتنت تضمین را به گیرندهها هل بده: فرستندهٔ سادهلوح دور یک crash ثابت میکند ۲ ایمیل در برابر ۱ اعمال ledger، پس کلید idempotency را داخل هر job بیرونی حمل کن و در gateway dedup کن. اثر exactly-once خاصیت گیرنده است.
- Box the promise first Draw the four boundaries and label what each contains: LOCAL (ledger rows that roll back), TRANSACTION (key insert plus effect, all-or-nothing), EXTERNAL (mail/SMS/webhook, no rollback exists), HORIZON (keys live 30 days teaching; ACK removes nothing). Every later claim must name its box.
- Commit, then ACK Fix the order: BEGIN, INSERT the key, apply the ledger effect, COMMIT, then ACK. Crash-before-commit leaves nothing durable; crash-after-commit leaves the key; crash-after-ACK still leaves the key — the demo asserts all three redeliveries end applied-exactly-once.
- Price the alternative Remove the table and rerun the same three crashes: 6 applications for 3 messages, balance 350 against 175 — the exact price of luck. Keep this pair of numbers; they are the whole argument for the dedup table.
- Bind payload, bound time Bind the payload and bound the time: store the payload hash with each key and reject mismatches with an alert (poison redelivery changes nothing); expire keys at the horizon and admit that a six-month replay past a 30-day horizon re-applies — then cover it with business timestamps, not hope.
- Idempotent receivers Push the guarantee to the receivers: the naive sender proves 2 mails for 1 ledger application around a crash, so carry the idempotency key into every external job and dedup at the gateway. Exactly-once effect is a receiver property.
طراحی مشخصConcrete design
جدولها: processed_messages(id text primary key, payload_hash text not null, processed_at timestamptz) و جدول کسبوکار (اینجا یک ledger). حلقهٔ مصرفکننده per-delivery: BEGIN؛ INSERT INTO processed_messages — موقع تعارض payload_hash را مقایسه کن (ناهمخوانی یعنی reject بهعلاوهٔ هشدار، هرگز اعمال نه)؛ اثر ledger را اعمال کن؛ COMMIT؛ بعد ACK. یک job نگهداری کلیدهای قدیمیتر از horizon (سی روز، آموزشی) را پاک میکند — هیچچیز دیگری حق پاک کردن کلیدها را ندارد، مخصوصاً نه مسیر ACK. پیامهای poison بعد از هشدار ACK میشوند تا از حلقه بیفتند؛ replayهای اپراتور شناسهٔ اصلی را حمل میکنند تا قاعدهٔ horizon به آنها هم بخورد.
Tables: processed_messages(id text primary key, payload_hash text not null, processed_at timestamptz) and the business table (here a ledger). Consumer loop per delivery: BEGIN; INSERT INTO processed_messages — on conflict compare payload_hash (mismatch means reject plus alert, never apply); apply the ledger effect; COMMIT; then ACK. A retention job deletes keys older than the horizon (30 days, teaching) — nothing else may delete keys, especially not the ACK path. Poison messages ACK after alerting so they stop looping; operator replays carry the original id so the horizon rule applies to them too.
مثال محاسبهشدهWorked example
اعداد آموزشی (آموزشی): سه پیام (۱۰۰+ و ۵۰+ و ۲۵+) ledger را با dedup به ۱۷۵ میرسانند با ۳ اعمال، در برابر ۳۵۰ با ۶ اعمال بدون آن — آن ۱۷۵ِ اضافه همان سه اثر تکراری است. آزمون poison ledger را ۰ تکان میدهد. آزمون horizon دقیقاً ۱ بار دوباره اعمال میکند. فرستندهٔ بیرونی سادهلوح ۲ ایمیل برای ۱ ردیف ledger صادر میکند — همان نسبت ۲به۱ مرز at-least-once است با واحد ارسال.
Teaching numbers (آموزشی): three messages (+100, +50, +25) land the ledger at 175 with 3 applications under dedup, against 350 with 6 applications without it — the extra 175 is three duplicated effects. The poison probe moves the ledger by 0. The horizon probe re-applies 1. The naive external sender emits 2 mails for 1 ledger row — the 2-to-1 ratio is the at-least-once boundary, priced in sends.
دانلود نمودار مستقلDownload standalone diagram
- crash 1: تحویل، crash قبل از COMMIT، تحویل دوباره یک بار اعمال میکند و ACK میدهد — هیچچیز پایدار نبود و هیچچیز تکراری نیست.
- crash 2: commit نشست، ACK هیچوقت فرستاده نشد، تحویل دوباره کلید را پیدا میکند — برخورد DEDUP، اثر رد شد، ACK امن.
- crash 3: commit بهعلاوهٔ ACK و crash قبل از پاکسازی — کلید هنوز زنده است، پس تحویل دوباره برخورد dedup است نه اعمال دوباره.
- دم poison: تحویل دوباره با همان کلید ولی payload متفاوت با هشدار رد میشود — ledger هیچوقت تکان نمیخورد.
- Crash 1: delivery, crash before COMMIT, redelivery applies once and ACKs — nothing was durable, nothing is duplicated.
- Crash 2: commit lands, ACK never sent, redelivery finds the key — DEDUP HIT, effect skipped, safe ACK.
- Crash 3: commit plus ACK, crash before cleanup — the key is still live, so the redelivery is a dedup hit, not a re-apply.
- Poison tail: redelivery with the same key but a different payload is rejected with an alert — the ledger never moves.
نقطهٔ تصمیم: نقطهٔ تصمیم پیکان ACK است: در هر سه نوار همیشه بعد از جعبهٔ commit میآید.
رد خرابی: هر نوار crash را بخوان: crash 1 هیچچیز را commit نمیکند پس تحویل دوباره یک بار اعمال میکند؛ crash 2 تأیید میکند ولی ACK را گم میکند پس تحویل دوباره به کلید میخورد؛ crash 3 ACK میدهد ولی پاکسازی را گم میکند پس کلید هنوز باید زنده باشد. بعد دم poison را بخوان: همان کلید با payload تازه — رد میشود.
تضمین نشاندادهشده: شکل تنها ترتیب امن (اثر پایدار قبل از ACK) و تصمیم dedup روی هر تحویل دوباره را نشان میدهد. horizon و ارسالهای بیرونی را نشان نمیدهد.
تضمین نشانندادهشده: انقضای horizon و replayهای اپراتور و صندوق پست در دو شکل بعدیاند.
Decision point: The decision point is the ACK arrow: it always follows the commit box, in all three bands.
Failure trace: Read each crash band: crash 1 commits nothing so the redelivery applies once; crash 2 commits then loses the ACK so the redelivery hits the key; crash 3 ACKs then loses cleanup so the key must still be live. Then read the poison tail: same key, new payload — rejected.
Guarantee shown: The figure shows the only safe order (effect durable before ACK) and the dedup decision on every redelivery. It does not show the horizon or external sends.
Not shown: Horizon expiry, operator replays and the mailbox live in the next two figures.
خودآزماییSelf-check
crash 2 پیام تأییدشده را دوباره تحویل میدهد: چرا مصرفکننده نباید دوباره اعمالش کند؟
پاسخ: نه: اثر قبلاً پایدار شده، پس اعمال دوباره تکراریاش میکند. کلید میگوید «تمام شد» — اثر را رد کن و ACK بده تا کارگزار از تحویل دوباره بیفتد.
Crash 2 redelivers a committed message: why must the consumer NOT apply it again?
Answer: No: the effect is already durable, so re-applying would duplicate it. The key says 'done' — skip the effect and ACK so the broker stops redelivering.
دانلود نمودار مستقلDownload standalone diagram
- لاین اصلی: از Delivered به Processing به Committed (کلید بهعلاوهٔ اثر، اتمیک) و بعد ACKed — با Applied once که اثر exactly-once را ثبت میکند.
- لاین بازیابی: هر سه crash روی Redelivered مینشینند؛ کلیدهای ناشناخته به Processing برمیگردند و کلیدهای شناختهشده به Key live میروند.
- لاین dedup: تا وقتی horizon آن را به Key expired ببرد، Key live به هر تحویل دوباره با رد-بهعلاوهٔ-ACK جواب میدهد.
- لاین پایانی: poison موقع ناهمخوانی دور انداخته میشود و تکراری موقع replay پسا-horizon — و Applied once برای مسیری که هیچوقت دوتایی نشد.
- Main lane: Delivered to Processing to Committed (key plus effect, atomic), then ACKed — with Applied once recording the exactly-once effect.
- Recovery lane: all three crashes land on Redelivered; unknown keys re-enter Processing, known keys step aside to Key live.
- Dedup lane: Key live answers every redelivery with skip-plus-ACK until the horizon moves it to Key expired.
- Terminal lane: poison discarded on mismatch, duplication on horizon replay — and Applied once for the path that never doubled.
نقطهٔ تصمیم: حالت تصمیم Key live است: تحویلهای دوباره آنجا میمیرند و فقط انقضای horizon پیام را از آن بیرون میبرد.
رد خرابی: هر یال crash را تا Redelivered دنبال کن، بعد دو خروجی را: کلید ناشناخته به Processing برمیگردد و کلید شناختهشده به Key live میرود و ACK امن. بعد یال چیندار horizon را دنبال کن: از Key live به Key expired و replay ششماهه مستقیم به Effect duplicated.
تضمین نشاندادهشده: شکل نشان میدهد هر مسیر بازیابی روی بررسی کلید مینشیند و دقیقاً کدام یال قول را تمام میکند. دنیای بدون-dedup را نشان نمیدهد.
تضمین نشانندادهشده: ارسالهای بیرونی و جایگزینهای 2PC بیرون این ماشین حالتاند — جدول اسمبل پوشششان میدهد.
Decision point: The decision state is Key live: redeliveries die there, and only horizon expiry moves the message out of it.
Failure trace: Trace each crash edge into Redelivered, then the two exits: key unknown re-enters Processing, key known steps to Key live and a safe ACK. Then trace the dashed horizon edge: Key live to Key expired, and the 6-month replay straight to Effect duplicated.
Guarantee shown: The figure shows every recovery path landing on the key check, and the exact edge where the guarantee ends. It does not show the without-dedup world.
Not shown: External sends and 2PC alternatives are outside this state machine — the assembly table covers them.
خودآزماییSelf-check
تا چه مدت بعد از ACK تحویل دوباره هنوز امن است؟
پاسخ: حداکثر horizon: تحویلهای دوبارهٔ داخلش dedup میشوند و replayهای بعدش تازه به نظر میرسند. horizon را بزرگتر از قدیمیترین replay مشروع اندازه بزن یا replayهای کهنه را با timestamp رد کن.
How long after ACK is a redelivery still safe?
Answer: At most the horizon: redeliveries inside it are deduped, replays past it look new. Size the horizon beyond your oldest legitimate replay, or reject stale replays by timestamp.
دانلود SVG مستقلDownload standalone SVG
- ردیفهای crash: بدون dedup هر تحویل دوباره دوباره اعمال میکند (صورتی)؛ با dedup هرکدام روی کلید مینشینند (سبز) — crash 3 فقط چون کلید از ACK بیشتر عمر میکند سبز میماند.
- ردیف poison: اعمال کورِ دوباره payload شرور را اجرا میکرد؛ بررسی hash ردش میکند و ledger هیچوقت تکان نمیخورد.
- ردیفهای horizon و صندوق پست: هر دو سمت راست صورتی میمانند — کلید منقضی نمیتواند dedup کند و ایمیل فرستادهشده را نمیشود نفرستاده کرد.
- نوار مرز: محلی با تراکنش rollback میشود، تراکنش کلید و اثر را اتمیک میبندد، بیرونی at-least-once است و horizon کلیدها را منقضی میکند — و ACK هیچچیز را پاک نمیکند.
- Crash rows: without dedup each redelivery re-applies (rose); with dedup each lands on the key (green) — crash 3 stays green only because the key outlives the ACK.
- Poison row: blind re-application would run the evil payload; the hash check rejects it and the ledger never moves.
- Horizon and mailbox rows: both stay rose on the right — an expired key cannot dedup, and a sent mail cannot be unsent.
- Boundary band: LOCAL rolls back with the transaction, TXN binds key and effect atomically, EXTERNAL is at-least-once, HORIZON expires keys — and ACK deletes nothing.
نقطهٔ تصمیم: ردیف تصمیم همان نوار مرز است: محلی و تراکنش داخل قولاند و بیرونی و horizon بیرون آن.
رد خرابی: اول ستون صورتی را دنبال کن: بدون جدول هر تحویل دوباره دوباره اعمال میکند — ۶ اعمال برای ۳ پیام و ۳۵۰ بهجای ۱۷۵. بعد دو خانهٔ لجوجانه صورتی سمت راست را دنبال کن: replay پسا-horizon و صندوق پست، که هیچ کلید محلی نجاتت نمیدهد.
تضمین نشاندادهشده: شکل هر دو نمودار گام را در یک حکم per-scenario ترکیب میکند و چهار مرزی را میکشد که متن استدلال میکند. اعداد آموزشیاند و همین برچسب را دارند.
تضمین نشانندادهشده: توان و اندازهٔ جدول کلید و پیکربندی کارگزار غایباند.
Decision point: The decision row is the boundary band: local and transaction are inside the promise, external and horizon are outside it.
Failure trace: Trace the rose column first: without the table every redelivery re-applies — 6 applications for 3 messages, 350 instead of 175. Then trace the two stubbornly rose cells on the right: horizon replay and the mailbox, where no local key can save you.
Guarantee shown: The figure composes both step diagrams into one verdict per scenario and draws the four boundaries the text argues. Numbers are teaching, labeled as such.
Not shown: Throughput, key-table sizing and broker configuration are absent.
خودآزماییSelf-check
crash 3 سبز میماند ولی replay پسا-horizon صورتی میشود: کدام جزء فرق میکند؟
پاسخ: horizon: کلید را پاک میکند، پس replay تازه به نظر میرسد و دوباره اعمال میشود. ACK هیچوقت کلیدها را پاک نمیکند — کل نکتهٔ سبز ماندن crash 3 همین است.
Crash 3 stays green but the horizon replay goes rose: which component differs?
Answer: The horizon: it deletes the key, so the replay looks new and re-applies. ACK never deletes keys — that is the whole point of crash 3 staying green.
گزینههای جایگزینAlternatives
- 2PC/XA روی صف و پایگاه: commit اتمیک واقعی مصرف-بهعلاوهٔ-اثر، ولی coordinator شرکتکنندهها را موقع in-doubt block میکند، صفها بهندرت XA میفهمند و یک شرکتکنندهٔ کند هر مصرفکنندهای را متوقف میکند. در تئوری درست و در بیشتر توپولوژیهای واقعی ناکاربردی — به همین دلیل فصل idempotency را توصیه میکند.
- تراکنشهای Kafka بهعلاوهٔ producer ایدمپوتنت: exactly-once داخل پایپلاین Kafka-به-Kafka (مصرف-تبدیل-تولید)، ولی تضمین در لبهٔ تاپیک تمام میشود — گیرندهٔ نهایی (ledger، صندوق پست، webhook) هنوز کلید idempotency خودش را لازم دارد.
- 2PC/XA across the queue and the database: true atomic commit of consume-plus-effect, but the coordinator blocks participants while in-doubt, queues rarely support XA, and one slow participant stalls every consumer. Correct in theory, unusable in most real topologies — which is why the chapter recommends idempotency instead.
- Kafka transactions plus the idempotent producer: exactly-once inside a Kafka-to-Kafka pipeline (consume-transform-produce), but the guarantee ends at the topic edge — the final receiver (ledger, mailbox, webhook) still needs its own idempotency key.
سناریوهای خرابیFailure scenarios
- ACK قبل از COMMIT: کارگزار پیام را فراموش میکند درحالیکه اثر هنوز تأییدنشده است، پس یک crash پیام را برای همیشه گم میکند — تنها نتیجهای که تحویل دوباره نمیتواند تعمیرش کند. اول commit قرارداد است نه سلیقه.
- پاک کردن کلید dedup در مسیر ACK («دیگر کارش تمام شد»): تحویل دوبارهٔ بعدی — crash 3 تضمین میکند که یکی خواهد بود — هیچ کلیدی نمییابد و اثر را بار دوم اعمال میکند. کلیدها با انقضای horizon میمیرند، هرگز با ACK نه.
- dedup فقط روی شناسه بدون بستن payload: تحویل دوبارهٔ مسموم (همان کلید با payload مهاجم) بهعنوان retry مشروع اعمال میشود. hash payload را با کلید ذخیره کن و ناهمخوانیها را با هشدار رد کن.
- horizon کوتاهتر از قدیمیترین replay مشروع: replayهای پایان ماه و restoreهای بازیابی از فاجعه و retryهای اپراتور بیصدا دو بار اعمال میکنند. horizon را بزرگتر از قدیمیترین replayی که باید محترم بشمری اندازه بزن و هرچه قدیمیتر است با timestamp کسبوکاری رد کن — نه با امید.
- ACK before COMMIT: the broker forgets the message while the effect is still uncommitted, so a crash loses the message forever — the one outcome redelivery cannot repair. Commit first is not style, it is the protocol.
- Deleting the dedup key on the ACK path ('we're done with it'): the next redelivery — crash 3 guarantees there will be one — finds no key and applies the effect a second time. Keys die by horizon expiry, never by ACK.
- Dedup on the id alone without payload binding: a poisoned redelivery (same key, attacker's payload) applies as a legitimate retry. Store the payload hash with the key and reject mismatches with an alert.
- A horizon shorter than the oldest legitimate replay: month-end replays, disaster-recovery restores and operator retries silently double-apply. Size the horizon beyond the oldest replay you must honor, and reject anything older by business timestamp — not by hope.
کد قابل اجراRunnable code
اجراشده و تأییدشده در همین محیطExecuted and verified in this environment
این کد آموزشی و سادهسازیشده است؛ فقط برای آشنایی با ایده. کد واقعی پروداکشن ملاحظات بسیار بیشتری دارد و هیچکس اینشکلی کد پروداکشن نمیزند.Simplified teaching code for building intuition only — not production code; nobody ships production code like this.
import java.util.*;
/**
* Teaching demo: exactly-once EFFECT via idempotency keys — not via 2PC.
* Boundaries (stated up front, checked by the demo):
* local effect = ledger update, inside ONE transaction with the key insert
* transaction = {insert idempotency key + apply local effect}, atomic
* external effect= e-mail/SMS send, OUTSIDE any transaction (cannot roll back)
* dedup horizon = keys expire after H days; ACK is NOT a delete permit
*
* Crash points: (1) before commit, (2) after commit but before ACK,
* (3) after ACK but before cleanup. Redelivery follows every crash.
*/
public class IdempotencyDemo {
// The "database": dedup table (UNIQUE key -> payload hash) + local ledger.
static class Store {
final Map<String, String> keys = new HashMap<>(); // id -> payloadHash
final Map<String, Integer> ledger = new HashMap<>();// account -> balance
final Map<String, Long> keyAgeDays = new HashMap<>();// id -> age in days
long horizonDays = 30;
int externalSends = 0; // e-mails actually emitted (no undo possible)
int appliedEffects = 0; // local ledger applications (must stay 1/msg)
/** One consumer attempt. Returns true if the broker may ACK. */
boolean attempt(String id, String payloadHash, int amount, boolean external,
int crashPoint) {
// Transaction body: key insert + local effect, atomically.
boolean duplicate = keys.containsKey(id);
if (duplicate && !keys.get(id).equals(payloadHash)) {
System.out.println(" reject " + id + ": same key, DIFFERENT payload (poison redelivery)");
return true; // ACK the poison so it does not loop forever; alert instead
}
boolean doCommit;
if (duplicate) {
doCommit = false; // dedup hit: skip the effect
} else {
doCommit = true;
}
if (crashPoint == 1) {
System.out.println(" crash BEFORE commit for " + id + " -> redeliver");
return false; // nothing committed, broker redelivers
}
if (doCommit) {
keys.put(id, payloadHash);
keyAgeDays.put(id, 0L);
ledger.merge("acct", amount, Integer::sum);
appliedEffects++;
if (external) externalSends++; // sent inside the attempt: at-least-once!
} else {
System.out.println(" dedup hit for " + id + ": effect skipped, safe to ACK");
}
if (crashPoint == 2) {
System.out.println(" crash AFTER commit BEFORE ack for " + id + " -> redeliver");
return false;
}
// ACK (commit-then-ack order). Cleanup of the key is NOT allowed here.
if (crashPoint == 3) {
System.out.println(" crash AFTER ack BEFORE cleanup for " + id + " -> redeliver");
}
return true;
}
void expireOldKeys() {
keys.keySet().removeIf(k -> keyAgeDays.getOrDefault(k, 0L) > horizonDays);
}
}
static void runWithDedup() {
System.out.println("[dedup ON] three crash points, one message m1 (+100):");
Store s = new Store();
s.attempt("m1", "h1", 100, false, 1); // crash before commit
boolean acked = false;
while (!acked) acked = s.attempt("m1", "h1", 100, false, 0); // redelivery applies once
s.attempt("m2", "h2", 50, false, 2); // first try: commits, then crash before ack
acked = false;
while (!acked) acked = s.attempt("m2", "h2", 50, false, 0); // redelivery: dedup hit
s.attempt("m3", "h3", 25, false, 3); // first try: commits, acks, then crash before cleanup
acked = false;
while (!acked) acked = s.attempt("m3", "h3", 25, false, 0); // redelivery: dedup hit
System.out.println(" ledger=" + s.ledger + " appliedEffects=" + s.appliedEffects
+ " (each message applied EXACTLY once)");
// Payload binding check.
s.attempt("m3", "EVIL", 9999, false, 0);
System.out.println(" ledger after poison redelivery=" + s.ledger + " (unchanged)");
}
static void runWithoutDedup() {
System.out.println("[dedup OFF] same crashes, no id table:");
int applied = 0, balance = 0;
balance += 100; applied++; // m1 first try, crash before "commit" -> retry
balance += 100; applied++; // m1 redelivery applies AGAIN
balance += 50; applied++; // m2 applies
balance += 50; applied++; // m2 redelivery after commit-crash applies AGAIN
balance += 25; applied++; // m3 applies, acked, redelivered...
balance += 25; applied++; // ...applies AGAIN
System.out.println(" balance=" + balance + " applied=" + applied
+ " (6 applications for 3 messages — luck, not safety)");
}
static void runHorizon() {
System.out.println("[horizon] operator replays 6-month-old m9, keys expire after 30 days:");
Store s = new Store();
s.horizonDays = 30;
s.attempt("m9", "h9", 200, false, 0);
s.keyAgeDays.put("m9", 180L); // six months pass
s.expireOldKeys(); // dedup row is gone — ACK never permitted this
int before = s.appliedEffects;
s.attempt("m9", "h9", 200, false, 0); // replay looks brand new
System.out.println(" re-applied after expiry=" + (s.appliedEffects - before)
+ " (guarantee ends at the horizon; replays beyond it need business rules)");
}
static void runExternal() {
System.out.println("[external] e-mail send is outside the transaction boundary:");
Store s = new Store();
s.attempt("m5", "h5", 10, true, 2); // crash after commit, before ack
s.attempt("m5", "h5", 10, true, 0); // redelivery: ledger safe...
System.out.println(" careful sender: ledger applied=1, e-mails sent=" + s.externalSends);
// Naive sender: notify FIRST, then commit — crash between the two.
Store n = new Store();
n.externalSends++; // mail goes out...
System.out.println(" naive sender crashes before commit -> redeliver");
n.attempt("m6", "h6", 10, true, 0); // redelivery sends AGAIN and applies
System.out.println(" naive sender: ledger applied=1 BUT e-mails sent=" + n.externalSends
+ " (external effects are at-least-once: make THEM idempotent too)");
}
public static void main(String[] x) {
runWithDedup();
runWithoutDedup();
runHorizon();
runExternal();
System.out.println("RULE: commit-then-ACK; ACK never deletes the key; horizon bounds the promise.");
}
}
اجرا: javac IdempotencyDemo.java && java IdempotencyDemo — فقط استاندارد جاوا، فوری. نه کارگزار نه پایگاه لازم است؛ crashها فراخوانی تزریقیاند.
خروجی مورد انتظار: با dedup: ledger برابر ۱۷۵ و applied برابر ۳ (هر پیام دقیقاً یک بار)؛ بدون dedup: ۳۵۰ با ۶ اعمال؛ replay پسا-horizon یک بار دوباره اعمال میشود؛ فرستندهٔ بیاحتیاط ۲ ایمیل میفرستد. (خروجی واقعی همین محیط)
Run: javac IdempotencyDemo.java && java IdempotencyDemo — stdlib Java only, instant. No broker or database needed; crashes are injected calls.
Expected output: [dedup ON] three crash points, one message m1 (+100): crash BEFORE commit for m1 -> redeliver crash AFTER commit BEFORE ack for m2 -> redeliver dedup hit for m2: effect skipped, safe to ACK crash AFTER ack BEFORE cleanup for m3 -> redeliver dedup hit for m3: effect skipped, safe to ACK ledger={acct=175} appliedEffects=3 (each message applied EXACTLY once) reject m3: same key, DIFFERENT payload (poison redelivery) ledger after poison redelivery={acct=175} (unchanged) [dedup OFF] same crashes, no id table: balance=350 applied=6 (6 applications for 3 messages — luck, not safety) [horizon] operator replays 6-month-old m9, keys expire after 30 days: re-applied after expiry=1 (guarantee ends at the horizon; replays beyond it need business rules) [external] e-mail send is outside the transaction boundary: crash AFTER commit BEFORE ack for m5 -> redeliver dedup hit for m5: effect skipped, safe to ACK careful sender: ledger applied=1, e-mails sent=1 naive sender crashes before commit -> redeliver naive sender: ledger applied=1 BUT e-mails sent=2 (external effects are at-least-once: make THEM idempotent too) RULE: commit-then-ACK; ACK never deletes the key; horizon bounds the promise.
آزمون صحت و معیار عملیاتیCorrectness checks and operational criteria
invariantهای per-crash با dedup: اثرهای اعمالشده == ۳ برای ۳ پیام، ledger == ۱۷۵، تحویل مسموم هیچچیز را عوض نمیکند، replay پسا-horizon دقیقاً ۱ بار دوباره اعمال میشود و فرستندهٔ بیرونی سادهلوح ۲ ایمیل برای ۱ اعمال صادر میکند. بدون dedup: ۶ اعمال و موجودی ۳۵۰. معیارهای عملیاتی: نرخ تحویل دوباره، نرخ برخورد dedup (بعد از incidentها باید غالب باشد)، رشد جدول کلید در برابر job نگهداری و سن قدیمیترین کلید منقضینشده — وقتی replayها به horizon نزدیک شدند هشدار بده.
Per-crash invariants with dedup: applied effects == 3 for 3 messages, ledger == 175, poison redelivery changes nothing, horizon replay re-applies exactly 1, naive external sender emits 2 mails for 1 application. Without dedup: 6 applications, balance 350. Operational metrics: redelivery rate, dedup-hit rate (should dominate after incidents), key-table growth against the retention job, and age of the oldest unexpired key — alert when replays approach the horizon.
پاسخ سؤالهای پیگیریFollow-up answers
پیگیری (سینیور): اپراتور پیام ششماهه را replay میکند ولی dedup بعد از یک ماه حذف شده؛ چه میشود؟Follow-up (Senior): An operator replays a six-month-old message after dedup was deleted at one month. What happens?
دوباره اعمال میشود: کلید در یک ماهگی منقضی شده، پس replay ششماهه کاملاً تازه به نظر میرسد و ledger بار دوم تکان میخورد. قول dedup در horizon تمام شد — دقیقاً همان چیزی که آزمون horizon دمو چاپ میکند. دفاعها بهترتیب: horizon را بلندتر از هر replayی که باید محترم بشمری نگه دار؛ timestamp یا version کسبوکاری داخل پیام حمل کن و replayهای قدیمیتر از سیاست را رد کن؛ خودِ اثر پاییندست را ایدمپوتنت کن (گیرندههای ایدمپوتنت ترکیب میشوند و horizonها نه). هیچوقت با نگه داشتن کلیدها برای همیشه horizon را بیصدا تمدید نکن — رشد بیکران کلیدها خودش outage است.
It re-applies: the key expired at one month, so the six-month-old replay looks brand new and the ledger moves a second time. The dedup promise ended at the horizon — exactly what the demo's horizon probe prints. Defenses, in order: keep the horizon longer than any replay you must honor; carry a business timestamp or version in the message and reject replays older than policy; make the downstream effect itself idempotent (idempotent receivers compose, horizons do not). Never silently extend the horizon by keeping keys forever — unbounded key growth is its own outage.
پیگیری (سینیور): چرا بهجای همهٔ اینها 2PC بین صف و پایگاه نه؟Follow-up (Senior): Why not 2PC between the queue and the database instead of all this?
چون اتمی بودن 2PC در مرز سیستم میایستد. صف و پایگاه به coordinator مشترک XA نیاز داشتند، حالتهای in-doubt موقع outage coordinator مصرفکنندهها را block میکردند و بیشتر صفها شرکت نمیکنند — درحالیکه صندوق پست بیرونی اصلاً نمیتواند وارد هیچ تراکنشی شود. idempotency تضمین را به گیرنده منتقل میکند: تحویل at-least-once بهعلاوهٔ اثر dedupشده با مرزهای صریح بهجای commit سراسری.
Because 2PC's atomicity stops at the system boundary. The queue and the database would need a shared XA coordinator, in-doubt states would block consumers during coordinator outages, and most queues do not participate — while the external mailbox cannot join any transaction at all. Idempotency moves the guarantee to the receiver: at-least-once delivery plus a deduped effect, with explicit boundaries instead of a global commit.
پیگیری (استف): ایمیل دور یک crash دو بار میرود: باگ کیست و کجا درست میشود؟Follow-up (Staff): The mail goes out twice around a crash: whose bug is that, and where is it fixed?
مال فرستنده — ولی اصلاح سمت گیرنده هم هست. هر اثری بیرون تراکنش فرستنده (ایمیل، پیامک، webhook، شارژ) میتواند دور یک crash دو بار صادر شود، چنانکه فرستندهٔ سادهلوح دمو اثبات میکند (۲ ارسال و ۱ اعمال ledger). پس هر گیرندهٔ بیرونی باید ایدمپوتنت باشد: کلید idempotency را داخل job ایمیل حمل کن، در gateway dedup کن، یا عملیات پاییندست را ذاتاً تکرارپذیر کن. اثر exactly-once خاصیت گیرنده است، هرگز خاصیت ارسال نه.
The sender's — but the fix is on the receiving side too. Any effect outside the sender's transaction (mail, SMS, webhook, charge) can be emitted twice around a crash, as the naive sender in the demo proves (2 sends, 1 ledger application). So every external receiver must be idempotent: carry the idempotency key into the mail job, dedup at the gateway, or make the downstream operation naturally repeatable. Exactly-once effect is a property of the receiver, never of the send.
اشتباهات رایجCommon mistakes
- تلقی ACK بهعنوان مجوز حذف کلید dedup: نقطهٔ crash 3 دقیقاً برای تنبیه همین است — تحویل دوبارهٔ بعد از ACK هیچ کلیدی نمییابد و اثر را دو برابر میکند.
- قول exactly-once روی سیستمها با 2PC: coordinator و پنجرهٔ in-doubt و صندوق پستِ شرکتنکننده دامنهٔ خرابی را بزرگتر میکنند از تکراریهایی که جلوگیری میکند.
- کلیدهای dedup بدون hashِ payload و بدون horizon: اولی تحویلهای مسموم را دعوت میکند و دومی جدول کلید را آنقدر بزرگ میکند تا خودش outage شود.
- Treating ACK as a delete permit for the dedup key: crash 3 exists precisely to punish this — the redelivery after ACK finds no key and doubles the effect.
- Promising exactly-once across systems with 2PC: the coordinator, the in-doubt window and the non-participating mailbox make it a bigger failure domain than the duplicates it prevents.
- Dedup keys without payload hashes and without a horizon: the first invites poisoned redeliveries, the second grows the key table until it becomes the outage.
منابع:Sources: AWS · safe retries with idempotent APIs · Kafka 4.1 · design and delivery semantics