11 تمرین ۱ — شمارندهٔ exactly-once روی کافکا و پایگاه دادهExercise 1 — an exactly-once counter on Kafka and a database
بازگشت به صورت تمرینBack to the exercise · ارزیابی و سطحAssessment and level
پاسخ ۶۰ثانیهایThe 60-second answer
صورت مسئله و فرضهاProblem and assumptions
- سه پارتیشن، کلید = user id، مسیریابی ثابت key-to-partition (نسخهدار اگر روزی عوض شود). مقیاس آموزشی: حدود 10k کلیک بر ثانیه روی تاپیک و checkpoint بهازای هر batch یا هر ثانیه — عدد آموزشی است نه بنچمارک.
- پایگاه داده مرجع تراکنشی است (آموزشی و شبه Postgres)؛ گروه مصرفکننده به هر پارتیشن یک مالک میدهد؛ بازپخش لاگ درون هر پارتیشن مرتب است.
- اثر بیرونی در مسیر شمارش نیست؛ checkpoint فعال تا وقتی state متناظر معتبر است حفظ میشود. بازپخش از قبلِ retention نیازمند بازسازی یا رد صریح است.
- Three partitions, key = user id, fixed key-to-partition routing (versioned if it ever changes). Teaching scale: about 10k clicks/s across the topic, checkpoint once per batch or per second — a teaching number, not a benchmark.
- The record database is transactional (teaching Postgres-like); the consumer group assigns one owner per partition; log replay is ordered inside each partition.
- No external effect is included. Retain active checkpoints while their corresponding state remains valid. Replay older than log retention requires rebuilding or explicit rejection.
چرا این روش و این عددها؟Why this method and these numbers?
offset شمارهٔ موقعیت در یک partition است؛ offset=5 در دو partition دو پیام مستقل است. checkpoint و total در یک commit لازماند چون جداشدنشان بین دوبارهشمردن و جاانداختن انتخاب تحمیل میکند. قفل checkpoint، دو batch همان epoch را هم سریال میکند؛ epoch فقط مالک کهنه را رد میکند و جای lock هممالک نیست. ۱۰هزار کلیک/ثانیه ورودی ظرفیت است؛ اندازهٔ batch از سقف lag و هزینهٔ commit انتخاب میشود. checkpoint فعال تاریخ انقضا ندارد؛ عقبافتادن از retention لاگ به recovery نیاز دارد.
An offset is a position within one partition; offset 5 in two partitions identifies two independent records. Commit checkpoints and totals together to avoid double counting or omission. Lock the checkpoint to serialize same-epoch batches; epoch fencing alone only rejects stale owners. Ten thousand clicks/s is a capacity input; choose batch size from lag and commit cost. Active checkpoints do not expire; falling behind log retention requires recovery.
مدل ذهنیMental model
حل گامبهگامStep by step
- مسیریابی با کلید، قلمرو offset هر کلیک را با user id مسیریابی کن و offsetها را پارتیشنمحور بدان: یک عدد روی دو پارتیشن هیچ ترتیبی نمیسازد، پس کلید checkpoint همیشه (topic, partition) خودش را حمل میکند.
- نگهبان برای هر اجرای مجدد checkpoint هر پارتیشن را قفل و خواندن کن؛ هر offset کوچکتر یا مساوی آن قبلاً اعمال شده است. recordها را بهترتیب جلو ببر و قبل از تمامشدن اثرهای batch checkpoint را جلو نبر.
- ثبت اتمیک اثر و checkpoint اثر و checkpoint آن را در یک تراکنش نگهبانیشده با epoch ثبت کن تا crash میان apply و commit هیچ نیمحالتی نگذارد و ack گمشده جلوی حافظهای صادق بازپخش شود.
- فنس کردن نویسنده کهنه مالک تازه epoch خود را با پروتکل انتقال مالکیت در sink ثبت میکند. تراکنش مالک قبلی باید پیش از انتقال تمام شود یا بعد از آن با epoch کهنه رد شود؛ صرف callback گروه Kafka تضمین sink نیست.
- نام بردن مرزها checkpoint فعال را با TTL پاک نکن. اگر نخستین offset موجود در لاگ از offset بعدی موردنیاز جلوتر باشد، بخشی از ورودی گم است و باید rebuild یا بازیابی صریح شود؛ از آن نقطه حدس نزن.
- Route by key, scope offsets Route every click by user id and treat offsets as partition-scoped: the same number on two partitions orders nothing, so the checkpoint key always carries its (topic, partition).
- Guard every re-execution Lock and read the partition checkpoint. Offsets at or below it have already applied. Process records in order and advance the checkpoint only with the committed batch effects.
- Commit effect with checkpoint Commit the effect and its checkpoint in one epoch-guarded transaction, so a crash between apply and commit leaves no half-state and a lost ack replays against an honest memory.
- Fence the stale writer Install new ownership through a sink ownership-transfer protocol. An old transaction must finish before transfer or fail its epoch check after transfer; Kafka group callbacks alone do not guarantee sink fencing.
- Name the boundaries Never expire active checkpoints by TTL. If the earliest available record is beyond the next required offset, input is missing: explicitly rebuild or recover rather than silently skip ahead.
طراحی مشخصConcrete design
جدول click_counts(user_id,total) و consumer_checkpoints(topic,partition,last_offset,owner_epoch) با کلید مرکب داریم. یک تراکنش ابتدا سطر checkpoint را قفل میکند و epoch معتبر sink را میسنجد؛ سپس offsetهای بعد از checkpoint را بهترتیب اعمال میکند و اثر و checkpoint را با هم commit میکند. هر batch همان epoch هم باید از همین قفل عبور کند. broker ACK پس از commit است. نصب epoch جدید باید در DB با مالکیت گروه هماهنگ شود؛ Kafka خودکار write پایگاه داده را fence نمیکند. کلید checkpoint شامل topic است؛ دمو فقط یک topic ثابت clicks دارد.
Use click_counts(user_id,total) and consumer_checkpoints(topic,partition,last_offset,owner_epoch) with a composite key. A transaction first locks the checkpoint row and validates sink ownership, then applies ordered offsets newer than the checkpoint and commits effect plus checkpoint together. Same-epoch batches also serialize on this row. Broker ACK follows commit. Coordinate new-epoch installation at the database with group ownership; Kafka does not automatically fence database writes. The demo has one fixed topic, clicks.
مثال محاسبهشدهWorked example
در دمو u2 چهار کلیک در P1 با offsetهای ۰ تا ۳ و u3 سه کلیک در همان پارتیشن با offsetهای ۴ تا ۶ دارد؛ checkpoint مشترک P1 پس از batch برابر ۶ است. بعد از rebalance، کلیک تازهٔ u2 در offset=7 آن را به ۵ میرساند. offset=0 در P0 و P2 پیامهای مستقلاند؛ replay هر پارتیشن با checkpoint خودش سنجیده میشود. crash شبیهسازیشده پیش از commit اثر ندارد؛ ACK گمشده باعث تکرار تحویل میشود ولی total تغییر نمیکند.
The demo places u2 at P1 offsets 0..3 and u3 at 4..6; the shared P1 checkpoint becomes 6. After rebalance, u2’s next click is offset 7 and raises its total to 5. Offset 0 in P0 and P2 represents independent records; replay uses each partition checkpoint. A simulated pre-commit crash has no effect, while lost ACKs redeliver without changing totals.
دانلود نمودار مستقلDownload standalone diagram
- تولیدکنندهها با کلید user id الصاق میکنند: هر کاربر دقیقاً روی یک پارتیشن مینشیند، پس offsetهای per-user کاملاً مرتباند — ولی فقط درون همان پارتیشن.
- هر پارتیشن دقیقاً یک مالک در گروه دارد؛ مالک offsetهایش را مرتب میخواند و هرگز با پارتیشن دیگر مقایسهشان نمیکند.
- sink مجموعها و checkpointِ (topic, partition) را در یک تراکنش نگهبانیشده با epoch ثبت میکند: تحویل مجدد جلوی حافظهای بازپخش میشود که فقط با اثر جلو رفته.
- Producers append with key = user id: each user lands on exactly one partition, so per-user offsets are totally ordered — but only inside that partition.
- Each partition has exactly one owner in the group; the owner reads its offsets in order and never compares them with another partition's.
- The sink commits totals and the (topic, partition) checkpoint in one epoch-guarded transaction: redelivery replays against a memory that moved only with the effect.
نقطهٔ تصمیم: نقطه تصمیم تراکنش sink است: اثر و checkpoint با هم commit میشوند یا هیچکدام، مشروط به epoch.
رد خرابی: crash را روی همین شکل دنبال کن: کار ثبتنشده مصرفکننده محو میشود و poll بعدی همان offsetهای مرتب را جلوی checkpointای میگذارد که بدون اثر جلو نرفته. مالک کهنه را دنبال کن: پیکانهایش epoch قدیم را حمل میکنند و سر write شرطی میمیرند.
تضمین نشاندادهشده: شکل نشان میدهد ترتیب کجاست (درون پارتیشنها)، اتمیک بودن کجاست (تراکنش sink) و fencing کجاست (شرط epoch). ثابت نمیکند بروکر مرتب تحویل میدهد — آن قرارداد خود لاگ است.
تضمین نشانندادهشده: درباره اثرهای بیرونی، رکوردهای سمی و تغییر تعداد پارتیشن چیزی نمیگوید — آنها شاخه خودشان را در شکلهای بعد میخواهند.
Decision point: The decision point is the sink transaction: effect and checkpoint commit together or not at all, conditional on the epoch.
Failure trace: Trace a crash on this figure: the consumer's uncommitted work simply vanishes, and the next poll replays the same ordered offsets against a checkpoint that never moved without the effect. Trace a stale owner: its arrows carry the old epoch and die at the conditional write.
Guarantee shown: The figure shows where ordering lives (inside partitions), where atomicity lives (the sink transaction), and where fencing lives (the epoch condition). It does not prove the broker delivers in order — that is the log's own contract.
Not shown: It says nothing about external side effects, poison records, or a partition-count change — those need their own branches in the next figures.
خودآزماییSelf-check
چرا checkpoint باید topic و partition خودش را نام ببرد نه اینکه یک عدد per-user باشد؟
پاسخ: چون offset فقط درون یک پارتیشن مرتب میکند: نگهبان offset بزرگتر از قبلی فقط جلوی بازپخش همان پارتیشن درست است و checkpoint باید (topic, partition) خودش را نام ببرد.
Why must the checkpoint name its topic and partition instead of living as one number per user?
Answer: Because offsets order only inside one partition: the guard offset > last is sound solely against the same partition's replay, and the checkpoint must name its (topic, partition).
دانلود نمودار مستقلDownload standalone diagram
- شاخه crash: بروکر offsetهای ۱۰۱ تا ۱۰۴ را مرتب تحویل میدهد؛ تراکنش فقط یکبار اعمالشان میکند چون checkpoint هیچ نیم-commitای ثبت نکرده.
- شاخه ack گمشده: commit شده ولی ack مرده، پس offsetهای ۱۰۳ و ۱۰۴ برمیگردند و نگهبان چون شمردهشدهاند skipشان میکند.
- شاخه rebalance: مالک تازه با epoch ۲ از checkpoint ۱۰۴ ادامه میدهد و write دیررس مالک قدیم با epoch ۱ سر رسیدن فنس میشود.
- Crash branch: the broker redelivers offsets 101–104 in order; the transaction applies them once because the checkpoint never recorded a half-commit.
- Lost-ack branch: the commit happened but the ack died, so offsets 103–104 come back and the guard skips them as already counted.
- Rebalance branch: the new owner at epoch 2 resumes from checkpoint 104 while the old owner's epoch-1 write is fenced on arrival.
نقطهٔ تصمیم: نقطه تصمیم هر بازخوانی است: offset بزرگتر از ذخیرهشده شمردن یا skip را مشخص میکند و برابری epoch فراخوان با مالک، پذیرش یا fence را.
رد خرابی: شاخه crash را بخوان: apply بدون commit و بعد تحویل مجدد همان offsetهای مرتب، و totalها دو بار تکان نمیخورند. شاخه rebalance را بخوان: write دیررس مالک قدیم با epoch ۱ بعد از رفتن مالکیت به epoch ۲ میرسد و پایگاه داده ردش میکند.
تضمین نشاندادهشده: شکل نشان میدهد هر مسیر تحویل مجدد به همان نگهبان میرسد. رکورد سمی و پرش offset را نشان نمیدهد — آنها عامدانه از این مسیر خوش بیروناند.
تضمین نشانندادهشده: فرض میکند بروکر همان پارتیشن را به همان ترتیب بازپخش میکند — برای لاگ درست است و برای صفی که تکپیام را دوباره تخصیص میدهد نادرست.
Decision point: The decision point is each re-read: offset > stored decides count versus skip, and caller epoch = owner epoch decides accept versus fence.
Failure trace: Read the crash branch: apply without commit is followed by a redelivery of the same ordered offsets, and the totals do not move twice. Read the rebalance branch: the old owner's late write arrives with epoch 1 after ownership moved to epoch 2, and the database rejects it.
Guarantee shown: The figure shows that every redelivery path ends at the same guard. It does not show poison records or offset jumps — those leave this happy path by design.
Not shown: It assumes the broker replays the same partition in the same order — true for a log, false for a queue that reassigns single messages.
خودآزماییSelf-check
مالک قدیم که مرده فرض شده بیدار میشود و بعد از rebalance مینویسد. write آن چه میشود؟
پاسخ: با شرط epoch رد میشود: مالکیت به epoch ۲ رفته، پس write با epoch ۱ نگهبان را نمیگذرد و چیزی را عوض نمیکند.
The old owner, presumed dead, wakes up and writes after the rebalance. What happens to its write?
Answer: It is rejected by the epoch condition: ownership moved to epoch 2, so the epoch-1 write fails the guard and changes nothing.
دانلود نمودار مستقلDownload standalone diagram
- ستون فقرات: poll یک پارتیشن، چک epoch، اعمال فقط offsetهای تازهتر، commit همزمان total و checkpoint، تکرار بهازای هر batch.
- کهنه و مهاجرت: مالک فنسشده از نوشتن میایستد و هویت dedup آن زیر کلید مسیریابی نسخهدار جابهجا میشود بهجای ویرایش درجا.
- abort و هشدار: rebalanceها تراکنش باز را rollback میکنند و پرش offset از روی retention به انسان page میزند و re-seed میکند.
- Spine: poll one partition, check the epoch, apply only newer offsets, commit totals and checkpoint together, repeat per batch.
- Stale and migrate: a fenced owner stops writing, and its dedup identity moves under a versioned routing key instead of being edited in place.
- Abort and alert: rebalances roll back the open transaction, and an offset jump past retention pages a human and re-seeds.
نقطهٔ تصمیم: ناوردای حلقه: checkpoint فقط درون همان تراکنشی جلو میرود که totalهای توصیفشدهاش را دارد.
رد خرابی: مالک کهنه پس از نصب epoch تازه در sink رد میشود؛ batchهای همepoch با قفل checkpoint سریال میشوند. checkpoint فعال expire نمیشود و نبود بازهٔ لازم در لاگ به بازسازی صریح میانجامد. تغییر routing به معنای تعریف checkpoint تازه برای لاگ قبلی نیست.
تضمین نشاندادهشده: شکل کل استدلال را سوار میکند: ترتیب پارتیشنمحور بهعلاوه یک تراکنش اتمیک بهعلاوه fencing با epoch مساوی شمارش effectively-once است. اثرهای بیرونی و افقهای بیکران را همچنان بیرون میگذارد — آنها را مرز بخوان نه حفره.
تضمین نشانندادهشده: رکوردهای سمی را پوشش نمیدهد (از شاخه DLQ متن بیرون میروند) و نه دسترسپذیری خود بروکر را.
Decision point: The loop's invariant: the checkpoint advances only inside the same transaction as the totals it describes.
Failure trace: After new sink ownership is installed, stale owners are rejected; checkpoint locks serialize same-epoch batches. Active checkpoints do not expire. Missing required log input triggers explicit recovery. Routing changes do not redefine old-log checkpoints.
Guarantee shown: The figure assembles the whole argument: partition-scoped order plus one atomic transaction plus epoch fencing equals effectively-once counting. It still excludes external effects and unbounded horizons — read those as boundaries, not gaps.
Not shown: It does not cover poison records (they leave through the DLQ branch of the text) nor the broker's own availability.
خودآزماییSelf-check
سه مرزی را نام ببر که این حلقه فقط درونشان میتواند صادقانه ادعای exactly-once کند.
پاسخ: دقیقاً همان سه مرز نامبرده: ترتیب درون پارتیشنها، اتمیک بودن درون یک تراکنش، و یک نویسنده per-partition با ضمانت epoch. بیرون آنها این کلمه آرزوست.
Name the three boundaries inside which this loop may honestly claim exactly-once.
Answer: Exactly the three named boundaries: order inside partitions, atomicity inside one transaction, and one writer per partition enforced by epochs. Outside them the word is aspirational.
گزینههای جایگزینAlternatives
- جدول dedup با event_id بهجای نگهبان offset: هر event_id اعمالشده با قید UNIQUE ذخیره میشود و بازپخش با همان قید رد میشود. وقتی بازپخش نامرتب است یا offset در دست نیست (لاگ فشرده) میبرد؛ قیمتش یک write اضافه بهعلاوه ایندکس یکتاست و خود جدول هم افق نگهداری میخواهد.
- استریمینگ کاملاً تراکنشی (تراکنش کافکا، checkpoint فلینک) با همه اثرها درون فریمورک: وقتی sink در همان تراکنش شرکت میکند میبرد؛ لحظهای که پایگاه داده بیرونی است میبازد چون فریمورک نمیتواند commit خارجی را rollback کند.
- شمارش at-least-once با sketch تقریبی (HyperLogLog): وقتی محصول نرخ میخواهد نه حسابرسی دقیق میبرد؛ همه استدلال صحت این تمرین را میبازد.
- Event-ID dedup table instead of offset guards: store each applied event_id under a UNIQUE constraint and rely on the constraint to reject replays. Wins when replay is unordered or offsets are unavailable (compacted logs); costs one extra write plus a unique index per event, and the table itself needs a retention horizon.
- Fully transactional streaming (Kafka transactions, Flink checkpoints) with every effect inside the framework: wins when the sink participates in the same transaction; loses the moment the database is external, because the framework cannot roll back a foreign commit.
- At-least-once counting with an approximate sketch (HyperLogLog): wins when the product needs a rate, not an exact audit; loses every correctness argument this exercise makes.
سناریوهای خرابیFailure scenarios
- crash میان apply و commit: چیزی commit نشده پس بازپخش مرتب همان totalها را میسازد؛ +۹۹ اضافی دمو با تلاش ثبتنشده محو میشود.
- ack گمشده بعد از commit: بروکر offsetهای checkpointشده را تحویل میدهد؛ نگهبان همه را skip میکند (۱۴ skip در دمو) و totalها تکان نمیخورند.
- rebalance وسط batch: تراکنش باز rollback میشود، مالک تازه epoch-2 از checkpoint ۱۰۴ ادامه میدهد و فقط offset ۴ (کلیک واقعاً تازه) اعمال میشود.
- write مالک کهنه بعد از revoke: مرده فرض شده ولی زنده، commit با epoch ۱ آن شرط epoch را نمیگذرد و بدون دست زدن به totalها فنس میشود.
- offset خارج از بازه بعد از retention: offset خواندهشده دیگر نیست، پس مصرفکننده هشدار میدهد و از snapshot دوباره seed میکند بهجای حدس زدن total.
- رکورد سمی که هیچوقت اعمال نمیشود: بعد از retryهای کراندار با متریک به صف نامههای مرده میرود و پارتیشن صریح از رویش رد میشود.
- Crash between apply and commit: nothing was committed, so ordered replay rebuilds the same totals; the demo's garbage +99 vanishes with the uncommitted attempt.
- Lost ack after commit: the broker redelivers already-checkpointed offsets; the guard skips every one (14 skips in the demo) and totals do not move.
- Rebalance mid-batch: the open transaction rolls back, the new epoch-2 owner resumes from checkpoint 104, and only offset 4 (the genuinely new click) applies.
- Stale owner writes after revoke: presumed dead but alive, its epoch-1 commit fails the epoch condition and is fenced without touching totals.
- Offset out of range after retention: the fetched offset no longer exists, so the consumer alerts and re-seeds from a snapshot instead of guessing a total.
- Poison record that never applies: after bounded retries it moves to a dead-letter queue with a metric, and the partition advances past it explicitly.
کد قابل اجرا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 model: one fixed topic "clicks", three partitions, no real Kafka/DB.
// synchronized models a sink transaction + checkpoint-row lock, not network safety.
public class ExactlyOnceCounter {
record Click(long offset, String user, int delta) {}
static class Db {
Map<String, Long> totals = new TreeMap<>();
Map<Integer, Long> checkpoint = new TreeMap<>();
Map<Integer, Long> epoch = new TreeMap<>();
long skipped, fenced;
synchronized void assign(int partition, long newEpoch) {
if (newEpoch <= epoch.getOrDefault(partition, 0L))
throw new IllegalArgumentException("epoch must increase");
epoch.put(partition, newEpoch);
}
synchronized boolean consume(int partition, long callerEpoch,
List<Click> batch, boolean crashBeforeCommit) {
if (epoch.getOrDefault(partition, 0L) != callerEpoch) {
fenced++;
return false;
}
// Read under the same lock that guards commit, including same-owner batches.
long upto = checkpoint.getOrDefault(partition, -1L);
long previous = -1;
Map<String, Long> staged = new HashMap<>();
for (Click click : batch) {
if (click.offset < previous)
throw new IllegalArgumentException("batch must preserve partition order");
previous = click.offset;
if (click.offset <= upto) { skipped++; continue; }
staged.merge(click.user, (long) click.delta, Long::sum);
upto = click.offset; // includes earlier records in THIS batch
}
if (crashBeforeCommit) return false; // no effects or checkpoint survive
staged.forEach((user, delta) -> totals.merge(user, delta, Long::sum));
checkpoint.put(partition, upto);
return true; // only now may the broker be ACKed
}
synchronized void requireReplayAvailable(int p, long earliestAvailable) {
if (earliestAvailable > checkpoint.getOrDefault(p, -1L) + 1)
throw new IllegalStateException("required input missing: rebuild explicitly");
}
}
static void check(boolean ok) { if (!ok) throw new AssertionError(); }
public static void main(String[] args) {
Db db = new Db();
for (int p=0; p<3; p++) db.assign(p, 1);
List<Click> p0 = new ArrayList<>(), p1 = new ArrayList<>(), p2 = new ArrayList<>();
for (int i=0; i<5; i++) p0.add(new Click(i,"u1",1));
for (int i=0; i<4; i++) p1.add(new Click(i,"u2",1));
for (int i=4; i<7; i++) p1.add(new Click(i,"u3",1));
for (int i=0; i<2; i++) p2.add(new Click(i,"u4",1));
check(!db.consume(0,1,p0,true)); // simulated crash before atomic commit
check(db.totals.isEmpty() && db.checkpoint.isEmpty());
check(db.consume(0,1,p0,false));
check(db.consume(1,1,p1,false));
check(db.consume(2,1,p2,false)); // offset 0 on P2 is independent of P0
Map<String,Long> beforeReplay = new TreeMap<>(db.totals);
db.consume(0,1,p0,false); db.consume(1,1,p1,false); db.consume(2,1,p2,false);
check(db.totals.equals(beforeReplay)); // lost ACK/redelivery has no new effect
db.assign(1,2);
p1.add(new Click(7,"u2",1));
check(!db.consume(1,1,p1,false));
check(db.totals.equals(beforeReplay));
check(db.consume(1,2,p1,false));
check(db.totals.equals(Map.of("u1",5L,"u2",5L,"u3",3L,"u4",2L)));
// Duplicate record within one batch; later replay with the same epoch.
List<Click> duplicate = List.of(new Click(8,"u2",1),new Click(8,"u2",1));
db.consume(1,2,duplicate,false); db.consume(1,2,duplicate,false);
check(db.totals.get("u2")==6 && db.checkpoint.get(1)==8);
db.requireReplayAvailable(1,9);
boolean refused=false;
try { db.requireReplayAvailable(1,10); } catch (IllegalStateException e) { refused=true; }
check(refused); // do not silently jump over missing log input
check(db.checkpoint.equals(Map.of(0,4L,1,8L,2,1L)));
System.out.println("OK totals="+db.totals+" checkpoints="+db.checkpoint);
System.out.println("PASS: crash, replay, partition scope, stale epoch, same-batch duplicate, missing history");
}
}
اجرا: javac ExactlyOnceCounter.java && java ExactlyOnceCounter — فقط استاندارد جاوا و فوری. پارتیشنها لیست درونحافظهاند: این منطق sink را اثبات میکند نه اتصال به بروکر را.
خروجی مورد انتظار: OK totals={u1=5, u2=6, u3=3, u4=2} checkpoints={0=4, 1=8, 2=1}؛ assertionهای شش سناریو پاس میشوند. کلیک اضافهٔ u2 در offset=8 آزمون تکرار داخل batch است.
Run: javac ExactlyOnceCounter.java && java ExactlyOnceCounter — stdlib Java only, instant. Partitions are in-memory lists: this proves the sink logic, not a broker integration.
Expected output: OK totals={u1=5, u2=6, u3=3, u4=2} checkpoints={0=4, 1=8, 2=1}; six scenario assertions pass. The extra u2 click at offset 8 is an explicit duplicate-within-batch test.
آزمون صحت و معیار عملیاتیCorrectness checks and operational criteria
ناورداها: total هر کاربر برابر جمع دلتاهای اعمالشده آن است؛ checkpointها per-partition صعودیاند؛ kill -9 بهعلاوه بازپخش مرتب هیچ totalای را تکان نمیدهد. lag مصرفکننده per-partition، نرخ skip تحویل مجدد و شمارنده write فنسشده را بسنج؛ وقتی skipها رشد کنند (دردسر بروکر) یا fenceها شلیک شوند (دردسر مالکیت) هشدار بده. راهنمای اجرای محلی برای بروکر واقعی (فقط راهنما، اینجا اجرا نشده): کافکا را محلی بالا بیاور، تاپیک clicks با ۳ پارتیشن بساز، کلیک کلیددار produce کن، مصرفکننده را وسط batch بکش و ببین totalها سر جایشان میمانند.
Invariants: each user's total equals the sum of its applied deltas; checkpoints are monotonic per partition; a kill -9 plus ordered replay never moves a total. Measure consumer lag per partition, the redelivery-skip rate, and the fenced-write counter; alert when skips grow (broker trouble) or fences fire (ownership trouble). Local-run guide for a real broker (guide only, not executed here): run Kafka locally, create topic clicks with 3 partitions, produce keyed clicks, kill the consumer mid-batch and watch the totals stay put.
پاسخ سؤالهای پیگیریFollow-up answers
پیگیری (استف): تعداد partitionها تغییر کند و کاربر جابهجا شود؛ dedup key چگونه مهاجرت میکند؟Follow-up (Staff): Partition count changes and a user moves. How do you migrate the dedup identity?
تغییر partition بهتنهایی نسخهٔ checkpoint جدید نمیخواهد؛ هویت (topic,partition) هر لاگ حفظ میشود. اگر همان intent به دو مسیر کپی میشود، offsetهای متفاوت dedup مشترک نیستند و event_id پایدار لازم است. برای cutover، مرز مسیر قدیم را ثبت، تا آن مرز drain و مسیر تازه را فعال کن؛ افزایش اتمیک total یا قفل کاربر، همزمانی چند پارتیشن را کنترل میکند.
A partition-count change does not itself redefine an existing (topic,partition) checkpoint. If the same intent is copied into both routes, offsets differ and cross-route dedup needs a stable event ID. Record an old-route boundary, drain through it and cut over; atomic total increments or a user-row lock control concurrent partition updates.
پیگیری (سینیور): چرا خواندن-بعد-نوشتن total بهتنهایی کافی نیست و ستون last_offset دقیقاً چه شرطی را چک میکند؟Follow-up (Senior): Why isn't read-then-write of the total enough on its own? What exactly does the last_offset column check?
دو مرحلهٔ read سپس write بدون قفل یا CAS میتوانند snapshot قدیمی را دوبار اعمال کنند. checkpoint پارتیشن باید در همان تراکنش اثر قفل/شرطگذاری شود؛ epoch مالک کهنه را رد میکند ولی دو batch یک مالک هم به سریالسازی نیاز دارند.
Unprotected read-then-write can apply the same old snapshot twice. Lock or compare-and-swap the partition checkpoint within the effect transaction. Epoch checks reject stale owners; same-owner batches still need serialization.
پیگیری (استف): پارتیشن شبکه دو مصرفکننده میگذارد که هرکدام فکر میکنند مالک پارتیشناند. شمارش چطور درست میماند؟Follow-up (Staff): A network split leaves two consumers each believing it owns the partition. How does counting stay correct?
مالک تازه epoch خود را با پروتکل انتقال مالکیت در sink ثبت میکند. تراکنش مالک قبلی باید پیش از انتقال تمام شود یا بعد از آن با epoch کهنه رد شود؛ صرف callback گروه Kafka تضمین sink نیست.
Install new ownership through a sink ownership-transfer protocol. An old transaction must finish before transfer or fail its epoch check after transfer; Kafka group callbacks alone do not guarantee sink fencing.
اشتباهات رایجCommon mistakes
- مقایسه offset دو پارتیشن با هم انگار ۱۰۴ روی P0 کنار ۱۰۴ روی P1 معنایی دارد. offsetها دنباله per-partitionاند؛ مقایسه میانپارتیشنی گناه اولیه این تمرین است.
- ثبت offset قبل از اثر (شمارش at-most-once) یا در تراکنش جدا از اثر (باز کردن پنجره crashای که طراحی بسته بود).
- فرض اینکه rebalanceها فوریاند: مالک قدیم batch زنده و قلم زنده دارد، پس بدون fencing با epoch نوشتههایش بعد از مالک تازه مینشیند.
- ادعای exactly-once درحالیکه اثر بیرونی (push، SMS، ایمیل) بدون کلید idempotency خودش سوار همان مسیر است — تضمین بروکر هرگز از آن مرز رد نمیشود.
- Comparing offsets across partitions as if 104 on P0 meant anything next to 104 on P1. Offsets are per-partition sequences; cross-partition comparison is the original sin of this exercise.
- Committing the offset before the effect (at-most-once counting) or in a separate transaction from the effect (reopening the crash window the design closed).
- Assuming rebalances are instant: the old owner keeps a live batch and a live pen, so without epoch fencing its writes land after the new owner's.
- Claiming exactly-once while an external side effect (push, SMS, email) rides the same path without its own idempotency key — the broker's guarantee never crosses that boundary.
منابع:Sources: Kafka 4.1 · design and delivery semantics · AWS · safe retries with idempotent APIs · Kleppmann · distributed locking and fencing
22 تمرین ۲ — join جستجو–کلیک با دیررسهاExercise 2 — a search–click join with laggards
بازگشت به صورت تمرینBack to the exercise · ارزیابی و سطحAssessment and level
پاسخ ۶۰ثانیهایThe 60-second answer
صورت مسئله و فرضهاProblem and assumptions
- استریمهای جستجو و کلیک با session id همپارتیشناند (کلید یکسان، تعداد پارتیشن یکسان)، پس دو سوی هر session روی یک ماشین به هم میرسند. بار آموزشی: ۱M جستجو در ساعت با ۲۰۰k کلیک؛ میانگین lateness چهل ثانیه و p99 شش دقیقه — اعداد آموزشیاند نه بنچمارک.
- زمان رویداد از timestamp تعامل دستگاه میآید با اصلاح انحراف ساعت دستگاه؛ تکراریها با تحویل مجدد میآیند و event id پایدار دارند.
- اصلاحیهها پاییندست پذیرفتنیاند: مصرفکننده CTR مقادیر نسخهدار را میفهمد (اول v1 بعد v2 با پسگیری).
- Search and click streams are co-partitioned by session id (same key, same partition count), so each session's two sides meet on one machine. Teaching load: 1M searches/hour with 200k clicks; average lateness 40s, p99 6 minutes — teaching numbers, not benchmarks.
- Event time comes from the device interaction timestamp, corrected for device skew; duplicates arrive by redelivery and carry stable event ids.
- Corrections are acceptable downstream: the CTR consumer understands versioned values (v1 then v2 with retraction).
چرا این روش و این عددها؟Why this method and these numbers?
window از توزیع lateness انتخاب میشود، نه یک عدد رند: با p99 شش دقیقه، پنجرهٔ کوتاهتر تقریباً بیشتر از یک درصد lateها را خارج میگذارد، اما رفتار واقعی به تعریف توزیع مربوط است. state تقریباً نرخ ورود × مدت نگهداری × بایت هر ورودی است؛ افزایش window هزینهٔ RAM و recovery را بالا میبرد. watermark ادعای پیشرفت event-time با فرض منبع است، نه اثبات رسیدن همهٔ رویدادها. retraction برای اصلاح نتیجهای لازم است که قبلاً منتشر شده است.
Choose windows from lateness distributions. A six-minute p99 suggests shorter retention excludes more late events, subject to what population that percentile describes. State scales roughly as arrival rate × retention duration × bytes/entry, so wider windows cost RAM and recovery time. Watermarks express event-time progress assumptions, not proof all events arrived. Retractions correct previously published results.
مدل ذهنیMental model
حل گامبهگامStep by step
- بافر هر دو ترتیب رسیدن بهازای هر session بافر دوسویه نگه دار: جستجوها و کلیکها هرکدام منتظر دیگری میمانند تا کلیک-قبل-از-جستجو دقیقاً مثل جستجو-قبل-از-کلیک join شود و مجموعه دیدهشده هر سو بازتحویلها را بیندازد.
- کمینه watermark و پارک بیکار با زمان رویداد کار کن و کمینه روی پارتیشنها را watermark کن: پارتیشن ساکتِ گذشته از timeout بیکاری پارک و با متریک کنار گذاشته میشود تا یک استریم ساکت همه پنجرهها را گروگان نگیرد.
- بودجه lateness از داده lateness مجاز را از توزیع اندازهگیریشده بگذار (آموزشی: ۱۰ دقیقه روی p99 ششدقیقهای): سر گذر watermark از پایان-بهعلاوه-lateness ببند چون timeout اندازهگیرینشده حدسی است با نقاب عدد.
- اصلاح یا drop، صریح به دیررسها دو سرنوشت صریح بده: درون lateness اصلاحیه پسگیرنده منتشر کن (v1 بیرون و v2 درون)؛ آنور افق بینداز، در late_dropped_total بشمار و با رشد سهم هشدار بده.
- برآورد صادقانه ۱۰ برابری رشد دهبرابری را قبل از آمدن قیمت بگذار: state بافر با نرخ × پنجره × بایت مقیاس میگیرد، پس sessionهای سرد را به دیسک محلی سرریز کن و بگذار بافرهای کراندار با هشدار lag بهجای drop بیصدا backpressure بدهند.
- Buffer both arrival orders Keep a two-sided buffer per session: searches and clicks each wait for the other, so click-before-search joins exactly like search-before-click, and a seen-set on each side drops redeliveries.
- Watermark the minimum, park idle Run on event time and watermark the minimum over partitions: a partition quiet past the idle timeout is parked and excluded with a meter, so one silent stream cannot hold every window hostage.
- Budget lateness from data Set allowed lateness from the measured lateness distribution (teaching: 10 minutes over a p99 of 6): close at watermark past end-plus-lateness, because an unmeasured timeout is a guess wearing a number.
- Correct or drop, explicitly Give laggards two explicit fates: inside lateness, publish a retracting correction (v1 out, v2 in); beyond the horizon, drop, count in late_dropped_total, and alert when the share grows.
- Estimate 10x honestly Price tenfold growth before it arrives: buffer state scales with rate × window × bytes, so spill cold sessions to local disk and let bounded buffers push backpressure with lag alerts instead of silent drops.
طراحی مشخصConcrete design
هر دو استریم را با session id و همان پارتیشنر کلید بزن تا هر session روی یک ماشین با هر دو سوی محلی پردازش شود. state هر session: لیست جستجو و لیست کلیک درون پنجره یکساعته بهعلاوه مجموعه دیدهشده event idها در هر سو. با هر رسیدن، بافر کن و سوی دیگر را probe کن؛ با match فقط یکبار clicked منتشر کن (اولکلیک میبرد و کلیکهای بعدی همان session مدرکاند نه join تازه)؛ وقتی watermark از پایان پنجره بهعلاوه lateness مجاز گذشت، برای باقیمانده بیهمتا unclicked منتشر کن.
CTR عبارت است از جستجوهای کلیکشده بر همه جستجوهای پنجرههای بسته، نسخهدار: اصلاحیه v1 را پس میگیرد و v2 منتشر میکند و مخرج زیر اصلاحیه عوض نمیشود. watermark = کمینه watermarkهای per-partition؛ پارتیشنی که بیشتر از مهلت بیکاری ساکت مانده پارک میشود (با متریک کنار گذاشته میشود) تا داده برگردد.
Key both streams by session id with the same partitioner, so every session is processed on one machine with both sides local. State per session: the search list and click list inside the one-hour window, plus a seen-set of event ids on each side. On any arrival, buffer it and probe the other side; on a match emit clicked once (first click wins, later clicks in the same session are evidence, not new joins); when the watermark passes the window end plus allowed lateness, emit unclicked for the unmatched remainder.
CTR is defined as clicked searches over all searches in closed windows, versioned: a correction retracts v1 and publishes v2, and the denominator never changes under a correction. Watermark = minimum of per-partition watermarks; a partition silent past the idle timeout is parked (excluded with a meter) until data resumes.
مثال محاسبهشدهWorked example
اعداد آموزشی: ۱M جستجو در ساعت ≈ ۲۷۸ در ثانیه؛ هر session بافرشده حدود ۲۰۰ بایت × ۲ سو × ۱ ساعت ≈ چند گیگابایت بهازای هر ساعت پنجره نگه میدارد — در ۱۰ برابر، دهها گیگابایت، پس sessionهای سرد به دیسک محلی سرریز میشوند و داغها در حافظه میمانند. p99 ششدقیقهای lateness، مجاز را ۱۰ میکند؛ ۰.۳٪ دیرتررسیده انداخته و شمرده میشوند و CTR را حداکثر به همان سهم به پایین بایاس میکنند — کنار نرخ روی داشبورد اعلام میشود، هرگز پنهان نه. اصلاحیهها کمتر از ۱ در ۳۰۰ پنجره را بازبینی میکنند و هرکدام دقیقاً یک v1 را پس میگیرند.
Teaching numbers: 1M searches/hour ≈ 278/s; each buffered session holds ~200 bytes × 2 sides × 1 hour ≈ a few GB per hour of window — at 10x, tens of GB, so cold sessions spill to local disk while hot ones stay in memory. Lateness p99 of 6 minutes sets allowed lateness to 10; the 0.3% arriving later are dropped and counted, biasing CTR down by at most that share — stated on the dashboard next to the rate, never hidden. Corrections revise fewer than 1 in 300 windows, each retracting exactly one v1.
دانلود SVG مستقلDownload standalone SVG
- SessionBuffer هر دو سو را با زمان رویداد نگه میدارد بهعلاوه مجموعه dedup: اتاق انتظاری که کلیک ممکن است قبل از جستجوش برسد.
- JoinOperator هر رسیدن را بافر میکند و سوی دیگر را probe میکند: matchها یکبار clicked میدهند، انقضاها unclicked، دیررسها اصلاح یا drop.
- sink ـه CTR هر جستجو را یکبار با مقادیر نسخهدار میشمارد، سهم دیررس انداختهشده را متریک میکند و با رشد آن هشدار میدهد.
- SessionBuffer keeps both sides by event time plus a dedup set: the waiting room where a click may arrive before its search.
- JoinOperator buffers every arrival and probes the other side: matches emit clicked once, expiries emit unclicked, laggards correct or drop.
- The CTR sink counts each search once with versioned values, meters the dropped late share, and alerts when that share grows.
نقطهٔ تصمیم: نقطه تصمیم شکل بافر است: دو سو بهعلاوه مجموعه dedup، پس ترتیب رسیدن بیاثر میشود.
رد خرابی: خرابی را از جعبهها بخوان: کلیکی که قبل از جستجوش میرسد بهجای انداخته شدن در بافر میماند؛ پارتیشن بیکار بهجای نگه داشتن همه پنجرهها پارک میشود؛ event id تکراری در مجموعه دیدهشده میمیرد بهجای باد کردن CTR.
تضمین نشاندادهشده: شکل stateای را نشان میدهد که هر ترتیب رسیدن را امن میکند. نشان نمیدهد پنجرهها کی بسته میشوند — آن کار چرخه حیات است، بعدی.
تضمین نشانندادهشده: درباره منبع watermark، بودجه lateness و رشد دهبرابری چیزی نمیگوید — همه عامدانه به دو شکل بعد موکول شدهاند.
Decision point: The decision point is the buffer shape: two sides plus a dedup set, so arrival order stops mattering.
Failure trace: Read the failure out of the boxes: a click arriving before its search waits in the buffer instead of being dropped; an idle partition is parked instead of holding every window; a duplicate event id dies in the seen-set instead of inflating CTR.
Guarantee shown: The figure shows the state that makes either arrival order safe. It does not show when windows close — that is the lifecycle's job, next.
Not shown: It says nothing about watermark sources, lateness budgets, or tenfold growth — all deliberately deferred to the next two figures.
خودآزماییSelf-check
کلیکی قبل از جستجوش میرسد و بعد همان کلیک دوباره تحویل میشود. چه چیزی و چند بار منتشر میشود؟
پاسخ: بافر دوسویه بهعلاوه مجموعه دیدهشده: هرکدام اول برسد میماند، match یکبار منتشر میشود و event id بازپخششده skip میشود.
A click arrives before its search, then the same click is redelivered. What emits, and how many times?
Answer: The two-sided buffer plus the seen-set: whichever arrives first waits, the match emits once, and a replayed event id is skipped.
دانلود نمودار مستقلDownload standalone diagram
- باز و بافرینگ: بازه ثابت زمانرویداد جستجوها و کلیکهای timestampدار هر دو سو را جمع میکند.
- watermark و انتشار: وقتی آستانه از پایان پنجره گذشت، ببند و اولین مقدار را منتشر کن — clicked یا unclicked.
- مهلت و پایانیها: درون lateness مجاز اصلاحیه پسگیرنده بده؛ آنور افق بینداز، بشمار و هشدار بده.
- Open and buffering: the fixed event-time range accumulates timestamped searches and clicks on both sides.
- Watermark and emit: when the threshold passes the window end, close and publish the first value — clicked or unclicked.
- Grace and terminals: inside allowed lateness publish a retracting correction; beyond the horizon drop, count, and alert.
نقطهٔ تصمیم: نقطه تصمیم لوزی watermark است: آستانه گذشته پس ببند — با یادآوری اینکه آستانه قول نیست.
رد خرابی: لاین انتظار را برو: پارتیشن بیکار با timeout پارک میشود و با داده برمیگردد، پس یک استریم ساکت نمیتواند همه پنجرهها را گروگان بگیرد. لاین بازیابی را برو: restart لاگ ورودی را با زمان رویداد بازپخش میکند و همان پنجره را میسازد.
تضمین نشاندادهشده: شکل همه حالتهای پنجره و دو سیاست دیررس را نشان میدهد. بودجه lateness را ثابت نمیکند — آن عدد از داده اندازهگیریشده میآید، در خط زمانی بعدی.
تضمین نشانندادهشده: فرض میکند اصلاحیهها پاییندست پذیرفتنیاند؛ جایی که نیستند (صورتحساب)، شاخه drop بسته است و lateness باید بزرگ شود.
Decision point: The decision point is the watermark diamond: the threshold has passed, so close — while remembering the threshold is not a promise.
Failure trace: Walk the waiting lane: an idle partition parks on timeout and rejoins on data, so one quiet stream cannot hold every window hostage. Walk the recovery lane: a restart replays the input log by event time and rebuilds the identical window.
Guarantee shown: The figure shows every window state and the two late policies. It does not fix the lateness budget — that number comes from measured data, in the timeline next.
Not shown: It assumes corrections are acceptable downstream; where they are not (billing), the drop branch is closed and lateness must grow instead.
خودآزماییSelf-check
watermark گذشته: مطمئنی دیگر هیچ رویدادی برای این پنجره نمیآید؟
پاسخ: هیچکدام: watermark آستانهای است که میگوید پیامی قبل از t1 انتظار نمیرود نه اثبات اینکه هیچکدام نمیآید — از این رو lateness مجاز بهعلاوه drop-یا-اصلاح.
The watermark has passed: are you certain no more events for this window will arrive?
Answer: Neither: the watermark is a threshold that says no more pre-t1 messages are expected, not a proof none will come — hence allowed lateness plus drop-or-correct.
دانلود SVG مستقلDownload standalone SVG
- لاینهای پارتیشن: رویدادهای session روی پارتیشن ۰ جمع میشوند و پارتیشن ۱ بعد از t=20 ساکت و پارک میشود نه اطاعت.
- بستن با watermark: آستانه خطچین به ۶۰ میرسد و پنجره [0,60) با حکمهای clicked و unclickedاش بسته میشود.
- حکمهای دیررس: رسیدن t=50 با پسگیری اصلاح میکند و رسیدن t=10 انداخته و شمرده میشود و در مجموع هشدار میدهد.
- Partition lanes: session events accumulate on partition 0 while partition 1 goes quiet after t=20 and is parked, not obeyed.
- Watermark close: the dashed threshold reaches 60 and window [0,60) closes with its clicked and unclicked verdicts.
- Laggard verdicts: the t=50 arrival corrects with a retraction while the t=10 arrival is dropped, counted, and alerts in aggregate.
نقطهٔ تصمیم: نقطههای تصمیم دو پیکان دیررساند: سازوکار یکسان و حکم متفاوت با یک افق اندازهگیریشده.
رد خرابی: پارتیشن ۱ را دنبال کن: بعد از t=20 ساکت، با timeout پارک شده و watermark بدون آن جلو میرود — پنجرهها سر وقت بسته میشوند چون پارک صریح است. بعد دو پیکان را دنبال کن: دیررس t=50 اصلاحیه مینشاند و t=10 انداخته و شمرده میشود.
تضمین نشاندادهشده: شکل کل استدلال را سوار میکند: state دوسویه، انضباط watermark و lateness اندازهگیریشده در یک خط زمانی. state دهبرابری را اندازه نمیکند — آن حساب در مثال محاسبهشده است.
تضمین نشانندادهشده: هیچ عدد latenessای را ثابت نمیکند: ۱۰ دقیقه اینجا بودجه آموزشی از روی p99 ششدقیقهای است و خواننده باید روی داده خودش دوباره بسنجد.
Decision point: The decision points are the two laggard arrows: same mechanism, different verdicts, decided by one measured horizon.
Failure trace: Trace partition 1: quiet after t=20, parked by timeout, the watermark advancing without it — windows close on time because parking is explicit. Then trace the two arrows: the t=50 laggard lands a correction, the t=10 one is dropped and counted.
Guarantee shown: The figure assembles the whole argument: two-sided state, watermark discipline, and measured lateness in one timeline. It does not size the tenfold state — that arithmetic lives in the worked example.
Not shown: It fixes no lateness number: 10 minutes here is a teaching budget from a p99 of 6, and the reader must re-measure on their own data.
خودآزماییSelf-check
یک پارتیشن یک ساعت ساکت میماند و بقیه جاریاند. پنجرهها اصلاً چطور بسته میشوند؟
پاسخ: بعد از timeout بیکاری کراندار پارکش کن: با متریک از کمینه watermark کنارش بگذار، به بستن پنجرهها ادامه بده و با داده برگردانش — هرگز بیصدا صبر نکن.
One partition goes quiet for an hour while the rest stream on. How do windows ever close?
Answer: Park it after a bounded idle timeout: exclude it from the watermark minimum with a meter, keep closing windows, and readmit it on data — never wait silently.
گزینههای جایگزینAlternatives
- پنجرههای processing-time: بستن با ساعت پردازشگر. فقط وقتی میبرد که lag تولید-تا-پردازش تضمینشده ناچیز باشد؛ زیر هر lag واقعی جهش مصنوعی شکل ۱۲-۸ را میسازد، پس این تمرین با مدرک ردش میکند.
- جاسازی جزئیات جستجو درون رویداد کلیک بهجای join: درباره جستجوهای کلیکشده همهچیز میگوید و درباره کلیکنشدهها هیچچیز، پس مخرج نرخ از دست میرود. برای attribution میبرد و برای CTR میبازد.
- join استریم–جدول با جدول session و پنجره نامتناهی: وقتی sessionها state غنی کندتغییر دارند میبرد؛ قیمتش state نامتناهی جدول و ناقطعیبودن ترتیب میاناستریمی است، جایی که بافر دوسویه کراندار این تمرین صادق میماند.
- Processing-time windows: close by the processor clock. Wins only when produce-to-process lag is guaranteed negligible; under any real lag it manufactures the artificial spike of Figure 12-8, so this exercise rejects it with evidence.
- Embedding search details inside the click event instead of joining: tells you everything about clicked searches and nothing about unclicked ones, so the rate's denominator is gone. Wins for attribution, loses for CTR.
- Stream–table join against a session table with an infinite window: wins when sessions carry rich slowly-changing profile state; costs unbounded table state and the nondeterminism of cross-stream order, where this exercise's bounded two-sided buffer stays honest.
سناریوهای خرابیFailure scenarios
- کلیک قبل از جستجو: کلیک در بافر session میماند و با رسیدن جستجو join میشود — ذاتاً بینیاز از ترتیب، در دمو زنده نشان داده شده (c2 قبل از q2).
- پارتیشن بیکار watermark را نگه داشته: بعد از timeout بیکاری پارک و با متریک از کمینه کنار گذاشته میشود تا پنجرهها بسته شوند؛ با داده برمیگردد و backlog آن با زمان رویداد بازپخش میشود.
- انحراف ساعت دستگاه: زمان وقوع با جفت ارسال/دریافت اصلاح میشود تا ساعت دروغگوی گوشی رویداد را در پنجره اشتباه بایگانی نکند.
- تحویل تکراری در هر دو سو: event idهای پایدار رسیدن دوم را skip میکنند تا کلیک بازتحویلشده دوبار join نشود و CTR را باد نکند.
- rebalance وسط پنجره یک session را جابهجا میکند: بافر آن از changelog روی مالک تازه برمیگردد و بازپخش همگرا میشود چون محتویات پنجره تابع خالص زمان رویداد است.
- throughput دهبرابری: state بافر با پنجره حدود ۱۰ برابر میشود (نرخ × پنجره × بایت)، پس sessionهای سرد به دیسک محلی سرریز میشوند و بافرهای کراندار بهجای drop بیصدا backpressure با هشدار lag اعمال میکنند.
- Click before search: the click waits in the session buffer and joins when the search lands — order-free by construction, shown live in the demo (c2 before q2).
- Idle partition holds the watermark: after the idle timeout it is parked and excluded from the minimum with a meter, so windows close; on data it is readmitted and its backlog replays by event time.
- Device clock skew: occurrence time is corrected with the send/receive pair, so a lying phone clock cannot file an event into the wrong window.
- Duplicate delivery on both sides: stable event ids make the second arrival a skip, so a redelivered click cannot join twice or inflate CTR.
- Rebalance moves a session mid-window: its buffer restores from the changelog on the new owner and replay converges, because window contents are a pure function of event time.
- Tenfold throughput: buffer state grows ~10x with the window (rate × window × bytes), so cold sessions spill to local disk and bounded buffers apply backpressure with lag alerts instead of dropping silently.
کد قابل اجرا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: search-click windowed join on EVENT time with watermarks,
// idle-partition handling, allowed lateness and corrections. Both arrival
// orders work (two-sided per-session buffers + dedup sets). Stdlib only.
public class WindowedJoin {
record Ev(String kind, String session, String id, long eventTime) {}
static final long WINDOW = 60, ALLOWED = 10; // 1-hour window, 10-min allowed lateness (teaching)
Map<String, List<Ev>> searches = new HashMap<>(), clicks = new HashMap<>();
Set<String> seen = new HashSet<>(), matchedClick = new HashSet<>();
long watermark = 0;
int emitted = 0, unclicked = 0, droppedLate = 0, corrections = 0;
void watermarkTo(long t, Set<Integer> idle) {
watermark = t;
if (!idle.isEmpty()) System.out.println(" watermark=" + t + " (idle partitions excluded: " + idle + ")");
}
void on(Ev e) {
if (!seen.add(e.kind + ":" + e.id)) { System.out.println(" dedup: skip " + e.id); return; }
long windowEnd = (e.eventTime / WINDOW + 1) * WINDOW;
if (e.eventTime < watermark - ALLOWED && watermark > windowEnd) {
droppedLate++;
System.out.println(" late beyond horizon: DROP " + e.id + " (counted in late_dropped_total)");
return;
}
(e.kind.equals("search") ? searches : clicks)
.computeIfAbsent(e.session, k -> new ArrayList<>()).add(e);
tryMatch(e.session, windowEnd);
}
void tryMatch(String s, long windowEnd) {
List<Ev> ss = searches.getOrDefault(s, List.of());
List<Ev> cs = clicks.getOrDefault(s, List.of());
for (Ev c : cs) {
if (matchedClick.contains(c.id)) continue;
Optional<Ev> base = ss.stream()
.filter(x -> c.eventTime >= x.eventTime && c.eventTime - x.eventTime <= WINDOW)
.findFirst();
if (base.isPresent()) {
matchedClick.add(c.id);
emitted++;
System.out.println(" JOIN " + base.get().id + " x " + c.id + " (session " + s + ")");
}
}
}
// Watermark passes window end: searches with no click expire as "unclicked".
void closeWindow(long windowEnd) {
for (var en : searches.entrySet())
for (Ev s : en.getValue())
if (s.eventTime < windowEnd && clicks.getOrDefault(en.getKey(), List.of()).stream()
.noneMatch(c -> matchedClick.contains(c.id) && c.eventTime - s.eventTime <= WINDOW)) {
unclicked++;
System.out.println(" EXPIRE " + s.id + " -> unclicked");
}
}
// Correction path: a laggard inside allowed lateness revises the metric.
void correct(Ev lateClick, String searchId) {
matchedClick.add(lateClick.id); corrections++; unclicked--; emitted++;
System.out.println(" CORRECTION " + searchId + " x " + lateClick.id + " (retract unclicked, republish clicked)");
}
public static void main(String[] a) {
WindowedJoin j = new WindowedJoin();
System.out.println("== 1. both arrival orders ==");
j.on(new Ev("search", "s1", "q1", 5)); // search first
j.on(new Ev("click", "s1", "c1", 12));
j.on(new Ev("click", "s2", "c2", 20)); // click BEFORE its search
j.on(new Ev("search", "s2", "q2", 18));
System.out.println("== 2. watermark with an idle partition ==");
j.watermarkTo(60, Set.of(2)); // partition 2 idle -> excluded, windows still close
j.closeWindow(60);
System.out.println("== 3. laggard inside lateness -> correction; outside -> drop ==");
j.on(new Ev("search", "s3", "q3", 61));
j.watermarkTo(125, Set.of());
j.closeWindow(120);
j.correct(new Ev("click", "s3", "c3", 70), "q3"); // inside lateness: revises q3 from unclicked to clicked
j.on(new Ev("click", "s4", "c9", 3)); // far too late -> drop
long denom = j.emitted + j.unclicked;
System.out.println("emitted=" + j.emitted + " unclicked=" + j.unclicked
+ " corrections=" + j.corrections + " droppedLate=" + j.droppedLate);
System.out.println("CTR(clicked searches / all searches) = " + j.emitted + "/" + denom);
System.out.println("OK");
}
}
اجرا: javac WindowedJoin.java && java WindowedJoin — فقط استاندارد جاوا و فوری. watermarkها و پارتیشنها مقادیر شبیهسازیشدهاند: این منطق join/watermark را اثبات میکند نه استقرار Flink/Kafka را.
خروجی مورد انتظار: هر دو ترتیب join میشوند (q1×c1 و q2×c2 با اینکه c2 اول رسیده)؛ watermark با پارک پارتیشن ۲ جلو میرود؛ q3 اول unclicked منقضی و بعد به clicked اصلاح میشود؛ c9 انداخته و شمرده میشود. جمع: emitted=3 و unclicked=0 و corrections=1 و droppedLate=1 و CTR سهسوم. با OK تمام میشود. (خروجی واقعی همین محیط.)
Run: javac WindowedJoin.java && java WindowedJoin — stdlib Java only, instant. Watermarks and partitions are simulated values: this proves the join/watermark logic, not a Flink/Kafka deployment.
Expected output: Both orders join (q1×c1, q2×c2 even though c2 arrived first); the watermark advances with partition 2 parked; q3 expires unclicked then is corrected to clicked; c9 is dropped and counted. Totals: emitted=3, unclicked=0, corrections=1, droppedLate=1, CTR 3/3. Ends with OK. (Actual output of this environment.)
آزمون صحت و معیار عملیاتیCorrectness checks and operational criteria
قطعیبودن بازپخش: لاگ ورودی یکسان پنجرههای بایتیکسان بازمیسازد. متریکها: late_dropped_total، نرخ اصلاحیه، lag watermark هر پارتیشن و رویدادهای پارک بیکاری. روش: CDF lateness را از یک روز ترافیک رسم و lateness مجاز را ماهانه بازمشتق کن؛ با ۱۰ برابر بار و spill دیسک روشن تست بار بگیر و راستیآزمایی کن هشدارهای lag قبل از dropها شلیک میشوند. راهنمای استقرار واقعی Flink/Kafka اینجا خارج قلمروست.
Replay determinism: the same input log rebuilds byte-identical windows. Metrics: late_dropped_total, correction rate, watermark lag per partition, idle-park events. Method: plot the lateness CDF from a day of traffic and re-derive allowed lateness monthly; load-test at 10x with disk spill on and verify lag alerts fire before drops. A real Flink/Kafka deployment guide is out of scope here.
پاسخ سؤالهای پیگیریFollow-up answers
پیگیری (سینیور): یک partition بیکار watermark را نگه دارد؛ پنجرهها را چطور میبندی؟Follow-up (Senior): An idle partition holds back the watermark. How do you close windows safely?
بعد از timeout بیکاری کراندار پارکش کن: از کمینه watermark کنارش بگذار، کنارگذاری را متریک کن، به بستن پنجرهها ادامه بده و با داده برگردانش. هرگز بیصدا صبر نکن و backlog پارکشده را بدون بازپخش با زمان رویداد نینداز.
Park it after a bounded idle timeout: exclude it from the watermark minimum, meter the exclusion, keep closing windows, and readmit on data. Never wait silently and never drop the parked backlog without replaying it by event time.
پیگیری (سینیور): اگر کلیک قبل از جستجو برسد کدام سو کجا صبر میکند؟Follow-up (Senior): If the click arrives before the search, which side waits, and where?
هر دو: کلیک در لیست کلیک session میماند و جستجو با رسیدن probeاش میکند — بافر دقیقاً دوسویه است تا هیچ ترتیبی خاص نباشد. دمو q2×c2 را join میکند با اینکه c2 اول رسیده.
Both: the click waits in the session's click list and the search probes it on arrival — the buffer is two-sided precisely so neither order is special. The demo joins q2×c2 although c2 arrived first.
پیگیری (استف): اصلاحیهای بعد از crash دوباره تحویل میشود. متریک چطور exactly-once میماند؟Follow-up (Staff): A correction is redelivered after a crash. How does the metric stay exactly-once?
اصلاحیهها writeهای نسخهدار idempotent با کلید (session, window, version)اند: اصلاحیه بازپخششده همان نسخه را حمل میکند و همگرا میشود و مخرج سر بستن منجمد است تا بازپخشها بادش نکنند. exactly-once متریک به writeهای idempotent sink بهعلاوه بازپخش قطعی فروکاسته میشود — همان دو نگهبان تمرین ۱.
Corrections are idempotent versioned writes keyed by (session, window, version): a replayed correction carries the same version and converges, and the denominator is frozen at close so replays cannot inflate it. Exactly-once of the metric reduces to idempotent sink writes plus deterministic replay — the same two guards as Exercise 1.
اشتباهات رایجCommon mistakes
- بستن با processing time یا چند دقیقه ثابت بدون اندازهگیری: timeout صورت آزمایشگاه تا وقتی توزیع lateness جایگزینش شود فقط نگهدارنده جاست.
- join چندبهچند که هر جفت کلیک-جستجو را میشمارد: CTR میترکد چون یک جستجو با سه کلیک سه بار شمرده میشود. اولکلیک-میبرد (یا قاعده صریح اعلامشده) متریک را صادق نگه میدارد.
- بیخیال شدن بیصدا از دیررسها: سهم انداختهشده یک متریک با هشدار است چون رشد آن یعنی بودجه lateness اشتباه بوده نه داده.
- پنداشتن watermark بهعنوان تضمین نیامدن دیررس: آن آستانه بستن است و کل ماشین drop-یا-اصلاح هست چون آستانه میتواند اشتباه باشد.
- Closing by processing time or by a fixed few minutes picked without measuring: the timeout in the lab brief is only a stand-in until the lateness distribution replaces it.
- A many-to-many join that counts every click-search pair: CTR explodes because one search with three clicks counts three times. First-click-wins (or an explicit stated rule) keeps the metric honest.
- Dropping laggards silently: the dropped share is a metric with an alert, because a growing late fraction means the lateness budget — not the data — was wrong.
- Treating the watermark as a guarantee that nothing late will come: it is a threshold for closing, and the whole drop-or-correct machinery exists because the threshold can be wrong.
منابع:Sources: Kafka 4.1 · design and delivery semantics · Atlassian · engineering interview handbook
33 تمرین ۳ — آوردنِ ایندکسِ تازه با CDC بدونِ قطعیExercise 3 — bringing a fresh index with CDC, outage-free
بازگشت به صورت تمرینBack to the exercise · ارزیابی و سطحAssessment and level
پاسخ ۶۰ثانیهایThe 60-second answer
صورت مسئله و فرضهاProblem and assumptions
- پایگاه داده مرجع تراکنشی با لاگ replication منطقی (آموزشی و شبه Postgres) و بروکر لاگمحور با compaction و retention. مقیاس آموزشی: ۵۰M سطر، snapshot مهارشده با ۲۰k سطر بر ثانیه و WAL با نگهداری ۳ روز — اعداد آموزشیاند نه بنچمارک.
- ایندکسر idempotent است و stateای ندارد که مرجع نتواند بازسازیاش کند؛ مصرفکنندههای پایین ایندکس اصلاحیههای نسخهدار را تاب میآورند ولی خود ایندکس باید به مرجع همگرا شود.
- برای شروع از snapshot پشتیبانیشدهٔ connector استفاده میکنیم. انتقال snapshot به replica فقط با پشتیبانی نسخه و هماهنگی snapshot/LSN مجاز است؛ بار replica و WAL همچنان هزینه دارند.
- A transactional record database with a logical replication log (teaching Postgres-like) and a log-based broker with compaction and retention. Teaching scale: 50M rows, throttled snapshot at 20k rows/s, WAL retained 3 days — teaching numbers, not benchmarks.
- The indexer is idempotent and owns no state the record cannot rebuild; consumers downstream of the index tolerate versioned corrections but the index itself must converge to the record.
- Begin with the connector-supported snapshot protocol. Replica snapshots require version support and coordinated snapshot/LSN boundaries; replica load and WAL still cost resources.
چرا این روش و این عددها؟Why this method and these numbers?
۵۰میلیون سطر ÷۲۰هزار سطر/ثانیه =۲۵۰۰ ثانیه، حدود ۴۱٫۷ دقیقه؛ این حد پایین آموزشی بدون overhead و تغییرهای همزمان است. سه روز WAL فقط وقتی کافی است که زمان snapshot+قطعی+catch-up+حاشیه از آن کمتر باشد و حجم دیسک هم پاسخ دهد. snapshot هزینهٔ IO و نگهداری نسخه دارد حتی روی replica. مرز LSN از پروتکل snapshot سازگار میآید؛ event_id تکرار همان رویداد را میگیرد و entity_version جلوی عقبرفتن state را. compaction برحسب key کار میکند و جای snapshot کاملِ مرجع یا تاریخ وقایع را نمیگیرد.
50M rows / 20k rows/s = 2500 seconds, about 41.7 minutes, a teaching lower bound before overhead and concurrent changes. Three-day WAL retention suffices only if snapshot + outage + catch-up + margin fit and disk capacity supports it. Snapshots cost I/O and version retention even on replicas. Derive LSN boundaries through a consistent snapshot protocol. Event IDs dedup events; entity versions prevent state regression. Key-based compaction replaces neither complete record snapshots nor event history.
مدل ذهنیMental model
حل گامبهگامStep by step
- یک تراکنش، قرارداد پایدار business و outbox را یکجا ثبت کن؛ payload نسخهدار، event_id، entity_key و entity_version دارند. تغییر ستون داخلی فقط وقتی بیرون را نمیشکند که تبدیل payload سازگار نیز در همان rollout حفظ شود.
- snapshot با مرز snapshot و مرز B را با پروتکل رسمی connector هماهنگ کن؛ یک SELECT عادی با LSN خواندهشده در زمانی دیگر کافی نیست. نرخ snapshot را زیر بودجهٔ IO و lag مهار کن.
- catch-up با dedup پس از نصب snapshot، تغییرهای از مرز سازگار را catch-up کن؛ event_id تکرار رویداد و entity_version بازپخش کهنه را مهار میکنند. در یک state snapshot نمیدانیم کدام event_idهای قبلی حضور دارند؛ پس dedup شناسه بهتنهایی snapshot overlap را حل نمیکند.
- سه قرارداد، اعلامشده سه قرارداد را جدا نگه دار: snapshot یکبارمصرف، تاپیک append-only رویداد با کلید event id زیر retention، و تاپیک فشرده state با کلید entity key که tombstoneها تا مشاهده میمانند. دفترچه پاره هرگز seed نمیکند.
- پذیرش با kill ـه test با kill اثباتش کن: ایندکسر را وسط catch-up نگه دار، ادامه بده و ایندکس را در برابر مرجع در مرز اعلامشده checksum بگیر؛ نگهداری WAL را پهنتر از کندترین bootstrap نگه دار و عکس را مهار کن وگرنه بلند re-snapshot بگیر.
- One transaction, stable contract Commit business and outbox together with versioned payload, event ID, entity key and entity version. Internal column changes preserve consumers only when the compatible payload mapping is maintained during rollout.
- Snapshot with a boundary Coordinate snapshot and boundary B through the supported connector protocol; an ordinary SELECT with an independently sampled LSN is insufficient. Throttle snapshot work against I/O and lag budgets.
- Catch up with dedup After installing the snapshot, catch up from the consistent boundary. Event IDs dedup repeated events; entity versions reject stale replay. A state snapshot does not list historical event IDs, so event-ID dedup alone cannot resolve snapshot overlap.
- Three contracts, stated Keep three contracts apart: the one-off snapshot, the append-only event topic keyed by event id under retention, and the compacted state topic keyed by entity key where tombstones linger until observed. A purged diary never seeds.
- Acceptance by kill test Prove it with a kill: stop the indexer mid-catch-up, resume, and checksum index versus record at a stated boundary; keep WAL retention wider than the slowest bootstrap and throttle the photo, or re-snapshot loudly.
طراحی مشخصConcrete design
outbox(event_id UUID PK,entity_key,entity_version,type,payload) همراه سطر business commit میشود. رویدادها در این طرح با entity_key پارتیشن میشوند تا ترتیب همان entity حفظ شود و event_id در payload/header برای dedup است؛ کلید event_id هم ممکن است اما ترتیب entity را نیازمند پروتکل جدا میکند. state topic اختیاری با entity key و null tombstone، آخرین state هر entity را نگه میدارد. compaction روی کلید یکتای هر event معمولاً فضا کم نمیکند.
snapshot کاملِ مرجع و مرز B باید از یک پروتکل سازگار بیایند؛ snapshot خودِ outbox purgeشده state کامل نیست. پس از bulk-load، تغییرها بهترتیب با version guard اعمال میشوند. retention لاگ باید snapshot+قطعی+catch-up را با حاشیه پوشش دهد؛ اگر تاریخ لازم از دست رفت، بازسازی صریح لازم است. checksum فقط بین stateهای یک مرز معتبر مقایسه میشود.
Commit outbox(event_id UUID PK,entity_key,entity_version,type,payload) with business rows. This design partitions events by entity_key to preserve entity order and carries event_id in payload/headers for dedup. Event-ID keys are also possible but require separate entity-order handling. An optional compacted entity-keyed state topic uses null tombstones. Compaction over unique event keys usually saves no space.
Obtain the complete record snapshot and boundary B through one consistent protocol; a snapshot of a purged outbox is incomplete state. Bulk-load, then apply ordered changes under version guards. Retention must cover snapshot + outage + catch-up with margin; missing history requires explicit rebuilding. Compare checksums only at a valid shared boundary.
مثال محاسبهشدهWorked example
با فرض آموزشی ۵۰میلیون سطر و ۲۰هزار سطر/ثانیه، حد پایین snapshot برابر ۲۵۰۰ ثانیه یا ۴۱٫۷ دقیقه است. overhead و IO و catch-up آن را افزایش میدهند؛ اثر روی سرویس باید اندازهگیری شود. سه روز retention حدود ۱۰۳٫۷ برابر این حد پایین است، اما تضمین پوشش همهٔ خرابیها نیست. دمو فقط snapshot اتمیکِ درونحافظهای و crash منطقی را مدل میکند؛ نرخ آن benchmark پایگاه داده نیست.
At the teaching 50M rows and 20k rows/s, snapshotting takes at least 2500 seconds or 41.7 minutes. Overhead, I/O and catch-up add time; measure service impact. Three-day retention is about 103.7 times that lower bound, not protection against every outage. The demo models an atomic in-memory snapshot and logical crash only; its throughput is not a database benchmark.
دانلود نمودار مستقلDownload standalone diagram
- سطر اصلی: مرجع OLTP ترتیب write را مشخص میکند، outbox آن را در همان تراکنش حمل میکند و رله رویدادهای کلیددار منتشر میکند.
- مسیرهای کمکی: snapshot مهارشده در LSN ـه B seed میکند و تاپیک فشرده state اسکن کامل و تعمیر پیشنهاد میدهد.
- سرو: ایندکسر با event id ـه dedup میکند، upsertها و حذفهای tombstone را اعمال میکند و فقط وقتی lag نزدیک صفر است سرو میکند.
- Main row: the OLTP record decides write order, the outbox carries it in the same transaction, and the relay publishes keyed events.
- Helper paths: a throttled snapshot seeds at LSN B while the compacted state topic offers full-state scans and repair.
- Serve: the indexer dedups by event id, applies upserts and tombstone deletes, and serves only when lag is near zero.
نقطهٔ تصمیم: نقطه تصمیم سطر outbox است: کسبوکار و رویداد با هم commit میشوند تا ترتیب پاییندست همان ترتیب خود رهبر باشد.
رد خرابی: پس از توقف ایندکسر، تغییرها از checkpoint معتبر با event_id و version guard بازپخش میشوند. اگر WAL موردنیاز حذف شده باشد بازسازی لازم است. حذف ستون داخلی فقط وقتی سازگار است که تولید payload عمومی نیز حفظ شود؛ صرف وجود outbox کافی نیست.
تضمین نشاندادهشده: سه قرارداد snapshot، رویداد و state را جدا نشان میدهد. snapshot/LSN باید با پروتکل پشتیبانیشده هماهنگ شود؛ replica روی شکل یک گزینهٔ مشروط به نسخه است. figure تضمین فشار صفر یا قطعی صفر نمیدهد.
تضمین نشانندادهشده: فرض میکند نگهداری WAL ـه bootstrap را میپوشاند؛ وقتی نپوشاند مسیر snapshot باید دوباره بدود — در رانبوک گفته شده نه اینجا کشیده.
Decision point: The decision point is the outbox row: business and event commit together, so downstream order is the leader's own order.
Failure trace: After an indexer pause, replay from a valid checkpoint with event-ID dedup and version guards. Missing WAL requires rebuilding. Internal column removal is compatible only while public payload generation remains compatible; an outbox alone is insufficient.
Guarantee shown: Separates snapshot, event and state contracts. Snapshot/LSN alignment needs a supported protocol; replica reads in the figure are a version-dependent option. The diagram does not prove zero load or zero downtime.
Not shown: It assumes WAL retention covers the bootstrap; when it does not, the snapshot path must run again — said in the runbook, not drawn here.
خودآزماییSelf-check
فردا ستونی از جدول داخلی محصولات حذف میشود. کدام بخش این پایپلاین متوجه میشود و کدام نه؟
پاسخ: جدول outbox با اسکیمای پایدار خودش: رله آن را میخواند نه جدولهای دامنه را، پس ستونهای داخلی آزادانه میتوانند حذف شوند.
Tomorrow a column drops from the internal products table. Which part of this pipeline notices, and which does not?
Answer: The outbox table with its own stable schema: the relay reads it, never the domain tables, so internal columns can drop freely.
دانلود نمودار مستقلDownload standalone diagram
- عکس: snapshot مهارشده replica مرز B را ثابت میکند بدون اینکه رهبر export را حس کند.
- دفترچه: رویدادهای outbox بعد از B با dedup ـه event-ID جاری میشوند و همپوشانی snapshot را ذاتاً جذب میکنند.
- اثبات: checksumها در مرز اعلامشده، kill وسط کار با ادامه و test حذف ستون که سبز میماند.
- Photograph: a throttled replica snapshot fixes boundary B without the leader noticing the export.
- Diary: outbox events after B stream in with event-ID dedup, absorbing the snapshot overlap by construction.
- Proof: checksums at a stated boundary, a kill mid-work with resume, and a schema-drop test that stays green.
نقطهٔ تصمیم: نقطه تصمیم LSN ثبتشده است: همه قبل آن از عکس میآید و همه بعد آن از دفترچه و همپوشانی dedup میشود نه اعتماد.
رد خرابی: پس از توقف ایندکسر، تغییرها از checkpoint معتبر با event_id و version guard بازپخش میشوند. اگر WAL موردنیاز حذف شده باشد بازسازی لازم است. حذف ستون داخلی فقط وقتی سازگار است که تولید payload عمومی نیز حفظ شود؛ صرف وجود outbox کافی نیست.
تضمین نشاندادهشده: سه قرارداد snapshot، رویداد و state را جدا نشان میدهد. snapshot/LSN باید با پروتکل پشتیبانیشده هماهنگ شود؛ replica روی شکل یک گزینهٔ مشروط به نسخه است. figure تضمین فشار صفر یا قطعی صفر نمیدهد.
تضمین نشانندادهشده: بودجه snapshot را برای کاتالوگ خواننده اندازه نمیکند — ۲۰k سطر بر ثانیه اینجا نرخ آموزشی است و per-database دوباره سنجیده میشود.
Decision point: The decision point is the recorded LSN: everything before it comes from the photo, everything after from the diary, and the overlap is deduped, never trusted.
Failure trace: After an indexer pause, replay from a valid checkpoint with event-ID dedup and version guards. Missing WAL requires rebuilding. Internal column removal is compatible only while public payload generation remains compatible; an outbox alone is insufficient.
Guarantee shown: Separates snapshot, event and state contracts. Snapshot/LSN alignment needs a supported protocol; replica reads in the figure are a version-dependent option. The diagram does not prove zero load or zero downtime.
Not shown: It does not size the snapshot budget for the reader's catalog — 20k rows/s here is a teaching rate, re-measured per database.
خودآزماییSelf-check
bootstrap از نگهداری WAL طولانیتر شده و LSN مرز منقضی شده. حالا چه؟
پاسخ: بلند re-snapshot بگیر: LSN از WAL رفته پس هیچ دفترچهای شکاف را نمیپوشاند — حدس زدن تاریخ جعل میکرد. فقط عکس مهارشده تازه بهعلاوه جاریسازی از مرز تازه مسیر صادق است.
The bootstrap takes longer than WAL retention and the boundary LSN has expired. What now?
Answer: Re-snapshot loudly: the LSN is gone from WAL, so no diary covers the gap — guessing would invent history. A fresh throttled photo plus streaming from the new boundary is the only honest path.
دانلود SVG مستقلDownload standalone SVG
- ستون snapshot: کپی سازگار یکبارمصرف در LSN ـه B، مهارشده و بعد از catch-up انداختهشده — دقیقاً یک bootstrap را seed میکند.
- ستون رویداد: دفترچه append-only با کلید event id و retention؛ tombstoneها رویدادهای کوتاهاند؛ صفحههای پاره چیزی seed نمیکنند.
- ستون state: دفتر تلفن فشرده با کلید entity key؛ tombstoneها تا مشاهده میمانند؛ تا وقتی نگه داشته شده تازهوارد seed میکند.
- Snapshot column: a one-off consistent copy at LSN B, throttled, dropped after catch-up — seeds exactly one bootstrap.
- Event column: append-only diary keyed by event id with retention; tombstones are brief events; purged pages seed nothing.
- State column: compacted phone book keyed by entity key; tombstones linger until observed; seeds newcomers while retained.
نقطهٔ تصمیم: نقطههای تصمیم سطرهای کلیدند: event id در برابر entity key مشخص میکند کدام تاپیک میتواند فشرده شود و کدام seed کند.
رد خرابی: پس از توقف ایندکسر، تغییرها از checkpoint معتبر با event_id و version guard بازپخش میشوند. اگر WAL موردنیاز حذف شده باشد بازسازی لازم است. حذف ستون داخلی فقط وقتی سازگار است که تولید payload عمومی نیز حفظ شود؛ صرف وجود outbox کافی نیست.
تضمین نشاندادهشده: سه قرارداد snapshot، رویداد و state را جدا نشان میدهد. snapshot/LSN باید با پروتکل پشتیبانیشده هماهنگ شود؛ replica روی شکل یک گزینهٔ مشروط به نسخه است. figure تضمین فشار صفر یا قطعی صفر نمیدهد.
تضمین نشانندادهشده: درباره نرخ throttle ـه snapshot یا پذیرش kill چیزی نمیگوید — آنها در شکل رانبوک بالا زندگی میکنند.
Decision point: The decision points are the key rows: event id versus entity key decides which topic may compact and which may seed.
Failure trace: After an indexer pause, replay from a valid checkpoint with event-ID dedup and version guards. Missing WAL requires rebuilding. Internal column removal is compatible only while public payload generation remains compatible; an outbox alone is insufficient.
Guarantee shown: Separates snapshot, event and state contracts. Snapshot/LSN alignment needs a supported protocol; replica reads in the figure are a version-dependent option. The diagram does not prove zero load or zero downtime.
Not shown: It says nothing about the snapshot throttle rate or the kill acceptance — those live in the runbook figure above.
خودآزماییSelf-check
همه سطرهای outbox قدیمیتر از یک هفته purge شدهاند. مصرفکننده کاملاً تازه state کامل را از کجا میگیرد؟
پاسخ: تاپیک فشرده entity-state تا وقتی هنوز هر کلید را نگه داشته یا snapshot مهارشده تازه — هرگز outbox پاکشده که صفر رویداد بازپخش میکند.
Every outbox row older than a week is purged. Where does a brand-new consumer get complete state?
Answer: The compacted entity-state topic while it still retains every key, or a fresh throttled snapshot — never the purged outbox, which replays zero events.
گزینههای جایگزینAlternatives
- CDC مستقیم جدول بدون outbox (Debezium روی جدولهای دامنه): روز اول با صفر تغییر کد میبرد؛ لحظهای که ستون داخلی حذف شود میبازد چون اسکیمای داخلی حالا API عمومی است و مصرفکننده پروداکشن روبهمشتری را میشکند.
- نوشتن دوگانه از کد اپ (اول پایگاه داده بعد ایندکس): روی هر دو خطای §۴.۱ میبازد — مسابقه ترتیب بدون هیچ خطایی و جفت نیمcommitشده. اینجا فقط بهعنوان آنتیپترنی که outbox جایگزینش میشود نگه داشته شده.
- ETL دستهای دورهای به ایندکس: روی سادگی و کاتالوگهای کوچک میبرد؛ تازگی را میبازد (کهنه تا اجرای بعد) و هر اجرا تاریخ را بیهوده بازپخش میکند.
- Direct table CDC without an outbox (Debezium on the domain tables): wins on day one with zero code changes; loses the moment an internal column drops, because the internal schema is now public API and a production consumer breaks customer-faced.
- Dual writes from application code (database, then index): loses on both faults of §4.1 — the ordering race with no error at all, and the half-committed pair. Kept here only as the anti-pattern the outbox replaces.
- Periodic batch ETL into the index: wins on simplicity and on tiny catalogs; loses freshness (stale until the next run) and replays history needlessly on every run.
سناریوهای خرابیFailure scenarios
- شکاف snapshot/استریم: snapshot ـه LSN ـه B را ثبت میکند و جاریسازی از همانجا شروع میشود، پس چیزی جا نمیماند؛ همپوشانی بازپخش میشود و dedup ـه event-ID جذبش میکند (دمو: e3 دوبار اعمال میشود بیاثر).
- تکراری snapshot/استریم: همان همپوشانی و همان dedup — نگهبان event id است، پس بازپخش دوباره مرز همگرا میشود بهجای دو برابر شدن.
- kill وسط bootstrap: ایندکسر از checkpoint ادامه میدهد، بازپخش و dedup میکند و checksum مرز همگرایی را اثبات میکند (بخش ۲ دمو: یک skip و match درست).
- حذف ستون داخلی: رله قرارداد outbox را میخواند نه جدولهای دامنه را، پس پایپلاین سبز میماند و هیچ مصرفکنندهای page نمیشود.
- گم شدن tombstone قبل از مشاهده همه دنبالهروها: حذف روی عقبمانده زنده میشود، پس نگهداری tombstone باید از lag کندترین دنبالهرو بهعلاوه حاشیه بیشتر باشد.
- purge ـه outbox قبل از رسیدن تازهوارد: دفترچه نمیتواند seed کند، پس تازهوارد تاپیک فشرده entity-state را میخواند یا snapshot مهارشده تازه میگیرد — بلند تصمیم گرفته میشود نه با حدس.
- فشار بیکران snapshot: خواندنها از replica با نرخ سقفی و بودجه اعلامشده میآیند (آموزشی ۲۰k سطر بر ثانیه) و بودجه منفجرشده snapshot را کند میکند — هرگز رهبر را نه.
- Snapshot/stream gap: the snapshot records LSN B and streaming starts there, so nothing is missed; the overlap replays and event-ID dedup absorbs it (demo: e3 applies twice, harmlessly).
- Snapshot/stream duplicate: same overlap, same dedup — the guard is the event id, so replaying the boundary twice converges instead of doubling.
- Kill mid-bootstrap: the indexer resumes from its checkpoint, replays, dedups, and the boundary checksum proves convergence (demo part 2: one skip, match true).
- Internal column dropped: the relay reads the outbox contract, never the domain tables, so the pipeline stays green and no consumer pages.
- Tombstone lost before every follower observed it: the delete resurrects on the laggard, so tombstone retention must exceed the slowest follower's lag plus margin.
- Outbox purged before a newcomer arrives: the diary cannot seed, so the newcomer reads the compacted entity-state topic or triggers a fresh throttled snapshot — decided loudly, never by guessing.
- Unbounded snapshot pressure: reads come from a replica at a capped rate with a stated budget (teaching 20k rows/s), and a blown budget slows the snapshot — never the leader.
کد قابل اجرا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.*;
// Single-thread, in-memory teaching model. Snapshot+LSN is atomic by construction.
// A real connector must supply a consistent snapshot/log-boundary protocol.
// No real Kafka, database throttling or durable process crash is tested here.
public class CdcBootstrap {
record Event(String eventId, String key, String value, long version) {}
Map<String,String> record=new LinkedHashMap<>(), index=new LinkedHashMap<>();
Map<String,Long> versions=new HashMap<>(); // includes tombstone versions
List<Event> events=new ArrayList<>();
Set<String> dedup=new HashSet<>();
long lsn=0, snapshotBoundary=-1;
void write(String key,String value) {
lsn++;
if(value==null) record.remove(key); else record.put(key,value);
events.add(new Event("e"+lsn,key,value,lsn));
}
void snapshot() {
// No concurrent writes in this model: source state and boundary agree.
snapshotBoundary=lsn;
index.clear(); index.putAll(record); versions.clear(); dedup.clear();
for(String key:index.keySet()) versions.put(key,snapshotBoundary);
}
void apply(Event event) {
// A snapshot has state, not old event IDs; guard old overlap by boundary.
if(event.version<=snapshotBoundary || dedup.contains(event.eventId)) return;
if(event.version>versions.getOrDefault(event.key,-1L)) {
if(event.value==null) index.remove(event.key); else index.put(event.key,event.value);
versions.put(event.key,event.version);
}
dedup.add(event.eventId); // apply+version+dedup are one modeled sink step
}
void catchUp(int stopAfter) {
int applied=0;
for(Event event:events) {
if(event.version<=snapshotBoundary || dedup.contains(event.eventId)) continue;
apply(event);
if(++applied==stopAfter) return; // simulated consumer pause
}
}
void check(boolean condition) { if(!condition) throw new AssertionError(); }
public static void main(String[] args) {
CdcBootstrap c=new CdcBootstrap();
c.write("p1","red boots"); c.write("p2","blue coat"); c.write("p3","green hat");
c.snapshot(); // B=3
c.write("p2","navy coat"); c.write("p3",null);
c.catchUp(1); // pause with only e4 applied, then resume from modeled sink state
c.check(!c.index.equals(c.record));
c.catchUp(Integer.MAX_VALUE); c.check(c.index.equals(c.record));
c.apply(c.events.get(2)); // old pre-snapshot row must not resurrect deleted p3
c.check(!c.index.containsKey("p3"));
c.catchUp(Integer.MAX_VALUE); c.check(c.index.equals(c.record));
c.write("p1","v6"); Event old=c.events.get(c.events.size()-1);
c.write("p1","v7"); Event newer=c.events.get(c.events.size()-1);
c.apply(newer); c.apply(old); c.apply(newer);
c.check(c.index.get("p1").equals("v7"));
c.check(c.index.equals(c.record));
Map<String,String> stateTopic=new HashMap<>(c.record); // full current-state model
List<Event> purgedHistory=List.of();
c.check(purgedHistory.isEmpty() && !c.record.isEmpty());
c.check(stateTopic.equals(c.record));
System.out.println("PASS snapshot B=3, pause/resume, duplicate, stale overlap, version regression, tombstone");
System.out.println("PASS purged events cannot seed full state; index="+c.index);
}
}
اجرا: javac CdcBootstrap.java && java CdcBootstrap — فقط استاندارد جاوا و فوری. لاگ و تاپیکها و snapshot مدلهای درونحافظهاند: این منطق مرز/dedup/قرارداد را اثبات میکند نه استقرار Debezium/Kafka را.
خروجی مورد انتظار: PASS برای snapshot در B=3، توقف/ادامه، تکرار، همپوشانی کهنه، عقبرفتن نسخه و tombstone؛ ایندکس نهایی {p1=v7, p2=navy coat}. این مدل در حافظه است و آزمون crash پایگاه داده یا بروکر نیست.
Run: javac CdcBootstrap.java && java CdcBootstrap — stdlib Java only, instant. The log, topics and snapshot are in-memory models: this proves the boundary/dedup/contract logic, not a Debezium/Kafka deployment.
Expected output: PASS snapshot B=3, pause/resume, duplicate, stale overlap, version regression, tombstone; PASS purged events cannot seed full state; index={p1=v7, p2=navy coat}. This is an in-memory model, not a broker/database crash test.
آزمون صحت و معیار عملیاتیCorrectness checks and operational criteria
پذیرش: ایندکسر را وسط catch-up بکش و lag-تا-صفر بهعلاوه تطبیق checksum مرز را بخواه؛ ستون داخلی حذف کن و سبز ماندن پایپلاین را بخواه؛ outbox را purge کن و بخواه رانبوک تازهوارد آن را بهعنوان seed رد کند. متریکها: lag سرتاسری، نرخ خواندن snapshot در برابر بودجه، سن tombstone در برابر کندترین دنبالهرو و نرخ skip ـه dedup. گامهای واقعی Debezium/Kafka اینجا فقط راهنما هستند.
Acceptance: kill the indexer mid-catch-up and require lag-to-zero plus a boundary checksum match; drop an internal column and require the pipeline to stay green; purge the outbox and require the newcomer runbook to refuse it as seed. Metrics: end-to-end lag, snapshot read rate vs budget, tombstone age vs slowest follower, dedup-skip rate. Real Debezium/Kafka steps are guide-only here.
پاسخ سؤالهای پیگیریFollow-up answers
پیگیری (استف): outbox قدیمی purge شده؛ مصرفکنندهٔ جدید از کجا state کامل را میسازد؟Follow-up (Staff): Old outbox rows have been purged. Where does a new consumer get complete state?
تاپیک فشرده entity-state تا وقتی هر کلید را نگه داشته یا snapshot مهارشده تازه. هرگز outbox پاکشده: retention آن صفحهها را انداخته، پس بازپخش آن تاریخ پنیرسوییسی میسازد. شکل نوار دقیقاً هست تا این اشتباه ناشدنی شود.
The compacted entity-state topic while it retains every key, or a fresh throttled snapshot. Never the purged outbox: retention already dropped those pages, so replaying it rebuilds a Swiss-cheese history. The strip figure exists to make exactly this mistake unmakeable.
پیگیری (سینیور): کدامیک از دو تاپیک باید log-compact شود و چرا فشرده کردن دیگری غلط است؟Follow-up (Senior): Which of the two topics should be log-compacted, and why is compacting the other one wrong?
در این طرح state topic با entity key از compaction سود میبرد چون آخرین state را میخواهیم. event topic باید تمام وقایع داخل retention را حفظ کند؛ compaction با entity key میتواند history لازم را حذف کند. اگر کلید event_id یکتا باشد compaction معمولاً چیزی حذف نمیکند، نه اینکه history را خودکار از بین ببرد.
The entity-keyed state topic benefits from compaction because its contract needs latest state. Event history must retain every event in the supported window; entity-key compaction can erase that history. With unique event-ID keys, compaction generally removes nothing rather than automatically deleting history.
پیگیری (استف): دو سازنده ایندکس هنگام مهاجرت همزمان میدوند. کی خواندن سرو میکند و با kill چه میماند؟Follow-up (Staff): Two index builders run at once during a migration. Who serves reads, and what survives a kill?
دو ایندکسر میتوانند همزمان بسازند (ایندکس آبی-سبز) ولی بهازای هر alias فقط یکی مالک سرو خواندن است و هر دو مستقل checkpoint میگیرند: kill یکی دیگری را عقب نمیبرد. cutover بعد از checksum مرز alias را میچرخاند و rollback برمیگرداند — سوییچ اتمیک alias است نه داده.
Two indexers may build concurrently (blue-green index), but only one owns serving reads per alias, and both checkpoint independently: a kill on one never rewinds the other. Cutover flips the alias after the boundary checksum, and rollback flips it back — the alias, not the data, is the atomic switch.
اشتباهات رایجCommon mistakes
- seed کردن تازهوارد از outbox پاکشده: retention آن صفحهها را انداخته، پس بازپخش تاریخ ناقص میسازد و کلیدهای گمشده بیصدا شکست میخورند. outbox دفترچه است نه دفتر تلفن.
- فشرده کردن تاپیک رویداد outbox با کلید entity برای «صرفهجویی جا»: تاریخای را نابود میکند که مصرفکنندههای دیگر میخواهند و یک کپی per-entity نگه میدارد — بدترین هر دو قرارداد.
- snapshot بدون ثبت مرز LSN: استریم بعد از حدس شروع میشود و سؤال شکاف-یا-تکراری جواب صادق ندارد.
- خواندن جدولهای داخلی با CDC انگار قرارداد پایدارند: اولین ستون حذفشده نصفشب به مصرفکننده پروداکشن page میزند.
- فرض اینکه فشار snapshot صفر است: export انبوه مهارنشده با رهبر رقابت میکند و قطعیای که میسازد خودزده است.
- Seeding a newcomer from the purged outbox: retention dropped those pages, so replay rebuilds partial history and the missing keys fail silently. The outbox is a diary, not a phone book.
- Compacting the outbox event topic by entity key to 'save space': it destroys the history other consumers need while keeping one copy per entity — the worst of both contracts.
- Snapshotting without recording the LSN boundary: the stream then starts from a guess, and the gap-or-duplicate question has no honest answer.
- Reading internal tables through CDC as if they were a stable contract: the first dropped column pages a production consumer at night.
- Assuming snapshot pressure is zero: an unthrottled bulk export contends with the leader, and the outage it causes is self-inflicted.
منابع:Sources: Debezium · outbox event router · Debezium · PostgreSQL snapshot and streaming · Kafka 4.1 · design and delivery semantics