Say Goodbye to Redundant Requests: Golang Singleflight

Sammi Aldhi Yanto
7 min readJul 7, 2023

--

Photo by James Wainscoat on Unsplash

Dalam pengembangan aplikasi, kita sering berhadapan dengan interaksi terhadap resource eksternal dan operasi I/O, seperti API request, akses ke database, dan operasi pada filesystem. Namun, masalah yang sering muncul adalah adanya permintaan duplikat yang datang bersamaan ke resource tersebut. Nah, hal ini bisa membuat penggunaan resource menjadi nggak efisien, menambah waktu tunggu, dan menghambat kinerja aplikasi kita.

Untungnya, ada pola desain yang dapat membantu mengatasi masalah permintaan duplikat tersebut, yaitu Singleflight. Singleflight ini sangat berguna untuk menghindari terjadinya permintaan yang identik, seperti banyak request yang ingin meminta resource yang sama. Nah, dengan menggunakan pola Singleflight ini, hanya satu permintaan yang akan diteruskan atau diproses, sedangkan yang lainnya akan bengong nungguin hasilnya 🦥 hehe

Ringkasnya, Dengan penerapan Singleflight ini, kita bisa mengendalikan akses ke resource secara efisien, jadi cuma satu permintaan yang dijalankan dan hasilnya bisa dipakai oleh semua permintaan yang duplikat. Jadi, nggak ada lagi tu duplikasi yang bikin aplikasi kita maupun 3rd party app jadi berat.

Sekarang, mari kita akan bahas konsep dan implementasi Singleflight yang digunakan dalam bahasa pemrograman Go. Nanti kita akan main-main dengan contoh kasus yang terkait penggunaan Singleflight untuk nge-handle permintaan duplikat saat call ke external API.

Apa itu Singleflight?

Singleflight merupakan sebuah pola desain yang digunakan untuk menghindari adanya permintaan duplikat yang tidak perlu terhadap resource internal maupun eksternal atau operasi I/O. Pola ini memastikan bahwa hanya ada satu pemanggilan yang sebenarnya dilakukan untuk permintaan yang sama, sementara pemanggilan lainnya menunggu hasilnya. Konsep ini sangat membantu dalam mengoptimalkan penggunaan resource, mengurangi latensi, dan meningkatkan kinerja aplikasi.

Ketika suatu permintaan masuk, Singleflight akan memeriksa apakah permintaan tersebut telah dilakukan sebelumnya. Jika sudah, pemanggilan baru akan menunggu hasil dari pemanggilan sebelumnya. Namun, jika belum ada pemanggilan sebelumnya, pemanggilan baru akan dilakukan dan hasilnya akan disimpan untuk digunakan oleh pemanggilan-pemanggilan berikutnya.

Singleflight sangat berguna dalam situasi di mana terdapat banyak komponen dalam sistem yang memicu permintaan yang sama secara bersamaan, seperti panggilan API, akses ke database, atau operasi pada filesystem.

Bahasa pemrograman Go telah menyediakan implementasi Singleflight yang dapat langsung digunakan melalui library golang.org/x/sync/singleflight. Dengan demikian, kita tidak perlu repot-repot membuat implementasi sendiri, mengapa harus susah-susah membuat sendiri jika sudah ada yang tersedia, bukan? 😁 wqwqwq… Namun, nanti kita tetap akan melihat bagaimana implementasi Singleflight dalam Go bekerja di balik layar.

Studi kasus mendapatkan data github topic dari github API

Setelah mengenal konsep Singleflight selanjutnya kita akan praktek dengan studi kasus, kita akan melihat bagaimana Singleflight dapat digunakan untuk mengatasi masalah permintaan redundan ketika mengambil data github topic menggunakan GitHub API. Kita akan menggunakan bahasa pemrograman Go dan library github.com/google/go-github untuk berinteraksi dengan Github API.

Talk is cheap. Show me the code! 😁

Baiklaah, berikut merupakan implementasi untuk mengambil data github topic dari GitHub API menggunakan Singleflight:

Intinya, Fungsi GetGitHubTopicWithSingleflight menggunakan Singleflight yg telah diimplementasikan di Go untuk menghindari permintaan redundan ke github API. Pertama, kita membuat sebuah key unik dengan format github_topic_ + topic, contohnya github_user_gRPC.

Key ini akan digunakan sebagai identifier untuk setiap request. Jika key untuk request-request selanjutnya sama dengan request sebelumnya, maka request tersebut akan menunggu hasil dari request sebelumnya, jadi disini key mempunyai peran yang paling penting.

Kita menggunakan fungsi group.Do untuk menjalankan fungsi callback hanya jika tidak ada request lain yang sedang menjalankan permintaan dengan key yang sama.

Ok Lanjut 💻

Dalam fungsi callback, kita melakukan penambahan total permintaan ke GitHub API menggunakan fungsi IncrementRequestCounter (nantinya ini untuk keperluan testing). Kemudian, kita menggunakan githubClient.Search.Topics untuk mendapatkan data topic dari github API.

Setelah permintaan selesai, hasilnya akan disimpan dalam variabel result yang dikembalikan oleh group.Do. Variable shared akan bernilai true ketika result yang didapatkan didapat dari request sebelumnya (dengan key yg sama).

Kita juga membuat fungsi GetGitHubTopic yang nantinya digunakan untuk membandingkan hasilnya dengan GetGitHubTopicWithSingleflight

Testing

Selanjutnya, kita akan membuat unit test untuk membandingkan hasil dari kedua fungsi tersebut

Kita bisa lihat hasilnya pada gambar berikut, bahwa TestGetGitHubTopicWithSingleflight hanya melakukan 1 request ke github API, sedangkan TestGetGitHubTopic melakukan 10 kali requests.

Mengulik implementasi internal `Singleflight` di Golang

Yosha, sekarang kita akan menelisir bagaimana Singleflight diimplementasikan di Golang.

Source code lengkapnya bisa dilihat di sini. Kita akan fokus pada core logicnya.

type Group struct {
mu sync.Mutex // protects m
m map[string]*call // lazily initialized
}

Group digunakan untuk membuat instance Singleflight. mutex digunakan untuk mengamankan map pada proses concurrent, dan map sendiri berguna untuk menyimpan key (request identifier) dan Call (proses) yang sedang berjalan.

type call struct {
wg sync.WaitGroup
val interface{}
err error
dups int
chans []chan<- Result
}

Call digunakan untuk menyimpan hasil dari callback yang dijalankan oleh Do dan DoChan. WaitGroup digunakan untuk menunggu proses pemanggilan callback selesai. val merupakan hasil dari callback dan err menyimpan error yang dihasilkan dari callback. dups merupakan jumlah request yang menunggu. chans merupakan channel yang digunakan untuk mengirimkan hasil dari callback yang khusus untuk method DoChan.

type Result struct {
Val interface{}
Err error
Shared bool
}

Result digunakan sebagai kembalian / hasil dari method DoChan

func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool) {
g.mu.Lock()
if g.m == nil {
g.m = make(map[string]*call)
}
if c, ok := g.m[key]; ok {
c.dups++
g.mu.Unlock()
c.wg.Wait()

if e, ok := c.err.(*panicError); ok {
panic(e)
} else if c.err == errGoexit {
runtime.Goexit()
}
return c.val, c.err, true
}
c := new(call)
c.wg.Add(1)
g.m[key] = c
g.mu.Unlock()

g.doCall(c, key, fn)
return c.val, c.err, c.dups > 0
}

Method Do digunakan untuk menjalankan fungsi yang terkait dengan key tertentu dalam Group. method ini memastikan bahwa hanya satu eksekusi yang berjalan pada satu waktu untuk kunci yang sama. Jika ada panggilan duplikat, pemanggil duplikat akan menunggu hingga panggilan asli selesai dan menerima hasil yang sama.

Okei, Mari kita bahas potongan-potongan implementasi dari method Do ini.

 if g.m == nil {
g.m = make(map[string]*call)
}

Pada bagian ini, dilakukan pengecekan apakah map sudah diinisialisasi atau belum. Jika belum, maka kita akan menginisialisasi dengan map kosong (lazy initialization)

