ویرگول
ورودثبت نام
افشین یاری نیا
افشین یاری نیا
افشین یاری نیا
افشین یاری نیا
خواندن ۲۶ دقیقه·۴ ماه پیش

رزرو پرواز: طراحی قولی که بین Search، Payment و Ticketing می‌دهیم

Alibaba.ir مثال‌ خوبی هست، چون محصول از بیرون خیلی آشنا و ساده به نظر می‌رسد:

جستجوی پرواز انتخاب گزینه مناسب وارد کردن اطلاعات مسافر پرداخت دریافت بلیط

از نگاه کاربر، این شبیه یک checkout معمولی است.

اما از نگاه backend، ماجرا checkout ساده نیست.

مسئله اصلی این نیست که یک ردیف در جدول order ذخیره کنیم یا یک payment gateway را صدا بزنیم. سؤال سخت‌تر این است:

چطور چیزی را می‌فروشیم که هم زمان قیمت، موجودی، وضعیت payment، پاسخ supplier و وضعیت نهایی ticketing آن می‌تواند مستقل از هم تغییر کند؟

مشکل واقعی دقیقاً پشت همان دکمه ساده «پرداخت» پنهان شده است.

در یک ecommerce یا marketplace معمولی هم پیچیدگی کم نیست، اما پلتفرم معمولاً کنترل بیشتری روی حقیقت سیستم دارد: catalog، inventory فروشنده، lifecycle سفارش، انبار، ارسال، refund و غیره.

در رزرو پرواز، پلتفرم از همان ابتدا با سیستم‌هایی کار می‌کند که داخل database خودش نیستند و با transaction خودش commit نمی‌شوند.

پلتفرم می‌تواند وضعیت booking خودش را کنترل کند. اما مالک airline inventory، سیستم GDS، charter supplier، NDC provider یا payment gateway نیست.

این تفاوت خیلی مهم است.

خیلی از مشکل‌های سطح inventory را خود پلتفرم سفر حل نمی‌کند. اگر دو کانال مختلف هم‌زمان بخواهند آخرین seat را بفروشند، مرجع نهایی معمولاً airline، GDS یا supplier reservation system است. پلتفرم نمی‌تواند از database خودش کل دنیای airline را lock کند.

مسئولیت سخت‌تر پلتفرم چیز دیگری است:

به کاربر قول اشتباه ندهد. مبلغ اشتباه دریافت نکند. booking پرداخت‌شده را گم نکند. بعد از retry دو بار ticket صادر نکند. timeout را با failure اشتباه نگیرد. وقتی payment truth و supplier truth با هم نمی‌خوانند، تیم عملیات را کور نگذارد.

پس این مقاله درباره اختراع دوباره airline inventory control نیست. درباره طراحی workflow کنار سیستم‌های خارجی است: اینکه پلتفرم چطور quote می‌دهد، revalidate می‌کند، hold می‌گیرد، payment را verify می‌کند، ticket صادر می‌کند، reconcile می‌کند، refund می‌دهد و وضعیت را برای کاربر و تیم support قابل فهم نگه می‌دارد.

مرز مالکیت حقیقت تقریباً این شکلی است:

با این نگاه، طراحی نباید از اینجا شروع شود:

orders table + payments table + tickets table

باید از این سؤال‌ها شروع شود:

invariantهای سیستم چیست؟ مالک هر حقیقت واقعاً چه سیستمی است؟ کدام عملیات را می‌توان safe retry کرد؟ کدام stateها ممکن است موقتاً unknown باشند؟ وقتی واقعیت بیرون با state داخلی ما نمی‌خواند، چطور آن را repair می‌کنیم؟

در این مقاله تمرکز اصلی روی patternهای system design پشت یک پلتفرم رزرو پرواز است.

فرض‌های اولیه در مقیاس

عدد دقیق خیلی مهم نیست. چیزی که مهم است، شکل workload است.

برای یک پلتفرم رزرو پرواز، فرض‌های منطقی می‌تواند این‌ها باشد:

ترافیک search بسیار بیشتر از booking است. هدف این است که latency در search تا حد ممکن در حد چند صد میلی‌ثانیه باقی بماند.

latency در سمت supplier می‌تواند متغیر باشد و گاهی به چند ثانیه برسد. reliability نیز بین supplierها یکسان نیست.

freshness نتایج search محدود است و مطلق نیست. به همین دلیل، freshness در مرحله booking باید در همان لحظه revalidate شود.

payment state ماهیت asynchronous دارد و گاهی ممکن است در وضعیت unknown باقی بماند.

ticketing state به وضعیت و پاسخ supplier وابسته است.

ترکیب passengerها شامل adult، child، infant، nationality و documents است.

شکل itinerary می‌تواند one-way، round-trip، multi-city یا connecting flights باشد.

seat constraints شامل adjacent seats، locked seats و paid seat selection است.

bundle complexity می‌تواند از flight-only تا flight-plus-hotel یا tour package متفاوت باشد.

support risk بیشتر روی حالت paid-but-not-ticketed متمرکز می‌شود.

این فرض‌ها مسیر طراحی را مشخص می‌کنند:

search و booking باید مسیرهای جداگانه داشته باشند. search result فقط یک candidate است، نه تعهد نهایی.

قبل از payment باید reprice انجام شود. اگر supplier پشتیبانی کند، قبل از charge باید reservation hold گرفته شود.

سیستم به state machine نیاز دارد، نه چند flag پراکنده.

همه external writeها باید idempotent باشند.

external eventها باید قبل از پردازش ذخیره شوند.

reconciliation بخشی از core system است، نه یک job تزئینی.

stateهای ambiguous باید برای تیم operations قابل مشاهده باشند.

