Let's Go Further عملیات CRUD پیشرفته › کنترل همگام‌سازی خوش‌بینانه
قبلی · فهرست مطالب · بعدی
فصل 8.2.

کنترل همگام‌سازی خوش‌بینانه

ممکن است بعضی از شما متوجه یک مشکل کوچک در تابع updateMovieHandler ما شده باشید — اگر دو کلاینت دقیقاً در همان زمان سعی کنند رکورد فیلم یکسانی را به‌روزرسانی کنند، یک race condition به وجود می‌آید.

برای توضیح این موضوع، فرض کنیم دو کلاینت از API ما استفاده می‌کنند: Alice و Bob. Alice می‌خواهد مقدار مدت زمان فیلم The Breakfast Club را به 97 دقیقه اصلاح کند و Bob می‌خواهد ژانر ‘comedy’ را به همان فیلم اضافه کند.

حالا تصور کنید Alice و Bob دقیقاً در همان زمان این دو درخواست به‌روزرسانی را ارسال می‌کنند. همانطور که در Let's Go توضیح دادیم، سرور Go درخواست‌های HTTP جداگانه‌ای را در goroutine‌های مختلف مدیریت می‌کند، بنابراین وقتی این اتفاق می‌افتد، کد در تابع updateMovieHandler ما به‌طور همزمان در دو goroutine مختلف اجرا می‌شود.

بیایید مرور کنیم که در این سناریو چه اتفاقی می‌تواند بیفتد:

  1. goroutine Alice تابع app.models.Movies.Get() را فراخوانی می‌کند تا یک کپی از رکورد فیلم (با شماره نسخه N) دریافت کند.
  2. goroutine Bob تابع app.models.Movies.Get() را فراخوانی می‌کند تا یک کپی از رکورد فیلم (با نسخه N) دریافت کند.
  3. goroutine Alice مدت زمان را در کپی خود از رکورد فیلم به 97 دقیقه تغییر می‌دهد.
  4. goroutine Bob ژانرها را در کپی خود از رکورد فیلم به‌روزرسانی می‌کند تا ‘comedy’ را شامل شود.
  5. goroutine Alice تابع app.models.Movies.Update() را با کپی خود از رکورد فیلم فراخوانی می‌کند. رکورد فیلم در پایگاه داده نوشته می‌شود و شماره نسخه به N+1 افزایش می‌یابد.
  6. goroutine Bob تابع app.models.Movies.Update() را با کپی خود از رکورد فیلم فراخوانی می‌کند. رکورد فیلم در پایگاه داده نوشته می‌شود و شماره نسخه به N+2 افزایش می‌یابد.

علی‌رغم انجام دو به‌روزرسانی جداگانه، فقط به‌روزرسانی Bob در پایگاه داده بازتاب خواهد شد زیرا دو goroutine برای اعمال تغییر با هم رقابت می‌کنند. به‌روزرسانی Alice به مدت زمان فیلم زمانی که به‌روزرسانی Bob آن را با مقدار مدت زمان قدیمی که در مرحله 2 دریافت شده بازنویسی می‌کند، از بین می‌رود. و این به طور خاموش اتفاق می‌افتد — هیچ چیزی Alice یا Bob را از مشکل آگاه نمی‌کند.

جلوگیری از data race

حالا که می‌دانیم data race وجود دارد و چرا اتفاق می‌افتد، چگونه می‌توانیم از آن جلوگیری کنیم؟

چندین گزینه وجود دارد، اما ساده‌ترین و تمیزترین رویکرد در این مورد استفاده از نوعی optimistic locking بر اساس شماره version در رکورد فیلم ما است.

راه‌حل به این صورت عمل می‌کند:

  1. goroutine‌های Alice و Bob هر دو تابع app.models.Movies.Get() را فراخوانی می‌کنند تا یک کپی از رکورد فیلم دریافت کنند. هر دو کپی دارای شماره نسخه N هستند.
  2. goroutine‌های Alice و Bob تغییرات مربوطه را روی رکورد فیلم اعمال می‌کنند.
  3. goroutine‌های Alice و Bob تابع app.models.Movies.Update() را با کپی‌های خود از رکورد فیلم فراخوانی می‌کنند. اما به‌روزرسانی فقط اگر شماره version در پایگاه داده هنوز N باشد اجرا می‌شود. اگر تغییر کرده باشد، به‌روزرسانی را اجرا نمی‌کنیم و به جای آن پیام خطایی به کلاینت ارسال می‌کنیم.

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

برای عملی کردن این، باید دستور SQL به‌روزرسانی فیلم را به این صورت تغییر دهیم:

