مدیریت تایماوت پرسوجوهای SQL
تا اینجا در کدمان از متدهای Exec() و QueryRow() زبان Go برای اجرای پرسوجوهای SQL استفاده کردهایم. اما Go نسخههای آگاه از context این دو متد را نیز ارائه میدهد: ExecContext() و QueryRowContext(). این نسخهها یک نمونه context.Context را به عنوان پارامتر اول دریافت میکنند که میتوانید از آن برای خاتمه دادن به پرسوهای در حال اجرای دیتابیس استفاده کنید.
این قابلیت زمانی مفید است که یک پرسوجوی SQL بیش از حد انتظار طول بکشد. وقتی این اتفاق میافتد، نشاندهنده یک مشکل است — چه در آن پرسوجوی خاص و چه در دیتابیس یا برنامه شما به طور کلی — و احتمالاً میخواهید پرسوجو را لغو کنید (برای آزادسازی منابع)، یک خطا برای بررسی بیشتر ثبت کنید، و یک پاسخ 500 Internal Server Error به کلاینت برگردانید.
در این فصل، برنامه خود را به گونهای بهروزرسانی خواهیم کرد که دقیقاً همین کار را انجام دهد.
شبیهسازی یک پرسوجوی طولانی در حال اجرا
برای کمک به نشان دادن نحوه عملکرد این مکانیزم، بیایید با تغییر متد Get() مدل دیتابیس خود شروع کنیم تا یک پرسوجوی طولانی در حال اجرا را شبیهسازی کند. به طور خاص، پرسوجوی SQL خود را به گونهای بهروزرسانی خواهیم کرد که مقدار pg_sleep(8) را برگرداند، که باعث میشود PostgreSQL به مدت 8 ثانیه قبل از بازگرداندن نتیجه خود در حالت خواب باشد.
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 ثانیه تکمیل نشود به طور خودکار لغو شود.
برای این کار باید:
- از تابع
context.WithTimeout()برای ایجاد یک نمونهcontext.Contextبا مهلت تایماوت 3 ثانیهای استفاده کنیم. - پرسوجوی SQL را با استفاده از متد
QueryRowContext()اجرا کنیم و نمونهcontext.Contextرا به عنوان پارامتر ارسال کنیم.
من این کار را نشان خواهم داد:
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 } ...
چند نکته در کد بالا وجود دارد که میخواهم بر آنها تأکید کنم و توضیح دهم:
خط
defer cancel()ضروری است زیرا تضمین میکند که منابع مرتبط با context ما همیشه قبل از بازگشت متدGet()آزاد شوند و از نشت حافظه جلوگیری شود. بدون آن، منابع تا زمانی که تایماوت 3 ثانیهای فرا برسد یا context والد (که در این مثال خاصcontext.Background()است) لغو شود، آزاد نخواهند شد.شمارش معکوس تایماوت از لحظهای آغاز میشود که context با
context.WithTimeout()ایجاد میشود. هر زمان صرفشده برای اجرای کد بین ایجاد context و فراخوانیQueryRowContext()به عنوان تایماوت محاسبه خواهد شد.
خوب، بیایید این را امتحان کنیم.
اگر برنامه را مجدداً راهاندازی کنید و درخواست دیگری به اندپوینت 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() خود نیز حذف خواهیم کرد.
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 با تایماوت ایجاد کنیم — و سپس آن را به مدل دیتابیس خود منتقل کنیم.
اما — و اما بزرگی است — انجام این کار پیچیدگی رفتاری زیادی معرفی میکند و برای بیشتر برنامهها مزایای آن به اندازه کافی بزرگ نیستند که این مبادله ارزشمند باشد.
جزئیات پشت این موضوع بسیار جالب هستند، اما همچنین نسبتاً پیچیده و دشوارند. به همین دلیل من آن را بیشتر در این پیوست بحث کردهام.