كيفية العمل مع Postgres in Go: الممارسات والميزات والفروق الدقيقة


السلوك غير المتوقع للتطبيق فيما يتعلق بالعمل مع قاعدة البيانات يؤدي إلى حرب بين DBA والمطورين: DBA يصرخ: "التطبيق الخاص بك يسقط قاعدة البيانات" ، والمطورين - "لكن كل شيء يعمل من قبل!" والأسوأ من ذلك كله ، أن DBA والمطورين لا يستطيعون مساعدة بعضهم البعض: البعض لا يعرفون عن الفروق الدقيقة في التطبيق وبرنامج التشغيل ، والبعض الآخر لا يعرفون الميزات المتعلقة بالبنية التحتية. سيكون من الرائع تجنب مثل هذا الموقف.


عليك أن تفهم ، فغالبًا لا يكفي البحث من خلال go-database-sql.org . من الأفضل تسليح نفسك بتجربة الآخرين. حتى أفضل إذا كانت تجربة اكتسبها الدم وخسر المال.



اسمي Ryabinkov Artemy وهذه المقالة هي ترجمة مجانية لتقريري من مؤتمر Saints HighLoad 2019 .


الأدوات


يمكنك العثور على الحد الأدنى من المعلومات الضرورية حول كيفية العمل مع Go مع أي قاعدة بيانات تشبه SQL في go-database-sql.org . إذا لم تكن قد قرأته ، فاقرأه.


sqlx


في رأيي ، قوة الذهاب هي البساطة. ويتم التعبير عن ذلك ، على سبيل المثال ، لأنه من المعتاد بالنسبة لـ Go كتابة استعلامات في SQL عارية (ORM ليس تكريماً). هذه ميزة ومصدر لصعوبات إضافية.


لذلك ، مع أخذ حزمة لغة database/sql القياسية ، ستحتاج إلى توسيع واجهاتها. بمجرد حدوث ذلك ، ألقِ نظرة على github.com/jmoiron/sqlx . اسمح لي أن أريك بعض الأمثلة عن كيف يمكن لهذا التمديد تبسيط حياتك.


باستخدام StructScan يلغي الحاجة إلى نقل البيانات يدويا من الأعمدة إلى خصائص الهيكل.


 type Place struct { Country string City sql.NullString TelephoneCode int `db:"telcode"` } var p Place err = rows.StructScan(&p) 

يتيح لك استخدام NamedQuery استخدام خصائص البنية كعناصر نائبة في استعلام.


 p := Place{Country: "South Africa"} sql := `.. WHERE country=:country` rows, err := db.NamedQuery(sql, p) 

يتيح لك استخدام Get and Select التخلص من الحاجة إلى كتابة حلقات يدويًا تحصل على خطوط من قاعدة البيانات.


 var p Place var pp []Place // Get   p     err = db.Get(&p, ".. LIMIT 1") // Select   pp   . err = db.Select(&pp, ".. WHERE telcode > ?", 50) 

السائقين


database/sql هي مجموعة من الواجهات للعمل مع قاعدة البيانات ، و sqlx هو امتدادها. لكي تعمل هذه الواجهات ، فإنها تحتاج إلى تنفيذ. السائقين مسؤولون عن التنفيذ.


السائقين الأكثر شعبية:


  • github.com/lib/pq - pure Go Postgres driver for database/sql. ظل برنامج التشغيل هذا هو المعيار الافتراضي لفترة طويلة. لكن اليوم فقد أهميته ولا يتم تطويره بواسطة المؤلف.
  • github.com/jackc/pgx - PostgreSQL driver and toolkit for Go. من الأفضل اليوم اختيار هذه الأداة.