itinerary و passenger constraints باید به‌صورت explicit مدل شوند.

چالش طراحی happy path نیست. چالش این است که وقتی happy path از وسط راه خراب می‌شود، damage را کنترل کنیم.

1. چرا رزرو پرواز از checkout معمولی سخت‌تر است؟

در یک checkout ساده ecommerce، پلتفرم معمولاً می‌تواند این کارها را انجام دهد:

reserve item charge customer create order ship item

حتی اگر هر مرحله پیچیده باشد، بخش بزرگی از system boundary دست خود شرکت است.

رزرو پرواز فرق دارد. نتیجه‌ای که در search به کاربر نشان داده می‌شود، معمولاً snapshot از availability بیرونی است. این snapshot ممکن است قبل از اینکه کاربر اطلاعات مسافر را کامل کند، stale شود.

بین search و ticket issuing ممکن است این اتفاق‌ها بیفتد:

fare ممکن است دوباره قیمت‌گذاری شود.

fare class ممکن است بسته شود.

آخرین seat ممکن است از کانال دیگری فروخته شود.

supplier hold ممکن است expire شود.

payment gateway ممکن است با تأخیر پاسخ دهد.

callback ممکن است دو بار دریافت شود.

supplier ممکن است ticket را issue کرده باشد، اما response در timeout گم شود.

platform ممکن است بعد از payment success و قبل از ticketing crash کند.

این دیگر CRUD نیست. این یک distributed workflow است که در آن inventory خارجی و user promise کنار هم قرار گرفته‌اند.

تغییر ذهنیت مهم این است:

booking row منبع حقیقت کل دنیا نیست. booking row فقط تفسیر پایدار پلتفرم از چند حقیقت بیرونی است.

این تفسیر باید آن‌قدر درست و قابل repair باشد.

2. مسئله اصلی: قیمت، seat availability و payment مستقل از هم تغییر می‌کنند

یک رزرو پرواز حداقل چند حوزه حقیقت دارد:

Commercial truth یعنی: آیا این گزینه در همین لحظه با این قیمت قابل فروش است؟

Inventory truth یعنی: آیا این seat یا fare class هنوز موجود است؟

Payment truth یعنی: آیا پول واقعاً جابه‌جا شده است یا نه؟

Ticketing truth یعنی: آیا supplier order یا ticket نهایی واقعاً صادر شده است یا نه؟

Constraint truth یعنی: آیا این itinerary هنوز برای این passenger mix، seat request و bundle معتبر است یا نه؟

این حوزه‌ها هم‌زمان update نمی‌شوند.

کاربر یک flow ساده می‌بیند.

backend یک saga می‌بیند:

Search -> Reprice -> Hold -> Pay -> Verify -> Issue -> Deliver -> Reconcile

طراحی خوب این saga را explicit می‌کند.

چند invariant مهم:

Invariant 1: در flow معمول prepaid consumer، قبل از verified payment نباید ticket صادر شود.

Invariant 2: نباید قیمتی را charge کنیم که کاربر آن را قبول نکرده است.

Invariant 3: برای یک active booking attempt نباید دو payment موفق وجود داشته باشد.

Invariant 4: نباید برای یک passenger و یک supplier booking reference، دو ticket عمدی صادر شود.

Invariant 5 :موضوع paid-but-not-ticketed یک final failure نیست. یک recovery state مهم است.

Invariant 6:موضوع timeout یعنی unknown، نه failure.

Invariant 7:در offer قیمت‌گذاری‌شده باید دقیقاً با itinerary، passenger mix، seat constraints و bundle مورد قبول کاربر match باشد.

این invariantها از schema مهم‌ترند. schema فقط ابزاری است برای enforce و observe کردن آن‌ها.

3. موجودیت‌های اصلی

طراحی ساده ممکن است فقط یک جدول orders داشته باشد.

طراحی بهتر مفاهیمی را جدا می‌کند که مستقل از هم تغییر می‌کنند.

Search Result

SearchResult چیزی است که کاربر در لیست نتایج می‌بیند. این یک candidate است، نه ticket تضمین‌شده.

قانون مهم: client نباید search result را تبدیل به trusted price کند. server باید با supplier reference یا server-side quote دوباره reprice کند.

Fare / Offer

Fare یا Offer گزینه قیمت‌گذاری‌شده دقیق‌تری است.

supplier offer reference fare class and cabin total price and currency price validity window rules hash

rules_hash مهم است، چون دو fare ممکن است قیمت یکسان داشته باشند اما refund rule، baggage rule یا change rule متفاوتی داشته باشند.

Itinerary

Itinerary شکل واقعی سفر را توضیح می‌دهد. خیلی از requirementهایی که در UI کوچک به نظر می‌رسند، اینجا تبدیل به constraintهای backend می‌شوند.

trip type: one-way, round-trip, multi-city segments: each flight leg connection rules round-trip pairing multi-city grouping

one-way، round-trip، multi-city و connecting flight فقط optionهای UI نیستند. این‌ها روی pricing، availability، cancellation rule و supplier booking semantics اثر می‌گذارند.

Segment

Segment هر leg پرواز داخل itinerary است.

origin_airport destination_airport departure_time arrival_time marketing_airline operating_airline flight_number fare_class

در connecting یا multi-city، partial availability یک ریسک واقعی است. ممکن است یک segment available باشد، اما segment بعدی دیگر available نباشد.

Passenger

اطلاعات passenger بخشی از supplier order و ticket می‌شود.

first_name last_name national_id_or_passport birth_date passenger_type nationality document_type validation_status

Passenger validation بخشی از booking correctness است. یک typo ممکن است از یک payment failed هم گران‌تر تمام شود. adult، child و infant هم می‌توانند pricing یا rule متفاوت داشته باشند.

