İş Kuyruğu ve Worker Sistemi
Kullanıcıyı bekletmeyin: işi kuyruğa bırakın, arka planda bir worker çalıştırsın. Yarışsız iş kapma, üstel geri çekilmeli yeniden deneme, ölü mektup kutusu ve takılmış işlerin kurtarılması — Redis gerekmeden, elinizdeki MySQL ile.
Ekran Görüntüleri 6 görsel
Bu Örnek Ne Yapıyor?
- Aynı işi iki worker alamaz: kontrol ve yazma tek UPDATE ifadesinde
- SKIP LOCKED gerektirmez — MySQL 5.7 ve MariaDB 10.3 uyumlu
- Üstel geri çekilmeli yeniden deneme: 10 sn → 20 sn → 40 sn, tavan 1 saat
- Deneme hakkı dolan iş silinmez, ölü mektup kutusuna taşınır
- Takılmış iş ile çalışan iş ayrı rozetlerle gösterilir ve kurtarılabilir
- Hata mesajı saklanır, yığın izi saklanmaz — dosya yolları sızmaz
- Tanınmayan iş sınıfı çalıştırılmaz (is_subclass_of denetimi)
- Adlandırılmış kuyruklar ve iş başına deneme hakkı
- Redis yok: elinizdeki MySQL ve tek bir bileşik indeks yeterli
Nerede İşe Yarar?
- Kayıt veya sipariş sonrası e-posta, rapor ve bildirim gönderenler
- "Sayfa neden 8 saniye açılıyor?" sorusuyla boğuşanlar
- Redis kurmadan, elindeki MySQL ile kuyruk isteyenler
- Yeniden deneme, geri çekilme ve ölü mektup kutusunu öğrenmek isteyenler
Gereksinimler
- PHP 8.0+ (CLI erişimi) · MySQL 5.7+ / MariaDB 10.3+ · pdo_mysql · mbstring · Apache mod_rewrite
Nasıl Kurulur?
- Depoyu klonlayın veya ZIP olarak indirip web kökünüze açın
- database.sql dosyasını içe aktarın (veritabanını kendisi oluşturur)
- .env.example dosyasını .env adıyla kopyalayıp veritabanı bilgilerinizi yazın
- Tarayıcıdan açıp [email protected] / Admin1234 ile giriş yapın
- Ayrı bir terminalde php bin/worker.php --max=5 çalıştırıp akışı izleyin
Veritabanı şeması projedeki database.sql dosyasında.
Nasıl Çalışıyor?
İş Kuyruğu ve Worker Sistemi
PHP 8 · PDO · MySQL · Oturum Girişli Panel · Yeniden Deneme · Ölü Mektup Kutusu · Çılgın Yazılım Tasarım Kalıbı
Kullanıcıyı bekletmeyin: işi kuyruğa bırakın, arka planda bir worker çalıştırsın.
🇹🇷 Türkçe · 🇬🇧 English
Canlı Demo
Kurulum yok, kayıt yok, indirme yok — tarayıcınızdan 3 saniyede deneyin.
▲ Görsele tıklayarak demoyu açabilirsinizDemo hesapları
| Rol | E-posta | Parola |
|---|---|---|
| Yönetici | [email protected] | Admin1234 |
| Kullanıcı | [email protected] | Demo1234 |
Giriş ekranındaki hazır düğmelerle alanları tek tıkla doldurabilirsiniz.
Demoda 60 saniyede neleri deneyebilirsiniz?
| # | Şunu deneyin | Perde arkasında ne oluyor? |
|---|---|---|
| 1 | Kuyruk sayfasını açın ve dört sayaca bakın | Bekleyen · Çalışıyor · Ertelenmiş · Başarısız. Dördü tek bir SUM(CASE WHEN ...) sorgusundan gelir; dört ayrı COUNT atılmaz |
| 2 | Tablodaki #106 satırına bakın: rozeti Takılmış diyor | O işi bir worker kaptı ama bitirmedi (süreç çöktü). reserved_at 5 dakikadan eskiyse iş "çalışıyor" değil, takılmıştır. İkisini aynı rozetle göstermek sorunu gizlerdi |
| 3 | "Takılanları kurtar" düğmesine basın | reserved_at temizlenir, iş kuyruğa geri döner. Eşik, Queue::STUCK_AFTER ile rozetinkiyle aynı sabittir — ekranda takılmış görünen işi düğme mutlaka bulur |
| 4 | #107 satırında 2 / 3 ve kırmızı son hata yazısına bakın | Kararsız iş iki kez denendi. Üstel geri çekilme yürürlükte: 10 sn → 20 sn → 40 sn. Hemen tekrar denemek, düşmüş bir servisi daha da yormaktır |
| 5 | Başarısız İşler sayfasını açın | Deneme hakkı dolan işler buraya düşer (ölü mektup kutusu). Hata metinleri okunabilir; yığın izi saklanmaz — dosya yolları veritabanına yazılmaz |
| 6 | Bir başarısız işi "Yeniden dene" ile kuyruğa geri gönderin | Satır failed_jobs'tan jobs'a taşınır, deneme sayacı sıfırlanır. Servis düzeldiğinde işi kaybetmemiş olursunuz |
| 7 | "Kuyruğa ekle" ile bir e-posta işi bırakın | jobs tablosuna satır düşer, sayfa hemen döner. Kullanıcı SMTP'yi beklemez — kuyruğun bütün varlık sebebi budur |
| 8 | Gecikme kutusuna 120 yazıp iş ekleyin | Satır Ertelenmiş görünür: available_at iki dakika ileridedir. Worker onu zamanı gelmeden görmez |
| 9 | Kararsız işi ekleyin ve terminalde php bin/worker.php --max=5 çalıştırın | %70 ihtimalle hata verir, geri çekilmeyle yeniden denenir, üçüncü denemeden sonra başarısız kutusuna düşer. Tüm akışı canlı izlersiniz |
| 10 | Telefonunuzdan açın | Sayaçlar alt alta dizilir, ikincil sütunlar gizlenir; sayfa gövdesinde yatay kaydırma yoktur |
Not: Demo sunucusunda sürekli çalışan bir worker yoktur. Bu bilinçlidir: kuyruğun dolu görünmesi, "işleri bekleten şey nedir?" sorusunu somutlaştırır. Panel de bunu söyler ve çalıştırılacak komutu ekrana yazar. İşleri gerçekten işlemek için projeyi indirip php bin/worker.php çalıştırın.
Demo alanı hakkında bilinmesi gerekenler
| Konu | Durum |
|---|---|
| Veriler | database.sql içindeki 51 kullanıcı + 10 kuyruk işi + 4 başarısız iş. Gerçek kişi verisi yoktur. |
| Sıfırlama | Demo veritabanı düzenli aralıklarla başlangıç hâline döner; eklediğiniz işler kalıcı değildir. |
| Worker | Demoda çalışmıyor (yukarıdaki nota bakın). Yerelde tek komutla çalıştırılır. |
| Kimlik doğrulama | Vardır. Oturum, "beni hatırla" jetonu, hız sınırı ve CSRF korumasıyla birlikte gelir. |
APP_DEBUG | Canlıda kendiliğinden false — sunucu adından türetilir, yerelde true kalır. |
| Bağımlılık | Sıfır. Redis yok, Composer yok, npm yok, CDN yok. |
Demo geçici olarak kapalıysa endişelenmeyin: depoyu klonlayıp database.sql'i içe aktarmanız aynı ekranı kendi bilgisayarınızda 2 dakikada ayağa kaldırır → Kurulum
Bu Proje Nedir?
Kayıt formuna basan kullanıcı, hoş geldin e-postası gönderilirken bekler. SMTP sunucusu yavaşsa 4 saniye bekler. SMTP sunucusu düşmüşse 30 saniye bekler ve sonunda bir hata sayfası görür — üstelik kaydı başarıyla oluşturulmuş olduğu hâlde.
Sorun e-postada değil, işin yanlış yerde yapılmasındadır. Kullanıcının isteği ile e-postanın gönderilmesi aynı anda olmak zorunda değildir. İkisini ayırırsanız istek milisaniyelerde biter, e-posta arka planda gider ve gitmezse tekrar denenir.
Bu proje o ayrımı MySQL üzerinde kurulmuş bir iş kuyruğuyla gösteriyor: jobs tablosuna bir satır bırakırsınız, ayrı bir süreç (worker) o satırı kapar, çalıştırır ve siler. Hata olursa iş üstel geri çekilmeyle yeniden denenir; deneme hakkı dolarsa failed_jobs tablosuna — ölü mektup kutusuna — düşer ve orada sizi bekler.
Kuyruğun zor kısmı iş çalıştırmak değildir; aynı işin iki kez çalışmamasını sağlamaktır. İki worker aynı satırı aynı anda görürse e-posta iki kez gider. Bu proje o yarışı, koşullu bir UPDATE ile — kilitsiz ve MySQL 5.7 uyumlu — çözer.
Kimler için uygun?
- Kayıt/sipariş sonrası e-posta, rapor veya bildirim gönderenler
- "Sayfa neden 8 saniye açılıyor?" sorusuyla boğuşanlar
- Redis kurmadan, elindeki MySQL ile kuyruk isteyenler
- Yeniden deneme, geri çekilme ve ölü mektup kutusunun neden gerekli olduğunu öğrenmek isteyenler
- Bootstrap 5 üzerine kurulu, tekrar kullanılabilir bir panel kalıbı arayanlar
Klonla, database.sql'i içe aktar, çalıştır. Başka hiçbir kurulum adımı yok. Composer yok, npm yok, Redis yok.
Bu proje, Çılgın Yazılım Kütüphanesi altında yayınlanan açıklamalı, üretime hazır örneklerden biridir.
İçindekiler
- Canlı Demo
- Bu Proje Nedir?
- Ekran Görüntüleri
- Bir işin hayatı
- Kritik Kararlar
- Neler Var?
- Worker'ı çalıştırmak
- Kendi işinizi yazmak
- Güvenlik: Neyi, Nasıl Kapattık?
- Kurulum
- Yapılandırma
- Dosya Yapısı
- Nasıl Çalışıyor?
- Veritabanı Şeması
- SSS
- Canlı Ortama Alırken
- Sorun Giderme
- Yol Haritası
- Katkı
- Lisans
Ekran Görüntüleri
Kuyruk
Dört sayaç — Bekleyen · Çalışıyor · Ertelenmiş · Başarısız — tek bir SUM(CASE WHEN …) sorgusundan gelir; dört ayrı COUNT atılmaz. Tablodaki Takılmış rozeti "çalışıyor"dan ayrı bir durumdur: işi bir worker kapmış ama süreç ölmüştür. Rozetin eşiği ile "Takılanları kurtar" düğmesinin eşiği aynı sabittir (Queue::STUCK_AFTER), yani ekranda takılmış görünen işi düğme mutlaka bulur. 2 / 3 yazan satır üstel geri çekilmeyi gösterir: 10 sn → 20 sn → 40 sn.

