Kısım II · İnşaat · Bölüm 39

Postgres Pratikte

PostgreSQL in practice

Neden burada · Neye dayanıyor

Neden burada: Bölüm 38'de veritabanının ne olduğunu, tabloyu, satırı, SQL'i ve şema tasarımını öğrendin; hepsi kâğıt üzerindeki kavramlardı. Bu bölüm o kavramları gerçek bir sistemde, PostgreSQL'de (kısaca Postgres) çalıştırır ve şu soruya cevap verir: veri yarım milyon satıra çıkınca sorgu neden yavaşlar ve nasıl hızlandırılır; iki kullanıcı aynı anda aynı satıra puan yazarsa ne olur; şemayı değiştirmek gerekince canlı veriye ne yapılır; Go kodu veritabanına nasıl, kimin eliyle bağlanır; bir gün her şey silinirse nasıl geri dönülür?

Neye dayanıyor: Bölüm 37 (Go'da handler → servis → depo katmanları; depo katmanı bu bölümde Postgres'e bağlanır), Bölüm 38 (tablo, birincil/yabancı anahtar, SQL) ve özellikle Bölüm 14.4: orada iki sürecin aynı veriyi aynı anda değiştirdiğinde ortaya çıkan yarış durumunu (race condition) ve kilidi gördün. Bu bölümde aynı sorun, veritabanı katında işlem (transaction) adıyla karşına çıkacak.

  1. Kat 10Bizim uygulamamızGo sunucusu (depo katmanı) ve Postgres: puanlar, cevaplar, kullanıcılar kalıcı olarak burada durur. Bu bölümün işi.
  2. Kat 8AğGo süreci ile Postgres süreci TCP ile konuşur; bağlantı kurmak ağ gidiş gelişi demektir (bkz. Bölüm 19). Bu yüzden bağlantı havuzu var.
  3. Kat 7İşletim sistemiPostgres sıradan bir süreçtir; verisini dosyalara yazar (bkz. Bölüm 16). Yedekleme bu dosyaların ve günlüğün yönetimidir.
  4. Kat 3Bellek ve diskİndeksin sorguyu hızlandırması, sonunda diske (yavaş) gitmemek demektir (bkz. Bölüm 8).

Bu bölümün sonunda şunları yapabilirsin: bir sorgunun neden yavaş olduğunu EXPLAIN çıktısını okuyarak söyleyebilmek; indeksin ne kazandırıp ne kaybettirdiğini açıklamak; "iki kullanıcı aynı anda puan kazanırsa" sorusuna işlem, kilit ve yalıtım düzeyi kavramlarıyla cevap vermek; bir agent'ın yazdığı göç dosyasını (migration) okuyup ne yaptığını anlamak; ve bir Go koduna bakıp SQL enjeksiyonuna açık olup olmadığını görmek.

Bölümdeki bütün sayısal çıktılar hayal ürünü değil: Postgres 18.7'yi bir konteynerde çalıştırıp 10.000 kullanıcı ve 500.000 cevaplık bir tablo kurdum, komutları gerçekten çalıştırdım ve çıktıları buraya olduğu gibi aldım. Makinenden çıkan süreler farklı olacak; öğretici olan süreler değil, oranlar ve plan şekilleri.

39.1 Postgres neden: güvenilirlik, standartlara uyum, zengin tipler (JSONB)

Bölüm 38.1'de veritabanı yönetim sistemlerinin (DBMS) bir tür olduğunu söylemiştik: Postgres, MySQL, SQLite ve diğerleri aynı ilişkisel fikrin farklı gerçeklemeleridir. Postgres'in kendi tanıtım sayfası kendini şöyle tarif eder: SQL dilini kullanan ve genişleten, açık kaynak, nesne-ilişkisel (object-relational) bir veritabanı sistemi. Kökeni 1986'da Berkeley'deki Kaliforniya Üniversitesi'nde başlayan POSTGRES projesine uzanır; yaklaşık kırk yıllık bir geliştirme geçmişi vardır, 2001'den beri ACID uyumludur ve bütün büyük işletim sistemlerinde çalışır.1 Aynı sayfa, geliştiricilerin kendi veri tiplerini ve işlevlerini tanımlayabildiği bir genişletilebilirlik de vurgular. Bu yazıyı yazdığım gün (13 Kasım 2026) güncel sürüm 18; 18.7 sürümü bir gün önce, 12 Kasım 2026'da yayımlandı.1

Neden bizim uygulamamız için Postgres? Üç gerekçeyi ayrı ayrı söyleyeyim, çünkü her biri ayrı bir endişeye cevap verir.

JSON ile JSONB: tablonun içinde esnek bir kutu

Bölüm 38.4'te şemayı sütunlara bölmeyi anlatmıştık: her veri parçası bir sütun. Ama bazen verinin şekli değişkendir. Bizim sorularımızı düşün: bir soru çoktan seçmelidir (3 şık), bir başkası "doğru/yanlış"tır (2 seçenek), bir üçüncüsü boşluk doldurmadır. Hepsi için ayrı tablo mu kuracaksın? Postgres burada iki JSON tipi sunar: json ve jsonb. Resmî belgeye göre json girilen metnin birebir kopyasını saklar (boşluklar ve anahtar sırası dahil) ve her kullanımda yeniden çözümlenir; jsonb ise ayrıştırılmış ikilik bir biçimde saklanır, girişte biraz yavaş, işlemede belirgin biçimde hızlıdır ve indekslenebilir. Belge, özel bir ihtiyaç yoksa çoğu uygulamanın jsonb tercih etmesi gerektiğini söyler.2

Farkı kendim denedim. Aynı metni iki tipe de çevirdim; metinde "a" anahtarı iki kez geçiyor:

SELECT '{"b":1, "a":2, "a":3}'::json AS j,
       '{"b":1, "a":2, "a":3}'::jsonb AS jb;

 j                       | jb
-------------------------+---------------------
 {"b":1, "a":2, "a":3}   | {"a": 3, "b": 1}