Seat Request

Seat selection فقط یک UI decoration نیست. می‌تواند inventory و adjacency constraint ایجاد کند.

requested_seat seat_type adjacency_group_id

اگر یک خانواده بخواهد کنار هم بنشیند، سیستم دیگر فقط ticket inventory را چک نمی‌کند. باید seat-map inventory و adjacency constraints را هم در نظر بگیرد.

Ancillary / Bundle

همه bookingها flight-only نیستند. ممکن است baggage، seat، insurance، hotel، airport transfer یا tour package هم داشته باشند.

bundle type required components optional components component-level state bundle-level price and rules

flight-plus-hotel consistency متفاوتی دارد. پلتفرم باید availability و cancellation rule چند product domain را با هم هماهنگ کند.

Booking Attempt

این core aggregate سیستم است.

accepted_price current_price version idempotency_key expires_at

version مهم است. state transitionها باید با optimistic locking یا مکانیزم مشابه محافظت شوند.

Reservation Hold

ReservationHold وقتی وجود دارد که supplier از hold موقت پشتیبانی کند.

supplier_reference hold_expires_at request_id fencing_token

fencing_token جلوی workerهای stale را می‌گیرد. اگر یک worker قدیمی بعداً بیدار شود، نباید بتواند با assumption قدیمی ticketing را ادامه دهد.

Payment

Payment lifecycle خودش را دارد و نباید در booking.is_paid خلاصه شود.

amount currency merchant_reference gateway_reference status idempotency_key verified_at

Ticket / Supplier Order

Ticket از payment جداست.

ticket record باید به یک سؤال ساده جواب دهد: آیا supplier واقعاً travel document یا final order قابل اتکا صادر کرده است؟

به همین دلیل ticketing از payment جدا می‌شود. یک booking پرداخت‌شده هنوز می‌تواند در TICKETING_UNKNOWN، MANUAL_REVIEW یا REFUND_REQUIRED باشد. معماری نباید این stateها را به یک paid = true ساده کاهش دهد.

Supplier

رفتار supplierها یکسان نیست. پس capability modeling مهم است.

supports_reprice supports_hold supports_hold_cancel supports_order_retrieve supports_idempotency_key supports_void max_safe_retry_count timeout_policy reconciliation_policy

supplier model باید روی workflow اثر بگذارد. supplierای که hold-and-ticket دارد نباید دقیقاً همان path supplierای را طی کند که فقط immediate issue دارد.

4. معماری سطح بالا

معماری باید سه concern را جدا کند:

Serving: مدیریت search سریع و readهای کاربر

Orchestration: مدیریت booking saga، state transition، idempotency و retry

Recovery: مدیریت reconciliation، repair، manual review و refund

اجزای مهم:

Search Aggregator : برای supplier fanout، latency، cache freshness و ranking بهینه می‌شود.

Supplier Gateway :اینجا APIهای مختلف supplierها را normalize می‌کند و جزئیات هر supplier را از core workflow جدا نگه می‌دارد.

Booking Orchestrator:اینجا allowed state transitionها و business invariantها را کنترل می‌کند.

Transactional Outbox : مطمئن می‌شود side effect خارجی فقط بعد از commit شدن state داخلی schedule شود.

Webhook / Supplier Inbox: اینحا callbackهای خارجی را قبل از پردازش ذخیره می‌کند تا deduplication و replay ممکن باشد.

Ticketing Worker: اینجا supplier writeها را با idempotency و retrieve-before-retry انجام می‌دهد.

Reconciliation Jobs: اینجا platform state را با payment truth و supplier truth مقایسه می‌کنند.

Component Coordinator : وقتی محصول flight-only نیست استفاده می‌شود. مثلاً flight، hotel، baggage، transfer یا insurance را به عنوان reservationهای جدا زیر یک bundle state دنبال می‌کند.

برای flight-only، Booking Orchestrator شاید فقط با repricing، hold، payment و ticketing سروکار داشته باشد. اما در package، unit of sale تغییر می‌کند:

bundle = flight component + hotel component + optional services

هر component ممکن است availability، hold expiry، cancellation rule و compensation path خودش را داشته باشد. bundle فقط وقتی باید confirmed شود که همه componentهای ضروری به state سازگار برسند. اگر یکی fail شود، سیستم باید بداند بقیه را release کند، retry کند، reprice کند، alternative بدهد یا booking را به manual review ببرد.

outbox و inbox مهم‌اند، چون خیلی از سیستم‌ها دقیقاً در crashهای بین state و side effect event را گم می‌کنند:

DB updated but message not published message published but DB not updated callback received but processing crashed supplier issued but response timed out

معماری باید این حالت‌ها را recoverable کند.

در سطح lifecycle، booking یک saga است، نه یک transaction:

5. جریان Search: گرفتن نتیجه از Airline، GDS و Supplier APIها

Search منبع حقیقت booking نیست. Search سطح discovery است.

پلتفرم در search روی این‌ها optimize می‌کند:

latency coverage supplier fallback deduplication ranking bounded freshness

flow معمولی:

1. کاربر route، date، passenger count و cabin را وارد می‌کند.

2. Search Aggregator cache کوتاه‌مدت را چک می‌کند.

3. برای resultهای missing یا stale، به supplierها fanout می‌کند.

4. Supplier Gateway responseها را normalize می‌کند.

5. Search service offerها را deduplicate و rank می‌کند.

6. response شامل expiry و server-side offer reference است.

سخت‌ترین سؤال search، هزینه freshness است.

اگر هر search به همه supplierها live call بزند، latency و cost بالا می‌رود. اگر cache خیلی aggressive باشد، کاربر fareهایی می‌بیند که دیگر قابل فروش نیستند.

