هیچ پیام منتشرشدهای از دست نمیرود
Offset فقط پس از Commit موفق PostgreSQL جابهجا میشود. پیامی که خوانده شده اما نوشته نشده، هرگز بهعنوان «پردازششده» علامت نمیخورد.
مکانیزم:enable_auto_commit=False + ترتیب نوشتن پیش از Commit
مصرف سه تاپیک Kafka و نوشتن ماندگار آنها در PostgreSQL. این تنها نقطهای در پروژه است که داده از حالت «در حال عبور» به حالت «ماندگار و قابل کوئری» تبدیل میشود — و همین، سطح سختگیری معماری آن را تعیین میکند.
این مستند معماری، فلوی اجرا، تضمینهای دادهای و تصمیمات مهندسی زیرسیستم Storage Consumer را شرح میدهد. این زیرسیستم سه تاپیک app-stats، reviews و network-metrics را از Kafka مصرف میکند و آنها را در PostgreSQL مینویسد.
نقش آن در پروژه یک نقش ویژه است و همین یک جمله، تفاوت سطح سختگیری آن با بقیهی زیرسیستمها را توضیح میدهد: اگر Crawler وسط کار Crash کند، همان اپ در دور بعدی دوباره خزش میشود و چیزی از دست نمیرود. اگر Network Analyzer شکست بخورد، همان فایل pcap دوباره تحلیل میشود. اما اگر Storage Consumer یک پیام را از دست بدهد، آن داده برای همیشه رفته است. تمام پیچیدگیهای این مستند — Commit دستی Offset، کلیدهای Idempotency، تراکنش واحد، و صف پیامهای مرده — پاسخ مستقیم همین یک نیازمندی هستند.
پیش از هر جزئیات فنی، کل طراحی این زیرسیستم را میتوان در چهار تضمین خلاصه کرد. هر تصمیم مهندسی در ادامهی این مستند، در خدمت یکی از این چهار مورد است.
Offset فقط پس از Commit موفق PostgreSQL جابهجا میشود. پیامی که خوانده شده اما نوشته نشده، هرگز بهعنوان «پردازششده» علامت نمیخورد.
مکانیزم:enable_auto_commit=False + ترتیب نوشتن پیش از Commit
هر جدول یک قید UNIQUE روی کلید طبیعی پیام دارد و هر نوشتن یک INSERT ... ON CONFLICT است. کافکا At-least-once میدهد؛ قیدها آن را به Effectively-once تبدیل میکنند.
پیام غیرقابلذخیره در جدول dead_letter_events ثبت میشود و Offset از رویش عبور میکند — پس قفلشدن ابدی یک Partition روی یک پیام معیوب (Poison Pill) غیرممکن است.
شکست هر Thread، تمام Threadهای دیگر را متوقف و فرآیند را با کد خروج غیرصفر خارج میکند. کانتینری با Uptime سالم و یک تاپیک بیصدا، از Crash بدتر است چون دیده نمیشود.
مکانیزم: Supervisor با قاعدهی همهیاهیچاین زیرسیستم انتهای زنجیرهی جمعآوری داده و ابتدای زنجیرهی تحلیل است. دو تولیدکنندهی مستقل (Crawler و Network Analyzer) روی سه تاپیک پیام مینویسند، و این زیرسیستم هر سه را به چهار جدول تحت مالکیت خودش تبدیل میکند.
یک Consumer واحد میتوانست به هر سه تاپیک Subscribe کند و کد سادهتری داشته باشد. این گزینه عمداً رد شد، و دلیل آن هیچ ارتباطی به سرعت ندارد — به «انصاف در تأخیر» مربوط است.
حجم بار این سه تاپیک بهشدت نامتوازن است. تاپیک reviews یک مسئلهی توان عملیاتی است: در هر دور خزش تا ۲۰۰٫۰۰۰ پیام در بازهی ۵ تا ۱۵ دقیقه منتشر میشود. در مقابل، network-metrics یک مسئلهی تأخیر است: روزی چند پیام، اما درست در لحظهای که کاربر یک تست شبکه اجرا کرده و منتظر دیدن نتیجه است. با یک Consumer واحد، آن چند پیام حساس به تأخیر پشت صف ۲۰۰٫۰۰۰ پیامی نقدها میماندند و دقیقهها معطل میشدند.
قلب این زیرسیستم یک حلقهی کوتاه است: poll ← پردازش ← commit. آنچه این حلقه را کوتاه نگه میدارد یک قاعده است: هیچ حالتی (State) از یک Poll به Poll بعدی منتقل نمیشود. نتیجهی این قاعده آن است که Rebalance شدن Consumer Group در میانهی یک بچ بهطور خودکار بیخطر است — بچ در جریان، نانوشته رها میشود و به هر عضوی که Partition را تحویل بگیرد دوباره تحویل داده میشود.
مهمترین خط این زیرسیستم، ترتیب دو عملیات است. enable_auto_commit عمداً خاموش است، چون Commit خودکار بر پایهی زمان کار میکند نه بر پایهی موفقیت نوشتن — یعنی میتواند Offset پیامی را جابهجا کند که هنوز در پایگاهداده ننشسته است. با Commit دستی و پس از نوشتن، تنها حالت خرابی ممکن، تحویل مجدد است، و تحویل مجدد بهلطف قیدهای UNIQUE بیخطر است.
Livelock بسیار سختتر از یک Crash تشخیص داده میشود، پس عمداً به Crash تبدیل شده است: پس از ۵ شکست پیاپی، فرآیند با خطا خارج میشود تا کانتینر Restart شود.کافکا تحویل At-least-once میدهد؛ تبدیل آن به Effectively-once وظیفهی این زیرسیستم است. مکانیزم، یک قید UNIQUE روی «کلید طبیعی» هر پیام است، بههمراه یک INSERT ... ON CONFLICT متناسب با معنای همان جدول. انتخاب بین DO NOTHING و DO UPDATE در هر جدول یک تصمیم معنایی است، نه سلیقهای.
| جدول | کلید Idempotency | رفتار در تعارض | استدلال |
|---|---|---|---|
app_stats | (application_id, crawled_at) | DO NOTHING | هر پیام با همان جفت کلید، طبق تعریف یک تحویل مجدد است — Crawler برای هر اپ در هر دور یک پیام با crawled_at دقیق (میکروثانیه) میفرستد، پس چیز تازهتری در پیام دوم نیست. خزش بعدی crawled_at جدید دارد و ردیف جدید میسازد؛ همین است که جدول را Append-Only نگه میدارد. |
reviews | (review_id) | DO UPDATE با نگهبان یکنوا | تنها جدولی که واقعاً بهروزرسانی میشود، چون thumbs_up_count یک نقد در طول زمان تغییر میکند. اما بهروزرسانی بیقید و شرط خطرناک است (بخش بعد). |
network_metrics | (analysis_id) | DO NOTHING | یک فایل pcap یکبار تحلیل میشود؛ همان analysis_id برای بار دوم یعنی تحویل مجدد، نه اصلاح. اجرای واقعی مجدد یک تست، UUID تازه و در نتیجه ردیف تازه تولید میکند. |
dead_letter_events | (topic, partition, kafka_offset) | DO NOTHING | مختصات یک رکورد در کافکا، هویت آن است. Replay یک Partition همان پیامهای خراب را دوباره رد میکند و این نباید ردیفها را چند برابر کند. |
جدول reviews تنها جدولی است که بهروزرسانی میشود، و همین آن را در معرض دو خرابی واقعی قرار میدهد که هر دو با یک شرط WHERE حل شدهاند.
first_seen_at هرگز بهروزرسانی نمیشودمقدار DEFAULT now() خودش را از لحظهی Insert نگه میدارد، تا معنایش «اولین بار که این نقد را دیدیم» بماند، مهم نیست چند بار بعد از آن همگامسازی مجدد شود.sentiment هرگز لمس نمیشوداین ستون به زیرسیستم آیندهی sentiment/ تعلق دارد. دست زدن به آن در اینجا یعنی هر بار که Crawler یک نقد را دوباره میخواند، نتیجهی تحلیل احساسات محاسبهشده پاک شود.اشتباه گرفتن این سه دسته با هم، دقیقاً همان جایی است که یک Consumer کافکا یا داده از دست میدهد یا تا ابد قفل میشود. به همین دلیل این تفکیک در کد صریح و آزمونشده است.
OperationalError و InterfaceError. همان بچ با Backoff نمایی تکرار میشود (۱s → ۲s → ۴s → ۸s → ۱۶s) و هرگز Dead-letter نمیشود.
MessageDecodeError یا package_name ثبتنشده. در جدول ثبت میشود، Offset عبور میکند و هرگز Retry نمیشود.
ConfigError، MigrationError و هر خطای پیشبینینشده. خروج با کد غیرصفر و Restart کانتینر از آخرین Offset ثبتشده.
psycopg2.OperationalError و kafka.errors.KafkaError اصلی باقی میمانند. بستهبندیکردنشان در یک Exception داخلی، فقط تشخیص «کدام مجموعه Retry میشود» را سختتر میکرد.پیامی که هرگز قابل ذخیره نیست (JSON نامعتبر، فیلد هویتی غایب، یا package_name ثبتنشده) در جدول dead_letter_events ثبت میشود. نکتهی کلیدی این طراحی، محل Commit است: این ثبت در همان تراکنشی انجام میشود که دادههای سالم همان بچ در آن نوشته میشوند.
این همان خصوصیتی است که یک تاپیک DLQ در کافکا نمیتوانست بدهد: با تراکنش واحد، غیرممکن است که Offset از روی یک پیام خراب عبور کند در حالی که رکورد آن پیام گم شده باشد. اگر تراکنش Rollback شود، هم داده و هم رکورد خطا با هم برمیگردند و بچ دوباره تحویل داده میشود.
BYTEAنه TEXT — دقیقاً به این دلیل که پیامی که بهخاطر UTF-8 نامعتبر رد شده هم باید قابل ذخیره باشد. Payloadهای بزرگتر از ۶۴ کیلوبایت بریده میشوند و این بریدگی در متن دلیل خطا ثبت میشود.پیامهای هر سه تاپیک package_name حمل میکنند، هرگز application_id. این تصمیم عمدی است: تولیدکنندگان (یک Scraper و یک تحلیلگر بستههای شبکه) نباید هویت پایگاهداده را بشناسند. تبدیل این دو به هم، وظیفهی این زیرسیستم است چون مالک اسکیما خودش است.
این خواندن مستقیماً از PostgreSQL انجام میشود، نه از طریق Endpoint مربوط به App API که دو زیرسیستم دیگر استفاده میکنند. استدلال: این زیرسیستم از قبل Credential پایگاهداده را دارد، این جستوجو یک کوئری روی یک Unique Index است، و گذاشتن یک فراخوانی HTTP روی مسیر داغِ نوشتن، فقط یک نقطهی شکست جدید اضافه میکرد بدون هیچ فایدهای.
| تصمیم | مقدار / رفتار | دلیل |
|---|---|---|
| یک کوئری برای کل بچ | WHERE package_name = ANY(%s) | برای ۵۰۰ نقد یک اپ، تفاوت بین یک Round-trip و ۵۰۰ Round-trip است |
| کش اختصاصی هر Thread | TTL پیشفرض ۳۰۰ ثانیه | مشترک کردن یک کش بین سه Thread، نیازمند Lock روی هر پیام بود تا در ساعتی یک کوئری صرفهجویی شود |
| عدم کش کردن نتایج ناموفق | Miss در کش ذخیره نمیشود | Negative Caching باعث میشد اپی که اپراتور همین حالا ثبت کرده، تا انقضای TTL دیده نشود |
بدون فیلتر is_active | اپ غیرفعال هم application_id میگیرد | دادهای که از قبل تولید شده باید ذخیره شود؛ Soft Delete یک حکم دربارهی «چه چیزی را بعداً خزش کن» است، نه دربارهی دادهی تاریخی |
| اپ ثبتنشده = Dead Letter | بدون raise، فقط ردشدن همان پیام | قفل کردن Partition تا زمانی که کسی اپ را ثبت کند، ingestion تمام اپهای دیگر را هم متوقف میکرد |
یک پایگاهداده، اما مالکیت جدولها تفکیکشده است. جنگو (App API) مالک جدول applications است و این زیرسیستم مالک چهار جدول خودش. این چهار جدول هیچ Model جنگویی ندارند و با فایلهای شمارهگذاریشدهی SQL ساخته میشوند.
هر جدول یک Repository دارد که فقط در سه چیز با بقیه تفاوت دارد: نام جدول، فهرست ستونها، و متن ON CONFLICT. تمام منطق مشترک — تبدیل کل بچ به یک دستور INSERT چندردیفی و شمارش نتیجه — یکجا نوشته شده است.
Repositoryها هیچ اتصالی به پایگاهداده نگه نمیدارند؛ Cursor از بیرون (توسط Worker) به آنها داده میشود. همین نکته است که اجازه میدهد نوشتن داده و ثبت پیامهای مرده در یک تراکنش واحد قرار بگیرند.
| جزئیات پیادهسازی | توضیح |
|---|---|
execute_values با page_size=1000 | بزرگتر از بزرگترین max_poll_records (۵۰۰ برای نقدها)، پس یک بچ در حالت معمول یک Round-trip است |
RETURNING (xmax = 0) AS inserted | Postgres مقدار xmax را برای ردیف تازه Insert شده صفر و برای ردیفی که DO UPDATE جایگزینش کرده غیرصفر میگذارد. همین است که به یک خط لاگ اجازه میدهد در یک نگاه بگوید «این Backfill است یا وضعیت پایدار» |
شمارندهی skipped | ردیفهایی که پایگاهداده نپذیرفت: تعارض کلید در DO NOTHING، یا بهروزرسانیای که نگهبان یکنوا رد کرد. مقدار مداوماً غیرصفر یعنی چیزی در حال Replay است |
dedupe_by_key پیش از نوشتن | یک الزام است، نه بهینهسازی: Postgres یک ON CONFLICT DO UPDATE که دو بار همان ردیف را در یک دستور لمس کند رد میکند و کل تراکنش را Abort میکند |
Decoderها فقط ساختار پیام را بررسی نمیکنند؛ آنها محدودیتهای ستون پایگاهداده را هم در همان لحظه اعمال میکنند: بازهی SMALLINT و INTEGER و BIGINT، طول VARCHAR، معتبر بودن UUID، و مقادیر NaN و Inf.
یک تصمیم مرتبط دیگر: کتابخانهی google-play-scraper فیلد زمانی at را بدون Timezone (Naive) تولید میکند، در حالی که تمام ستونهای زمانی این زیرسیستم TIMESTAMPTZ هستند. سیاست صریح این است که زمانهای Naive در همین لایه به UTC نسبت داده شوند، تا هیچ ابهامی به پایگاهداده نرسد.
تمام تنظیمات در یک کلاس Settings واحد و frozen جمع شدهاند که در لحظهی شروع Validate میشود. مقدار محیطی نامعتبر، بلافاصله برنامه را با پیام واضح متوقف میکند، نه ساعتها بعد وسط یک بچ. این کلاس Immutable و Slotted است چون تنها شیئی است که بین سه Thread مشترک است (بهجز رخداد توقف) و همین Immutable بودن است که مدل Share-Nothing را بدون Lock ایمن میکند.
| تنظیم | مقدار پیشفرض | دلیل انتخاب |
|---|---|---|
enable_auto_commit | False | Commit خودکار بر پایهی زمان است نه موفقیت نوشتن؛ میتوانست Offset پیامی را جابهجا کند که هنوز ننشسته است |
auto_offset_reset | earliest | یک Group تازه باید کل Backlog موجود را بخواند، نه اینکه دادهی منتشرشده را نادیده بگیرد |
poll_timeout_ms | 1000 | بهقدر کافی کوتاه که SIGTERM حداکثر یک ثانیه معطل بماند |
max_poll_interval_ms | 300000 (۵ دقیقه) | سقف زمان پردازش یک بچ پیش از آنکه Broker عضو را مرده فرض کند؛ با احتساب بدترین حالت Retry پایگاهداده انتخاب شده |
session_timeout_ms | 45000 | تحمل نوسان شبکه بدون Rebalance بیمورد |
db_retry_max_attempts | 5 با Backoff پایهی ۱ ثانیه | پوشش یک Restart کامل PostgreSQL (۱+۲+۴+۸+۱۶ ثانیه) بدون از دست دادن بچ |
application_cache_ttl_seconds | 300 | تعادل بین کم کردن کوئری و دیده شدن سریع اپ تازه ثبتشده |
shutdown_timeout_seconds | 30 | فرصت کافی برای اتمام و Commit بچ در جریان؛ در Compose با stop_grace_period: 45s همراستا شده |
STORAGE_MAX_POLL_RECORDS_REVIEWS)، سپس متغیر عمومی STORAGE_MAX_POLL_RECORDS، و در نهایت مقدار پیشفرض تنظیمشده در کد — پس اپراتور میتواند بدون تغییر کد آن را تنظیم کند.Supervisor یک قاعده را اعمال میکند: فرآیند تا زمانی زنده است که تمام Pipelineهایش زنده باشند. کانتینری که با دو Pipeline از سه Pipeline در حال کار باشد، Uptime سالمی نشان میدهد در حالی که داده بیصدا قطع شده است — و این از Crash کردن بدتر است، چون دیده نمیشود.
touch میکند و healthcheck.sh بررسی میکند تمام فایلهای مورد انتظار تازه باشند. پس کانتینری که Thread نقدهایش گیر کرده، فقط بهخاطر «در حال اجرا بودن» سالم گزارش نمیشود./tmp یک مشکل مانیتورینگ است و نباید به قطعی ingestion تبدیل شود. عمل touch هم نرخمحدود است تا به یک System Call در هر Poll تبدیل نشود.exec در Entrypointفرآیند پایتون PID 1 میشود و SIGTERM را مستقیماً دریافت میکند، نه اینکه یک Shell آن را ببلعد.LeaveGroup در خاموشیسیگنال در مرز Poll دیده میشود، بچ در جریان کامل و Commit میشود، سپس Consumer از Group خارج میشود تا Broker بلافاصله Rebalance کند — در یک Rolling Restart این تفاوت میان چند ثانیه و نزدیک یک دقیقه ingestion متوقف است.مجموعهی تستها به دو بخش تقسیم میشود، و این تقسیم مستقیماً از یک تصمیم معماری میآید: هستهی زیرسیستم (config، events، decoders، batching، retry_policy) عمداً نه Kafka میشناسد و نه psycopg2. همین است که اجازه میدهد قرارداد دادهی هر سه تاپیک بدون بالا آوردن هیچ سرویسی آزمون شود.
تستهای یکپارچگی همهی تاپیکها و اپهای مورد نیاز خودشان را میسازند و پس از پایان پاک میکنند، پس اجرای آنها روی یک Stack فعال بیخطر است. اگر Kafka یا PostgreSQL در دسترس نباشند، این تستها بهجای شکست، Skip میشوند تا مجموعه روی ماشینی بدون کانتینر هم سبز بماند.
سه عملیات روزمرهی این زیرسیستم بهصورت صریح مستند و ابزارمند شدهاند.
| عملیات | روش | نکتهی مهم |
|---|---|---|
| تأیید سلامت استقرار | اسکریپت scripts/verify/verify_storage_consumer.sh | کل مسیر را بهترتیب بررسی میکند: نسخهی اسکیما، تعداد Partition تاپیکها، پیامهای تولیدشده، ردیفهای نوشتهشده، پیامهای مرده، و Lag هر Group — پس شکست، مرحلهی خراب را نشان میدهد نه کل Pipeline را |
| Replay یک تاپیک | Reset کردن Offset همان Consumer Group به earliest | چون هر تاپیک Group مستقل دارد، میتوان reviews را برای بازسازی کامل جدولش Replay کرد بدون اینکه app-stats هم دوباره پردازش شود. Idempotency باعث میشود این کار داده را تکراری نکند |
| مقیاسدهی یک Pipeline | اجرای نسخهی دوم کانتینر با STORAGE_PIPELINES مخصوص همان Pipeline | چون هر تاپیک ۳ Partition دارد، تا ۳ عضو فعال در هر Group پشتیبانی میشود. همین متغیر اجازه میدهد همان Image بهصورت سه کانتینر تکPipeline با Restart Policy مستقل هم اجرا شود، بدون تغییر کد |
app-stats و reviews نشستهاند، از قبل از تثبیت قرارداد فعلی فیلدها هستند (ساختار خام camelCase کتابخانه). این پیامها طبق طراحی به dead_letter_events میروند. این یک باگ نیست؛ در اولین استقرار باید این Backlog قدیمی را رد کرد (Reset کردن Offset به latest) یا تاپیکها را پاک کرد.Groupهای جدا همان چیزی هستند که اجازه میدهند اپراتور reviews را برای بازسازی جدولش Reset کند بدون Replay شدن app-stats، و همان چیزی که Lag هر تاپیک را در kafka-ui جداگانه قابل مشاهده میکند.
تاپیک ساختهشده بهصورت خودکار فقط یک Partition دارد، و این سقف هر Consumer Group را برای همیشه روی یک عضو فعال قفل میکند. تعداد Partition یک تاپیک قابل کاهش نیست، پس فهمیدن این موضوع زیر بار یعنی بازسازی تاپیک؛ ساختن آنها از ابتدا تنها لحظهی ارزان برای درست انجام دادنش است.
Cursor از بیرون تزریق میشود تا نوشتن به تراکنش بچ ملحق شود، نه اینکه تراکنش خودش را باز کند. همین یک تصمیم است که تراکنش واحد داده و DLQ را ممکن کرده است.
.env هاست از مقادیر Dockerفایل storage_consumer/.env فقط برای اجرای مستقل روی هاست است (localhost:9092 و 127.0.0.1)، در حالی که سرویس در Compose به kafka:29092 و postgres نیاز دارد. این زیرسیستم روشنترین نمونهی این تفکیک در کل پروژه است، چون تنها زیرسیستمی است که هم به Kafka و هم به PostgreSQL وصل میشود و در نتیجه هر دو دسته آدرس را دارد.
retry_policyاین فایل نسخهی مستقلی از همتای خود در crawler/ و network_analyzer/ است، چون هر زیرسیستم یک واحد استقرار مستقل با Image و requirements.txt خودش است و سورس بقیه در Image آن حاضر نیست. آنچه واقعاً متفاوت است، مجموعهی خطاهای قابل Retry است: دو زیرسیستم دیگر HTTP را Retry میکنند، این یکی پایگاهداده را.
| ابزار | نقش |
|---|---|
| kafka-python (KafkaConsumer) | مصرف پیام از سه تاپیک با Commit دستی Offset و مدیریت Consumer Group |
| psycopg2 | اتصال به PostgreSQL و اجرای INSERT چندردیفی با execute_values |
| threading (stdlib) | یک Thread بهازای هر Pipeline، بههمراه رخداد توقف مشترک برای خاموشی هماهنگ |
| SQL خام + جدول رهگیری مهاجرت | مالکیت اسکیمای چهار جدول این زیرسیستم، مستقل از مهاجرتهای جنگو |
| dataclasses (frozen, slots) | تنظیمات و رخدادهای Immutable که بین Threadها بیخطر مشترک میشوند |
| python-dotenv | بارگذاری پیکربندی محیطی در حالت اجرای مستقل (venv) |
| pytest (با marker جداگانهی integration) | تفکیک تستهای بدون سرویس از تستهایی که به Kafka و PostgreSQL واقعی نیاز دارند |
| Docker Healthcheck + فایلهای Heartbeat | سنجش سلامت بهتفکیک هر Pipeline، بدون نیاز به بالا آوردن یک سرور HTTP |