PostgreSQL 18 Yenilikleri: Asynchronous I/O, Skip Scan, uuidv7, Virtual Generated Column, RETURNING OLD/NEW ve Temporal Constraint'ler
PostgreSQL 18'de generated column varsayılan olarak virtual: bir milyon satırlık tabloya kolon eklemek 17'deki gibi tabloyu yeniden yazmıyor, ama index de konamıyor. io_method ile asynchronous I/O, skip scan, BUFFERS'ın varsayılan olduğu EXPLAIN, uuidv7(), RETURNING OLD/NEW, WITHOUT OVERLAPS, varsayılan data checksum, OAuth ve logical replication değişiklikleri; her biri 17'deki karşılığıyla.
Sürüm notu: Bu yazıdaki örnekler PostgreSQL 18.7 (resmî postgres:18 Docker image'ı) üzerinde, 02'de kurduğumuz Bookstore lab'ında, şema yeniden yüklendikten hemen sonra çalıştırılmıştır; "17'de nasıl" karşılaştırmaları aynı şemanın yüklendiği PostgreSQL 17.12 (postgres:17-alpine) üzerinde yapılmıştır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) fatura ekranında KDV (katma değer vergisi) dahil tutarı göstermek istiyor ve Emre bunu bir milyon satırlık orders tablosuna generated column olarak ekliyor. Aynı komutu 17'de ve 18'de çalıştırıyoruz, sonra 18'de bu kolona bir index ekliyoruz:
ALTER TABLE orders ADD COLUMN total_with_vat numeric(12,2)
GENERATED ALWAYS AS (round(total_amount * 1.20, 2));
CREATE INDEX ix_orders_total_with_vat ON orders (total_with_vat);
17'de bu komut tabloyu yeniden yazar mı, kaç saniye sürer? 18'de aynı satır ne yapar ve index oluşur mu?
/* 17.12 */
ERROR: syntax error at or near ";"
/* 18.7 */
ALTER TABLE
Time: 3.946 ms
ERROR: indexes on virtual generated columns are not supported
17'de STORED yazmadan generated column tanımlanamıyor; syntax hatası. 18'de aynı satır 4 milisaniyede bitti, çünkü artık varsayılan virtual: değer diske yazılmıyor, okunurken hesaplanıyor. Bedeli de üçüncü satırda: virtual kolona index konamıyor. Bu yazıda serinin tabanı olan PostgreSQL 18'in yeniliklerini topluca ele alıyoruz. Bir kısmını önceki yazılarda kullandık; burada her biri için "17'de bunu nasıl yapıyorduk" karşılaştırması ve Bookstore'da küçük bir deneme var.
Asynchronous I/O: io_method
18'e kadar her backend bir sayfayı okumak için işletim sistemine istek gönderip cevabı bekliyordu; read-ahead'i işletim sistemi tahmin ediyordu. 18'in AIO (asynchronous I/O; okuma isteklerini cevabını beklemeden sıraya koyma) altyapısı, sequential scan, bitmap heap scan ve VACUUM (dead tuple'ların yerini yeniden kullanıma açan bakım komutu, 08) gibi işlemlerin birden çok okuma isteğini önceden göndermesini sağlıyor. Yöntem io_method ile seçiliyor ve değişikliği restart uyguluyor:
name | setting | enumvals
--------------------------+---------+------------------------
effective_io_concurrency | 16 |
io_combine_limit | 16 |
io_max_concurrency | 64 |
io_method | worker | {sync,worker,io_uring}
io_workers | 3 |
Varsayılan worker: okumaları üç arka plan process'i yapıyor. sync 17'deki davranış. io_uring Linux çekirdeğinin asenkron I/O arayüzünü kullanıyor ama Docker container'ında, varsayılan güvenlik ayarlarıyla açılmadı: FATAL: could not setup io_uring queue: Operation not permitted. 124 MB'lık order_items'ı paralel sorgu kapalıyken, shared buffers'ı her seferinde boşaltarak (pg_buffercache_evict_relation, 18) okuduk:
17.12 shared read=15927 67-69 ms
18.7 io_method=sync shared read=15927 71-75 ms
18.7 io_method=worker shared read=15927 61-62 ms
17'de bu fonksiyon olmadığı için shared buffers'ı container'ı yeniden başlatarak boşalttık. 18'de worker'ın sync'e göre kazancı yaklaşık yüzde on beş; dosyalar işletim sisteminin page cache'inde olduğu için her okuma zaten çok hızlı. AIO'nun asıl farkı, her okumanın diske gidip milisaniyeler beklediği yerde, örneğin bulut disklerinde görülür; bunu lab'da ölçemedik. Pratikte worker varsayılanı kalsın; yoğun I/O yapan sistemde io_workers'ı artırmak ve pg_aios view'ı ile işlemdeki istekleri izlemek mümkün.
Skip Scan ve EXPLAIN'deki Değişiklikler
06'da gördüğümüz skip scan, çok kolonlu bir B-tree index'te baştaki kolon sorguda yokken de index'i kullanabiliyor: (status, created_at) index'inde yalnızca created_at ile arama 18'de 26 buffer okudu, 17.11'de Seq Scan ile 10.328. 17'de bunun için ya ikinci bir index (created_at tek başına) ya da sorguya status = ANY(...) ile bütün değerleri eklemek gerekiyordu. Baştaki kolonun az sayıda farklı değeri olduğunda işe yarıyor; EXPLAIN (planner'ın seçtiği sorgu planını gösteren komut, 07) çıktısındaki Index Searches satırı kaç kez atlandığını gösteriyor.
EXPLAIN'in kendisi de değişti. Aynı sorgu iki sürümde:
/* 17.12: EXPLAIN (ANALYZE, TIMING OFF, COSTS OFF) */
Aggregate (actual rows=1 loops=1)
-> Seq Scan on books (actual rows=2000 loops=1)
/* 18.7: aynı komut */
Aggregate (actual rows=1.00 loops=1)
Buffers: shared read=444 dirtied=2
-> Seq Scan on books (actual rows=2000.00 loops=1)
Buffers: shared read=444 dirtied=2
18'de ANALYZE ile BUFFERS kendiliğinden geliyor (07'de bunu kullandık) ve satır sayıları ondalıklı: döngülerin ortalaması 0,5 gibi bir değer olduğunda artık 0'a yuvarlanmıyor. 17'de eski planları okurken BUFFERS'ı elle istemen gerektiğini unutma.
uuidv7(): Zamana Göre Sıralı UUID
03'te v4 ile v7'nin index yoğunluğunu karşılaştırmıştık (yüzde 71–72'ye karşı yüzde 90). 18 bunu çekirdekte üretiyor:
SELECT uuidv7() AS id, uuid_extract_timestamp(uuidv7()) AS ts, uuid_extract_version(uuidv7()) AS v;
id | ts | v
--------------------------------------+----------------------------+---
01a1d815-4d12-7614-9798-5df9bc9322fc | 2026-11-14 03:23:57.074+00 | 7
17'de function uuidv7() does not exist. O sürümde v7'yi uygulama üretiyordu: .NET 9'dan beri Guid.CreateVersion7() var ve 12'de gördüğümüz gibi Npgsql'in Entity Framework Core (EF Core) provider'ı primary key'ler için zaten istemcide v7 üretiyor. Sunucuda üretmenin avantajı, değeri hangi istemci yazarsa yazsın aynı kuralın uygulanması: DEFAULT uuidv7() ya da EF Core'da HasDefaultValueSql("uuidv7()"). Bir uyarı: v7'nin ilk 48 biti oluşturulma zamanı, yani ID'yi gören kayıt zamanını da görür.
Virtual Generated Column
Tahmindeki kolonun planı, değerin okurken hesaplandığını gösteriyor:
Index Scan using orders_pkey on public.orders
Output: round((total_amount * 1.20), 2)
Index Cond: (orders.order_id = 1)
17'de tek seçenek STORED idi. Aynı kolonu STORED ile eklemek iki sürümde de tabloyu yeniden yazdı: 17'de 739 ms, 18'de 803 ms; 17'de tablo 81 MB'tan 89 MB'a çıktı ve bu sürede tablo ACCESS EXCLUSIVE lock altındaydı. Virtual kolon ise yalnızca katalogu değiştiriyor. Hangisini seçeceğin kolonun kullanımına bağlı: sık okunan, pahalı hesaplanan ya da index gereken bir değer için STORED (10'daki search_vector bu yüzden stored), basit ve seyrek okunan bir türetme için virtual. Elle yazdığın migration script'lerinde STORED'u açıkça yaz; yazmazsan 18'de virtual kolon oluşur.
Virtual kolonun bir kısıtı daha var: ifadesi yalnızca yerleşik function ve tipleri kullanabiliyor; kendi yazdığın bir function, bir extension function'ı (ör. unaccent) ya da enum gibi kullanıcı tanımlı bir tip kullanılamıyor. 10'daki turkish_unaccent configuration'ını kullanan search_vector bu yüzden de virtual olamazdı. Stored kolonlarda bu kısıt yok.
RETURNING OLD/NEW
Fiyat değişikliğini log'lamak için hem eski hem yeni değeri tek komutla almak:
UPDATE books SET price = round(price * 1.05, 2)
WHERE book_id IN (1, 2, 3)
RETURNING book_id, old.price AS old_price, new.price AS new_price;
book_id | old_price | new_price
---------+-----------+-----------
1 | 32.77 | 34.41
2 | 39.00 | 40.95
3 | 35.69 | 37.47
17'de bu satır missing FROM-clause entry for table "old" veriyor. O sürümdeki karşılığı, eski değerleri FOR UPDATE ile kilitleyip okuyan bir CTE (Common Table Expression; sorgunun başında WITH ile tanımlanan geçici sonuç kümesi):
WITH before AS (
SELECT book_id, price FROM books WHERE book_id IN (1, 2, 3) FOR UPDATE
)
UPDATE books b SET price = round(b.price * 1.05, 2)
FROM before
WHERE b.book_id = before.book_id
RETURNING b.book_id, before.price AS old_price, b.price AS new_price;
Sonuç aynı, ama satırları iki kez okuyor ve FOR UPDATE'i unutursan araya giren bir güncelleme yanlış eski değer döndürür. OLD/NEW, INSERT ... ON CONFLICT DO UPDATE'te de çalışıyor: 04'te gördüğümüz upsert'te satırın eklendiğini mi güncellendiğini mi old.book_id IS NULL ile anlayabilirsin.
Temporal Constraint'ler: WITHOUT OVERLAPS ve PERIOD
04'te kitap fiyat dönemlerini PRIMARY KEY (book_id, valid_during WITHOUT OVERLAPS) ile tanımlamış ve kampanyaları FOREIGN KEY ... PERIOD ile bu dönemlere bağlamıştık. 17'de aynı tablo syntax error at or near "WITHOUT" veriyor. O sürümdeki yol btree_gist extension'ı (13) ile bir EXCLUDE USING gist (book_id WITH =, valid_during WITH &&) constraint'iydi; çakışmayı aynı şekilde engelliyor, ama bir primary key değil, yani foreign key onu hedef alamıyor. Temporal foreign key'in 17'de doğrudan karşılığı yok; trigger ile yazılırdı. 18'de bile temporal key'ler için btree_gist gerekiyor, çünkü book_id'nin = karşılaştırması bir GiST index'inde yapılıyor.
Operasyon: Checksum, İstatistik, OAuth, md5 ve Logical Replication
Data checksum'ları: 18'in initdb'si onları varsayılan olarak açıyor; lab'da SHOW data_checksums on, aynı image ailesinin 17'sinde off. Bir sayfanın diskte bozulması okunurken fark ediliyor. 14'te gördüğümüz gibi 17'den pg_upgrade ile gelirken yeni cluster'ı --no-data-checksums ile oluşturmak gerekiyor. Aynı yazıda gördüğümüz diğer yenilik: pg_upgrade planner istatistiklerini taşıyor, extended statistics hariç.
OAuth: 18, pg_hba.conf'a oauth yöntemini ekledi; kullanıcı bir kimlik sağlayıcısından aldığı token ile bağlanıyor. Sunucu token'ı kendisi doğrulamıyor, bunun için kimlik sağlayıcısına özel bir validator module (oauth_validator_libraries) gerekiyor ve PostgreSQL bunu hazır vermiyor. İstemci tarafında libpq destekliyor; Npgsql'de ise bu yazının yazıldığı gün GitHub'da açık bir issue olarak duruyor. Yani .NET uygulaması için bugün kullanılabilir değil; managed servislerin kendi token tabanlı girişleri (Microsoft Entra ID gibi) Npgsql'in periodic password provider'ı ile çalışıyor.
md5: 14'te gördüğümüz gibi 18'de deprecated; md5 parola atamak uyarı veriyor. Metin tarafında casefold() ve PG_UNICODE_FAST builtin collation'ı 03'te ele aldık; büyük/küçük harfe duyarsız karşılaştırmada lower()'dan daha doğru sonuç veriyor. Autovacuum'a gelen değişiklikleri (autovacuum_vacuum_max_threshold, eager freezing) 08'de gördük.
Logical replication tarafında iki değişiklik var: stored generated column'ların değerleri artık replike edilebiliyor (publish_generated_columns = stored); bu özellikle değeri kendisi hesaplayamayan, PostgreSQL olmayan hedefler için önemli. CREATE SUBSCRIPTION'ın streaming varsayılanı da off'tan parallel'e geçti: büyük transaction'lar commit'ten önce akıtılıp paralel uygulanıyor. Ayrıntılar Veritabanı Performansı ve Scaling serisinin replication yazısında.
Üretimde Dikkat
18'e upgrade ettikten sonra şema değişikliği yapan script'lerde GENERATED ALWAYS AS (...) satırlarını kontrol et: STORED yazmayan yeni bir kolon artık virtual olur, index konamaz ve sorgu her okumada hesaplar.
io_method'u ölçmeden değiştirme. worker varsayılanı çoğu sistem için iyi; io_uring hem çekirdeğin hem container güvenlik profilinin izin vermesini istiyor ve açılmazsa sunucu başlamıyor.
Upgrade sonrası kısa bir kontrol listesi: SHOW data_checksums beklediğin değerde mi, pg_stats_ext'teki extended statistics yeniden toplandı mı, md5 parolası kalan role var mı, ve pg_hba.conf'ta md5 yöntemi hâlâ kullanılıyor mu. Bunlar 14. yazıdaki sorgularla birkaç dakikada kontrol edilir.
Yeni sürümün varsayılanlarını izleme araçlarınla da dene: EXPLAIN çıktısındaki ondalıklı satır sayıları ve yeni BUFFERS satırları, planları parse eden araçları etkileyebilir.
Sık Düşülen Tuzaklar
Virtual generated column'ı "ücretsiz" sanmak: tahmindeki gibi index konamıyor ve her okumada hesaplanıyor; WHERE'de sık kullanılacaksa ya STORED yap ya da aynı ifadeyle bir expression index tanımla.
uuidv7'yi gizli bir kimlik sanmak: oluşturulma zamanını taşıyor. Dışarıya açılan ve zamanı sızdırmaması gereken ID'ler için v4 kullan.
17'den upgrade ederken checksum farkını pg_upgrade anında öğrenmek: bakım penceresinde --check hatası almamak için yeni cluster'ı nasıl oluşturacağını önceden kararlaştır.
Küçük Bir Deneme
★☆☆ ISBN (kitapların uluslararası standart numarası) üzerinden INSERT ... ON CONFLICT (isbn) DO UPDATE ... RETURNING old.price, new.price, (old.book_id IS NULL) AS inserted ile hem yeni hem var olan bir numara dene. inserted ne döndü?
★★☆ total_with_vat'ı virtual olarak ekle, WHERE total_with_vat > 500 sorgusunun planına bak. Aynı ifadeyle bir expression index oluşturup sorgu planının değişip değişmediğini kontrol et.
★★★ io_method'un sync ve worker değerleriyle VACUUM (VERBOSE) çıktısındaki okuma hızlarını order_items üzerinde karşılaştır. Sonucu page cache'in etkisiyle birlikte yorumla.
Bir sonraki yazı serinin son yazısı: PostgreSQL 19'un release candidate'i üzerinde REPACK (CONCURRENTLY), sequence replikasyonu, standby'da WAIT komutu, ON CONFLICT DO SELECT ve davranış değişiklikleri.