json metni olduğu gibi tuttu; jsonb tekrarlanan anahtarı son değere indirdi ve anahtarları yeniden sıraladı. Belgede yazanla birebir aynı davranış: jsonb boşluk, anahtar sırası ve tekrarlanan anahtar saklamaz.2 Bir de içeriği sorgulayabiliriz:

CREATE TABLE questions (
  id      serial PRIMARY KEY,
  body    text   NOT NULL,
  options jsonb  NOT NULL
);
INSERT INTO questions (body, options) VALUES
 ('Sözleşme ne zaman kurulur?',
  '{"choices":["Teklif ve kabul","Yalnız teklif","Yalnız ödeme"],
    "correct":0, "tags":["borçlar","sözleşme"]}');

SELECT options->>'correct' AS dogru, options->'choices'->>0 AS ilk FROM questions;
-- 0 | Teklif ve kabul

SELECT count(*) FROM questions WHERE options @> '{"tags":["borçlar"]}';
-- 1

-> bir JSON parçasını, ->> onu metin olarak verir; @> ise "bu belge, sağdakini içeriyor mu?" sorusudur. Bunun için indeks de kurulabilir (CREATE INDEX ... USING GIN (options), çalıştı); GIN'in ne olduğunu 39.2'de bir cümleyle göreceğiz.

JSONB bir kaçış kapısıdır, ana yol değil

Her şeyi tek bir jsonb sütununa doldurmak cazip gelir, çünkü şema düşünmek zorunda kalmazsın. Bedeli: veritabanı o içerik için yabancı anahtar, NOT NULL, tip denetimi gibi güvenceleri vermez; "puan" bir sayı mı metin mi, sana kalır. Bizim kullanıcı, puan, cevap gibi şekli belli verilerimiz düz sütunlarda durmalı; jsonb yalnızca gerçekten değişken şekilli içerik için (ör. farklı soru türlerinin ayrıntıları).

39.2 Dizin (index) ve B-tree; sorgu planı (EXPLAIN)

Şimdi bölümün en pratik konusuna geliyoruz. Önce deney düzeneği. İki tablo kurdum: users (10.000 kullanıcı) ve answers (kim, hangi soruyu, doğru mu, ne zaman; 500.000 satır). Soru: "42 numaralı kullanıcının kaç cevabı var?"

CREATE TABLE users (
  id bigserial PRIMARY KEY, username text NOT NULL UNIQUE,
  total_xp int NOT NULL DEFAULT 0);
CREATE TABLE answers (
  id bigserial PRIMARY KEY,
  user_id bigint NOT NULL REFERENCES users(id),
  question_id int NOT NULL, correct boolean NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now());

SELECT count(*) FROM answers WHERE user_id = 42;   -- cevap: 59

Sorgu planı: veritabanının "ne yapacağım" notu

