İleri
uygulama-iskeleti/php-baslangic-sablonu
PHP Başlangıç Şablonu (CY PHP Starter)
- PHP 8.1
- PDO
- MySQL
- MariaDB
- +4
309 inceleme 150 canlı açılış 6 beğeni 2 yorum
Uygulama İskeleti İleri
Redis ya da RabbitMQ olmadan, yalnızca MySQL ile iş kuyruğu: FOR UPDATE SKIP LOCKED ile yarışsız rezervasyon, üstel geri çekilme, görünürlük zaman aşımı, dead-letter ve CLI worker.
Veritabanı şeması projedeki cy_queue.sql dosyasında.
Kullanıcı "Kaydet"e bastığında e-posta gönderiliyorsa, o kullanıcı SMTP sunucusunun hızına bağımlıdır. Rapor üretiliyorsa 30 saniye bekler. Görsel işleniyorsa istek zaman aşımına uğrar.
Çözüm bilinir: işi kuyruğa al, arka planda çalıştır. Sorun, kuyruğun kendisini yazmaktır — ve internetteki çoğu örnek şunu yapar:
SELECT * FROM jobs WHERE reserved = 0 LIMIT 1; -- worker A ve B aynı satırı okur
UPDATE jobs SET reserved = 1 WHERE id = ?; -- ikisi de "benim" derBu proje o yarışı kapatıyor — ve kuyruğun diğer dört zor sorusunu da cevaplıyor:
FOR UPDATE SKIP LOCKEDRESERVE_TTL)Kimler için uygun?
Klonla, cy_queue.sql'i içe aktar, çalıştır. Başka hiçbir kurulum adımı yok. Composer yok, npm yok, internet bağlantısı bile gerekmiyor — tüm kütüphaneler proje içinde.
Bu proje, Çılgın Yazılım Kütüphanesi altında yayınlanan açıklamalı, üretime hazır örneklerden biridir.
FOR UPDATE SKIP LOCKED — yarışı kapatan tek satırKlasik "önce SELECT, sonra UPDATE" kalıbının arasında bir pencere vardır ve iki worker o pencereye aynı anda girer. SKIP LOCKED bu pencereyi sıfırlar: satırı okurken kilitler ve başka bir worker'ın kilitlediği satırı beklemeden atlar.
SELECT id FROM jobs
WHERE queue = :q
AND available_at <= NOW()
AND (reserved_at IS NULL OR reserved_at < (NOW() - INTERVAL :ttl SECOND))
ORDER BY id ASC
LIMIT 1
FOR UPDATE SKIP LOCKEDSKIP LOCKED olmadan FOR UPDATE de doğru çalışır ama beklemeli: ikinci worker, birincisi işini bitirene kadar durur. On worker'la kuyruk tek şeritli bir yola döner. SKIP LOCKED her worker'a "meşgul olanı atla, sıradakini al" dedirtir.
SKIP LOCKED MySQL 8.0 ve MariaDB 10.6 ile geldi. XAMPP uzun süre MariaDB 10.4 ile dağıtıldı; orada bu sözdizimi 1064 verir.
Sürüm dizgisini ayrıştırmak ("10.4.32-MariaDB" gibi bir metni parçalamak) kırılgandır — dağıtımlar kendi eklerini koyar. Onun yerine sorguyu bir kez deniyoruz ve sonucu hatırlıyoruz:
function supports_skip_locked(PDO $db): bool
{
static $supported = null;
if ($supported !== null) { return $supported; }
try {
$db->beginTransaction();
$db->query('SELECT id FROM jobs LIMIT 0 FOR UPDATE SKIP LOCKED');
$db->commit();
$supported = true;
} catch (Throwable $e) {
if ($db->inTransaction()) { $db->rollBack(); }
$supported = false;
}
return $supported;
}UPDATE içinde yapılır ve WHERE koşulu "hâlâ rezerve edilmemiş" der:
UPDATE jobs SET reserved_at = NOW(), reserved_by = :stamp, attempts = attempts + 1
WHERE queue = :q AND available_at <= NOW()
AND (reserved_at IS NULL OR reserved_at < (NOW() - INTERVAL :ttl SECOND))
ORDER BY id ASC LIMIT 1rowCount() === 1 ise iş bizimdir; 0 ise başkası kapmıştır. :stamp benzersiz bir damgadır (hostname:pid#rastgele) — "benim satırımı geri oku" sorgusunu mümkün kılan şey odur.
available_at tek sütunla iki işi birden yaparGecikmeli iş ("yarın gönder") ve yeniden deneme beklemesi (backoff) aynı soruyu sorar: bu iş ne zaman işlenebilir? İkisi için ayrı sütun tutmak (delay_until + retry_at) gereksizdir ve hangisinin geçerli olduğunu her sorguda karar vermeyi gerektirir.
function backoff_seconds(int $attempts): int
{
return (int) min(BACKOFF_BASE * (2 ** max(0, $attempts - 1)), BACKOFF_CAP);
}BACKOFF_CAP ise beklemenin saatlere çıkmasını önler.
Bir worker işi rezerve edip çökerse (elektrik, OOM, deploy) o iş sonsuza dek "işleniyor" durumunda kalır. Kimse işlemez, kimse fark etmez.
Çözüm reserved_at sütununun eskiliğine bakmaktır:
AND (reserved_at IS NULL OR reserved_at < (NOW() - INTERVAL 90 SECOND))İkinci bir incelik: attempts rezervasyon anında artar, işin sonunda değil. Çöken bir worker'ın işi de bir denemeyi tüketmiş sayılır; yoksa sürekli çöken bir işleyici o işi sonsuza dek döndürürdü.
Başarısız işleri jobs tablosunda bir status sütunuyla tutmak cazip görünür. İki sebeple yanlıştır:
jobs, worker'ın her turda taradığı tablodur ve küçük kalmalıdır. Asla işlenmeyecek satırlar orada birikirse her rezervasyon sorgusu yavaşlar.Demodaki dead-letter kayıtları bilerek farklı hata türleri taşır: geçici bir aksaklık (yeniden denemek mantıklı), geçersiz bir e-posta adresi (kalıcı — aynı sonucu verir) ve bilinmeyen bir iş türü (kodda o handler yok). "Yeniden dene" düğmesine basmadan önce hata metnini okumak gerekir.
Kuyruk motoru
FOR UPDATE SKIP LOCKED ile yarışsız rezervasyon10 → 20 → 40 …, tavanlı)attempts rezervasyon anında artar (çökme de sayılır)default, mails) — ayrı worker'lar içinavailable_at tek sütunla iki işi birden yapar--once, --max=N, --queue=…, zarif kapanış (SIGTERM)Arayüz ve altyapı
reserved_by, son hata#is-10aria-live| Açık | Tipik hatalı kod | Bu projede |
|---|---|---|
| Aynı işin iki kez çalışması | SELECT … LIMIT 1 sonra UPDATE | FOR UPDATE SKIP LOCKED; yedek yolda tek UPDATE + rowCount() kontrolü. Bu bir "performans iyileştirmesi" değil, doğruluk meselesidir |
| SQL Injection | "… WHERE queue = '".$_POST['q']."'" | Tüm sorgular prepared statement, EMULATE_PREPARES = false. Kuyruk adı ve iş türü beyaz listeden geçer (KNOWN_QUEUES, KNOWN_JOBS) |
| XSS (kuyruk ekranında) | $('#row').html(job.type) | İş türü, payload ve hata metni kullanıcı/dış servis verisidir. Sunucuda e(), istemcide esc() |
| Worker'ın web'den çalıştırılması | bin/worker.php erişilebilir | İki katman: dosyanın başında PHP_SAPI !== 'cli' kontrolü ve .htaccess'te Require all denied |
| Sonsuz yeniden deneme | while (true) retry | JOB_MAX_ATTEMPTS ve dead-letter. Kalıcı bir hata sonsuz bir maliyete dönüşmez |
| Kaybolan iş (çöken worker) | Sadece reserved = 1 bayrağı | reserved_at + RESERVE_TTL. Sahipsiz kalan iş otomatik geri döner |
| Yarım kalan durum geçişi | Önce INSERT, sonra DELETE | Dead-letter'a taşıma ve yeniden kuyruğa alma tek transaction; iş asla iki yerde birden bulunmaz |
| Bozuk kodlamanın yanıtı yutması | json_encode() → false → boş gövde | JSON_INVALID_UTF8_SUBSTITUTE: dış servisten gelen bozuk bir bayt, kuyruğun tamamını görünmez yapmaz |
| CSRF | (genelde hiç yok) | Oturuma bağlı 32 baytlık token, her istekte, hash_equals() ile sabit zamanlı doğrulama |
| Kaynak tüketimi | Sınırsız enqueue | ENQUEUE_MAX_COUNT (25) ve üç ayrı hız sınırı kovası. status en sık, work en pahalı olandır |
| Bilgi sızıntısı | Ekrana basılan MySQL hataları | APP_DEBUG sunucu adından türetilir; canlıda otomatik false, detay error_log()'a gider |
| Kurulum dosyasının indirilmesi | /cy_queue.sql → HTTP 200 | .htaccess: .sql, .md, .json, .log … kapalı (README dosyaları bilinçli istisnadır) |
php bin/worker.php # varsayılan kuyruk, süresiz
php bin/worker.php --once # tek iş işle ve çık
php bin/worker.php --max=50 # 50 iş işleyince çık
php bin/worker.php --queue=mails # başka bir kuyruk
Worker sürekli çalışan bir süreçtir ve çökebilir. Bir süpervizör onu yeniden başlatmalıdır. systemd örneği:
[Unit]
Description=CY Job Queue Worker
After=mysql.service
[Service]
ExecStart=/usr/bin/php /var/www/proje/bin/worker.php --queue=default
Restart=always
RestartSec=5
User=www-data
[Install]
WantedBy=multi-user.target--max=500 ile birlikte kullanmak da yaygındır: worker 500 iş sonra kendini kapatır, süpervizör yeniden başlatır. Bu, uzun süren PHP süreçlerinde bellek sızıntısı riskini sıfırlar.
* * * * * cd /var/www/proje && php bin/worker.php --max=20 >> /var/log/cy-queue.log 2>&1pcntl eklentisi varsa worker SIGTERM/SIGINT aldığında işlenen işi bitirir, sonra çıkar. Deploy sırasında yarım kalan iş olmaz.
Tüm ayarlar system/config.php içindedir.
| Sabit | Varsayılan | Ne işe yarar |
|---|---|---|
JOB_MAX_ATTEMPTS | 3 | Bu kadar denemeden sonra iş dead-letter'a taşınır |
BACKOFF_BASE | 10 | İlk yeniden deneme beklemesi (saniye) |
BACKOFF_CAP | 600 | Beklemenin üst sınırı — saatlere çıkmasın |
RESERVE_TTL | 90 | Görünürlük zaman aşımı: çöken worker'ın işi bu süre sonra serbest kalır |
WORKER_SLEEP | 3 | Kuyruk boşken worker'ın uykusu (sıfır olsaydı %100 CPU) |
KNOWN_JOBS | 4 tür | Arayüzden eklenebilecek iş türleri (beyaz liste) |
KNOWN_QUEUES | default, mails | İzin verilen kuyruk adları (beyaz liste) |
ENQUEUE_MAX_COUNT | 25 | Tek seferde eklenebilecek en fazla iş |
RATE_LIMIT_* | [istek, saniye] | enqueue / work / status için ayrı kovalar |
RESERVE_TTL nasıl seçilir?En uzun işinizin süresinden belirgin biçimde uzun olmalıdır. Kısa seçerseniz, hâlâ çalışan bir worker'ın işi "bayat" sayılır ve ikinci kez alınır — tam da kaçındığınız şey olur.
30 saniyelik rapor işleriniz varsa RESERVE_TTL = 300 makuldür. Emin değilseniz büyük tarafta hata yapın: geç fark edilen bir çökme, iki kez çalışan bir işten ucuzdur.
Bu projede ekstra bir sebep var: worker ayrı bir süreçtir ve aynı yapılandırmayı okur. Künyeyi iki yerde tutmak, birini güncelleyip diğerini unutmanın kesin yoludur.
cp system/config.local.php.example system/config.local.php
# sonra dört satırı doldurunconfig.local.php → ortam değişkeni → yerel varsayılan.
1 — İki tabloyu alın
jobs ve failed_jobs iş türünüzden bağımsızdır; olduğu gibi kopyalayın. idx_jobs_pick indeksini mutlaka taşıyın: worker'ın her turda çalıştırdığı sorgu ona dayanır.
2 — Kendi işleyicilerinizi yazın
// system/function.php
function run_job_handler(string $type, array $payload): string
{
return match ($type) {
'send_invoice' => handle_send_invoice($payload),
'sync_stock' => handle_sync_stock($payload),
default => throw new RuntimeException("Bilinmeyen iş türü: $type"),
};
}attempts kontrol edilir, ya backoff ile geri bırakılır ya dead-letter'a taşınır.
3 — Kodunuzdan iş ekleyin
enqueue($db, 'send_invoice', ['order_id' => 4271], 'default', 0);
enqueue($db, 'sync_stock', ['sku' => 'ABC-1'], 'default', 300); // 5 dk sonraYukarıdaki systemd birimi ya da cron satırı.
Atlamayın: İşleyicileriniz idempotent olmalıdır — aynı iş iki kez çalışsa bile sonuç aynı olmalı.SKIP LOCKEDyarışı kapatır, ama worker işi bitiripcomplete_job()çağırmadan hemen önce çökerse iş yeniden alınır. Dağıtık sistemlerde "tam bir kez" garantisi yoktur; "en az bir kez" vardır.
assets/css/cilginyazilim.css dosyası bu projeye değil markaya aittir. Bu örneğe özgü her şey (sayaç şeridi, durum rozetleri, worker günlüğü) assets/css/style.css içindedir.
| Sınıf | Ne işe yarar | ||
|---|---|---|---|
.cy-card / .cy-card__header / __body / __footer | Gradyan başlıklı ana kart | ||
.cy-brand / .cy-brand__mark / __title / __subtitle | Logo kutusu + başlık bloğu | ||
.cy-btn + --primary \ | --onbrand \ | --glass | Marka butonları |
.cy-badge + --glass \ | --soft | Rozetler | |
.cy-table | Marka görünümlü tablo | ||
.cy-modal / .cy-detail | Gradyan başlıklı modal ve etiket/değer listesi | ||
.cy-toast + --success \ | --danger \ | --info | Bildirim balonları |
Bu örneğe özgü (style.css): .cy-stats / .cy-stat, .cy-state--ready|delayed|reserved|stale|failed, .cy-log, .cy-panel, .cy-worker, .cy-switch, .cy-attempts, .cy-warn-dot.
:root {
--cy-brand-900: #061321; /* Logodaki en koyu lacivert */
--cy-brand-600: #0b5cb5; /* Ana marka mavisi */
--cy-accent: #0ea5e9; /* Vurgu rengi */
--cy-gradient: linear-gradient(135deg, #061321, #0b5cb5 45%, #0284c7);
}
Kullanıcının işletim sistemi koyu temadaysa otomatik devreye girer. Zorlamak isterseniz: <html data-cy-theme="dark">
job-queue/
├── index.php → Arayüz. Veritabanına DOKUNMAZ; üç kart + bir modal.
├── cy_queue.sql → Şema + 13 iş + 4 dead-letter kaydı (NOW() ± INTERVAL)
├── .htaccess → Dizin listeleme kapalı, .sql/.md kapalı, bin/ kapalı
│
├── bin/
│ └── worker.php → CLI worker. Web'den çalıştırılamaz (iki katman koruma).
│
├── system/
│ ├── config.php → Ayarlar, PDO bağlantısı, APP_DEBUG türetimi
│ ├── config.local.php → (siz oluşturursunuz) canlı künye — .gitignore'da
│ ├── config.local.php.example
│ ├── function.php → KUYRUK MOTORU: enqueue, reserve, complete, release, fail
│ ├── ajax.php → 8 uç nokta
│ └── .htaccess → BEYAZ LİSTE: yalnızca ajax.php dışarıya açık
│
├── assets/
│ ├── css/
│ │ ├── cilginyazilim.css → MARKA KALIBI (projeler arası ortak — dokunmayın)
│ │ ├── style.css → Yalnızca bu örneğe özgü stiller
│ │ └── bootstrap.min.css
│ ├── js/
│ │ ├── queue.js → Sayaçlar, tetikleme, dead-letter, modal (6 bölüm)
│ │ ├── jquery-3.7.0.js
│ │ └── bootstrap.bundle.js
│ └── images/logo.png
│
├── docs/screenshots/
├── CHANGELOG.md
├── README.md · README.en.md
└── LICENSE → MIT
enqueue($db, 'send_email', […], 'default', 0)
│
▼
┌────────────────────────────────────────────────────────┐
│ jobs │
│ available_at = NOW() + gecikme │
│ reserved_at = NULL │
│ attempts = 0 │
└────────────────────────────────────────────────────────┘
│
worker: reserve_job() ▼
┌────────────────────────────────────────────────────────┐
│ BEGIN │
│ SELECT id … FOR UPDATE SKIP LOCKED ← yarış YOK │
│ WHERE available_at <= NOW() │
│ AND (reserved_at IS NULL │
│ OR reserved_at < NOW() - RESERVE_TTL) │
│ UPDATE reserved_at = NOW(), attempts = attempts + 1 │
│ COMMIT │
└────────────────────────────────────────────────────────┘
│
run_job_handler($type, $payload)
│
┌───────────────────┴───────────────────┐
başarı exception
│ │
▼ ▼
complete_job() attempts < JOB_MAX_ATTEMPTS ?
satır SİLİNİR │ │
evet hayır
│ │
▼ ▼
release_job() fail_job()
reserved_at = NULL failed_jobs'a TAŞI
available_at = NOW() jobs'tan SİL
+ backoff(attempts) (tek transaction)
| Katman | Dosya | Sorumluluk |
|---|---|---|
| Sunum | index.php | Yalnızca HTML iskeleti. Veri yok, sorgu yok. |
| Arayüz mantığı | assets/js/queue.js | Sayaçlar, tetikleme, otomatik mod, modal |
| Uç noktalar | system/ajax.php | İstek doğrulama, beyaz listeler, JSON yanıtı |
| Kuyruk motoru | system/function.php | Rezervasyon, backoff, dead-letter — asıl iş burada |
| Worker | bin/worker.php | Döngü, sinyal yönetimi, konsol çıktısı |
| Ayar | system/config.php | Sabitler, PDO, APP_DEBUG türetimi |
Dikkat:bin/worker.phpile arayüzdeki düğme aynıprocess_one()fonksiyonunu çağırır. İki ayrı işleme kodu yazılsaydı, ikisi zamanla ayrışır ve "arayüzde çalışıyor ama worker'da çalışmıyor" durumu doğardı.
Tüm istekler system/ajax.php adresine POST ile gider ve CSRF token taşır.
status — sayaçlar + iki liste
İstek: queue
{
"success": true,
"queue": "default",
"queues": ["default", "mails"],
"counts": {"ready": 6, "delayed": 2, "reserved": 1, "stale": 1, "total": 10, "failed": 3},
"pending": [
{"id": 10, "type": "generate_report", "attempts": 1,
"reserved": true, "stale": true, "in_seconds": 0, "has_error": false}
],
"failed": [
{"id": 2, "type": "send_email", "attempts": 3,
"exception": "InvalidArgumentException: Geçersiz e-posta adresi: gecersiz-adres",
"failed_at": "30.08.2026 20:12"}
],
"max_attempts": 3,
"reserve_ttl": 90
}reserved ile stale ayrı alanlardır: ikisi de "rezerve edilmiş" demektir, ama stale olan işin sahibi kayıptır.
work_once — worker'ı bir kez tetikle
{"success": true, "queue": "default", "took_ms": 236.7,
"status": "done", "job_id": 4, "type": "send_email",
"message": "İş #4 (send_email) tamamlandı: e-posta gönderildi → [email protected]"}status dört değerden biridir:
| Değer | Anlamı |
|---|---|
done | İş tamamlandı, satır silindi |
retry | Hata alındı, backoff ile geri bırakıldı |
failed | Deneme hakkı bitti, dead-letter'a taşındı |
empty | İşlenecek iş yok |
enqueue — kuyruğa iş ekle
İstek: type, count, delay, queue
{"success": true, "description": "3 iş \"default\" kuyruğuna eklendi.",
"ids": [121, 122, 123], "queue": "default"}type KNOWN_JOBS, queue KNOWN_QUEUES beyaz listesinden geçer; geçersiz tür 422, geçersiz kuyruk sessizce varsayılana düşer.
job_detail — tek işin künyesi
İstek: id, source (jobs \| failed)
{
"success": true,
"job": {
"id": 10, "source": "jobs", "queue": "default", "type": "generate_report",
"attempts": 1, "payload": "{\n \"report\": \"stok-durumu\",\n \"format\": \"xlsx\"\n}",
"available_at": "30.08.2026 23:34:33", "in_seconds": 0,
"reserved_at": "30.08.2026 23:35:33", "reserved_by": "worker-02:8891",
"stale": true, "last_error": null,
"created_at": "30.08.2026 23:27:33", "next_backoff": 20
}
}stale ve in_seconds SQL'de hesaplanır. İlk sürümde PHP'nin time() değeri kullanılıyordu ve sunucu ile veritabanının saat dilimi farklı olduğunda liste ile detay birbirinden farklı cevap veriyordu.
release_stale · retry_failed · forget_failed · clear_failed
release_stale bayatlamış rezervasyonları serbest bırakır ve kaç tanesini bıraktığını döner. Normalde gerekli değildir — mekanizma kendiliğinden işler; bu uç nokta onu görünür kılmak içindir.
retry_failed dead-letter kaydını kuyruğa geri koyar ve dead-letter satırını siler — tek transaction. Ayrı yazılsaydı iş hem kuyrukta hem başarısızlarda görünebilirdi.
| Kod | Anlamı |
|---|---|
200 | İşlem başarılı (status: empty de 200'dür) |
400 | Geçersiz parametre ya da bilinmeyen işlem |
403 | CSRF token geçersiz veya oturum düşmüş |
404 | İş bulunamadı (işlenmiş ve silinmiş olabilir) |
405 | POST dışı istek |
422 | Bilinmeyen iş türü |
429 | Hız sınırı aşıldı (retry_after saniye döner) |
500 | Sunucu / veritabanı hatası |
CREATE TABLE `jobs` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`queue` VARCHAR(60) NOT NULL DEFAULT 'default',
`type` VARCHAR(60) NOT NULL,
`payload` JSON NOT NULL,
`attempts` TINYINT UNSIGNED NOT NULL DEFAULT 0,
`available_at` DATETIME NOT NULL,
`reserved_at` DATETIME NULL DEFAULT NULL,
`reserved_by` VARCHAR(100) NULL DEFAULT NULL,
`last_error` VARCHAR(1000) NULL DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_jobs_pick` (`queue`, `available_at`, `reserved_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE `failed_jobs` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`queue` VARCHAR(60) NOT NULL DEFAULT 'default',
`type` VARCHAR(60) NOT NULL,
`payload` JSON NOT NULL,
`attempts` TINYINT UNSIGNED NOT NULL DEFAULT 0,
`exception` TEXT NOT NULL,
`failed_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_failed_type` (`type`),
KEY `idx_failed_queue` (`queue`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
| Karar | Neden |
|---|---|
idx_jobs_pick (queue, available_at, reserved_at) | Worker'ın her turda çalıştırdığı sorgunun dayandığı indeks. Sütun sırası önemlidir: önce eşitlik (queue), sonra aralık (available_at). Ters sırada indeks aralık taramasından sonra kullanılamaz |
available_at DATETIME | Gecikmeli iş ve backoff aynı sütunla ifade edilir; ayrı bir retry_at gereksizdir |
reserved_at + reserved_by | Biri zaman aşımı için, diğeri "kim aldı" sorusu için. reserved_by yedek rezervasyon yolunda benzersiz damga da taşır |
attempts TINYINT | 255 deneme her senaryo için fazlasıyla yeter; INT kullanmak satır başına 3 bayt israfıdır |
payload JSON | İş türü başına farklı alanlar gerekir; sabit sütunlar her yeni iş türünde şema değişikliği isterdi |
failed_jobs AYRI tablo | jobs worker'ın her turda taradığı tablodur ve küçük kalmalıdır. Başarısız işleri orada bir status sütunuyla tutmak her rezervasyon sorgusunu yavaşlatır |
last_error VARCHAR(1000) | Kuyrukta duran iş için son hatanın özeti yeter; tam metin dead-letter'da TEXT olarak saklanır |
BIGINT id | Kuyruk satırları sürekli oluşur ve silinir; AUTO_INCREMENT hızla büyür ve INT tavanı beklenenden erken gelir |
| InnoDB | Satır kilidi ve transaction desteği — SKIP LOCKED'ın ön şartı |
Neden Redis ya da RabbitMQ değil?
Çünkü çoğu proje için gerekmiyor. Zaten bir veritabanınız var, yedekleniyor, izleniyor ve ekibiniz onu biliyor. İkinci bir servis eklemek: kurulum, yedekleme, izleme, sürüm yükseltme ve bir arıza noktası daha demektir.
MySQL kuyruğu şu noktaya kadar rahat gider: dakikada birkaç bin iş, birkaç worker. Ötesinde Redis (hız) ya da RabbitMQ (yönlendirme, çoklu tüketici desenleri) anlamlı olmaya başlar.
Ayrıca bir avantajı vardır ki küçümsenmemeli: işi ekleyen transaction ile aynı transaction içinde kuyruğa koyabilirsiniz. Sipariş kaydı geri alınırsa e-posta işi de geri alınır. Ayrı bir kuyruk servisinde bu garanti yoktur.
SKIP LOCKED olmadan gerçekten iki worker aynı işi alır mı?
Evet — ve bu, testte asla görünmeyen hatalardandır. Tek worker'la çalıştırdığınızda hiç oluşmaz.
Klasik kalıpta SELECT … LIMIT 1 ile UPDATE … SET reserved = 1 arasında bir pencere vardır. İki worker aynı milisaniyede SELECT yaparsa ikisi de aynı idyi görür, ikisi de UPDATE eder, ikisi de işi çalıştırır. Kullanıcı e-postayı iki kez alır.
Bu projede yedek yol bile bu pencereyi kapatır: rezervasyon ve sahiplik iddiası tek bir UPDATE içindedir ve WHERE koşulu "hâlâ rezerve edilmemiş" der. İkinci worker'ın UPDATE'i 0 satır etkiler.
İşim iki kez çalışabilir mi? "Tam bir kez" garantisi var mı?
Yoktur — ve hiçbir dağıtık kuyrukta yoktur. Worker işi bitirip complete_job() çağırmadan hemen önce çökerse, iş RESERVE_TTL sonra yeniden alınır ve ikinci kez çalışır.
Bu yüzden işleyicileriniz idempotent olmalıdır: aynı iş iki kez çalışsa bile sonuç aynı olmalı. Pratikte bu, "bu siparişin faturası zaten gönderildi mi?" kontrolü ya da bir unique indeks demektir.
Kuyruğun garantisi "en az bir kez"dir. "Tam bir kez" davranışını uygulama katmanı üretir.
RESERVE_TTL değerini nasıl seçerim?
En uzun işinizin süresinden belirgin biçimde uzun olmalıdır. Kısa seçerseniz hâlâ çalışan bir worker'ın işi "bayat" sayılır ve ikinci kez alınır — tam da kaçındığınız şey.
30 saniyelik rapor işleriniz varsa 300 saniye makuldür. Emin değilseniz büyük tarafta hata yapın: geç fark edilen bir çökme, iki kez çalışan bir işten ucuzdur.
Kuyruk tablosu şişer mi? Temizlik gerekir mi?
jobs tablosu şişmez: tamamlanan iş silinir. Bu bilinçli bir karardır — worker'ın her turda taradığı tablo küçük kalmalıdır.
failed_jobs ise büyür. Orada bir saklama politikası belirleyin: örneğin 30 günden eski kayıtları arşivleyin ya da silin. Silmeden önce okuyun: dead-letter, sisteminizin nerede kırıldığını anlatan en dürüst kayıttır.
Tamamlanan işlerin kaydını tutmak isterseniz complete_job() içinde ayrı bir arşiv tablosuna yazabilirsiniz — ama bunun kuyruk için gerekli olmadığını, raporlama ihtiyacı olduğunu bilerek yapın.
Neden birden fazla kuyruk (default, mails) var?
Çünkü bütün işler eşit değildir. 40 saniye süren bir rapor işi, 200 milisaniyelik e-posta işlerinin önünde durursa e-postalar gecikir.
Ayrı kuyruk = ayrı worker demektir:
php bin/worker.php --queue=default # raporlar, görseller
php bin/worker.php --queue=mails # e-postalar, hızlı işlerqueue sütunu ve idx_jobs_pick indeksinin ilk sütunu tam olarak bunun içindir.
Arayüzdeki "Worker'ı çalıştır" düğmesi üretimde de kullanılabilir mi?
Hayır — ve zaten kullanılmamalı. O düğme bir işi web isteğinin içinde çalıştırır: istek, işin süresi kadar bekler. Kuyruğun var olma sebebi tam olarak bunu önlemektir.
Düğme demoda iki iş görür: kuyruğun davranışını adım adım izletmek, ve bir işi istek içinde çalıştırmanın neden kötü fikir olduğunu hissettirmek (generate_report işini tetikleyin, isteğin nasıl beklediğini görün).
--once çağırın--max=N kullanın: uzun süren PHP süreçlerinde bellek sızıntısı riskini sıfırlarRESERVE_TTL değerini en uzun işinizden uzun seçinAPP_DEBUG zaten ortamdan türetiliyor — yine de canlıda false olduğunu doğrulayınsystem/config.local.php oluşturun; künyeyi config.php'ye yazmayın (worker da aynı dosyayı okur)bin/worker.php adresinin web'den erişilemediğini doğrulayınfailed_jobs için bir saklama politikası belirleyin.htaccess çalışmaz; şu iki yolu sunucu yapılandırmasından kapatın:```nginx
location ~* \.(sql|log|ini|bak)$ { deny all; }
location ^~ /bin/ { deny all; }
```
| Belirti | Çözüm |
|---|---|
| "You have an error in your SQL syntax" (1064) | Muhtemelen SKIP LOCKED desteklenmiyor. Kod bunu kendiliğinden tespit edip yedek yola düşer; hata görüyorsanız supports_skip_locked() değiştirilmiş olabilir. |
AS delayed yazınca 1064 alıyorum | DELAYED MySQL/MariaDB'de ayrılmış kelimedir. Takma adı backtick içine alın: ` AS delayed `. |
| İşler "işleniyor"da takılı kaldı | Worker çökmüş olabilir. RESERVE_TTL sonra otomatik serbest kalırlar; hemen istiyorsanız "Bayatları serbest bırak" düğmesini kullanın. |
| Aynı iş iki kez çalışıyor | Ya RESERVE_TTL en uzun işinizden kısa, ya da bir worker complete_job() öncesi çöküyor. İşleyicilerinizi idempotent yapın. |
| Worker hiç iş almıyor | --queue parametresi kuyruk adıyla eşleşiyor mu? Varsayılan default; demo verisinde mails kuyruğunda da işler var. |
| Liste ile detay farklı "bayat" cevabı veriyor | Bu sürümde düzeltildi: bayatlık artık SQL'de hesaplanıyor. Değiştirdiyseniz, PHP time() ile MySQL NOW() karşılaştırmayın — saat dilimleri farklı olabilir. |
| HTTP 429 dönüyor | Hız sınırı. Otomatik mod açıkken status dakikada 30, work dakikada 30 istek atar; otomatik bir test aracı sınırı kolayca aşar. |
bin/worker.php tarayıcıda 403 | Beklenen davranış: worker yalnızca komut satırından çalışır (iki katman koruma). |
| Türkçe karakterler bozuk | Veritabanı utf8mb4 değil ya da SQL dosyası SET NAMES utf8mb4 olmadan aktarılmış. Dead-letter'daki hata metinleri okunamaz hâle gelir. |
priority sütunu + indeks sırası)INSERT … VALUES (…), (…) tek sorguda)completed_jobs arşiv tablosu ve saklama politikasıbackoff_seconds, reserve_job, fail_job)Bu proje herkese açıktır — dilediğiniz geliştirmeyle katkı sağlayabilirsiniz.
📦 Depo: github.com/CilginYazilim/PHP-MySQL-Job-Queue-Is-Kuyrugu-PDO-Skip-Locked-Worker
| Nasıl katkı sağlarım? | Nereden |
|---|---|
| 🐛 Hata bildir | Issues |
| 💡 Özellik öner | Issues |
| 🔧 Kod gönder | Pull Requests |
| ❓ Soru sor | Discussions |
time() ile MySQL NOW() aynı saati göstermeyebilir.style.css üzerinden yapın; cilginyazilim.css markaya aittir ve diğer projelerle ortaktır.MIT — ticari kullanım dahil serbesttir.
cilginyazilim.com tarafından ❤ ile geliştirildi
İncelemek istediğiniz dosyayı ağaçtan seçin.
Güvenlik gereği kaynak dosyalardaki parola, API anahtarı ve benzeri gizli değerler gösterilmeden önce maskelenir (••••••••).
Çünkü çoğu proje için gerekmiyor. Zaten bir veritabanınız var; yedekleniyor, izleniyor ve ekibiniz onu biliyor. İkinci bir servis eklemek kurulum, yedekleme, izleme, sürüm yükseltme ve bir arıza noktası daha demektir. MySQL kuyruğu dakikada birkaç bin iş ve birkaç worker ile rahat gider. Ayrıca küçümsenmemesi gereken bir avantajı vardır: işi ekleyen transaction ile aynı transaction içinde kuyruğa koyabilirsiniz; sipariş kaydı geri alınırsa e-posta işi de geri alınır.
Evet, ve bu testte asla görünmeyen hatalardandır çünkü tek worker ile hiç oluşmaz. Klasik kalıpta SELECT ile UPDATE arasında bir pencere vardır; iki worker aynı milisaniyede SELECT yaparsa ikisi de aynı kaydı görür, ikisi de rezerve eder ve iş iki kez çalışır. Kullanıcı e-postayı iki kez alır ya da ödeme iki kez çekilir. Bu projede yedek yol bile bu pencereyi kapatır: rezervasyon ve sahiplik iddiası tek bir UPDATE içindedir ve WHERE koşulu hâlâ rezerve edilmemiş olmayı şart koşar.
Yoktur ve hiçbir dağıtık kuyrukta yoktur. Worker işi bitirip tamamlandı işaretini koymadan hemen önce çökerse, iş görünürlük zaman aşımı sonrası yeniden alınır ve ikinci kez çalışır. Bu yüzden işleyicileriniz idempotent olmalıdır: aynı iş iki kez çalışsa bile sonuç aynı olmalı. Pratikte bu, bu siparişin faturası zaten gönderildi mi kontrolü ya da bir benzersiz indeks demektir. Kuyruğun garantisi en az bir kezdir; tam bir kez davranışını uygulama katmanı üretir.
En uzun işinizin süresinden belirgin biçimde uzun olmalıdır. Kısa seçerseniz hâlâ çalışan bir worker'ın işi bayat sayılır ve ikinci kez alınır; tam da kaçındığınız şey olur. Otuz saniyelik rapor işleriniz varsa üç yüz saniye makuldür. Emin değilseniz büyük tarafta hata yapın: geç fark edilen bir çökme, iki kez çalışan bir işten ucuzdur.
Kuyruk tablosu şişmez çünkü tamamlanan iş silinir; bu bilinçli bir karardır, worker'ın her turda taradığı tablo küçük kalmalıdır. Başarısız işler tablosu ise büyür; orada bir saklama politikası belirleyin, örneğin otuz günden eski kayıtları arşivleyin. Silmeden önce okuyun: dead-letter, sisteminizin nerede kırıldığını anlatan en dürüst kayıttır.
Hayır. O düğme bir işi web isteğinin içinde çalıştırır ve istek, işin süresi kadar bekler; kuyruğun var olma sebebi tam olarak bunu önlemektir. Düğme demoda iki iş görür: kuyruğun davranışını adım adım izletmek ve bir işi istek içinde çalıştırmanın neden kötü fikir olduğunu hissettirmek. Gerçek kurulumda worker bir süpervizör altında sürekli çalışır ya da cron ile dakikada bir tetiklenir.
Bu örneğin daha gelişmişi projenizde mi lazım?
İhtiyacınızı anlatın; bu yapıyı sizin sisteminize uyarlayalım ya da size özel geliştirelim.
Henüz yorum yok. Takıldığınız bir yer varsa ilk soruyu siz sorun.
Yorumunuz onaylandıktan sonra yayınlanır. Teknik sorularınıza ekibimiz yanıt verir.
İleri
uygulama-iskeleti/php-baslangic-sablonu
309 inceleme 150 canlı açılış 6 beğeni 2 yorum
Orta
uygulama-iskeleti/php-scheduler-cron
178 inceleme 50 canlı açılış
Orta
uygulama-iskeleti/php-multi-language-i18n
150 inceleme 48 canlı açılış