دیستریبیوت ترنس‌اکشن‌ها (Distributed Transactions)

دیتابیس به ازای هر سرویس (Database per Service)

در معماری میکروسرویس اگر برای هر سرویس یک Storage جداگانه داشته باشیم و از یک دیتابیس مرکزی استفاده نکنیم، از الگوی “دیتابیس‌-پر-سرویس” پیروی کرده‌ایم.

  • ویژگی‌ها: ایزولیشن، انتخاب دیتابیس متناسب با نیاز سرویس، و قابلیت اسکیل‌پذیری مستقل.
  • نکته: در این معماری باید مکانیزم‌هایی برای هماهنگ‌سازی تراکنش‌های بین سرویس‌ها در نظر گرفته شود.

الگوهای هماهنگی تراکنش‌ها

Two-Phase Commit (2PC)

در این روش پارت‌ها باید مطمئن شوند که تراکنش با موفقیت انجام می‌شود و سپس کامیت کنند. این الگو شامل دو نقش اصلی است:

  • Coordinator

سرویس مدیر که مدیریت تراکنش را برعهده دارد.

  • Participants

نودهایی که داده را نگهداری یا پردازش می‌کنند.

الگوریتم کار به این صورت است که در فاز اول Coordinator از همهٔ Participants می‌پرسد که آیا می‌توانند کامیت انجام دهند؛ اگر همه پاسخ مثبت بدهند، در فاز دوم به همه دستور کامیت ارسال می‌کند، در غیر این صورت دستور رول‌بک صادر می‌شود.

  • مزایا: تضمین سازگاری ACID در صورت پشتیبانی کامل از تراکنش‌ها.
  • معایب: پیچیدگی، بن‌بست (blocking) در زمان قطعی، و ناسازگاری با برخی دیتابیس‌های NoSQL که قابلیت prepare/commit ندارند.

Saga

در این الگو تراکنش بلندِ توزیع‌شده به مجموعه‌ای از تراکنش‌های محلی تقسیم می‌شود که هر کدام در سرویس مربوطه به‌صورت محلی کامیت می‌شوند.

  • نکته: ساگا برای حفظ ACID به‌صورت سنتی مناسب نیست اما الگوی مناسبی برای مقیاس‌پذیری و تحمل خطا است.
  • مسئله: دیباگ و تست ساگا دشوار است و نیاز به طراحی برای idempotency و تشخیص توالی تراکنش‌ها دارد.

روش‌های پیاده‌سازی Saga

  • Orchestration (متمرکز): یک سرویس ارکستریتور مسئول ترتیب اجرای مراحل است و در صورت خطا مراحل جبران‌کننده (compensating actions) را اجرا می‌کند.

    • مزایا: مناسب برای workflowهای پیچیده؛ مشارکت‌کننده‌ها نیازی به شناخت یکدیگر ندارند.
    • معایب: ارکستریتور تایت‌کاپل با همه تغییرات می‌شود و تک‌نقطهٔ شکست بالقوه است.
  • Choreography (رقص / توزیعی): هر سرویس با فرستادن و شنیدن ایونت‌ها تصمیم به ادامه یا جبران می‌گیرد؛ هیچ ارکستریتور مرکزی وجود ندارد.

    • مزایا: سبک و مناسب برای سرویس‌های کوچک؛ وابستگی مستقیم به سرویس مرکزی حذف می‌شود.
    • معایب: پیچیدگی نگهداری، دشواری در تست انتها‌به‌انتها و افزایش پیچیدگی با مقیاس سیستم.

مقایسهٔ Saga و 2PC

معیارSaga2PC
زمان کامیتهر جزء به‌صورت محلی کامیت می‌کندکل کامیت پس از توافق همه انجام می‌شود
پیچیدگینیاز به طراحی برای idempotency و compensationsنیاز به پشتیبانی prepare/commit در participants
تحمل خطابا compensating actions مدیریت می‌شودممکن است به blocking و deadlock منجر شود

Event Sourcing

در این روش تغییرات به‌صورت سری از ایونت‌ها ذخیره می‌شوند و به‌جای آپدیت/دیلیت مستقیم، ایونت‌ها append می‌شوند. ترتیب ایونت‌ها اهمیت بسیار زیادی دارد.

  • نکته: Event Sourcing معمولاً همراه با CQRS استفاده می‌شود تا خواندن و نوشتن از هم جدا شوند.

Distributed Transactions در دیتابیس‌های مدرن

در برخی دیتابیس‌های جدید امکاناتی برای پشتیبانی از تراکنش توزیع‌شده یا شبه-تراکنش‌های توزیع‌شده فراهم شده است؛ این رویکرد پیچیدگی هماهنگی را از لایهٔ معماری به خود دیتابیس منتقل می‌کند.

  • نکته: استفاده از چنین قابلیت‌هایی ممکن است ساده‌سازی توسعه را بیاورد ولی وابستگی به ویژگی‌های خاص دیتابیس را افزایش می‌دهد.

مزایای Database per Service — جمع‌بندی

  • ایزولیشن داده و مالکیت سرویس روی دیتای خودش.
  • انتخاب تکنولوژی دیتابیس مناسب برای نیاز هر سرویس.
  • مقیاس‌پذیری مستقل هر سرویس.

