omeryanbas.com

Ömer Yanbaş

Genel Müdür, Ticofab Yazılım

VeriOperasyon

Hiç gönderilmeyen kampanya: veritabanında saat dilimi

09:00'a kurulan iş hiçbir hata vermeden kuyrukta bekliyor. Uygulama yerel saati yazıyor, worker UTC okuyor, gerisini fark hallediyor.

Kampanya saat 09:00'a kuruluyor. 09:00 geliyor ve hiçbir şey olmuyor. Worker ayakta, başka işleri alıyor, kayıt ilk durumuyla tabloda duruyor, hiçbir log'da hata yok. Değer uygulama sunucusunun yerel saatiyle yazılmış, vadesi gelen işleri seçen sorgu ise UTC saatle karşılaştırıyor. Arada üç saat var, yani iş henüz vadesinde değil; bu hatanın en kötü halinde ise vadesi hiç gelmiyor.

Gerçekte ne oluyor

İşin içinde üç ayrı saat var ve her biri farklı kurulabiliyor: uygulama sunucusunun makine saati, veritabanı bağlantısının oturum saati ve zamanlayıcının karşılaştırmayı hangi saatle yaptığı.

Uygulama formdan 09:00 değerini alıyor, fark bilgisi olmayan düz bir metin olarak biçimlendiriyor ve kaydediyor. 2025-04-18 09:00:00 gibi bir metin, kendisini hangi saatin ürettiğine dair hiçbir bilgi taşımaz. Onu okuyan kim ne varsayıyorsa o anlama gelir.

Worker ise gayet makul görünen bir soru soruyor:

SELECT id FROM campaigns
WHERE status = 'SCHEDULED' AND send_at <= UTC_TIMESTAMP()
ORDER BY send_at LIMIT 50;

UTC'nin üç saat gerisinde olduğu bir bölgede saat 09:00 iken UTC saat 06:00'dır. Kayıt vadesinde değildir. Vadesi yerel saatle 12:00'de gelir, yani üç saat gecikmeyle, ve "günaydın" diye başlayan kampanya öğle vakti gider.

Bu, hatanın zararsız hali. İki varyantı daha kötü.

Birincisi ters yön. Uygulama UTC yazar, worker yerel saatle karşılaştırır; her şey üç saat erken gider ve bugün ilerisi için kurulmuş ne varsa anında tetiklenir.

İkincisi hiç göndermeyen hali. Zamanlayıcıların çoğu vadesi gelmiş her şeyi istemez, kesintiden sonra birikmiş yığını tek seferde tetiklememek için sadece kısa bir pencerede vadesi gelenleri ister:

WHERE send_at BETWEEN UTC_TIMESTAMP() - INTERVAL 5 MINUTE AND UTC_TIMESTAMP()

Üç saat kaymış bir kayıt, doğru durumdayken o pencerenin içine hiç düşmez. Tablo durdukça kuyrukta bekler ve kimseye hata gitmez, çünkü hiçbir şey hata vermemiştir. Çalışması gereken iş yalnızca seçilmemiştir.

Hatanın test aşamasında yakalanmamasının sebebi de sıradan. Geliştiricinin makinesi, uygulama sunucusu ve veritabanı çoğu zaman aynı bölgeye kurulur. Üçü aynı yanlışı paylaştığı sürece sonuç doğru çıkar. Fark ancak biri değiştiğinde, mesela veritabanı UTC'ye kurulu bir imaja taşındığında görünür olur ve o gün deploy yapılmadığı için kimse zamanlamadan şüphelenmez.

Kolon tipleri işin üstüne ikinci bir katman koyar. Saat dilimi taşımayan datetime kolonu rakamları saklar ve onlara hiçbir anlam iliştirmez. Timestamp kolonu ise bir anı saklar, oturumun saat dilimine göre girerken ve çıkarken çevirir; yani farklı ayarlı iki bağlantı aynı satırdan farklı değer okur. Şemada ikisi birden varsa ekranda birbirinin aynı görünen iki kolon aynı sorgu altında farklı davranır.

Nasıl görülür

Tek sorgu meseleyi kapatır. Kayıtlı değeri, iki saati ve oturum ayarlarını yan yana koyun:

SELECT id,
       send_at,
       NOW()                                           AS db_local_now,
       UTC_TIMESTAMP()                                 AS db_utc_now,
       @@session.time_zone                             AS session_tz,
       @@global.time_zone                              AS global_tz,
       TIMESTAMPDIFF(MINUTE, send_at, UTC_TIMESTAMP()) AS minutes_past_due
