Query database tidak selalu cepat: tabel besar tanpa index, kunci yang diperebutkan, atau jaringan yang tersendat bisa membuat satu query menggantung berdetik-detik. Handler HTTP yang menunggu query seperti itu menahan goroutine, memori, dan slot connection pool — dan pengguna hanya melihat spinner. context.Context adalah alat Go untuk memberi batas waktu pada operasi semacam itu: saat deadline terlampaui, query dibatalkan dan sumber daya kembali. Chapter ini mempraktikkan pembatalan dengan simulasi lokal yang bisa dijalankan tanpa PostgreSQL, lalu menunjukkan pola QueryContext yang memakai mekanisme sama pada database/sql.

Demo Pembatalan Tanpa Database #

Mekanisme intinya bisa dibuktikan tanpa koneksi nyata. Fungsi QueryLambat di bawah ini meniru query berdurasi 50 milidetik: goroutine mengerjakan tugasnya sementara fungsi menunggu di select. Cabang ctx.Done() menang bila context dibatalkan lebih dulu — query “berhenti” tanpa menunggu pekerjaan selesai:

go
package main

import (
	"context"
	"fmt"
	"time"
)

// QueryLambat meniru query database yang membutuhkan 50 milidetik.
// Fungsi ini menghormati context: bila ctx dibatalkan lebih dulu,
// fungsi langsung kembali tanpa menunggu kerja selesai.
func QueryLambat(ctx context.Context) (string, error) {
	selesai := make(chan string, 1)
	go func() {
		time.Sleep(50 * time.Millisecond) // kerja tiruan query berat
		selesai <- "42 baris"
	}()

	select {
	case hasil := <-selesai:
		return hasil, nil
	case <-ctx.Done():
		return "", ctx.Err() // context.DeadlineExceeded
	}
}

func main() {
	// Skenario 1: batas waktu 200 ms, query 50 ms selesai lebih dulu.
	ctxLonggar, batal1 := context.WithTimeout(context.Background(), 200*time.Millisecond)
	defer batal1()
	hasil, err := QueryLambat(ctxLonggar)
	fmt.Printf("batas 200ms: hasil=%q err=%v\n", hasil, err)

	// Skenario 2: batas waktu 10 ms, query dibatalkan di tengah jalan.
	ctxKetat, batal2 := context.WithTimeout(context.Background(), 10*time.Millisecond)
	defer batal2()
	hasil2, err2 := QueryLambat(ctxKetat)
	fmt.Printf("batas 10ms : hasil=%q err=%v\n", hasil2, err2)
}

Jalankan berkasnya dari direktori chapter:

bash
go run context-timeout-demo.go

Output yang Dihasilkan:

text
batas 200ms: hasil="42 baris" err=<nil>
batas 10ms : hasil="" err=context deadline exceeded

Skenario pertama memakai batas 200 milidetik: pekerjaan 50 milidetik selesai lebih dulu sehingga hasil "42 baris" kembali dengan error nil. Skenario kedua memakai batas 10 milidetik: deadline terlampaui sebelum pekerjaan selesai, ctx.Err() mengembalikan context.DeadlineExceeded, dan hasil berupa string kosong. Mari kita telaah bagian-bagian penting berkas tersebut:

  1. Baris 12 (func QueryLambat): parameter pertama bertipe context.Context — konvensi Go: fungsi yang bisa lama selalu menerima ctx, biasanya sebagai parameter pertama.
  2. Baris 13-17 (goroutine kerja): pekerjaan berjalan di goroutine dan mengirim hasil lewat channel berbuffer 1, sehingga goroutine tidak bocor meski tidak ada yang membaca hasilnya.
  3. Baris 19-24 (select): dua cabang: hasil selesai atau context dibatalkan. select menunggu yang mana pun datang lebih dulu.
  4. Baris 22-23 (ctx.Done()): channel ini tertutup oleh runtime saat deadline terlampaui atau pembatalan dipanggil; ctx.Err() menjelaskan alasannya.
  5. Baris 29-30 (WithTimeout): membuat turunan context berbatas waktu 200 ms dari context.Background(). Fungsi batal wajib dipanggil — defer batal1() melepas sumber daya segera setelah fungsi selesai, bukan menunggu deadline habis.

