پست

Change Data Capture در مصاحبه‌های طراحی سیستم

بررسی CDC برای همگام نگه داشتن Cache و Search Index و Warehouse بدون Dual Write و جایی که نباید از آن استفاده کرد

Change Data Capture در مصاحبه‌های طراحی سیستم

در مصاحبه‌ی طراحی سیستم هستید و باید دو Data Store را همگام نگه دارید. شاید پایگاه داده‌ی اصلی‌تان باید یک Search Index را تغذیه کند. یا شاید داده را به Warehouse منتقل می‌کنید تا تحلیل آفلاین اجرا کنید.

پاسخ معمولاً Change Data Capture یا CDC است، که با خواندن تغییرات از Replication Log پایگاه داده و انتشار آن‌ها به سرویس‌های پایین‌دست کار می‌کند. اغلب CDC دقیقاً ابزار درست است. اما نامزدها در موقعیت‌هایی هم سراغ CDC می‌روند که مناسب‌ترین انتخاب نیست و جایگزینی بهتر جواب می‌دهد.

CDC وقتی بهترین عملکرد را دارد که مصرف‌کننده‌ها فقط به یک کپی از داده‌ی شما نیاز دارند. اما وقتی لازم دارند بدانند چرا آن داده تغییر کرد، شروع به شکستن می‌کند. در این مطلب یک آزمون ساده می‌سازیم که در مصاحبه بتوانید با آن تصمیم بگیرید CDC کی انتخاب درستی است، کی مشکلات ظریف صحت (Correctness) می‌سازد و به‌جای آن چه باید استفاده کرد.

CDC چیست؟

فرض کنید کاتالوگ محصولات شما در Postgres است، ولی برای جستجوی محصول در سراسر اپلیکیشن از Elasticsearch استفاده می‌کنید. Postgres منبع حقیقت (Source of Truth) است، اما هر بار محصولی ساخته، به‌روز یا حذف می‌شود، باید آن تغییر در Elasticsearch هم منعکس شود.

گزینه‌ی اول، Dual Write: اپلیکیشن در هر دو سیستم می‌نویسد، یعنی وقتی محصولی تغییر می‌کند، اپلیکیشن هم‌زمان Postgres و Elasticsearch را به‌روز می‌کند. ساده به نظر می‌رسد، ولی اگر یک نوشتن موفق شود و دیگری شکست بخورد، دو سیستم ناهمگام می‌شوند. می‌توانید نوشتن ناموفق را Retry کنید، اما حالا به منطق Retry، Idempotency و راهی برای بازیابی در صورت Crash اپلیکیشن بین دو عملیات نیاز دارید.

گزینه‌ی دوم، Polling: هر چند ثانیه یک Cron Job می‌تواند از Postgres بپرسد چه چیزی تغییر کرده و Elasticsearch را بر همان اساس به‌روز کند. این هم می‌تواند جواب بدهد، ولی حالا مدام از پایگاه داده Query می‌گیرید، بار اضافه می‌سازید و باید منطقی نگه دارید که تشخیص دهد از Poll قبلی کدام رکوردها عوض شده‌اند.

Change Data Capture به شما اجازه می‌دهد تغییرات را مستقیم و همان لحظه‌ی رخ دادنشان از پایگاه داده بگیرید و به سیستم‌های پایین‌دست مثل Elasticsearch برسانید.

CDC چطور کار می‌کند؟

CDC از چیزی بهره می‌برد که پایگاه داده‌ی شما از قبل انجام می‌دهد. برای زنده ماندن از Crash و به‌روز نگه داشتن Replicaها، بیشتر پایگاه‌های داده هر تغییر را پیش از رسیدن به فایل‌های داده در یک Log مرتب می‌نویسند.

PostgreSQL اسمش را Write-Ahead Log یا WAL گذاشته. MySQL Binary Log دارد که هر تغییر ردیف را برای Replication ثبت می‌کند. بیشتر پایگاه‌های داده معادل مشابهی دارند.