UPDATE movies 
SET title = $1, year = $2, runtime = $3, genres = $4, version = version + 1
WHERE id = $5 AND version = $6
RETURNING version

توجه کنید که در بخش WHERE اکنون به دنبال رکوردی با ID مشخص و شماره version مشخص هستیم؟

اگر رکورد مطابقی یافت نشود، این query با خطای sql.ErrNoRows بازمی‌گردد و می‌دانیم که شماره version تغییر کرده (یا رکورد کاملاً حذف شده است). در هر دو مورد، این یک نوع edit conflict است و می‌توانیم از آن به عنوان trigger برای ارسال پاسخ خطای مناسب به کلاینت استفاده کنیم.

پیاده‌سازی optimistic locking

خب، این تئوری کافی بود... بیایید آن را عملی کنیم!

ما با ایجاد یک خطای سفارشی ErrEditConflict شروع می‌کنیم که می‌توانیم در صورت تعارض از مدل‌های پایگاه داده خود برگردانیم. ما این را بعداً در کتاب هنگام کار با رکوردهای کاربر نیز استفاده خواهیم کرد، بنابراین منطقی است که آن را در فایل internal/data/models.go به این صورت تعریف کنیم:

فایل: internal/data/models.go
package data

import (
    "database/sql"
    "errors"
)

var (
    ErrRecordNotFound = errors.New("record not found")
    ErrEditConflict   = errors.New("edit conflict")
)

...

سپس، بیایید متد Update() مدل پایگاه داده خود را به‌روزرسانی کنیم تا query SQL جدید را اجرا کند و وضعیتی را که رکورد مطابق یافت نشده مدیریت کند.

فایل: internal/data/movies.go
package data

...

func (m MovieModel) Update(movie *Movie) error {
    // Add the 'AND version = $6' clause to the SQL query.
    query := `
        UPDATE movies 
        SET title = $1, year = $2, runtime = $3, genres = $4, version = version + 1
        WHERE id = $5 AND version = $6
        RETURNING version`

    args := []any{
        movie.Title,
        movie.Year,
        movie.Runtime,
        pq.Array(movie.Genres),
        movie.ID,
        movie.Version, // Add the expected movie version.
    }

    // Execute the SQL query. If no matching row could be found, we know the movie 
    // version has changed (or the record has been deleted) and we return our custom
    // ErrEditConflict error.
    err := m.DB.QueryRow(query, args...).Scan(&movie.Version)
    if err != nil {
        switch {
        case errors.Is(err, sql.ErrNoRows):
            return ErrEditConflict
        default:
            return err
        }
    }

    return nil
}

...

سپس بیایید به فایل cmd/api/errors.go برویم و یک helper جدید editConflictResponse() ایجاد کنیم. می‌خواهیم این پاسخ 409 Conflict همراه با یک پیام خطای ساده‌فهوم برای توضیح مشکل به کلاینت ارسال کند.

فایل: cmd/api/errors.go
package main

...

func (app *application) editConflictResponse(w http.ResponseWriter, r *http.Request) {
    message := "unable to update the record due to an edit conflict, please try again"
    app.errorResponse(w, r, http.StatusConflict, message)
}

و سپس به عنوان قدم نهایی، باید تابع updateMovieHandler خود را تغییر دهیم تا خطای ErrEditConflict را بررسی کند و در صورت لزوم helper editConflictResponse() را فراخوانی کند. به این صورت:

فایل: cmd/api/movies.go
package main

...