QueryContext pada database/sql #

database/sql memakai mekanisme yang sama persis. Setiap method query punya pasangan berakhiran Context yang menerima ctx — QueryContext, QueryRowContext, ExecContext, BeginTx. Fragmen berikut membutuhkan PostgreSQL aktif untuk dijalankan (lihat proyek REST API PostgreSQL ):

go
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()

rows, err := db.QueryContext(ctx, "SELECT id, nama FROM items WHERE harga < $1", 50000)
if err != nil {
	return fmt.Errorf("query items: %w", err)
}
defer rows.Close()

Saat deadline terlampaui, QueryContext mengembalikan error dan driver database memutus query yang sedang berjalan di sisi server — PostgreSQL menerima pesan cancel dan menghentikan eksekusinya, bukan sekadar diabaikan oleh kode Go. Koneksi dari pool juga diperlakukan hati-hati: koneksi yang rusak karena pembatalan dibuang oleh pool, dan SetConnMaxLifetime (dibahas pada chapter Manajemen Koneksi ) melengkapi ctx dengan membatasi umur koneksi itu sendiri.

Timeout Berlapis #

Dua tempat timeout biasa dipasang, dan keduanya bekerja bersama:

TingkatContoh batasSumber ctx
Per request5 detikr.Context() dari HTTP server
Per query500 milidetikcontext.WithTimeout(ctx, ...) turunan

Pola berlapisnya: request mendapat ctx induk dari http.Request.Context(), lalu query berat menurunkan ctx anak dengan batas lebih ketat. Batas anak tidak bisa lebih panjang dari batas induk — pembatalan induk otomatis membatalkan semua turunannya:

go
func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context() // mati saat client memutus koneksi

	queryCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
	defer cancel()

	items, err := repo.ListMahal(queryCtx)
	// ...
}

Kebiasaan context yang Sehat #

Context punya dua akar yang sah: context.Background() untuk program CLI dan titik masuk main, serta r.Context() untuk handler HTTP. Turunan dibuat hanya lewat WithTimeout, WithCancel, atau WithValue dan selalu disertai pemanggilan fungsi cancel-nya.

Pembatalan query menjaga aplikasi tetap responsif di bawah beban. Namun kecepatan respons juga ditentukan oleh test yang percaya diri menjalankan query asli — topik chapter selanjutnya tentang strategi testing lapisan data.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

context-timeout-demo.go

Unduh berkas
go
package main

import (
	"context"
	"fmt"
	"time"
)

// QueryLambat meniru query database yang membutuhkan 50 milidetik.
// Fungsi ini menghormati context: bila ctx dibatalkan lebih dulu,
// fungsi langsung kembali tanpa menunggu kerja selesai.
func QueryLambat(ctx context.Context) (string, error) {
	selesai := make(chan string, 1)
	go func() {
		time.Sleep(50 * time.Millisecond) // kerja tiruan query berat
		selesai <- "42 baris"
	}()

	select {
	case hasil := <-selesai:
		return hasil, nil
	case <-ctx.Done():
		return "", ctx.Err() // context.DeadlineExceeded
	}
}

func main() {
	// Skenario 1: batas waktu 200 ms, query 50 ms selesai lebih dulu.
	ctxLonggar, batal1 := context.WithTimeout(context.Background(), 200*time.Millisecond)
	defer batal1()
	hasil, err := QueryLambat(ctxLonggar)
	fmt.Printf("batas 200ms: hasil=%q err=%v\n", hasil, err)

	// Skenario 2: batas waktu 10 ms, query dibatalkan di tengah jalan.
	ctxKetat, batal2 := context.WithTimeout(context.Background(), 10*time.Millisecond)
	defer batal2()
	hasil2, err2 := QueryLambat(ctxKetat)
	fmt.Printf("batas 10ms : hasil=%q err=%v\n", hasil2, err2)
}