Let's Go Further راه‌اندازی و پیکربندی database › پیکربندی database connection pool
قبلی · فهرست مطالب · بعدی
فصل ۵.۳.

پیکربندی database connection pool

در کتاب اول Let’s Go درباره connection pool مربوط به sql.DB در سطح کلی صحبت کردیم و اصل‌های اصلی استفاده از آن را نشان دادیم. اما در این فصل عمیق‌تر می‌شویم؛ توضیح می‌دهیم connection pool پشت صحنه چطور کار می‌کند و settingهایی را بررسی می‌کنیم که می‌توانیم برای تغییر و بهینه‌سازی رفتار آن استفاده کنیم.

پس connection pool مربوط به sql.DB واقعا چطور کار می‌کند؟

مهم‌ترین چیزی که باید بفهمید این است که یک pool از نوع sql.DB شامل دو نوع connection است: connectionهای «in-use» و connectionهای «idle». وقتی از یک connection برای انجام یک database task استفاده می‌کنید، مثل اجرای یک SQL statement یا query گرفتن از rowها، آن connection به عنوان in-use علامت‌گذاری می‌شود؛ و وقتی task کامل شد، آن connection به عنوان idle علامت‌گذاری می‌شود.

وقتی به Go دستور می‌دهید یک database task انجام دهد، ابتدا بررسی می‌کند آیا connection idleای در pool موجود است یا نه. اگر موجود باشد، Go همان connection موجود را reuse می‌کند و برای مدت انجام task آن را به عنوان in-use علامت‌گذاری می‌کند. اگر زمانی که به connection نیاز دارید هیچ connection idleای در pool وجود نداشته باشد، Go یک connection جدید اضافه ایجاد می‌کند.

وقتی Go یک connection idle را از pool reuse می‌کند، هر مشکلی در connection به شکل graceful مدیریت می‌شود. connectionهای بد قبل از تسلیم شدن، به صورت خودکار دو بار retry می‌شوند؛ در آن نقطه Go connection بد را از pool حذف می‌کند و برای انجام task یک connection جدید می‌سازد.

پیکربندی pool

connection pool چهار method دارد که می‌توانیم برای پیکربندی رفتار آن استفاده کنیم. بیایید آن‌ها را یکی‌یکی بررسی کنیم.

متد SetMaxOpenConns

متد SetMaxOpenConns() اجازه می‌دهد یک حد بالای MaxOpenConns برای تعداد connectionهای «open» در pool تنظیم کنید؛ یعنی connectionهای in-use به علاوه idle. به صورت پیش‌فرض، تعداد connectionهای open نامحدود است.

به طور کلی، هرچه limit مربوط به MaxOpenConns را بالاتر بگذارید، queryهای database بیشتری می‌توانند به صورت concurrent اجرا شوند و ریسک اینکه خود connection pool به bottleneck در application شما تبدیل شود کمتر می‌شود.

اما نامحدود گذاشتن آن لزوما بهترین کار نیست. PostgreSQL به صورت پیش‌فرض یک hard limit برابر با ۱۰۰ connection open دارد، و اگر در load سنگین به این hard limit برسیم، باعث می‌شود driver مربوط به pq خطای "sorry, too many clients already" برگرداند.

برای جلوگیری از این error، منطقی است تعداد connectionهای open در pool را با فاصله‌ای مناسب پایین‌تر از ۱۰۰ محدود کنیم، تا برای applicationها یا sessionهای دیگری که نیاز دارند از PostgreSQL استفاده کنند هم headroom کافی باقی بماند.

مزیت دیگر تنظیم limit برای MaxOpenConns این است که مثل یک throttle بسیار ساده عمل می‌کند و جلوی این را می‌گیرد که database هم‌زمان با تعداد زیادی task غرق شود.