CDC می‌تواند مستقیم از همین مکانیزم‌های موجود بخواند، به‌جای اینکه از اپلیکیشن بخواهد نوشتن دیگری انجام دهد. معماری معمول چنین است: سرور فقط در Postgres می‌نویسد، Postgres آن تغییرات را در WAL ثبت می‌کند، یک CDC Connector از WAL می‌خواند و رکوردهای تغییر را به Kafka می‌فرستد، و Worker آن‌ها را به Elasticsearch (یا مقصد دیگر) اعمال می‌کند.

اپلیکیشن همچنان فقط در Postgres می‌نویسد. Postgres آن تغییرات را در WAL به‌عنوان بخشی از پردازش عادی تراکنش ثبت می‌کند، و وقتی تراکنش Commit شد، CDC می‌تواند آن‌ها را به پایین‌دست منتقل کند.

بعد CDC Connector آن تغییرات Commit‌شده را می‌خواند و به رکوردهایی تبدیل می‌کند که سیستم‌های پایین‌دست می‌توانند مصرف کنند. Debezium یک پلتفرم CDC متن‌باز و محبوب است که برای پایگاه‌های داده‌ای مثل PostgreSQL و MySQL Connector دارد و خود را پلتفرم توزیع‌شده‌ی متن‌باز برای CDC معرفی می‌کند [web:25]. در معماری بالا، Debezium رکوردهای تغییر را در Kafka منتشر می‌کند و Consumer آن‌ها را به Elasticsearch اعمال می‌کند. Kafka لایه‌ی انتقال (Transport) در همین Pipeline مسیر Postgres به Elasticsearch است.

فرض کنید سفارشی از PROCESSING به SHIPPED تغییر می‌کند. رکورد CDC ساده‌شده ممکن است چنین باشد:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
  "table": "orders",
  "operation": "UPDATE",
  "before": {
    "id": 123,
    "status": "PROCESSING"
  },
  "after": {
    "id": 123,
    "status": "SHIPPED"
  },
  "offset": {
    "lsn": "24023128"
  }
}

فرمت دقیق بسته به پایگاه داده و ابزار CDC فرق می‌کند، ولی اجزای اصلی معمولاً مشابه‌اند. operation به Consumer می‌گوید رکورد Insert، Update یا Delete بوده است. تصویر before مقدار قبلی را دارد و تصویر after مقدار جدید را. offset منبع مشخص می‌کند این تغییر کجای Log پایگاه داده ظاهر شده، تا سیستم CDC بتواند پیشرفتش را ردیابی کند و پس از Restart از موقعیت درست ادامه دهد.

از آنجا Consumerها تصمیم می‌گیرند با هر تغییر چه کنند. سرویس ایندکس جستجوی ما از قبل، با تغییرات محصول سند متناظر را در Elasticsearch تازه می‌کند تا نتایج جستجو به‌روز بمانند. در مثال دیگر، Pipeline داده‌ای که Warehouse مثل Snowflake را تغذیه می‌کند ممکن است همان تغییر را بگیرد تا جدول‌های تحلیلی با داده‌ی Production همگام بمانند.

هیچ‌کدام از این Consumerها در نوشتن اصلی شرکت ندارند. اپلیکیشن حتی نمی‌داند وجود دارند، پس می‌توانید بدون تغییر کد اپلیکیشن یکی جدید اضافه کنید.

کی از CDC استفاده کنیم؟

CDC با اختلاف زمانی بیشترین فایده را دارد که یک سیستم منبع حقیقت باشد و سیستم دیگری باید یک کپی مشتق‌شده (Derived Copy) از آن داده نگه دارد. «مشتق‌شده» یعنی می‌توانید سیستم پایین‌دست را دور بیندازید و از منبع بازسازی کنید. هیچ چیزی آنجا نیست که ابتدا جای دیگری نباشد.

سه مثال رایج:

نگه داشتن Index مشتق‌شده