پس می‌توان freshness را adaptive طراحی کرد:

Hot route: shorter TTL, background refresh, tighter supplier monitoring Long-tail route: slightly longer TTL, revalidate at booking boundary Unstable supplier: lower cache confidence, visible reprice expectation High conversion route: invest in prefetch and freshness

Search result می‌تواند confidence داخلی داشته باشد:

fresh cached_recently stale_but_usable_for_discovery requires_live_reprice

کاربر لازم نیست همه این label ها را ببیند، اما booking service باید آن‌ها را بداند.

قانون مهم:

Search price is display price. Booking price is confirmed price.

مرز این دو، repricing است.

یک مرز دیگر هم هست که خیلی راحت فراموش می‌شود:

Generic availability is not booking availability.

ممکن است search result بگوید flight seat دارد، اما booking دقیق همچنان fail شود چون:

fare rule برای infant مناسب نیست.

child fare قیمت متفاوتی دارد.

nationality مسافر به document validation متفاوتی نیاز دارد.

در connecting itinerary، یکی از segmentها دیگر available نیست.

round-trip fare فقط زمانی معتبر است که outbound و return با هم pair شوند.

کاربر adjacent seats درخواست کرده، اما seat map نمی‌تواند آن را فراهم کند.

hotel component در package available است، اما flight available نیست.

به همین دلیل booking service باید full constraint set را validate کند، نه فقط selected flight ID را.

6. جریان Booking: قبل از payment، temporary hold می‌گیریم

Booking وقتی شروع می‌شود که کاربر اطلاعات passenger را برای یک fare انتخاب‌شده وارد می‌کند.

در این نقطه، سیستم باید دیگر به search freshness اعتماد نکند و از supplier بپرسد:

آیا این itinerary دقیقاً برای این passenger set و با این قیمت هنوز قابل فروش است؟

در عمل، این سؤال می‌تواند این باشد:

آیا این itinerary دقیق، با این passengerها، documentها، seat preferenceها، ancillaryها و package componentها هنوز با همین شرایط پذیرفته‌شده قابل فروش است؟

یک booking flow امن‌تر:

1. BookingAttempt با status CREATED ساخته می‌شود.

2. passenger data، document rules و route rules validate می‌شوند.

3. concurrency guard در سطح booking گرفته می‌شود.

4. selected offer با supplier reprice می‌شود.

5. شکل itinerary validate می‌شود: one-way، round-trip، multi-city، connecting.

6. seat و ancillary constraints در صورت نیاز validate می‌شوند.

7. supplier price با user-accepted price مقایسه می‌شود.

8. اگر قیمت تغییر کرده باشد، status به PRICE_CHANGED می‌رود و user acceptance جدید لازم است. 9. اگر available بود، در صورت پشتیبانی supplier، hold ساخته می‌شود.

10. hold reference، expiry و fencing token ذخیره می‌شود.

11. BookingAttempt به HOLD_CONFIRMED می‌رود.

12. payment برای مبلغ confirmed و accepted شروع می‌شود.

booking flow
booking flow

Supplier hold را باید مثل lease دید، نه ownership.

این lease چند چیز دارد:

holder expiry time supplier reference allowed next action release policy fencing token

اگر payment بعد از hold expiry کامل شود، پلتفرم نباید کورکورانه ticket issue کند. باید supplier state را دوباره چک کند یا بسته به capability supplier، hold جدید بگیرد.

اینجا جایی است که designهای ساده خراب می‌شوند. آن‌ها فرض می‌کنند:

hold confirmed once = safe forever

در واقعیت:

hold confirmed = safe until expiry, subject to supplier contract and reconciliation

7. جریان Payment: success، failure، timeout، duplicate callback و idempotency

Payment باید state machine مستقل خودش را داشته باشد.

frontend redirect حقیقت payment نیست. فقط navigation event کاربر است.

backend باید payment را server-to-server با gateway verify کند و هر gateway event را قبل از processing ذخیره کند.

قانون‌های اصلی:

Payment initiation باید idempotent باشد. Payment callback processing باید idempotent باشد. Payment verification باید amount، currency، merchant reference و booking ID را چک کند. Duplicate success callback باید harmless باشد. Timeout باید UNKNOWN بسازد، نه FAILED. در flow معمول prepaid consumer، ticketing باید منتظر verified payment بماند.

Payment event model بهتر:

payment_attempt payment_gateway_event payment_verification payment_ledger_entry

چرا جدا؟

چون callback با verified money یکی نیست.

Gateway event: "یک callback payload دریافت شد.

" Verification: "از gateway پرسیدیم و status را تأیید کردیم.

" Ledger entry: "پلتفرم این مبلغ را captured، refunded یا failed ثبت کرد."

این جداسازی وقتی callbackها duplicate یا out of order می‌آیند کمک می‌کند.

مثال:

12:00 user starts payment 12:02 platform receives callback success 12:02 verification API times out 12:04 user refreshes booking page 12:05 callback success arrives again 12:07 reconciliation verifies payment succeeded

سیستم ضعیف بسته به اینکه کدام event زودتر برسد، stateهای تصادفی می‌زند.

سیستم قوی می‌گوید:

payment is UNKNOWN until verification succeeds duplicate callbacks are stored and deduplicated booking page reads current durable state reconciliation completes the transition

idempotency باید در چند سطح وجود داشته باشد:

client request idempotency: user اگر دو بار Pay را بزند، نباید دو active payment بسازد.

gateway idempotency: retry کردن payment creation نباید gateway transaction تکراری بسازد. callback idempotency: duplicate gateway event نباید internal ledger entry تکراری بسازد. ticketing idempotency: paid booking نباید چند ticket صادر کند.

