مقدمه: یک تجربه واقعی
وقتی در Dr. Link روی سیستم کلینیکهای روانشناسی کار میکردم، یک الگوی جالب دیدم که بعداً در PaymentHub هم دقیقاً همون رو تجربه کردم. این الگو ریشه در دو نوع کاملاً متفاوت از نیاز به داده داره — چیزی که کتاب Software Architecture: The Hard Parts بهش میپردازه وقتی صحبت از تجزیه دیتا (Data Decomposition) و مرزهای معماری میشه.
بذار با یک سوال ساده شروع کنیم: چرا یک سیستم واحد نمیتونه هم به خوبی تراکنش ثبت کنه، هم گزارش تحلیلی بده؟
بخش اول: OLTP — دنیای «الان»
OLTP یعنی Online Transaction Processing. تصور کن در Dr. Link یک بیمار وقت رزرو میکنه:
1. چک موجودی ظرفیت دکتر در اون ساعت
2. ثبت رزرو در جدول Appointments
3. کم کردن یک اسلات از ظرفیت دکتر
4. ارسال نوتیفیکیشن
یا در PaymentHub، لحظهای که یک تراکنش پرداخت اتفاق میافته:
1. چک موجودی کیفپول
2. ثبت تراکنش (Pending)
3. صدا زدن PSP
4. آپدیت وضعیت (Success/Failed)
5. ثبت Outbox Event برای اطلاعرسانی به سرویسهای دیگه
ویژگیهای کلیدی این دنیا:
| ویژگی | توضیح |
|---|---|
| حجم هر عملیات | کوچک — یک رکورد، یک کاربر |
| تعداد عملیات | بسیار زیاد در ثانیه |
| اولویت | Consistency و Latency پایین |
| مدل داده | Normalized (برای جلوگیری از تناقض) |
| الگوهای رایج | ACID Transaction، Outbox Pattern، Saga |
اینجا دقیقاً جاییست که تخصص تو در Outbox Pattern و Saga معنا پیدا میکنه — چون یک تراکنش پرداخت ممکنه چند سرویس رو درگیر کنه (کیف پول، PSP، حسابداری) و باید همه یا هیچکدوم (all-or-nothing) اجرا بشن.
بخش دوم: OLAP — دنیای «چرا و چقدر»
حالا یک سوال کاملاً متفاوت رو تصور کن. مدیر Dr. Link میپرسه:
«در سه ماه گذشته، کدوم دکترها بیشترین نرخ لغو جلسه رو داشتن؟ آیا الگوی خاصی بین ساعت جلسه و نرخ عدمحضور هست؟»
یا در PaymentHub:
«نرخ موفقیت تراکنشها بر اساس هر PSP و هر بانک مقصد در بازه ۶ ماهه چطور بوده؟ کدوم PSP در ساعات پیک کندتر جواب میده؟»
این سوالات هیچربطی به «یک رکورد» ندارن. اینجا OLAP (Online Analytical Processing) وارد میشه:
| ویژگی | توضیح |
|---|---|
| حجم هر عملیات | میلیونها ردیف در یک کوئری |
| تعداد عملیات | کم — چند کوئری سنگین در روز/ساعت |
| اولویت | Throughput تحلیلی، نه Latency لحظهای |
| مدل داده | Denormalized / Columnar (برای اسکن سریع) |
| ابزارهای رایج | ClickHouse، Data Warehouse، Star Schema |
بخش سوم: چرا این دو نباید در یک جا زندگی کنن؟
اینجا دقیقاً همون درسیست که Hard Parts روی تجزیه دیتا تاکید میکنه. اگه بخوای کوئری تحلیلی رو مستقیم روی همون دیتابیس OLTP بزنی:
-- این کوئری روی دیتابیس زنده PaymentHub اجرا میشه
SELECT psp_name, bank_code,
COUNT(*) as total,
SUM(CASE WHEN status='Success' THEN 1 ELSE 0 END) as success
FROM Transactions
WHERE created_at > '2026-01-01'
GROUP BY psp_name, bank_code
این کوئری روی جدولی با میلیونها ردیف، table lock یا row lock سنگین ایجاد میکنه. نتیجه؟ تراکنشهای زندهی کاربرا (که همین الان دارن پرداخت میکنن) کند یا حتی timeout میشن. یعنی یک گزارشگیری داخلی، تجربهی کاربر نهایی رو خراب میکنه.
راهحل: جداسازی مسیر Write از Read/Analytics
[Payment Service - OLTP]
SQL Server / Postgres
(Normalized Schema)
│
│ Outbox Pattern
▼
[Message Broker]
Kafka / RabbitMQ
│
▼
[Stream Processor / ETL]
│
▼
[Analytics Store - OLAP]
ClickHouse / Warehouse
(Denormalized, Columnar)
│
▼
[BI Dashboard / Reports]
این معماری همون CQRS رو در سطح دیتا پیاده میکنه — چیزی که احتمالاً در سطح اپلیکیشن باهاش کار کردی، ولی حالا در سطح Storage:
// Write Model - OLTP side
public class PaymentTransaction
{
public Guid Id { get; set; }
public decimal Amount { get; set; }
public string PspName { get; set; }
public TransactionStatus Status { get; set; }
// بعد از commit موفق:
// یک Domain Event در Outbox ثبت میشه
}
public class TransactionCompletedEvent
{
public Guid TransactionId { get; set; }
public string PspName { get; set; }
public string BankCode { get; set; }
public decimal Amount { get; set; }
public DateTime CompletedAt { get; set; }
}
// این Event، async توسط یک Consumer خونده میشه
// و به شکل denormalized در ClickHouse نوشته میشه:
// جدولی flat که هر ردیفش شامل تمام context لازم برای تحلیل هست
// بدون نیاز به JOIN زدن بین چند جدول
بخش چهارم: تجربهی Dr. Link — یک مثال ملموستر
در Dr. Link، جدول اصلی جلسات (Sessions) برای ثبت سریع رزرو بهینه بود — normalized، با foreign key به Doctor و Patient. ولی وقتی نیاز به گزارش «نرخ retention بیماران بر اساس دکتر» پیدا شد، مجبور شدیم یک Read Model جدا بسازیم که هر بار با یک Job شبانه (یا با event) sync میشد. این باعث شد:
- کوئریهای سنگین گزارشگیری هیچ اثری روی سرعت رزرو نداشته باشن
- Schema گزارشگیری بتونه کاملاً مستقل از Schema عملیاتی تکامل پیدا کنه
جمعبندی
| OLTP | OLAP | |
|---|---|---|
| سوال | «الان چه اتفاقی افتاد؟» | «در طول زمان چه الگویی بوده؟» |
| مثال PaymentHub | ثبت یک تراکنش | گزارش نرخ موفقیت PSP در ۶ ماه |
| مثال Dr. Link | رزرو یک جلسه | نرخ لغو جلسات بر اساس دکتر |
| الگوی معماری | Outbox، Saga، ACID | ETL، Star Schema، Columnar Store |
نکتهی کلیدی که Hard Parts بهش تاکید میکنه اینه که این جداسازی، یک تصمیم معماری آگاهانه در مرز بین Bounded Contextهاست، نه صرفاً یک بهینهسازی فنی — و باید از همون ابتدای طراحی PaymentHub بهش فکر کرد، نه بعد از اینکه دیتابیس زیر بار گزارشگیری از پا در اومد.