اما تنظیم limit یک caveat مهم دارد. اگر به limit مربوط به MaxOpenConns برسیم و همه connectionها in-use باشند، هر database task بعدی مجبور می‌شود صبر کند تا یک connection آزاد و به عنوان idle علامت‌گذاری شود. در context API ما، HTTP request کاربر ممکن است هنگام انتظار برای connection آزاد، نامحدود «hang» کند. پس برای کاهش این ریسک، مهم است همیشه روی database taskها با استفاده از type مربوط به context.Context timeout تنظیم کنیم. بعدا در کتاب توضیح می‌دهیم چطور این کار را انجام دهید.

متد SetMaxIdleConns

متد SetMaxIdleConns() یک حد بالای MaxIdleConns برای تعداد connectionهای idle در pool تنظیم می‌کند. به صورت پیش‌فرض، حداکثر تعداد connectionهای idle برابر با ۲ است.

در تئوری، اجازه دادن به تعداد بیشتری connection idle در pool باعث بهبود performance می‌شود، چون احتمال اینکه لازم باشد یک connection جدید از صفر ایجاد شود کمتر می‌شود و بنابراین به صرفه‌جویی در resourceها کمک می‌کند.

اما مهم است بدانید زنده نگه داشتن یک connection idle هزینه دارد. memory مصرف می‌کند که در غیر این صورت می‌توانست برای application و database شما استفاده شود، و همچنین ممکن است اگر یک connection برای مدت طولانی idle بماند، unusable شود. برای مثال، MySQL به صورت پیش‌فرض هر connectionای را که ۸ ساعت استفاده نشده باشد به صورت خودکار close می‌کند.

پس ممکن است تنظیم بیش از حد بالای MaxIdleConns باعث شود connectionهای بیشتری unusable شوند و resourceهای بیشتری نسبت به یک idle connection pool کوچک‌تر مصرف شود؛ poolای با connectionهای کمتر که بیشتر استفاده می‌شوند. به عنوان guideline: فقط وقتی می‌خواهید یک connection را idle نگه دارید که احتمالا به‌زودی دوباره از آن استفاده می‌کنید.

نکته دیگری که باید اشاره کنیم این است که limit مربوط به MaxIdleConns همیشه باید کمتر یا مساوی MaxOpenConns باشد. Go این را enforce می‌کند و در صورت نیاز limit مربوط به MaxIdleConns را به صورت خودکار کاهش می‌دهد.

متد SetConnMaxLifetime

متد SetConnMaxLifetime() limit مربوط به ConnMaxLifetime را تنظیم می‌کند؛ یعنی حداکثر مدت زمانی که یک connection می‌تواند reuse شود. به صورت پیش‌فرض، maximum lifetime وجود ندارد و connectionها برای همیشه reuse می‌شوند.

اگر مثلا ConnMaxLifetime را روی یک ساعت تنظیم کنیم، یعنی همه connectionها یک ساعت بعد از اولین ایجادشان به عنوان «expired» علامت‌گذاری می‌شوند و بعد از expired شدن دیگر نمی‌توانند reuse شوند. اما توجه کنید:

در تئوری، نامحدود گذاشتن ConnMaxLifetime یا تنظیم lifetime طولانی به performance کمک می‌کند، چون احتمال اینکه لازم باشد connectionهای جدید از صفر ساخته شوند کمتر می‌شود. اما در بعضی وضعیت‌ها enforce کردن lifetime کوتاه‌تر می‌تواند مفید باشد. برای مثال:

اگر تصمیم گرفتید روی pool خود ConnMaxLifetime تنظیم کنید، مهم است frequencyای را که connectionها در آن expired و بعد دوباره ساخته می‌شوند در نظر داشته باشید. برای مثال، اگر ۱۰۰ connection open در pool داشته باشید و ConnMaxLifetime برابر با ۱ دقیقه باشد، application شما ممکن است به طور متوسط تا ۱.۶۷ connection را در هر ثانیه از بین ببرد و دوباره بسازد. نمی‌خواهید این frequency آن‌قدر زیاد باشد که در نهایت performance را مختل کند.