یکی از مهم‌ترین درس‌های طراحی همین است: idempotency یک key در یک table نیست. idempotency ویژگی هر boundaryای است که در آن retry ممکن است اتفاق بیفتد.

callback handler نباید فقط شبیه «mark paid» باشد. باید بیشتر شبیه «store، verify، deduplicate، بعد advance state» باشد:

handlePaymentCallback(event): if inbox.alreadyProcessed(event.id): return OK inbox.store(event) payment = payments.findByMerchantReference(event.merchantReference) verified = gateway.verify(event.gatewayReference) if verified.success: if payment.canMoveToCaptured(verified.amount, verified.currency): payment.markCaptured(verified.gatewayReference) booking.markPaymentVerified(payment.bookingId) outbox.enqueue("IssueTicket", payment.bookingId) else: payment.markFailedOrUnknown(verified.status) inbox.markProcessed(event.id) return OK

مهم syntax نیست. مهم ترتیب کار است: external event را ذخیره کن، پول را server-to-server verify کن، internal state را فقط یک بار update کن و ticketing را از طریق outbox schedule کن.

8. جریان Ticket Issuing: بعد از payment success چه می‌شود؟

Payment success یعنی پلتفرم حالا یکی از این دو چیز را به کاربر بدهکار است:

ticket یا refund / support resolution

payment success به معنی complete شدن booking نیست.

Ticket issuing معمولاً بهتر است async و از طریق worker انجام شود، چون supplier call می‌تواند timeout شود، ambiguous جواب دهد یا follow-up read لازم داشته باشد.

یک ticketing flow امن‌تر:

1. Payment به VERIFIED_SUCCESS می‌رود.

2. Booking transition به READY_TO_ISSUE commit می‌شود.

3. Outbox ticketing command را schedule می‌کند.

4. Ticketing worker آخرین booking version را load می‌کند.

5. Worker hold expiry و fencing token را چک می‌کند.

6. Worker supplier issue request را با idempotency reference می‌فرستد، اگر supplier پشتیبانی کند.

7. اگر supplier success داد، ticket/PNR ذخیره می‌شود و status به TICKET_ISSUED می‌رود.

8. اگر supplier timeout داد، status به TICKETING_UNKNOWN می‌رود.

9. قبل از retry، reconciliation supplier order را retrieve می‌کند.

10. اگر supplier permanent reject داد، booking به MANUAL_REVIEW یا refund flow می‌رود.

قانون حیاتی:

Never retry an external write blindly when the previous attempt may have succeeded.

برای supplier ticketing یعنی:

issue request timed out -> retrieve supplier order by reference -> if issued, store ticket -> if not issued and safe, retry -> if ambiguous, manual review

این pattern جلوی duplicate ticketing و گم شدن supplier order تأییدشده را می‌گیرد.

مسیر recovery برای paid-but-not-ticketed ارزش visual شدن دارد:

worker logic هم همین ایده را دنبال می‌کند:

issueTicket(bookingId): booking = bookings.loadForTicketing(bookingId) if booking.ticketAlreadyIssued(): return if booking.holdExpired(): booking.markNeedsRevalidation() return result = supplier.issue(booking.holdReference, booking.idempotencyKey) if result.issued: booking.storeTicket(result.pnr, result.ticketNumber) return if result.timeout: supplierState = supplier.retrieveOrder(booking.holdReference) if supplierState.issued: booking.storeTicket(supplierState.pnr, supplierState.ticketNumber) elif supplierState.safeToRetry: outbox.enqueue("IssueTicket", booking.id) else: booking.markManualReview()

فرق مهم این است: retry کردن job مشکلی ندارد. retry کردن کورکورانه ی یک supplier write است که duplicate یا ambiguous ticketing درست می‌کند.

9. سناریوهای خطا

قیمت بعد از Search تغییر می‌کند

Search این قیمت را نشان داده:

18,500,000 IRR

Repricing این را برمی‌گرداند:

19,200,000 IRR

رفتار درست:

booking به PRICE_CHANGED می‌رود payment شروع نمی‌شود قیمت جدید به کاربر نشان داده می‌شود explicit user acceptance لازم است quote version جدید ساخته می‌شود

accepted price باید version داشته باشد تا support، refund و audit tools دقیقاً بدانند کاربر چه قیمتی را قبول کرده است.

Seat قبل از Payment فروخته می‌شود

Supplier نمی‌تواند hold بسازد، چون inventory از بین رفته است.

رفتار درست:

HOLD_REJECTED payment شروع نمی‌شود alternativeها نمایش داده می‌شوند supplier reason ذخیره می‌شود search freshness signals update می‌شوند

نرخ بالای HOLD_REJECTED روی یک route یا supplier یعنی search freshness ضعیف است. این فقط booking failure نیست؛ سیگنال کیفیت search است.

Payment موفق می‌شود ولی Supplier Ticketing شکست می‌خورد

این یکی از مهم‌ترین business-risk stateهاست.

پلتفرم پول را گرفته، اما ticket را تحویل نداده است.

رفتار درست:

در این سناریو، با وجود موفق بودن پرداخت، وضعیت booking به TICKETING_FAILED یا MANUAL_REVIEW تغییر می‌کند.

در این مرحله، ارسال notification موفقیت به‌صورت خودکار block می‌شود تا کاربر پیام اشتباه دریافت نکند.

قبل از هر retry، سیستم ابتدا وضعیت سفارش را در سمت تأمین‌کننده بررسی می‌کند. فقط در صورتی که retry از نظر عملیاتی safe باشد، درخواست صدور بلیت دوباره انجام می‌شود.

اگر بلیت دیگر قابل issue نباشد، فرآیند refund آغاز می‌شود.

در نهایت، action موردنیاز برای operator به‌صورت واضح نمایش داده می‌شود تا تیم پشتیبانی بداند باید چه تصمیمی بگیرد.

