
Perilaku aplikasi yang tidak terduga dalam kaitannya dengan bekerja dengan database menyebabkan perang antara DBA dan pengembang: DBA berteriak: "Aplikasi Anda menjatuhkan basis data", pengembang - "Tapi semuanya bekerja sebelumnya!" Yang paling parah, DBA dan pengembang tidak dapat saling membantu: beberapa tidak tahu tentang nuansa aplikasi dan driver, yang lain tidak tahu tentang fitur yang terkait dengan infrastruktur. Akan lebih baik untuk menghindari situasi seperti itu.
Anda harus mengerti, seringkali tidak cukup hanya dengan melihat go-database-sql.org . Lebih baik mempersenjatai diri dengan pengalaman orang lain. Bahkan lebih baik jika itu adalah pengalaman yang diperoleh dengan darah dan kehilangan uang.
Nama saya Ryabinkov Artemy dan artikel ini adalah interpretasi gratis dari laporan saya dari konferensi Saints HighLoad 2019 .
Alat-alatnya
Anda dapat menemukan informasi minimum yang diperlukan tentang cara bekerja dengan Go dengan database seperti SQL di go-database-sql.org . Jika Anda belum membacanya, bacalah.
sqlx
Menurut pendapat saya, kekuatan Go adalah kesederhanaan. Dan ini diungkapkan, misalnya, karena sudah biasa bagi Go untuk menulis kueri dalam bare SQL (ORM tidak menghormati). Ini merupakan keuntungan dan juga sumber kesulitan tambahan.
Oleh karena itu, dengan mengambil paket bahasa database/sql
standar, Anda ingin memperluas antarmuka-nya. Setelah itu terjadi, lihat di github.com/jmoiron/sqlx . Izinkan saya menunjukkan kepada Anda beberapa contoh bagaimana ekstensi ini dapat menyederhanakan hidup Anda.
Menggunakan StructScan menghilangkan kebutuhan untuk secara manual memindahkan data dari kolom ke properti struktur.
type Place struct { Country string City sql.NullString TelephoneCode int `db:"telcode"` } var p Place err = rows.StructScan(&p)
Menggunakan NamedQuery memungkinkan Anda untuk menggunakan properti struktur sebagai placeholder dalam kueri.
p := Place{Country: "South Africa"} sql := `.. WHERE country=:country` rows, err := db.NamedQuery(sql, p)
Menggunakan Get and Select memungkinkan Anda untuk menghilangkan kebutuhan untuk secara manual menulis loop yang mendapatkan baris dari database.
var p Place var pp []Place
Driver
database/sql
adalah seperangkat antarmuka untuk bekerja dengan database, dan sqlx
adalah ekstensi mereka. Agar antarmuka ini berfungsi, mereka membutuhkan implementasi. Driver bertanggung jawab atas implementasi.
Driver paling populer:
- github.com/lib/pq -
pure Go Postgres driver for database/sql.
Driver ini telah lama tetap menjadi standar standar. Tetapi hari ini telah kehilangan relevansinya dan tidak sedang dikembangkan oleh penulis. - github.com/jackc/pgx -
PostgreSQL driver and toolkit for Go.
Hari ini lebih baik memilih alat ini.
github.com/jackc/pgx - ini adalah driver yang ingin Anda gunakan. Mengapa
- Didukung dan dikembangkan secara aktif.
- Ini bisa lebih produktif jika digunakan tanpa antarmuka
database/sql
. - Dukungan untuk lebih dari 60 jenis PostgreSQL yang diterapkan
PostgreSQL
luar standar SQL
. - Kemampuan untuk dengan mudah mengimplementasikan pencatatan apa yang terjadi di dalam driver.
pgx
kesalahan yang dapat dibaca manusia , sementara hanya lib/pq
melempar serangan panik. Jika Anda tidak panik, program akan macet. ( Anda seharusnya tidak menggunakan panik di Go, ini tidak sama dengan pengecualian. )- Dengan
pgx
, kami memiliki kemampuan untuk mengkonfigurasi setiap koneksi secara independen. - Ada dukungan untuk protokol replikasi logis
PostgreSQL
.
4KB
Biasanya, kami menulis loop ini untuk mendapatkan data dari database:
rows, err := s.db.QueryContext(ctx, sql) for rows.Next() { err = rows.Scan(...) }
Di dalam driver, kami mendapatkan data dengan menyimpannya di buffer 4KB . rows.Next()
memunculkan perjalanan jaringan dan mengisi buffer. Jika buffer tidak cukup, maka kami pergi ke jaringan untuk data yang tersisa. Lebih banyak kunjungan jaringan - lebih sedikit kecepatan pemrosesan. Di sisi lain, karena batas buffer adalah 4KB, jangan lupa seluruh memori proses.
Tapi, tentu saja, saya ingin membuka volume buffer secara maksimal untuk mengurangi jumlah permintaan ke jaringan dan mengurangi latensi layanan kami. Kami menambahkan kesempatan ini dan mencoba untuk mengetahui percepatan yang diharapkan pada tes sintetis :
$ 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
Dapat dilihat bahwa tidak ada perbedaan besar dalam kecepatan pemrosesan. Kenapa begitu
Ternyata kita dibatasi oleh ukuran buffer untuk mengirim data di dalam Postgres itu sendiri. Buffer ini memiliki ukuran tetap 8KB . Menggunakan strace
Anda dapat melihat bahwa OS mengembalikan 8192
byte dalam panggilan sistem baca . Dan tcpdump
mengkonfirmasi ini dengan ukuran paket.
Tom Lane ( salah satu pengembang inti dari kernel Postgres ) berkomentar seperti ini:
Secara tradisional, setidaknya, itu adalah ukuran buffer pipa di mesin Unix, jadi pada prinsipnya ini adalah ukuran chunk yang paling optimal untuk mengirim data melalui soket Unix.
Andres Freund ( pengembang Postgres dari EnterpriseDB ) percaya bahwa buffer 8KB bukanlah pilihan implementasi terbaik saat ini, dan Anda perlu menguji perilaku pada ukuran yang berbeda dan dengan konfigurasi soket yang berbeda.
Kita juga harus ingat bahwa PgBouncer juga memiliki buffer dan ukurannya dapat dikonfigurasi dengan parameter pkt_buf
.
OID
Fitur lain dari driver pgx ( v3 ): untuk setiap koneksi, ia membuat permintaan ke database untuk mendapatkan informasi tentang Object ID ( OID ).
Pengidentifikasi ini ditambahkan ke Postgres untuk secara unik mengidentifikasi objek internal: baris, tabel, fungsi, dll.
Pengemudi menggunakan pengetahuan OIDs
untuk memahami kolom basis data mana yang menjadi bahasa primitif untuk menambahkan data. Untuk ini, pgx
mendukung tabel seperti itu ( kuncinya adalah nama tipe, nilainya adalah Object ID )
map[string]Value{ "_aclitem": 2, "_bool": 3, "_int4": 4, "_int8": 55, ... }
Implementasi ini mengarah pada fakta bahwa driver untuk setiap koneksi yang dibuat dengan database membuat sekitar tiga permintaan untuk membentuk tabel dengan Object ID
. Dalam mode operasi normal dari database dan aplikasi, kumpulan koneksi di Go memungkinkan Anda untuk tidak membuat koneksi baru ke database. Tetapi pada degradasi terkecil dari database, kumpulan koneksi pada sisi aplikasi habis dan jumlah koneksi yang dihasilkan per unit waktu meningkat secara signifikan. Permintaan OIDs
cukup berat, sebagai akibatnya, pengemudi dapat membawa database ke kondisi kritis.
Inilah saat ketika permintaan seperti itu dituangkan ke salah satu dari basis data kami:

