Terakhir kali kami berbicara tentang konsistensi data, melihat perbedaan antara tingkat isolasi transaksi dari sudut pandang pengguna dan mencari tahu mengapa ini penting untuk diketahui. Sekarang kita mulai mengeksplorasi bagaimana PostgreSQL mengimplementasikan isolasi snapshot dan konkurensi multiversion.
Pada artikel ini, kita akan melihat bagaimana data secara fisik diletakkan dalam file dan halaman. Ini membuat kita menjauh dari membahas isolasi, tetapi penyimpangan seperti itu diperlukan untuk memahami apa yang terjadi selanjutnya. Kita perlu mencari tahu bagaimana penyimpanan data diatur pada tingkat rendah.
Hubungan
Jika Anda melihat di dalam tabel dan indeks, ternyata mereka disusun dengan cara yang sama. Keduanya adalah objek basis data yang berisi beberapa data yang terdiri dari baris.
Tidak ada keraguan bahwa tabel terdiri dari baris, tetapi ini kurang jelas untuk indeks. Namun, bayangkan B-tree: terdiri dari node yang berisi nilai yang diindeks dan referensi ke node atau baris tabel lainnya. Ini node yang dapat dianggap sebagai baris indeks, dan pada kenyataannya, mereka.
Sebenarnya, beberapa objek diatur dengan cara yang sama: urutan (pada dasarnya tabel baris tunggal) dan tampilan terwujud (pada dasarnya, tabel yang mengingat kueri). Dan ada juga tampilan reguler, yang tidak menyimpan data sendiri, tetapi dalam semua hal lain mirip dengan tabel.
Semua objek di PostgreSQL ini disebut
relasi kata umum. Kata ini sangat tidak tepat karena merupakan istilah dari teori relasional. Anda bisa menggambar paralel antara relasi dan tabel (tampilan), tetapi tentu saja tidak antara relasi dan indeks. Tapi itu terjadi begitu saja: asal mula akademik PostgreSQL memanifestasikan dirinya. Sepertinya saya bahwa itu adalah tabel dan pandangan yang disebut begitu pertama, dan sisanya membengkak seiring waktu.
Untuk lebih sederhana, kita akan membahas lebih lanjut tabel dan indeks, tetapi
hubungan lainnya diatur dengan cara yang sama persis.
Garpu dan file
Biasanya beberapa
fork berhubungan dengan masing-masing relasi. Garpu dapat memiliki beberapa jenis, dan masing-masingnya berisi jenis data tertentu.
Jika ada garpu, itu pertama diwakili oleh satu-satunya
file . Nama file adalah pengenal angka, yang dapat ditambahkan dengan akhiran yang sesuai dengan nama fork.
File secara bertahap tumbuh dan ketika ukurannya mencapai 1 GB, file baru dari garpu yang sama dibuat (file seperti ini kadang-kadang disebut
segmen ). Nomor urut segmen ditambahkan pada akhir nama file.
Batasan ukuran file 1 GB muncul secara historis untuk mendukung sistem file yang berbeda, beberapa di antaranya tidak dapat menangani file dengan ukuran lebih besar. Anda dapat mengubah batasan ini ketika membangun PostgreSQL (
./configure --with-segsize
).
Jadi, beberapa file pada disk dapat berhubungan dengan satu relasi. Misalnya, untuk meja kecil akan ada tiga dari mereka.
Semua file objek milik satu tablespace dan satu database akan disimpan dalam satu direktori. Anda perlu mengingat ini karena sistem file biasanya gagal berfungsi dengan baik dengan sejumlah besar file dalam direktori.
Perhatikan di sini bahwa file, pada gilirannya, dibagi menjadi beberapa
halaman (atau
blok ), biasanya sebesar 8 KB. Kami akan membahas struktur internal halaman sedikit lebih jauh.