FROM campaigns
WHERE id = 4721;
send_at              2025-04-18 09:00:00
db_local_now         2025-04-18 09:02:11
db_utc_now           2025-04-18 06:02:11
session_tz           SYSTEM
global_tz            SYSTEM
minutes_past_due     -178

Değer bir saate göre vadesinde, diğerine göre değil. Duvardaki saat dokuzu geçtiğini söylerken minutes_past_due eksi çıkıyor. Hatanın tamamı bu tek ekranda.

Sonra uygulama sunucusuyla karşılaştırın:

date +"%F %T %Z"; date -u +"%F %T"
# 2025-04-18 09:02:14 +03
# 2025-04-18 06:02:14

Sunucu yerel bir bölge yazdırıyorsa ve worker sorgusu UTC fonksiyonu kullanıyorsa, çelişen çifti bulmuşsunuz demektir. Veritabanı oturumu SYSTEM diyorsa cevap bir makinedeki ayara bağlıdır; deploy yapmadan değişen kısım da tam olarak orasıdır.

Çözüm

Anı UTC saklayın, dönüşümü girişte bir kez yapın, biçimlendirmeyi kenarda yapın. Pratikte bu dört değişiklik demek.

Kullanıcının niyeti hâlâ biliniyorken çevirin. Saat dilimini form bilir, veritabanı bilmez:

// yanlış: fark taşımayan metin, okuyan ne varsayıyorsa o anlama gelir
const sendAt = '2025-04-18 09:00:00';

// doğru: kullanıcının seçtiği bölgeyle, kenarda, bir kez çevrilmiş an
const sendAt = toUtcInstant('2025-04-18 09:00', 'Europe/Istanbul');
// 2025-04-18T06:00:00Z

Oturumu sabitleyin ki davranış makineye bağlı kalmasın. Bunu her bağlantıda, pool kurulumunun içinden çalıştırın; birinin hatırlayıp elle çalıştırdığı bir betikten değil:

SET time_zone = '+00:00';

Kullanıcının saat dilimini ayrı kolonda tutun. Bir kolon an için, bir kolon bölge adı için, tekrar eden zamanlamalarda bir kolon da kullanıcının yazdığı duvar saati için. Tek seferlik gönderim için an yeterlidir. Tekrar eden her şeyde duvar saati ve bölge adı gerekir ki bir sonraki tekrar, o tarihte geçerli olan kurala göre hesaplanabilsin.

Worker'da aynı cinsi aynı cinsle karşılaştırın. Kolon UTC tutuyorsa karşılaştırma UTC fonksiyonuyla yapılır ve kodda aynı kolona karşı hem NOW() hem UTC_TIMESTAMP() kullanan tek bir yer bile kalmaz.

Elinizde zaten yanlış kayıtlar varsa bunları uygulama içinde fark ekleyerek değil, açıkça düzeltin:

UPDATE campaigns
SET send_at = CONVERT_TZ(send_at, '+03:00', '+00:00')
WHERE status = 'SCHEDULED' AND send_at >= '2025-04-01 00:00:00';

Bunu bir kez, aralığı da not ederek yapın; okuma yoluna kalıcı bir düzeltme koymayın. Okuma yoluna konan düzeltme, sistemin iki ayrı kurala sahip olmasına ve hangi satırın hangi kurala uyduğunun anlaşılamamasına giden yoldur.

Çalıştığını nasıl doğrularsınız

Veritabanına aynı satırı üç şekilde yazdırın ve iki dakika sonrasına bir iş kurun:

SELECT send_at                                        AS utc_instant,
       CONVERT_TZ(send_at, '+00:00', '+03:00')        AS shown_to_user,
       TIMESTAMPDIFF(SECOND, UTC_TIMESTAMP(), send_at) AS seconds_until_due
FROM campaigns WHERE id = 4722;
utc_instant        2025-04-18 06:00:00
shown_to_user      2025-04-18 09:00:00
seconds_until_due  118

Kayıtlı değerle ekrandaki değer tam olarak fark kadar ayrışıyor, istenen de bu. Geri sayım da kol saatiyle uyuşuyor. Sonra işi çalıştırın ve hedefin birkaç saat değil birkaç saniye içinde başladığını görün.

