Ini adalah cerita tentang mengapa Anda tidak boleh mengabaikan kesalahan ketika Anda berada di dalam transaksi dalam database. Mencari tahu cara menggunakan transaksi dengan benar dan apa yang harus dilakukan saat menggunakannya bukan merupakan pilihan. Spoiler: ini akan tentang kunci penasihat di PostgreSQL!
Saya bekerja pada sebuah proyek di mana pengguna dapat mengimpor sejumlah besar entitas berat (sebut saja produk) dari layanan eksternal ke dalam aplikasi kami. Untuk setiap produk, bahkan lebih beragam data yang terkait dengannya dimuat dari API eksternal. Tidak jarang seorang pengguna memuat ratusan produk beserta semua dependensinya, sebagai akibatnya, mengimpor satu produk memerlukan waktu nyata (30-60 detik), dan keseluruhan proses dapat memakan waktu lama. Pengguna mungkin bosan menunggu hasilnya dan ia berhak mengklik tombol "Batal" kapan saja dan aplikasi harus berguna dengan jumlah produk yang dapat diunduh saat ini.
βImpor yang terputusβ diimplementasikan sebagai berikut: di awal untuk setiap produk, catatan tugas sementara dibuat di papan nama dalam database. Untuk setiap produk, tugas impor latar belakang diluncurkan, yang mengunduh produk, menyimpannya ke database bersama dengan semua dependensi (melakukan semuanya secara umum), dan pada akhirnya menghapus catatan tugasnya. Jika pada saat tugas latar belakang dimulai, tidak akan ada catatan dalam database - tugas hanya diam-diam berakhir. Jadi, untuk membatalkan impor, cukup menghapus semua tugas dan hanya itu.
Tidak masalah jika impor dibatalkan oleh pengguna atau diselesaikan sendiri - dalam hal apa pun, tidak adanya tugas berarti semuanya telah selesai dan pengguna dapat mulai menggunakan aplikasi.
Desainnya sederhana dan dapat diandalkan, tetapi ada satu bug kecil di dalamnya. Laporan bug khas tentang dia adalah: "Setelah impor dibatalkan, pengguna ditampilkan daftar barang-barangnya. Namun, jika Anda me-refresh halaman, maka daftar produk dilengkapi dengan beberapa entri. " Alasan untuk perilaku ini sederhana - ketika pengguna mengklik tombol "Batal", ia segera dipindahkan ke daftar semua produk. Namun saat ini, sudah mulai impor barang-barang tertentu masih "berjalan".
Ini, tentu saja, agak mudah, tetapi pengguna bingung dengan pesanan, jadi akan lebih baik untuk memperbaikinya. Saya punya dua cara: entah bagaimana mengidentifikasi dan "membunuh" tugas yang sudah berjalan, atau ketika saya menekan tombol batal, tunggu sampai mereka selesai dan "matikan kematian mereka sendiri" sebelum mentransfer pengguna lebih lanjut. Saya memilih cara kedua - untuk menunggu.
Kunci transaksi terburu-buru untuk menyelamatkan
Untuk semua orang yang bekerja dengan database (relasional), jawabannya jelas: gunakan transaksi !
Penting untuk diingat bahwa dalam sebagian besar RDBMS, catatan yang diperbarui dalam suatu transaksi akan diblokir dan tidak dapat diakses untuk perubahan oleh proses lain sampai transaksi ini selesai. Catatan yang dipilih menggunakan SELECT FOR UPDATE
juga akan dikunci.
Tepatnya kasus kami! Saya membungkus tugas mengimpor barang individual ke dalam transaksi dan memblokir catatan tugas di awal:
ActiveRecord::Base.transaction do task = Import::Task.lock.find_by(id: id)
Sekarang, ketika pengguna ingin membatalkan impor, operasi penghentian impor akan menghapus tugas-tugas untuk impor yang belum dimulai dan akan dipaksa untuk menunggu penyelesaian yang sudah ada:
user.import_tasks.delete_all
Sederhana dan elegan! Saya menjalankan tes, memeriksa impor secara lokal dan pada pementasan, dan menyebarkan "ke pertempuran".
Tidak terlalu cepat ...
Puas dengan pekerjaan saya, saya sangat terkejut menemukan segera laporan bug dan banyak kesalahan dalam log. Banyak produk yang tidak diimpor sama sekali . Dalam beberapa kasus, hanya satu produk tunggal yang dapat tetap setelah selesainya semua impor.
Kesalahan dalam log juga tidak mendorong: PG::InFailedSqlTransaction
dengan backtrack yang mengarah ke kode yang mengeksekusi SELECT
s yang tidak bersalah. Apa yang sedang terjadi?
Setelah seharian melakukan debugging yang melelahkan, saya mengidentifikasi tiga penyebab utama masalah:
- Penyisipan kompetitif catatan yang bertentangan ke dalam database.
- Pembatalan transaksi otomatis dalam PostgreSQL setelah kesalahan.
- Kesunyian masalah (pengecualian Ruby) dalam kode aplikasi.
Masalah Satu: Penyisipan Kompetitif dari Entri yang Berkonflik
Karena setiap operasi impor memakan waktu hingga satu menit dan ada banyak tugas ini, kami melakukannya secara paralel untuk menghemat waktu. Catatan barang yang bergantung dapat bersinggungan, sepanjang semua produk pengguna dapat merujuk ke satu catatan tunggal, dibuat sekali dan kemudian digunakan kembali.
Ada cek untuk menemukan dan menggunakan kembali dependensi yang sama dalam kode aplikasi, tetapi sekarang, ketika kita menggunakan transaksi, cek ini menjadi tidak berguna : jika transaksi A membuat catatan dependen tetapi belum selesai, maka transaksi B tidak akan dapat mencari tahu tentang keberadaannya dan akan mencoba untuk membuat duplikat merekam.
Masalah Dua: Pembatalan transaksi otomatis PostgreSQL setelah kesalahan
Tentu saja, kami mencegah pembuatan tugas duplikat di tingkat database menggunakan DDL berikut:
ALTER TABLE product_deps ADD UNIQUE (user_id, characteristics);
Jika suatu transaksi sedang berlangsung A menyisipkan catatan baru dan transaksi B mencoba menyisipkan catatan dengan nilai yang sama dari bidang user_id
dan characteristics
, transaksi B akan menerima kesalahan:
BEGIN; INSERT INTO product_deps (user_id, characteristics) VALUES (1, '{"same": "value"}');
Tetapi ada satu fitur yang tidak boleh dilupakan - transaksi B, setelah mendeteksi kesalahan, akan secara otomatis dibatalkan dan semua pekerjaan yang dilakukan di dalamnya akan sia-sia. Namun, transaksi ini masih terbuka dalam keadaan "salah", tetapi dengan segala upaya untuk mengeksekusi apa pun, bahkan permintaan yang paling tidak berbahaya, hanya kesalahan yang akan dikembalikan sebagai respons:
SELECT * FROM products; ERROR: current transaction is aborted, commands ignored until end of transaction block
Yah, sama sekali tidak perlu untuk mengatakan bahwa segala sesuatu yang dimasukkan ke dalam basis data dalam transaksi ini tidak akan disimpan:
COMMIT;
Masalah Tiga: Diam
Pada titik ini, sudah jelas bahwa hanya menambahkan transaksi ke aplikasi memecahkannya. Tidak ada pilihan: Saya harus menyelami kode impor. Dalam kode, cukup sering pola-pola berikut mulai menarik perhatian saya:
def process_stuff(data)
Penulis kode di sini memberi tahu kami, "Kami mencoba, kami tidak berhasil, tapi tidak apa-apa, kami melanjutkan tanpanya." Dan meskipun alasan untuk pilihan ini bisa sangat dijelaskan (tidak semuanya dapat diproses pada tingkat aplikasi), ini yang membuat logika apa pun berdasarkan transaksi tidak mungkin: eksekusi yang dibuang tidak akan dapat mengapung ke blok transaction
, dan tidak akan menyebabkan kemunduran yang benar transaksi (ActiveRecord menangkap semua kesalahan dalam blok ini, memutar kembali transaksi dan melemparkannya lagi).
Badai sempurna
Dan inilah bagaimana ketiga faktor ini bersatu untuk menciptakan yang sempurna badai bug:
- Aplikasi dalam transaksi mencoba untuk memasukkan catatan yang saling bertentangan ke dalam database dan menyebabkan kesalahan "duplikat kunci" dari PostgreSQL. Namun, kesalahan ini tidak menyebabkan transaksi digulung kembali dalam aplikasi, karena "diam" di dalam salah satu bagian aplikasi.
- Transaksi menjadi tidak valid, tetapi aplikasi tidak mengetahuinya dan terus bekerja. Dalam setiap upaya untuk mengakses database, aplikasi kembali menerima kesalahan, kali ini "transaksi saat ini dibatalkan", tetapi kesalahan ini juga dapat dibuang ...
- Anda mungkin sudah mengerti bahwa sesuatu dalam aplikasi terus rusak, tetapi tidak ada yang akan mengetahuinya sampai eksekusi mencapai tempat pertama, di mana tidak ada
rescue
terlalu rakus dan di mana kesalahan akhirnya muncul, dicatat, terdaftar di pelacak kesalahan - apa pun. Tapi tempat ini sudah sangat jauh dari tempat yang menjadi akar penyebab kesalahan, dan ini saja akan mengubah debugging menjadi mimpi buruk.
Alternatif untuk kunci transaksional di PostgreSQL
Berburu untuk rescue
dalam kode aplikasi dan menulis ulang semua logika impor bukanlah suatu pilihan. Waktu yang lama Saya membutuhkan solusi cepat dan menemukannya di postgres! Ini memiliki solusi bawaan untuk kunci, alternatif untuk mengunci catatan dalam transaksi, kunci rapat sesi-penasehat. Saya menggunakannya sebagai berikut:
Pertama, saya menghapus transaksi pembungkus terlebih dahulu. Bagaimanapun, berinteraksi dengan API eksternal (atau "efek samping" lainnya) dari kode aplikasi dengan transaksi terbuka adalah ide yang buruk, karena bahkan jika Anda memutar kembali transaksi bersama dengan semua perubahan dalam database kami, perubahan dalam sistem eksternal akan tetap , dan aplikasi secara keseluruhan mungkin dalam keadaan aneh dan tidak diinginkan. Permata isolator dapat membantu Anda memastikan bahwa efek samping terisolasi dengan benar dari transaksi.
Kemudian, di setiap operasi impor, saya mengambil kunci bersama pada beberapa kunci unik untuk seluruh impor (misalnya, dibuat dari ID pengguna dan hash dari nama kelas operasi):
SELECT pg_advisory_lock_shared(42, user.id);
Kunci bersama pada tombol yang sama dapat diambil secara bersamaan oleh sejumlah sesi.
Pembatalan operasi impor pada saat yang sama menghapus semua entri tugas dari database dan mencoba mengambil kunci eksklusif pada kunci yang sama. Dalam hal ini, ia harus menunggu sampai semua kunci bersama dilepaskan:
SELECT pg_advisory_lock(42, user.id)
Dan itu saja! Sekarang "pembatalan" akan menunggu sampai semua "berjalan" impor barang individu selesai.
Selain itu, sekarang kita tidak terhubung dengan transaksi, kita dapat menggunakan retasan kecil untuk membatasi waktu untuk menunggu impor dibatalkan (seandainya beberapa impor "tongkat"), karena itu tidak baik untuk memblokir aliran server web untuk waktu yang lama (dan memaksa tunggu pengguna):
transaction do execute("SET LOCAL lock_timeout = '30s'") execute("SELECT pg_advisory_lock(42, user.id)") rescue ActiveRecord::LockWaitTimeout nil
Aman untuk menangkap kesalahan di luar blok transaction
, karena ActiveRecord sudah akan memutar kembali transaksi .
Tapi apa yang harus dilakukan dengan penyisipan kompetitif catatan identik?
Sayangnya, saya tidak tahu solusi yang akan bekerja dengan baik dengan sisipan kompetitif . Ada beberapa pendekatan berikut, tetapi mereka semua akan memblokir insert bersamaan sampai transaksi pertama selesai:
INSERT β¦ ON CONFLICT UPDATE
(tersedia sejak PostgreSQL 9.5) dalam transaksi kedua akan dikunci hingga transaksi pertama selesai dan kemudian akan mengembalikan catatan yang dimasukkan oleh transaksi pertama.- Kunci beberapa catatan umum dalam transaksi sebelum menjalankan validasi untuk memasukkan catatan baru. Di sini kita akan menunggu hingga catatan yang dimasukkan dalam transaksi lain terlihat dan validasi tidak dapat sepenuhnya berhasil.
- Ambil semacam kunci rekomendasi umum - efeknya sama dengan memblokir catatan umum.
Nah, jika Anda tidak takut bekerja dengan kesalahan tingkat dasar, Anda bisa menangkap kesalahan keunikan:
def import_all_the_things
Pastikan kode ini tidak lagi dibungkus dalam suatu transaksi.
Mengapa mereka diblokir?
Kendala UNIQUE dan EXCLUDE menghalangi potensi konflik dengan mencegahnya direkam pada saat yang bersamaan. Misalnya, jika Anda memiliki batasan unik pada kolom bilangan bulat dan satu transaksi menyisipkan baris dengan nilai 5, maka transaksi lain yang juga mencoba memasukkan 5 akan diblokir, tetapi transaksi yang mencoba memasukkan 6 atau 4 akan berhasil segera, tanpa pemblokiran. Karena tingkat isolasi transaksi aktual minimum PostgreSQL adalah READ COMMITED
, transaksi tidak berhak melihat perubahan yang tidak dikomit dari transaksi lain. Oleh karena itu, INSERT
dengan nilai yang bertentangan tidak dapat diterima atau ditolak sampai transaksi pertama melakukan perubahannya (kemudian yang kedua menerima kesalahan keunikan) atau memutar kembali (kemudian masukkan dalam transaksi kedua akan berhasil). Baca lebih lanjut tentang ini di sebuah artikel oleh penulis pembatasan EXCLUDE .
Mencegah bencana di masa depan
Sekarang Anda tahu bahwa tidak semua kode dapat dibungkus dalam suatu transaksi. Akan lebih baik untuk memastikan bahwa tidak ada orang lain yang membungkus kode tersebut dalam transaksi di masa depan, mengulangi kesalahan saya.
Untuk melakukan ini, Anda dapat membungkus semua operasi Anda dalam modul tambahan kecil yang akan memeriksa apakah transaksi terbuka sebelum menjalankan kode operasi terbungkus (di sini diasumsikan bahwa semua operasi Anda memiliki antarmuka yang sama - metode call
).
Sekarang, jika seseorang mencoba untuk membungkus layanan berbahaya dalam suatu transaksi, maka ia akan segera menerima kesalahan (kecuali, tentu saja, ia membiarkannya diam).
Ringkasan
Pelajaran utama yang harus dipelajari: berhati-hatilah dengan pengecualian. Jangan menangani semuanya secara berurutan, tangkap hanya pengecualian yang Anda tahu cara menangani dan biarkan sisanya masuk ke log. Jangan pernah mengabaikan pengecualian (hanya jika Anda tidak 100% yakin mengapa Anda melakukan ini). Semakin cepat suatu kesalahan diperhatikan, semakin mudah untuk melakukan debug.
Dan jangan berlebihan dengan transaksi dalam database. Ini bukan obat mujarab. Gunakan isolator permata kami dan after_commit_everywhere - mereka akan membantu transaksi Anda menjadi sangat mudah.
Apa yang harus dibaca
Ruby Luar Biasa oleh Avdi Grimm . Buku pendek ini akan mengajarkan Anda bagaimana menangani pengecualian yang ada di Ruby dan bagaimana merancang dengan baik sistem pengecualian untuk aplikasi Anda.
Menggunakan Transaksi Atom untuk Memberi API Idempoten oleh @Brandur. Blognya memiliki banyak artikel bermanfaat tentang keandalan aplikasi, Ruby, dan PostgreSQL.