وضعیت paid-but-not-ticketed باید بر اساس age سفارش و amount پرداختی alert شود.

این حالت یک background failure معمولی نیست. کاربر پرداخت را انجام داده، اما هنوز بلیت صادر نشده است. بنابراین سیستم باید این وضعیت را به‌عنوان یک failure حساس و نیازمند پیگیری فوری تشخیص دهد.

هرچه زمان بیشتری از پرداخت گذشته باشد، یا مبلغ سفارش بالاتر باشد، سطح alert باید جدی‌تر شود تا تیم عملیات سریع‌تر وارد عمل شود.

Supplier تأیید می‌کند ولی Callback یا Response دیر می‌رسد

ممکن است تأمین‌کننده بلیت را صادر کرده باشد، اما response صدور بلیت به پلتفرم نرسیده باشد.

رفتار درست:

وضعیت سفارش به TICKETING_UNKNOWN تغییر می‌کند. سپس سفارش ثبت‌شده در سمت تأمین‌کننده با استفاده از correlation ID بازیابی می‌شود.

اگر رویداد تأخیری از سمت تأمین‌کننده دریافت شود، در inbox ذخیره می‌شود. این رویداد با استفاده از supplier reference بررسی و deduplicate می‌شود تا از پردازش تکراری جلوگیری شود.

بعد از آن، وضعیت سفارش در پلتفرم repair می‌شود و پس از دریافت تأیید نهایی، نتیجه به کاربر اطلاع داده می‌شود.

late success در distributed system عادی است. نباید هر timeout را به refund یا retry تبدیل کرد.

کاربر Refresh می‌کند یا دوباره Payment را می‌زند

کاربر tab دیگری باز می‌کند، back می‌زند، refresh می‌کند یا دوبار payment را کلیک می‌کند.

رفتار درست:

در این حالت، active BookingAttempt بارگذاری می‌شود و current status همان attempt به کاربر برگردانده می‌شود.

اگر امکان‌پذیر باشد، همان active PaymentAttempt دوباره استفاده می‌شود تا از ایجاد payment جدید و رخ دادن second successful payment جلوگیری شود.

تمام callbackهای پرداخت باید به‌صورت idempotent پردازش شوند. به همین شکل، فرآیند ticket issuing هم باید idempotent بماند تا اجرای تکراری callback یا retry باعث صدور چندباره بلیت یا تغییر اشتباه state نشود.

UI باید تا حد ممکن stateless طراحی شود تا در صورت refresh شدن صفحه، کاربر بتواند بدون از دست رفتن فرآیند به وضعیت درست برگردد.

Passenger Mix قیمت را تغییر می‌دهد

صفحه search شاید ظاهراً «سه seat» بفروشد، اما تأمین‌کننده ها مسافر ها را متفاوت قیمت‌گذاری می‌کنند.

مثال:

1 adult + 1 child + 1 infant

همیشه معادل این نیست:

3 adults

رفتار درست:

price by passenger type validate infant and child rules validate documents and nationality rules bind accepted price to passenger mix reprice if passenger data changes

ترکیب مسافر ها بخشی از هویت quote است. اگر کاربر اطلاعاتی مثل child، infant، nationality یا document data را تغییر دهد، quote قبلی ممکن است دیگر معتبر نباشد.

Connecting یا Multi-City Itinerary به شکل Partial شکست می‌خورد

یک پرواز مستقیم one-way ساده‌ترین حالت است. connecting یا multi-city سخت‌تر است، چون availability می‌تواند در سطح segment fail شود.

مثال:

Segment 1: Tehran -> Istanbul available Segment 2: Istanbul -> Berlin no longer available

رفتار درست:

itinerary نیمه‌معتبر ساخته نمی‌شود. segment شکست‌خورده باید به‌صورت واضح مشخص شود.

اگر امکان‌پذیر باشد، سیستم باید alternative combination برگرداند.

تا زمانی که کل itinerary به‌صورت کامل تأیید نشده باشد، charge انجام نمی‌شود.

unit of sale خود itinerary است، نه individual segment، مگر اینکه product صراحتاً partial booking را پشتیبانی کند.

Seat Adjacency قابل تأمین نیست

ممکن است پرواز به اندازه کافی seat داشته باشد، اما seat دقیق یا کنار هم مورد نظر کاربر موجود نباشد.

مثال:

3 seats available but no 3 adjacent seats available

رفتار درست:

موجودی بلیت باید از موجودی seat map جدا در نظر گرفته شود.

قبل از تأیید نهایی، خطای انتخاب صندلی باید به کاربر نمایش داده شود. اگر policy اجازه دهد، کاربر می‌تواند بدون انتخاب صندلی ادامه دهد.

انتخاب صندلی یک مسئله مربوط به ancillary inventory است. نباید core ticketing state را خراب کند.

Flight-Plus-Hotel Package به شکل Partial unavailable می‌شود

وقتی product تبدیل به package می‌شود، consistency problem گسترده‌تر می‌شود.

مثال:

flight hold succeeded hotel room is no longer available payment is still pending

رفتار درست:

هر package component باید به‌صورت جداگانه مدل شود و success و failure rules در سطح package تعریف شوند.

تا زمانی که همه required components تأیید یا hold نشده‌اند، charge انجام نمی‌شود.

اگر bundle کامل نشود، componentهای تأییدشده release می‌شوند.

در صورت رخ دادن paid partial failure، سفارش به manual review منتقل می‌شود.

Package booking یک multi-resource reservation problem است. سیستم به component-level state و bundle-level state نیاز دارد.

Platform بین Local Commit و External Call crash می‌کند

این یک bug کلاسیک distributed system است.