func (app *application) updateMovieHandler(w http.ResponseWriter, r *http.Request) {
    
    ...

    // Intercept any ErrEditConflict error and call the new editConflictResponse()
    // helper.
    err = app.models.Movies.Update(movie)
    if err != nil {
        switch {
        case errors.Is(err, data.ErrEditConflict):
            app.editConflictResponse(w, r)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }

    err = app.writeJSON(w, http.StatusOK, envelope{"movie": movie}, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
}

در این مرحله، تابع updateMovieHandler ما باید اکنون از race condition که در مورد آن صحبت کردیم ایمن باشد. اگر دو goroutine همزمان کد را اجرا کنند، اولین به‌روزرسانی موفق خواهد شد و دومی به این دلیل که شماره version در پایگاه داده دیگر با مقدار مورد انتظار مطابقت ندارد، ناموفق خواهد بود.

بیایید این را با استفاده از دستور xargs برای ارسال تعدادی درخواست همزمان به endpoint خود آزمایش کنیم. با فرض اینکه کامپیوتر شما درخواست‌ها را به اندازه کافی نزدیک به هم اجرا می‌کند، باید متوجه شوید که بعضی درخواست‌ها موفق هستند اما بقیه اکنون با کد وضعیت 409 Conflict ناموفق هستند. به این صورت:

$ xargs -I % -P8 curl -X PATCH -d '{"runtime": "97 mins"}' "localhost:4000/v1/movies/4" < <(printf '%s\n' {1..8})
 {
    "movie": {
        "id": 4,
        "title": "Breakfast Club",
        "year": 1985,
        "runtime": "97 mins",
        "genres": [
            "drama"
        ],
        "version": 4
    }
}
{
    "error": "unable to update the record due to an edit conflict, please try again"
}
{
    "error": "unable to update the record due to an edit conflict, please try again"
}
{
    "error": "unable to update the record due to an edit conflict, please try again"
}
{
    "error": "unable to update the record due to an edit conflict, please try again"
}
{
    "error": "unable to update the record due to an edit conflict, please try again"
}
{
    "error": "unable to update the record due to an edit conflict, please try again"
}
{
    "movie": {
        "id": 4,
        "title": "Breakfast Club",
        "year": 1985,
        "runtime": "97 mins",
        "genres": [
            "drama"
        ],
        "version": 5
    }
}

برای بستن این بحث، race condition که در این فصل نشان دادیم نسبتاً بی‌ضرر است. اما در سایر برنامه‌ها این نوع دقیق race condition می‌تواند عواقب جدی‌تری داشته باشد — مانند به‌روزرسانی سطح موجودی یک محصول در فروشگاه آنلاین یا به‌روزرسانی موجودی یک حساب.

همانطور که به طور خلاصه در Let's Go ذکر کردم، خوب است عادت کنید هر زمان که کد می‌نویسید به race condition‌ها فکر کنید و برنامه‌های خود را به گونه‌ای ساختار دهید که یا آنها را مدیریت کنید یا کاملاً از آنها اجتناب کنید — بدون توجه به اینکه چقدر بی‌ضرر به نظر می‌رسند.


اطلاعات اضافی

قفل رفت و برگشتی

یکی از مزایای الگوی optimistic locking که در اینجا استفاده کردیم این است که می‌توانید آن را گسترش دهید تا کلاینت شماره version مورد انتظار خود را در هدر If-Match یا X-Expected-Version ارسال کند.

در برنامه‌های خاص، این می‌تواند به کلاینت کمک کند تا مطمئن شود درخواست به‌روزرسانی خود را بر اساس اطلاعات قدیمی ارسال نمی‌کند.

به طور بسیار خلاصه، می‌توانید این را با افزودن یک بررسی به تابع updateMovieHandler خود به این صورت پیاده‌سازی کنید:

func (app *application) updateMovieHandler(w http.ResponseWriter, r *http.Request) {
    id, err := app.readIDParam(r)
    if err != nil {
        app.notFoundResponse(w, r)
        return
    }

    movie, err := app.models.Movies.Get(id)
    if err != nil {
        switch {
        case errors.Is(err, data.ErrRecordNotFound):
            app.notFoundResponse(w, r)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }

    // If the request contains a X-Expected-Version header, verify that the movie 
    // version in the database matches the expected version specified in the header.
    if r.Header.Get("X-Expected-Version") != "" {
        if strconv.Itoa(int(movie.Version)) != r.Header.Get("X-Expected-Version") {
            app.editConflictResponse(w, r)
            return
        }
    }

    ...
}

قفل روی فیلدها یا انواع دیگر

استفاده از یک شماره version صحیح افزاینده به عنوان مبنای قفل خوش‌بینانه ایمن و از نظر محاسباتی ارزان است. من این رویکرد را توصیه می‌کنم مگر اینکه دلیل خاصی برای استفاده نکردن از آن داشته باشید.

به عنوان جایگزین، می‌توانید از یک timestamp last_updated به عنوان مبنای قفل استفاده کنید. اما این کمتر ایمن است — این احتمال نظری وجود دارد که دو کلاینت دقیقاً در همان زمان رکوردی را به‌روزرسانی کنند و استفاده از timestamp همچنین خطر مشکلات بیشتر را اگر ساعت سرور شما نادرست باشد یا در طول زمان نادرست شود، ایجاد می‌کند.

اگر برای شما مهم است که شناسه version قابل حدس نباشد، گزینه خوبی استفاده از یک رشته تصادفی با آنتروپی بالا مانند UUID در فیلد version است. PostgreSQL دارای نوع UUID و افزونه uuid-ossp است که می‌توانید برای این منظور به این صورت استفاده کنید:

UPDATE movies 
SET title = $1, year = $2, runtime = $3, genres = $4, version = uuid_generate_v4()
WHERE id = $5 AND version = $6
RETURNING version