Postgres کاتالوگ محصول را نگه می‌دارد، Elasticsearch جستجو را سرویس می‌دهد و CDC ایندکس را به‌روز نگه می‌دارد.

این کار می‌کند چون ایندکسی مثل Elasticsearch منبع حقیقت نیست؛ یک پروجکشن قابل‌جستجو از آن چیزی است که در Postgres هست. این پروجکشن می‌تواند کمی عقب بیفتد بدون اینکه مشکلی بسازد، پس تغییر قیمت ممکن است یکی دو ثانیه طول بکشد تا در نتایج جستجو ظاهر شود، در حالی که Postgres در تمام مدت درست می‌ماند. اگر ایندکس خراب شود یا خیلی ناهمگام شود، می‌توانید آن را دور بیندازید، از Postgres بازسازی کنید و پردازش تغییرات جدید را ادامه دهید. (صفحه‌ی Elasticsearch در همین منبع هم تأیید می‌کند اغلب وقتی Elasticsearch در مصاحبه مطرح می‌شود، از طریق CDC به یک Data Store معتبر مثل Postgres یا DynamoDB وصل است، و ناهمگامی بین آن‌ها منبع رایج باگ است [web:24].)

Replicate کردن پایگاه داده‌ی OLTP به Data Warehouse

تصور کنید اپلیکیشن داده‌ی Production را در Postgres نگه می‌دارد، در حالی که تیم Analytics آن داده را از Data Warehouse جدایی مثل Snowflake Query می‌کند. برای کم کردن فشار روی پایگاه داده‌ی Production، این الگوی بسیار رایجی است.

رویکرد سنتی اجرای دوره‌ای Batch Job است که داده را از Postgres به Warehouse کپی می‌کند. جواب می‌دهد، ولی خواندن‌های Batch بزرگ می‌توانند گران باشند و بار اضافه روی پایگاه داده‌ی Production بگذارند و با ترافیک اپلیکیشن بر سر منابع رقابت کنند. همچنین ممکن است زمان زیادی برای کامل شدن بگیرند، که محدود می‌کند چند وقت یک‌بار می‌توانید اجرایشان کنید. Warehouse فقط به‌اندازه‌ی آخرین Batch کامل‌شده به‌روز است و تشخیص Update و Delete ممکن است به حسابداری اضافه نیاز داشته باشد.

CDC با ضبط پیوسته‌ی فقط تغییرات از آخرین موقعیت پردازش‌شده، از اسکن مکرر بخش بزرگی از پایگاه داده اجتناب می‌کند. به‌جای افزودن مسیر Query دیگری روی جدول‌های منبع، Replication Log را که پایگاه داده از قبل نگه می‌دارد می‌خواند. با CDC هر Insert، Update و Delete به محض رخ دادن به Warehouse Stream می‌شود.

پایگاه داده‌ی Production منبع حقیقت می‌ماند. Snowflake (یا مشابه) نمایش تحلیلی مشتق‌شده از آن داده را دارد و معمولاً مقداری تأخیر Replication قابل‌قبول است. اگر Pipeline مربوط به Warehouse خراب شود، می‌توانید داده را از منبع تعمیر کنید و پردازش تغییرات را ادامه دهید.

مهاجرت پایگاه داده بدون Downtime

CDC هنگام مهاجرت از یک پایگاه داده به پایگاه داده‌ی دیگر هم مفید است. فرض کنید می‌خواهید پایگاه داده‌ی بزرگ Postgres را بدون آفلاین کردن اپلیکیشن به پایگاه داده‌ی جدیدی ببرید. یک بار کپی کردن پایگاه داده کافی نیست، چون کاربران در طول اجرای کپی به تغییر داده ادامه می‌دهند.

در عوض، CDC را روی پایگاه داده‌ی قدیمی شروع می‌کنید، بعد داده‌ی موجود را به پایگاه داده‌ی جدید Backfill می‌کنید. CDC هر چیزی را که هنگام Backfill تغییر می‌کند ضبط می‌کند و وقتی Backfill تمام شد، آن تغییرات روی پایگاه داده‌ی جدید اعمال می‌شوند تا به آن برسد. در آن نقطه می‌توانید خواندن را به پایگاه داده‌ی جدید ببرید و سرانجام نوشتن را هم منتقل کنید.