if c, ok := g.m[key]; ok {
c.dups++
g.mu.Unlock()
c.wg.Wait()

if e, ok := c.err.(*panicError); ok {
panic(e)
} else if c.err == errGoexit {
runtime.Goexit()
}
return c.val, c.err, true
}

Pada bagian ini, dilakukan pengecekan apakah key sudah ada di dalam map atau belum. Jika sudah ada, maka akan mengambil call yang terkait dengan key tersebut. Jika belum ada, maka nantinya akan dibuat instance call yang baru.

ketika key sudah ada di dalam map, maka dups akan di increment dengan 1. dups merupakan banyak request dengan key yang sama yang menunggu hasil dari callback yang dijalankan oleh Do dan DoChan. Setelah itu, menunggu semua request/goroutine dengan key yang sesuai selesai dengan waitgroup. Setelah itu selesai,maka akan mengembalikan hasil dari callback.

c := new(call)
c.wg.Add(1)
g.m[key] = c
g.mu.Unlock()

g.doCall(c, key, fn)
return c.val, c.err, c.dups > 0

Ketika key belum ada di dalam map, maka akan dibuat instance call yang baru, menambahkan wg dengan 1, dan menyimpan call tersebut ke dalam map. Setelah itu, dilakukan unlock / release pada mutex dan menjalankan doCall untuk menjalankan callback.

func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result {
ch := make(chan Result, 1)
g.mu.Lock()
if g.m == nil {
g.m = make(map[string]*call)
}
if c, ok := g.m[key]; ok {
c.dups++
c.chans = append(c.chans, ch)
g.mu.Unlock()
return ch
}
c := &call{chans: []chan<- Result{ch}}
c.wg.Add(1)
g.m[key] = c
g.mu.Unlock()

go g.doCall(c, key, fn)

return ch
}

Implementasi method DoChan juga mirip dengan Do, bedanya kalau DoChan akan mengembalikan channel yang digunakan untuk mengirimkan hasil dari callback, dan callback akan dijalankan di goroutine yang berbeda.

Method DoChan ini cocok digunakan dalam kasus di mana si caller/pemanggil ingin menerima hasil secara asinkronus dari pemanggilan fungsi di Group. Dengan menggunakan DoChan, pemanggil dapat mendapatkan hasil melalui channel ketika hasil tersebut sudah tersedia, tanpa harus secara aktif / bengong 😆 menunggu pemanggilan selesai. Jadi, di caller dapat melakukan hal yang lain sembari menunggu hasil dari channel. Selanjutnya kita bahas bagian doCall

c.val, c.err = fn() ①

if g.m[key] == c { ②
delete(g.m, key)
}

for _, ch := range c.chans { ③
ch <- Result{c.val, c.err, c.dups > 0}
}

Kode diatas hanya sebagian kecil dari implementasi doCall, karena menurut saya ini merupakan bagian yang perlu untuk di highlight. Pada bagian dilakukan pemanggilan callback yang dijalankan oleh Do atau DoChan. Pada bagian key yang telah selesai dijalankan akan dihapus (forget) dari map supaya next request dengan key yang sama akan membuat instance Call yang baru (invalidate cache). Pada bagian dilakukan pengiriman hasil dari callback ke channel yang digunakan oleh DoChan.

Hmmm pusying? enggak lah ya, ini saya buatkan flow ketika method Do dipanggil:

  • Cek apakah key sudah ada di dalam map
  • jika sudah ada, maka tunggu sampai request sebelumnya selesai mengembalikan data
  • jika belum ada, maka buat instance call baru
  • callback function di proses
  • jika proses callback selesai, release waitgroup (c.wg.Done) yang yg ada di dalam Call
  • hapus key dari map (cache invalidation)
  • Return result

Kesimpulan

Jadi kesimpulannya, ya temen-temen pasti sudah tau lah ya. Silahkan gunakan singleflight jika ingin menghindari thundering herd ketika melakukan request ke service lain, database, dll. Tapi kita harus bijak kapan dan usecase apa aja yang cocok untuk menggunakan singleflight.

Terima kasih sudah membaca, semoga bermanfaat 😊

Referensi

--

--