مثال:

booking marked READY_TO_ISSUE process crashes before ticketing message is published

رفتار درست:

transactional outbox، command مربوط به ticketing را در همان DB transaction ذخیره می‌کند.

سپس outbox dispatcher در مرحله بعد آن را publish می‌کند.

ارسال تکراری command امن است، چون فرآیند ticketing به‌صورت idempotent طراحی شده است.

بدون outbox یا مکانیزم مشابه، سیستم ممکن است هنگام crash کار را گم کند.

External Write موفق می‌شود ولی Response گم می‌شود

مثال:

supplier issued ticket network timeout before response reached platform

رفتار درست:

فرآیند issue به‌صورت کورکورانه retry نمی‌شود.

ابتدا supplier order بازیابی می‌شود. سپس با استفاده از supplier reference،

deduplication انجام می‌شود.

در نهایت، ticket تأییدشده ذخیره می‌شود.

10. مدل Consistency: strong consistency سخت است و همیشه هم هدف درست نیست

پلتفرم می‌تواند داخل boundary خودش strong consistency داشته باشد.

این موارد را می‌تواند تضمین کند:

valid state transitions one active payment attempt per booking policy one captured ledger entry per gateway transaction one final ticket record per supplier order no ticketing command before verified payment audited support actions

اما نمی‌تواند این‌ها را در یک ACID transaction بگذارد:

platform DB airline inventory supplier hold payment gateway settlement ticket issuing system email/SMS delivery

پس consistency model باید explicit باشد.

Strong Internal Consistency

برای این‌ها از database transaction، uniqueness constraint، optimistic locking و state-transition guard استفاده می‌شود:

booking attempt version payment ledger entries ticket records refund records operator actions outbox command creation inbox event deduplication

Bounded External Inconsistency

سیستم‌های خارجی ممکن است موقتاً با platform state اختلاف داشته باشند.

این فقط وقتی قابل قبول است که:

state نام داشته باشد کاربر گمراه نشود سیستم بداند چطور reconcile کند operatorها risk را ببینند money exposure محدود باشد

مثال:

PAYMENT_UNKNOWN TICKETING_UNKNOWN HOLD_RELEASE_UNKNOWN REFUND_PENDING SUPPLIER_CONFIRMATION_PENDING

Eventual Consistency

eventual consistency برای این‌ها قابل قبول است:

search result freshness notification delivery analytics dashboards supplier status after timeout payment verification after gateway outage

اما اینکه فقط بگوییم «eventual consistency» کافی نیست.

طراحی باید مشخص کند:

eventual by when? reconciled by what job? visible to whom? what happens if it never resolves?

جدول Tradeoff

Cache search results: برای سریع‌تر شدن search و کاهش فشار روی supplier، قیمت ها را cache کن؛ اما قبل از booking، حتماً reprice انجام بده.

Reprice before payment: درست بودن قیمت را حفظ می‌کند

Hold before payment : باعث کاهش حالت paid-without-seat می‌شود، اما پیچیدگی hold expiry را هم اضافه می‌کند. این حالت فقط زمانی مناسب است که supplier از hold پشتیبانی کند.

Payment before ticketing : باعث می‌شود ticket بدون پرداخت صادر نشود، اما ریسک paid-but-not-ticketed را ایجاد می‌کند. این حالت رایج است، ولی به recovery قوی نیاز دارد.

Async ticketing باعث می‌شود retry و observability بهتر مدیریت شوند، اما معماری را پیچیده‌تر می‌کند. برای production booking توصیه می‌شود.

Ticketing inside callback پیاده‌سازی ساده‌تری دارد، اما callback را کند می‌کند، recovery ضعیف‌تری دارد و ریسک timeout را بالا می‌برد. برای production booking مناسب نیست.

Outbox pattern احتمال lost command را کاهش می‌دهد، اما نیاز به infra اضافه دارد. این pattern برای مرز بین state change و side effect مناسب است.

Inbox pattern برای deduplication callbackها و eventها استفاده می‌شود، اما به storage و replay logic نیاز دارد. این pattern برای gateway و supplier مناسب است.

Manual review ابهام را مدیریت می‌کند، اما هزینه عملیاتی دارد. برای edge caseهای مربوط به money و inventory لازم است.

Automatic refund باعث resolution سریع‌تر می‌شود، اما ممکن است booking قابل بازیابی را refund کند. فقط برای rejection نهایی یا policy واضح مناسب است.

Reconciliation job یک کار maintenance ساده نیست. مکانیزمی است که بعد از partial failure، وضعیت واقعی سیستم را کامل، بررسی و اصلاح می‌کند.

Loopهای مهم:

Payment reconciliation یعنی بررسی و اصلاح اختلاف وضعیت بین platform و payment gateway.

برای مثال، gateway وضعیت پرداخت را paid گزارش می‌کند، اما platform از این پرداخت خبر ندارد.

یا platform وضعیت پرداخت را unknown می‌داند، اما gateway final state را ثبت کرده است.

گاهی هم refund درخواست شده، اما gateway هنوز settlement را کامل نکرده است.

Supplier reconciliation یعنی بررسی و اصلاح اختلاف وضعیت بین platform و supplier.

برای مثال، platform وضعیت ticketing را unknown می‌داند، اما supplier بلیت را issued کرده است.

یا platform وضعیت hold را confirmed می‌داند، اما supplier آن را expired گزارش می‌کند.

گاهی هم supplier order وجود دارد، اما platform هنوز ticket را ذخیره نکرده است.

Booking reconciliation یعنی بررسی bookingهایی که در state ناسالم یا ناقص باقی مانده‌اند.

برای مثال، booking پرداخت‌شده بعد از SLA هنوز ticket ندارد.