جایگزین آن Dual Write است که اپلیکیشن هر نوشتن را تا پایان مهاجرت به هر دو پایگاه داده می‌فرستد. این یعنی منطق مهاجرت در کد اپلیکیشن شما زندگی می‌کند و باید در تمام مدتی که هر دو پایگاه داده نوشتن می‌گیرند درست بماند. با CDC اپلیکیشن در تمام مسیر فقط در یک پایگاه داده می‌نویسد و هرگز نمی‌فهمد مهاجرت در جریان است.

هر سه مثال بالا ویژگی‌های مشترکی دارند:

  • منبع هنگام اجرای CDC معتبر می‌ماند. Postgres سیستم ثبت (System of Record) است، در حالی که Elasticsearch، Snowflake یا پایگاه داده‌ی جدید از آن همگام می‌شود.
  • مقداری کهنگی قابل‌قبول است. سیستم پایین‌دست لازم نیست پیش از کامل شدن درخواست اصلی، هر نوشتن را منعکس کند.
  • هدف را می‌توان بازسازی یا دوباره همگام کرد. اگر مشکلی پیش بیاید، می‌توانید سیستم پایین‌دست را بازسازی یا تعمیر کنید در حالی که CDC به کارش ادامه می‌دهد.

کی از CDC استفاده نکنیم؟

در هر سه مورد بالا، سیستم پایین‌دست فقط به State اهمیت می‌دهد. مشکل وقتی ظاهر می‌شود که Consumer چیزی بیش از کپی وفادارانه‌ی آن State لازم دارد.

حالت شکست رایج این است که با CDC مثل معماری واکنشی (Reactive) رفتار کنیم، که در آن تغییر یک ردیف، اقدام بعدی را شروع می‌کند. چیزی که می‌توانست فراخوانی مستقیم سرویس باشد، حالا پشت Connector و Consumer پنهان شده است. یک اسکریپت Backfill یا تعمیر می‌تواند ناگهان سرویس پایین‌دست را غرق کند و ردیابی شکست‌ها سخت‌تر است، چون نوشتن اصلی هیچ سرنخی نمی‌دهد چه چیزی را فعال خواهد کرد. اگر Consumer فقط به کسر کوچکی از تغییرات پایگاه داده اهمیت می‌دهد، این ماشین‌آلات زیادی است.

ارسال ایمیل هنگام ارسال سفارش

فرض کنید سفارشی از PROCESSING به SHIPPED می‌رود و می‌خواهید برای مشتری ایمیل بفرستید. وسوسه‌انگیز است که به تغییرات جدول orders گوش دهید و هر بار این را دیدید ایمیل بفرستید.

1
2
3
4
5
6
7
8
9
10
11
12
{
  "table": "orders",
  "operation": "UPDATE",
  "before": {
    "id": 123,
    "status": "PROCESSING"
  },
  "after": {
    "id": 123,
    "status": "SHIPPED"
  }
}

در نگاه اول معقول به نظر می‌رسد. Consumer به‌وضوح می‌بیند سفارش به SHIPPED تغییر کرده است.

مشکل اینجاست که CDC می‌داند که ردیفی تغییر کرده ولی نمی‌داند چرا. مهاجرتی که سفارش‌های تاریخی را بازنویسی می‌کند یا Job تعمیری که داده‌ی بد را اصلاح می‌کند، هر دو می‌توانند همین تغییر را تولید کنند.

حالا سرویس ایمیل باید یک رویداد کسب‌وکار را از نمایش فیزیکی داده‌ی شما استنباط کند. status = SHIPPED دیگر فقط وضعیت سفارش را توصیف نمی‌کند. سیستم‌های دیگر ضمناً توافق کرده‌اند که تنظیم این ستون یعنی رویداد OrderShipped رخ داده است.