github.com/jackc/pgx - هذا هو برنامج التشغيل الذي تريد استخدامه. لماذا؟


  • بدعم بنشاط وتطويرها .
  • يمكن أن يكون أكثر إنتاجية إذا تم استخدامه دون واجهات database/sql .
  • دعم أكثر من 60 نوعًا من PostgreSQL التي ينفذها PostgreSQL خارج معيار SQL .
  • القدرة على تنفيذ تسجيل ما يحدث داخل السائق بشكل مريح.
  • pgx على أخطاء يمكن قراءتها من قِبل الإنسان ، بينما يرمي lib/pq فقط بنوبات الهلع. إذا لم تصاب بالذعر ، فسوف يتعطل البرنامج. ( يجب عدم استخدام الذعر في Go ، وهذا ليس هو نفسه الاستثناء. )
  • مع pgx ، لدينا القدرة على تكوين كل اتصال بشكل مستقل.
  • هناك دعم لبروتوكول PostgreSQL للنسخ المتماثل المنطقي .

4KB


عادة ، نكتب هذه الحلقة للحصول على البيانات من قاعدة البيانات:


 rows, err := s.db.QueryContext(ctx, sql) for rows.Next() { err = rows.Scan(...) } 

داخل برنامج التشغيل ، نحصل على البيانات عن طريق تخزينها في مخزن مؤقت 4 كيلوبايت . rows.Next() يولد رحلة شبكة ويملأ المخزن المؤقت. إذا لم يكن المخزن المؤقت كافيًا ، فسنذهب إلى الشبكة للحصول على البيانات المتبقية. المزيد من زيارات الشبكة - سرعة معالجة أقل. من ناحية أخرى ، نظرًا لأن حد المخزن المؤقت هو 4 كيلو بايت ، فلننسى ذاكرة العملية بالكامل.


لكن ، بالطبع ، أرغب في إلغاء فك وحدة التخزين المؤقتة إلى الحد الأقصى من أجل تقليل عدد الطلبات على الشبكة وتقليل زمن الوصول إلى الخدمة الذي نقدمه. نضيف هذه الفرصة ونحاول معرفة التسارع المتوقع في الاختبارات الاصطناعية :


 $ go test -v -run=XXX -bench=. -benchmem goos: linux goarch: amd64 pkg: github.com/furdarius/pgxexperiments/bufsize BenchmarkBufferSize/4KB 5 315763978 ns/op 53112832 B/op 12967 allocs/op BenchmarkBufferSize/8KB 5 300140961 ns/op 53082521 B/op 6479 allocs/op BenchmarkBufferSize/16KB 5 298477972 ns/op 52910489 B/op 3229 allocs/op BenchmarkBufferSize/1MB 5 299602670 ns/op 52848230 B/op 50 allocs/op PASS ok github.com/furdarius/pgxexperiments/bufsize 10.964s 

يمكن ملاحظة أنه لا يوجد فرق كبير في سرعة المعالجة. لماذا هذا


اتضح أننا مقيدون بحجم المخزن المؤقت لإرسال البيانات داخل بوستجرس نفسها. يحتوي هذا المخزن المؤقت حجم ثابت 8 كيلو بايت . باستخدام strace يمكنك رؤية أن نظام التشغيل يُرجع 8192 بايت في استدعاء نظام القراءة . و tcpdump يؤكد هذا مع حجم الحزم.


توم لين ( أحد المطورين الأساسيين لنواة Postgres ) يعلق مثل هذا:


تقليديًا ، على الأقل ، كان هذا هو حجم المخازن المؤقتة للأنابيب في أجهزة Unix ، لذلك من حيث المبدأ ، يعد هذا الحجم الأكثر حجمًا لإرسال البيانات عبر مأخذ يونيكس.

يعتقد Andres Freund ( مطور Postgres من EnterpriseDB ) أن المخزن المؤقت 8 كيلو بايت ليس هو أفضل خيار للتنفيذ حتى الآن ، وتحتاج إلى اختبار السلوك بأحجام مختلفة وبتكوين مختلف للمقبس.


يجب أن نتذكر أيضًا أن PgBouncer يحتوي أيضًا على مخزن مؤقت ويمكن تكوين pkt_buf المعلمة pkt_buf .


OIDs


ميزة أخرى لبرنامج التشغيل pgx ( v3 ): لكل اتصال ، يجعل طلب قاعدة البيانات للحصول على معلومات حول معرف الكائن ( OID ).