15 transaksi per menit dalam mode normal, lompatan hingga 6500 transaksi selama degradasi.
Apa yang harus dilakukan
Pertama dan terpenting, batasi ukuran kolam Anda dari atas.
Untuk database/sql
ini dapat dilakukan dengan fungsi DB.SetMaxOpenConns . Jika Anda meninggalkan antarmuka database/sql
dan menggunakan pgx.ConnPool
( pgx.ConnPool
koneksi yang diimplementasikan oleh driver itu sendiri ), Anda dapat menentukan MaxConnections
( standarnya adalah 5 ).
Omong-omong, ketika menggunakan pgx.ConnPool
driver akan menggunakan kembali informasi tentang OIDs
diterima dan tidak akan membuat pertanyaan ke database untuk setiap koneksi baru.
Jika Anda tidak ingin menolak database/sql
, maka Anda dapat menyimpan informasi tentang OIDs
sendiri.
github.com/jackc/pgx/stdlib.OpenDB(pgx.ConnConfig{ CustomConnInfo: func(c *pgx.Conn) (*pgtype.ConnInfo, error) { cachedOids =
Ini adalah metode yang berfungsi, tetapi menggunakannya bisa berbahaya dalam dua kondisi:
- Anda menggunakan enum atau tipe domain di Postgres;
- jika wisaya gagal, Anda beralih aplikasi ke replika, yang dituangkan oleh replikasi logis.
Pemenuhan kondisi-kondisi ini mengarah pada fakta bahwa OIDs
cache menjadi tidak valid. Tapi kami tidak akan bisa membersihkannya, karena kami tidak tahu saat beralih ke pangkalan baru.
Di dunia Postgres
, replikasi fisik biasanya digunakan untuk mengatur ketersediaan tinggi, yang menyalin contoh basis data sedikit demi sedikit, sehingga masalah dengan cache OIDs
jarang terlihat di alam liar. ( Tetapi lebih baik untuk memeriksa dengan DBA Anda bagaimana siaga bekerja untuk Anda ).
Dalam versi utama driver pgx
- v4
, tidak akan ada kampanye untuk OIDs
. Sekarang driver hanya akan bergantung pada daftar OIDs
yang OIDs
dalam kode. Untuk tipe kustom, Anda perlu mengendalikan deserialization di sisi aplikasi Anda: driver hanya akan memberikan sepotong memori sebagai array byte.
Penebangan dan Pemantauan
Pemantauan dan logging akan membantu untuk melihat masalah sebelum pangkalan crash.
database/sql
menyediakan metode DB.Stats () . Snapshot status yang dikembalikan akan memberi Anda gambaran tentang apa yang terjadi di dalam driver.
type DBStats struct { MaxOpenConnections int
Jika Anda menggunakan kumpulan dalam pgx
secara langsung, metode ConnPool.Stat () akan memberi Anda informasi yang serupa:
type ConnPoolStat struct { MaxConnections int CurrentConnections int AvailableConnections int }
Logging sama pentingnya, dan pgx
memungkinkan Anda melakukan ini. Pengemudi menerima antarmuka Logger
, dengan menerapkan yang, Anda mendapatkan semua peristiwa yang terjadi di dalam pengandar.
type Logger interface {
Kemungkinan besar, Anda bahkan tidak perlu mengimplementasikan antarmuka ini sendiri. Dalam pgx
out of the box ada satu set adapter untuk penebang paling populer, misalnya, uber-go / zap , sirupsen / logrus , rs / zerolog .
Infrastruktur
Hampir selalu ketika bekerja dengan Postgres
Anda akan menggunakan pooler koneksi , dan itu akan menjadi PgBouncer ( atau pengembaraan - jika Anda Yandex ).
Mengapa demikian, Anda dapat membaca di artikel yang sangat bagus brandur.org/postgres-connections . Singkatnya, ketika jumlah klien melebihi 100, kecepatan pemrosesan permintaan mulai menurun. Ini terjadi karena fitur implementasi Postgres itu sendiri: peluncuran proses terpisah untuk setiap koneksi, mekanisme untuk menghapus foto dan penggunaan memori bersama untuk interaksi - semua ini mempengaruhi.
Berikut ini adalah patokan dari berbagai implementasi penghubung koneksi:

Dan benchmark bandwidth dengan dan tanpa PgBouncer.

Akibatnya, infrastruktur Anda akan terlihat seperti ini:

Di mana Server
adalah proses yang memproses permintaan pengguna. Proses ini berputar dalam kubernetes
dalam 3 salinan ( setidaknya ). Secara terpisah, pada server besi, ada Postgres
, dicakup oleh PgBouncer'
. PgBouncer
sendiri PgBouncer
single-threaded, jadi kami meluncurkan beberapa bouncer, lalu lintas yang kami gunakan menggunakan HAProxy
. Sebagai hasilnya, kami mendapatkan rangkaian eksekusi permintaan dalam database: β HAProxy β PgBouncer β Postgres
.
PgBouncer
dapat bekerja dalam tiga mode:
- Pengumpulan sesi - untuk setiap sesi, satu koneksi dikeluarkan dan ditugaskan padanya untuk seumur hidup.
- Pengumpulan transaksi - koneksi aktif saat transaksi berjalan. Segera setelah transaksi selesai,
PgBouncer
mengambil koneksi ini dan mengembalikannya ke transaksi lain. Mode ini memungkinkan pembuangan senyawa yang sangat baik. - Pengumpulan pernyataan - mode usang . Itu dibuat hanya untuk mendukung PL / Proxy .
Anda dapat melihat matriks properti apa yang tersedia di setiap mode. Kami memilih Pengumpulan Transaksi , tetapi memiliki batasan untuk bekerja dengan Prepared Statements
.
Pooling Transaksi + Pernyataan Disiapkan
Mari kita bayangkan bahwa kita ingin menyiapkan permintaan dan kemudian melaksanakannya. Pada titik tertentu, kami memulai transaksi di mana kami mengirim permintaan Siapkan, dan kami mendapatkan ID permintaan yang disiapkan dari database.

Setelah, pada saat lain, kami menghasilkan transaksi lain. Di dalamnya, kita beralih ke database dan ingin memenuhi permintaan menggunakan pengidentifikasi dengan parameter yang ditentukan.

Dalam mode Transaction Pooling , dua transaksi dapat dieksekusi dalam koneksi yang berbeda, tetapi ID Pernyataan hanya valid dalam satu koneksi. Kami mendapatkan prepared statement does not exist
kesalahan saat mencoba menjalankan permintaan.
Yang paling tidak menyenangkan: karena selama pengembangan dan pengujian bebannya kecil, PgBouncer
sering mengeluarkan koneksi yang sama dan semuanya berfungsi dengan benar. Tetapi segera setelah kami meluncurkannya ke prod, permintaan mulai jatuh dengan kesalahan.
Sekarang temukan Prepared Statements
dalam kode ini:
sql := `select * from places where city = ?` rows, err := s.db.Query(sql, city)
Anda tidak akan melihatnya! Persiapan kueri akan secara implisit terjadi di dalam Query()
. Pada saat yang sama, persiapan dan pelaksanaan permintaan akan terjadi dalam transaksi yang berbeda dan kami akan sepenuhnya menerima semua yang saya jelaskan di atas.
Apa yang harus dilakukan
Opsi pertama dan termudah adalah mengganti PgBouncer
ke Session pooling
. Satu koneksi dialokasikan ke sesi, semua transaksi mulai masuk dalam koneksi ini dan permintaan yang disiapkan berfungsi dengan benar. Tetapi dalam mode ini, efisiensi pemanfaatan senyawa meninggalkan banyak hal yang diinginkan. Oleh karena itu, opsi ini tidak dipertimbangkan.
Opsi kedua adalah menyiapkan permintaan di sisi klien . Saya tidak ingin melakukan ini karena dua alasan:
- Potensi kerentanan SQL. Pengembang mungkin lupa atau tidak sengaja melarikan diri.
- Melewati parameter kueri setiap kali Anda harus menulis dengan tangan Anda.
Pilihan lain adalah dengan membungkus secara eksplisit setiap permintaan dalam suatu transaksi . Lagi pula, selama transaksi berlangsung, PgBouncer
tidak mengambil koneksi. Ini berfungsi, tetapi, selain verbositas dalam kode kami, kami juga mendapatkan lebih banyak panggilan jaringan: Mulai, Siapkan, Jalankan, Komit. Total 4 panggilan jaringan per permintaan. Latensi sedang tumbuh.
Tetapi saya menginginkannya dengan aman, nyaman, dan efisien. Dan ada opsi seperti itu! Anda dapat secara eksplisit memberi tahu pengemudi bahwa Anda ingin menggunakan mode Pertanyaan Sederhana . Dalam mode ini, tidak akan ada persiapan dan seluruh permintaan akan berlalu dalam satu panggilan jaringan. Dalam hal ini, driver akan membuat perisai dari masing-masing parameter itu sendiri ( standard_conforming_strings
harus diaktifkan di tingkat dasar atau ketika membuat koneksi ).
cfg := pgx.ConnConfig{ ... RuntimeParams: map[string]string{ "standard_conforming_strings": "on", }, PreferSimpleProtocol: true, }
Batalkan permintaan
Masalah-masalah berikut terkait dengan membatalkan permintaan di sisi aplikasi.
Lihatlah kode ini. Dimana jebakannya?
rows, err := s.db.QueryContext(ctx, ...)
Go memiliki metode untuk mengendalikan alur eksekusi program - context.Context . Dalam kode ini, kami meneruskan ctx
driver sehingga ketika konteksnya ditutup, driver membatalkan permintaan di tingkat database.
Pada saat yang sama, diharapkan bahwa kami akan menghemat sumber daya dengan membatalkan permintaan yang tidak ditunggu oleh siapa pun. Tetapi ketika membatalkan permintaan, PgBouncer
versi 1.7 mengirimkan informasi ke koneksi yang koneksi ini siap digunakan, dan setelah itu mengembalikannya ke pool. Perilaku PgBouncer'
ini menyesatkan pengemudi, yang ketika mengirim permintaan berikutnya, langsung menerima ReadyForQuery
sebagai tanggapan. Pada akhirnya, kami menangkap kesalahan ReadyForQuery yang tidak terduga .
Dimulai dengan PgBouncer
versi 1.8, perilaku ini telah diperbaiki . Gunakan versi PgBouncer
.
Dan, meskipun, dalam kasus ini, kesalahan akan hilang - perilaku yang menarik akan tetap ada. Dalam beberapa kasus, aplikasi kita mungkin menerima jawaban bukan untuk permintaannya, tetapi untuk yang bertetangga (yang utama adalah bahwa permintaan sesuai dengan jenis dan urutan data yang diminta). Misalnya, ke kueri di where user_id = 2
, respons kueri di where user_id = 42
akan dikembalikan. Hal ini disebabkan oleh pemrosesan permintaan pembatalan di level yang berbeda: di level pool driver dan pool bouncer.
Pembatalan yang tertunda
Untuk membatalkan permintaan, kita perlu membuat koneksi baru ke database dan meminta pembatalan. Postgres
menciptakan proses terpisah untuk setiap koneksi. Kami mengirim perintah untuk membatalkan permintaan saat ini dalam proses tertentu. Untuk melakukan ini, buat koneksi baru dan di dalamnya transfer ID proses (PID) yang menarik kepada kami. Tetapi sementara perintah pembatalan terbang ke pangkalan, permintaan yang dibatalkan dapat berakhir dengan sendirinya.

Postgres
akan menjalankan perintah dan membatalkan permintaan saat ini dalam proses yang diberikan. Namun permintaan saat ini bukan yang ingin kami batalkan pada awalnya. Karena perilaku ini ketika bekerja dengan Postgres
dengan PgBouncer
lebih aman untuk tidak membatalkan permintaan di tingkat driver. Untuk melakukan ini, Anda dapat mengatur CustomCancel
, yang tidak akan membatalkan permintaan, bahkan jika context.Context
. context.Context
digunakan.
cfg := pgx.ConnConfig{ ... CustomCancel: func(_ *pgx.Conn) error { return nil }, }
Daftar Periksa Postgres
Alih-alih kesimpulan, saya memutuskan untuk membuat daftar periksa untuk bekerja dengan Postgres. Ini akan membantu artikel masuk ke kepala saya.
- Gunakan github.com/jackc/pgx sebagai driver untuk bekerja dengan Postgres.
- Batasi ukuran kumpulan koneksi dari atas.
- Cache
OIDs
atau gunakan pgx.ConnPool jika Anda bekerja dengan pgx
versi 3. - Kumpulkan metrik dari kumpulan koneksi menggunakan DB.Stats () atau ConnPool.Stat () .
- Catat apa yang terjadi pada pengemudi.
- Gunakan mode Pertanyaan Sederhana untuk menghindari masalah dengan persiapan kueri dalam mode transaksional
PgBouncer
. - Perbarui
PgBouncer
ke versi terbaru. - Hati-hati dengan membatalkan permintaan dari aplikasi.