Sekarang mari kita lihat jenis garpu.
Garpu utama adalah data itu sendiri: baris tabel dan indeks yang paling. Garpu utama tersedia untuk semua hubungan (kecuali tampilan yang tidak mengandung data).
Nama-nama file dari garpu utama terdiri dari satu-satunya pengidentifikasi numerik. Misalnya, ini adalah jalur ke tabel yang kami buat terakhir kali:
=> SELECT pg_relation_filepath('accounts');
pg_relation_filepath ---------------------- base/41493/41496 (1 row)
Dari mana pengidentifikasi ini muncul? Direktori "base" berhubungan dengan tablespace "pg_default". Subdirektori berikutnya, yang terkait dengan basis data, adalah lokasi file yang menarik:
=> SELECT oid FROM pg_database WHERE datname = 'test';
oid ------- 41493 (1 row)
=> SELECT relfilenode FROM pg_class WHERE relname = 'accounts';
relfilenode ------------- 41496 (1 row)
Path relatif, ditentukan mulai dari direktori data (PGDATA). Selain itu, hampir semua jalur di PostgreSQL ditentukan mulai dari PGDATA. Berkat ini, Anda dapat dengan aman memindahkan PGDATA ke lokasi yang berbeda - tidak ada yang membatasi (kecuali untuk itu mungkin diperlukan untuk mengatur jalur ke perpustakaan di LD_LIBRARY_PATH).
Selanjutnya, melihat ke sistem file:
postgres$ ls -l --time-style=+ /var/lib/postgresql/11/main/base/41493/41496
-rw------- 1 postgres postgres 8192 /var/lib/postgresql/11/main/base/41493/41496
Garpu inisialisasi hanya tersedia untuk tabel yang tidak di-log (dibuat dengan UNLOGGED yang ditentukan) dan indeksnya. Objek seperti ini tidak jauh berbeda dari objek biasa kecuali bahwa operasi dengan mereka tidak dicatat dalam log tulis-depan (WAL). Karena itu, lebih cepat untuk bekerja dengan mereka, tetapi tidak mungkin untuk memulihkan data dalam keadaan konsisten jika terjadi kegagalan. Oleh karena itu, selama pemulihan PostgreSQL hanya menghapus semua garpu dari objek tersebut dan menulis garpu inisialisasi sebagai pengganti garpu utama. Ini menghasilkan objek kosong. Kami akan membahas login secara detail, tetapi di seri lain.
Tabel "akun" dicatat, dan karena itu, tidak memiliki garpu inisialisasi. Tetapi untuk bereksperimen, kami dapat mematikan pencatatan:
=> ALTER TABLE accounts SET UNLOGGED; => SELECT pg_relation_filepath('accounts');
pg_relation_filepath ---------------------- base/41493/41507 (1 row)
Contoh tersebut mengklarifikasi bahwa kemungkinan untuk menghidupkan dan mematikan logging dengan cepat dikaitkan dengan menulis ulang data ke file dengan nama yang berbeda.
Garpu inisialisasi memiliki nama yang sama dengan garpu utama, tetapi dengan akhiran "_init":
postgres$ ls -l --time-style=+ /var/lib/postgresql/11/main/base/41493/41507_init
-rw------- 1 postgres postgres 0 /var/lib/postgresql/11/main/base/41493/41507_init
Peta ruang bebas adalah garpu yang melacak ketersediaan ruang kosong di dalam halaman. Ruang ini terus berubah: berkurang ketika versi baris baru ditambahkan dan meningkat selama menyedot debu. Peta ruang bebas digunakan selama penyisipan versi baris baru untuk dengan cepat menemukan halaman yang cocok, di mana data yang akan ditambahkan akan cocok.
Nama peta ruang bebas memiliki akhiran "_fsm". Tetapi file ini muncul tidak dengan segera, tetapi hanya jika diperlukan. Cara termudah untuk mencapai ini adalah dengan menyedot debu (kami akan menjelaskan mengapa ketika saatnya tiba):
=> VACUUM accounts;
postgres$ ls -l --time-style=+ /var/lib/postgresql/11/main/base/41493/41507_fsm
-rw------- 1 postgres postgres 24576 /var/lib/postgresql/11/main/base/41493/41507_fsm
Peta visibilitas adalah garpu di mana halaman yang hanya berisi versi baris terbaru ditandai dengan satu bit. Secara kasar, ini berarti bahwa ketika suatu transaksi mencoba membaca suatu baris dari halaman seperti itu, baris tersebut dapat ditampilkan tanpa memeriksa visibilitasnya. Dalam artikel selanjutnya, kita akan membahas secara rinci bagaimana ini terjadi.
postgres$ ls -l --time-style=+ /var/lib/postgresql/11/main/base/41493/41507_vm
-rw------- 1 postgres postgres 8192 /var/lib/postgresql/11/main/base/41493/41507_vm
Halaman
Seperti yang telah disebutkan, file secara logis dibagi menjadi beberapa halaman.
Halaman biasanya memiliki ukuran 8 KB. Ukurannya dapat diubah dalam batas-batas tertentu (16 KB atau 32 KB), tetapi hanya selama build (
./configure --with-blocksize
). Sebuah instance yang dibangun dan dijalankan hanya dapat bekerja dengan halaman dengan ukuran yang sama.
Terlepas dari garpu tempat file berada, server menggunakannya dengan cara yang sangat mirip. Halaman pertama kali dibaca ke dalam cache buffer, tempat proses dapat membaca dan mengubahnya; kemudian ketika kebutuhan muncul, mereka diusir kembali ke disk.
Setiap halaman memiliki partisi internal dan secara umum berisi partisi berikut:
0 + ----------------------------------- +
| tajuk |
24 + ----------------------------------- +
| array pointer ke versi baris |
lebih rendah + ----------------------------------- +
| ruang kosong |
atas + ----------------------------------- +
| versi baris |
spesial + ----------------------------------- +
| ruang khusus |
pagesize + ----------------------------------- +
Anda dapat dengan mudah mengetahui ukuran partisi ini menggunakan halaman ekstensi "riset", menginspeksi:
=> CREATE EXTENSION pageinspect; => SELECT lower, upper, special, pagesize FROM page_header(get_raw_page('accounts',0));
lower | upper | special | pagesize -------+-------+---------+---------- 40 | 8016 | 8192 | 8192 (1 row)
Di sini kita melihat
header halaman pertama (nol) dari tabel. Selain ukuran area lain, tajuk memiliki informasi berbeda tentang halaman, yang belum kami minati.
Di bagian bawah halaman ada
ruang khusus , yang kosong dalam hal ini. Ini hanya digunakan untuk indeks, dan bahkan tidak untuk semuanya. "Di bagian bawah" di sini mencerminkan apa yang ada dalam gambar; mungkin lebih akurat untuk mengatakan "di alamat tinggi".
Setelah ruang khusus,
versi baris ditemukan, yaitu data yang kami simpan di dalam tabel plus beberapa informasi internal.
Di bagian atas halaman, tepat setelah header, ada daftar isi:
array pointer ke versi baris yang tersedia di halaman.
Ruang kosong dapat dibiarkan antara versi baris dan pointer (ruang bebas ini disimpan dalam peta ruang bebas). Perhatikan bahwa tidak ada fragmentasi memori di dalam halaman - semua ruang kosong diwakili oleh satu area yang berdekatan.
Pointer
Mengapa pointer ke versi baris dibutuhkan? Masalahnya adalah bahwa indeks harus entah bagaimana mereferensikan versi baris dalam tabel. Jelas bahwa referensi harus berisi nomor file, jumlah halaman dalam file dan beberapa indikasi versi baris. Kita bisa menggunakan offset dari awal halaman sebagai indikator, tetapi tidak nyaman. Kami tidak akan dapat memindahkan versi baris di dalam halaman karena akan merusak referensi yang tersedia. Dan ini akan mengakibatkan fragmentasi ruang di dalam halaman dan konsekuensi merepotkan lainnya. Oleh karena itu, indeks referensi nomor penunjuk, dan penunjuk referensi lokasi saat ini dari versi baris di halaman. Dan ini adalah penanganan tidak langsung.
Setiap pointer menempati tepat empat byte dan mengandung:
- referensi ke versi baris
- ukuran versi baris ini
- beberapa byte untuk menentukan status versi baris
Format data
Format data pada disk persis sama dengan representasi data dalam RAM. Halaman dibaca ke dalam cache buffer "apa adanya", tanpa konversi apa pun. Oleh karena itu, file data dari satu platform ternyata tidak kompatibel dengan platform lain.
Misalnya, dalam arsitektur X86, urutan byte dari paling signifikan ke byte paling signifikan (little-endian), z / Arsitektur menggunakan urutan terbalik (big-endian), dan dalam ARM urutan dapat ditukar.
Banyak arsitektur menyediakan penyelarasan data pada batas-batas kata-kata mesin. Misalnya, pada sistem x86 32-bit, angka integer (tipe "integer", yang menempati 4 byte) akan disejajarkan pada batas kata 4-byte, sama seperti angka presisi ganda (ketik "presisi ganda" , yang menempati 8 byte). Dan pada sistem 64-bit, angka presisi ganda akan disejajarkan pada batas kata 8-byte. Ini adalah satu lagi alasan ketidakcocokan.
Karena penyelarasan, ukuran baris tabel tergantung pada urutan bidang. Biasanya efek ini tidak terlalu terlihat, tetapi kadang-kadang, ini dapat menghasilkan pertumbuhan ukuran yang signifikan. Misalnya, jika bidang tipe "char (1)" dan "integer" disisipkan, biasanya 3 byte di antaranya akan sia-sia. Untuk detail lebih lanjut tentang ini, Anda dapat melihat presentasi Nikolay Shaplov "
Tuple internals ".
Versi baris dan TOAST
Kami akan membahas detail struktur internal versi baris lain kali. Pada titik ini, penting bagi kita untuk mengetahui bahwa setiap versi harus sepenuhnya sesuai dengan satu halaman: PostgreSQL tidak memiliki cara untuk "memperluas" baris ke halaman berikutnya. Teknik Oributized Attributes Storage (TOAST) digunakan sebagai gantinya. Nama itu sendiri mengisyaratkan bahwa satu baris dapat diiris menjadi roti panggang.
Bercanda terpisah, TOAST menyiratkan beberapa strategi. Kita bisa mengirimkan nilai atribut panjang ke tabel internal yang terpisah setelah memecahnya menjadi potongan roti kecil. Pilihan lain adalah untuk mengompres nilai sehingga versi baris tidak cocok dengan halaman biasa. Dan kita bisa melakukan keduanya: kompres pertama dan kemudian putus dan mengirimkan.
Untuk setiap tabel utama, tabel TOAST terpisah dapat dibuat jika diperlukan, satu untuk semua atribut (bersama dengan indeks di atasnya). Ketersediaan atribut yang berpotensi panjang menentukan kebutuhan ini. Misalnya, jika tabel memiliki kolom tipe "numerik" atau "teks", tabel TOAST akan segera dibuat bahkan jika nilai yang panjang tidak akan digunakan.
Karena tabel TOAST pada dasarnya adalah tabel biasa, ia memiliki set garpu yang sama. Dan ini menggandakan jumlah file yang sesuai dengan tabel.
Strategi awal ditentukan oleh tipe data kolom. Anda dapat melihatnya menggunakan perintah
\d+
di psql, tetapi karena itu juga menampilkan banyak informasi lain, kami akan meminta katalog sistem:
=> SELECT attname, atttypid::regtype, CASE attstorage WHEN 'p' THEN 'plain' WHEN 'e' THEN 'external' WHEN 'm' THEN 'main' WHEN 'x' THEN 'extended' END AS storage FROM pg_attribute WHERE attrelid = 'accounts'::regclass AND attnum > 0;
attname | atttypid | storage ---------+----------+---------- id | integer | plain number | text | extended client | text | extended amount | numeric | main (4 rows)
Nama-nama strategi berarti:
- plain - TOAST tidak digunakan (digunakan untuk tipe data yang dikenal pendek, seperti "integer").
- diperpanjang - baik kompresi dan penyimpanan dalam tabel TOAST terpisah diizinkan
- nilai eksternal - panjang disimpan dalam tabel TOAST tanpa kompresi.
- nilai - nilai main - long pertama kali dikompresi dan hanya masuk ke tabel TOAST jika kompresi tidak membantu.
Secara umum, algoritma adalah sebagai berikut. PostgreSQL bertujuan untuk memiliki setidaknya empat baris yang cocok untuk satu halaman. Oleh karena itu, jika ukuran baris melebihi satu halaman, header dipertimbangkan (2040 bytes untuk halaman 8K biasa), TOAST harus diterapkan ke bagian nilai. Kami mengikuti pesanan yang dijelaskan di bawah ini dan berhenti segera setelah baris tidak lagi melebihi ambang batas:
- Pertama kita membahas atribut dengan strategi "eksternal" dan "diperpanjang" dari atribut terpanjang ke yang terpendek. Atribut βExtendedβ dikompresi (jika itu efektif) dan jika nilainya sendiri melebihi satu dari halaman, ia langsung masuk ke tabel TOAST. Atribut "Eksternal" diproses dengan cara yang sama, tetapi tidak dikompresi.
- Jika setelah lulus pertama, versi baris belum sesuai dengan halaman, kami mengirimkan atribut yang tersisa dengan strategi "eksternal" dan "diperpanjang" ke tabel TOAST.
- Jika ini juga tidak membantu, kami mencoba mengompresi atribut dengan strategi "utama", tetapi membiarkannya di halaman tabel.
- Dan hanya jika setelah itu, barisnya tidak cukup pendek, atribut "utama" masuk ke tabel TOAST.
Terkadang mungkin berguna untuk mengubah strategi untuk kolom tertentu. Misalnya, jika diketahui sebelumnya bahwa data dalam kolom tidak dapat dikompresi, kita dapat mengatur strategi "eksternal" untuknya, yang memungkinkan kita menghemat waktu dengan menghindari upaya kompresi yang tidak berguna. Ini dilakukan sebagai berikut:
=> ALTER TABLE accounts ALTER COLUMN number SET STORAGE external;
Menjalankan kembali kueri, kita mendapatkan:
attname | atttypid | storage ---------+----------+---------- id | integer | plain number | text | external client | text | extended amount | numeric | main
Tabel dan indeks TOAST terletak di skema pg_toast terpisah dan, oleh karena itu, biasanya tidak terlihat. Untuk tabel sementara, skema "pg_toast_temp_
N " digunakan mirip dengan "pg_temp_
N " yang biasa.
Tentu saja, jika Anda suka tidak seorang pun akan menghalangi Anda memata-matai mekanisme internal proses. Katakanlah, dalam tabel "akun" ada tiga atribut yang berpotensi panjang, dan oleh karena itu, harus ada tabel TOAST. Ini dia:
=> SELECT relnamespace::regnamespace, relname FROM pg_class WHERE oid = ( SELECT reltoastrelid FROM pg_class WHERE relname = 'accounts' );
relnamespace | relname --------------+---------------- pg_toast | pg_toast_33953 (1 row)
=> \d+ pg_toast.pg_toast_33953
TOAST table "pg_toast.pg_toast_33953" Column | Type | Storage ------------+---------+--------- chunk_id | oid | plain chunk_seq | integer | plain chunk_data | bytea | plain
Masuk akal bahwa strategi "biasa" diterapkan pada roti panggang yang digunakan untuk memotong baris: tidak ada TOAST tingkat kedua.
PostgreSQL menyembunyikan indeks dengan lebih baik, tetapi tidak sulit untuk menemukan:
=> SELECT indexrelid::regclass FROM pg_index WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'pg_toast_33953' );
indexrelid ------------------------------- pg_toast.pg_toast_33953_index (1 row)
=> \d pg_toast.pg_toast_33953_index
Unlogged index "pg_toast.pg_toast_33953_index" Column | Type | Key? | Definition -----------+---------+------+------------ chunk_id | oid | yes | chunk_id chunk_seq | integer | yes | chunk_seq primary key, btree, for table "pg_toast.pg_toast_33953"
Kolom "klien" menggunakan strategi "extended": nilainya akan dikompresi. Mari kita periksa:
=> UPDATE accounts SET client = repeat('A',3000) WHERE id = 1; => SELECT * FROM pg_toast.pg_toast_33953;
chunk_id | chunk_seq | chunk_data ----------+-----------+------------ (0 rows)
Tidak ada dalam tabel TOAST: karakter berulang dikompresi dengan baik dan setelah kompresi nilainya cocok dengan halaman tabel biasa.
Dan sekarang biarkan nama klien terdiri dari karakter acak:
=> UPDATE accounts SET client = ( SELECT string_agg( chr(trunc(65+random()*26)::integer), '') FROM generate_series(1,3000) ) WHERE id = 1 RETURNING left(client,10) || '...' || right(client,10);
?column? ------------------------- TCKGKZZSLI...RHQIOLWRRX (1 row)
Urutan seperti itu tidak dapat dikompresi, dan masuk ke tabel TOAST:
=> SELECT chunk_id, chunk_seq, length(chunk_data), left(encode(chunk_data,'escape')::text, 10) || '...' || right(encode(chunk_data,'escape')::text, 10) FROM pg_toast.pg_toast_33953;
chunk_id | chunk_seq | length | ?column? ----------+-----------+--------+------------------------- 34000 | 0 | 2000 | TCKGKZZSLI...ZIPFLOXDIW 34000 | 1 | 1000 | DDXNNBQQYH...RHQIOLWRRX (2 rows)
Kita dapat melihat bahwa data dipecah menjadi potongan 2000-byte.
Ketika nilai panjang diakses, PostgreSQL secara otomatis dan transparan untuk aplikasi mengembalikan nilai asli dan mengembalikannya ke klien.
Tentu saja, cukup padat sumber daya untuk memampatkan dan memecah dan kemudian mengembalikan. Oleh karena itu, untuk menyimpan data masif di PostgreSQL bukan ide terbaik, terutama jika mereka sering digunakan dan penggunaannya tidak memerlukan logika transaksional (misalnya: pemindaian dokumen akuntansi asli). Alternatif yang lebih menguntungkan adalah menyimpan data tersebut pada sistem file dengan nama file yang disimpan dalam DBMS.
Tabel TOAST hanya digunakan untuk mengakses nilai yang panjang. Selain itu, konkurensi mutiversinya sendiri didukung untuk tabel TOAST: kecuali jika pembaruan data menyentuh nilai lama, versi baris baru akan merujuk nilai yang sama di tabel TOAST, dan ini menghemat ruang.
Perhatikan bahwa TOAST hanya berfungsi untuk tabel, tetapi tidak untuk indeks. Ini memberlakukan batasan pada ukuran kunci yang akan diindeks.
Untuk detail lebih lanjut dari struktur data internal, Anda dapat membaca dokumentasi .
Baca terus .