متد SetConnMaxIdleTime

متد SetConnMaxIdleTime() limit مربوط به ConnMaxIdleTime را تنظیم می‌کند. این روش بسیار شبیه ConnMaxLifetime کار می‌کند، با این تفاوت که حداکثر مدت زمانی را تنظیم می‌کند که یک connection می‌تواند قبل از expired شدن idle بماند. به صورت پیش‌فرض limitای وجود ندارد.

اگر مثلا ConnMaxIdleTime را روی ۱ ساعت تنظیم کنیم، هر connectionای که از آخرین استفاده به مدت ۱ ساعت در pool idle مانده باشد به عنوان expired علامت‌گذاری می‌شود و توسط عملیات cleanup در background حذف خواهد شد.

این setting واقعا مفید است، چون یعنی می‌توانیم limit نسبتا بالایی برای تعداد connectionهای idle در pool تنظیم کنیم، اما به صورت دوره‌ای با حذف connectionهای idleای که می‌دانیم دیگر واقعا استفاده نمی‌شوند، resourceها را آزاد کنیم.

استفاده عملی

خب، این اطلاعات زیادی بود… اما در عمل چه معنی‌ای دارد؟ بیایید همه موارد بالا را در چند نکته قابل اقدام خلاصه کنیم.

  1. به عنوان rule of thumb، باید مقدار MaxOpenConns را به صورت صریح تنظیم کنید. این مقدار باید با فاصله مناسب پایین‌تر از هر hard limitی باشد که database و infrastructure شما روی تعداد connectionها اعمال می‌کنند، و شاید بخواهید آن را نسبتا پایین نگه دارید تا مثل یک throttle ساده عمل کند.

    برای این پروژه limit مربوط به MaxOpenConns را روی ۲۵ connection تنظیم می‌کنیم. من دیده‌ام که این مقدار برای web applicationها و APIهای کوچک تا متوسط نقطه شروع منطقی‌ای است، اما در حالت ایدئال باید بسته به نتایج benchmark و load test، این مقدار را برای سخت‌افزار خودتان تنظیم کنید.

  2. به طور کلی، مقدارهای بالاتر MaxOpenConns و MaxIdleConns باعث performance بهتر می‌شوند. اما بازدهی آن‌ها diminishing است، و باید بدانید داشتن idle connection pool بیش از حد بزرگ، با connectionهایی که زیاد reuse نمی‌شوند، در واقع می‌تواند باعث کاهش performance و مصرف غیرضروری resourceها شود.

    چون MaxIdleConns همیشه باید کمتر یا مساوی MaxOpenConns باشد، برای این پروژه MaxIdleConns را هم به ۲۵ connection محدود می‌کنیم.

  3. برای کاهش ریسک نکته ۲ بالا، معمولا باید یک مقدار ConnMaxIdleTime تنظیم کنید تا connectionهای idleای که مدت زیادی استفاده نشده‌اند حذف شوند. در این پروژه duration مربوط به ConnMaxIdleTime را روی ۱۵ دقیقه تنظیم می‌کنیم.

  4. احتمالا مشکلی ندارد که ConnMaxLifetime را نامحدود بگذارید، مگر اینکه database شما hard limitای روی lifetime connection اعمال کند، یا مشخصا برای چیزی مثل تعویض graceful databaseها به آن نیاز داشته باشید. هیچ‌کدام از این موارد در این پروژه صدق نمی‌کند، پس آن را روی setting پیش‌فرض نامحدود باقی می‌گذاریم.

پیکربندی connection pool

به جای hard-code کردن این settingها، فایل cmd/api/main.go را به‌روزرسانی می‌کنیم تا آن‌ها را به عنوان command-line flag بپذیرد.