مثال: پیاده‌سازی Saga (Orchestration) — شبیه‌سازی با Go

در این مثال یک Orchestrator ساده داریم که مراحل ثبت سفارش، رزرو موجودی و پردازش پرداخت را پشت سر هم اجرا می‌کند و در صورت خطا مراحل جبران‌کننده را فراخوانی می‌کند.

package main
 
import (
    "bytes"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "time"
)
 
type OrderRequest struct {
    OrderID    int     `json:"order_id"`
    UserID     int     `json:"user_id"`
    ProductID  int     `json:"product_id"`
    Quantity   int     `json:"quantity"`
    TotalPrice float64 `json:"total_price"`
}
 
type InventoryRequest struct {
    ProductID int `json:"product_id"`
    Quantity  int `json:"quantity"`
}
 
type PaymentRequest struct {
    OrderID int     `json:"order_id"`
    Amount  float64 `json:"amount"`
}
 
func main() {
    order := OrderRequest{OrderID: 1, UserID: 1, ProductID: 1, Quantity: 2, TotalPrice: 200.00}
    if err := createOrderSaga(order); err != nil {
        fmt.Printf("Saga failed: %v\n", err)
    } else {
        fmt.Println("Saga completed successfully")
    }
}
 
func createOrderSaga(order OrderRequest) error {
    if err := createOrder(order); err != nil {
        return err
    }
 
    if err := reserveInventory(order.ProductID, order.Quantity); err != nil {
        if derr := compensateOrder(order.OrderID); derr != nil {
            return fmt.Errorf("reserve failed: %v, compensate failed: %v", err, derr)
        }
        return err
    }
 
    if err := createPayment(order.OrderID, order.TotalPrice); err != nil {
        if derr := compensateInventory(order.ProductID, order.Quantity); derr != nil {
            _ = compensateOrder(order.OrderID)
            return fmt.Errorf("payment failed: %v, compensate inventory failed: %v", err, derr)
        }
        if derr := compensateOrder(order.OrderID); derr != nil {
            return fmt.Errorf("payment failed: %v, compensate order failed: %v", err, derr)
        }
        return err
    }
 
    return nil
}
 
func createOrder(order OrderRequest) error {
    return sendRequest("POST", "http://order-service/orders", order)
}
 
func reserveInventory(productID, quantity int) error {
    req := InventoryRequest{ProductID: productID, Quantity: quantity}
    return sendRequest("POST", "http://inventory-service/inventory", req)
}
 
func createPayment(orderID int, amount float64) error {
    req := PaymentRequest{OrderID: orderID, Amount: amount}
    return sendRequest("POST", "http://payment-service/payments", req)
}
 
func compensateOrder(orderID int) error {
    url := fmt.Sprintf("http://order-service/orders/%d", orderID)
    return sendRequest("DELETE", url, nil)
}
 
func compensateInventory(productID, quantity int) error {
    req := InventoryRequest{ProductID: productID, Quantity: quantity}
    return sendRequest("POST", "http://inventory-service/inventory/compensate", req)
}
 
func sendRequest(method, url string, body interface{}) error {
    var bodyReader io.Reader
    if body != nil {
        b, err := json.Marshal(body)
        if err != nil {
            return err
        }
        bodyReader = bytes.NewReader(b)
    }
 
    req, err := http.NewRequest(method, url, bodyReader)
    if err != nil {
        return err
    }
    req.Header.Set("Content-Type", "application/json")
 
    client := &http.Client{Timeout: 5 * time.Second}
    resp, err := client.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()
 
    if resp.StatusCode >= 200 && resp.StatusCode < 300 {
        return nil
    }
 
    // خواندن بادی جهت لاگ یا خطای معنی‌دار
    respBody, _ := io.ReadAll(resp.Body)
    return errors.New(fmt.Sprintf("request to %s failed: status=%d body=%s", url, resp.StatusCode, string(respBody)))
}
  • نکته: هر میکروسرویس باید endpointهای مناسب برای عملیات جبران‌کننده (compensate) فراهم کند.

منطق شناسهٔ درخواست (Request ID) در بیزینس

در سیستم‌های پراکند، استفاده از یک شناسهٔ یکتا برای هر درخواست به دلایل زیر مهم است:

  • جلوگیری از پردازش دوبارهٔ درخواست‌ها (idempotency).
  • امکان ردیابی جریان درخواست در سراسر سرویس‌ها (tracing).
  • تسهیل پاسخ‌دهی آسنکرون: سرور می‌تواند یک کد رهگیری به کلاینت بدهد تا وضعیت را پیگیری کند.

نکته: در برخی سرویس‌ها (مثل سرویس‌های بانکی) ممکن است از کاربر خواسته شود که request_id خودش را تولید کند تا از بار ذخیره‌سازی سمت سرور کاسته شود، اما در بسیاری موارد Gateway یا کلاینت مسئول تولید شناسه است.


اگر بخواهید می‌توانم همین سند را به‌صورت یک فایل Markdown یا PDF آمادهٔ دانلود کنم یا نسخهٔ بدون تیتر (یکپارچه) براتون بسازم.