تمت إضافة هذه المعرفات إلى Postgres لتحديد الكائنات الداخلية بشكل فريد : الصفوف والجداول والوظائف ، إلخ.


يستخدم برنامج التشغيل معرفة OIDs لفهم أي عمود قاعدة البيانات إلى أي لغة بدائية لإضافة البيانات. لهذا ، يدعم pgx مثل هذا الجدول ( المفتاح هو اسم النوع ، والقيمة هي معرف الكائن )


 map[string]Value{ "_aclitem": 2, "_bool": 3, "_int4": 4, "_int8": 55, ... } 

يؤدي هذا التطبيق إلى حقيقة أن برنامج التشغيل لكل اتصال ثابت مع قاعدة البيانات يقدم حوالي ثلاثة طلبات لتشكيل جدول Object ID . في الوضع العادي لتشغيل قاعدة البيانات والتطبيق ، يسمح لك تجمع الاتصال في Go بعدم إنشاء اتصالات جديدة إلى قاعدة البيانات. ولكن عند أدنى تدهور في قاعدة البيانات ، يتم استنفاد مجموعة الاتصالات على جانب التطبيق ويزيد عدد الاتصالات التي تم إنشاؤها في وقت الوحدة زيادة كبيرة. طلبات OIDs ثقيلة جدًا ، ونتيجة لذلك ، يمكن لبرنامج التشغيل إحضار قاعدة البيانات إلى حالة حرجة.


هذه هي اللحظة التي تم فيها صب هذه الطلبات على إحدى قواعد البيانات الخاصة بنا:



15 معاملة في الدقيقة في الوضع العادي ، قفزة تصل إلى 6500 معاملة خلال التدهور.


ما يجب القيام به


أولاً وقبل كل شيء ، قم بتحديد حجم حمام السباحة الخاص بك من أعلى.


بالنسبة database/sql يمكن القيام بذلك باستخدام وظيفة DB.SetMaxOpenConns . إذا كنت تتخلى عن واجهات database/sql وتستخدم pgx.ConnPool ( تجمع الاتصال الذي ينفذه برنامج التشغيل نفسه ) ، فيمكنك تحديد MaxConnections ( الافتراضي هو 5 ).


بالمناسبة ، عند استخدام pgx.ConnPool سيقوم برنامج التشغيل بإعادة استخدام معلومات حول OIDs المستلمة ولن يقدم طلبات إلى قاعدة البيانات لكل اتصال جديد.