command-line flag مربوط به مقدار ConnMaxIdleTime به‌خصوص جالب است، چون می‌خواهیم یک duration زمانی را منتقل کند، مثل 5s یعنی ۵ ثانیه یا 10m یعنی ۱۰ دقیقه. برای کمک به این کار می‌توانیم از function مربوط به flag.DurationVar() استفاده کنیم تا مقدار command-line flag را بخواند و به صورت خودکار برای ما به type نوع time.Duration تبدیل کند.

فایل cmd/api/main.go را به شکل زیر به‌روزرسانی کنید:

File: cmd/api/main.go
package main

...

// Add maxOpenConns, maxIdleConns and maxIdleTime fields to hold the configuration
// settings for the connection pool.
type config struct {
    port int
    env  string
    db   struct {
        dsn          string
        maxOpenConns int
        maxIdleConns int
        maxIdleTime  time.Duration
    }
}

...

func main() {
    var cfg config

    flag.IntVar(&cfg.port, "port", 4000, "API server port")
    flag.StringVar(&cfg.env, "env", "development", "Environment (development|staging|production)")

    flag.StringVar(&cfg.db.dsn, "db-dsn", os.Getenv("GREENLIGHT_DB_DSN"), "PostgreSQL DSN")

    // Read the connection pool settings from command-line flags into the config struct.
    // Note that the default values we're using are the ones we discussed above.
    flag.IntVar(&cfg.db.maxOpenConns, "db-max-open-conns", 25, "PostgreSQL max open connections")
    flag.IntVar(&cfg.db.maxIdleConns, "db-max-idle-conns", 25, "PostgreSQL max idle connections")
    flag.DurationVar(&cfg.db.maxIdleTime, "db-max-idle-time", 15*time.Minute, "PostgreSQL max connection idle time")

    flag.Parse()

    ...
}

func openDB(cfg config) (*sql.DB, error) {
    db, err := sql.Open("postgres", cfg.db.dsn)
    if err != nil {
        return nil, err
    }

    // Set the maximum number of open (in-use + idle) connections in the pool. Note that
    // passing a value less than or equal to 0 will mean there is no limit.
    db.SetMaxOpenConns(cfg.db.maxOpenConns)

    // Set the maximum number of idle connections in the pool. Again, passing a value
    // less than or equal to 0 will mean there is no limit.
    db.SetMaxIdleConns(cfg.db.maxIdleConns)

    // Set the maximum idle timeout for connections in the pool. Passing a duration less
    // than or equal to 0 will mean that connections are not closed due to their idle time. 
    db.SetConnMaxIdleTime(cfg.db.maxIdleTime)

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

    err = db.PingContext(ctx)
    if err != nil {
        db.Close()
        return nil, err
    }

    return db, nil
}

اگر حالا application را دوباره اجرا کنید، همه چیز همچنان باید درست کار کند. برای flag مربوط به db-max-idle-time می‌توانید هر مقداری را پاس بدهید که برای function مربوط به time.ParseDuration() قابل قبول است؛ مثل 300ms یعنی ۳۰۰ millisecond، 5s یعنی ۵ ثانیه یا 2h45m یعنی ۲ ساعت و ۴۵ دقیقه. واحدهای زمانی معتبر عبارت‌اند از ns، us یا µs، ms، s، m و h.

برای مثال:

$ go run ./cmd/api -db-max-open-conns=50 -db-max-idle-conns=50 -db-max-idle-time=2h30m 

در این نقطه احتمالا تغییر واضحی در application نمی‌بینید و کار زیادی هم نمی‌توانیم برای نشان دادن اثر این settingها انجام دهیم. اما بعدا در کتاب کمی load testing انجام می‌دهیم و توضیح می‌دهیم چطور با استفاده از متد db.Stats() وضعیت connection pool را در real-time monitor کنید. آن موقع می‌توانید بخشی از رفتاری را که در این فصل درباره‌اش صحبت کردیم در عمل ببینید.