Microservices (Java/Spring) · میکروسرویس سنیورSenior ~49 دقیقه مطالعه~42 min read
مدیریتِ داده: Database-per-Service، Saga، Outbox و CQRSData Management: DB-per-Service, Saga, Outbox, CQRS
چرا هر میکروسرویس باید دیتابیس خودش را داشته باشد و چطور با Saga، الگوی Outbox، CDC (Debezium) و CQRS، بدون تراکنش توزیعشده، سازگاریِ داده را بین سرویسها مدیریت کنیم.Why every microservice must own its database, and how to keep data consistent across services without distributed transactions—using Saga, the transactional Outbox, CDC (Debezium), and CQRS.
پیشنیاز:Prerequisites: میکروسرویس: مبانی، مرزبندی و کِی/چراMicroservices: Foundations, Boundaries & When/Why
تا اینجا یاد گرفتی چطور سرویسها را از هم جدا کنی، با هم حرف بزنند و مقاوم بمانند. حالا میرسیم به سختترین و بیرحمترین بخشِ میکروسرویسها: داده. کدِ بیstateٍ خوشقیافه راحت تکهتکه میشود؛ اما لحظهای که دو سرویس بخواهند روی یک واقعیتِ کسبوکاری (مثلاً «سفارش ثبت شد و موجودی کم شد و پول کم شد») توافق کنند، همهی سختیِ سیستمهای توزیعشده روی سرت آوار میشود.
در یک مونولیت، این توافق تقریباً مجانی بود: یک @Transactional، یک دیتابیس، و کامیت اتمیک. یا همهچیز اتفاق میافتاد یا هیچچیز. در دنیای میکروسرویس این جادو از بین میرود، چون دیگر یک دیتابیس و یک تراکنش نداری. این فصل دربارهی این است که وقتی آن جادو را از دست میدهی، چطور با ابزارهای درست (Saga، Outbox، CDC، CQRS) دوباره یک سیستمِ قابلاعتماد بسازی — نه اینکه وانمود کنی مشکل وجود ندارد.
۱) Database-per-Service: چرا هر سرویس دیتابیسِ خودش را دارد و مالکِ دادهاش است. ۲) آنتیپترنِ Shared Database: چرا دیتابیسِ مشترک قاتلِ خاموشِ میکروسرویس است. ۳) چرا تراکنشِ توزیعشده / 2PC / XA را کنار میگذاریم. ۴) Eventual Consistency: سازگاریِ نهایی و طرزِ فکرِ لازم برای آن. ۵) Saga: ارکستراسیون در برابر کروگرافی، و اکشنهای جبرانی (Compensation). ۶) مشکلِ Dual-Write و راهحلِ آن: الگوی Transactional Outbox + CDC/Debezium. ۷) CQRS و مدلِ خواندن؛ مقدمهای بر Event Sourcing. ۸) اصولِ طراحی برای سازگاریِ نهایی و مالکیتِ داده در سطحِ سرویس.
۱) هر سرویس، دیتابیسِ خودش — Database per Service
یک مونولیت مثل آشپزخانهی مرکزیِ یک هتل است: همهی غذاها از یک آشپزخانه بیرون میآیند و همه از یک انبارِ مشترک برمیدارند. میکروسرویسها مثل یک فودکورتاند: هر غرفه (پیتزا، سوشی، قهوه) آشپزخانه، انبار و صندوقِ خودش را دارد. اگر انبارِ سوشی خالی شود، غرفهی پیتزا بیخیال کارش را ادامه میدهد. اما اگر همه از یک انبارِ مشترک بردارند، یک قفلِ اشتباه روی قفسهی برنج، کلِ فودکورت را میخواباند. Database-per-Service یعنی هر غرفه انبارِ خودش را دارد.
اصلِ Database per Service ساده به نظر میرسد ولی عمیق است: هر میکروسرویس دیتابیسِ خصوصیِ خودش را دارد و هیچ سرویسِ دیگری حق ندارد مستقیم به آن جداول دست بزند. تنها راهِ رسیدن به دادهی یک سرویس، عبور از API یا eventـهای همان سرویس است. جدولهای سرویس، جزئیاتِ داخلیِ آناند — دقیقاً مثل فیلدهای private در یک کلاس.
«دیتابیسِ خصوصی» لزوماً به معنیِ یک نصبِ فیزیکیِ جدا نیست. سه سطحِ جداسازی وجود دارد که هرکدام جای خودش را دارد:
| سطحِ جداسازی | یعنی چه | کِی مناسب است |
|---|---|---|
| Private schema / Private tables | یک instanceِ مشترک، اما هر سرویس schema یا مجموعهجدولِ خودش؛ گرنتِ دیتابیسی مانعِ دستدرازی | تیمِ کوچک، هزینهی زیرساخت مهم است |
| Private database (روی instance مشترک) | هر سرویس یک databaseِ مجزا روی همان سرور | حدِ وسطِ رایج |
| Private database server | هر سرویس سرورِ دیتابیسِ کاملاً جدا | مقیاسِ بالا، ایزولاسیونِ عملکرد و امنیت |
مالکیتِ داده در سطحِ سرویس است، نه در سطحِ جدول به اشتراک گذاشتهشده. هر تکه داده دقیقاً یک صاحب دارد که تنها نوشتندهی معتبرِ آن است. بقیه فقط از طریقِ API یا رویداد، کپی/نمای آن داده را میبینند.
نمای کلیِ یک سیستمِ سالم را ببین. توپولوژیِ Database-per-Service | Each service owns a private database:
flowchart LR
Client --> Gateway
Gateway --> OrderSvc[Order Service]
Gateway --> InventorySvc[Inventory Service]
Gateway --> PaymentSvc[Payment Service]
OrderSvc --> OrderDB[(Order DB)]
InventorySvc --> InvDB[(Inventory DB)]
PaymentSvc --> PayDB[(Payment DB)]
OrderSvc -. events .-> Broker[(Kafka)]
InventorySvc -. events .-> Broker
PaymentSvc -. events .-> Broker
چرا اینقدر مهم است؟
۱) استقلالِ تیم و استقرار. اگر تیمِ Order بخواهد یک ستون اضافه کند یا از Postgres به یک دیتابیسِ document کوچ کند، نباید هیچ تیمِ دیگری را هماهنگ کند. schemaِ مشترک یعنی هر تغییرِ schema باید بین همهی تیمها مذاکره شود — این دقیقاً همان کوپلینگی است که میکروسرویس قرار بود از بین ببرد.
۲) آزادی در انتخابِ تکنولوژی (Polyglot Persistence). سرویسِ کاتالوگ شاید Elasticsearch بخواهد، سرویسِ سبدِ خرید Redis، سرویسِ حسابداری Postgresِ ACIDِ سفتوسخت، و سرویسِ گراف اجتماعی Neo4j. هر سرویس ابزارِ مناسبِ کارش را انتخاب میکند.
۳) ایزولاسیونِ خطا و عملکرد. یک کوئریِ سنگین یا full table lock در یک سرویس، سرویسهای دیگر را نمیخواباند. blast radius محدود میشود.
۴) مقیاسپذیریِ مستقل. سرویسِ پرترافیک read-heavy را میتوانی جدا شارد یا replica بزنی، بدون اینکه دیتابیسِ سرویسهای دیگر را بزرگ کنی.
Database-per-Service «رایگان» نیست. تو داری سادگیِ join و تراکنشِ لوکال را میدهی و در ازایش استقلال میگیری. اگر دامنهات پر از join و تراکنشهای سفت بین موجودیتهاست و مرزهای پایداری ندارند، شاید هنوز آمادهی شکستن نیستی. سنیورها اول مرزِ context (Bounded Context در DDD) را درست میکِشند، بعد دیتابیس را میشکنند — نه برعکس. شکستنِ دیتابیس قبل از فهمِ دامنه، به یک «مونولیتِ توزیعشده» ختم میشود که بدترین حالتِ ممکن است.
۲) آنتیپترنِ Shared Database — قاتلِ خاموش
خیلی از تیمها به میکروسرویس مهاجرت میکنند، سرویسها را جدا میکنند، ولی همه هنوز به یک دیتابیسِ مشترک وصلاند. روی کاغذ میکروسرویس دارند؛ در عمل یک مونولیتِ توزیعشده ساختهاند با تمام معایبِ هر دو دنیا.
چرا اینقدر بد است؟ فرض کن سرویسِ Order و سرویسِ Reporting هر دو مستقیم روی جدولِ orders کوئری میزنند.
- کوپلینگِ پنهانِ schema: تیمِ Order ستونِ
statusرا ازVARCHARبه یک جدولِ lookup تغییر میدهد. گزارشگیری بیسروصدا میشکند — و کسی خبر ندارد تا مشتری زنگ بزند. schemaِ دیتابیس تبدیل به یک API عمومیِ منتشرنشده میشود که هیچکس نمیتواند امن تغییرش دهد. - از بین رفتنِ مالکیت: وقتی دادهی نامعتبر در
ordersپیدا میشود، کی نوشته؟ Order؟ Reporting که یک جای مخفی UPDATE میزند؟ هیچ منبعِ حقیقتِ واحدی نیست. - جنگ بر سرِ منابع و قفل: یک long-running report، روی همان جدولها lock و I/O میگیرد و مسیرِ حیاتیِ ثبتِ سفارش کند میشود.
- بیمعنا شدنِ استقرارِ مستقل: هر migration باید با همه هماهنگ شود. عملاً به یک قطارِ release مشترک برگشتهای.
اگر در طراحیات دو سرویس به یک جدول SELECT/UPDATE میزنند، مصاحبهگر همانجا تشخیص میدهد که مرزها را اشتباه کشیدهای. جوابِ درست همیشه این است: «سرویسِ B دادهی سرویسِ A را از طریقِ API یا یک event که A منتشر میکند میگیرد و در صورتِ نیاز یک نسخهی read-only لوکال (read model) نگه میدارد — هرگز مستقیم روی جدولِ A نمیرود.»
استثنا: در یک مونولیتِ ماژولار (مثلاً با Spring Modulith 2.1) داشتنِ یک دیتابیس با schemaهای منطقیِ جدا کاملاً درست است، چون هنوز یک deployment و یک تراکنش داری. Shared Database فقط وقتی آنتیپترن است که سرویسها مستقلاً مستقر و مقیاس میشوند ولی دیتابیس را قاچاقی به اشتراک گذاشتهاند.
۳) چرا تراکنشِ توزیعشده و 2PC را کنار میگذاریم
خب، حالا هر سرویس دیتابیسِ خودش را دارد. اما یک عملیاتِ کسبوکاری اغلب چند سرویس را لمس میکند: «ثبتِ سفارش» یعنی رزروِ موجودی + کسرِ پول + ساختِ رکوردِ سفارش. اگر هرکدام دیتابیسِ جدا دارند، چطور همهشان با هم کامیت یا رولبک شوند؟
جوابِ کلاسیکِ دنیای پایگاهداده Two-Phase Commit (2PC) با پروتکلِ XA است: یک هماهنگکننده (coordinator) به همه میگوید «آماده شو» (prepare)، همه قفل میگیرند و میگویند «آمادهام»، بعد coordinator میگوید «کامیت». روی کاغذ اتمیک است. در دنیای میکروسرویس، تقریباً همیشه اشتباه است.
- قفلِ طولانیمدت = مرگِ throughput: بین prepare و commit، منابع در همهی سرویسها قفل میمانند. یک سرویسِ کند، بقیه را گروگان میگیرد. زیرِ بارِ بالا این یعنی فاجعه.
- کوپلینگِ در دسترسبودن: اگر یکی از participantها (یا خودِ coordinator) پایین باشد، کلِ تراکنش گیر میکند. availabilityِ کلِ سیستم میشود حاصلضربِ availability تکتک اجزا — دقیقاً برعکسِ چیزی که از میکروسرویس میخواهیم.
- مشکلِ blockingِ coordinator: اگر coordinator بعد از prepare بمیرد، participantها با قفلِ گرفتهشده گیر میکنند (in-doubt transactions) تا coordinator برگردد. کابوسِ عملیاتی.
- پشتیبانیِ ضعیف: خیلی از دیتابیسها و تقریباً همهی brokerهای مدرن (Kafka و…) و NoSQLها XA را خوب یا اصلاً پشتیبانی نمیکنند. نمیتوانی Kafka و Postgres را در یک XA بگذاری.
- تئوریِ CAP: در یک سیستمِ توزیعشده وقتی partition شبکه رخ دهد، بینِ Consistency و Availability باید یکی را انتخاب کنی. 2PC سمتِ Cِ سفت را میگیرد و A را قربانی میکند؛ اکثرِ سیستمهای اینترنتی برعکس، A را میخواهند.
پس راهحل چیست؟ بهجای تلاش برای اتمیک بودنِ توزیعشده، ما اتمیک بودن را میشکنیم به دنبالهای از تراکنشهای لوکال (هرکدام اتمیک در دیتابیسِ خودش) و میپذیریم که سیستم برای مدتِ کوتاهی ناسازگار باشد و بعد به سازگاری برسد. اسمِ این ایده Eventual Consistency است و ابزارِ اجراییاش Saga.
«2PC مطلقاً ممنوع» شعارِ خوبی برای مصاحبه است، ولی سنیورِ واقعی میداند که در سیستمهای مالیِ درونسازمانی با تعدادِ کمِ participant و بارِ پایین، XA هنوز استفاده میشود (مثلاً یک تراکنشِ JMS + JDBC روی همان RDBMS با Atomikos/Narayana). قاعده این است: هرچه سیستم توزیعشدهتر، پرترافیکتر و ناهمگونتر (Kafka + چند دیتابیس) باشد، 2PC غیرِعملیتر میشود.
۴) Eventual Consistency — سازگاریِ نهایی
وقتی از بانکِ A به بانکِ B پول میفرستی، لحظهای هست که پول از حسابِ تو کم شده ولی هنوز به حسابِ مقصد ننشسته — «در راه» است. سیستم در آن لحظه ناسازگار است، ولی هیچکس نگران نیست، چون همه میدانند در نهایت پول یا میرسد یا (اگر شکست بخورد) به حسابت برمیگردد. کلِ سیستمِ بانکیِ جهانی روی Eventual Consistency میچرخد، نه روی یک قفلِ اتمیکِ جهانی.
Eventual Consistency یعنی: اگر نوشتنِ جدیدی نیاید، همهی replicaها و سرویسها سرانجام به یک مقدارِ یکسان همگرا میشوند. بینِ لحظهی نوشتن و لحظهی همگرایی، یک پنجرهی ناسازگاری (inconsistency window) وجود دارد — معمولاً میلیثانیه تا چند ثانیه.
پذیرفتنِ این پنجره یک تصمیمِ محصولی است، نه فقط فنی. باید با کسبوکار حرف بزنی: «آیا مشکلی هست که خریدار ۲ ثانیه بعد از ثبتِ سفارش، آن را در «سفارشهای من» ببیند؟» تقریباً همیشه جواب «نه، مشکلی نیست» است — و همین یک تصمیم، در را به روی معماریِ مقیاسپذیر باز میکند.
هیچوقت نگو «دادهی ما ناسازگار میشود». بگو: «ما سازگاریِ قوی را در مرزِ یک aggregate تضمین میکنیم (تراکنشِ لوکال)، و بینِ aggregateها سازگاریِ نهایی داریم که پنجرهاش را monitor میکنیم و SLA دارد.» این جمله فرقِ یک میدِ ترسیده و یک سنیورِ مسلط را نشان میدهد.
۵) Saga — تراکنشِ توزیعشده بدونِ قفلِ توزیعشده
Saga یک دنباله از تراکنشهای لوکال است. هر مرحله در دیتابیسِ سرویسِ خودش کامیت میشود و یک رویداد منتشر میکند که مرحلهی بعدی را روشن میکند. اگر مرحلهای شکست بخورد، Saga مراحلِ قبلی را با تراکنشهای جبرانی (Compensating Transactions) خنثی میکند.
Saga رولبکِ خودکار ندارد. چون هر مرحله واقعاً کامیت شده، نمیتوانی «برگردانیاش». بهجای رولبک، یک عملیاتِ معکوسِ معنایی اجرا میکنی. مثال: نمیتوانی «کسرِ پول» را رولبک کنی، ولی میتوانی یک «بازپرداخت (refund)» انجام دهی. رزروِ موجودی را با «آزادسازیِ رزرو» جبران میکنی. جبران، همیشه یک اکشنِ کسبوکاریِ جدید است، نه undo دیتابیسی.
تراکنشِ جبرانی — قانونِ سخت
هر مرحله باید یک عملِ جبرانیِ idempotent و قابلِهمیشهموفق داشته باشد. اگر مرحلهی «رزرو» جبرانش «آزادسازی» است، آزادسازی باید حتی اگر رزرو نیمهتمام مانده یا قبلاً آزاد شده، بدونِ خطا کار کند. جبرانی که خودش شکست بخورد یعنی سیستم برای همیشه در حالتِ ناسازگارِ گیرکرده میماند.
همهی اکشنها قابلِ جبران نیستند. اگر مرحلهای یک ایمیل به مشتری فرستاد یا یک محصولِ فیزیکی را ارسال کرد، نمیتوانی «unsend» کنی. راهحل: اینجور اکشنهای غیرقابلبازگشت را تا حدِ ممکن به آخرین مرحلهی Saga ببر (به آن میگویند pivot transaction / retriable steps). قبل از pivot همهچیز compensatable است؛ بعد از pivot همهچیز فقط retriable است (باید در نهایت موفق شود). ترتیبِ مراحل، یک تصمیمِ طراحیِ حیاتی است.
دو سبکِ Saga: کروگرافی و ارکستراسیون
کروگرافی (Choreography): هیچ مغزِ مرکزیای نیست. هر سرویس به رویدادهای بقیه گوش میدهد و واکنش نشان میدهد و رویدادِ خودش را منتشر میکند. مثل یک گروهِ رقص که هیچ رهبری ندارد و هرکس با دیدنِ حرکتِ بغلدستی، حرکتِ بعدی را میزند.
ارکستراسیون (Orchestration): یک هماهنگکنندهی مرکزی (Saga Orchestrator) به هر سرویس دستورِ (command) صریح میدهد و منتظرِ پاسخ میماند و مرحلهی بعد را تصمیم میگیرد. مثل یک ارکستر با رهبر.
جریانِ کامل با هر دو سبک را ببین. Saga سبکِ کروگرافی برای ثبتِ سفارش | Choreography saga for order placement:
sequenceDiagram
participant O as Order Service
participant K as Kafka
participant I as Inventory Service
participant P as Payment Service
O->>K: OrderCreated (status=PENDING)
K->>I: OrderCreated
I->>I: reserve stock (local tx)
I->>K: StockReserved
K->>P: StockReserved
P->>P: charge card (local tx)
P->>K: PaymentCompleted
K->>O: PaymentCompleted
O->>O: status=CONFIRMED (local tx)
Note over P,O: if charge fails -> PaymentFailed -> Inventory releases stock (compensation)
حالا همان با یک مغزِ مرکزی. Saga سبکِ ارکستراسیون با orchestrator | Orchestration saga driven by a central coordinator:
sequenceDiagram
participant SO as Order Saga Orchestrator
participant I as Inventory Service
participant P as Payment Service
SO->>I: ReserveStock (command)
I-->>SO: StockReserved (reply)
SO->>P: ChargePayment (command)
P-->>SO: PaymentFailed (reply)
SO->>I: ReleaseStock (compensation command)
I-->>SO: StockReleased
Note over SO: Orchestrator holds the state machine and decides each next step
مقایسه: کدام سبک را انتخاب کنم؟
| معیار | کروگرافی | ارکستراسیون |
|---|---|---|
| کوپلینگ | شل (فقط از طریقِ event) | orchestrator به همه وابسته است |
| فهمِ جریان | سخت — منطق پخش شده در سرویسها | آسان — همهی جریان یکجاست |
| نقطهی شکستِ واحد | ندارد | orchestrator (باید HA و بادوام باشد) |
| مناسب برای | جریانهای ساده، ۲–۳ مرحله | جریانهای پیچیده، شرطی، چندمرحلهای |
| خطرِ حلقهی event | بالا (event به event، سیکل پنهان) | پایین |
| دیباگ/observability | دشوار (باید trace پخششده را دنبال کنی) | راحتتر |
تا ۲–۳ مرحلهی ساده، کروگرافی تمیزتر و کمزیرساختتر است. اما بهمحضِ اینکه جریان شاخهدار، شرطی، یا بلند شد، ارکستراسیون بُرد میکند چون منطقِ کل تراکنش را در یک state machineِ قابلِمشاهده متمرکز میکند. یک آنتیپترنِ رایج: کروگرافیِ ۷ مرحلهای که هیچکس نمیفهمد event از کجا به کجا میرود — به آن میگویند «معماریِ pinball». اگر نمیتوانی روی یک وایتبورد کلِ جریان را بکشی، وقتش است به orchestrator کوچ کنی.
برای Saga در جاوا/Spring، معمولاً یا دستی با Kafka + یک state machine (مثلاً یک جدولِ saga_state) میسازی، یا از فریمورکهای آماده مثل Axon Framework، Eventuate Tram Sagas، یا موتورهای workflowِ بادوام مثل Temporal و Camunda/Zeebe استفاده میکنی. Temporal این روزها انتخابِ محبوبی برای orchestration است چون stateِ workflow را بادوام و retry را خودکار مدیریت میکند.
نمونهی یک orchestratorِ ساده و دستی با state machine (مفهومی، Spring):
public enum OrderSagaState {
STARTED, STOCK_RESERVED, PAYMENT_DONE, COMPLETED, COMPENSATING, FAILED
}
@Component
public class OrderSagaOrchestrator {
private final CommandGateway commands; // پیامفرست به سرویسها
private final SagaStateRepository repo; // جدولِ saga_state در دیتابیسِ Order
// با رسیدنِ هر reply فراخوانی میشود (مثلاً از یک Kafka listener)
@Transactional
public void on(SagaReply reply) {
SagaInstance saga = repo.findById(reply.sagaId()).orElseThrow();
switch (saga.getState()) {
case STARTED -> {
if (reply instanceof StockReserved) {
saga.setState(OrderSagaState.STOCK_RESERVED);
commands.send(new ChargePayment(saga.getOrderId(), saga.getAmount()));
} else { // StockRejected
saga.setState(OrderSagaState.FAILED);
// چیزی رزرو نشده، جبرانی لازم نیست
}
}
case STOCK_RESERVED -> {
if (reply instanceof PaymentCompleted) {
saga.setState(OrderSagaState.COMPLETED);
} else { // PaymentFailed -> جبران کن
saga.setState(OrderSagaState.COMPENSATING);
commands.send(new ReleaseStock(saga.getOrderId())); // اکشنِ جبرانی
}
}
case COMPENSATING -> saga.setState(OrderSagaState.FAILED); // StockReleased رسید
default -> { /* idempotent: reply تکراری را نادیده بگیر */ }
}
repo.save(saga); // stateِ saga و ارسالِ پیام باید اتمیک باشند -> Outbox!
}
}
آخرین کامنت مهمترین نکته را لو میدهد: وقتی saga را ذخیره میکنی و همزمان یک پیام میفرستی، دقیقاً به همان مشکلی میرسی که کلِ Saga قرار بود حلش کند. اسمش Dual-Write است.
۶) مشکلِ Dual-Write و راهحلِ آن: الگوی Outbox
هر مرحلهی Saga دو کارِ همزمان میکند: (۱) دیتابیسِ لوکال را عوض میکند و (۲) یک رویداد/پیام منتشر میکند. اما اینها دو سیستمِ متفاوتاند (دیتابیس و Kafka) و یک تراکنشِ مشترک ندارند. این «نوشتن در دو منبع» همان Dual-Write Problem است.
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order); // (۱) کامیت در دیتابیس
kafkaTemplate.send("orders", event); // (۲) انتشار در Kafka
}
چهار حالت ممکن است — دوتاشان فاجعهاند:
- دیتابیس کامیت شد، ولی درست بعدش سرویس crash کرد و Kafka هرگز ارسال نشد → سفارش هست، ولی موجودی و پرداخت هرگز خبردار نمیشوند. سفارشِ گمشده.
- Kafka ارسال شد، ولی تراکنشِ دیتابیس rollback خورد → event هست ولی سفارشی وجود ندارد. سرویسهای دیگر روی دادهی فانتوم کار میکنند.
نکتهی موذی: @Transactional فقط دیتابیس را میپوشاند. kafkaTemplate.send بیرونِ مرزِ تراکنشِ دیتابیس است. حتی اگر جای دو خط را عوض کنی، مشکل فقط شکلش عوض میشود، حل نمیشود. هیچ ترتیبی از دو نوشتنِ مستقل، اتمیک نمیشود.
راهحل: Transactional Outbox
ایده نبوغآمیز و ساده است: بهجای نوشتن در دو سیستم، در یک سیستم بنویس. یک جدولِ outbox در همان دیتابیسِ سرویس بساز. در همان تراکنشِ لوکال که دادهی کسبوکاری را مینویسی، رویداد را هم بهعنوان یک ردیف در جدولِ outbox insert کن. چون هر دو در یک تراکنشِ دیتابیساند، یا هر دو کامیت میشوند یا هیچکدام — اتمیک، تضمینشده.
بعد یک فرایندِ جداگانه (relay) ردیفهای outbox را میخواند و به Kafka میفرستد و علامتشان میزند.
جریانِ الگوی Outbox با CDC | Transactional Outbox with change data capture:
flowchart LR
App[Order Service] -->|single local tx| DB[(Order DB)]
DB --- OB[outbox table]
CDC[Debezium Connector] -->|reads WAL/redo log| OB
CDC -->|publishes| Kafka[(Kafka)]
Kafka --> Consumers[Inventory / Payment]
DDLِ جدولِ outbox را در هر دو دیالکت ببین. ساختار عمداً با انتظاراتِ Debezium همراستاست (id, aggregatetype, aggregateid, type, payload):
-- PostgreSQL
CREATE TABLE outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregatetype VARCHAR(255) NOT NULL, -- مثلا "Order" -> نگاشت به نامِ topic
aggregateid VARCHAR(255) NOT NULL, -- مثلا orderId -> کلیدِ پیام (پارتیشنبندی)
type VARCHAR(255) NOT NULL, -- مثلا "OrderCreated"
payload JSONB NOT NULL, -- بدنهی رویداد
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Oracle (19c/23ai)
CREATE TABLE outbox (
id RAW(16) DEFAULT SYS_GUID() PRIMARY KEY,
aggregatetype VARCHAR2(255) NOT NULL,
aggregateid VARCHAR2(255) NOT NULL,
type VARCHAR2(255) NOT NULL,
payload CLOB NOT NULL, -- یا JSON در 23ai
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL
);
- UUID/GUID: در Postgres نوعِ
UUIDبومی وgen_random_uuid()داری؛ در Oracle معمولاًRAW(16)+SYS_GUID(). اگر UUID را در اپ تولید میکنی، این تفاوت مهم نیست. - JSON: Postgres نوعِ
JSONB(باینریِ ایندکسپذیر) دارد؛ Oracle تا 19c معمولاًCLOBباIS JSONconstraint، و در 23ai یک دیتاتایپِ بومیِJSONمعرفی شد. - timestamp:
TIMESTAMPTZدر Postgres ≡TIMESTAMP WITH TIME ZONEدر Oracle. تابعِ زمانِ حال:now()در Postgres،SYSTIMESTAMPدر Oracle. - رشتهی خالی: یادت باشد Oracle رشتهی خالی
''راNULLمیبیند؛ اگر جایی payload را رشتهی خالی بگذاری،NOT NULLدر Oracle میترکد ولی در Postgres نه. تلهی کلاسیکِ پورتکردن.
کدِ سرویس حالا فقط با یک تراکنشِ لوکال سروکار دارد — نه Kafka، نه dual-write:
@Service
public class OrderService {
private final OrderRepository orders;
private final OutboxRepository outbox;
private final ObjectMapper json;
@Transactional // یک تراکنشِ اتمیکِ لوکال: هر دو insert با هم کامیت میشوند
public void placeOrder(PlaceOrderCommand cmd) {
Order order = Order.pending(cmd);
orders.save(order);
OutboxEvent event = new OutboxEvent(
"Order", // aggregatetype -> topic
order.getId().toString(), // aggregateid -> کلیدِ پیام
"OrderCreated", // type
json.writeValueAsString(new OrderCreated(order)) // payload
);
outbox.save(event); // فقط یک نوشتنِ دیگر در همان دیتابیس؛ بدونِ Kafka
} // COMMIT: یا هر دو ردیف، یا هیچکدام
}
دو راه برای انتشارِ outbox: Polling در برابر CDC
Polling Publisher: یک job با فاصلهی کوتاه SELECT ... FROM outbox WHERE processed = false ORDER BY created_at میزند، هر ردیف را به Kafka میفرستد و علامتِ processed=true میزند (یا ردیف را DELETE میکند). ساده و بدونِ زیرساختِ اضافه، اما latency دارد (به بازهی polling وابسته)، به دیتابیس بارِ کوئریِ مداوم میزند، و باید ترتیب و همزمانی (چند instance) را دستی مدیریت کنی (SELECT ... FOR UPDATE SKIP LOCKED).
Change Data Capture (CDC): بهجای کوئریزدن، یک ابزار مثل Debezium مستقیم لاگِ تراکنشِ دیتابیس (WAL در Postgres، redo/LogMiner یا OpenLogReplicator در Oracle، binlog در MySQL) را میخواند و هر INSERT در جدولِ outbox را بهصورتِ real-time به Kafka میفرستد. بارِ کوئری روی دیتابیس نمیگذارد، latencyِ پایین دارد، و ترتیب را از خودِ لاگ حفظ میکند.
Polling ساده است و برای شروع خوب است، ولی در مقیاس، آن SELECT ... WHERE processed=false هر ۲۰۰ms روی یک جدولِ داغ، خودش یک منبعِ فشار میشود. CDC چون از لاگِ از-پیش-نوشتهشدهی دیتابیس میخواند، عملاً مجانی است (دیتابیس آن لاگ را بههرحال مینویسد) و push-based و real-time است. هزینهاش پیچیدگیِ عملیاتی است: باید Kafka Connect + Debezium را نگه داری. قاعده: تیمِ کوچک و بارِ کم → Polling؛ سیستمِ جدی و event-driven → CDC/Debezium.
Debezium + Outbox Event Router
Debezium یک ترنسفورمِ آماده به نامِ Outbox Event Router (یک Single Message Transform) دارد که دقیقاً برای این الگو ساخته شده: ردیفِ خامِ CDC از جدولِ outbox را میگیرد و به یک پیامِ تمیز روی topicِ درست تبدیل میکند. بهصورتِ پیشفرض aggregatetype مقصدِ topic را تعیین میکند (outbox.event.${aggregatetype})، aggregateid کلیدِ پیام میشود (تا رویدادهای یک aggregate در یک پارتیشن و مرتب بمانند)، و payload بدنهی پیام.
پیکربندیِ کانکتورِ Debezium برای Postgres با outbox router (Debezium 3.6، روی Kafka Connect):
{
"name": "order-outbox-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "order-db",
"database.port": "5432",
"database.user": "debezium",
"database.password": "${secret:pg-pass}",
"database.dbname": "orders",
"topic.prefix": "orderdb",
"table.include.list": "public.outbox",
"plugin.name": "pgoutput",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
"transforms.outbox.route.by.field": "aggregatetype",
"transforms.outbox.table.field.event.key": "aggregateid",
"transforms.outbox.table.field.event.payload": "payload",
"transforms.outbox.route.topic.replacement": "outbox.event.${routedByValue}"
}
}
- Debezium 3.6.0.Final (تیر ۱۴۰۵ / جولای 2026) روی Kafka 4.3 ساخته شده؛ سریِ 3.2 روی Kafka Connect 3.9.1 بود. همیشه ماتریسِ سازگاریِ نسخهی دقیق را از مستندِ رسمی چک کن.
- در Postgres باید
plugin.nameراpgoutputبگذاری (پلاگینِ built-in منطقِ لاجیکال) وwal_level=logicalباشد. - در Oracle، Debezium از LogMiner (پیشفرض) یا OpenLogReplicator استفاده میکند و باید ARCHIVELOG mode روشن باشد و supplemental logging فعال.
- چون بعد از انتشار به ردیفهای outbox نیازی نداری، معمولاً یا یک retention/purge job میگذاری یا با یک trigger فوراً DELETE میکنی (Debezium خودِ event را از خودِ عملِ INSERT در WAL میگیرد، پس حذفِ ردیف مشکلی ایجاد نمیکند).
CDC/Outbox به تو at-least-once delivery میدهد، نه exactly-once. اگر relay بعد از ارسال ولی قبل از commitِ offset بمیرد، همان پیام دوباره فرستاده میشود. پس همهی مصرفکنندهها باید idempotent باشند: یک event_id یکتا بفرست و در سمتِ مصرف، یک جدولِ processed_events نگه دار و رویدادِ تکراری را نادیده بگیر. اگر این را جدی نگیری، در prod پرداختِ دوباره و کسرِ موجودیِ دوباره میبینی. این تنها بزرگترین علتِ باگ در سیستمهای event-driven است.
مصرفکنندهی idempotent در سمتِ Inventory:
@KafkaListener(topics = "outbox.event.Order")
@Transactional
public void on(ConsumerRecord<String, String> rec) {
String eventId = header(rec, "id"); // شناسهی یکتای رویداد از هدر
if (processedEvents.existsById(eventId)) {
return; // دیدهایمش؛ نادیده بگیر (idempotency)
}
OrderCreated evt = json.readValue(rec.value(), OrderCreated.class);
inventory.reserve(evt.orderId(), evt.items()); // منطقِ کسبوکار
processedEvents.save(new ProcessedEvent(eventId)); // ثبتِ «دیده شد» در همان تراکنش
}
۷) CQRS — جدا کردنِ خواندن از نوشتن
تا اینجا نوشتن را حل کردیم. اما خواندن چطور؟ فرض کن صفحهی «سفارشهای من» باید نام و عکسِ محصول (از سرویسِ Catalog)، آدرس (از سرویسِ User)، و وضعیتِ ارسال (از سرویسِ Shipping) را کنارِ هم نشان دهد. با Database-per-Service دیگر نمیتوانی یک JOIN بین اینها بزنی — جداول در دیتابیسهای مختلفاند. این «مشکلِ کوئریِ بینسرویسی» است.
در یک رستوران، آشپز (نوشتن/Command) با دقتِ زیاد و آهسته یک غذای درست میسازد؛ مدلش پیچیده و نرمالایز است. پیشخدمت (خواندن/Query) فقط یک بشقابِ آماده و سریع میخواهد که به مشتری بدهد؛ برایش شکلِ نهایی مهم است نه اینکه چطور پخته شد. CQRS یعنی این دو نقش را از هم جدا کنی: یک مدل برای نوشتن، یک مدلِ کاملاً متفاوت و بهینه برای خواندن.
CQRS (Command Query Responsibility Segregation) یعنی مسئولیتِ نوشتن (Command) و خواندن (Query) را به دو مدلِ جدا بسپاری. مدلِ نوشتن نرمالایز و درست است؛ مدلِ خواندن (Read Model / Materialized View) از پیش join و denormalize شده تا کوئریِ صفحه در یک خواندنِ ساده جواب بدهد.
این مدلِ خواندن چطور پر میشود؟ با همان eventهایی که از Outbox/Kafka بیرون میآیند. یک سرویسِ خواندن (یا یک ماژول در سرویسِ Order) به رویدادهای OrderCreated, OrderShipped, ProductRenamed, ... گوش میدهد و یک جدولِ تختِ آمادهی نمایش میسازد و بهروز نگه میدارد.
معماریِ CQRS با مدلِ خواندنِ رویداد-محور | CQRS with an event-driven read model:
flowchart LR
Cmd[Command API] -->|writes| WriteDB[(Write Model - normalized)]
WriteDB -->|outbox/CDC events| Kafka[(Kafka)]
Kafka --> Projector[Projector]
Projector -->|denormalized upsert| ReadDB[(Read Model - query-optimized)]
Query[Query API] -->|fast reads| ReadDB
پروجکشن (projector) که مدلِ خواندن را میسازد:
@Component
public class OrderSummaryProjector {
private final OrderSummaryRepository readModel;
@KafkaListener(topics = {"outbox.event.Order", "outbox.event.Shipping"})
@Transactional
public void on(DomainEvent evt) {
switch (evt) {
case OrderCreated e -> readModel.upsert(new OrderSummary(
e.orderId(), e.userId(), e.total(), "PENDING", e.createdAt()));
case OrderShipped e -> readModel.updateStatus(e.orderId(), "SHIPPED");
case ProductRenamed e -> // denormalizationِ نام
readModel.renameProductEverywhere(e.productId(), e.newName());
default -> { /* نادیده بگیر */ }
}
}
}
کوئریِ upsertِ مدلِ خواندن هم دیالکتمحور است:
-- PostgreSQL: upsert بومی
INSERT INTO order_summary (order_id, user_id, total, status, created_at)
VALUES (:orderId, :userId, :total, 'PENDING', :createdAt)
ON CONFLICT (order_id)
DO UPDATE SET total = EXCLUDED.total, status = EXCLUDED.status;
-- Oracle: MERGE
MERGE INTO order_summary t
USING (SELECT :orderId AS order_id FROM dual) s
ON (t.order_id = s.order_id)
WHEN MATCHED THEN
UPDATE SET t.total = :total, t.status = :status
WHEN NOT MATCHED THEN
INSERT (order_id, user_id, total, status, created_at)
VALUES (:orderId, :userId, :total, 'PENDING', :createdAt);
Postgres با INSERT ... ON CONFLICT ... DO UPDATE (که به آن UPSERT میگویند) و کلمهی کلیدیِ جادوییِ EXCLUDED برای اشاره به ردیفِ پیشنهادی. Oracle با MERGE و آن FROM dualِ مشهور. هر دو کار را میکنند ولی گرامرشان کاملاً فرق دارد؛ اگر لایهی persistence را قابلِپورت میخواهی، این کوئریها را پشتِ یک repository interface پنهان کن.
- پیچیدگیِ عملیاتی: حالا دو مدلِ داده، یک pipelineِ event، و یک projector داری که باید monitor شود. برای یک CRUDِ ساده، CQRS over-engineering است. فقط جایی بیاورش که الگوی خواندن و نوشتن واقعاً متفاوت است یا کوئریِ بینسرویسی داری.
- Read-your-own-writes: کاربر یک سفارش ثبت میکند و بلافاصله صفحهی سفارشها را باز میکند — ولی مدلِ خواندن هنوز بهروز نشده (پنجرهی eventual consistency). کاربر فکر میکند سفارشش گم شده. راهحلها: بعد از نوشتن، UI را از خودِ پاسخِ command خوشبینانه آپدیت کن (optimistic update)، یا کوتاهمدت از write model بخوان، یا صریحاً «در حالِ پردازش» نشان بده.
- بازسازیِ projection: اگر منطقِ projector باگ داشت، مدلِ خواندن خراب میشود. باید بتوانی projection را از صفر بازسازی کنی (از ابتدای topic دوباره consume کنی). این یعنی projector باید idempotent باشد و Kafka باید retention کافی داشته باشد (یا از event store بخوانی).
۸) Event Sourcing — یک قدم جلوتر
CQRS اغلب با یک ایدهی رادیکالتر همسفر میشود: Event Sourcing.
یک حسابدارِ درست، «موجودیِ فعلی» را روی کاغذ پاکنویس نمیکند تا با هر تراکنش عوضش کند. او یک دفترِ append-only از تکتکِ تراکنشها نگه میدارد (واریز ۱۰۰، برداشت ۳۰، ...). موجودیِ فعلی صرفاً حاصلِ جمعِ همهی تراکنشها است. اگر بخواهد، میتواند موجودی را در هر لحظهای از تاریخ بازسازی کند. Event Sourcing همین است.
در Event Sourcing، بهجای ذخیرهی وضعیتِ فعلیِ موجودیت، دنبالهی تغییرات (eventها) را ذخیره میکنی. وضعیتِ فعلی با «پخشِ مجددِ» (replay) همهی eventها از ابتدا محاسبه میشود. جدولی به نامِ orders با ستونِ status نداری؛ بهجایش یک event store داری با: OrderCreated, ItemAdded, OrderPaid, OrderShipped.
مزایا: auditِ کامل و رایگان (کلِ تاریخچه ذاتاً هست)، توانِ سفر در زمان (وضعیت در هر لحظه)، و منبعِ طبیعی برای CQRS و integration (خودِ eventها منبعِ حقیقتاند، پس دیگر dual-write نداری).
این سنگینترین ابزارِ این فصل است و اکثرِ سیستمها به آن نیاز ندارند. چالشهایش واقعیاند:
- Schema evolution رویدادها: یک event که ۳ سال پیش نوشته شده هنوز باید خوانده شود؛ باید versioning و upcasting داشته باشی.
- کوئریِ سخت: «همهی سفارشهای PENDING» یعنی replay یا اتکای کامل به projectionهای CQRS.
- Snapshotting: برای aggregateای با هزاران event، replay از صفر کند است؛ باید snapshot بگیری.
- پیچیدگیِ ذهنی: کلِ تیم باید طرزِ فکرِ event-first را بلد باشد. قاعدهی سنیور: Event Sourcing را فقط جایی بیاور که تاریخچه و auditِ کامل یک نیازِ کسبوکاریِ درجهیک است (بانکداری، حسابداری، دامنههای رگولاتوری). برای بقیه، Outbox + CQRS معمولاً کافی و بسیار ارزانتر است. CQRS بدونِ Event Sourcing کاملاً معتبر و رایج است؛ این دو جدا از هماند.
۹) طراحی برای سازگاریِ نهایی و مالکیتِ داده
حالا که ابزارها را داری، اصولِ طراحی که یک سنیور رعایت میکند:
۱) مرزِ تراکنش = مرزِ aggregate. یک تراکنشِ لوکال فقط یک aggregate را تغییر میدهد. تغییرِ چند aggregate در چند سرویس = Saga، نه تراکنشِ توزیعشده.
۲) یک صاحب برای هر داده. هر فیلد یک source of truth دارد. بقیه فقط کپیِ read-only (read model) نگه میدارند و از event بهروز میکنند.
۳) هر پیام idempotent مصرف شود. at-least-once را پیشفرض بگیر.
۴) ترتیب را جدی بگیر. با همکلید کردنِ eventهای یک aggregate روی یک پارتیشنِ Kafka (aggregateid بهعنوان key) ترتیب را حفظ کن.
۵) پنجرهی ناسازگاری را monitor کن. فاصلهی بین نوشتن و بهروزشدنِ read model یک متریک است؛ SLA بگذار و alert بزن.
همیشه یک پیام هست که هیچوقت درست پردازش نمیشود (payloadِ خراب، باگ، دادهی ناسازگار). اگر بینهایت retry کنی، آن «پیامِ سمی (poison message)» کلِ پارتیشن را میبندد و مصرف را متوقف میکند. راهحل: بعد از N بار retry، پیام را به یک Dead Letter Topic (DLT) بفرست و برو سراغِ بعدی، و روی DLT alert بگذار تا انسان بررسی کند. Spring Kafka با DefaultErrorHandler + DeadLetterPublishingRecoverer این را built-in دارد. فراموشکردنِ DLT یعنی یک باگِ کوچک، کلِ pipeline را میخواباند.
یک سؤالِ ظریف: در event چهقدر داده بگذاریم؟ دو مکتب: event لاغر (thin/notification) فقط orderId را میفرستد و مصرفکننده برای جزئیات API میزند (کوپلینگِ runtime دارد و بارِ اضافه)؛ event چاق (fat/event-carried state transfer) کلِ snapshotِ لازم را حمل میکند (مصرفکننده مستقل است ولی event بزرگ و کوپلینگِ schema بیشتر). سنیورها معمولاً حدِ وسط را میگیرند: بهاندازهی نیازِ اکثرِ مصرفکنندهها داده بگذار، ولی مراقب باش که event به یک API عمومیِ شکننده تبدیل نشود — همان دقتی که برای یک REST API داری، برای schemaِ eventات هم داشته باش (از Schema Registry و سازگاریِ backward استفاده کن).
مقایسهی جمعبندیِ الگوها:
| الگو | چه مشکلی را حل میکند | هزینه/پیچیدگی | کِی استفاده کنم |
|---|---|---|---|
| Database per Service | استقلال، ایزولاسیون، polyglot | از دسترفتنِ join و tx لوکال | تقریباً همیشه در میکروسرویس |
| Saga | تراکنشِ چندسرویسی بدونِ 2PC | منطقِ جبران، پیچیدگیِ جریان | هر عملیاتِ کسبوکاریِ چندسرویسی |
| Transactional Outbox | dual-write / atomicity نوشتن+انتشار | یک جدول + relay/CDC | هرجا event منتشر میکنی |
| CDC (Debezium) | انتشارِ real-time و کمبار از outbox | عملیاتِ Kafka Connect | سیستمِ event-driven جدی |
| CQRS | کوئریِ بینسرویسی، read/write نامتقارن | دو مدل + projector | صفحههای read-heavy پیچیده |
| Event Sourcing | audit کامل، تاریخچه، time-travel | بالاترین؛ evolution و snapshot | دامنههای audit-critical |
- در میکروسرویس هر سرویس مالکِ دیتابیسِ خودش است؛ دسترسیِ دیگران فقط از راهِ API یا event. Shared Database آنتیپترن است و یک مونولیتِ توزیعشده میسازد.
- تراکنشِ توزیعشده / 2PC/XA بهخاطرِ قفلِ طولانی، کوپلینگِ availability و ناسازگاری با Kafka، عملاً کنار گذاشته شده.
- بهجایش Eventual Consistency را میپذیریم و با Saga (دنبالهی تراکنشهای لوکال + اکشنهای جبرانی) اتمیک بودنِ توزیعشده را شبیهسازی میکنیم — کروگرافی برای جریانِ ساده، ارکستراسیون برای پیچیده.
- مشکلِ Dual-Write با الگوی Transactional Outbox حل میشود: نوشتنِ داده و event در یک تراکنشِ لوکال، و انتشار با Polling یا (بهتر) CDC/Debezium. تحویل at-least-once است، پس مصرفکنندهها باید idempotent باشند و DLT داشته باشی.
- CQRS خواندن را از نوشتن جدا میکند و یک read model رویداد-محور برای کوئریِ سریع و بینسرویسی میسازد؛ حواست به read-your-writes و بازسازیِ projection باشد.
- Event Sourcing تغییرات را بهجای وضعیت ذخیره میکند؛ قدرتمند ولی گران — فقط برای دامنههای audit-critical.
- در مصاحبه: قوی در مرزِ aggregate، نهایی بینِ aggregateها، یک صاحب برای هر داده، idempotency همهجا. همین چند جمله سطحِ سنیور را نشان میدهد.
پرسشهای مصاحبه
جواب: برای رسیدن به هدفِ اصلیِ میکروسرویس — استقلالِ کامل. اگر سرویسها دیتابیس را به اشتراک بگذارند، schema تبدیل به یک قراردادِ مشترک میشود که هیچ تیمی نمیتواند بدونِ هماهنگیِ بقیه تغییرش دهد؛ یعنی کوپلینگِ زمانِ استقرار برمیگردد. دیتابیسِ خصوصی چهار چیز میدهد: استقلالِ استقرار و schema، آزادیِ انتخابِ تکنولوژی (polyglot persistence)، ایزولاسیونِ خطا و عملکرد (blast radius محدود)، و مقیاسپذیریِ مستقل. تأکید میکنم که «خصوصی» یعنی هیچ سرویسِ دیگری مستقیم به جداولش دست نمیزند — دسترسی فقط از راهِ API یا event.
جواب: Shared Database یعنی چند سرویس روی همان جداول کوئری/نوشتن میکنند؛ در میکروسرویسِ مستقلمستقر این آنتیپترن است چون کوپلینگِ پنهانِ schema، جنگِ قفل، و از بین رفتنِ مالکیت میآورد. Database-per-Service هر سرویس را صاحبِ دیتابیسِ خودش میکند. استثنا: در یک مونولیتِ ماژولار (یک deployment، یک تراکنش) اشتراکِ فیزیکیِ دیتابیس با schemaهای منطقیِ جدا کاملاً درست است. قاعده: Shared Database فقط وقتی بد است که سرویسها مستقلاً مستقر و مقیاس میشوند.
جواب: چون در مقیاس و ناهمگونی میشکند. 2PC بین prepare و commit منابع را در همهی participantها قفل میکند؛ یک سرویسِ کند throughput همه را میخواباند. availability کلِ سیستم میشود حاصلضربِ availability اجزا (اگر یکی پایین باشد تراکنش گیر میکند). اگر coordinator بعد از prepare بمیرد، in-doubt lockها میمانند. و مهمتر: brokerهای مدرن مثل Kafka و اکثرِ NoSQLها XA را پشتیبانی نمیکنند، پس اصلاً نمیتوانی دیتابیس و Kafka را در یک تراکنشِ اتمیک بگذاری. بهجایش Saga + Outbox و سازگاریِ نهایی میرود.
جواب: Saga دنبالهای از تراکنشهای لوکال است؛ هر مرحله در دیتابیسِ سرویسِ خودش کامیت میشود و مرحلهی بعدی را (با event یا command) روشن میکند. چون هر مرحله واقعاً کامیت شده، رولبکِ خودکار ممکن نیست؛ بهجایش هر مرحله یک تراکنشِ جبرانیِ (compensating) معناییِ معکوس دارد که در صورتِ شکستِ مراحلِ بعدی اجرا میشود — مثلاً «کسرِ پول» با «بازپرداخت» جبران میشود، نه با undoِ دیتابیسی. جبرانها باید idempotent و عملاً همیشه-موفق باشند. اکشنهای غیرقابلبازگشت (ایمیل، ارسال) باید به آخرین مراحل (بعد از pivot) منتقل شوند.
جواب: در کروگرافی مغزِ مرکزی نیست؛ هر سرویس به eventها گوش میدهد و واکنش نشان میدهد. کوپلینگِ شل، اما منطقِ جریان پخششده و دیباگ سخت؛ خطرِ حلقهی event و «معماریِ pinball». در ارکستراسیون یک orchestrator با state machine به هر سرویس command میدهد و مرحلهی بعد را تصمیم میگیرد؛ جریان متمرکز و قابلمشاهده، اما یک نقطهی تمرکز (باید HA و بادوام باشد). قاعده: ۲–۳ مرحلهی ساده → کروگرافی؛ جریانِ بلند، شرطی و پیچیده → ارکستراسیون. اگر نمیتوانی جریان را روی وایتبورد بکشی، وقتِ orchestrator است.
جواب: وقتی در یک عملیات باید هم دیتابیس را عوض کنی و هم یک event به Kafka بفرستی، اینها دو سیستمِ جدا با تراکنشهای جدا هستند. اگر دیتابیس کامیت شود ولی درست بعدش سرویس crash کند، event هرگز نمیرود → سفارشِ گمشده؛ اگر event برود ولی تراکنشِ دیتابیس rollback شود → event فانتوم. @Transactional فقط دیتابیس را میپوشاند، نه Kafka را. هیچ ترتیبی از دو نوشتنِ مستقل اتمیک نمیشود. راهحل: الگوی Transactional Outbox.
جواب: بهجای نوشتن در دو سیستم، در یک سیستم مینویسی. در همان تراکنشِ لوکالی که دادهی کسبوکاری را ذخیره میکنی، event را هم بهعنوان یک ردیف در جدولِ outbox در همان دیتابیس insert میکنی. چون هر دو در یک تراکنشِ دیتابیساند، atomicity تضمین شده — یا هر دو یا هیچ. بعد یک فرایندِ جدا (relay) ردیفهای outbox را میخواند و به Kafka میفرستد. حالا مرزِ اتمیک فقط داخلِ یک دیتابیس است و dual-write حذف میشود.
جواب: Polling Publisher با یک job مرتباً SELECT ... WHERE processed=false میزند و ردیفها را میفرستد؛ ساده و بدونِ زیرساختِ اضافه، ولی latency به بازهی polling وابسته است، به دیتابیس بارِ کوئری میزند، و همزمانیِ چند instance را باید با FOR UPDATE SKIP LOCKED دستی حل کنی. CDC (مثلِ Debezium) مستقیم لاگِ تراکنشِ دیتابیس (WAL/redo/binlog) را میخواند و INSERTهای outbox را real-time منتشر میکند؛ بارِ کوئری صفر، latency پایین، ترتیب از خودِ لاگ. هزینهاش عملیاتِ Kafka Connect + Debezium است. تیمِ کوچک → polling؛ سیستمِ جدی → CDC.
جواب: Debezium یک پلتفرمِ CDC است که تغییراتِ ردیفیِ دیتابیس را از لاگِ تراکنش میخواند و بهصورتِ event در Kafka منتشر میکند (برای Postgres با pgoutput، برای Oracle با LogMiner/OpenLogReplicator). Outbox Event Router یک Single Message Transform است که ردیفِ خامِ CDC از جدولِ outbox را به یک پیامِ تمیز روی topicِ درست تبدیل میکند: پیشفرض aggregatetype نامِ topic را میسازد (outbox.event.${...})، aggregateid کلیدِ پیام (برای ترتیب و پارتیشنبندی) و payload بدنه. Debezium 3.6.0.Final روی Kafka 4.3 اجرا میشود.
جواب: چون Outbox/CDC تحویلِ at-least-once میدهد نه exactly-once. اگر relay بعد از ارسال ولی قبل از commitِ offset بمیرد، همان پیام دوباره میرود. اگر مصرفکننده idempotent نباشد، پرداختِ دوباره یا کسرِ موجودیِ دوباره اتفاق میافتد. راهِ استاندارد: هر event یک event_id یکتا داشته باشد، و مصرفکننده در همان تراکنشِ کسبوکاری، یک جدولِ processed_events را چک/insert کند و رویدادِ تکراری را نادیده بگیرد. این تنها بزرگترین علتِ باگ در سیستمهای event-driven است.
جواب: CQRS یعنی جدا کردنِ مدلِ نوشتن (Command) از مدلِ خواندن (Query). مدلِ نوشتن نرمالایز و درست است؛ مدلِ خواندن یک view از پیش join/denormalize شده که برای کوئریِ سریع بهینه است. در میکروسرویس مفید است چون با Database-per-Service دیگر نمیتوانی بینِ سرویسها JOIN بزنی؛ بهجایش یک read model میسازی که از eventهای سرویسهای مختلف پر میشود و کوئریِ صفحه را در یک خواندنِ ساده جواب میدهد. توجه: CQRS الزاماً یعنی دو دیتابیس نیست؛ حتی جدا کردنِ منطقیِ command و query در یک سرویس هم CQRS است.
جواب: چون read model بهصورتِ eventual بهروز میشود، کاربر ممکن است بلافاصله بعد از نوشتن، دادهی جدیدش را در read model نبیند (پنجرهی inconsistency) و فکر کند کارش گم شده. راهحلها: (۱) بعد از موفقیتِ command، UI را خوشبینانه از خودِ پاسخِ command آپدیت کن (optimistic update)؛ (۲) برای آن کاربرِ خاص، کوتاهمدت از write model بخوان؛ (۳) صریحاً وضعیتِ «در حالِ پردازش» نشان بده تا انتظارِ کاربر مدیریت شود. انتخاب به UX بستگی دارد، ولی نکته این است که این یک تصمیمِ آگاهانه باشد، نه یک سورپرایز در prod.
جواب: در Event Sourcing بهجای ذخیرهی وضعیتِ فعلیِ موجودیت، دنبالهی رویدادهای تغییر را در یک event storeِ append-only ذخیره میکنی و وضعیتِ فعلی را با replay محاسبه میکنی. مزایا: auditِ کامل و رایگان، سفر در زمان، و منبعِ طبیعی برای CQRS و integration. اما گران است: schema evolution رویدادها، snapshotting برای aggregateهای بزرگ، کوئریِ سخت، و بارِ ذهنیِ تیم. فقط جایی استفاده کن که تاریخچه و auditِ کامل یک نیازِ کسبوکاریِ درجهیک است (بانکداری، حسابداری، رگولاتوری). CQRS بدونِ Event Sourcing کاملاً معتبر است؛ این دو جدا از هماند.
جواب: با ترجمهی فنی به زبانِ محصول و ریسک. میگویم: «ما سازگاریِ قوی را داخلِ مرزِ هر aggregate تضمین میکنیم؛ بینِ aggregateها یک پنجرهی چندصدمیلیثانیهای تا چندثانیهای هست که monitor و SLA دارد.» بعد با کسبوکار مشخص میکنم کجا این پنجره قابلقبول است (نمایشِ سفارش در فهرست) و کجا نیست (کسرِ موجودیِ محدود که نباید oversell شود — آنجا باید در همان سرویس، سازگاریِ قوی و رزروِ اتمیک بگذاری). نکته این است که Eventual Consistency یک تصمیمِ محصولیِ آگاهانه است با SLA، نه یک نقصِ پنهان.
جواب: همیشه پیامی هست که هرگز درست پردازش نمیشود (payloadِ خراب، باگ، دادهی ناسازگار). اگر بینهایت retry کنی، آن «poison message» کلِ پارتیشن را میبندد و مصرف را متوقف میکند. راهحل: بعد از N بار retryِ ناموفق، پیام را به یک Dead Letter Topic بفرست و ادامه بده، و روی DLT alert بگذار تا انسان بررسی کند. در Spring Kafka با DefaultErrorHandler + DeadLetterPublishingRecoverer (با backoff) built-in است. بدونِ DLT، یک پیامِ بد کلِ pipeline را میخواباند.
By now you've learned how to split services apart, make them talk, and keep them resilient. Now we reach the hardest, most unforgiving part of microservices: data. Pretty stateless code splits easily; but the moment two services must agree on a business fact ("the order was placed, stock was decremented, money was charged"), the full weight of distributed systems lands on your shoulders.
In a monolith this agreement was almost free: one @Transactional, one database, one atomic commit. Either everything happened or nothing did. In microservices that magic disappears, because you no longer have one database and one transaction. This chapter is about how, when you lose that magic, you rebuild a trustworthy system with the right tools (Saga, Outbox, CDC, CQRS) — instead of pretending the problem isn't there.
- Database-per-Service: why each service owns its own database and its data.
- The Shared-Database anti-pattern: why a shared DB is the silent killer of microservices.
- Why we abandon distributed transactions / 2PC / XA.
- Eventual Consistency: what it is and the mindset it demands.
- Saga: orchestration vs choreography, and compensating actions.
- The Dual-Write problem and its cure: the Transactional Outbox pattern + CDC/Debezium.
- CQRS and read models; an intro to Event Sourcing.
- Design principles for eventual consistency and per-service data ownership.
1) Each Service, Its Own Database — Database per Service
A monolith is like a hotel's central kitchen: every dish comes out of one kitchen and everyone draws from one shared pantry. Microservices are like a food court: each stall (pizza, sushi, coffee) has its own kitchen, pantry, and cash register. If the sushi pantry runs empty, the pizza stall carries on unbothered. But if everyone drew from one shared pantry, a single wrong lock on the rice shelf freezes the whole food court. Database-per-Service means every stall owns its pantry.
The Database per Service principle sounds simple but runs deep: each microservice has its own private database, and no other service is allowed to touch those tables directly. The only way to reach a service's data is through its API or its events. A service's tables are its internal details — exactly like private fields in a class.
"Private database" doesn't necessarily mean a separate physical installation. There are three isolation levels, each with its place:
| Isolation level | Meaning | When it fits |
|---|---|---|
| Private schema / private tables | Shared instance, but each service gets its own schema or table set; DB grants block trespass | Small team, infra cost matters |
| Private database (shared server) | Each service has its own database on the same server | Common middle ground |
| Private database server | Each service gets a fully separate DB server | High scale, perf & security isolation |
Data ownership is at the service level, never shared at the table level. Every piece of data has exactly one owner — the only valid writer. Everyone else sees only a copy/view of that data, via API or event.
Here's the shape of a healthy system. Database-per-Service topology | Each service owns a private database:
flowchart LR
Client --> Gateway
Gateway --> OrderSvc[Order Service]
Gateway --> InventorySvc[Inventory Service]
Gateway --> PaymentSvc[Payment Service]
OrderSvc --> OrderDB[(Order DB)]
InventorySvc --> InvDB[(Inventory DB)]
PaymentSvc --> PayDB[(Payment DB)]
OrderSvc -. events .-> Broker[(Kafka)]
InventorySvc -. events .-> Broker
PaymentSvc -. events .-> Broker
Why does this matter so much?
Team and deployment independence. If the Order team wants to add a column or migrate from Postgres to a document store, they shouldn't have to coordinate with any other team. A shared schema means every schema change must be negotiated across all teams — exactly the coupling microservices were supposed to remove.
Freedom to pick technology (Polyglot Persistence). The catalog service might want Elasticsearch, the cart Redis, accounting a strict ACID Postgres, and the social graph Neo4j. Each service picks the right tool for its job.
Fault and performance isolation. A heavy query or full-table lock in one service doesn't freeze the others. The blast radius stays contained.
Independent scalability. You can shard or add replicas to a read-heavy hot service without bloating anyone else's database.
Database-per-Service is not "free." You trade away local joins and local transactions to buy independence. If your domain is full of joins and tight transactions across entities with no stable boundaries, maybe you're not ready to split yet. Seniors first draw the right context boundary (DDD's Bounded Context), then split the database — not the reverse. Splitting the database before understanding the domain leads to a "distributed monolith," the worst of all worlds.
2) The Shared-Database Anti-Pattern — the silent killer
Many teams migrate to microservices, split the services apart, but keep everyone connected to one shared database. On paper they have microservices; in practice they've built a distributed monolith with the drawbacks of both worlds.
Why is it so bad? Suppose Order and Reporting both query the orders table directly.
- Hidden schema coupling: The Order team changes the
statuscolumn from aVARCHARto a lookup table. Reporting silently breaks — and nobody knows until a customer calls. The DB schema becomes an unpublished public API that no one can safely change. - Lost ownership: When bad data shows up in
orders, who wrote it? Order? Reporting doing a sneaky hiddenUPDATE? There is no single source of truth. - Resource and lock contention: A long-running report grabs locks and I/O on the same tables, slowing the critical order-placement path.
- Independent deployment becomes meaningless: Every migration must be coordinated with everyone. You're effectively back on a shared release train.
If two services in your design SELECT/UPDATE the same table, the interviewer instantly knows you drew the boundaries wrong. The right answer is always: "Service B gets service A's data through A's API or through an event A publishes, and keeps a local read-only copy (read model) if needed — it never touches A's tables directly."
Exception: in a modular monolith (e.g. with Spring Modulith 2.1), one database with logically separate schemas is perfectly fine, because you still have one deployment and one transaction. Shared Database is only an anti-pattern when services deploy and scale independently but smuggle a shared database underneath.
3) Why We Abandon Distributed Transactions and 2PC
OK, now every service owns its database. But a business operation often touches several services: "place order" means reserve stock + charge money + create an order record. If each has a separate database, how do they all commit or roll back together?
The classic database-world answer is Two-Phase Commit (2PC) over the XA protocol: a coordinator tells everyone to "prepare," they all take locks and say "ready," then the coordinator says "commit." On paper it's atomic. In microservices, it's almost always wrong.
- Long-held locks = throughput death: Between prepare and commit, resources are locked across all services. One slow service holds the rest hostage. Under high load this is catastrophic.
- Availability coupling: If any participant (or the coordinator) is down, the whole transaction stalls. The system's availability becomes the product of each component's availability — the opposite of what we want from microservices.
- The coordinator blocking problem: If the coordinator dies after prepare, participants sit with locks held (in-doubt transactions) until it comes back. An operational nightmare.
- Poor support: Many databases and virtually all modern brokers (Kafka, etc.) and NoSQL stores support XA poorly or not at all. You cannot put Kafka and Postgres in one XA transaction.
- CAP theorem: In a distributed system, when a network partition happens you must choose between Consistency and Availability. 2PC takes strict C and sacrifices A; most internet systems want the opposite.
So what's the solution? Instead of striving for distributed atomicity, we break atomicity into a sequence of local transactions (each atomic in its own database) and accept that the system is briefly inconsistent before it converges. That idea is called Eventual Consistency, and its execution tool is the Saga.
"2PC is strictly forbidden" is a good interview slogan, but a real senior knows XA still lives in low-throughput internal financial systems with few participants (e.g. a JMS + JDBC transaction against the same RDBMS via Atomikos/Narayana). The rule is: the more distributed, high-traffic, and heterogeneous (Kafka + several databases) the system, the more impractical 2PC becomes.
4) Eventual Consistency
When you wire money from bank A to bank B, there's a moment when the money has left your account but hasn't landed at the destination yet — it's "in flight." The system is inconsistent in that instant, but nobody panics, because everyone knows that eventually the money either arrives or (if it fails) returns to you. The entire global banking system runs on eventual consistency, not on one global atomic lock.
Eventual Consistency means: if no new writes arrive, all replicas and services eventually converge on the same value. Between the write and the convergence there's an inconsistency window — usually milliseconds to a few seconds.
Accepting that window is a product decision, not just a technical one. You must talk to the business: "Is it a problem if a buyer sees their order in 'My Orders' two seconds after placing it?" The answer is almost always "no, that's fine" — and that single decision opens the door to scalable architecture.
Never say "our data becomes inconsistent." Say: "We guarantee strong consistency within the boundary of one aggregate (a local transaction), and eventual consistency between aggregates, whose window we monitor and put an SLA on." That sentence marks the difference between a nervous mid-level and a confident senior.
5) Saga — a distributed transaction without distributed locks
A Saga is a sequence of local transactions. Each step commits in its own service's database and emits an event that triggers the next step. If a step fails, the Saga undoes prior steps with compensating transactions.
Saga has no automatic rollback. Because each step really committed, you can't "un-commit" it. Instead of a rollback, you run a semantic inverse operation. Example: you can't roll back "charge the card," but you can issue a "refund." You compensate a stock reservation with "release the reservation." Compensation is always a new business action, never a database undo.
The compensating transaction — the hard rule
Every step must have an idempotent and effectively always-succeeds compensation. If the "reserve" step's compensation is "release," release must work without error even if the reservation was half-done or already released. A compensation that itself fails means the system is stuck in an inconsistent state forever.
Not all actions are compensatable. If a step sent an email to the customer or shipped a physical product, you can't "unsend" it. Solution: push such irreversible actions as far toward the last step of the Saga as possible (this is the pivot transaction / retriable steps idea). Before the pivot, everything is compensatable; after the pivot, everything is only retriable (it must eventually succeed). Step ordering is a critical design decision.
Two Saga styles: choreography and orchestration
Choreography: there is no central brain. Each service listens to others' events, reacts, and emits its own. Like a dance troupe with no leader, where each dancer sees their neighbor's move and makes the next.
Orchestration: a central coordinator (Saga Orchestrator) sends explicit commands to each service, waits for replies, and decides the next step. Like an orchestra with a conductor.
Here's the full flow in both styles. Choreography saga for order placement | event-driven, no central brain:
sequenceDiagram
participant O as Order Service
participant K as Kafka
participant I as Inventory Service
participant P as Payment Service
O->>K: OrderCreated (status=PENDING)
K->>I: OrderCreated
I->>I: reserve stock (local tx)
I->>K: StockReserved
K->>P: StockReserved
P->>P: charge card (local tx)
P->>K: PaymentCompleted
K->>O: PaymentCompleted
O->>O: status=CONFIRMED (local tx)
Note over P,O: if charge fails -> PaymentFailed -> Inventory releases stock (compensation)
Now the same with a central brain. Orchestration saga driven by a coordinator | one state machine decides each step:
sequenceDiagram
participant SO as Order Saga Orchestrator
participant I as Inventory Service
participant P as Payment Service
SO->>I: ReserveStock (command)
I-->>SO: StockReserved (reply)
SO->>P: ChargePayment (command)
P-->>SO: PaymentFailed (reply)
SO->>I: ReleaseStock (compensation command)
I-->>SO: StockReleased
Note over SO: Orchestrator holds the state machine and decides each next step
Comparison: which style should I pick?
| Criterion | Choreography | Orchestration |
|---|---|---|
| Coupling | Loose (only via events) | Orchestrator depends on all |
| Flow comprehension | Hard — logic spread across services | Easy — the whole flow is in one place |
| Single point of failure | None | Orchestrator (must be HA and durable) |
| Best for | Simple flows, 2–3 steps | Complex, conditional, multi-step flows |
| Event-loop risk | High (event begets event, hidden cycles) | Low |
| Debug/observability | Difficult (chase a distributed trace) | Easier |
Up to 2–3 simple steps, choreography is cleaner and lower-infrastructure. But the moment the flow branches, becomes conditional, or grows long, orchestration wins because it centralizes the whole transaction's logic in one observable state machine. A common anti-pattern: a 7-step choreography where nobody can trace where an event goes — it's called "pinball architecture." If you can't draw the whole flow on a whiteboard, it's time to move to an orchestrator.
For sagas in Java/Spring, you either build one by hand with Kafka + a state machine (e.g. a saga_state table), or use ready frameworks like Axon Framework, Eventuate Tram Sagas, or durable workflow engines like Temporal and Camunda/Zeebe. Temporal is a popular orchestration choice these days because it durably manages workflow state and automatic retries.
A minimal hand-rolled orchestrator with a state machine (conceptual, Spring):
public enum OrderSagaState {
STARTED, STOCK_RESERVED, PAYMENT_DONE, COMPLETED, COMPENSATING, FAILED
}
@Component
public class OrderSagaOrchestrator {
private final CommandGateway commands; // sends messages to services
private final SagaStateRepository repo; // saga_state table in the Order DB
// called on each incoming reply (e.g. from a Kafka listener)
@Transactional
public void on(SagaReply reply) {
SagaInstance saga = repo.findById(reply.sagaId()).orElseThrow();
switch (saga.getState()) {
case STARTED -> {
if (reply instanceof StockReserved) {
saga.setState(OrderSagaState.STOCK_RESERVED);
commands.send(new ChargePayment(saga.getOrderId(), saga.getAmount()));
} else { // StockRejected
saga.setState(OrderSagaState.FAILED);
// nothing reserved, no compensation needed
}
}
case STOCK_RESERVED -> {
if (reply instanceof PaymentCompleted) {
saga.setState(OrderSagaState.COMPLETED);
} else { // PaymentFailed -> compensate
saga.setState(OrderSagaState.COMPENSATING);
commands.send(new ReleaseStock(saga.getOrderId())); // compensating action
}
}
case COMPENSATING -> saga.setState(OrderSagaState.FAILED); // StockReleased arrived
default -> { /* idempotent: ignore a duplicate reply */ }
}
repo.save(saga); // saving saga state AND sending a message must be atomic -> Outbox!
}
}
That last comment leaks the most important point: when you save the saga and send a message at the same time, you hit exactly the problem the whole Saga was meant to solve. It's called Dual-Write.
6) The Dual-Write Problem and Its Cure: the Outbox Pattern
Every Saga step does two things at once: (1) it changes the local database, and (2) it publishes an event/message. But these are two different systems (the DB and Kafka) that don't share a transaction. This "writing to two sources" is the Dual-Write Problem.
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order); // (1) commit to the DB
kafkaTemplate.send("orders", event); // (2) publish to Kafka
}
Four outcomes are possible — two of them are disasters:
- The DB commits, but right after, the service crashes and Kafka is never sent → the order exists, but Inventory and Payment never hear about it. A lost order.
- Kafka is sent, but the DB transaction rolls back → the event exists but no order does. Other services act on phantom data.
The subtle catch: @Transactional only wraps the database. kafkaTemplate.send is outside the DB transaction boundary. Even if you swap the two lines, the problem just changes shape — it isn't fixed. No ordering of two independent writes is ever atomic.
The solution: Transactional Outbox
The idea is brilliant and simple: instead of writing to two systems, write to one. Create an outbox table in the service's own database. In the same local transaction that writes the business data, insert the event as a row in the outbox table. Because both are in one DB transaction, either both commit or neither does — atomic, guaranteed.
Then a separate process (relay) reads the outbox rows and sends them to Kafka, marking them done.
The Outbox pattern flow with CDC | Transactional Outbox with change data capture:
flowchart LR
App[Order Service] -->|single local tx| DB[(Order DB)]
DB --- OB[outbox table]
CDC[Debezium Connector] -->|reads WAL/redo log| OB
CDC -->|publishes| Kafka[(Kafka)]
Kafka --> Consumers[Inventory / Payment]
Here's the outbox table DDL in both dialects. The structure is deliberately aligned with Debezium's expectations (id, aggregatetype, aggregateid, type, payload):
-- PostgreSQL
CREATE TABLE outbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
aggregatetype VARCHAR(255) NOT NULL, -- e.g. "Order" -> maps to topic name
aggregateid VARCHAR(255) NOT NULL, -- e.g. orderId -> message key (partitioning)
type VARCHAR(255) NOT NULL, -- e.g. "OrderCreated"
payload JSONB NOT NULL, -- the event body
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Oracle (19c/23ai)
CREATE TABLE outbox (
id RAW(16) DEFAULT SYS_GUID() PRIMARY KEY,
aggregatetype VARCHAR2(255) NOT NULL,
aggregateid VARCHAR2(255) NOT NULL,
type VARCHAR2(255) NOT NULL,
payload CLOB NOT NULL, -- or JSON in 23ai
created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL
);
- UUID/GUID: Postgres has a native
UUIDtype andgen_random_uuid(); Oracle typically usesRAW(16)+SYS_GUID(). If you generate the UUID in the app, this difference doesn't matter. - JSON: Postgres has
JSONB(indexable binary); Oracle up to 19c usually usesCLOBwith anIS JSONconstraint, and 23ai introduced a nativeJSONdata type. - timestamp: Postgres
TIMESTAMPTZ≡ OracleTIMESTAMP WITH TIME ZONE. Current time:now()in Postgres,SYSTIMESTAMPin Oracle. - Empty string: remember Oracle treats an empty string
''asNULL; if you ever set payload to an empty string,NOT NULLblows up in Oracle but not in Postgres. The classic porting trap.
The service code now deals with just one local transaction — no Kafka, no dual-write:
@Service
public class OrderService {
private final OrderRepository orders;
private final OutboxRepository outbox;
private final ObjectMapper json;
@Transactional // one atomic local tx: both inserts commit together
public void placeOrder(PlaceOrderCommand cmd) {
Order order = Order.pending(cmd);
orders.save(order);
OutboxEvent event = new OutboxEvent(
"Order", // aggregatetype -> topic
order.getId().toString(), // aggregateid -> message key
"OrderCreated", // type
json.writeValueAsString(new OrderCreated(order)) // payload
);
outbox.save(event); // just one more write in the same DB; no Kafka
} // COMMIT: either both rows, or neither
}
Two ways to publish the outbox: Polling vs CDC
Polling Publisher: a short-interval job runs SELECT ... FROM outbox WHERE processed = false ORDER BY created_at, sends each row to Kafka, and marks processed=true (or DELETEs the row). Simple, no extra infrastructure, but it has latency (tied to the poll interval), puts a constant query load on the DB, and you must handle ordering and concurrency (multiple instances) by hand (SELECT ... FOR UPDATE SKIP LOCKED).
Change Data Capture (CDC): instead of polling, a tool like Debezium reads the database transaction log directly (WAL in Postgres, redo/LogMiner or OpenLogReplicator in Oracle, binlog in MySQL) and streams every INSERT into the outbox table to Kafka in real time. It adds no query load on the DB, has low latency, and preserves ordering from the log itself.
Polling is simple and fine to start with, but at scale that SELECT ... WHERE processed=false every 200ms against a hot table becomes a pressure source of its own. Because CDC reads the database's already-written log, it's practically free (the DB writes that log anyway), push-based, and real-time. Its cost is operational complexity: you have to run Kafka Connect + Debezium. Rule: small team, low load → Polling; serious event-driven system → CDC/Debezium.
Debezium + Outbox Event Router
Debezium ships a ready transform called the Outbox Event Router (a Single Message Transform) built precisely for this pattern: it takes the raw CDC row from the outbox table and reshapes it into a clean message on the right topic. By default aggregatetype determines the destination topic (outbox.event.${aggregatetype}), aggregateid becomes the message key (so an aggregate's events stay in one partition and in order), and payload is the message body.
Debezium connector config for Postgres with the outbox router (Debezium 3.6, on Kafka Connect):
{
"name": "order-outbox-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "order-db",
"database.port": "5432",
"database.user": "debezium",
"database.password": "${secret:pg-pass}",
"database.dbname": "orders",
"topic.prefix": "orderdb",
"table.include.list": "public.outbox",
"plugin.name": "pgoutput",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
"transforms.outbox.route.by.field": "aggregatetype",
"transforms.outbox.table.field.event.key": "aggregateid",
"transforms.outbox.table.field.event.payload": "payload",
"transforms.outbox.route.topic.replacement": "outbox.event.${routedByValue}"
}
}
- Debezium 3.6.0.Final (July 2026) is built against Kafka 4.3; the 3.2 series was on Kafka Connect 3.9.1. Always check the exact version compatibility matrix in the official docs.
- In Postgres you must set
plugin.nametopgoutput(the built-in logical decoding plugin) and havewal_level=logical. - In Oracle, Debezium uses LogMiner (default) or OpenLogReplicator, and you need ARCHIVELOG mode on and supplemental logging enabled.
- Since you don't need outbox rows after publication, you typically add a retention/purge job or DELETE immediately with a trigger (Debezium captures the event from the INSERT in the WAL itself, so deleting the row afterward causes no problem).
CDC/Outbox gives you at-least-once delivery, not exactly-once. If the relay dies after sending but before committing the offset, the same message is re-sent. So every consumer must be idempotent: send a unique event_id and, on the consume side, keep a processed_events table and ignore duplicates. If you don't take this seriously, in prod you'll see double charges and double stock decrements. This is the single biggest source of bugs in event-driven systems.
An idempotent consumer on the Inventory side:
@KafkaListener(topics = "outbox.event.Order")
@Transactional
public void on(ConsumerRecord<String, String> rec) {
String eventId = header(rec, "id"); // unique event id from the header
if (processedEvents.existsById(eventId)) {
return; // seen it; ignore (idempotency)
}
OrderCreated evt = json.readValue(rec.value(), OrderCreated.class);
inventory.reserve(evt.orderId(), evt.items()); // business logic
processedEvents.save(new ProcessedEvent(eventId)); // record "seen" in the same tx
}
7) CQRS — separating reads from writes
We've solved writing. But what about reading? Suppose the "My Orders" page must show the product name and image (from Catalog), the address (from User), and shipping status (from Shipping) side by side. With Database-per-Service you can no longer JOIN across these — the tables live in different databases. This is the "cross-service query problem."
In a restaurant, the cook (write/Command) slowly and carefully builds a correct dish; their model is complex and normalized. The waiter (read/Query) just wants a ready, fast plate to hand the customer; they care about the final shape, not how it was cooked. CQRS means separating these two roles: one model for writing, a completely different, optimized model for reading.
CQRS (Command Query Responsibility Segregation) means handing the write (Command) and read (Query) responsibilities to two separate models. The write model is normalized and correct; the read model (Read Model / Materialized View) is pre-joined and denormalized so a page query is answered in one simple read.
How does that read model get populated? By the same events that flow out of the Outbox/Kafka. A read service (or a module inside the Order service) listens to OrderCreated, OrderShipped, ProductRenamed, ... and builds and maintains a flat, display-ready table.
CQRS architecture with an event-driven read model | separate write and read models:
flowchart LR
Cmd[Command API] -->|writes| WriteDB[(Write Model - normalized)]
WriteDB -->|outbox/CDC events| Kafka[(Kafka)]
Kafka --> Projector[Projector]
Projector -->|denormalized upsert| ReadDB[(Read Model - query-optimized)]
Query[Query API] -->|fast reads| ReadDB
The projector that builds the read model:
@Component
public class OrderSummaryProjector {
private final OrderSummaryRepository readModel;
@KafkaListener(topics = {"outbox.event.Order", "outbox.event.Shipping"})
@Transactional
public void on(DomainEvent evt) {
switch (evt) {
case OrderCreated e -> readModel.upsert(new OrderSummary(
e.orderId(), e.userId(), e.total(), "PENDING", e.createdAt()));
case OrderShipped e -> readModel.updateStatus(e.orderId(), "SHIPPED");
case ProductRenamed e -> // denormalized name
readModel.renameProductEverywhere(e.productId(), e.newName());
default -> { /* ignore */ }
}
}
}
The read-model upsert query is also dialect-specific:
-- PostgreSQL: native upsert
INSERT INTO order_summary (order_id, user_id, total, status, created_at)
VALUES (:orderId, :userId, :total, 'PENDING', :createdAt)
ON CONFLICT (order_id)
DO UPDATE SET total = EXCLUDED.total, status = EXCLUDED.status;
-- Oracle: MERGE
MERGE INTO order_summary t
USING (SELECT :orderId AS order_id FROM dual) s
ON (t.order_id = s.order_id)
WHEN MATCHED THEN
UPDATE SET t.total = :total, t.status = :status
WHEN NOT MATCHED THEN
INSERT (order_id, user_id, total, status, created_at)
VALUES (:orderId, :userId, :total, 'PENDING', :createdAt);
Postgres uses INSERT ... ON CONFLICT ... DO UPDATE (called UPSERT) and the magic keyword EXCLUDED to reference the proposed row. Oracle uses MERGE with that famous FROM dual. Both do the job but their grammar differs entirely; if you want a portable persistence layer, hide these queries behind a repository interface.
- Operational complexity: you now have two data models, an event pipeline, and a projector that must be monitored. For a simple CRUD, CQRS is over-engineering. Bring it in only where read and write patterns are genuinely different or you have cross-service queries.
- Read-your-own-writes: a user places an order and immediately opens the orders page — but the read model isn't updated yet (the eventual-consistency window). The user thinks their order is lost. Solutions: after the write, optimistically update the UI from the command's own response (optimistic update), or briefly read from the write model, or explicitly show "processing."
- Rebuilding a projection: if the projector logic had a bug, the read model gets corrupted. You must be able to rebuild the projection from scratch (re-consume from the start of the topic). That means the projector must be idempotent and Kafka must have enough retention (or you read from an event store).
8) Event Sourcing — one step further
CQRS often travels with a more radical idea: Event Sourcing.
A proper accountant doesn't overwrite the "current balance" on a scratch pad with each transaction. They keep an append-only ledger of every single transaction (deposit 100, withdraw 30, ...). The current balance is simply the sum of all transactions. If needed, they can reconstruct the balance at any point in history. That's Event Sourcing.
In Event Sourcing, instead of storing the entity's current state, you store the sequence of changes (events). Current state is computed by "replaying" all events from the beginning. You have no orders table with a status column; instead you have an event store with: OrderCreated, ItemAdded, OrderPaid, OrderShipped.
Benefits: complete, free audit (the entire history is inherently there), time travel (state at any instant), and a natural source for CQRS and integration (the events themselves are the source of truth, so no dual-write).
This is the heaviest tool in this chapter, and most systems don't need it. Its challenges are real:
- Event schema evolution: an event written 3 years ago must still be readable; you need versioning and upcasting.
- Hard queries: "all PENDING orders" means a replay or full reliance on CQRS projections.
- Snapshotting: for an aggregate with thousands of events, replay from zero is slow; you must take snapshots.
- Cognitive load: the whole team must think event-first. Senior rule: bring Event Sourcing only where full history and audit are a first-class business requirement (banking, accounting, regulatory domains). For everything else, Outbox + CQRS is usually enough and far cheaper. CQRS without Event Sourcing is entirely valid and common; the two are independent.
9) Designing for Eventual Consistency and Data Ownership
Now that you have the tools, here are the design principles a senior follows:
- Transaction boundary = aggregate boundary. One local transaction changes one aggregate only. Changing multiple aggregates across services = Saga, not a distributed transaction.
- One owner per piece of data. Each field has a single source of truth. Others keep read-only copies (read models) updated from events.
- Every message consumed idempotently. Assume at-least-once by default.
- Take ordering seriously. Preserve order by co-keying an aggregate's events onto one Kafka partition (
aggregateidas key). - Monitor the inconsistency window. The gap between a write and the read model updating is a metric; set an SLA and alert on it.
There's always a message that never processes correctly (corrupt payload, bug, inconsistent data). If you retry infinitely, that "poison message" blocks the whole partition and halts consumption. Solution: after N retries, send the message to a Dead Letter Topic (DLT) and move on, and alert on the DLT so a human reviews it. Spring Kafka has this built-in via DefaultErrorHandler + DeadLetterPublishingRecoverer. Forgetting the DLT means one small bug freezes the whole pipeline.
A subtle question: how much data do we put in an event? Two schools: a thin/notification event sends only orderId and the consumer calls the API for details (runtime coupling, extra load); a fat/event-carried state transfer event carries the whole needed snapshot (the consumer is independent, but the event is large with more schema coupling). Seniors usually take the middle ground: carry enough for most consumers, but be careful the event doesn't become a brittle public API — treat your event schema with the same care as a REST API (use a Schema Registry and backward compatibility).
Summary comparison of the patterns:
| Pattern | Problem it solves | Cost/complexity | When to use |
|---|---|---|---|
| Database per Service | independence, isolation, polyglot | loss of joins and local tx | almost always in microservices |
| Saga | multi-service transaction without 2PC | compensation logic, flow complexity | any multi-service business op |
| Transactional Outbox | dual-write / atomic write+publish | one table + relay/CDC | anywhere you publish events |
| CDC (Debezium) | real-time, low-load publishing from outbox | Kafka Connect operations | serious event-driven system |
| CQRS | cross-service query, asymmetric read/write | two models + projector | complex read-heavy pages |
| Event Sourcing | full audit, history, time-travel | highest; evolution & snapshots | audit-critical domains |
- In microservices each service owns its own database; others reach it only via API or event. Shared Database is an anti-pattern that builds a distributed monolith.
- Distributed transactions / 2PC/XA are effectively abandoned due to long-held locks, availability coupling, and incompatibility with Kafka.
- Instead we accept Eventual Consistency and simulate distributed atomicity with a Saga (a sequence of local transactions + compensating actions) — choreography for simple flows, orchestration for complex ones.
- The Dual-Write problem is solved by the Transactional Outbox pattern: write the data and the event in one local transaction, then publish via Polling or (better) CDC/Debezium. Delivery is at-least-once, so consumers must be idempotent and you need a DLT.
- CQRS separates reads from writes and builds an event-driven read model for fast, cross-service queries; watch out for read-your-writes and projection rebuilds.
- Event Sourcing stores changes instead of state; powerful but expensive — only for audit-critical domains.
- In interviews: strong within an aggregate, eventual between aggregates, one owner per piece of data, idempotency everywhere. Those few sentences signal senior level.
Interview Questions
Answer: To achieve the core goal of microservices — full independence. If services share a database, the schema becomes a shared contract no team can change without coordinating with everyone; that reintroduces deploy-time coupling. A private database gives four things: deployment and schema independence, technology freedom (polyglot persistence), fault and performance isolation (contained blast radius), and independent scalability. I stress that "private" means no other service touches its tables directly — access is only via API or event.
Answer: Shared Database means several services query/write the same tables; in independently-deployed microservices it's an anti-pattern because it brings hidden schema coupling, lock contention, and lost ownership. Database-per-Service makes each service the owner of its own database. Exception: in a modular monolith (one deployment, one transaction), sharing a physical database with logically separate schemas is perfectly fine. Rule: Shared Database is only bad when services deploy and scale independently.
Answer: Because it breaks at scale and under heterogeneity. 2PC locks resources across all participants between prepare and commit; one slow service freezes everyone's throughput. The system's availability becomes the product of the components' availability (if one is down, the transaction stalls). If the coordinator dies after prepare, in-doubt locks linger. And crucially: modern brokers like Kafka and most NoSQL stores don't support XA, so you can't put the DB and Kafka in one atomic transaction at all. Instead you go with Saga + Outbox and eventual consistency.
Answer: A Saga is a sequence of local transactions; each step commits in its own service's database and triggers the next (via event or command). Because each step really committed, automatic rollback is impossible; instead each step has a semantic inverse compensating transaction that runs if later steps fail — e.g. "charge money" is compensated by "refund," not a DB undo. Compensations must be idempotent and effectively always succeed. Irreversible actions (email, shipping) should be moved to the last steps (after the pivot).
Answer: In choreography there is no central brain; each service listens to events and reacts. Loose coupling, but flow logic is scattered and debugging is hard; there's event-loop risk and "pinball architecture." In orchestration an orchestrator with a state machine sends commands to each service and decides the next step; the flow is centralized and observable, but there's a single point of focus (must be HA and durable). Rule: 2–3 simple steps → choreography; long, conditional, complex flows → orchestration. If you can't draw the flow on a whiteboard, it's time for an orchestrator.
Answer: When one operation must both change the database and send an event to Kafka, these are two separate systems with separate transactions. If the DB commits but the service crashes right after, the event never goes → a lost order; if the event goes but the DB transaction rolls back → a phantom event. @Transactional only wraps the database, not Kafka. No ordering of two independent writes is atomic. The solution is the Transactional Outbox pattern.
Answer: Instead of writing to two systems, you write to one. In the same local transaction that stores the business data, you also insert the event as a row in an outbox table in the same database. Because both are in one DB transaction, atomicity is guaranteed — both or neither. Then a separate process (relay) reads the outbox rows and sends them to Kafka. Now the atomic boundary is inside a single database and dual-write is eliminated.
Answer: A Polling Publisher repeatedly runs SELECT ... WHERE processed=false and sends the rows; simple, no extra infra, but latency ties to the poll interval, it loads the DB with queries, and you must solve multi-instance concurrency by hand with FOR UPDATE SKIP LOCKED. CDC (like Debezium) reads the DB transaction log (WAL/redo/binlog) directly and publishes outbox INSERTs in real time; zero query load, low latency, ordering from the log. Its cost is running Kafka Connect + Debezium. Small team → polling; serious system → CDC.
Answer: Debezium is a CDC platform that reads row-level DB changes from the transaction log and publishes them as events to Kafka (Postgres via pgoutput, Oracle via LogMiner/OpenLogReplicator). The Outbox Event Router is a Single Message Transform that reshapes the raw CDC row from the outbox table into a clean message on the right topic: by default aggregatetype forms the topic name (outbox.event.${...}), aggregateid the message key (for ordering and partitioning), and payload the body. Debezium 3.6.0.Final runs on Kafka 4.3.
Answer: Because Outbox/CDC gives at-least-once delivery, not exactly-once. If the relay dies after sending but before committing the offset, the same message is re-sent. If the consumer isn't idempotent, you get double charges or double stock decrements. The standard approach: give each event a unique event_id, and in the same business transaction the consumer checks/inserts a processed_events table and ignores duplicates. This is the single biggest source of bugs in event-driven systems.
Answer: CQRS means separating the write (Command) model from the read (Query) model. The write model is normalized and correct; the read model is a pre-joined/denormalized view optimized for fast queries. It's useful in microservices because with Database-per-Service you can no longer JOIN across services; instead you build a read model populated by events from various services that answers the page query in one simple read. Note: CQRS doesn't necessarily mean two databases; even separating command and query logically within one service is CQRS.
Answer: Because the read model updates eventually, a user might not see their new data in the read model right after writing (the inconsistency window) and think their action was lost. Solutions: (1) after the command succeeds, optimistically update the UI from the command's own response; (2) briefly read from the write model for that specific user; (3) explicitly show a "processing" state to manage the user's expectation. The choice depends on UX, but the point is to make this a conscious decision, not a surprise in prod.
Answer: In Event Sourcing, instead of storing the entity's current state, you store the sequence of change events in an append-only event store and compute current state by replay. Benefits: complete, free audit; time travel; and a natural source for CQRS and integration. But it's expensive: event schema evolution, snapshotting for large aggregates, hard queries, and team cognitive load. Use it only where full history and audit are a first-class business requirement (banking, accounting, regulatory). CQRS without Event Sourcing is entirely valid; the two are independent.
Answer: By translating the technical into product-and-risk language. I say: "We guarantee strong consistency inside each aggregate's boundary; between aggregates there's a window of a few hundred milliseconds to a few seconds that we monitor and put an SLA on." Then I work with the business to identify where that window is acceptable (showing an order in a list) and where it isn't (decrementing limited stock that must not oversell — there you need strong consistency and an atomic reservation within that one service). The point is that eventual consistency is a conscious product decision with an SLA, not a hidden defect.
Answer: There's always a message that never processes correctly (corrupt payload, bug, inconsistent data). If you retry infinitely, that "poison message" blocks the whole partition and halts consumption. Solution: after N failed retries, send the message to a Dead Letter Topic and continue, and alert on the DLT so a human reviews it. In Spring Kafka it's built-in via DefaultErrorHandler + DeadLetterPublishingRecoverer (with backoff). Without a DLT, one bad message freezes the whole pipeline.