طراحی سیستم پرداخت مشابه Stripe
طراحی یک Payment Processor با تمرکز بر امنیت، دوام، Idempotency و مقیاس ۱۰ هزار تراکنش در ثانیه
درک مسئله
سیستمهای پردازش پرداخت مثل Stripe به کسبوکارها (که در این مطلب «تاجر» یا Merchant نامیده میشوند) اجازه میدهند بدون ساختن زیرساخت پرداخت خودشان از مشتریان پول بگیرند. مشتری اطلاعات پرداخت را در وبسایت تاجر وارد میکند، تاجر آن را به Stripe میفرستد، Stripe پرداخت را پردازش میکند و نتیجه را به تاجر برمیگرداند.
نیازمندیهای عملکردی
نیازمندیهای اصلی:
- تاجر بتواند درخواست پرداخت را آغاز کند (مبلغ مشخصی را از مشتری دریافت کند).
- مشتری بتواند برای محصولات با کارت اعتباری یا دبیت پرداخت کند.
- تاجر بتواند بهروزرسانی وضعیت پرداختها را ببیند (مثلاً در انتظار، موفق، ناموفق).
خارج از محدوده:
- ذخیرهی روش پرداخت مشتری برای استفادههای بعدی.
- بازپرداخت (Refund) کامل یا جزئی.
- مشاهدهی تاریخچهی تراکنشها و گزارشها.
- روشهای پرداخت جایگزین (مثل حوالهی بانکی و کیف پول دیجیتال).
- پرداختهای تکرارشونده (اشتراک).
- پرداخت به تاجران (Payout).
نیازمندیهای غیرعملکردی
پیش از تعریف این نیازمندیها در مصاحبه، درباره مقیاس سیستم سؤال کنید، چون اثر مهمی روی طراحی دارد. در اینجا سیستمی را بررسی میکنیم که در اوج بار حدود ۱۰ هزار تراکنش در ثانیه (TPS) را مدیریت میکند.
نیازمندیهای اصلی:
- سیستم باید امنیت بالایی داشته باشد.
- سیستم باید Durability و قابلیت حسابرسی (Auditability) را تضمین کند و حتی در زمان خرابی، دادهی تراکنش از بین نرود.
- سیستم باید ایمنی تراکنش و یکپارچگی مالی را با وجود ماهیت ذاتاً ناهمگام (Asynchronous) شبکههای پرداخت خارجی تضمین کند.
- سیستم باید Scalable باشد و حجم بالای تراکنش (بیش از ۱۰ هزار TPS) و الگوهای ترافیکی سنگین مثل فروشهای تعطیلات را تحمل کند.
خارج از محدوده:
- رعایت مقررات مالی مختلف در سطح جهانی (بسته به مناطق پشتیبانیشده).
- قابلیت توسعه برای افزودن آسان روشها یا ویژگیهای پرداخت جدید در آینده.
رویکرد برنامهریزی
تعریف Entityهای اصلی
با شناسایی Entityهای اصلی شروع میکنیم. من ترجیح میدهم قبل از ورود به جزئیات، یک نمای کلی بسازم تا پایهای برای ادامه داشته باشیم. اگر ترجیح میدهید از ابتدا طرح کامل با همه ستونها و روابط را بکشید، آن هم خوب است. در مصاحبه یک رویکرد «درست» واحد وجود ندارد و مهم این است که بلوکهای سازندهی اصلی را روشن بفهمیم.
برای پوشش نیازمندیهای عملکردی به این Entityها نیاز داریم:
- Merchant (تاجر): اطلاعات کسبوکارهایی که از پلتفرم ما استفاده میکنند، شامل هویت، اطلاعات حساب بانکی و کلیدهای API.
- PaymentIntent: نمایندهی قصد تاجر برای دریافت مبلغ مشخصی از مشتری که چرخهی کلی پرداخت را از ایجاد تا تکمیل ردیابی میکند. این Entity مالک State Machine است: از
createdبهauthorizedو بعدcaptured،canceledیاrefunded. همچنین Idempotency را برای Retryها اعمال میکند. - Transaction (تراکنش): رکوردی چندشکلی (Polymorphic) از جابهجایی پول که به یک PaymentIntent وصل است. انواع آن شامل
charge(ورود وجه)،refund(خروج وجه)،dispute(برگشت احتمالی) وpayout(خروج به سمت تاجر) است. هر ردیف مبلغ، ارز، وضعیت، Timestampها و ارجاع به Intent و تاجر را دارد. برای سادگی فقط رویchargeتمرکز میکنیم، چون بقیه خارج از محدوده است.
مهم است تفاوت PaymentIntent و Transaction را روشن کنید، چون بهراحتی باعث سردرگمی میشود. رابطهی آنها یکبهچند است؛ یک PaymentIntent میتواند چند Transaction داشته باشد. مثلاً:
- اگر تلاش برای پرداخت بهخاطر کمبود موجودی شکست بخورد، تاجر ممکن است با همان PaymentIntent دوباره تلاش کند، ولی یک Transaction جدید ساخته میشود.
- در پرداختهای جزئی، چند Transaction به یک PaymentIntent وصل میشود.
- در بازپرداخت، یک Transaction جدید با مبلغ منفی ساخته میشود و به PaymentIntent اصلی وصل میشود.
از دید تاجر فقط یک PaymentIntent ساخته میشود و سیستم ما همهی پیچیدگی تراکنشها را داخلی مدیریت میکند و نمای سادهای از وضعیت کلی پرداخت میدهد.
در مصاحبهی واقعی، همین یک فهرست کوتاه کافی است. فقط Entityها را با مصاحبهکننده مرور کنید تا همنظر باشید.
مدل Transaction در اینجا عمداً ساده شده است تا بتوانیم روی بخشهای مهم طراحی تمرکز کنیم. در سیستم Production احتمالاً آن را به انواع مجزا تقسیم میکنید: charge، refund، dispute، payout و ردیفهای Double-entry که واقعاً مانده را جابهجا میکنند. ولی برای مصاحبه، جمع کردن همهی آنها زیر یک Transaction چندشکلی مدل ذهنی را ساده نگه میدارد: یک PaymentIntent میتواند رویدادهای جابهجایی پول زیادی بسازد که هر کدام مبلغ، ارز، وضعیت و Timestamp دارند. این سطح جزئیات برای استدلال درباره Idempotency، قابلیت حسابرسی و مدیریت شکست کافی است، بدون اینکه در جزئیات پیادهسازی غرق شویم.
رابط سیستم و API
API مهمترین راه تعامل تاجران با سیستم پرداخت ماست. تعریف زودهنگام آن به ساختار دادن به بقیهی طراحی کمک میکند. ساده شروع میکنیم و جزئیات را بعداً اضافه میکنیم. برای هر نیازمندی اصلی یک Endpoint میسازم.
اول، تاجر باید درخواست PaymentIntent را آغاز کند. این وقتی رخ میدهد که مشتری به صفحهی پرداخت وبسایت تاجر برسد. از یک POST برای ساخت PaymentIntent استفاده میکنیم:
1
2
3
4
5
6
POST /payment-intents -> paymentIntentId
{
"amountInCents": 2499,
"currency": "usd",
"description": "Order #1234"
}
بعد، سیستم باید پرداخت را ایمن بپذیرد و پردازش کند. چون ابتدا روی کارت اعتباری و دبیت تمرکز داریم، برای آن این Endpoint را داریم:
1
2
3
4
5
6
7
8
9
10
POST /payment-intents/{paymentIntentId}/transactions
{
"type": "charge",
"card": {
"number": "4242424242424242",
"exp_month": 12,
"exp_year": 2025,
"cvc": "123"
}
}
در پیادهسازی واقعی هرگز جزئیات خام کارت را به این شکل مستقیم به Backend نمیفرستیم. برای محافظت از دادهی حساس از فرایند Tokenization امن استفاده میکنیم. وقتی جلوتر رفتیم، جزئیات مدیریت ایمن آن را بررسی میکنیم. در مصاحبه کافی است بگویید میدانید این داده باید رمزگذاری شود.
در نهایت، تاجر باید وضعیت پرداخت را بررسی کند. این کار با یک GET ساده انجام میشود:
1
GET /payment-intents/{paymentIntentId} -> PaymentIntent
پاسخ شامل وضعیت فعلی پرداخت (مثل pending، succeeded، failed) و جزئیات مرتبط مثل پیام خطا برای پرداختهای ناموفق است.
برای رویکرد بلادرنگ (که در واقع استاندارد صنعت است) میتوانیم Webhook هم ارائه دهیم تا هنگام تغییر وضعیت پرداخت به تاجر اطلاع بدهیم. تاجر یک Callback URL میدهد و ما هر وقت وضعیت عوض شد، به آن POST میکنیم:
1
2
3
4
5
6
7
8
9
10
POST {merchant_webhook_url}
{
"type": "payment.succeeded",
"data": {
"paymentId": "pay_123",
"amountInCents": 2499,
"currency": "usd",
"status": "succeeded"
}
}
طراحی سیستم Webhook اغلب خودش یک سؤال مستقل در مصاحبههای طراحی سیستم است. طراحی کامل سیستم پرداخت همراه با Webhook در زمان مصاحبه بسیار سنگین میشود. همان ابتدا با مصاحبهکننده صحبت کنید تا درباره دامنهی کار همنظر باشید.
طراحی سطح بالا
برای طراحی سطح بالا، نیازمندیهای عملکردی را یکییکی پیش میبریم.
۱) تاجر بتواند درخواست پرداخت را آغاز کند
وقتی تاجر میخواهد از مشتری پول بگیرد، باید درخواست پرداخت بدهد. در سیستم ما این کار با ساخت یک PaymentIntent انجام میشود. اجزای لازم:
- API Gateway: نقطهی ورود همهی درخواستهای تاجر. احراز هویت و Rate Limiting را انجام میدهد و درخواست را به میکروسرویس مناسب میفرستد.
- Payment Service: میکروسرویسی که PaymentIntent را میسازد و مدیریت میکند و برای ذخیرهی اطلاعات با پایگاه داده ارتباط دارد.
- Database: پایگاه دادهی مرکزی که رکورد PaymentIntentها (وضعیت فعلی، مبلغ، ارز و Metadata) و اطلاعات تاجر (کلیدهای API، جزئیات کسبوکار و تنظیمات) را نگه میدارد.
جریان شروع پرداخت توسط تاجر:
- تاجر با ارسال
POSTبه/payment-intentsهمراه مبلغ، ارز و توضیح، به API ما درخواست میدهد. - بعد از احراز هویت، درخواست به PaymentIntent Service میرود.
- این سرویس یک رکورد PaymentIntent جدید با وضعیت اولیه
createdمیسازد و در پایگاه داده ذخیره میکند. - سیستم برای این PaymentIntent یک شناسهی یکتا میسازد و در پاسخ API به تاجر برمیگرداند.
این شناسه بسیار مهم است، چون در مراحل بعدی جریان پرداخت استفاده میشود. تاجر معمولاً آن را در صفحهی پرداخت خودش جاسازی میکند یا به کد سمت کلاینت میدهد تا هنگام جمعآوری اطلاعات پرداخت مشتری استفاده شود.
در این مرحله هیچ پولی دریافت نشده است. فقط قصد تاجر برای دریافت پرداخت ثبت شده و یک مرجع برای ردیابی پرداخت در کل چرخهی عمرش ساختهایم. PaymentIntent در وضعیت created است و منتظر مرحلهی بعد، یعنی دریافت اطلاعات پرداخت و شروع پردازش، میماند.
۲) پرداخت مشتری با کارت اعتباری یا دبیت
حالا که PaymentIntent داریم، قدم بعدی دریافت ایمن اطلاعات پرداخت از مشتری و پردازش آن است. نکتهی مهم این است که پردازش پرداخت ذاتاً Asynchronous است. پاسخ اولیهی Authorization از شبکهی پرداخت فقط اولین مرحله از فرایند طولانیتری است که تکمیل کامل آن ممکن است چند دقیقه یا حتی چند روز طول بکشد، چون شبکههای پرداخت برای مواردی مثل بررسی تقلب و درخواست استرداد (Chargeback) زمان لازم دارند.
الگو، فرایندهای چندمرحلهای (Multi-step Processes): پردازش پرداخت نمونهی کامل این الگوست. یک پرداخت از چند مرحله عبور میکند: Authorization، Capture، Settlement و احتمالاً Refund یا Dispute. هر مرحله میتواند مستقل شکست بخورد و نیاز به Retry یا جبران (Compensation) داشته باشد. این یعنی مدیریت دقیق State و هماهنگی لازم است تا Workflow پرداخت با موفقیت پیش برود و شکستها هم درست مدیریت شوند.
سیستم باید با نگه داشتن وضعیت هر پرداخت و تراکنش و باخبر کردن تاجران از تغییر وضعیت در کل چرخهی عمر، این ماهیت ناهمگام را مدیریت کند. معماری را برای این بخش گسترش میدهیم:
- Transaction Service: میکروسرویس اختصاصی که جزئیات کارت را از سرور تاجر میگیرد، رکورد تراکنشها را در طول چرخهی پرداخت مدیریت میکند و مستقیم با شبکههای پرداخت خارجی مثل Visa، Mastercard و سیستمهای بانکی ارتباط دارد.
- شبکهی پرداخت خارجی: در نمودار با خطچین نشان داده میشود، چون خارج از سیستم ماست، ولی بخش مهمی از جریان پرداخت است. اینها شبکههای پرداخت (Visa، Mastercard و مانند آن) و سیستمهای بانکی هستند که تراکنش مالی را واقعاً Authorize و پردازش میکنند.
جریان پس از وارد کردن اطلاعات پرداخت توسط مشتری:
- مشتری اطلاعات کارت را در فرم پرداخت وبسایت تاجر وارد میکند.
- تاجر این داده را جمع میکند و همراه شناسهی PaymentIntent به Transaction Service ما میفرستد.
- Transaction Service یک رکورد Transaction با وضعیت
pendingمیسازد. - Transaction Service مستقیم تعامل با شبکهی پرداخت را مدیریت میکند: به شبکهی مناسب وصل میشود و درخواست Authorization میفرستد، پاسخ اولیه (تأیید یا رد) را میگیرد، رکورد Transaction را با وضعیت اولیه بهروز میکند، از طریق اتصال خصوصی امن به Callbackهای شبکه گوش میدهد و هر وقت تغییر وضعیت دیگری رخ داد (Settlement، Chargeback و مانند آن)، Callback را میگیرد و رکوردها را بهروز میکند.
- Transaction Service با پیشرفت تراکنش در چرخهی عمرش، وضعیت PaymentIntent را هم بهروز میکند.
پوشش جزئیات این بخش در اغلب مصاحبهها غیرمعمول است، ولی ممکن است کنجکاو باشید اتصال به شبکههای پرداخت چطور کار میکند. بهطور خلاصه، شبکههای پرداخت روی شبکههای خصوصی و بسیار امن کار میکنند که کاملاً از اینترنت عمومی جدا هستند. برای اتصال به آنها، پردازشگرهای پرداخت باید از طریق Hardware Security Module یا HSMهای تخصصی و Data Centerهای امن که الزامات سختگیرانهی PCI DSS را برآورده میکنند، اتصال اختصاصی بگیرند. این شبکههای خصوصی از پروتکلهای اختصاصی با فرمت پیام خاص (مثل ISO 8583) استفاده میکنند و دسترسی به آنها نیازمند فرایند رسمی صدور گواهینامه است. برخلاف RESTful API معمولی، این اتصالها اغلب شامل پروتکلهای باینری، Leased Lineها و تونلهای VPN با Mutual TLS هستند.
این معماری سادهشده، Transaction Service را هم مسئول مدیریت رکورد تراکنشهای داخلی و هم ارتباط با شبکههای پرداخت خارجی نگه میدارد. با اینکه چند دغدغه را در یک سرویس جمع میکند، پیچیدگی را کم میکند، Hopهای شبکهی غیرضروری را حذف میکند و امنیت را هم از طریق رعایت درست PCI در همین سرویس حفظ میکند.
۳) ارائهی وضعیت پرداخت به تاجر
بعد از شروع و پردازش پرداخت، تاجر به راه قابلاعتمادی برای دانستن وضعیت فعلی نیاز دارد. این اطلاعات برای عملیات کسبوکار حیاتی است. تاجر باید بداند پرداخت کی موفق شده تا اقداماتی مثل ارسال کالای فیزیکی، دادن دسترسی به محتوای دیجیتال یا تأیید رزرو را انجام دهد. همینطور باید بداند پرداخت کی شکست خورده تا به مشتری اطلاع دهد یا روش پرداخت دیگری امتحان کند.
چون از قبل PaymentIntent Service داریم که PaymentIntent را مدیریت میکند، همین سرویس را برای ارائهی وضعیت هم استفاده میکنیم. برای بررسی وضعیت نیازی به سرویس جدا نیست. جریان:
- تاجر
GETبه/payment-intents/{paymentIntentId}میزند. - API Gateway اعتبار تاجر را تأیید و درخواست را به PaymentIntent Service هدایت میکند.
- این سرویس وضعیت فعلی PaymentIntent، پیام خطا (در صورت شکست) و جزئیات تراکنش مرتبط را از پایگاه داده میخواند.
- اطلاعات در قالب پاسخ ساختیافته به تاجر برمیگردد.
این مکانیزم سادهی Polling به تاجر اجازه میدهد وضعیت پرداخت را برنامهنویسیشده بررسی کند و نتیجه را در Workflow کسبوکارش ادغام کند.
PaymentIntent در طول چرخهی عمرش وضعیتهای مختلفی دارد، مثل:
created: وضعیت اولیه، بعد از ساخته شدن PaymentIntent توسط تاجر.processing: جزئیات پرداخت دریافت شده و در حال پردازش است.succeeded: PaymentIntent با موفقیت پردازش شده است.failed: پردازش پرداخت شکست خورده است (همراه دلیل).
این رویکرد Polling برای بسیاری از موارد خوب است، ولی برای بهروزرسانیهای بلادرنگ یا بررسیهای پرتکرار ایدهآل نیست. در یک Deep Dive بعدی نشان میدهیم Webhook چطور اعلانهای Push-based میدهد، نیاز به Polling را حذف میکند و تأخیر بین تکمیل پرداخت و اقدام تاجر را کم میکند.
حالا هر سه نیازمندی عملکردی را پوشش دادهایم و سیستم پرداخت اولیهای داریم که میتواند پرداخت بسازد، آن را ایمن پردازش کند و وضعیت را به تاجر بدهد.
Deep Dive های احتمالی
در این مرحله سیستمی پایه داریم که نیازمندیهای عملکردی اصلی را برآورده میکند. اما طراحی فعلی محدودیتهای مهمی دارد، بهویژه در ایمنی تراکنش، Durability و مقیاسپذیری برای حجم بالا. نیازمندیهای غیرعملکردی را مرور میکنیم تا ببینیم چطور سیستم را برای بیش از ۱۰ هزار TPS با Strong Consistency و Durability تضمینشده بهتر کنیم.
۱) سیستم باید امنیت بالایی داشته باشد
برای یک سیستم پرداخت، در تضمین امنیت دو چیز اصلی اهمیت دارد:
- آیا تاجری که درخواست پرداخت میدهد واقعاً همان کسی است که ادعا میکند؟
- آیا از اطلاعات شخصی مشتری طوری محافظت میکنیم که قابل سرقت یا افشا نباشد؟
با مورد اول شروع میکنیم. باید تأیید کنیم تاجرانی که به سیستم وصل میشوند همان کسانی هستند که ادعا میکنند. در نهایت به آنها اجازه میدهیم از مردم پول بگیرند و بهتر است مطمئن باشیم قانونی هستند. بیشتر سیستمهای پرداخت این مشکل را با API Key حل میکنند، ولی رویکردهای مختلفی با سطوح امنیتی متفاوت وجود دارد.
رویکرد اول، API Key ثابت: از API Keyهای استاندارد و ثابت بهعنوان مکانیزم اولیهی احراز هویت تاجر استفاده میکنیم. وقتی تاجر ثبتنام میکند، یک API Key یکتا میسازیم (معمولاً یک رشتهی تصادفی مثل pk_live_51NzQRtGswQnXYZ8o) و آن را در پایگاه داده کنار حساب تاجر ذخیره میکنیم. تاجر در هر درخواست API این کلید را در Header میگذارد، معمولاً به شکل Authorization: Bearer {api_key} یا یک Header سفارشی مثل X-API-Key: {api_key}. API Gateway کلید را استخراج میکند، در پایگاه داده جستجو میکند و تاجر مربوط را پیدا میکند. اگر کلید معتبر بود، درخواست احراز هویت و پردازش میشود.
چالشها: چون API Key اعتبارنامهی ثابتی است که در هر درخواست فرستاده میشود، در برابر شنود شبکه (Network Sniffing) آسیبپذیر است، بهویژه اگر از اتصال ناامن عبور کند. مهاجم پس از شنود میتواند این اعتبارنامه را بینهایت بار تکرار کند، چون مکانیزمی برای تشخیص یا جلوگیری از Replay Attack وجود ندارد. علاوه بر این، و حتی محتملتر، این کلیدها اغلب در اپلیکیشنهای کلاینت یا فایلهای پیکربندی Hardcode میشوند و خطر افشا از طریق Repositoryهای کد یا نسخههای پشتیبان را بالا میبرند. برای سیستم مالی که دادهی پرداخت حساس پردازش میکند، این آسیبپذیریها ریسک غیرقابلقبولی هستند و میتوانند به تراکنشهای متقلبانه، نشت داده و خسارت مالی و اعتباری قابلتوجه منجر شوند.
رویکرد دوم، امضای درخواست (Request Signing): برای بهبود راهحل قبلی، Request Signing پیاده میکنیم که تضمین میکند درخواستهای API اصیل (Authentic) و بدون تغییر (Integrity) هستند و مهاجم نمیتواند آنها را دوباره پخش کند. این کار را با ترکیب API Key عمومی (برای شناسایی تاجر) و Secret Key خصوصی (برای تولید امضاهای محدود به زمان) انجام میدهیم.
هنگام Onboarding تاجر دو کلید میدهیم: یک API Key عمومی برای شناسایی و یک Secret Key خصوصی که روی سرور تاجر بهصورت ایمن نگهداری میشود (هرگز در کد سمت کلاینت). این کلیدها برای احراز هویت سرور تاجر با سیستم پرداخت ما استفاده میشوند و از نحوهی مدیریت دادهی کارت مشتری، که در بخش بعد میآید، جدا هستند.
برای هر درخواست API، سرور تاجر با Hash کردن جزئیات درخواست (متد، Endpoint، پارامترها و Body) بههمراه یک Timestamp و یک Nonce یکتا با Secret Key خودش، امضای دیجیتال تولید میکند. این امضا اصالت درخواست را ثابت میکند و از دستکاری جلوگیری میکند. اینطور حتی اگر درخواست دوباره پخش شود، میفهمیم Timestamp خارج از پنجرهی قابلقبول است یا Nonce قبلاً استفاده شده و درخواست را رد میکنیم.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Example request with signature
{
"method": "POST",
"path": "/payment-intents/{paymentIntentId}/transactions",
"body": {
// body here
},
"headers": {
"Authorization": "Bearer pk_live_51NzQRtGswQnXYZ8o", // API Key
"X-Request-Timestamp": "2023-10-15T14:22:31Z", // Timestamp
"X-Request-Nonce": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", // Nonce
"X-Signature": "sha256=7f83b1657ff1fc53b92dc18148a1d65dfc2d4b1fa3d677284addd200126d9069" // Hash of body
}
}
وقتی API Gateway درخواست را میگیرد:
- Secret Key تاجر را بر اساس API Key ارسالشده بازیابی میکند.
- امضای HMAC را با همان الگوریتم (SHA-256)، Secret Key و دادهی درخواست دوباره تولید میکند.
- امضای محاسبهشده را با امضای موجود در Headerهای درخواست مقایسه میکند.
- بررسی میکند Timestamp در پنجرهی زمانی قابلقبول باشد (معمولاً ۵ تا ۱۵ دقیقه).
- با بررسی Cache یا پایگاه داده مطمئن میشود Nonce در پنجرهی زمانی معتبر قبلاً استفاده نشده باشد.
این رویکرد از HMAC (کد احراز هویت پیام مبتنی بر Hash) با Secret Key مشترک استفاده میکند که هم اصالت (درخواست را تاجر دارای کلید معتبر فرستاده) و هم تمامیت (دادهی درخواست دستکاری نشده) را تضمین میکند.
حالا به مورد دوم میرسیم: محافظت از دادهی حساس مشتری در جریان پرداخت. اگر فرد مخرب به اطلاعات کارت دیگران برسد، نتیجه میتواند کلاهبرداری، سرقت هویت و از بین رفتن کامل اعتماد مشتری باشد. علاوه بر این، مقررات سختگیرانهای مثل PCI DSS نحوهی مدیریت دادهی پرداخت را الزامی میکنند. رویکردهای مختلف را بررسی میکنیم.
رویکرد اول، جمعآوری دادهی کارت روی سرور تاجر: تاجر دادهی کارت را قبل از ارسال به سیستم ما روی سرور خودش جمع و پردازش میکند. مشتری اطلاعات کارت را در وبسایت تاجر وارد میکند، این اطلاعات با HTTPS به سرور تاجر میرسد، تاجر آن را رمزگذاری میکند و به Payment Service ما میفرستد. بعد پاسخ Authorization و تأیید تراکنش را از ما میگیرد.
چالشها: یک ملاحظهی کلیدی امنیتی این است که دادهی حساس کارت هرگز نباید به سرورهای تاجر برسد. این نهتنها بهترین روش است، بلکه الزام PCI DSS است. هر سرور تاجر به هدف حملهی بالقوه تبدیل میشود. اگر تاجری آسیبپذیری داشته باشد، دادهی کارت مشتریان در خطر است. تاجران هم مسئولیت سنگینی میگیرند و یک نشت میتواند جریمههای مالی ویرانگر و لطمه به اعتبار به همراه داشته باشد.
رویکرد دوم، iframe: درس مهم رویکرد قبلی این است که دادهی خصوصی (مثل شمارهی کارت) هرگز نباید وارد سرور تاجر شود. با مدیریت مستقیم این داده توسط خودمان، تاجران را از الزامات Compliance و ریسک مسئولیت محافظت میکنیم. اطلاعات کارت از طریق HTTPS مستقیم از مشتری به سرورهای ما میرسد و سیستم تاجر کاملاً دور زده میشود.
برای این کار یک JavaScript SDK ارائه میدهیم که iframe را مستقیم در صفحهی تاجر بارگذاری میکند. SDK ما یک فریم امن در صفحهی وب تاجر میسازد که مستقیم از دامنهی خودمان بارگذاری میشود. وقتی مشتری اطلاعات کارت را وارد میکند، بدون عبور از سیستمهای تاجر مستقیم به سرور ما میرود. iframe یک مرز امنیتی میسازد و Same-origin Policy مرورگر مانع دسترسی کد تاجر به دادهی داخل فریم میشود.
چالشها: این رویکرد از رسیدن دادهی کارت به سرور تاجر جلوگیری میکند، ولی هنوز محدودیت امنیتی دارد. امنیت iframe به سیاستهای امنیتی مرورگر وابسته است که ممکن است با حملههای پیچیده نقض شود. علاوه بر این، دادهی کارت فقط در مسیر انتقال با HTTPS محافظت میشود، یعنی اگر iframe به خطر بیفتد ممکن است افشا شود. رویکرد iframe همچنین گزینههای شخصیسازی را محدود میکند و میتواند در مرورگرهای موبایل مشکل کاربری ایجاد کند.
رویکرد سوم، iframe همراه رمزگذاری سمت کلاینت: امنترین رویکرد بر پایهی iframe است و محدودیتهای آن را با چند لایهی امنیتی پوشش میدهد. سیستم پرداخت ما جفتکلیدهای رمزنگاری را تولید و مدیریت میکند و JavaScript SDK ما برای رمزگذاری از Public Key ما استفاده میکند.
وقتی مشتری جزئیات کارت را در iframe ما وارد میکند، SDK همان لحظه و قبل از خروج داده از دستگاه، آن را با این Public Key رمزگذاری میکند. یعنی دادهی کارت هرگز بهصورت رمزگذارینشده خارج از مرورگر مشتری وجود ندارد. حتی اگر سایت تاجر هک شود، مهاجم فقط دادهی رمزگذاریشدهای میگیرد که قادر به رمزگشایی آن نیست.
بعد دادهی رمزگذاریشده با HTTPS به سرورهای ما میرسد و با Private Key متناظر (که بهصورت امن در HSM ذخیره شده) رمزگشایی میشود. HTTPS امنیت انتقال را میدهد و اکنون از دادهی از قبل رمزگذاریشده محافظت میکند، یعنی یک لایهی حفاظتی اضافه است.
این رویکرد چندلایه یعنی حتی اگر یکی از مکانیزمهای امنیتی از کار بیفتد (مثلاً iframe به خطر بیفتد)، لایههای دیگر همچنان از دادهی کارت محافظت میکنند. داده قبل از خروج از دستگاه مشتری رمزگذاری میشود، در مسیر محافظت میشود و روی سرورهای ما ایمن پردازش میشود.
۲) Durability و قابلیت حسابرسی بدون از دست رفتن دادهی تراکنش
برای سیستم پرداخت، بدترین کار از دست دادن دادهی تراکنش است. این فاجعهی مالی و حقوقی خواهد بود. هر تراکنش پول واقعی را نشان میدهد که بین حسابها جابهجا میشود و مقرراتی مثل PCI-DSS، انطباق SOX و استانداردهای حسابرسی مالی ما را ملزم میکنند رکورد کامل و تغییرناپذیر هر تلاش، موفقیت و شکست پرداخت را نگه داریم.
نهتنها باید وضعیت فعلی را ردیابی کنیم، بلکه باید کل توالی رویدادهایی را که به آن وضعیت منتهی شده ردیابی کنیم. وقتی مشتری شش ماه بعد به یک هزینه اعتراض میکند، باید بتوانیم دقیقاً ثابت کنیم چه اتفاقی افتاده: پرداخت کی آغاز شد، چه مبلغی Authorize شد، کی Capture شد و آیا بازپرداختی انجام شد یا نه. یک رکورد گمشده میتواند یعنی ناتوانی در دفاع از خود در برابر Chargeback، شکست در حسابرسیهای Compliance، یا بدتر، ناتوانی در تعیین وضعیت واقعی حساب مشتری.
این نیاز به Durability با چند نیاز دیگر سیستم تلاقی دارد. همانطور که بعداً میبینیم، تحویل Webhook نیازمند دانستن رویدادهایی است که برای هر تاجر باید ارسال شوند. Reconciliation با شبکههای پرداخت نیازمند مقایسهی رکوردهای ما با رکوردهای آنهاست. تشخیص تقلب نیازمند تحلیل الگو در کل تاریخچهی تراکنشهاست. همهی این قابلیتها به یک مسیر حسابرسی بادوام و قابل Query بهعنوان پایه وابستهاند.
رویکردهای دستیابی به این Durability و قابلیت حسابرسی:
رویکرد اول، بهروزرسانی در محل (In-place Update): سادهترین (و بدترین!) رویکرد، ذخیرهی دادهی پرداخت در پایگاه دادهی رابطهای سنتی و بهروزرسانی رکوردها در محل با تغییر وضعیتهاست. وقتی پرداخت از pending به captured میرود، فقط فیلد وضعیت را عوض میکنیم:
1
2
3
4
5
UPDATE payment_intents
SET status = 'captured',
captured_at = NOW(),
updated_at = NOW()
WHERE payment_id = 'pay_123';
این وضعیت فعلی هر پرداخت را میدهد که برای عملیات پایه کافی به نظر میرسد. پایگاه داده تضمینهای ACID میدهد، پس میدانیم بهروزرسانیها Atomic و Consistent هستند. میتوانیم وضعیت فعلی پرداخت را سریع Query کنیم و داشبوردهای ساده برای پرداختهای موفق و ناموفق بسازیم. برای Durability به پشتیبانگیری از پایگاه داده تکیه میکنیم؛ شاید Snapshot روزانه بهعلاوهی Transaction Logها که اجازه میدهند در صورت فاجعه به هر نقطهی زمانی برگردیم.
چالشها: مشکل بزرگ این است که تاریخچه را دور انداختهایم. وقتی بهروزرسانی بالا اجرا میشود، این حقیقت را از دست میدهیم که پرداخت در وضعیت pending بوده، این انتقال کی رخ داده و چه کسی آن را آغاز کرده است. اگر مشتری ادعا کند هزینه اشتباه بوده، نمیتوانیم توالی رویدادهایی را که به کسر پول منجر شده اثبات کنیم.
مهمتر، این رویکرد در برابر باگهای برنامه آسیبپذیر است. یک Deploy معیوب که وضعیت پرداخت را اشتباه بهروز کند، بیسروصدا داده را خراب میکند. تا وقتی متوجه شویم، وضعیت اولیه برای همیشه از بین رفته است. حتی با Backup پایگاه داده ممکن است ندانیم کدام رکوردها تحت تأثیر قرار گرفتهاند یا مقدار درستشان چه بوده است.
از نظر Compliance، این رویکرد اکثر الزامات حسابرسی مالی را برآورده نمیکند. حسابرسان فقط ماندهی فعلی را نمیخواهند و باید تاریخچهی کامل تراکنش را ببینند. مدرک میخواهند که رکوردها دستکاری نشدهاند. پایگاه دادهی قابلتغییری که میتوان تاریخچه را در آن بازنویسی کرد، هیچکدام را نمیدهد.
در نهایت، وقتی اختلاف پرداخت پیش میآید، پشتیبانی مشتری باید اتفاقات را دستی از لاگهای سرور (اگر هنوز موجود باشند) جمع کند. دیباگ مشکل Production به جای بررسی، شبیه باستانشناسی میشود.
رویکرد دوم، جدول Audit جدا: بیشتر مشکل بالا را میتوان با رویکرد پیچیدهتری حل کرد که کنار جدولهای اصلی تراکنش، جدول Audit جدا نگه میدارد. هر تغییر وضعیت یک INSERT در یک Audit Log از نوع Append-only میسازد:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
BEGIN TRANSACTION;
-- Update main table
UPDATE payments
SET status = 'captured',
updated_at = NOW()
WHERE payment_id = 'pay_123';
-- Insert audit record
INSERT INTO payment_audit_log (
payment_id,
change_type,
old_status,
new_status,
changed_by,
changed_at,
metadata
) VALUES (
'pay_123',
'status_change',
'authorized',
'captured',
'payment_service',
NOW(),
'{"amount": 2500, "auth_code": "ABC123"}'
);
COMMIT;
این کار تاریخچه را حفظ میکند و جدولهای اصلی را برای Query بهینه نگه میدارد. جدولهای Audit فقط Append میشوند؛ هرگز رکورد Audit را بهروز یا حذف نمیکنیم و این مقداری تضمین تغییرناپذیری (Immutability) میدهد. برای ایمنی بیشتر میتوانیم دسترسیهای پایگاه داده را طوری محدود کنیم که جدولهای Audit حتی در سطح پایگاه داده فقط اجازهی INSERT داشته باشند.
حالا با جستجو در Audit Log میتوانیم به سؤالهای Compliance پاسخ دهیم. اختلافهای مشتری با بررسی تاریخچهی کامل انتقال وضعیتها بررسی میشود. اگر جدولهای اصلی خراب شوند، از نظر تئوری میتوانیم وضعیت فعلی را با Replay همهی رکوردهای Audit بازسازی کنیم.
چالشها: این خیلی بهتر است، ولی هنوز محدودیتهای مهمی دارد. مشکل اصلی این است که کد برنامه باید یادش بماند هر دو عملیات را انجام دهد: بهروزرسانی جدول اصلی و درج رکورد Audit. اگر توسعهدهندهای فراموش کند Insert مربوط به Audit را بنویسد، یا باگی از آن عبور کند، بیصدا مسیر حسابرسی را از دست میدهیم و راهی برای بازیابی تاریخچهی گمشده نیست.
علاوه بر این، پایگاه دادهی عملیاتی پرحجم مجبور میشود سالها تاریخچهی Audit را هم نگه دارد. همان پایگاه دادهای که پردازش پرداخت بلادرنگ با ۱۰ هزار TPS را انجام میدهد باید جدولهای Audit روبهرشد را هم تحمل کند که در نهایت میلیاردها رکورد تاریخی دارند. این Coupling بهینهسازی هر Use Case را مستقل غیرممکن میکند؛ Queryهای Audit برای Compliance، Queryهای عملیاتی را کند میکنند و تغییر Schema عملیاتی با الزامات Audit محدود میشود.
رویکرد سوم، CDC و Event Stream تغییرناپذیر: رویکرد قویتر، که پردازشگرهایی مثل Stripe استفاده میکنند، دغدغههای عملیاتی و حسابرسی را از هم جدا میکند و در عین حال سازگاری بین آنها را تضمین میکند. هنوز برای عملیات رو به تاجر از پایگاه دادهی سنتی استفاده میکنیم، ولی Durability حسابرسی از یک Event Stream تغییرناپذیر میآید که با Change Data Capture یا CDC پر میشود.
نحوهی کار:
- پایگاه دادهی عملیاتی: درخواستهای API تاجر را با جدولهای بهینه برای Query وضعیت فعلی مدیریت میکند. نیازی به جدول Audit نیست و فقط مدل دادهی عملیاتی خالص داریم.
- Change Data Capture: Write-Ahead Log یا WAL (یا Oplog) پایگاه داده را دنبال میکند و هر تغییر Commitشده را بهصورت یک Event ضبط میکند. این کار در سطح پایگاه داده انجام میشود، نه سطح برنامه، پس تضمین میکند هرگز تغییری را از دست نمیدهیم.
- Event Stream تغییرناپذیر: CDC همهی تغییرات را به Kafka منتشر میکند و یک Log از نوع Append-only از هر انتقال وضعیت میسازد. Eventها با
payment_intent_idکلیدگذاری میشوند و State کامل قبل و بعد را دارند. - Consumerهای تخصصی: سرویسهای مختلف Event Stream را برای نیازهای خاص خود مصرف میکنند، بدون اثر روی پایگاه دادهی عملیاتی.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# Example Kafka events from CDC
{
"op": "insert",
"source": "payment_intents_db",
"table": "payment_intents",
"ts_ms": 1681234567890,
"before": null,
"after": {
"payment_intent_id": "pi_123",
"merchant_id": "merch_456",
"amount": 2500,
"status": "created"
}
}
{
"op": "update",
"source": "payment_intents_db",
"table": "payment_intents",
"ts_ms": 1681234568901,
"before": {
"payment_intent_id": "pi_123",
"status": "created"
},
"after": {
"payment_intent_id": "pi_123",
"status": "authorized"
}
}
نکتهی خوب این معماری این است که Consumerهای مختلف میتوانند نمایههای (View) مختلف بسازند:
- Audit Service: تاریخچهی کامل و تغییرناپذیر را نگه میدارد که برای Queryهای Compliance بهینه است.
- Analytics: نمایههای Denormalized برای Business Intelligence میسازد.
- Reconciliation: رویدادهای ما را با رویدادهای شبکهی پرداخت خارجی تطبیق میدهد (در ادامه توضیح داده میشود).
- Webhook Delivery: تغییر وضعیتهایی را که نیاز به اطلاعرسانی به تاجر دارند ردیابی میکند (آن هم بعداً میآید).
درست است که بسیاری از اینها خارج از محدودهی نیازمندیهای ماست، ولی مفید است بدانیم سیستم به اینها نیاز پیدا میکند و این معماری کار را سادهتر میکند.
برای Durability، Kafka امکان Replication قابلتنظیم (معمولاً ۳ نسخه) روی چند Broker و Availability Zone دارد. Eventها برای مدت قابلتنظیمی (اغلب ۷ تا ۳۰ روز) روی دیسک میمانند و Eventهای قدیمیتر برای نگهداری دائم در Object Storage بایگانی میشوند. همهی Eventها برای حسابرسی بلندمدت خودکار به S3 منتقل میشوند، یعنی میتوانیم تاریخچهی کامل هر پرداخت را حتی سالها بعد برای حسابرسی Compliance یا حل اختلاف بازسازی کنیم. این به ما دادهی Hot برای استفادهی عملیاتی و ذخیرهسازی Cold برای Compliance میدهد.
این معماری بهترین هر دو جهان را میدهد: تاجران پاسخ API زیر ۱۰ میلیثانیه را از پایگاه دادهی عملیاتی بهینه میگیرند، در حالی که هر تغییر بهصورت خودکار و بدون اثر بر Latency API در Event Stream تغییرناپذیر ثبت میشود. چون CDC در سطح پایگاه داده کار میکند، به کد برنامه برای نوشتن رکورد Audit اتکایی نیست؛ اگر تغییری در پایگاه داده رخ دهد، در Event Stream هم ظاهر میشود.
جداسازی دغدغهها مقیاسدهی مستقل را ممکن میکند: پایگاه دادهی عملیاتی ۱۰ هزار TPS را فقط روی وضعیت فعلی متمرکز میکند، در حالی که Event Stream و Consumerهایش Audit، Analytics و Reconciliation را بدون رقابت بر سر منابع انجام میدهند. این معماری الزامات Compliance برای تغییرناپذیری قابلتأیید رمزنگاری را هم برآورده میکند و پایهی قابلیتهای پیشرفته مثل Reconciliation و Webhook را میسازد.
با این رویکرد ترکیبی، به Durability و قابلیت حسابرسی واقعی میرسیم، بدون اینکه عملکرد مورد انتظار تاجران را فدا کنیم. Event Stream تغییرناپذیر فقط برای حسابرسی نیست و پایهی قابلیتهای پیشرفته است که سیستم پرداخت حرفهای را از یک پردازشگر تراکنش ساده متمایز میکند.
یک مصاحبهکنندهی تیزبین ممکن است بپرسد: «آیا CDC یک Single Point of Failure نیست؟ اگر سیستم CDC شما بیفتد و رویدادهای مهم پرداخت را از دست بدهید چه میشود؟» سؤال عالی است. از نظر فنی CDC یک نقطهی شکست است؛ اگر متوقف شود، حتی با ادامهی نوشتن پایگاه داده، Eventها به Kafka نمیرسند. شرکتهایی مثل Stripe این را با اجرای چند Instance مستقل CDC مدیریت میکنند که از یک پایگاه داده میخوانند و هر کدام در Cluster متفاوتی از Kafka مینویسند. همچنین پایشی دارند که اگر تأخیر CDC در حد چند ثانیه بالا برود هشدار میدهد، و روشهای بازیابی برای Replay رویدادهای ازدسترفته از Logهای پایگاه داده نگه میدارند. برای رویدادهای حیاتی پرداخت، ممکن است Fallback در سطح برنامه هم داشته باشند که اگر CDC در بازهی مشخصی Event را تأیید نکرد، مستقیم در Kafka بنویسد.
۳) ایمنی تراکنش و یکپارچگی مالی با وجود ماهیت Asynchronous شبکههای پرداخت
شبکههای پرداخت اساساً متفاوت از سیستمهای داخلی ما کار میکنند. وقتی درخواست شارژ را به Visa، Mastercard یا بانک میفرستیم، از سیستمهایی عبور میکنیم که کنترلی روی آنها نداریم. این شبکهها میلیونها تراکنش را روی زیرساخت جهانی با مکانیزم Retry، تأخیر صف و پنجرههای پردازش دستهای پردازش میکنند. پرداختی که ما «Timeout شده» میدانیم ممکن است هنوز در سیستمهای Authorization در جریان باشد، و پرداخت دیگری ممکن است فوراً موفق شده باشد ولی بستهی پاسخ در راه بازگشت گم شود.
این واقعیت Asynchronous ریسکهای جدی میسازد. خطرناکترین آنها Double Charge است. مشتری برای خرید ۵۰ دلاری «پرداخت» را میزند، ما منتظر پاسخ بانک میمانیم، تاجر دوباره تلاش میکند و ناگهان مشتری دو بار ۵۰ دلار در صورتحسابش میبیند. حتی اگر یکی را در نهایت Refund کنیم، اعتماد را از بین بردهایم و بار پشتیبانی ساختهایم. ریسک مخالف هم همانقدر بد است: پرداخت در بانک موفق میشود ولی ما هرگز نمیفهمیم، پس به تاجر میگوییم پرداخت انجام نشد. تاجر کالا را نمیفرستد، کارت مشتری به هر حال شارژ شده و حالا مشتری عصبانیای داریم که پول کالایی را داده که هرگز نمیگیرد.
چالش حذف این رفتار Asynchronous نیست، چون ممکن نیست. بهجای آن باید سیستمی بسازیم که عدم قطعیت را بپذیرد و با ظرافت مدیریت کند. خوشبختانه CDC و Event Stream که برای Durability ساختیم، دقیقاً پایهای است که برای ردیابی تلاشهای پرداخت، مدیریت هوشمند Timeoutها و حفظ سازگاری حتی در زمان رفتار نامنظم شبکهها لازم داریم.
رویکردهای مدیریت این عدم قطعیت ذاتی:
رویکرد اول، Timeout را شکست فرض کردن (بد): با ارتباط شبکه مثل تماس بین سرویسهای داخلی رفتار کنیم. برای درخواستهای شبکهی پرداخت Timeout معقولی (معمولاً ۳۰ تا ۶۰ ثانیه) میگذاریم. اگر در این پنجره پاسخی نیامد، فرض میکنیم پرداخت انجام نشده و پایگاه داده را بر این اساس بهروز میکنیم. این رویکرد ساده به ما اجازه میدهد بازخورد سریع به تاجر بدهیم. بعد از ۳۰ ثانیه تاجر میداند سفارش را انجام دهد یا از مشتری بخواهد دوباره امتحان کند. پایگاه داده هم با علامت موفق یا ناموفق روی هر پرداخت وضعیت تمیز دارد و هرگز در بلاتکلیفی نمیماند.
چالشها: این رویکرد فاجعهای در انتظار وقوع است. Timeout شبکه هیچ چیزی درباره اینکه واقعاً چه بر سر پرداخت آمده نمیگوید. این سناریو که روزانه در سیستمهای Production رخ میدهد را ببینید:
- درخواست شارژ ۲۰۰ دلاری را به بانک مشتری میفرستیم.
- بانک حساب مشتری را تأیید و بدهکار میکند.
- بستهی پاسخ به تأخیر میافتد یا در ازدحام شبکه گم میشود.
- Timeout ۳۰ ثانیهای ما فعال میشود و پرداخت را «ناموفق» علامت میزنیم.
- تاجر پیام «پرداخت انجام نشد، لطفاً دوباره امتحان کنید» نشان میدهد.
- مشتری دوباره تلاش میکند و شارژ دوم ۲۰۰ دلاری ساخته میشود.
- حالا مشتری برای یک خرید ۲۰۰ دلاری ۴۰۰ دلار پرداخت کرده است.
رویکرد دوم، وضعیت pending_verification و تأیید پسزمینه: رویکرد بهتر میپذیرد که Timeout عدم قطعیت میسازد، نه شکست. بهجای حدس زدن، نتایج نامشخص را صریح ردیابی میکنیم؛ وضعیت پرداخت را طوری گسترش میدهیم که وضعیت pending_verification برای Timeoutها داشته باشیم. همچنین یک جدول جدا میسازیم که هر تلاش شارژ به شبکهی پرداخت را ردیابی کند: چه چیزی فرستادیم، کی فرستادیم و شبکه چه شناسهی مرجعی داد.
وقتی Timeout رخ میدهد، پرداخت را pending_verification علامت میزنیم، نه ناموفق. بعد یک سرویس پسزمینه بهصورت دورهای با شناسهی مرجعی که ثبت کردهایم از شبکهی پرداخت میپرسد واقعاً چه بر سر پرداخت آمده است. این فرایند تأیید مستقل از API رو به تاجر اجرا میشود و بدون مسدود کردن پردازش پرداختهای جدید، حالتهای نامشخص را کمکم حل میکند.
نکتهی مهم: برای جلوگیری از شارژ تکراری، Idempotency را با یک Unique Constraint پایگاه داده روی ترکیب شناسهی تاجر و Idempotency Key پیاده میکنیم. وقتی تاجر با همان Idempotency Key دوباره تلاش میکند، بهجای ساخت شارژ تکراری، رکورد پرداخت موجود را میگیرد.
چالشها: بار Reconciliation دستی قابلتوجه است. تیم مالی باید پرداختهای معلق را روزانه بررسی کند، با فایلهای بانکی تطبیق دهد و وضعیتها را دستی بهروز کند. در دورههای شلوغ مثل Black Friday ممکن است هزاران پرداخت در وضعیت معلق بمانند و فرایندهای Reconciliation را زیر فشار بگذارند.
بهطور بنیادیتر، این رویکرد هنوز Reconciliation را موردی استثنایی میداند، نه رفتار اصلی سیستم. تأیید را روی سیستمی که برای پاسخهای Synchronous طراحی شده سوار میکنیم و بدون رسیدگی به علت اصلی، پیچیدگی عملیاتی میسازیم. پایگاه داده هم بار کامل Queryهای عملیاتی و جستجوهای Reconciliation را تحمل میکند که در اوج مصرف، یعنی زمانی که کمترین ظرفیت اضافه را داریم، عملکرد را پایین میآورد.
رویکرد سوم، ردیابی Attempt با CDC و Reconciliation خودکار: قویترین رویکرد از CDC و Event Stream که برای Durability ساختیم استفاده میکند و شبکههای پرداخت Asynchronous را شهروند درجهی یک معماری میداند. بینش کلیدی این است که قصدهایمان را قبل از اجرا ردیابی کنیم. جریان کامل:
- ثبت Attempt: پیش از تماس با هر شبکهی پرداخت، یک رکورد Attempt با نام شبکه، شناسهی مرجع و آنچه قصد انجامش را داریم در پایگاه داده مینویسیم. این یک Event CDC تولید میکند که قصد ما را ثبت میکند.
- تماس با شبکهی پرداخت: درخواست شارژ واقعی را با Timeout پیکربندیشده میفرستیم.
- مدیریت پاسخ (بر اساس نتیجه): در حالت موفقیت، وضعیت Attempt را در پایگاه داده
successمیکنیم که Event CDC دیگری با نتیجهی موفق میسازد. در حالت Timeout، وضعیت Attempt راtimeoutمیکنیم و یک Event CDC تولید میشود که Reconciliation Service پردازش میکند. در حالت شکست صریح، وضعیت Attempt راfailedهمراه دلیل شکست میگذاریم. - Reconciliation خودکار: یک Reconciliation Service اختصاصی Eventهای Timeout را مصرف میکند و با شناسهی مرجعی که ثبت کردهایم فعالانه از شبکهی پرداخت میپرسد واقعاً چه اتفاقی افتاده است.
این یک مسیر حسابرسی کامل میسازد که در آن هر مرحله، چه تماس موفق شود، چه شکست بخورد و چه Timeout شود، بهعنوان Event تغییرناپذیر ثبت میشود.
قدرتمندترین جنبهی این طراحی نحوهی برخورد با Reconciliation است. شبکههای پرداخت دو راه برای تأیید وضعیت تراکنش میدهند: درخواست مستقیم API برای تأیید Synchronous، و فایلهای Reconciliation دستهای. این فایلها رکوردهای جامع همهی تراکنشهای پردازششده در یک دورهی خاص هستند که طبق برنامهی منظم (روزانه یا ساعتی) تحویل داده میشوند. آنها از مشخصات قالببندی دقیقی پیروی میکنند و رکورد قطعی از چیزی هستند که واقعاً در شبکهی پرداخت رخ داده است.
Reconciliation Service اختصاصی این رویدادهای خارجی را با Attempt های داخلی ما تطبیق میدهد و وضعیت پرداخت را بر اساس دادهی معتبر شبکه بهروز میکند، نه حدس بر اساس Timeout. این سرویس بهطور پیوسته Eventها را مصرف میکند، هنگام Timeout فعالانه از شبکهها میپرسد و فایلهای Settlement را هنگام رسیدن بهطور سیستماتیک پردازش میکند.
این معماری میپذیرد که شبکههای پرداخت شرکای Asynchronous هستند، نه سرویسهای Synchronous. با ردیابی هر Attempt و نتیجهاش از طریق Event، سیستمی میسازیم که بهجای مبارزه با عدم قطعیت، با ظرافت آن را مدیریت میکند.
در سیستمهای پرداخت Production مثل Stripe، پردازش تراکنش در واقع از یک مدل رویدادی دوفازی استفاده میکند:
- Event «تراکنش ایجاد شد»: وقتی پردازش در Transaction Service شروع میشود و پیش از تکمیل نوشتن در پایگاه داده منتشر میشود. اگر این Event منتشر نشود، تراکنش وارد وضعیت قفل یا شکستخورده میشود و میتوان دوباره امتحانش کرد.
- Event «تراکنش تکمیل شد»: بعد از نوشتن موفق در پایگاه داده منتشر میشود. اگر انتشار این Event شکست بخورد، تراکنش نیز وارد وضعیت قفل یا شکستخورده میشود که در آن تا انتشار موفق Event تکمیل، بهروزرسانیهای بعدی مسدود میشوند.
این رویکرد دومرحلهای به سیستم اجازه میدهد دادهی Event «ایجاد شد» را در زمان Retry با وضعیت واقعی پایگاه داده مقایسه کند. اگر نوشتن در پایگاه داده قبلاً انجام شده باشد، سیستم فقط باید Event تکمیل را دوباره بفرستد، نه اینکه کل تراکنش را دوباره امتحان کند. این الگو تضمینهای قویتری برای سازگاری داده بین Transaction Service و Consumerهای پاییندستی میدهد. بیشتر Eventهای «تکمیل شد» گمشده در Production در واقع بهخاطر Timeout سیستم خارجی پیش از نوشتن در پایگاه داده هستند، نه شکست انتشار Event بعد از نوشتن موفق.
کلید مدیریت شبکههای پرداخت Asynchronous این است که بپذیریم عدم قطعیت اجتنابناپذیر است و سیستم را برای Eventual Consistency طراحی کنیم. با ترکیب پایگاه دادهی سنتی برای نیازهای Synchronous کسبوکار با Event Stream برای واقعیت Asynchronous شبکه، هم به عملکرد میرسیم و هم به صحت. این الگو که در شرکتهایی مثل Stripe که میلیاردها پرداخت پردازش میکنند اثبات شده، نشان میدهد بهترین سیستمهای توزیعشده با ماهیت وابستگیهای خارجی مبارزه نمیکنند؛ آن را میپذیرند و برایش طراحی میکنند.
۴) مقیاسپذیری برای ۱۰ هزار TPS و بیشتر
راه درازی آمدهایم و سیستمی داریم که تقریباً همهی نیازمندیهای عملکردی و غیرعملکردی را برآورده میکند. حالا فقط باید بحث کنیم مقیاس را چطور مدیریت کنیم. من دوست دارم مقیاس را برای آخرین Deep Dive نگه دارم، چون آنوقت تصویر روشنی از کل سیستم دارم. احتمالاً در بخشهای دیگر مصاحبه هم درباره مقیاس صحبت کردهاید (مثل کمی که ما کردیم)، ولی اینجا همهی آنها را به هم وصل میکنیم.
سرورها: با اصول شروع میکنیم. هر سرویس ما Stateless است و با Load Balancer جلوی آنها که بار را پخش میکند، افقی مقیاس میگیرد. ذکر این نکته در مصاحبه ارزش دارد، ولی لازم نیست طولانی توضیح دهید. مصاحبهکننده منظور شما را میداند و امروزه این تا حد زیادی بدیهی فرض میشود.
Kafka: بعد Event Log یعنی Kafka را عمیقتر بررسی میکنیم. انتظار داریم تا ۱۰ هزار TPS داشته باشیم. Kafka در سطح Cluster میتواند میلیونها پیام در ثانیه را پشتیبانی کند، ولی نکته این است که هر Partition بهتنهایی در شرایط عادی Production معمولاً حدود ۵ تا ۱۰ هزار پیام در ثانیه را تحمل میکند. برای پشتیبانی راحت از ۱۰ هزار TPS به چند Partition نیاز داریم، احتمالاً ۳ تا ۵، تا هم Throughput و هم Fault Tolerance تضمین شود.
هنگام Partitioning باید ترتیب تراکنشها در هر PaymentIntent را تضمین کنیم. پس Topicهای Kafka را با payment_intent_id پارتیشن میکنیم. این تضمین میکند همهی Eventهای یک PaymentIntent به ترتیب پردازش شوند و در عین حال PaymentIntentهای مختلف بتوانند موازی پردازش شوند. این استراتژی نیاز Throughput را با نیاز ترتیب متعادل میکند: همهی انتقالهای وضعیت یک PaymentIntent (created → authorized → captured) بهصورت متوالی پردازش میشوند، در حالی که PaymentIntentهای مختلف موازی روی Partitionها پردازش میشوند.
برای Redundancy، Kafka را با Replication Factor برابر ۳ پیکربندی میکنیم، یعنی هر Partition سه نسخه دارد که روی Brokerهای مختلف پخش شدهاند. اگر یک Broker بیفتد، این Fault Tolerance را میدهد. برای هر سرویس هم Consumer Group جدا میسازیم که اجازه میدهد Consumerها را افقی مقیاس دهیم و در عین حال معنای پردازش Exactly-once را حفظ کنیم.
پایگاه داده: پایگاه دادهی ما باید از نظر تعداد تراکنش در ثانیه تقریباً هممرتبه با Event Log باشد. با ۱۰ هزار TPS برای Event Stream، انتظار حدود ۱۰ هزار نوشتن در ثانیه در پایگاه داده داریم. این دقیقاً لبهی چیزی است که یک Instance خوب بهینهشدهی PostgreSQL میتواند تحمل کند، بهخصوص وقتی با Read Replicaها و Indexing مناسب ترکیب شود. برای مدیریت بهتر مقیاس میتوانیم پایگاه داده را بر اساس merchant_id Shard کنیم.
برای رشد داده، فرض میکنیم هر ردیف حدود ۵۰۰ بایت است. یعنی ( 10000 \times 500 ) بایت برابر ۵ مگابایت در ثانیه، و ( 5 \text{ MB} \times 100000 ) (ثانیههای گردشدهی یک روز) برابر ۵۰۰ گیگابایت در روز میشود. این در سال حدود ۱۸۰ ترابایت است. این رشد ذخیرهسازی قابلتوجه است و به برنامهریزی دقیق برای Retention و Archive نیاز دارد.
تراکنشهای قدیمیتر از یک بازهی مشخص (مثلاً ۳ تا ۶ ماه) را میتوانیم به Cold Storage مثل Amazon S3 یا Google Cloud Storage منتقل کنیم. این دادهی بایگانیشده برای Compliance و حسابرسی در دسترس میماند ولی روی عملکرد پایگاه دادهی عملیاتی اثری ندارد. یک Job زمانبندیشده رکوردهای قدیمی را طبق سیاست Retention شناسایی، Export و پاک میکند.
برای مقیاس خواندن میتوانیم Read Replica پیاده کنیم. بیشتر درخواستهای مرتبط با پرداخت عملیات خواندنی هستند (بررسی وضعیت، تولید گزارش)، پس چند Read Replica این بار را پخش میکند. همچنین میتوانیم لایهی Caching با Redis یا Memcached برای دادههایی که زیاد خوانده میشوند، مثل وضعیت پرداختهای اخیر، اضافه کنیم.
Deep Dive جایزه، پشتیبانی از Webhook
همانطور که گفتیم، سیستم Webhook قوی خودش یک موضوع بزرگ طراحی سیستم است، موضوعی که میتواند کل مصاحبه را پر کند (و اغلب هم میکند). در چارچوب این بحث پردازشگر پرداخت، بهجای ورود به همهی جزئیات پیادهسازی، رویکرد سطح بالا را توضیح میدهیم.
Endpoint وضعیت مبتنی بر Polling ما برای سناریوهای ساده خوب است، ولی تاجران اغلب برای راهاندازی فرایندهای کسبوکار مثل انجام سفارش یا فعالسازی دسترسی به بهروزرسانی بلادرنگ تغییر وضعیت پرداخت نیاز دارند. Webhook این مشکل را حل میکند و به سیستم اجازه میدهد تاجران را فعالانه از رویدادها باخبر کند.
تاجران دو اطلاعات اضافه میدهند:
- Callback URL: نشانیای که بهروزرسانیها را به آن
POSTمیکنیم. - رویدادهای مشترک شده (Subscribed Events): فهرست رویدادهایی که میخواهند مشترک آنها باشند. هر وقت یکی از این رویدادها رخ دهد، آنها را از طریق Callback URL باخبر میکنیم.
شاید با شنیدن «بهروزرسانی بلادرنگ» به WebSocket یا SSE فکر کنید. ولی اینها اغلب با هم اشتباه گرفته میشوند. Webhook ارتباط سرور به سرور است، نه سرور به کلاینت (مثل WebSocket یا SSE). سرور سیستم پرداخت، اعلانها را مستقیم از طریق درخواست HTTP به Endpoint از پیش تعریفشده روی سرور تاجر میفرستد. این با بهروزرسانیهای بلادرنگ مثل WebSocket یا Server-Sent Events که از سرور به مرورگر یا اپلیکیشن موبایل میرسند اساساً فرق دارد. Webhook برای ارتباط سیستم به سیستم طراحی شده و معمولاً از تاجر میخواهد یک Endpoint عمومی برای دریافت اعلانها داشته باشد.
عملکرد Webhook در سیستم پرداخت ما در سطح بالا:
- تغییرات پایگاه داده: Transaction Service و PaymentIntent Service با پیشرفت پرداختها در چرخهی عمرشان پایگاه دادهی عملیاتی را بهروز میکنند (
created→authorized→capturedو مانند آن). - Eventهای CDC: Change Data Capture این تغییرات را خودکار ضبط میکند و در Kafka Event Stream منتشر میکند. این Eventها تغییر وضعیت پرداخت، تکمیل تراکنش و سایر انتقالهای وضعیت را شامل میشوند.
- Webhook Service: سرویس جدیدی اضافه میکنیم که از همان Kafka Event Stream مثل بقیهی Consumerهای تخصصی، مثل Reconciliation، مصرف میکند. وقتی Event CDC میرسد، بررسی میکند تاجر مرتبط برای آن نوع Event یک Webhook Endpoint پیکربندی کرده یا نه. اگر کرده باشد، Payload را با جزئیات Event آماده میکند، آن را با Shared Secret امضا میکند تا امکان Verification باشد و تلاش میکند به Endpoint تاجر برساند.
- مدیریت تحویل: برای هر Webhook، تلاش تحویل با وضعیتش ثبت میشود. اگر تحویل شکست بخورد، Webhook Service استراتژی Retry با Exponential Backoff اجرا میکند (مثلاً بعد از ۵ ثانیه، ۲۵ ثانیه، ۱۲۵ ثانیه و مانند آن، تا سقف معقولی مثل ۱ ساعت).
- پیادهسازی سمت تاجر: تاجر باید یک Endpoint عمومی HTTPS برای دریافت Webhook پیکربندی کند. باید امضای Webhookهای دریافتی را با Shared Secret تأیید کند تا از اصالت مطمئن شود. بعد از تأیید، Payload را پردازش و سیستمهایش را بر اساس اطلاعات جدید بهروز میکند. در نهایت باید کد وضعیت HTTP از نوع
2xxبرگرداند تا دریافت Webhook را تأیید کند و از Retry غیرضروری جلوگیری شود.
نمونهی سادهی Payload:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"id": "evt_1JklMnOpQrStUv",
"type": "payment.succeeded",
"created": 1633031234,
"data": {
"object": {
"id": "pay_1AbCdEfGhIjKlM",
"amountInCents": 2499,
"currency": "usd",
"status": "succeeded",
"created": 1633031200
}
}
}
در سیستمهای Production، قابلیت اطمینان تحویل Webhook بسیار مهم است. هرچند اینجا همهی جزئیات را نمیگوییم، یک سیستم Webhook کامل ویژگیهایی مثل Idempotency Key، پایش دقیق تحویل، Log های Webhook برای دیباگ و داشبورد برای مشاهده و Replay Webhookها توسط تاجران دارد.
اگر این مصاحبهی اختصاصی طراحی سیستم Webhook بود، چالشهای پیچیدهتری را بررسی میکردیم، مثل معناشناسی تحویل Exactly-once، مدیریت Backlog صف Webhook در زمان قطعی، نسخهبندی Payload و Rate Limiting تطبیقی برای جلوگیری از غرق شدن سیستمهای تاجر. اما برای طراحی پردازشگر پرداخت ما، همین نمای کلی سطح بالا عملکرد اساسی لازم را نشان میدهد.
انتظار در هر سطح
ممکن است فکر کنید «واقعاً چقدر از اینها در مصاحبه لازم است؟». بیایید تفکیکش کنیم.
Mid-level: برای نامزد سطح متوسط که درباره سیستم پرداخت بحث میکند، در درجهی اول روی توانایی ساخت عملکرد اصلی تمرکز میکنم. باید بتوانید جریان پرداخت سادهای طراحی کنید که نیازمندیهای پایه، یعنی آغاز درخواست پرداخت، پردازش کارتها و برگرداندن بهروزرسانی وضعیت را برآورده کند.
باید نشان دهید نیاز به امنیت در سیستمهای پرداخت را میفهمید و میدانید دادهی حساس کارت نباید به سرور تاجر برسد. با این حال انتظار ندارم همهی ظرافتهای امنیت پرداخت یا رویکردهای پیچیدهی Tokenization را عمیق بفهمید. هنگام بحث درباره Consistency، راهحل معقولی مثل فرض کردن Timeout بهعنوان شکست (حتی اگر ایدهآل نباشد) تا وقتی رویکرد را روشن توضیح دهید قابلقبول است. بیشتر به فرایند حل مسئلهی شما نگاه میکنم تا انتظار راهحل کامل برای چالشهای پیچیدهی Reconciliation پرداخت.
پیشبینی میکنم ممکن است برای چالشهای فنی عمیقتر به راهنمایی نیاز داشته باشید و این کاملاً مشکلی ندارد. مصاحبهکننده احتمالاً گفتگو را در بخشهای پایانی هدایت میکند.
Senior: از مهندس Senior انتظار دارم سریع از عملکردهای پایه عبور کند و گفتگو را به سمت جنبههای غیرعملکردی حیاتی سیستم پرداخت ببرد. باید امنیت و Consistency را فعالانه بهعنوان چالشهای اصلی شناسایی کنید.
در امنیت، باید راهحلی مثل رویکرد ایزولهسازی با iframe پیشنهاد دهید و مزایای آن را در محافظت از دادهی حساس کارت بفهمید. باید بتوانید توضیح دهید چرا این رویکرد از جمعآوری مستقیم دادهی کارت توسط تاجران بهتر است. برای Consistency پرداخت، باید بدانید Timeout لزوماً به معنای شکست نیست و رویکرد پیچیدهتری مثل تراکنشهای Idempotent با حلوفصل وضعیت معلق (Pending Resolution) پیشنهاد دهید. باید بتوانید Race Conditionهای احتمالی را شناسایی کنید و توضیح دهید طراحی شما چطور از Double Charge جلوگیری میکند.
برای بحث مقیاسدهی، انتظار دارم با اطمینان درباره نحوهی پیادهسازی مقیاس افقی برای سرویسها و پایگاههای داده صحبت کنید، هرچند ممکن است برای بررسی موضوعات پیشرفتهتر مثل استراتژیهای Partitioning در Kafka یا Event Sourcing به راهنمایی نیاز داشته باشید.
Staff و بالاتر: از نامزد Staff+ انتظار دارم تخصص عمیق در طراحی سیستمهای پرداخت داشته باشد و همزمان تفکر معماری و محصولی استثنایی نشان دهد. بهجای عجله در راهحلهای پیچیده، ابتدا باید ارزیابی کند که آیا اصلاً لازماند یا نه. مثلاً ممکن است اشاره کند برای بسیاری از سناریوهای پرداخت، Event Sourcing همراه Reconciliation رویکرد درستی است، نه فقط به دلایل فنی، بلکه چون با نحوهی کار بنیادین سیستمهای مالی همراستاست و ماهیت ذاتاً Asynchronous شبکههای پرداخت را میپذیرد. برای امنیت، باید بتواند رویکردهای چندلایه مثل Tokenization همراه رمزگذاری سمت کلاینت را توضیح دهد و درک درستی از استراتژی دفاع در عمق (Defense in Depth) لازم برای مدیریت دادهی مالی حساس نشان دهد.
باید فعالانه چالشبرانگیزترین Edge Caseهای پردازش پرداخت را شناسایی و رسیدگی کند. مثلاً میتواند درباره نحوهی مدیریت قطعی شبکهی پرداخت از طریق مسیرهای پردازش Fallback یا نحوهی طراحی فرایندهای Reconciliation مقاوم که Eventual Consistency با شبکههای پرداخت خارجی را تضمین میکنند بحث کند.