Let's Go Further عملیات CRUD پیشرفته › مدیریت تایم‌اوت پرس‌وجوهای SQL
قبلی · فهرست مطالب · بعدی
فصل 8.3.

مدیریت تایم‌اوت پرس‌وجوهای SQL

تا اینجا در کدمان از متدهای Exec() و QueryRow() زبان Go برای اجرای پرس‌وجوهای SQL استفاده کرده‌ایم. اما Go نسخه‌های آگاه از context این دو متد را نیز ارائه می‌دهد: ExecContext() و QueryRowContext(). این نسخه‌ها یک نمونه context.Context را به عنوان پارامتر اول دریافت می‌کنند که می‌توانید از آن برای خاتمه دادن به پرس‌وهای در حال اجرای دیتابیس استفاده کنید.

این قابلیت زمانی مفید است که یک پرس‌وجوی SQL بیش از حد انتظار طول بکشد. وقتی این اتفاق می‌افتد، نشان‌دهنده یک مشکل است — چه در آن پرس‌وجوی خاص و چه در دیتابیس یا برنامه شما به طور کلی — و احتمالاً می‌خواهید پرس‌وجو را لغو کنید (برای آزادسازی منابع)، یک خطا برای بررسی بیشتر ثبت کنید، و یک پاسخ 500 Internal Server Error به کلاینت برگردانید.

در این فصل، برنامه خود را به گونه‌ای به‌روزرسانی خواهیم کرد که دقیقاً همین کار را انجام دهد.

شبیه‌سازی یک پرس‌وجوی طولانی در حال اجرا

برای کمک به نشان دادن نحوه عملکرد این مکانیزم، بیایید با تغییر متد Get() مدل دیتابیس خود شروع کنیم تا یک پرس‌وجوی طولانی در حال اجرا را شبیه‌سازی کند. به طور خاص، پرس‌وجوی SQL خود را به گونه‌ای به‌روزرسانی خواهیم کرد که مقدار pg_sleep(8) را برگرداند، که باعث می‌شود PostgreSQL به مدت 8 ثانیه قبل از بازگرداندن نتیجه خود در حالت خواب باشد.

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

...

func (m MovieModel) Get(id int64) (*Movie, error) {
    if id < 1 {
        return nil, ErrRecordNotFound
    }

    // Update the query to return pg_sleep(8) as the first value.
    query := `
        SELECT pg_sleep(8), id, created_at, title, year, runtime, genres, version
        FROM movies
        WHERE id = $1`

    var movie Movie

    // Importantly, update the Scan() arguments so that the pg_sleep(8) return value 
    // is scanned into a []byte slice.
    err := m.DB.QueryRow(query, id).Scan(
        &[]byte{}, // Add this line.
        &movie.ID,
        &movie.CreatedAt,
        &movie.Title,
        &movie.Year,
        &movie.Runtime,
        pq.Array(&movie.Genres),
        &movie.Version,
    )

    if err != nil {
        switch {
        case errors.Is(err, sql.ErrNoRows):
            return nil, ErrRecordNotFound
        default:
            return nil, err
        }
    }

    return &movie, nil
}

...

اگر برنامه را مجدداً راه‌اندازی کنید و درخواستی به اندپوینت GET /v1/movies/:id بفرستید، باید ببینید که درخواست به مدت 8 ثانیه متوقف می‌شود تا سرانجام یک پاسخ موفق با اطلاعات فیلم دریافت کنید. چیزی شبیه به این:

$ curl -w '\nTime: %{time_total}s \n' localhost:4000/v1/movies/1
{
    "movie": {
        "id": 1,
        "title": "Moana",
        "year": 2015,
        "runtime": "107 mins",
        "genres": [
            "animation",
            "adventure"
        ],
        "version": 1
    }
}

Time: 8.013534s

افزودن یک تایم‌اوت به پرس‌وجو

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

برای این کار باید:

  1. از تابع context.WithTimeout() برای ایجاد یک نمونه context.Context با مهلت تایم‌اوت 3 ثانیه‌ای استفاده کنیم.
  2. پرس‌وجوی SQL را با استفاده از متد QueryRowContext() اجرا کنیم و نمونه context.Context را به عنوان پارامتر ارسال کنیم.

من این کار را نشان خواهم داد:

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

import (
    "context" // New import
    "database/sql"
    "errors"
    "time"

    "greenlight.alexedwards.net/internal/validator"

    "github.com/lib/pq"
)

...