إذا كنت لا ترغب في رفض database/sql ، فيمكنك تخزين معلومات عن OIDs بنفسك.


 github.com/jackc/pgx/stdlib.OpenDB(pgx.ConnConfig{ CustomConnInfo: func(c *pgx.Conn) (*pgtype.ConnInfo, error) { cachedOids = //  OIDs   . info := pgtype.NewConnInfo() info.InitializeDataTypes(cachedOids) return info, nil } }) 

هذه طريقة عمل ، لكن استخدامها قد يكون خطيرًا في ظل شرطين:


  • يمكنك استخدام التعداد أو أنواع المجال في بوستجرس ؛
  • إذا فشل المعالج ، يمكنك تبديل التطبيق إلى النسخة المتماثلة التي يتم سكبها بواسطة النسخ المتماثل المنطقي.

يؤدي تنفيذ هذه الشروط إلى حقيقة أن OIDs المخزنة مؤقتًا تصبح غير صالحة. لكننا لن نكون قادرين على تنظيفها ، لأننا لا نعرف لحظة التحول إلى قاعدة جديدة.


في عالم Postgres ، عادةً ما يتم استخدام النسخ المتماثل الفعلي لتنظيم التوافر العالي ، الذي ينسخ مثيلات قاعدة البيانات شيئًا فشيئًا ، لذلك نادراً ما تظهر مشكلات التخزين المؤقت OIDs في البرية. ( ولكن من الأفضل أن تتأكد من طريقة DBA الخاصة بك كيف يعمل وضع الاستعداد لديك ).


في الإصدار الرئيسي التالي من برنامج تشغيل pgx - الإصدار pgx ، لن تكون هناك حملات OIDs . الآن سيعتمد برنامج التشغيل فقط على قائمة OIDs في التعليمات البرمجية. بالنسبة للأنواع المخصصة ، ستحتاج إلى التحكم في إلغاء التسلسل في جانب التطبيق الخاص بك: سوف يتخلى برنامج التشغيل ببساطة عن جزء من الذاكرة كصفيف من وحدات البايت.


تسجيل ومراقبة


ستساعد المراقبة والتسجيل في ملاحظة المشكلات قبل تعطل القاعدة.


database/sql طريقة DB.Stats () . ستعطيك لقطة الحالة التي تم إرجاعها فكرة عما يحدث داخل السائق.


 type DBStats struct { MaxOpenConnections int // Pool State OpenConnections int InUse int Idle int // Counters WaitCount int64 WaitDuration time.Duration MaxIdleClosed int64 MaxLifetimeClosed int64 } 

إذا كنت تستخدم التجمع في pgx مباشرةً ، pgx طريقة ConnPool.Stat () معلومات مماثلة:


 type ConnPoolStat struct { MaxConnections int CurrentConnections int AvailableConnections int } 

التسجيل مهم بنفس القدر ، ويتيح لك pgx القيام بذلك. يقبل برنامج التشغيل واجهة Logger ، من خلال تطبيق ، والتي ستحصل على جميع الأحداث التي تحدث داخل برنامج التشغيل.


 type Logger interface { // Log a message at the given level with data key/value pairs. // data may be nil. Log(level LogLevel, msg string, data map[string]interface{}) } 

على الأرجح ، ليس عليك حتى تنفيذ هذه الواجهة بنفسك. في pgx خارج الصندوق ، توجد مجموعة من المحولات الخاصة بأكثر أجهزة التسجيل شعبية ، على سبيل المثال ، uber-go / zap ، sirupsen / logrus ، rs / zerolog .


البنية التحتية


دائمًا تقريبًا عند العمل مع Postgres ستستخدم أداة تجمع الاتصالات ، وستكون PgBouncer ( أو odyssey - إذا كنت Yandex ).


لماذا هذا ، يمكنك أن تقرأ في المقالة الممتازة brandur.org/postgres-connections . باختصار ، عندما يتجاوز عدد العملاء 100 ، تبدأ سرعة معالجة الطلبات في الانخفاض. يحدث هذا بسبب ميزات تنفيذ بوستجرس نفسها: إطلاق عملية منفصلة لكل اتصال ، وآلية إزالة اللقطات واستخدام الذاكرة المشتركة للتفاعل - كل هذا يؤثر.


هنا هو المعيار من مختلف تطبيقات تجمع الاتصالات:


وعرض النطاق الترددي القياسي مع وبدون PgBouncer.



نتيجة لذلك ، ستبدو البنية الأساسية الخاصة بك كما يلي:



حيث Server هو العملية التي تعالج طلبات المستخدم. هذه العملية تدور في kubernetes في 3 نسخ ( على الأقل ). بشكل منفصل ، على خادم الحديد ، هناك Postgres ، التي تغطيها PgBouncer' . PgBouncer نفسه ذو ترابط واحد ، لذلك نطلق العديد من الحراس ، حركة المرور التي PgBouncer خلالها باستخدام HAProxy . نتيجة لذلك ، نحصل على سلسلة تنفيذ الاستعلام هذه في قاعدة البيانات: → HAProxy → PgBouncer → Postgres .


يمكن أن يعمل PgBouncer في ثلاثة أوضاع:


  • تجمع الجلسة - لكل جلسة ، يتم إصدار اتصال واحد وتعيين لها طوال فترة الحياة.
  • تجمع المعاملات - يعيش الاتصال أثناء تشغيل المعاملة. بمجرد اكتمال المعاملة ، يأخذ PgBouncer هذا الاتصال PgBouncer إلى معاملة أخرى. يسمح هذا الوضع بالتخلص جيدًا من المركبات.
  • تجميع البيانات - وضع الإهمال . تم إنشاؤه فقط لدعم PL / Proxy .

يمكنك رؤية مصفوفة الخصائص المتوفرة في كل وضع. نختار تجمّع المعاملات ، لكن له قيودًا على العمل مع Prepared Statements .


تجمع المعاملات + البيانات المعدة


دعنا نتخيل أننا نريد إعداد طلب ثم تنفيذه. في مرحلة ما ، نبدأ معاملة نرسل فيها طلب تحضير ، ونحصل على معرّف الطلب المعد من قاعدة البيانات.



بعد ، في أي لحظة أخرى في الوقت المناسب ، نقوم بإنشاء معاملة أخرى. في ذلك ، ننتقل إلى قاعدة البيانات ونريد تلبية الطلب باستخدام المعرف مع المعلمات المحددة.



في وضع " تجمع المعاملات" ، يمكن تنفيذ معاملتين في اتصالات مختلفة ، لكن معرف البيان صالح فقط داخل اتصال واحد. نحصل على prepared statement does not exist خطأ عند محاولة تنفيذ طلب.


الأكثر رضاءًا: نظرًا لأنه أثناء التطوير واختبار الحمل صغير ، فإن PgBouncer يصدر في كثير من الأحيان نفس الاتصال ويعمل كل شيء بشكل صحيح. ولكن بمجرد طرحنا على المنتج ، تبدأ الطلبات في الانخفاض مع وجود خطأ.


الآن ابحث عن Prepared Statements في هذا الكود:


 sql := `select * from places where city = ?` rows, err := s.db.Query(sql, city) 

لن تراه! سيحدث تحضير الاستعلام ضمنًا داخل Query() . في الوقت نفسه ، سيحدث إعداد وتنفيذ الطلب في معاملات مختلفة وسنتلقى بالكامل كل ما وصفته أعلاه.


ما يجب القيام به


الخيار الأول والأسهل هو تبديل PgBouncer إلى Session pooling . يتم تخصيص اتصال واحد للجلسة ، وتبدأ جميع المعاملات في هذا الصدد وتعمل الطلبات المعدة بشكل صحيح. ولكن في هذا الوضع ، فإن كفاءة استخدام المركبات تترك الكثير مما هو مرغوب فيه. لذلك ، لا يعتبر هذا الخيار.


الخيار الثاني هو إعداد طلب من جانب العميل . لا أريد القيام بذلك لسببين:


  • نقاط ضعف SQL المحتملة. قد ينسى المطور أو يهرب بشكل غير صحيح.
  • الهروب من معلمات الاستعلام في كل مرة يجب أن تكتب بيديك.

خيار آخر هو التفاف كل طلب في معاملة بشكل صريح . بعد كل شيء ، ما دامت المعاملة حية ، PgBouncer لا تلتقط الاتصال. يعمل هذا ، ولكن بالإضافة إلى الفعل في الشفرة الخاصة بنا ، نحصل أيضًا على المزيد من مكالمات الشبكة: البدء ، الإعداد ، التنفيذ ، الالتزام. إجمالي 4 مكالمات شبكة لكل طلب. الكمون ينمو.


لكنني أريدها بأمان وراحة وكفاءة. وهناك مثل هذا الخيار! يمكنك إخبار برنامج التشغيل بشكل صريح أنك تريد استخدام وضع Simple Query . في هذا الوضع ، لن يكون هناك إعداد وسيتم تمرير الطلب بالكامل في مكالمة شبكة واحدة. في هذه الحالة ، سيقوم برنامج التشغيل بعمل التدريع لكل من المعلمات نفسها ( يجب تنشيط standard_conforming_strings على المستوى الأساسي أو عند إنشاء اتصال ).


 cfg := pgx.ConnConfig{ ... RuntimeParams: map[string]string{ "standard_conforming_strings": "on", }, PreferSimpleProtocol: true, } 

إلغاء الطلبات


تتعلق المشكلات التالية بإلغاء الطلبات من جانب التطبيق.


ألق نظرة على هذا الرمز. أين هي المزالق؟


 rows, err := s.db.QueryContext(ctx, ...) 

Go لديه طريقة للتحكم في تدفق تنفيذ البرنامج - context.Context . في هذا الرمز ، نقوم بتمرير ctx برنامج التشغيل بحيث عندما يتم إغلاق السياق ، يلغي برنامج التشغيل الطلب على مستوى قاعدة البيانات.


في الوقت نفسه ، من المتوقع أن نوفر الموارد عن طريق إلغاء الطلبات التي لا ينتظرها أحد. ولكن عندما يتم PgBouncer الطلب PgBouncer يرسل PgBouncer الإصدار 1.7 معلومات إلى الاتصال بأن هذا الاتصال جاهز للاستخدام ، وبعد ذلك PgBouncer إلى التجمع. هذا السلوك من PgBouncer' هو تضليل برنامج التشغيل ، والذي عند إرسال الطلب التالي ، يتلقى ReadyForQuery فورًا استجابة لذلك. في النهاية ، نلاحظ أخطاء ReadyForQuery غير المتوقعة .


بدءًا من PgBouncer الإصدار 1.8 ، تم إصلاح هذا السلوك. استخدم الإصدار الحالي من PgBouncer .


وعلى الرغم من ذلك ، في هذه الحالة ، ستختفي الأخطاء - سيبقى سلوك مثير للاهتمام. في بعض الحالات ، قد يتلقى تطبيقنا إجابات ليس لطلبه ، ولكن للطلب المجاور (الشيء الرئيسي هو أن الطلبات تتوافق مع نوع وترتيب البيانات المطلوبة). هذا هو ، على سبيل المثال ، للاستعلام where user_id = 2 ، استجابة الاستعلام where user_id = 42 سيتم إرجاعها. ويرجع ذلك إلى معالجة طلبات الإلغاء على مستويات مختلفة: على مستوى تجمع السائقين وتجمع الحارس.


تأجيل الإلغاء


لإلغاء الطلب ، نحتاج إلى إنشاء اتصال جديد بقاعدة البيانات وطلب الإلغاء. Postgres ينشئ عملية منفصلة لكل اتصال. نرسل أمرًا لإلغاء الطلب الحالي في عملية محددة. للقيام بذلك ، قم بإنشاء اتصال جديد وفيه نقل معرف العملية (PID) الذي يهمنا. ولكن في الوقت الذي ينتقل فيه أمر الإلغاء إلى القاعدة ، فقد ينتهي الطلب الملغى من تلقاء نفسه.



سيقوم Postgres بتنفيذ الأمر وإلغاء الطلب الحالي في العملية المحددة. لكن الطلب الحالي لن يكون الطلب الذي أردنا إلغاؤه في البداية. نظرًا لهذا السلوك عند العمل مع Postgres مع PgBouncer عدم إلغاء الطلب على مستوى برنامج التشغيل. للقيام بذلك ، يمكنك تعيين CustomCancel ، والتي لن تلغي الطلب ، حتى لو context.Context استخدام context.Context .


 cfg := pgx.ConnConfig{ ... CustomCancel: func(_ *pgx.Conn) error { return nil }, } 

Postgres المرجعية


بدلاً من الاستنتاجات ، قررت إنشاء قائمة مرجعية للعمل مع بوستجرس. هذا من شأنه أن يساعد المادة تنسجم مع رأسي.


  • استخدم github.com/jackc/pgx كسائق للعمل مع Postgres.
  • الحد من حجم تجمع الاتصال من أعلاه.
  • ذاكرة التخزين المؤقت OIDs أو استخدام pgx.ConnPool إذا كنت تعمل مع pgx الإصدار 3.
  • جمع المقاييس من تجمع الاتصال باستخدام DB.Stats () أو ConnPool.Stat () .
  • سجل ما يحدث في السائق.
  • استخدم وضع الاستعلام البسيط لتجنب مشاكل في إعداد الاستعلام في وضع معاملات PgBouncer .
  • تحديث PgBouncer إلى أحدث إصدار.
  • كن حذرا مع إلغاء الطلبات من التطبيق.

Source: https://habr.com/ru/post/ar461935/


All Articles