یا hold منقضی شده، اما payment همچنان pending است.

یا booking بیش از حد مجاز در یک intermediate state مانده است.

یک dashboard مفید فقط لیست failed rowها نیست.

باید به این سؤال‌ها جواب دهد:

چقدر پول در risk است؟ چند کاربر منتظر ticket هستند؟ کدام supplier بیشترین (ابهام )ambiguity را دارد؟ کدام bookingها نزدیک hold expiry هستند؟ کدام paid booking از ticketing SLA عبور کرده؟ کدام retryها safe هستند؟ کدام caseها human action لازم دارند؟

queueهای عملیاتی مهم:

Paid but not ticketed Payment unknown over N minutes Ticketing unknown over N minutes Hold expires soon Refund pending Supplier degraded High price-change rate High hold-reject rate Duplicate callback spike

dashboard باید next safe action را هم نشان دهد:

wait verify payment retrieve supplier order retry issue refund manual call to supplier contact customer

فرق observability واقعی با support nightmare همین است.

12. امنیت و سوءاستفاده

رزرو پرواز ترکیبی از payment، identity و scarce inventory است. Abuse prevention باید بخشی از architecture باشد.

کنترل‌های مهم:

هرگز به price ارسال‌شده از سمت client اعتماد نکن.

amount پرداخت باید به accepted quote سمت سرور bind شود.

payment callback باید به‌صورت server-to-server verify شود.

external eventها باید deduplicate شوند.

search باید بر اساس user، IP، device و route rate-limit شود.

ساخت hold نیز باید rate-limit شود.

hold abuse و bot traffic باید detect شوند.

unpaid holdها باید سریع expire شوند.

sensitive passenger data باید encrypt شود.

همه support state changeها باید audit شوند.

دسترسی manual refund و ticket override باید محدود شود.

supplier credentials و fare tokenها باید محافظت شوند.

نمونه abuse:

Bot با ساخت تعداد زیادی hold تلاش می‌کند inventory ارزان را block کند.

User مقدار پرداخت را در سمت client دست‌کاری می‌کند.

Attacker، payment callback را replay می‌کند.

Scraper از search API برای جمع‌آوری fare data استفاده می‌کند.

Support account بدون trace، وضعیت booking را تغییر می‌دهد.

Compromised supplier credential باعث expose شدن booking operationها می‌شود.

security و consistency در این بخش از هم جدا نیستند.

13. چند اصل طراحی که از این flow بیرون می‌آید

تا اینجا pattern طراحی مشخص است: سیستم حول happy path سازماندهی نمی‌شود. سیستم حول مرز بین local certainty و external uncertainty طراحی می‌شود.

پلتفرم می‌تواند روی ruleهای خودش strict باشد:

one booking state machine one accepted quote version one captured payment record one final ticket per supplier order audited support actions

اما باید درباره سیستم‌هایی که مالکشان نیست واقع‌بین باشد:

supplier inventory can change payment status can be delayed ticketing can timeout callbacks can duplicate seat maps can disagree with fare availability package components can fail independently

به همین دلیل طراحی از transaction ساده به workflow control صریح‌تر می‌رود:

Use internal transactions for internal invariants. Use a saga for the external workflow. Use repricing before payment. Use holds as leases, not permanent guarantees. Treat itinerary, passenger mix, seats, and bundles as quote constraints. Use idempotency at every retry boundary. Use outbox for commands and inbox for callbacks. Treat timeout as unknown. Reconcile payment and supplier truth continuously. Expose high-risk states to operations.

درس عمیق‌تر boundary awareness است.

database transaction می‌تواند platform state را محافظت کند. اما نمی‌تواند airline، supplier، payment gateway و notification system را مجبور کند با booking table ما commit شوند. وقتی این روشن شود، معماری کمتر درباره «order را کجا ذخیره کنیم؟» است و بیشتر درباره این است: «وقتی واقعیت اطراف مدام تغییر می‌کند، چطور قولی که به کاربر داده‌ایم را safe نگه داریم؟»

14. جمع‌بندی

رزرو پرواز شبیه checkout به نظر می‌رسد، چون UI uncertainty را پنهان می‌کند.

واقعیت backend این است:

search فقط یک snapshot است.

price موقتی است.

inventory بیرونی است.

itinerary shape می‌تواند unit of sale را تغییر دهد.

passenger mix روی pricing و validation اثر می‌گذارد.

seat selection ممکن است مستقل از ticket inventory شکست بخورد.

packageها مسئله cross-domain reservation را اضافه می‌کنند.

holdها lease هستند.

پرداخت asynchronous است.

ticketing ممکن است ambiguous شود.

callbackها ممکن است duplicate شوند.

timeout یعنی وضعیت unknown است.

reconciliation لازم است.

طراحی خوب وانمود نمی‌کند این مشکل‌ها وجود ندارند. آن‌ها را نام‌گذاری می‌کند و معماری را دورشان می‌سازد.

این معماری به این‌ها نیاز دارد:

separate search and booking paths server-side repricing supplier-aware reservation holds explicit itinerary, passenger, seat, and bundle constraints explicit booking state machine payment ledger and verification idempotent external writes transactional outbox callback inbox retrieve-before-retry supplier logic reconciliation jobs manual review queues operational dashboards

درس اصلی:

رزرو پرواز سخت نیست چون UI آن چند مرحله راحت دارد. سخت است چون پلتفرم در حال دادن یک قول به کاربر است، آن هم روی چند سیستمی که کامل در کنترل خودش نیستند.

همین است که رزرو پرواز را به یک مسئله جدی system design تبدیل می‌کند.

system designbackendمصاحبه
۶
۰
افشین یاری نیا
افشین یاری نیا
شاید از این پست‌ها خوشتان بیاید