Randevu sistemi tasarımı: çakışmalar, iptaller, hatırlatmalar
Basit görünen takvimin altındaki mayın tarlası: PsyBank'ın randevu motorundan edinilen dersler.
Bir terapistin takviminde iki farklı danışanın aynı saate düştüğünü ilk kez gördüğümüzde, hata kodda değildi. Hata, takvimi "basit bir CRUD ekranı" sanmamızdaydı. Ekranda güzel görünen o saat kutucuğu, arka planda saat dilimlerinin, para iadelerinin, yarış koşullarının ve insan psikolojisinin kesiştiği en zorlu ürün problemlerinden biriydi. PsyBank'ta 6.443'ten fazla terapistin ve 101.446'yı aşkın danışanın takvimini yönetirken, bu görünmez zorlukları tek tek deneyerek öğrendik.
Randevu sistemi, yazılımın en yanıltıcı alanlarından biridir. Demosu on dakikada yapılır; üretimde on ay dayanması ise bambaşka bir iştir. Çünkü randevu, saf teknik bir problem değil — teknik kararların ürün kararlarına, ürün kararlarının da danışanın güvenine dönüştüğü bir yer. Bir danışan seansına gelmediğinde bunun faturasını kim öder? Terapist Berlin'de, danışan İstanbul'da ise "14:00" kimin saatidir? İki kişi aynı milisaniyede "randevu al" butonuna basarsa ne olur? Bu soruların cevabı, veritabanı şemanızda ve iade politikanızda saklıdır.
Bu yazıda takvimin görünmez zorluklarını dört başlık altında anlatacağız: saat dilimleri, çift rezervasyon yarışları, terapist müsaitliğinin gerçek zamanlı tutulması ve iptal-iade-hatırlatma üçlüsünün no-show oranına etkisi. Her bölümde verdiğimiz somut kararları, ölçtüğümüz rakamları ve kaçındığımız anti-pattern'leri paylaşacağız. Yıllar bize şunu öğretti: randevu sistemi, bir ürünün olgunluğunu ölçen en dürüst testtir.
Saat dilimleri: takvimin sessiz baş belası
Randevu sistemlerinde yaşanan hataların bizce en sinsi kaynağı saat dilimleridir. Sinsi olmasının sebebi, çoğu zaman geliştirici ekibin de danışanın da aynı saat diliminde olması; hata ancak biri yurt dışına çıktığında ya da yaz saati uygulaması değiştiğinde ortaya çıkıyor. O zamana kadar her şey çalışıyor gibi görünüyor.
İlk ve en önemli kararımız şuydu: her randevunun başlangıç ve bitişini veritabanında UTC olarak saklamak, gösterimi ise her zaman kullanıcının IANA saat dilimi kimliğiyle yapmak. Yani veritabanında "14:00 +03:00" gibi bir offset değil, saf UTC zaman damgası tutuyoruz; danışanın profilinde ise Europe/Istanbul, terapistin profilinde Europe/Berlin gibi bir bölge kimliği duruyor. Bu ayrım küçük görünür ama kritiktir. Offset saklayan bir sistem, yaz saati geçişini asla doğru yönetemez; çünkü offset zamana göre değişen bir şeydir, sabit değil.
Zamanı offset ile saklamak, bir adresi enlem-boylam yerine "buradan sağa 200 metre" diye tarif etmeye benzer. Referans noktası kayınca her şey kayar.
En çok ders aldığımız senaryo, tekrarlayan randevularda yaz saati geçişiydi. Bir danışan terapistiyle "her salı 18:00" diye haftalık seans ayarladığında, biz bunu sabit bir UTC zamanı olarak değil, "Europe/Istanbul saatiyle salı 18:00" kuralı olarak saklarız. Böylece yaz saati uygulaması geçişinde danışanın duvar saatindeki 18:00 korunur — UTC değeri kayar ama danışan için hiçbir şey değişmez. Eğer bunu ham UTC damgasıyla saklasaydık, geçiş haftasında seans danışanın gözünde bir saat kayardı ve bu, en kötü anda fark edilirdi: danışan ekranın başında beklerken.
Bir başka somut ders: sunucularımızın işletim sistemi saat dilimini asla varsaymayız. Tüm servisler UTC ile çalışır, loglar UTC yazar, cron işleri UTC'ye göre planlanır. "Sunucu Türkiye saatinde" varsayımı, AWS bölgesi değiştiğinde ya da bir konteyner farklı bir host'a kaydığında sessizce çöker. Yaşadık, öğrendik, bir daha yapmadık.
Somut bir akış üzerinden bakalım. Berlin'deki bir terapist, panelinden salı günü için 18:00-19:00 arası bir müsaitlik bloğu açıyor. Bu blok bizde Europe/Berlin bölgesiyle birlikte saklanıyor; ilgili UTC aralığı o hafta için 16:00-17:00 UTC oluyor. İstanbul'daki bir danışan aynı slotu arama ekranında görüntülediğinde, biz UTC aralığını danışanın Europe/Istanbul bölgesine çevirip ekranda 19:00-20:00 yazıyoruz. İki taraf da kendi duvar saatini görüyor, veritabanında ise tek ve tartışmasız bir UTC damgası duruyor. Danışan onayladığında görüşme linki, her iki panelde de o kişinin yerel saatiyle beliriyor. Bu akışın her adımında dönüşümü gösterim katmanında yapmak, sakınılması gereken tek şeydir: veriyi asla yerel saate çevirip öyle saklamayız.
Çift rezervasyon yarışı: optimistic lock ve transaction
Şimdi randevu sisteminin en klasik ve en tehlikeli anına geldik: iki danışan, aynı terapistin aynı boş saatine, neredeyse aynı anda "randevu al" diyor. Uygulama katmanında "önce boş mu diye kontrol et, boşsa yaz" mantığı yazarsanız, kod okurken kusursuz görünür. Üretimde ise iki isteğin ikisi de "boş" cevabını alıp ikisi de yazabilir. Bu, kitaplardaki adıyla klasik bir check-then-act yarış koşuludur ve trafiği düşük bir platformda haftalarca gizlenip yoğun bir kampanya gününde ortaya çıkar.
Bu problemi uygulama kodunda "çözmeye" çalışmak, bizim gördüğümüz en yaygın anti-pattern. Mutex, önce-oku-sonra-yaz, iki kere kontrol et... hepsi tek bir servis örneğinde çalışır, yatayda ölçeklenince dağılır. Doğru yer, doğruluğun garanti edilebileceği tek katmandır: veritabanının kendisi. PostgreSQL kullandığımız için, çakışmayı bir dışlama kısıtı (exclusion constraint) ile veritabanı seviyesinde imkânsız hale getirdik.
CREATE TABLE randevu (
id bigserial PRIMARY KEY,
terapist_id bigint NOT NULL,
aralik tstzrange NOT NULL, -- [baslangic, bitis) UTC
durum text NOT NULL DEFAULT 'onayli',
EXCLUDE USING gist (
terapist_id WITH =,
aralik WITH && -- aralik cakismasi yasak
) WHERE (durum <> 'iptal')
);
Bu tek kısıt, "aynı terapist için, iptal olmayan iki randevunun zaman aralığı çakışamaz" kuralını fiziksel olarak dayatıyor. İki eşzamanlı transaction aynı slota yazmaya çalıştığında, veritabanı ikincisini reddediyor; uygulama katmanı bu reddi yakalayıp danışana "bu saat az önce doldu, komşu saatlerden birini seçin" diyor. Yarışın kazananı belli, kaybedeni nazikçe yönlendiriliyor ve hiçbir durumda çift kayıt oluşmuyor. Bu kısıtı ekledikten sonra çift rezervasyon kaynaklı destek talebi bizde sıfıra indi.
Dışlama kısıtının bir başka faydası, transaction izolasyon seviyeleriyle uğraşmaktan bizi kurtarmasıydı. Uygulama katmanında yarışı çözmeye çalışsaydık, doğru sonucu almak için serializable izolasyona çıkmamız ve serialization hatalarını elimizle yeniden denememiz gerekirdi — hem karmaşık hem de yük altında performansı düşen bir yol. Kısıtı veritabanına gömünce, sıradan bir transaction içinde INSERT yeter; çakışma varsa veritabanı zaten reddeder. Bir gerçekçi ölçü: en yoğun akşam saatlerinde bile bu reddedilen yazımların oranı toplam rezervasyon denemelerinin binde birinin altında; yani danışanın "bu saat doldu" mesajıyla karşılaşma ihtimali çok düşük ama sistemin doğruluğu her koşulda tam.
Peki ya optimistic lock? Onu da kullanıyoruz ama farklı bir yerde: terapistin kendi takvim ayarlarını (müsaitlik bloklarını, mola saatlerini) düzenlediği ekranlarda. Terapist iki sekmede aynı anda takvimini düzenliyorsa, satırdaki bir version sütununu kontrol ederek geç kalan yazımı reddediyoruz. Kural şudur: çakışma domain kuralıysa dışlama kısıtı kullan, çakışma "kimin yazımı kazanacak" sorunuysa optimistic lock kullan. İkisini karıştırmak, ya gereksiz kilitlenmeye ya da sessiz veri kaybına yol açar.
Terapist müsaitliğini Redis ile gerçek zamanlı tutmak
Veritabanı doğruluğun son sözünü söylese de, her "boş saat var mı" sorgusunu PostgreSQL'e sormak ne akıllıca ne de gerekli. 6.443 terapistin haftalık takvimi, danışanların arama ekranında saniyede yüzlerce kez sorgulanıyor; her filtreleme, her takvim açılışı için diske gitmek hem yavaş hem israf. Bu yüzden terapist müsaitliğini Redis'te gerçek zamanlı bir katman olarak tutuyoruz. Redis burada bir önbellek değil, canlı bir çalışma alanı.
Mimarinin kalbi iki fazlı bir rezervasyon akışı. Danışan bir saati seçtiğinde onu hemen veritabanına yazmayız; önce Redis'te o slotu kısa süreli "tutarız" (soft-hold). Danışan ödeme adımını tamamlarsa tutuş kalıcı kayda dönüşür; tamamlamazsa tutuş kendiliğinden buharlaşır ve slot yeniden boşalır. Bu, gerçek hayattaki "sepette bekleyen bilet" mantığının randevuya uyarlanmış hâli.
func (b *Booking) Hold(ctx context.Context, slot string, danisan int64) (bool, error) {
key := "hold:" + slot
// SETNX + TTL: slot 90 saniye boyunca bu danisana kilitli kalir
ok, err := b.rdb.SetNX(ctx, key, danisan, 90*time.Second).Result()
if err != nil {
return false, err
}
return ok, nil // ok=false ise slot baskasi tarafindan tutuldu
}
Bu SetNX (set if not exists) çağrısı atomiktir: yalnızca anahtar yoksa yazar ve true döner. İki danışan aynı slota aynı anda dokunursa, Redis birine true birine false verir — yarış, milisaniye altında ve tek bir ağ turunda çözülür. 90 saniyelik TTL ise en sevdiğimiz detay: tutuşu temizlemek için ayrı bir arka plan işine, "terk edilmiş sepet" taramasına ihtiyacımız yok. Danışan ödemeyi bitirmezse slot kendiliğinden geri gelir. Zamanlayıcıyı Redis'e devretmek, yıllar içinde bakımını yaptığımız en az sayıda satır kod demek.
Müsaitlik verisini Redis'te tutarken bir uyarıyı hatırlatalım: Redis doğruluğun kaynağı değildir, PostgreSQL'dir. Redis'i, veritabanındaki kalıcı randevulardan türetilen hızlı bir izdüşüm olarak görürüz. Bir tutarsızlık şüphesinde ya da Redis yeniden başladığında, müsaitlik gridini veritabanından yeniden inşa ederiz. Redis'i tek gerçek kaynak yapmak, elektrik kesintisinde randevuları kaybetmek demek olurdu — sağlık verisiyle çalışan bir üründe kabul edilemez. Bu iş bölümü nettir: Redis hızı ve gerçek zamanlılığı, PostgreSQL kalıcılığı ve doğruluğu sağlar.
Müsaitlik gridini nasıl kurduğumuz da ders niteliğinde. Her terapist için önümüzdeki iki haftalık slot durumunu Redis'te bir hash olarak tutuyoruz: anahtar terapist ve gün, alanlar ise o günün yarım saatlik dilimleri. Bir randevu onaylandığında ya da iptal olduğunda, ilgili günün grid'ini nokta atışı güncelliyoruz — tüm önbelleği süpürmüyoruz. Bu "hedefli geçersiz kılma" (targeted invalidation), en çok hata yaptığımız ve sonunda en çok işimize yarayan detaylardan biri oldu. Tüm önbelleği temizlemek kolaydır ama yoğun saatte veritabanına gereksiz bir yük dalgası bindirir; sadece değişen günü tazelemek ise hem ucuz hem doğru. Grid'i tümden yeniden inşa etmeyi yalnızca gece düşük trafikte, bir tutarlılık ağı olarak çalıştırıyoruz.
Terapist "çevrimiçi/müsait" durumunu da aynı katmanda tutuyoruz. Terapist panelini açtığında bir presence anahtarı yazıyoruz, kısa TTL ile; istemci düzenli olarak yeniliyor. Terapistin sekmesi kapanır ya da interneti giderse anahtar süresi dolup düşüyor ve danışan arama ekranında "şu an müsait" rozeti kayboluyor. Bu, WebRTC görüşme altyapımızla da uyumlu: aynı Redis örneği hem müsaitlik durumunu hem oturum yönlendirmesini besliyor.
İptal ve iade: teknik değil, bir ürün kararı
Buraya kadar anlattıklarımız mühendislik problemleriydi. Şimdi randevu sisteminin en çok tartışılan ve en az kodla ilgili kısmına geliyoruz: iptal ve iade politikası. Bu bölümün teknik kısmı yarım gün sürer; doğru politikayı bulmak ise haftalar. Çünkü her iptal kuralı, iki taraf arasında bir güven pazarlığıdır. Danışanı korursanız terapistin geliri belirsizleşir; terapisti korursanız danışan platformu riskli bulur.
PsyBank'ta üzerinde uzun düşündüğümüz kademeli bir model kurduk. Kararların hepsi, "bu saati başkasına satabilir miydik" sorusunun etrafında şekillendi:
- 24 saatten önce iptal: tam iade. Terapistin o slotu yeniden doldurmak için bolca zamanı vardır; kimse mağdur olmaz.
- 24 ile 4 saat arası iptal: kısmi iade — seans ücretinin yarısı. Slotu doldurma ihtimali düşer ama sıfır değildir; maliyeti iki taraf paylaşır.
- Son 4 saat içinde iptal veya no-show: iade yok. Terapistin o zaman dilimini artık satması imkânsıza yakındır; ayrılan zamanın karşılığı ödenir.
- Terapist kaynaklı iptal: her koşulda tam iade ve danışana öncelikli yeniden randevu hakkı. Kusur platformdaysa faturayı danışan ödemez — bu ilke pazarlık konusu değildir.
- Teknik arıza kaynaklı kesinti: görüşme bizim tarafımızdaki bir sorunla düşerse seans telafi edilir ya da tam iade yapılır; kanıt, sunucu tarafındaki oturum kayıtlarımızdır.
Bu kademeleri koda dökerken öğrendiğimiz en önemli şey, iade tutarını hesaplayan mantığın iptal anındaki koşullara göre değil, iptalin gerçekleştiği ana göre dondurulması gerektiğiydi. Politika ileride değişebilir; ama bir danışan altı ay önce iptal ettiği bir seansın iade oranını sorduğunda, ona o günkü kuralın cevabını vermeliyiz. Bu yüzden her iptal olayının yanına, uygulanan politikanın anlık bir kopyasını (kademe, oran, gerekçe) yazıyoruz. Denetlenebilirlik, sağlık ve ödeme verisiyle çalışan bir üründe lüks değil, zorunluluk.
Bir başka ürün kararı: iptal butonunu gizlemedik. Kimi platform, iptali zorlaştırarak iade maliyetini düşürmeye çalışır. Biz tersini yaptık — iptal akışını şeffaf ve tek tıkla erişilebilir tuttuk, ama iptal ekranında danışana ne kadar iade alacağını ve seansı ertelemenin (iptal yerine) bir seçenek olduğunu net gösterdik. Sonuç: iptallerin kayda değer bir kısmı, iptal yerine erteleme ile sonuçlandı. Danışan da terapist de kazandı. Yıllar bize şunu öğretti: sürtünme yaratarak kazanılan gelir, kaybedilen güvenin yanında pahalıya patlar.
Hatırlatmalar ve no-show: kanal seçimi bir mühendislik kararıdır
Randevu sisteminin ekonomik başarısını belirleyen tek bir sayı varsa, o da no-show oranıdır — yani danışanın haber vermeden seansa gelmemesi. Yüksek no-show, terapistin gelirini eritir, takvimini çöpe çevirir ve platformdan soğumasına yol açar. Bu oranı düşürmenin en güçlü kaldıracı ise pahalı bir yapay zekâ değil, doğru zamanda gönderilen basit bir hatırlatmadır.
Hatırlatmayı zamanlarken yine Redis'e güvendik. Randevu onaylandığında iki hatırlatmayı önceden planlıyoruz: biri 24 saat önce, biri 1 saat önce. Bunları bir Redis sıralı kümesine (sorted set), gönderim zaman damgasını skor olarak vererek yazıyoruz. Bir işçi süreç, kümenin başındaki "zamanı gelmiş" üyeleri düzenli olarak çekip gönderiyor.
// Randevu onaylaninca iki hatirlatma zamanla
func (r *Reminder) Planla(randevu Randevu) {
for _, offset := range []time.Duration{24 * time.Hour, 1 * time.Hour} {
t := randevu.Baslangic.Add(-offset)
r.rdb.ZAdd(ctx, "hatirlatma:kuyruk", redis.Z{
Score: float64(t.Unix()),
Member: key(randevu.ID, offset), // benzersiz: idempotency
})
}
}
Buradaki Member alanının randevu kimliği ve offset ile benzersiz olması tesadüf değil. Dağıtık bir sistemde işçi süreç aynı hatırlatmayı iki kez çekmeye kalkışabilir; benzersiz üye ve gönderim öncesi bir "gönderildi" işareti sayesinde idempotency garanti altında. Danışana aynı SMS'i iki kez göndermek, hatırlatmamaktan daha çok güven zedeler. Zamanlanmış işleri bir sorted set üzerinden yürütmek, ayrı bir kuyruk altyapısı kurmadan zamanlama ihtiyacımızın neredeyse tamamını karşıladı.
Kanal seçimini ise ölçerek yaptık, tahminle değil. Denediğimiz ve öğrendiğimiz şuydu:
- E-posta ucuzdur ama açılma oranı düşüktür; onay ve makbuz için iyi, son dakika hatırlatması için zayıf.
- Push bildirimi uygulamayı yüklemiş danışan için mükemmel ama web kullanıcısını yakalayamaz.
- SMS en yüksek görülme oranını verdi; 1 saat kala gönderilen SMS, no-show'a karşı en etkili tek kanaldı.
Katmanlı bir strateji kurduk: onay ve 24 saat hatırlatması e-posta ve push ile, son 1 saat hatırlatması SMS ile. Bu düzeni devreye aldıktan sonra no-show oranımız %19 seviyesinden %7'ye indi. Bunun terapist gelirine ve platform memnuniyetine katkısı, geliştirme maliyetinin defalarca üzerindeydi. Somut bir örnek: haftada 20 seans yapan bir terapist için bu, ayda ortalama 10 boşa giden slotun geri kazanılması demek.
No-show bir davranış problemidir, algoritma problemi değil. Doğru kanaldan, doğru anda, tek cümlelik bir hatırlatma; en zeki tahmin modelinden daha çok seans kurtarır.
Zamanlamanın ince ayarını da veriyle bulduk. Başta tek bir hatırlatmayı seanstan 2 saat önce gönderiyorduk; ölçtüğümüzde bunun ne çok erken (unutuluyor) ne çok geç (danışan zaten yola çıkmış ya da vazgeçmiş) olduğunu gördük. 24 saat ve 1 saat ikilisi, iki farklı işi görüyor: 24 saatlik hatırlatma danışana iptal ya da erteleme için hâlâ vakit var mesajını verip son dakika no-show'unu baştan eritiyor; 1 saatlik hatırlatma ise seansı fiilen unutmuş olanı masaya oturtuyor. Terapist kaynaklı gecikmeleri de aynı boru hattından yönetiyoruz: terapist son anda müsait olamazsa, danışana giden mesaj otomatik olarak iptal edilip yerine "terapistiniz sizi bekliyor olacak" onayı geçmiyor — yanlış hatırlatma, hiç hatırlatmamaktan daha zararlıdır.
Son bir detay: hatırlatma mesajının içine tek tıkla "onaylıyorum" ve "ertelemem gerekiyor" bağlantıları koyduk. Erteleme niyetini önceden yakalamak, o slotu bir başka danışana açma şansı verdi. Böylece hatırlatma yalnızca bir uyarı değil, takvimi kendi kendine iyileştiren bir mekanizma hâline geldi.
Sonuç: takvim, ürünün olgunluk testidir
Randevu sistemini yeniden kursak, sıralamayı değiştirmezdik. Önce doğruluğu veritabanına gömerdik — çünkü uygulama katmanında "çözülen" yarış koşulları üretimde geri gelir. Sonra hızı ve gerçek zamanlılığı Redis'e verirdik, ama onu asla tek gerçek kaynak yapmadan. İptal ve iade politikasını mühendislik değil ürün toplantısında konuşurduk, çünkü o kararlar kodda değil danışanın güveninde yaşıyor. Ve hatırlatmayı en sona değil en başa koyardık; çünkü no-show'u düşürmeden takvimin geri kalanı boşuna işler.
Benzer bir sistem kuracaksanız net tavsiyemiz şu: takvimi "CRUD ekranı" olarak görmeyi bırakın. Onu, saat dilimlerinin, yarış koşullarının, para akışının ve insan davranışının kesiştiği bir sistem olarak tasarlayın. Çakışmayı veritabanına imzalatın, müsaitliği gerçek zamanlı tutun, iade politikasını dondurup denetlenebilir kılın, hatırlatmayı ölçerek katmanlayın. Bu dört kararı baştan doğru verirseniz, geri kalanı gerçekten "basit bir takvim" olur. Vermezseniz, o güzel görünen saat kutucuğu size aylarca destek talebi olarak geri döner.