func (m MovieModel) Get(id int64) (*Movie, error) {
    if id < 1 {
        return nil, ErrRecordNotFound
    }

    query := `
        SELECT pg_sleep(8), id, created_at, title, year, runtime, genres, version
        FROM movies
        WHERE id = $1`

    var movie Movie

    // Use the context.WithTimeout() function to create a context.Context which carries a
    // 3-second timeout deadline. Note that we're using the empty context.Background() 
    // as the 'parent' context.
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)

    // Importantly, use defer to make sure that we cancel the context before the Get()
    // method returns.
    defer cancel()

    // Use the QueryRowContext() method to execute the query, passing in the context 
    // (including the deadline) as the first argument. 
    err := m.DB.QueryRowContext(ctx, query, id).Scan(
        &[]byte{},
        &movie.ID,
        &movie.CreatedAt,
        &movie.Title,
        &movie.Year,
        &movie.Runtime,
        pq.Array(&movie.Genres),
        &movie.Version,
    )

    if err != nil {
        switch {
        case errors.Is(err, sql.ErrNoRows):
            return nil, ErrRecordNotFound
        default:
            return nil, err
        }
    }

    return &movie, nil
}

...

چند نکته در کد بالا وجود دارد که می‌خواهم بر آن‌ها تأکید کنم و توضیح دهم:

خوب، بیایید این را امتحان کنیم.

اگر برنامه را مجدداً راه‌اندازی کنید و درخواست دیگری به اندپوینت GET /v1/movies/:id بفرستید، باید بعد از یک تأخیر 3 ثانیه‌ای پاسخ خطایی شبیه به این دریافت کنید:

$ curl -w '\nTime: %{time_total}s \n' localhost:4000/v1/movies/1
{
    "error": "the server encountered a problem and could not process your request"
}

Time: 3.025179s

اگر به پنجره ترمینالی که برنامه را اجرا می‌کند بازگردید، باید یک خط گزارش با پیام خطای "pq: canceling statement due to user request" نیز ببینید. چیزی شبیه به این:

$ go run ./cmd/api
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
time=2023-09-10T10:59:16.722+02:00 level=ERROR msg="pq: canceling statement due to user request"

در ابتدا عبارت این پیام خطا ممکن است عجیب به نظر برسد… تا زمانی که بیاموزید پیام “canceling statement due to user request” از PostgreSQL می‌آید. از این منطق معقول است: برنامه ما کاربر PostgreSQL است و ما عمداً پرس‌وجو را بعد از 3 ثانیه لغو می‌کنیم.

پس این واقعاً خیلی خوب است و اوضاع دقیقاً طوری که انتظار داریم پیش می‌رود.

بعد از 3 ثانیه، تایم‌اوت context فرا می‌رسد و درایور دیتابیس pq ما یک سیگنال لغو به PostgreSQL ارسال می‌کند. PostgreSQL سپس پرس‌وجوی در حال اجرا را خاتمه می‌دهد، منابع مربوطه آزاد می‌شوند و پیام خطایی که در بالا می‌بینیم را برمی‌گرداند. سپس یک پاسخ 500 Internal Server Error به کلاینت ارسال می‌شود و خطا ثبت می‌گردد تا بدانیم مشکلی پیش آمده است.

تایم‌اوت‌های خارج از PostgreSQL

نکته مهم دیگری که باید به آن اشاره کنم این است: ممکن است مهلت تایم‌اوت حتی قبل از شروع پرس‌وجوی PostgreSQL فرا برسد.

شاید به یاد بیاورید که قبلاً در کتاب pool اتصال sql.DB خود را به گونه‌ای پیکربندی کردیم که حداکثر 25 اتصال باز مجاز باشد. اگر همه این اتصالات در حال استفاده باشند، هر پرس‌وجوی اضافی توسط sql.DB ‘در صف قرار می‌گیرد’ تا یک اتصال در دسترس شود. در این سناریو — یا هر سناریو دیگری که باعث تأخیر شود — ممکن است مهلت تایم‌اوت حتی قبل از در دسترس شدن یک اتصال دیتابیس آزاد فرا برسد. اگر این اتفاق بیفتد، QueryRowContext() خطای context.DeadlineExceeded را برمی‌گرداند.

در واقع، ما می‌توانیم این را در برنامه خود با تنظیم حداکثر اتصالات باز به 1 و ارسال دو درخواست همزمان به اندپوینت خود نشان دهیم. بیایید API را با پرچم -db-max-open-conns=1 مجدداً راه‌اندازی کنیم، چیزی شبیه به این:

$ go run ./cmd/api -db-max-open-conns=1
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development

سپس در پنجره ترمینال دیگر دو درخواست همزمان به اندپوینت GET /v1/movies/:id بفرستید. در لحظه‌ای که تایم‌اوت 3 ثانیه‌ای فرا می‌رسد، باید یک پرس‌وجوی SQL در حال اجرا و دیگری همچنان ‘در صف’ در pool اتصال sql.DB داشته باشیم. باید دو پاسخ خطا دریافت کنید که شبیه به این باشند:

$ curl localhost:4000/v1/movies/1 & curl localhost:4000/v1/movies/1 &
[1] 33221
[2] 33222
$ {
    "error": "the server encountered a problem and could not process your request"
}
{
    "error": "the server encountered a problem and could not process your request"
}

وقتی اکنون به ترمینال اصلی خود بازگردید، باید دو پیام خطای متفاوت ببینید:

$ go run ./cmd/api -db-max-open-conns=1
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="database connection pool established"
time=2023-09-10T10:59:13.722+02:00 level=INFO msg="starting server" addr=:4000 env=development
time=2023-09-10T10:59:27.722+02:00 level=ERROR msg="context deadline exceeded"
time=2023-09-10T10:59:27.722+02:00 level=ERROR msg="pq: canceling statement due to user request" addr=:4000 env=development

در اینجا پیام خطای pq: canceling statement due to user request مربوط به خاتمه یافتن پرس‌وجوی SQL در حال اجرا است، در حالی که پیام context deadline exceeded مربوط به لغو شدن پرس‌وجوی SQL در صف حتی قبل از در دسترس شدن یک اتصال دیتابیس آزاد است.

به همین ترتیب، امکان فرا رسیدن مهلت تایم‌اوت بعداً هنگام پردازش داده‌های بازگشتی از پرس‌وجو با Scan() نیز وجود دارد. اگر این اتفاق بیفتد، Scan() نیز خطای context.DeadlineExceeded را برمی‌گرداند.

به‌روزرسانی مدل دیتابیس ما

بیایید به سرعت مدل دیتابیس خود را برای استفاده از مهلت تایم‌اوت 3 ثانیه‌ای برای همه عملیات‌هایمان به‌روزرسانی کنیم. در همین حین، بخش pg_sleep(8) را از متد Get() خود نیز حذف خواهیم کرد.

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

...

func (m MovieModel) Insert(movie *Movie) error {
    query := `
        INSERT INTO movies (title, year, runtime, genres) 
        VALUES ($1, $2, $3, $4)
        RETURNING id, created_at, version`

    args := []any{movie.Title, movie.Year, movie.Runtime, pq.Array(movie.Genres)}

    // Create a context with a 3-second timeout.
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    // Use QueryRowContext() and pass the context as the first argument.
    return m.DB.QueryRowContext(ctx, query, args...).Scan(&movie.ID, &movie.CreatedAt, &movie.Version)
}

func (m MovieModel) Get(id int64) (*Movie, error) {
    if id < 1 {
        return nil, ErrRecordNotFound
    }

    // Remove the pg_sleep(8) clause.
    query := `
        SELECT id, created_at, title, year, runtime, genres, version
        FROM movies
        WHERE id = $1`

    var movie Movie

    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    // Remove &[]byte{} from the first Scan() destination.
    err := m.DB.QueryRowContext(ctx, query, id).Scan(
        &movie.ID,
        &movie.CreatedAt,
        &movie.Title,
        &movie.Year,
        &movie.Runtime,
        pq.Array(&movie.Genres),
        &movie.Version,
    )

    if err != nil {
        switch {
        case errors.Is(err, sql.ErrNoRows):
            return nil, ErrRecordNotFound
        default:
            return nil, err
        }
    }

    return &movie, nil
}

func (m MovieModel) Update(movie *Movie) error {
    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,
    }

    // Create a context with a 3-second timeout.
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    // Use QueryRowContext() and pass the context as the first argument.
    err := m.DB.QueryRowContext(ctx, query, args...).Scan(&movie.Version)
    if err != nil {
        switch {
        case errors.Is(err, sql.ErrNoRows):
            return ErrEditConflict
        default:
            return err
        }
    }

    return nil
}

func (m MovieModel) Delete(id int64) error {
    if id < 1 {
        return ErrRecordNotFound
    }

    query := `
        DELETE FROM movies
        WHERE id = $1`

    // Create a context with a 3-second timeout.
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    // Use ExecContext() and pass the context as the first argument.
    result, err := m.DB.ExecContext(ctx, query, id)
    if err != nil {
        return err
    }

    rowsAffected, err := result.RowsAffected()
    if err != nil {
        return err
    }

    if rowsAffected == 0 {
        return ErrRecordNotFound
    }

    return nil
}

اطلاعات تکمیلی

استفاده از context درخواست

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

اما — و اما بزرگی است — انجام این کار پیچیدگی رفتاری زیادی معرفی می‌کند و برای بیشتر برنامه‌ها مزایای آن به اندازه کافی بزرگ نیستند که این مبادله ارزشمند باشد.

جزئیات پشت این موضوع بسیار جالب هستند، اما همچنین نسبتاً پیچیده و دشوارند. به همین دلیل من آن را بیشتر در این پیوست بحث کرده‌ام.