Bölüm 38.3'te SQL'in bildirimsel olduğunu söylemiştik (Bölüm 21.5): sen ne istediğini yazarsın, nasıl bulunacağına veritabanı karar verir. O karara sorgu planı (query plan) denir ve planı görmek için sorgunun önüne EXPLAIN yazılır. EXPLAIN ANALYZE ise sorguyu gerçekten çalıştırır ve gerçek süreleri de ekler.3 (Bu yüzden EXPLAIN ANALYZE'i UPDATE ya da DELETE üzerinde çalıştırırsan değişiklik gerçekten yapılır; belge, veriyi değiştirmeden analiz etmek için komutu BEGIN ... ROLLBACK arasına almayı önerir.3)

İndeks yokken aldığım çıktı (okunaklı olsun diye bazı satırları kısalttım):

EXPLAIN ANALYZE SELECT count(*) FROM answers WHERE user_id = 42;

 Finalize Aggregate (cost=7281.43..7281.44 rows=1) (actual time=11.763..14.077 rows=1)
   -> Gather (cost=7281.22..7281.43 rows=2) ... Workers Launched: 2
        -> Partial Aggregate ...
             -> Parallel Seq Scan on answers (cost=0.00..6281.17 rows=21 width=0)
                                             (actual time=0.245..7.262 rows=19.67 loops=3)
                  Filter: (user_id = 42)
                  Rows Removed by Filter: 166647
                  Buffers: shared hit=3677
 Execution Time: 14.102 ms

Okuma kılavuzu:

Dizin ve B-tree

Çözüm, bir kitabın arkasındaki dizindir. Postgres belgesi benzetmeyi kendisi yapar: çok aranan terimler kitabın sonunda alfabetik bir dizinde toplanır, okur bütün kitabı okumak yerine dizine bakıp ilgili sayfaya gider.4 Veritabanında dizin (index), bir sütunun değerlerini sıralı tutan ve her değerin tablodaki yerini gösteren ek bir veri yapısıdır. Kurmak tek komut:

CREATE INDEX answers_user_id_idx ON answers (user_id);

Postgres'te CREATE INDEX varsayılan olarak B-tree (B-tree, dengeli arama ağacı) kurar.5 Bu, Bölüm 11'deki ağaç fikrinin disk için ayarlanmış halidir: her düğüm birçok anahtar taşır, ağaç dengeli kalır ve herhangi bir değere, ağacın birkaç düzeyi inilerek ulaşılır (belge de "arama ağacında birkaç düzey yürümesi yeterli" der4). 500.000 değeri ikili aramayla ortadan bölerek bulmak yaklaşık 19 adımdır (219 = 524.288); B-tree düğümleri geniş olduğundan gerçekte daha da az sayfa okunur. B-tree eşitlik (=) ve aralık (<, <=, >, >=, BETWEEN) sorgularını destekler, sıralı veri döndürmeye de yarar.5

Aynı sorguyu dizinden sonra çalıştırdım:

 Aggregate (cost=188.07..188.08 rows=1) (actual time=0.125..0.126 rows=1)
   -> Bitmap Heap Scan on answers (cost=4.81..187.94 rows=50) (actual rows=59)
        Recheck Cond: (user_id = 42)
        -> Bitmap Index Scan on answers_user_id_idx
             Index Cond: (user_id = 42)
 Execution Time: 0.145 ms

14,1 ms'den 0,145 ms'ye: yaklaşık 100 kat. Planda "Index Scan" ailesi göründü; sayfalar yalnızca 59'luk sonuca gidecek kadar okundu (Buffers: shared hit=62, öncekinde 3677). Dizinin kendisi 3.496 kB, tablo 29 MB: yaklaşık altıda biri kadar ek yer.

Dizin bedava değildir

Her yeni cevap eklendiğinde Postgres yalnızca tabloya değil, her dizine da yazar; bu yüzden dizinler yazmayı yavaşlatır, yer kaplar ve bakım ister. Belge de dizinlerin eklenip kaldırılabileceğini, sistemin dizini güncel tuttuğunu ve yalnızca daha verimli olacağını düşündüğünde kullandığını söyler.4 İlkel kural: okuma çok, yazma az olan ve sık filtrelenen/sıralanan/birleştirilen sütunlara dizin kur; her sütuna kurma. Bu kurala bir istisna: çok küçük bir tabloda (birkaç yüz satır) tarama zaten ucuzdur, planlayıcı dizini kullanmayabilir ve bu normaldir.

Bileşik dizin: filtre ve sıralama birlikte

Leaderboard'a ya da "kullanıcının son 10 cevabı" ekranına gelelim: WHERE user_id = 42 ORDER BY created_at DESC LIMIT 10. Tek sütunluk dizinle plan, 59 satırı bulup sonra sıralıyor (Sort düğümü). İki sütunlu bir dizin ikisini birden çözer:

CREATE INDEX answers_user_created_idx ON answers (user_id, created_at DESC);

 Limit (cost=0.42..41.40 rows=10) (actual time=0.039..0.046 rows=10)
   -> Index Scan using answers_user_created_idx on answers
        Index Cond: (user_id = 42)
 Execution Time: 0.056 ms

Dizin zaten user_id'ye göre, onun içinde created_at'e göre sıralı olduğundan veritabanı ilk 10 girdiyi alıp duruyor; sıralama adımı planda yok. Bileşik dizinde sütun sırası önemlidir: bu dizin "yalnızca created_at'e göre ara" sorgusuna aynı verimle yardım etmez, çünkü önce user_id ile sıralıdır.

Dizin ne zaman kullanılmaz: bir tuzak

users.username üzerinde dizin vardı (çünkü UNIQUE). Yine de WHERE username LIKE 'u99%' ve WHERE lower(username) = 'u99' sorgularının ikisi de planda Seq Scan verdi. Neden? Belge ilkini açıklıyor: B-tree dizini LIKE için ancak desenin başa sabitli olduğu durumda kullanılabilir ('foo%'), fakat veritabanı C yerel ayarında değilse özel bir işleç sınıfıyla kurulması gerekir.5 İkincisi daha basit: dizin username'in kendisini tutuyor, lower(username)'i değil; sorguda sütun bir işlevin içine girince dizin devre dışı kalır. Çözümü ("ifade dizini": CREATE INDEX ... (lower(username))) bilmen şart değil; asıl ders şu: dizin kurmak yetmez, planı gör. Agent "dizin ekledim" dediğinde EXPLAIN ile kullanılıp kullanılmadığına bakmak senin işin.

Postgres'te başka dizin türleri de var: Hash (yalnızca eşitlik), GiST, SP-GiST, GIN ("içinde birden çok değer barındıran" veriler: diziler, JSONB), BRIN.5 Hepsini ezberlemene gerek yok; adı geçtiğinde bunların "aynı fikrin farklı veri şekilleri için varyantları" olduğunu bil.

Uygulamamızda

Bizim sorgu kalıplarımız şunlar olacak: kullanıcıyı username ya da e-postayla bulmak (zaten UNIQUE dizin), "bu kullanıcının cevapları" (answers.user_id), "bu bölümün soruları" (questions.lesson_id). Yabancı anahtar sütunlarına, onlardan sorgu yapıyorsan, dizin kurmak genellikle iyi bir fikirdir; Belgeye göre yabancı anahtarın gösterdiği sütunlarda dizin zaten vardır; ama anahtarı taşıyan sütun (answers.user_id) için dizin otomatik kurulmaz ve oradan yapılan silme/güncelleme taramaya yol açabilir.17 Leaderboard'u ise Postgres dizinleri yerine Bölüm 40'ta Redis'in sıralı kümesiyle çözeceğiz; nedeni orada.

39.3 İşlem (transaction) ve ACID; eşzamanlı yazma

Bölüm 14.4'te iki iş parçacığı aynı sayacı arttırınca bir artışın kaybolabildiğini görmüştük: yarış durumu. Aynı şey veritabanında da olur; hatta daha sık, çünkü yüzlerce kullanıcı aynı anda yazıyor. Veritabanının buna cevabı işlem (transaction): birden çok adımı tek bir "ya hep ya hiç" birimine bağlayan mekanizma. Belgenin tarifi: bir işlem birçok adımı tek bir hep-ya-da-hiç işlemine toplar; adımlar arasındaki ara durumlar diğer eşzamanlı işlemlerce görülmez ve bir arıza işlemin tamamlanmasını engellerse hiçbir adım veritabanını etkilemez.6

Hukuk benzetmesi yerinde: noterde yapılan bir işlem. Satış sözleşmesinde "bedel ödendi" ve "mülkiyet geçti" iki ayrı adımdır; ikisinden yalnızca biri gerçekleşmiş bir dünya kabul edilemez. İşlem, ikisini tek bir tasarrufa çevirir.

BEGIN;
UPDATE users SET total_xp = total_xp + 10 WHERE id = 1;
INSERT INTO answers (user_id, question_id, correct) VALUES (1, 7, true);
COMMIT;      -- ikisi birden görünür olur
-- araya ROLLBACK; girseydi ikisi de iptal olurdu

BEGIN işlemi açar, COMMIT onaylar, ROLLBACK geri alır. Postgres'te BEGIN yazmasan bile her SQL deyimi kendi küçük işleminin içinde çalışır.6

ACID: dört güvence

İşlemlerin vaadi dört harfle anılır. Postgres'in öğretici belgesi her birine değinir; ben aynı sırayla açıklıyorum:6

HarfTerimNe diyor
Abölünmezlik (atomicity)İşlemdeki adımların hepsi olur ya da hiçbiri olmaz. Puan yazıldı ama cevap kaydı yazılmadı durumu olmaz.
Ctutarlılık (consistency)İşlem veritabanını kurallı bir durumdan kurallı bir duruma götürür: NOT NULL, UNIQUE, yabancı anahtar gibi kısıtlar (constraint) ihlal edilmişse işlem reddedilir.
Iyalıtım (isolation)Eşzamanlı işlemler birbirinin yarım kalmış değişikliklerini görmez.
Dkalıcılık (durability)"Tamam" denildikten sonra veri, sunucu hemen çökse bile kaybolmaz: belgeye göre işlemin yaptığı güncellemeler, işlem bitti denmeden önce kalıcı depolamaya (diske) günlüklenir.

Kalıcılık, Bölüm 8.3'teki uçucu ve kalıcı ayrımıyla birebir ilgili: bellekte duran veri güç gidince kaybolur; "tamam" cevabından önce diske yazılmak zorunda olan şey, işlemin günlüğüdür.

Yarış durumunu gözümüzle görelim

İki kullanıcı değil, aynı kullanıcı için iki olay düşün: Kerem aynı anda iki sekmede iki soruyu doğru bildi; sunucu iki istek alıyor, biri +10, öbürü +5 puan yazacak. Doğru sonuç 15. Üç yöntemi iki gerçek psql oturumuyla yarıştırdım (her ikisi de 0 puanla başlıyor):

YöntemNe yapıyorSonuç
1. Okuyup-hesaplayıp-yazmaUygulama puanı okur (SELECT), kodda toplar, sonucu UPDATE total_xp = :yeni ile yazar5: +10 kayboldu
2. Atomik güncellemeUPDATE users SET total_xp = total_xp + :n15, doğru
3. Satırı kilitleyip okumaSELECT ... FOR UPDATE ile oku, hesapla, yaz15, doğru

Birinci yöntem tam Bölüm 14.4'ün hatası: iki oturum da 0'ı okudu; biri 0+10 yazdı, öbürü 0+5 yazdı ve sonuncu kazandı. Hata mesajı yok, kimse fark etmeyecek; puan sessizce yanlış. İkinci yöntemde işlem artırmayı veritabanının içinde yapar; bir satırı güncelleyen iki komut sırayla çalışır ve ikincisi, birincinin değiştirdiği güncel değeri görür. Belgenin Read Committed açıklaması tam bunu söyler: ikinci güncelleyici, birincinin bitmesini bekler ve commit olmuşsa satırın güncellenmiş sürümü üzerinde işlemini uygular.7 Üçüncü yöntemde FOR UPDATE satırı "kilitler": belgeye göre aynı satırı güncellemeye, silmeye ya da kilitlemeye çalışan diğer işlemler, bu işlem bitene kadar bloke olur.8

Ders: değeri koda çekip hesaplayıp geri yazmak yerine, hesabı veritabanına yaptır (Bölüm 35.4'te gördüğün idempotent işlem fikri de buna yakın bir ruhla çalışır). Bunu agent'ın kodunda gözünle arayacağın en önemli kalıplardan biri olarak not et: "user.XP += 10; db.Save(user)" türü kod, tam bu hatanın kaynağıdır.

Yalıtım düzeyleri ve MVCC

"Yalıtım" tek bir ayar değil, bir kademe. SQL standardı dört düzey tanımlar; Postgres'in varsayılanı Read Committed'dır.7 Kademeleri anlamak için önce hangi anomalilerin önlenmeye çalışıldığını bil:7

Postgres Read Committed'da her komut, komutun başladığı anın görüntüsünü (snapshot) görür. Repeatable Read'de ise görüntü işlemin başladığı andır; yani işlem boyunca dünya donmuş gibi görünür.7 Serializable en katı düzeydir: sonucu sanki işlemler teker teker çalışmış gibi garanti eder.7

Bu görüntü fikrinin arkasındaki mekanizma çok sürümlü eşzamanlılık denetimi (MVCC, multiversion concurrency control)'dir. Belgenin tanımı: her SQL komutu, altta yatan verinin şu an ne olduğundan bağımsız olarak, verinin bir zaman önceki görüntüsünü görür.9 Pratik sonucu: MVCC'de okumak için alınan kilitler yazmak için alınanlarla çakışmaz; yani okuma yazmayı, yazma okumayı bloke etmez.9 Leaderboard sayfasını yüzlerce kullanıcı açarken puan yazan işlemler beklemez; bu, Postgres'in bu iş için iyi bir seçim olmasının temel nedenlerinden biri.

Repeatable Read'i de denedim. İki işlem aynı satırı güncellemeye çalışırken, ilki onayladı; ikincisi şu hatayı aldı ve geri alındı:

ERROR:  could not serialize access due to concurrent update

Belgenin söylediği tam bu: Repeatable Read'de işlem başladıktan sonra başkasının onayladığı bir değişikliği değiştirmeye çalışırsan hata alırsın ve uygulama işlemi baştan denemeye hazır olmalıdır (retry).7 Yani daha katı yalıtım, bedelini "yeniden dene" yükü olarak uygulamaya bırakır. Varsayılan Read Committed + atomik UPDATE kalıbı, bizim ihtiyaçlarımızın çoğunu karşılar; katı düzeylere ancak gerçekten gerektiğinde, nedenini bilerek geç.

Başka bir kalkan: kısıt ve ON CONFLICT

Aynı bölümü iki kez tamamlayarak puanı iki kez almak ("çift tıklama" ya da ağ yeniden denemesi) de bir yarış durumudur. Çözümü kodda "daha önce aldı mı?" diye bakmak (bu da okuyup-sonra-yazmak) yerine, veritabanına kısıt bildirmektir:

CREATE TABLE completions (
  user_id bigint, lesson_id int, xp int,
  PRIMARY KEY (user_id, lesson_id));        -- aynı çift iki kez olamaz

INSERT INTO completions VALUES (1, 7, 10) ON CONFLICT DO NOTHING;  -- INSERT 0 1
INSERT INTO completions VALUES (1, 7, 10) ON CONFLICT DO NOTHING;  -- INSERT 0 0

İkinci eklemede hata da yok, ikinci satır da yok (INSERT 0 0: sıfır satır eklendi). Kontrolü veritabanı yapıyor; iki istek aynı anda gelse de çakışan birine veritabanı karar verir. "Bir kullanıcı bir bölümü yalnızca bir kez tamamlayabilir" kuralı artık kodun değil, şemanın kuralı.10

Uygulamamızda: "cevap ver" işlemi

Bir cevap gönderildiğinde yapılacaklar: cevabı kaydet, doğruysa puanı arttır, seriyi güncelle, belki bölümü tamamlandı say. Bunların hepsi tek işlemde olmalı; yoksa sunucu yarıda kapanırsa "cevap kayıtlı ama puan yok" gibi bozuk bir ara durum kalır. Aşağıdaki Go parçası bu bölümde derleyip çalıştırdığım gerçek bir örnek; aynı mantığı agent'ın yazdığı kodda arayacaksın:

func AddXP(ctx context.Context, pool *pgxpool.Pool, userID int64, xp int) (int, error) {
	tx, err := pool.Begin(ctx)                 // BEGIN
	if err != nil {
		return 0, err
	}
	defer tx.Rollback(ctx)                     // hata olursa geri al; COMMIT sonrası zararsız

	var total int
	err = tx.QueryRow(ctx,
		`UPDATE users SET total_xp = total_xp + $1 WHERE id = $2 RETURNING total_xp`,
		xp, userID).Scan(&total)               // atomik artış, yeni değeri geri al
	if err != nil {
		return 0, err
	}
	_, err = tx.Exec(ctx,
		`INSERT INTO answers (user_id, question_id, correct) VALUES ($1, $2, true)`,
		userID, 1)
	if err != nil {
		return 0, err
	}
	return total, tx.Commit(ctx)               // COMMIT: ikisi birden kalıcı
}

Her satırı oku: Begin işlemi açar; defer tx.Rollback "bu fonksiyondan nasıl çıkarsan çık, onaylanmamışsa geri al" güvencesidir; $1, $2 parametreler (39.5'te açıyorum); RETURNING güncellenen satırın yeni değerini aynı komutta geri verir, ayrı bir SELECT gerekmez. Bu çağrıyı çalıştırdığımda sonuç 25 <nil> geldi (testten kalan 15 puana +10).

39.4 Şema göçü (migration)

İlk gün şemayı kurarsın. Üçüncü hafta bir şey değişir: kullanıcıya "seri" (streak) sütunu eklemek gerekir. Prototipte dosyayı elle değiştirirdin. Ama canlı veritabanında binlerce kullanıcının verisi var ve her ortamda (senin bilgisayarın, test, üretim; Bölüm 43.1) şemanın aynı olması lazım. Çözüm: şema değişikliklerini, sırayla numaralanmış küçük SQL dosyaları olarak depoya koymak ve bir araçla uygulamak. Bu dosyalara şema göçü (migration) denir.

Go dünyasında yaygın araçlardan biri golang-migrate. Resmî öğreticisindeki düzen şöyle: migrate create -ext sql -dir db/migrations -seq create_users_table komutu iki dosya üretir, 000001_create_users_table.up.sql ve 000001_create_users_table.down.sql. up dosyası değişikliği uygular, down dosyası geri alır. Uygulamak için migrate -database $URL -path db/migrations up çalıştırılır.11 Yani:

db/migrations/
  000001_create_users.up.sql         -- CREATE TABLE users (...)
  000001_create_users.down.sql       -- DROP TABLE users
  000002_add_streak_to_users.up.sql  -- ALTER TABLE users ADD COLUMN streak int NOT NULL DEFAULT 0;
  000002_add_streak_to_users.down.sql-- ALTER TABLE users DROP COLUMN streak;

Araç, hangi numaraya kadar uygulandığını veritabanının içinde tutar; up dediğinde yalnızca henüz uygulanmamış dosyaları, sırayla çalıştırır. Böylece şema da kod gibi sürüm denetimine girer (Bölüm 28): bir git commit'i hem kodu hem o kodun beklediği şemayı birlikte getirir.

Aracın öğreticisi bir önemli ayrıntıyı vurgular: Postgres'te sorguların bir işlem içinde çalışmasını istiyorsan BEGIN ve COMMIT ile sarmalamalısın; yoksa göç yarıda kesilirse şema yarım kalır.11 Üç tür tuzak:

Uygulamamızda: agent bir göç yazdığında

Agent'lar (Bölüm 46) çoğu zaman şema değişikliğini göç dosyası olarak yazacak. Kontrol listen: dosya up ve down çifti mi? Mevcut satırlara ne olacağı düşünülmüş mü (DEFAULT, doldurma sorgusu)? Dizin eklenecekse neden? Veri silen bir şey var mı? Sen SQL yazmasan bile bu dört soruyu sorabilirsin.

39.5 Go'dan bağlanmak: bağlantı havuzu, SQL enjeksiyonu ve parametreli sorgu

Bölüm 37'de handler → servis → depo katmanlarını kurmuştuk; depo katmanı artık gerçekten Postgres'le konuşacak. Postgres ayrı bir süreç olduğundan (Bölüm 14) Go programı ona ağ üzerinden, TCP ile bağlanır (Bölüm 19.5). Go'nun standart kütüphanesindeki database/sql ortak arayüzü sunar; Postgres için yaygın olarak kullanılan sürücü ve araç takımı ise pgx'tir. Aşağıdaki örneklerin hepsi pgx v5 ile yazılıp gerçek bir Postgres'e karşı çalıştırıldı.

Bağlantı havuzu

Her istek için yeni bir veritabanı bağlantısı açmak pahalıdır: ağ el sıkışması, kimlik doğrulama, sunucu tarafında bir bağlantı kurmak. Bunun yerine program açılırken birkaç bağlantıyı hazır açar, istekler kullanıp geri verir. Bu hazır bağlantı grubuna bağlantı havuzu (connection pool) denir. pgx'in pgxpool paketi tam bunu yapar; paketin tanımı: "pgx için eşzamanlı çağrılara güvenli (concurrency-safe) bir bağlantı havuzu". Havuz kurulurken bağlantıların hemen açılmasını beklemez (bağlantıyı denemek için hemen bir bağlantı edinmek gerekir) ve MaxConns ayarı havuzun azami boyutudur; varsayılanı 4 ile işlemci sayısından büyük olanıdır.13

cfg, err := pgxpool.ParseConfig(os.Getenv("DATABASE_URL"))  // adres ortam değişkeninden (bkz. 43.7)
if err != nil { panic(err) }
cfg.MaxConns = 10                                           // en fazla 10 eşzamanlı bağlantı
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil { panic(err) }
defer pool.Close()

Havuz sınırdır de: Postgres'in kendisinin de bir üst bağlantı sınırı vardır (max_connections; denediğim kurulumda 100). Hem yüzlerce Go goroutine'in (Bölüm 36.4) hem de birden çok sunucu kopyasının her biri havuz boyutu kadar bağlantı alırsa bu sınır dolar. "Daha çok bağlantı = daha hızlı" değildir; havuz boyutu bilinçli seçilen bir sayıdır.

SQL enjeksiyonu

Güvenliğin en klasik hatası, kullanıcıdan gelen metni SQL cümlesine yapıştırmaktır. Kullanıcı adı alanına şunu yazdığını düşün: x' OR '1'='1. Kod, sorguyu metin birleştirerek kurduysa, veritabanının gördüğü şey artık:

SELECT count(*) FROM users WHERE username = 'x' OR '1'='1'

Kullanıcıdan gelen veri, verinin içinde kalmadı; komutun bir parçası oldu. Bu hatanın adı SQL enjeksiyonu (SQL injection)'dur. İki yöntemi aynı bağlantıda denedim:

kotu := "x' OR '1'='1"
// 1) parametreli: değer ayrı gönderilir
pool.QueryRow(ctx, `SELECT count(*) FROM users WHERE username = $1`, kotu).Scan(&n)
//    -> n = 0       (kimse "x' OR '1'='1" adını taşımıyor)

// 2) metin birleştirme: değer cümlenin içine gömülür
pool.QueryRow(ctx, "SELECT count(*) FROM users WHERE username = '"+kotu+"'").Scan(&n)
//    -> n = 10000   (koşul her zaman doğru: bütün kullanıcılar eşleşti)

Birincisi 0, ikincisi 10.000 döndü. Parametreli sorgu (parameterized query)nun fikri: SQL'in yapısı ile değerleri ayrı kanallardan gider; veritabanı $1 yerine gelen şeyi her koşulda bir değer olarak ele alır, komut olarak asla. Hem OWASP'ın SQL Injection Prevention Cheat Sheet'i hem Go'nun resmî belgesi aynı şeyi söyler: parametreli sorguları tercih et, elle kaçışlamaya (escaping) güvenme ve sütun/tablo adı gibi parametrelenemeyen yapısal parçaları kullanıcıdan alma, sabit bir izin listesinden seçtir.1415 SQL enjeksiyonunu Bölüm 41.4'te diğer saldırılarla birlikte yeniden göreceğiz.

Agent'ın kodunda aranacak şey

Go'da fmt.Sprintf("SELECT ... WHERE name = '%s'", ad) ya da "... " + ad + " ..." görürsen ve ad kullanıcıdan geliyorsa, bu bir güvenlik açığıdır. Doğrusu her zaman $1 yer tutucusu ve değerin ayrı argüman olarak verilmesidir.

39.6 Yedekleme (backup)

Bütün bu güvencelerin (ACID, kısıtlar) bir sınırı var: hiçbiri "yanlışlıkla DELETE FROM users; yazdım", "disk bozuldu", "sunucu silindi" ya da "bir göç hatalı veri yazdı" durumunu kurtarmaz. Bunun için tek çare, verinin ayrı bir yerde duran bir kopyasıdır: yedek. Postgres belgesi yedeklemenin üç ayrı yaklaşımını anlatır: SQL dökümü, dosya sistemi düzeyinde yedek ve sürekli arşivleme.

SQL dökümü: pg_dump

En basit ve en çok kullanılanı pg_dump: veritabanını, geri yüklendiğinde aynı durumu yeniden kuracak SQL komutlarına (ya da sıkıştırılmış bir biçime) dönüştürür.12 Belgeye göre dökümler, pg_dump'ın çalışmaya başladığı andaki tutarlı bir görüntüdür ve çalışırken diğer işlemleri engellemez (özel kilit isteyen işlemler hariç).12 Bunu da MVCC'ye borçlusun: dökümü alan işlem kendi görüntüsünü okur, yazanlar beklemez. Aynı belge, pg_dump çıktısının genellikle daha yeni Postgres sürümlerine yüklenebildiğini, dosya düzeyindeki yedeklerin ise sürüme özel olduğunu söyler.12

pg_dump -Fc postgres -f hukuk.dump            # -Fc: sıkıştırılmış özel biçim
createdb kopya                                # boş bir veritabanı
pg_restore -d kopya hukuk.dump                # geri yükle

Denedim: 500.000 cevaplı veritabanının dökümü yaklaşık 3,7 MB'lık bir dosya oldu (sıkıştırılmış) ve kopya veritabanında users 10.000, answers 500.001 satır olarak geri geldi (500.001: Go örneğinin eklediği bir cevap). Belge ayrıca iki önemli ayrıntı söyler: düz metin dökümü psql ile yüklenir ve --single-transaction (-1) ile yüklersen yükleme ya tam biter ya da tam geri alınır; yükleme sonrası da ANALYZE çalıştırman önerilir ki planlayıcı güncel istatistiklere sahip olsun.12

Sürekli arşivleme ve belirli ana dönüş (PITR)

Dökümün zayıf yanı, dökümün alındığı ana dönebilmeni sağlamasıdır; dünkü dökümle çalışıyorsan bugünkü işler gider. Postgres her değişikliği önce bir ön yazma günlüğüne (WAL, write-ahead log) yazar; bu günlük çökme güvenliği için vardır, ama aynı zamanda yedekleme için de kullanılır: bir dosya sistemi yedeği ile günlük dosyalarının arşivini birleştirirsen, günlüğü istediğin ana kadar tekrar oynatarak veritabanını geçmişteki herhangi bir ana döndürebilirsin. Buna belirli ana dönüş (point-in-time recovery, PITR) denir.16 Belge bu yöntemi yönetmesi daha karmaşık, ama yüksek güvenilirlik istenen birçok durumda tercih edilen yöntem diye tarif eder; aynı belge, pg_dump çıktısının bu yönteme kaynak olamayacağını da belirtir (dökümler mantıksaldır).16

Uygulamamızda: yedek stratejisi

Kerem ve Emre'nin ilk günlerde işi küçük: yönetilen (managed) bir Postgres hizmeti kullanırsanız sağlayıcı genellikle otomatik yedek ve geri dönüş sunar (Bölüm 43.3); kendi sunucunuzda çalıştırırsanız pg_dump ile zamanlanmış bir yedek ve geri yüklemeyi gerçekten denemek en az gereken. Bir yedeğin değeri, geri yüklenebildiğinin kanıtlandığı kadardır; bu bölümde yukarıda denediğim gibi, yedeği başka bir veritabanına yükleyip satır sayısını karşılaştırmak ilk sağlık denetimidir. Üstelik yedek de kişisel veri içerir; onu da koruman gerekir (Bölüm 41.6).

Bölüm 38 ile bağlantı

Bölüm 38 veritabanını "veriyi kalıcı ve yapılı tutan sistem" olarak kurdu; tablo, anahtar ve SQL. Bu bölüm o sistemin mühendislik gerçekliklerini ekledi: büyüyen veride hız (dizin, plan), aynı anda yazan çoklu kullanıcı (işlem, MVCC), zamanla değişen şema (göç), kodla konuşma (havuz, parametre) ve felaket (yedek). Hiçbiri SQL'in sözdizimi değil; hepsi SQL'in çevresindeki kararlar. Bir sonraki bölüm (Bölüm 40) tam bu bölümün ilk bulgusundan çıkar: dizin bile olsa her istekte veritabanına sormak, sık sorulan ve sık değişen şeyler için (leaderboard) gereksiz iştir.

Bu bölümde neler kuruldu

Postgres güvenilirliği, SQL uyumu ve JSONB gibi zengin tipleri nedeniyle seçilen, ACID uyumlu ilişkisel bir sistemdir. Dizin (varsayılan olarak B-tree) sorguyu tüm tabloyu taramaktan kurtarır; EXPLAIN hangi planın seçildiğini gösterir; bedeli yazma yavaşlığı ve yerdir. İşlem adımları ya-hep-ya-hiç birimine toplar; ACID bölünmezlik, tutarlılık, yalıtım ve kalıcılığı vaat eder; MVCC ile okuma yazmayı bloke etmez; aynı satıra eşzamanlı yazmada hesabı veritabanına yaptırmak (atomik UPDATE), gerekirse FOR UPDATE ya da kısıt/ON CONFLICT kullanmak gerekir. Şema göçü şema değişikliğini sürümlü SQL dosyalarına çevirir. Go'da bağlantı havuzu bağlantıları yeniden kullanır; parametreli sorgu SQL enjeksiyonunu engeller. Yedek felakette dönüş yoludur; pg_dump mantıksal, sürekli arşivleme ise zamanda geri gitmeye izin verir.

Terimler sözlükte, Bölüm 39 başlığı altında.

Birincil okuma

PostgreSQL belgeleri, Tutorial, 3.4 Transactions (iki sayfa; bankadaki havale örneği bu bölümün omurgası). Ardından 14.1 Using EXPLAIN: plan düğümlerinin tek tek okunuşu. Derinleşmek için 13.2 Transaction Isolation.

Takıldığın her yeri yapay zekâ öğretmenine sor. Bir alıştırma önerisi: ondan users ve answers gibi iki tablolu bir şema ve birkaç yüz bin satırlık örnek veri üreten bir betik yazmasını iste, sonra aynı sorguyu dizinsiz ve dizinli EXPLAIN ANALYZE ile çalıştırıp planı satır satır sana okumasını söyle.

Bölüm sonu soruları

Kitaba bakmadan cevapla. Kâğıt kalem serbest.

1. Bir tabloda WHERE user_id = 42 sorgusu Seq Scan ile yavaş çalışıyor. user_id üzerine B-tree dizini kurulunca plan neden değişir?
Dizin sıralı bir arama ağacıdır; değerin yerini birkaç düzey inerek bulur, tabloyu satır satır taramaz. Bkz. 39.2.
2. EXPLAIN ANALYZE ile EXPLAIN arasındaki temel fark nedir?
EXPLAIN planı tahmini maliyetlerle gösterir; EXPLAIN ANALYZE sorguyu gerçekten çalıştırıp gerçek süreleri ekler. Bu yüzden UPDATE/DELETE üzerinde dikkat gerekir. Bkz. 39.2.
3. İki istek aynı anda aynı kullanıcıya +10 ve +5 puan yazacak. Hangi kod doğru sonuç (15) verir?
Hesabı veritabanına yaptıran atomik güncelleme ikinci isteği ilkinin güncel değeriyle çalıştırır. Okuyup-kodda-hesaplayıp-yazmak denemede 5 verdi. Bkz. 39.3.
4. ACID'deki D (durability, kalıcılık) neyi vaat eder?
Postgres belgesine göre işlemin güncellemeleri işlem tamamlandı denmeden önce kalıcı depolamaya günlüklenir. Bkz. 39.3.
5. MVCC'nin pratik sonucu hangisidir?
MVCC'de her komut verinin bir görüntüsünü görür; okuma kilitleri yazma kilitleriyle çakışmaz. Bkz. 39.3.
6. Repeatable Read'de işlem sırasında başkası aynı satırı onaylayıp değiştirdi, bizim işlem de o satırı güncellemek istiyor. Ne olur?
Postgres "could not serialize access due to concurrent update" hatasını verir ve uygulamanın işlemi baştan denemesini bekler. Bkz. 39.3.
7. Şema göç dosyalarının çift olarak (up ve down) yazılmasının amacı nedir?
up değişikliği uygular, down geri alır; araç hangi numaranın uygulandığını izler. Veriyi geri getirmediğine dikkat. Bkz. 39.4.
8. Bağlantı havuzu (pgxpool) hangi sorunu çözer?
Hazır bir bağlantı grubunu paylaştırarak bağlantı kurma maliyetini ve eşzamanlı bağlantı sayısını sınırlar. Bkz. 39.5.
9. "... WHERE name = '" + ad + "'" kodunun asıl sorunu nedir?
Değer ve yapı karıştığında x' OR '1'='1 gibi girdi koşulu değiştirir (denemede 10.000 satır eşleşti). Çözüm parametreli sorgu. Bkz. 39.5.
10. Kendi cümlelerinle: bir kullanıcı iki sekmeden aynı anda iki cevap gönderirse neden "oku–hesapla–yaz" kodu puanı yanlış yazabilir? Çözümü ve hangi yan kuralı (kısıt) ekleyeceğini anlat.
Örnek cevabı göster

İki istek de puanı aynı anda (ör. 0) okur, kodda kendi artışını ekler (10 ve 5) ve yazar; sonuncu yazan öncekini ezer, sonuç 15 yerine 5 ya da 10 olur. Çözüm artırmayı veritabanına yaptırmak (total_xp = total_xp + n), cevap kaydı ve puanı tek işlemde yapmak ve aynı bölümün iki kez puan getirmesini PRIMARY KEY (user_id, lesson_id) ve ON CONFLICT DO NOTHING gibi bir kısıtla veritabanına bırakmaktır.

11. Bir agent "yavaş sorguyu çözmek için answers tablosuna dizin ekledim" dedi. Doğru olduğundan nasıl emin olursun? Hangi komutu çalıştırır, çıktıda neye bakarsın?
Örnek cevabı göster

Sorguyu EXPLAIN ANALYZE ile çalıştırırım. Planda Seq Scan yerine Index Scan/Bitmap Index Scan ve dizin adı geçmeli; Execution Time düşmeli, taranan sayfa (Buffers) azalmalı. Dizin kullanılmıyorsa sütunun bir işlev içine alınmış olması gibi bir neden aranır.

Kaynaklar

  1. PostgreSQL, About: nesne-ilişkisel sistem, 1986 kökeni, 2001'den beri ACID uyumu, genişletilebilirlik; sayfa 13 Kasım 2026'da 18.7 sürümünü duyurmaktadır.
  2. PostgreSQL 18 belgeleri, 8.14 JSON Types: json ile jsonb farkı.
  3. PostgreSQL 18 belgeleri, 14.1 Using EXPLAIN: maliyet birimi, EXPLAIN ANALYZE. Bölümdeki plan çıktıları Postgres 18.7 üzerinde bu bölümü yazarken gerçekten alındı.
  4. PostgreSQL 18 belgeleri, 11.1 Introduction (Indexes): kitap dizini benzetmesi, dizin bakımı, ANALYZE.
  5. PostgreSQL 18 belgeleri, 11.2 Index Types: B-tree, Hash, GiST, SP-GiST, GIN, BRIN; B-tree ve LIKE.
  6. PostgreSQL 18 belgeleri, 3.4 Transactions: hep-ya-da-hiç, kalıcılık, BEGIN/COMMIT/ROLLBACK, SAVEPOINT.
  7. PostgreSQL 18 belgeleri, 13.2 Transaction Isolation: anomaliler, Read Committed, Repeatable Read, Serializable, "could not serialize access" hatası.
  8. PostgreSQL 18 belgeleri, 13.3 Explicit Locking: FOR UPDATE.
  9. PostgreSQL 18 belgeleri, 13.1 Introduction (Concurrency Control): MVCC, okuma yazmayı bloke etmez.
  10. PostgreSQL 18 belgeleri, INSERT: ON CONFLICT DO NOTHING.
  11. golang-migrate, PostgreSQL tutorial: up/down dosyaları, migrate create, migrate up, BEGIN/COMMIT ile göç.
  12. PostgreSQL 18 belgeleri, 25.1 SQL Dump: pg_dump, özel kilit gerektiren işlemler (ör. çoğu ALTER TABLE), geri yükleme, ANALYZE.
  13. pgx, pgxpool paketi: eşzamanlı çağrılara güvenli havuz, MaxConns varsayılanı.
  14. OWASP, SQL Injection Prevention Cheat Sheet: parametreli sorgular, izin listesi.
  15. Go belgeleri, Avoiding SQL injection risk: parametre değerlerini sql paketi işlevlerine argüman olarak vermek.
  16. PostgreSQL 18 belgeleri, 25.3 Continuous Archiving and PITR: WAL, belirli ana dönüş.
  17. PostgreSQL 18 belgeleri, 5.5 Constraints: yabancı anahtar ve dizin.