Başarısız işler
Deneme hakkı dolan işler buraya — ölü mektup kutusuna — düşer ve kaybolmadan bekler. Hata metinleri okunabilir; yığın izi saklanmaz, çünkü dosya yolları veritabanına yazılmamalıdır. "Yeniden dene" satırı failed_jobs'tan jobs'a geri taşır ve deneme sayacını sıfırlar.

Kontrol paneli
Kuyruğun nabzı ve worker'ın durumu. Demo sunucusunda sürekli çalışan bir worker yoktur; panel bunu açıkça söyler ve çalıştırılacak komutu kopyalanmaya hazır biçimde ekrana yazar.

Giriş ekranı
Demo hesapları tek tıkla doldurulur. Giriş denemeleri hız sınırına tabidir; art arda başarısız denemeden sonra hesap geçici olarak kilitlenir.

Koyu tema
Tema tarayıcıda değil kullanıcı hesabında saklanır. Tablo hücrelerinin kontrastı koyu temada 14,5:1'dir; durum rozetleri rengin yanında metin de taşır, böylece rengi ayırt edemeyen bir ziyaretçi de durumu okuyabilir.

Mobil görünüm
390px genişlikte sayaçlar alt alta dizilir, ikincil sütunlar gizlenir ve alt navigasyon devreye girer. Sayfa gövdesinde yatay kaydırma yoktur; geniş tablolar yalnızca kendi kapsayıcılarında kayar.
Bir işin hayatı
push() reserve() handle()
│ │ │
▼ ▼ ▼
┌────────┐ zamanı geldi ┌──────────┐ başarılı ┌─────────┐
│BEKLİYOR│ ──────────────► │ ÇALIŞIYOR│ ───────────► │ SİLİN │
└────────┘ └──────────┘ └─────────┘
▲ │
│ │ hata
│ ▼
│ deneme < max ?
│ │ │
│ 10 sn → 20 sn → 40sn │ evet │ hayır
└───────────────────────┘ ▼
(üstel geri çekilme) ┌──────────────┐
│ BAŞARISIZ │ ölü mektup kutusu
│ failed_jobs │ → "Yeniden dene" ile geri döner
└──────────────┘
Worker çöktüyse: ÇALIŞIYOR durumunda 5 dakikadan uzun kalan iş
TAKILMIŞ sayılır → recoverStuck() onu BEKLİYOR'a döndürürDört durum, iki sütundan okunur:
| Durum | Nasıl anlaşılır |
|---|---|
| Bekliyor | reserved_at IS NULL ve available_at <= NOW() |
| Ertelenmiş | reserved_at IS NULL ve available_at > NOW() |
| Çalışıyor | reserved_at dolu ve 5 dakikadan yeni |
| Takılmış | reserved_at dolu ve 5 dakikadan eski |
Kritik Kararlar
1. Aynı işi iki worker alamaz — kilit kullanmadan
En sık yapılan hata şudur:
// YANLIŞ: iki worker aynı satırı görebilir
$job = $db->query('SELECT * FROM jobs WHERE reserved_at IS NULL LIMIT 1')->fetch();
$db->exec("UPDATE jobs SET reserved_at = NOW() WHERE id = {$job['id']}");İki worker
SELECT'i aynı anda çalıştırırsa ikisi de aynı satırı alır ve e-posta iki kez gider. "Önce bak, sonra yaz" her zaman bir yarış koşuludur.
Doğrusu, kontrolü ve yazmayı tek ifadeye koymaktır:
UPDATE jobs
SET reserved_at = NOW(), reserved_by = :worker, attempts = attempts + 1
WHERE id = :id
AND reserved_at IS NULL -- ◄── kritik satırreserved_at IS NULL koşulu UPDATE'in içindedir; veritabanı satırı yazarken zaten kilitler. İki worker aynı satırı denerse yalnızca birinin rowCount() değeri 1 olur:
if ($claim->rowCount() === 0) {
continue; // başka bir worker önce davrandı, sıradakine geç
}Uygulama önce 5 aday satır okur, sonra bunları sırayla kapmayı dener. Böylece iki worker çakışsa bile boş dönmez, listedeki bir sonrakine geçer.
2. SELECT ... FOR UPDATE SKIP LOCKED neden kullanılmadı?
Kullanılabilirdi ve MySQL 8.0'da daha zarif olurdu. Ama SKIP LOCKED, MySQL 5.7 ve MariaDB 10.5'ten önceki sürümlerde yoktur — paylaşımlı hosting'lerin büyük kısmı hâlâ orada.
Koşullu UPDATE yöntemi her sürümde çalışır, ek bir işlem (transaction) yönetimi gerektirmez ve aynı güvenceyi verir. Taşınabilirlik, buradaki zarafetten daha değerli görüldü.
3. Yeniden deneme üstel geri çekilmeyle yapılır
Hata alan işi hemen tekrar denemek, çoğu zaman zarar verir: düşmüş bir SMTP sunucusuna saniyede üç istek göndermek onu daha da boğar.
// app/Core/Queue.php — release()
$delay = min(3600, 10 * (2 ** ($attempts - 1)));Bekleme süresi her denemede ikiye katlanır: 10 sn → 20 sn → 40 sn → … ve bir saatte tavanlanır. Tavan olmasaydı 20. deneme 60 gün sonraya düşerdi.
4. Deneme hakkı dolan iş silinmez, taşınır
Üç kez denenip başaramayan bir işi silmek, veriyi kaybetmektir. Bu iş failed_jobs tablosuna taşınır ve orada sizi bekler.
Neden ayrı tablo? Çünkü jobs tablosu worker'ın en sıcak sorgusudur; saniyede birkaç kez taranır. Başarısız işleri orada tutmak, o tabloyu asla temizlenmeyen ölü satırlarla şişirirdi.
Servisi düzelttiğinizde "Yeniden dene" düğmesi işi geri gönderir — kaybolmuş bir e-posta yerine, gönderilmeyi bekleyen bir e-postanız olur.
5. Hata metni saklanır, yığın izi saklanmaz
'error' VARCHAR(2000) NOT NULL DEFAULT ''Yığın izi dosya yollarınızı, klasör yapınızı ve sınıf adlarınızı içerir. Veritabanı bir gün sızarsa saldırgana hazır bir harita vermiş olursunuz. Hata mesajı sorunu anlamaya yeter.
6. Takılmış iş ile çalışan iş ayrı gösterilir
Worker çökerse (sunucu yeniden başladı, bellek doldu, deploy sırasında kapatıldı) reserved_at dolu kalır ve iş sonsuza kadar "çalışıyor" görünür — oysa onu çalıştıran kimse yoktur.
Bu yüzden 5 dakikadan eski rezervasyonlar arayüzde ayrı bir Takılmış rozetiyle gösterilir. Eşik Queue::STUCK_AFTER sabitindedir ve hem rozet hem kurtarma sorgusu onu kullanır. İkisi ayrışsaydı, ekranda takılmış görünen bir işi kurtarma düğmesi bulamazdı.
7. Payload JSON, iş sınıfı ise tam sınıf adı
job_class: App\Jobs\SendEmailJob
payload: {"to":"...","subject":"..."}İşin ne olduğu ile neyle çalışacağı ayrılır. Worker sınıfı yükler,
Job sınıfından türediğini doğrular ve handle($payload, $db) çağırır:
if (!class_exists($class) || !is_subclass_of($class, Job::class)) {
// tanınmayan sınıf çalıştırılmaz
}Bu kontrol güvenliğin parçasıdır:
job_class alanına elle bir değer yazılsa bile rastgele bir sınıf örneklenip çalıştırılamaz.
8. Neden Redis yok?
Redis kuyruk için mükemmeldir ama bir bağımlılıktır: kurulacak, izlenecek, yedeklenecek ve paylaşımlı hosting'de çoğu zaman bulunmayan bir servistir.
Günde on binlerce işe kadar MySQL fazlasıyla yeter — özellikle idx_jobs_reserve bileşik indeksi varken. Üstelik işleriniz veritabanı yedeğinizin içinde durur; ayrıca yedeklemeniz gereken ikinci bir depo olmaz.
Milyonlarca işe çıktığınızda Redis'e geçin. O gün gelene kadar kurmayın.
Neler Var?
Kuyruk çekirdeği
push()— iş bırak, gecikme verilebilirreserve()— yarışsız kapma (koşulluUPDATE)release()— üstel geri çekilmeyle iadefail()— ölü mektup kutusuna taşırecoverStuck()— çökmüş worker'ın kilidini aç- Adlandırılmış kuyruklar (
default,reports, …) - İş başına ayarlanabilir deneme hakkı
Worker (CLI)
--queue=belirli kuyruğu dinle--max=NN iş sonra dur--oncetek iş çalıştır ve çık--sleep=Nboşta bekleme süresiPHP_SAPIkontrolü — tarayıcıdan çalışmaz
Panel
- Dört canlı sayaç (bekleyen/çalışan/ertelenmiş/başarısız)
- Kuyruk listesi, dört durumlu rozet
- Başarısız işler sayfası, yeniden dene / unut
- Takılanları kurtar düğmesi
- Panelden iş bırakma (üç örnek iş türü)
- Worker çalışmıyorsa uyarı şeridi
Ortak altyapı
- Oturum girişi, "beni hatırla", hız sınırı, CSRF
- CSP (
script-src 'self'),X-Frame-Options: DENY - Açık / koyu tema, hesaba kayıtlı
- Mobilde alt navigasyon, yatay kaydırma yok
- Kullanıcılar sayfasında canlı filtre (JS kapalıysa da çalışır)
- Sıfır bağımlılık
Worker'ı çalıştırmak
İşler ancak bir worker çalışıyorken işlenir. Proje klasöründe ayrı bir terminal açın:
php bin/worker.phpSüreç kuyruğu dinlemeye başlar ve iş geldikçe çalıştırır. Durdurmak için
Ctrl+C.
Seçenekler
| Seçenek | Anlamı |
|---|---|
--queue=reports | Yalnızca bu kuyruğu dinle (varsayılan: default) |
--max=5 | 5 iş çalıştırdıktan sonra çık — denemek için ideal |
--once | Tek iş çalıştır ve çık (cron'dan tetiklemek için) |
--sleep=3 | Kuyruk boşken kaç saniye beklesin (varsayılan: 1) |
--help | Seçenekleri yazdır |
Canlı sunucuda
Worker bir gün mutlaka ölür: bellek dolar, deploy sırasında kapatılır, sunucu yeniden başlar. Bu yüzden onu kendiliğinden yeniden başlatan bir yönetici altında çalıştırın.
systemd (/etc/systemd/system/cy-worker.service):
[Unit]
Description=CY Queue Worker
After=network.target mysql.service
[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/uygulama
ExecStart=/usr/bin/php bin/worker.php
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.targetsudo systemctl enable --now cy-workersupervisor veya cPanel kullanıyorsanız: her dakika çalışan bir cron kurun ve
--once verin.
* * * * * cd /var/www/uygulama && php bin/worker.php --once >> /dev/null 2>&1
Deploy sırasında: worker'ı önce durdurun, kodu güncelleyin, sonra başlatın. Aksi hâlde çalışan süreç eski kodu, yeni gelen işler yeni şemayı kullanır.
Kendi işinizi yazmak
Bir iş, Job sınıfından türeyen ve iki metot uygulayan bir sınıftır.
<?php
namespace App\Jobs;
use PDO;
final class SendSmsJob extends Job
{
/** Arayüzde ve günlükte görünecek ad. */
public static function label(): string
{
return 'SMS gönderimi';
}
/** Kaç kez denensin? (varsayılan 3) */
public static function maxAttempts(): int
{
return 5;
}
/**
* İşin kendisi. İstisna fırlatırsa iş başarısız sayılır ve
* geri çekilmeyle yeniden denenir.
*/
public function handle(array $payload, PDO $db): void
{
$numara = (string) ($payload['numara'] ?? '');
if ($numara === '') {
// Kalıcı hata: tekrar denemenin anlamı yok.
throw new \InvalidArgumentException('Numara boş.');
}
// ... SMS sağlayıcısına istek ...
}
}Kuyruğa bırakmak:
(new Queue($pdo))->push(SendSmsJob::class, ['numara' => '5xx…']);
// 10 dakika sonra çalışsın:
(new Queue($pdo))->push(SendSmsJob::class, ['numara' => '5xx…'], Queue::DEFAULT, 600);
// Ayrı bir kuyruğa:
(new Queue($pdo))->push(SendSmsJob::class, [...], 'sms');İki kural:
handle()yeniden çalıştırılabilir olsun. İş, ağ koptuğu için tam bittiği anda da başarısız sayılabilir ve tekrar denenir. "Bu işi ikinci kez çalıştırırsam ne olur?" sorusunun cevabı zararsız olmalıdır — sipariş iki kez oluşturulmamalıdır.- Payload'a nesne değil, kimlik koyun.
['user_id' => 42]yazın, kullanıcı nesnesinin tamamını değil. İş çalıştığında veri değişmiş olabilir; taze hâlinihandle()içinde okuyun.
Güvenlik: Neyi, Nasıl Kapattık?
| Açık | Tipik hatalı kod | Bu projede |
|---|---|---|
| Rastgele sınıf çalıştırma | (new $payload['class'])->run() | Sınıf Job'dan türemiyorsa çalıştırılmaz (is_subclass_of) |
| Yarış koşulu (çift işleme) | "Önce SELECT, sonra UPDATE" | Kontrol ve yazma tek UPDATE ifadesinde; rowCount() ile doğrulanır |
| Yığın izi sızıntısı | Hata nesnesinin tamamını loglamak | Yalnızca mesaj saklanır; dosya yolu ve sınıf yapısı veritabanına yazılmaz |
| SQL enjeksiyonu | "... WHERE id = $id" | Tüm sorgular hazır ifade; ATTR_EMULATE_PREPARES = false |
| CLI betiğine web'den erişim | bin/ klasörü açık | bin/.htaccess ile kapalı ve betikte PHP_SAPI === 'cli' kontrolü |
| CSRF | Gizli alan yok | Her POST'ta jeton; karşılaştırma hash_equals() ile |
| XSS | echo $row['payload'] | Sunucuda e(); ayrıca CSP script-src 'self' |
| Parola sızıntısı | md5($parola) | password_hash() / password_verify(); otomatik yeniden özetleme |
| Kaba kuvvet | Sınırsız deneme | login_attempts tablosuyla e-posta + IP başına hız sınırı |
| Hata sızıntısı | Canlıda display_errors=On | APP_DEBUG ortamdan türetilir; gerçek alan adında kendiliğinden kapanır |
| Sonsuz büyüyen kuyruk | Tavansız geri çekilme | Geri çekilme 1 saatte tavanlanır; deneme hakkı dolan iş ölü mektup kutusuna taşınır |
| Bozuk UTF-8'de sessiz JSON kaybı | json_encode($v) | JSON_INVALID_UTF8_SUBSTITUTE |
Kurulum
Gereksinimler
| PHP | 8.0 veya üzeri (CLI erişimi gerekir) |
| MySQL / MariaDB | 5.7+ / 10.3+ |
| Web sunucusu | Apache (mod_rewrite) veya Nginx |
| PHP eklentileri | pdo_mysql, mbstring |
Adımlar
1 · Dosyaları yerleştirin
git clone https://github.com/CilginYazilim/queue-worker-system.git
cd queue-worker-system2 · Veritabanını içe aktarın
mysql -u root -p < database.sqlDosya veritabanını kendisi oluşturur (
cy_queue_worker), tabloları kurar ve örnek kuyruk verisini yükler.
3 · .env dosyasını oluşturun
cp .env.example .env # Windows: copy .env.example .env4 · Açın ve worker'ı başlatın
http://localhost/queue-worker-system/Ayrı bir terminalde:
php bin/worker.php --max=5Giriş:
[email protected] / Admin1234
Yapılandırma
APP_DEBUG=true # silerseniz: yerelde açık, canlıda kapalı
APP_URL=
APP_PRETTY_URLS=true # false → /index.php?r=queue
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=cy_queue_worker
DB_USER=root
DB_PASS=
••••••••
Kuyruk davranışını belirleyen sabitler koddadır:
| Sabit | Yeri | Varsayılan | Ne yapar |
|---|---|---|---|
| `Queue::DEFAULT` | `app/Core/Queue.php` | `'default'` | Kuyruk adı verilmezse kullanılan |
| `Queue::STUCK_AFTER` | `app/Core/Queue.php` | `300` | Kaç saniye sonra iş "takılmış" sayılır |
| `Job::maxAttempts()` | `app/Jobs/Job.php` | `3` | Deneme hakkı (iş sınıfında değiştirilebilir) |
| geri çekilme | `Queue::release()` | `10 · 2ⁿ`, tavan `3600` | Yeniden deneme aralığı |
> **`STUCK_AFTER`'ı işlerinize göre ayarlayın.** İşleriniz dakikalarca sürüyorsa 300 saniye çok kısadır: hâlâ çalışan bir iş "takılmış" sayılıp ikinci kez kuyruğa girebilir.
---
## Dosya Yapısı
queue-worker-system/
│
├── index.php Ön denetleyici — TEK giriş noktası
├── database.sql Şema + örnek kuyruk verisi
├── .env.example
│
├── bin/
│ ├── worker.php ★ Kuyruk işçisi (CLI)
│ └── .htaccess Web erişimine kapalı
│
├── app/
│ ├── Core/
│ │ ├── Queue.php ★ push · reserve · release · fail · recoverStuck
│ │ ├── Worker.php ★ Döngü, sinyal yakalama, sınıf doğrulama
│ │ ├── Auth.php Giriş, "beni hatırla"
│ │ ├── Session.php Sertleştirilmiş oturum
│ │ ├── Csrf.php Jeton + hash_equals
│ │ ├── RateLimiter.php Giriş denemesi sınırı
│ │ ├── Database.php PDO (EMULATE_PREPARES = false)
│ │ ├── Env.php .env okuyucu + isLocalHost()
│ │ └── ...
│ │
│ ├── Jobs/
│ │ ├── Job.php ★ Soyut taban: handle() · label() · maxAttempts()
│ │ ├── SendEmailJob.php Örnek: e-posta gönderimi
│ │ ├── ReportJob.php Örnek: rapor üretimi (dosyaya yazar)
│ │ └── FlakyJob.php Örnek: %70 hata veren "kararsız" iş
│ │
│ ├── Http/Controllers/ Auth · Dashboard · Queue · User
│ ├── Repositories/ JobRepository · UserRepository · ActivityRepository
│ └── Support/helpers.php
│
├── storage/ Worker çıktıları (web'e kapalı)
├── views/ Düzenler, kuyruk ve başarısız iş sayfaları
├── assets/ css · js · images
├── config/config.php
├── routes/web.php
└── docs/screenshots/
---
## Nasıl Çalışıyor?
WEB İSTEĞİ AYRI SÜREÇ (worker)
────────── ───────────────────
Kullanıcı "Kaydet"e basar php bin/worker.php
│ │
▼ ▼
Queue::push(SendEmailJob::class, [...]) ┌─► reserve('default', $workerId)
│ │ │
│ INSERT INTO jobs (...) │ │ 1) 5 aday satır SELECT
│ available_at = NOW() + gecikme │ │ WHERE reserved_at IS NULL
│ │ │ AND available_at <= NOW()
▼ │ │
Yanıt DÖNER (milisaniyeler) │ │ 2) her aday için koşullu UPDATE
Kullanıcı beklemez │ │ SET reserved_at = NOW()
│ │ WHERE id=? AND reserved_at IS NULL
│ │
│ │ rowCount() === 1 ?
│ │ hayır → sıradaki adaya geç
│ │ evet → iş benim
│ ▼
│ Job::handle($payload, $db)
│ │
│ ├─ başarılı → Queue::delete()
│ │
│ └─ istisna → deneme < max ?
│ │ │
│ evet │ │ hayır
│ ▼ ▼
│ Queue::release() Queue::fail()
│ available_at = failed_jobs'a
│ NOW() + 10·2ⁿ TAŞINIR
│ │
└─────────────┘
(kuyruğa geri döner)
---
## Veritabanı Şeması
### `jobs` — bekleyen ve çalışan işler
| Sütun | Tip | İşi |
|---|---|---|
| `id` | BIGINT UNSIGNED | Birincil anahtar |
| `queue` | VARCHAR(60) | Kuyruk adı; farklı önceliklere ayrı worker atanabilir |
| `job_class` | VARCHAR(190) | Çalıştırılacak sınıf (`App\Jobs\SendEmailJob`) |
| `payload` | TEXT | İşin verisi (JSON) |
| `attempts` / `max_attempts` | SMALLINT | Kaç kez denendi / en fazla kaç kez |
| `available_at` | DATETIME | "Bu zamandan önce çalıştırma" — gecikme **ve** geri çekilme |
| `reserved_at` / `reserved_by` | DATETIME / VARCHAR | Kapan worker ve kapma anı; `NULL` = boşta |
| `last_error` | VARCHAR(1000) | Son hata; iş hâlâ kuyrukta ama sorunlu |
| `created_at` | DATETIME | Bırakılma anı |
**Tek indeks, en sıcak sorgu için:**
```sql
KEY idx_jobs_reserve (queue, reserved_at, available_at)Worker saniyede birkaç kez şunu sorar:
WHERE queue = ? AND reserved_at IS NULL AND available_at <= NOW() ORDER BY idBu indeks olmadan her sorgu tüm tabloyu tarar; kuyruk büyüdükçe sistem yavaşlar. Sütun sırası rastgele değildir: önce eşitlik (
queue), sonra NULL kontrolü, en sonda aralık karşılaştırması.
failed_jobs — ölü mektup kutusu
| Sütun | Tip | İşi |
|---|---|---|
id | BIGINT UNSIGNED | Birincil anahtar |
queue · job_class · payload | — | jobs'tan taşınan alanlar |
attempts | SMALLINT | Kaç kez denendi |
error | VARCHAR(2000) | Yalnızca mesaj — yığın izi saklanmaz |
failed_at | DATETIME | Pes edilen an |
| Karar | Neden |
|---|---|
| Ayrı tablo | jobs, worker'ın en sıcak sorgusudur; ölü satırlarla şişmemeli |
| Yığın izi yok | Dosya yolu ve sınıf yapısı veritabanına yazılmamalı |
payload korunur | "Yeniden dene" işi aynı veriyle geri gönderebilsin diye |
SSS
Worker'ı sürekli açık tutmak zorunda mıyım?
Hayır, iki seçeneğiniz var.
Sürekli süreç (önerilen): php bin/worker.php bir kez başlar ve dinler. İşler saniyeler içinde işlenir. systemd veya supervisor ile yeniden başlatılmasını garanti edin.
Cron ile: her dakika php bin/worker.php --once çalıştırın. Kurulumu daha kolaydır ama işler en fazla bir dakika bekler. Paylaşımlı hosting'de genellikle tek seçenek budur.
Aynı e-posta iki kez gitti, neden?
Neredeyse her zaman şu sebeptendir: iş, gönderimi bitirdikten sonra ama satır silinmeden önce başarısız oldu (ağ koptu, süreç öldürüldü). Kuyruk onu bitmemiş sayar ve tekrar dener.
Bu, kuyrukların yapısal bir gerçeğidir: "en az bir kez" teslim garantisi verilir, "tam olarak bir kez" değil. Çözüm işin kendisindedir — handle() yeniden çalıştırıldığında zararsız olmalıdır. Gönderdiğiniz her e-postaya bir kimlik verip "bu kimlik daha önce gönderildi mi?" diye bakmak en yaygın yöntemdir.
Birden fazla worker çalıştırabilir miyim?
Evet, tasarım buna göre yapıldı. İki worker aynı satırı alamaz; koşullu UPDATE bunu garanti eder.
Farklı kuyruklara farklı worker atamak da yaygındır:
php bin/worker.php --queue=default # hızlı işler
php bin/worker.php --queue=reports # uzun süren raporlarBöylece 4 dakika süren bir rapor, hoş geldin e-postasını bekletmez.
İşler birikiyor ama işlenmiyor
Sırayla kontrol edin:
- Worker gerçekten çalışıyor mu? (
ps aux | grep worker.php) - Doğru kuyruğu mu dinliyor? İşi
reportskuyruğuna bırakıpdefaultdinliyorsanız hiçbir şey olmaz. - İşler Ertelenmiş mi görünüyor?
available_atgelecekteyse worker onları görmez. - Hepsi Takılmış mı? Bir worker çökmüş olabilir; "Takılanları kurtar" düğmesine basın.
Neden SKIP LOCKED kullanmadınız?
MySQL 5.7 ve MariaDB 10.5'ten önceki sürümlerde yoktur; paylaşımlı hosting'lerin büyük kısmı hâlâ orada.
Koşullu UPDATE yöntemi her sürümde çalışır ve aynı garantiyi verir. MySQL 8 kullandığınızdan eminseniz reserve() metodunu SKIP LOCKED ile değiştirebilirsiniz; arayüzün geri kalanı hiç değişmez.
Ölü mektup kutusu ne zaman temizlenir?
Kendiliğinden temizlenmez ve bu bilerek böyledir: başarısız bir iş, birinin bakması gereken bir olaydır.
Panelden tek tek "Unut" diyebilir veya belirli bir yaştan eskileri silen bir görev yazabilirsiniz. Silmeden önce neden başarısız olduklarına bakın; genellikle orada tek bir kök sebep vardır.
Canlı Ortama Alırken
- [ ]
.enviçindeAPP_DEBUG=false(veya satırı tümüyle silin) - [ ] Worker'ı systemd/supervisor altında çalıştırın;
Restart=alwaysolsun - [ ] Deploy sırasında worker'ı önce durdurun, sonra başlatın
- [ ]
STUCK_AFTERdeğerini en uzun işinizden büyük seçin - [ ]
bin/klasörünün web'den erişilemediğini doğrulayın (403 dönmeli) - [ ]
storage/klasörünün web'den erişilemediğini doğrulayın - [ ]
failed_jobstablosunu izleyin — büyüyorsa bir şey bozuktur - [ ] Veritabanı için root olmayan bir kullanıcı açın
- [ ] HTTPS zorunlu olsun
- [ ] Demo hesaplarının parolalarını değiştirin veya hesapları silin
Sorun Giderme
| Belirti | Sebep | Çözüm |
|---|---|---|
| İşler hep "Bekliyor" | Worker çalışmıyor | php bin/worker.php başlatın |
| İşler hep "Ertelenmiş" | available_at gelecekte | Gecikme verdiniz ya da geri çekilme sürüyor; bekleyin |
| Hepsi "Takılmış" | Worker çökmüş, kilit üzerinde kalmış | "Takılanları kurtar" düğmesine basın |
| Uzun iş ikinci kez çalışıyor | STUCK_AFTER işten kısa | Sabiti yükseltin |
Class not found hatası | job_class yanlış veya sınıf silinmiş | Tam sınıf adını ve app/Jobs/ içeriğini kontrol edin |
bin/worker.php tarayıcıda 403 | Bilerek | CLI betiği web'e kapalıdır; terminalden çalıştırın |
| Tüm adresler 404 | mod_rewrite kapalı | Açın veya APP_PRETTY_URLS=false yapın |
| Sayfa boş / beyaz | PHP hatası, display_errors kapalı | .env içinde APP_DEBUG=true yapıp tekrar deneyin |
Yol Haritası
- [ ] İş öncelikleri (aynı kuyruk içinde sıralama)
- [ ] Zincirleme işler (biri bitince diğeri)
- [ ] Toplu iş (batch) ve ilerleme yüzdesi
- [ ] Başarısız iş sayısı eşiği aşınca e-posta uyarısı
- [ ] MySQL 8 için
SKIP LOCKEDsürücüsü (isteğe bağlı)
Katkı
Hata bildirimi ve öneriler için issue açabilirsiniz.
Lisans
MIT — ticari projelerinizde de özgürce kullanabilirsiniz.
Kaynak Kod soldaki ağaçtan bir dosya seçin
-
app
-
Core
- Auth.php 13.9 KB
- Autoloader.php 2.2 KB
- Config.php 1.9 KB
- Csrf.php 2.8 KB
- Database.php 3.5 KB
- Env.php 4.3 KB
- Flash.php 2.6 KB
- Middleware.php 2.5 KB
- Paginator.php 8.6 KB
- Queue.php 12.1 KB
- RateLimiter.php 3.9 KB
- Request.php 4.5 KB
- Response.php 7 KB
- Router.php 6.3 KB
- Session.php 6.3 KB
- Validator.php 8.8 KB
- View.php 3.5 KB
- Worker.php 8 KB
-
Http
-
Controllers
-
Api
- PreferenceApiController.php 2.5 KB
- AuthController.php 3.2 KB
- DashboardController.php 1.3 KB
- QueueController.php 7 KB
- UserController.php 3.1 KB
-
- Controller.php 1.3 KB
-
-
Jobs
- FlakyJob.php 2.1 KB
- Job.php 2.9 KB
- ReportJob.php 2.8 KB
- SendEmailJob.php 2.7 KB
-
Models
- User.php 5.9 KB
-
Repositories
- ActivityRepository.php 3.8 KB
- JobRepository.php 6.2 KB
- RememberTokenRepository.php 5.8 KB
- UserRepository.php 6.9 KB
-
Support
- helpers.php 12.3 KB
- .htaccess 851 B
-
-
assets
-
css
- admin.css 58.6 KB
- bootstrap.min.css 227.5 KB
- cilginyazilim.css 29.8 KB
- feature.css 3.1 KB
-
images
- logo.png 70.4 KB
-
js
- app.js 20.2 KB
- bootstrap.bundle.js 203.2 KB
- jquery-3.7.0.js 278.3 KB
- login.js 1.8 KB
- queue.js 2 KB
- users.js 4.7 KB
-
-
bin
- .htaccess 666 B
- worker.php 4.4 KB
-
config
- .htaccess 196 B
- config.php 5 KB
- menu.php 1.2 KB
-
docs
-
screenshots
- 01-giris.png 372.7 KB
- 02-kontrol-paneli.png 77.5 KB
- 03-kuyruk.png 132.1 KB
- 04-basarisiz-isler.png 80.7 KB
- 05-koyu-tema.png 133.7 KB
- 06-mobil.png 76 KB
-
-
routes
- .htaccess 197 B
- web.php 3.3 KB
-
storage
-
reports
- rapor-20260904-184407.csv 136 B
- .gitkeep 1 B
- .htaccess 675 B
-
-
views
-
auth
- login.php 7.4 KB
-
dashboard
- _feature.php 2.3 KB
- index.php 4 KB
-
errors
- 404.php 844 B
- 500.php 565 B
-
layouts
- admin.php 6.8 KB
- auth.php 2.9 KB
- plain.php 1.3 KB
-
partials
- bottomnav.php 2.1 KB
- head_extra.php 414 B
- pagination.php 4.9 KB
- sidebar.php 2.7 KB
- topbar.php 3.5 KB
-
queue
- failed.php 5.3 KB
- index.php 12 KB
-
users
- index.php 6.8 KB
- .htaccess 262 B
-
- .gitattributes 2.3 KB
- .gitignore 1.4 KB
- .htaccess 6 KB
- CHANGELOG.md 8.1 KB
- database.sql 22.9 KB
- index.php 5.4 KB
- LICENSE 1.1 KB
- README.en.md 34.8 KB
- README.md 36.8 KB
Güvenlik gereği kaynak dosyalardaki parola, API anahtarı ve benzeri gizli
değerler gösterilmeden önce maskelenir (••••••••).
Sık Sorulan Sorular
Hayır, iki seçeneğiniz var. Sürekli süreç önerilendir: php bin/worker.php bir kez başlar ve dinler, işler saniyeler içinde işlenir; systemd veya supervisor ile yeniden başlatılmasını garanti edin. Alternatif olarak her dakika php bin/worker.php --once çalıştıran bir cron kurabilirsiniz; kurulumu kolaydır ama işler en fazla bir dakika bekler.
Neredeyse her zaman şu sebeptendir: iş gönderimi bitirdikten sonra ama satır silinmeden önce başarısız oldu. Kuyruk onu bitmemiş sayar ve tekrar dener. Bu kuyrukların yapısal bir gerçeğidir; "en az bir kez" teslim garantisi verilir. Çözüm işin kendisindedir: handle() yeniden çalıştırıldığında zararsız olmalıdır.
Evet, tasarım buna göre yapıldı. İki worker aynı satırı alamaz; koşullu UPDATE bunu garanti eder. Farklı kuyruklara farklı worker atamak da yaygındır: böylece dört dakika süren bir rapor, hoş geldin e-postasını bekletmez.
MySQL 5.7 ve MariaDB 10.5'ten önceki sürümlerde yoktur; paylaşımlı hosting'lerin büyük kısmı hâlâ orada. Koşullu UPDATE yöntemi her sürümde çalışır ve aynı garantiyi verir. MySQL 8 kullandığınızdan eminseniz reserve() metodunu değiştirebilirsiniz; arayüzün geri kalanı hiç değişmez.
Demo sunucusunda sürekli çalışan bir worker yoktur ve bu bilinçlidir: kuyruğun dolu görünmesi, işleri bekleten şeyin ne olduğunu somutlaştırır. Panel de bunu söyler ve çalıştırılacak komutu ekrana yazar. Projeyi indirip php bin/worker.php çalıştırdığınızda akışın tamamını canlı izlersiniz.
Kendiliğinden temizlenmez ve bu bilerek böyledir: başarısız bir iş, birinin bakması gereken bir olaydır. Panelden tek tek unutabilir veya belirli bir yaştan eskileri silen bir görev yazabilirsiniz. Silmeden önce neden başarısız olduklarına bakın; genellikle orada tek bir kök sebep vardır.
Yorumlar 0 konuşma
Bu kod örneğine henüz yorum yapılmamış. Takıldığınız bir yer veya merak ettiğiniz bir ayrıntı varsa ilk soruyu siz sorun.
Soru Sor veya Yorum Yaz
Yorumunuz onaylandıktan sonra yayınlanır. Teknik sorularınıza ekibimiz yanıt verir.