این Coupling وقتی به‌خصوص خطرناک می‌شود که Consumer یک Side Effect انجام دهد، مثل ارسال ایمیل، کسر پول از کارت یا فعال کردن Workflow دیگر. برای چیزی مثل Elasticsearch این مهم نیست. نوشتن دوباره‌ی همان State نهایی بی‌ضرر است. ارسال ایمیل ارسال دوم به همان مشتری بی‌ضرر نیست.

Deduplicate کردن رکوردهای CDC هم این را حل نمی‌کند. از دید پایگاه داده این‌ها نوشتن‌های متفاوت در موقعیت‌های متفاوت Log هستند، پس چیزی برای گرفتن توسط Dedup Key وجود ندارد. چیزی که کم است یک واقعیت کسب‌وکاری ماندگار است، مثل OrderShipped با شناسه‌ی رویداد مخصوص خودش.

اپلیکیشن تفاوت را می‌دانست. وقتی status = SHIPPED را تنظیم کرد، داشت سفارشی را ارسال می‌کرد، نه تعمیر داده. اما هیچ چیز در آن نوشتن ثبت نمی‌کند کدام بود. تا وقتی CDC Log را می‌خواند، تنها چیزی که می‌ماند ستونی است که تغییر کرده.

پس اپلیکیشن را وادار کنید آن را بنویسد. در همان تراکنشی که سفارش را به‌روز می‌کند، ردیفی درج کنید که می‌گوید چه اتفاقی افتاد:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
BEGIN TRANSACTION;

UPDATE orders
SET status = 'SHIPPED'
WHERE id = 123;

INSERT INTO outbox_events (
  event_id,
  event_type,
  payload
)
VALUES (
  '8f12...',
  'OrderShipped',
  '{ ... }'
);

COMMIT;

یا هر دو ردیف Commit می‌شوند یا هیچ‌کدام، پس ردیف OrderShipped دقیقاً وقتی وجود دارد که سفارش واقعاً ارسال شده باشد. این Transactional Outbox است و یک Relay می‌تواند آن رویدادها را به پایین‌دست منتشر کند.

تمایز مهم این است که Relay چه چیزی را می‌خواند. orders.status وضعیت اپلیکیشن است. ردیف Outbox دقیقاً وجود دارد چون اپلیکیشن تصمیم گرفته رویداد کسب‌وکاری رخ داده. این به Consumerهای پایین‌دست رویداد صریحی مثل OrderShipped همراه با event_id پایداری می‌دهد که می‌توانند برای Deduplication استفاده کنند.

از رویداد Outbox به‌عنوان قرارداد با Consumerهای پایین‌دست استفاده کنید، نه تغییر جدول orders.

Invalidate کردن Cache هنگام تغییر ردیف

فرض کنید اپلیکیشن شما پروفایل کاربران را از Redis سرویس می‌دهد و می‌خواهد هر وقت ردیف زیربنایی در Postgres تغییر کرد، نسخه‌ی کش‌شده را Invalidate کند. طبیعی به نظر می‌رسد که جدول users را دنبال کنید و با رسیدن به‌روزرسانی‌ها کلید Redis مربوط را حذف کنید. جواب می‌دهد، ولی اغلب غیرضروری است.

در حالت رایج، اپلیکیشنی که ردیف را عوض کرده دقیقاً می‌داند کدام ورودی کش حالا کهنه است. شناسه‌ی کاربر را در دست دارد، در همان درخواست و همان مسیر کد. Invalidate کردن کلید همان‌جا ساده و فوری است. فرستادن همان اطلاعات از طریق CDC یعنی معرفی Connector، Topic و Consumer فقط برای کشف دوباره‌ی چیزی که نویسنده از قبل می‌دانست.

گاهی اصلاً به Invalidation صریح نیاز ندارید. اگر سرویس‌دهی پروفایلی که یک دقیقه کهنه است قابل‌قبول باشد، TTL کوتاه شاید کافی باشد.