Nelere dikkat etmeli

  • Fark ile bölge aynı şey değildir. Yaz saati uygulayan bir bölge için artı iki saat yazmak yılın yarısında doğrudur. Bölge adı saklayın, farkı o tarih için saat dilimi veritabanı çözsün.
  • Bölge kuralları kanunla değişir. Çalıştığım bir ülke 2016'da sabit farka geçip saat değiştirmeyi bıraktı; bu, koda gömülmüş bütün yaz saati kurallarını ve saat dilimi verisi güncellenmemiş bütün makineleri bozdu. Saat dilimi verisini sistemin geri kalanıyla birlikte güncelleyin, eski kalmış bir bölge dosyasını üretim hatası sayın.
  • Sunucu taşıma, container'ı yeniden kurma veya temel imaj güncellemesi, deponuzda hiçbir şey değişmeden makinenin saat dilimini değiştirebilir. Makine saatine bağlı olan her şey sessizce anlam değiştirir; bu da yeniden başlatmadan sonra uygulamayı geri getirecek bir ayarın eksik olmasıyla aynı sınıftan bir sürprizdir.
  • Yılda iki bozuk gün gerçektir. İleri alınan gecede bir duvar saati hiç yaşanmaz, geri alınan gecede iki kez yaşanır. Atlanan bir işin bir sonraki geçerli dakikada mı çalışacağına yoksa hiç çalışmayacağına önceden karar verin ve işin iki kez çalışmasını zararsız hale getirin.
  • Kuyruktan hiç çıkmayan kampanya ile zamanında çıkıp From alan adı hizalı olmadığı için spam'e düşen kampanya, yalnızca gönderim sayan bir panelde birbirinin aynı görünür. Zamanlanan, başlayan ve teslim edilen sayılarını ayrı takip edin, yoksa yanlış şeyi düzeltirsiniz.

Saat dilimi hataları sessizdir, çünkü hiçbir şey hata vermez. Her parça kendisine söyleneni yapar, kayıt durumunu korur, log'lar temiz kalır ve elinizdeki tek delil, olması gereken bir şeyin olmamasıdır. Bunu önleyen alışkanlık sıkıcı ve ucuzdur: depoda tek saat, her kenarda tek dönüşüm, değerin yanında duran bölge adı ve on saniyede çalıştırabileceğiniz, iki saati satırla yan yana gösteren bir sorgu. Bir zamanlama tuhaf davrandığında, worker'ın tek satırını okumadan önce o sorguyu çalıştırın.

Sorular ve cevaplar

Zamanlanmış işim neden saatler sonra çalışıyor ya da hiç çalışmıyor?
Neredeyse her zaman değer bir saatle yazılıp başka bir saatle okunduğu için. Uygulama yerel duvar saatini yazıyor ve worker bunu UTC saatle karşılaştırıyorsa, iş ancak UTC saat yetiştiğinde yani fark kadar sonra sıraya girer. Worker sadece son birkaç dakikada vadesi gelen kayıtları alıyorsa o satır hiç seçilmez ve durumu değişmeden yerinde kalır.
Tarihleri UTC olarak mı yoksa yerel saatle mi saklamalıyım?
Anı UTC olarak saklayın, gösterirken çevirin. Değeri yeniden hesaplamanız veya yeniden göstermeniz gerekiyorsa kullanıcının seçtiği saat dilimini de saklayın, fark olarak değil bölge adı olarak. Böylece kayıtlı anın anlamı hiç değişmez, yerel gösterim ise o tarihte geçerli olan kurala göre hesaplanır.
Saat dilimi taşımayan datetime kolonu ile timestamp kolonu arasındaki fark ne?
Datetime kolonu duvar saati rakamlarını saklar, üzerine hiçbir fark iliştirmez, yani okuyan kim varsayıyorsa o anlama gelir. Timestamp kolonu ise bir anı saklar ve oturumun saat dilimine göre girerken ve çıkarken çevirir, bu yüzden aynı satır iki farklı bağlantıdan farklı okunabilir. İkisini aynı şemada karıştırmak, ekranda birbirinin aynı görünen ama farklı davranan iki kolon demektir.
Tekrar eden zamanlamalarda yaz saatini nasıl yönetmeliyim?
Tekrar eden her şey için duvar saatini ve bölge adını saklayın, bir sonraki anı sabit fark yazarak değil çalışma anında hesaplayın. Yaz saati uygulayan bir bölgede 09:00 ocak ayında başka, temmuzda başka bir andır. Ayrıca yılda iki kez yaşanan bozuk günlerde, yani bir duvar saatinin hiç var olmadığı veya iki kez yaşandığı günlerde ne olacağına önceden karar verin.