تفاوت با ایندکس جستجو این است که کش به Stream ماندگار از هر تغییر نیاز ندارد. Invalidation از دست رفته می‌تواند با TTL منقضی شود و مقدار می‌تواند در صورت نیاز دوباره بارگذاری شود.

اجرای Cleanup یا Job پس‌زمینه بعد از تغییر State

فرض کنید حذف حساب باید کار Cleanup را شروع کند. باید فایل‌هایش را از Object Storage پاک کنید، API Keyها را باطل کنید و داده‌ی مرتبط را از سیستم‌های دیگر حذف کنید. می‌توانید جدول users را با CDC دنبال کنید و وقتی DELETE ظاهر شد آن کار را شروع کنید.

این همان مشکل مثال OrderShipped را دارد. DELETE به شما می‌گوید ردیفی ناپدید شده، نه اینکه Job Cleanup مشخصی باید اجرا شود. اما کار پس‌زمینه مشکل دیگری هم می‌آورد. Kafka می‌تواند رکوردهای CDC را ماندگار تحویل دهد و Consumerها می‌توانند Retry اضافه کنند. اما وقتی Consumer مالک State کار، Backoff، مدیریت شکست و اجرای تأخیری هم شود، شما یک سیستم Job را حول تغییر ضمنی یک ردیف ساخته‌اید.

این با کار تأخیری حتی روشن‌تر می‌شود. فرض کنید رزرو ۳۰ دقیقه بعد از ساخته شدن منقضی می‌شود و بعد باید موجودی را آزاد کند. CDC می‌تواند بگوید رزرو درج شد، ولی حالا Consumer باید Timer را جایی ذخیره کند، بعد از Crash بازیابی کند و مطمئن شود Cleanup سرانجام اجرا می‌شود. Kafka این را عوض نمی‌کند. Timer و بازیابی آن هنوز باید جایی زندگی کنند.

اگر اپلیکیشن از قبل می‌داند کاری باید انجام شود، معمولاً بهتر است آن کار را مستقیم نمایش دهید. برای کار پس‌زمینه‌ی فوری، یک Job ماندگار را در صف بگذارید، یا برای کار طولانی، تأخیری یا چندمرحله‌ای، Workflow ماندگار را با موتور اجرایی مثل Temporal شروع کنید.

نتیجه‌گیری

CDC Logی را می‌خواند که پایگاه داده‌ی شما از قبل برای بازیابی از Crash و Replication نگه می‌دارد و آن را به Streamی تبدیل می‌کند که سیستم‌های دیگر می‌توانند مصرف کنند. اپلیکیشن به نوشتن در یک پایگاه داده ادامه می‌دهد و Consumerها را می‌توان بدون دست زدن به آن کد اضافه یا حذف کرد.

وقتی استفاده کنید که سیستم پایین‌دست فقط به کپی داده‌ی شما نیاز دارد. ایندکس جستجو، Warehouse تحلیلی، پایگاه داده‌ای که به آن مهاجرت می‌کنید. منبع در تمام مدت معتبر می‌ماند، کمی تأخیر مشکلی نیست و اگر هدف ناهمگام شود می‌توانید آن را بازسازی کنید.

مراقب باشید از CDC به‌عنوان Trigger عمومی برای هر چیزی که باید بعداً رخ دهد استفاده نکنید. قصد کسب‌وکار باید به‌صورت رویداد صریح نوشته شود و کاری که اپلیکیشن از قبل از آن خبر دارد باید مستقیم صدا زده یا صف‌بندی شود. وقتی Consumer به Retry، Backoff یا زمان‌بندی نیاز پیدا کند، چیزی که می‌خواستید سیستم Job یا Workflow بود، نه CDC.

در مصاحبه، وقتی از CDC استفاده کنید که مسئله به‌روشنی با یکی از الگوهای کپی مشتق‌شده‌ی بالا جور باشد. اگر اپلیکیشن از قبل می‌داند بعد چه باید رخ دهد، آن وابستگی را صریح کنید.