Laravel Queues: Pilih Redis atau Database Driver?
Nasihat biasa tentang topik ini biasanya berhenti pada “Redis lebih cepat”. Itu memang benar, tapi ia juga maklumat paling tidak berguna semasa anda memilih driver queue. Kelajuan jarang menjadi penentu. Yang menentukan ialah apa yang berlaku pada job bila transaction rollback, bila server restart, bila Redis kehabisan memori, atau bila worker ambil masa tiga saat untuk nampak kerja baharu. Untuk dapat jawapan sebenar, bukan sekadar ulang cerita orang, saya jalankan kelas DatabaseQueue dan RedisQueue milik Laravel sendiri (tag framework v13.35.0) menggunakan MariaDB 10.11 dan Redis 7.0, kemudian mengukur perbezaannya. Artikel ini beri anda jawapan ringkas, bukti di belakangnya, serta konfigurasi yang membuat setiap driver berfungsi dengan betul.
Jawapan ringkas
Laravel sendiri bersetuju bahawa driver database ialah default yang masuk akal: dalam fail config/queue.php yang disertakan bersama framework pada 13.35, QUEUE_CONNECTION secara default ialah database. Selebihnya artikel ini menerangkan bila anda patut tinggalkan default tersebut.
Cara setiap driver menyimpan job
Driver database
Job disimpan dalam satu jadual, yang dicipta oleh migration default 0001_01_01_000002_create_jobs_table.php (atau php artisan make:queue-table jika aplikasi anda belum ada). Lajurnya ialah id, queue (berindeks), payload, attempts, reserved_at, available_at dan created_at.
Bila worker minta job, driver ini menjalankan query seperti ini dalam transaction. Ini SQL sebenar yang dirakam daripada satu run terhadap MariaDB 10.11:
select * from `jobs`
where `queue` = ?
and ((`reserved_at` is null and `available_at` <= ?) or (`reserved_at` <= ?))
order by `id` asc
limit 1
FOR UPDATE SKIP LOCKED;
update `jobs` set `reserved_at` = ?, `attempts` = ? where `id` = ?;
Bahagian FOR UPDATE SKIP LOCKED itulah yang membolehkan banyak worker tarik job serentak tanpa menunggu satu sama lain: baris yang sudah dikunci worker lain akan dilangkau, bukan ditunggu. Framework hanya menggunakan ciri ini bila server anda menyokongnya. Berdasarkan sumber DatabaseQueue: MySQL 8.0.1 atau lebih baharu, MariaDB 10.6 atau lebih baharu, PostgreSQL 9.5 atau lebih baharu, dan Vitess terkini. Pada versi lama, ia kembali kepada kunci FOR UPDATE biasa, yang masih berfungsi tetapi membuat worker beratur di belakang satu sama lain. Bila job berjaya, baris itu dipadam. Query yang sama juga mengambil baris yang reserved_at lebih lama daripada retry_after, dan inilah cara job daripada worker yang crash kembali.
Driver Redis
Setiap queue ialah sekumpulan kecil Redis keys. Saya push dua job dan satu delayed job, pop satu, dan senaraikan keys tersebut:
Pop, release dan migration bagi expired jobs masing-masing berjalan sebagai Lua script (lihat LuaScripts.php), jadi peralihan daripada “ready” ke “reserved” adalah atomic pada sisi Redis. Tiada row locks, tiada transaction, tiada table yang membengkak.
Apa yang saya ukur
Persediaan: container 2-vCPU dengan MariaDB 10.11, Redis 7.0.15 dan PHP, semuanya pada mesin yang sama, empty jobs, dan kelas queue framework sebenar. Nombor ini berasal daripada persekitaran saya, bukan benchmark universal. Latency rangkaian ke database terurus atau Redis host akan mengubahnya, dan job anda melakukan kerja sebenar. Baca ia sebagai perbandingan dua driver dalam keadaan sama persis.
Throughput: pop dan delete 8,000 empty jobs
Setiap run memproses setiap job tepat sekali, dengan sifar duplikasi, pada kedua-dua driver. Masa yang diukur termasuk permulaan proses PHP, dan baris 8-worker terhad kerana hanya mempunyai dua CPU core, jadi lihat baris 1 dan 4 worker untuk perbandingan yang lebih bersih.
Apa maksud ini dalam praktik: dengan satu worker, queue overhead sekitar 0.3 ms setiap job pada Redis dan sekitar 1.9 ms setiap job pada database. Kalau job anda hantar e-mel, panggil API atau render PDF, ia ambil puluhan atau ratusan millisecond, dan overhead driver akan tenggelam dalam masa itu. Database driver hanya menjadi faktor pembatas apabila anda mempunyai sangat banyak job yang sangat pendek, dalam ratusan job setiap detik.
Versi database lama menanggung kos locking
Saya paksa database driver guna plain-lock fallback (yang anda dapat pada versi MySQL lebih lama daripada 8.0.1 atau versi MariaDB lebih lama daripada 10.6) dan menjalankan 8 worker ke atas 4,000 jobs, dua kali untuk setiap mod. Dengan SKIP LOCKED, kedua-dua run mengambil masa 5.3 s; dengan plain lock, ia mengambil masa 6.6 s dan 6.4 s. Dalam satu run sebelum ini dengan plain lock, salah satu worker saya crash dengan MySQL error 1213 (deadlock). Skrip ujian saya tidak retry, manakala worker Laravel menangkap exception, melaporkannya dan berehat satu detik sebelum cuba lagi, jadi anda akan nampak kelambatan dan log noise, bukan worker yang mati. Kalau anda menjalankan database driver pada server lama, naik tarafkan server itu sebelum menyalahkan Laravel.
Latency: berapa lama job baharu menunggu worker
Throughput menyembunyikan perbezaan yang lebih ketara. Worker yang idle tidak menunggu push; secara default, ia meminta kerja, tidak menemui apa-apa, lalu tidur selama –sleep saat (default 3). Saya mulakan empat worker idle dan push 8 job pada masa rawak, kemudian ukur kelewatan sebelum setiap job diambil:
Dua perkara yang menonjol. Pertama, Redis hanya terasa serta-merta selepas anda mengkonfigurasinya: secara asal, ia melakukan polling seperti database. Kedua, block_for sahaja tidak cukup. Dalam worker loop ( Worker::daemon ), apabila blocking pop timeout tanpa job, worker masih tidur selama –sleep saat, dan job yang di-push semasa tidur itu akan menunggu. Itulah nilai maksimum 2.73 saat dalam baris ketiga. Menetapkan –sleep=0 bersama block_for menghilangkan jurang itu. Lakukan itu hanya dengan block_for ditetapkan kepada nombor positif: tanpa blocking, –sleep=0 menjadikan worker sebagai busy loop yang menghentam Redis.
// config/queue.php
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
'block_for' => 5, // was null: wait up to 5 seconds for a job instead of polling
'after_commit' => false,
],
php artisan queue:work redis --sleep=0 --tries=3 --max-time=3600
Dokumentasi Laravel memberi amaran bahawa block_for yang ditetapkan kepada 0 akan memblokek tanpa had dan menghalang isyarat seperti SIGTERM daripada dikendalikan sehingga job seterusnya diproses, jadi guna nilai positif seperti 5.
Database driver tiada padanan untuk blocking. Latensinya dibatasi oleh –sleep. Anda boleh turunkannya kepada 1, tetapi setiap worker idle akan menjalankan query setiap saat. Dengan sepuluh worker idle, itu bermakna sepuluh query setiap saat yang tidak melakukan apa-apa, dan itulah kos tersembunyi database driver pada primary yang sibuk.
Perbezaan yang lebih penting daripada kelajuan
1. Transactions: driver mana yang memberi anda job yang sepatutnya tidak wujud
Ini perbezaan tingkah laku yang paling penting. Saya dispatch job ke setiap driver di dalam database transaction, kemudian roll back transaction itu:
Dengan database driver, baris job ditulis pada connection yang sama dengan data anda, jadi ia commit atau roll back bersama data itu. Dengan Redis, job kelihatan sebaik sahaja anda push-nya, tidak kira apa yang transaction lakukan. Akibat klasik: anda cipta order, dispatch SendReceipt, worker mengambilnya dalam dua millisecond, dan baris order yang dicari belum commit lagi. Atau transaction roll back, dan resit dihantar keluar untuk order yang tidak wujud.
Jawapan Laravel untuk Redis (dan mana-mana driver bukan transaksi) ialah opsi after_commit. Dokumentasi menyatakan bahawa jika pilihan ini ditetapkan kepada true , Laravel menunggu sehingga transaksi induk yang masih terbuka selesai commit sebelum menghantar job, dan membuang job yang dihantar dalam transaksi yang rollback. Anda juga boleh menetapkannya setiap job menggunakan ->afterCommit() atau ->beforeCommit() :
// config/queue.php, redis connection
'after_commit' => true,
// or per dispatch
ProcessOrder::dispatch($order)->afterCommit();
Satu amaran untuk database driver: jaminan ini hanya sah apabila queue menggunakan sambungan database yang sama dengan data aplikasi anda. Sambungan queue dipilih melalui DB_QUEUE_CONNECTION (key sambungan dalam konfigurasi queue). Jika anda arahkan ia ke database lain untuk mengurangkan beban primary, anda akan kehilangan tingkah laku transaksi dan perlu after_commit di situ juga.
2. Ketahanan: apa yang kekal selepas restart atau lonjakan memori
Job database sama tahan dengan database anda: backup, replikasi dan pemulihan crash semua terpakai. Redis ialah storan dalam memori, dan apa yang kekal bergantung pada konfigurasinya. Dalam konfigurasi lalai Redis 7.0.15 (saya periksa dengan CONFIG GET ), snapshot diambil ikut jadual ( save 3600 1 300 100 60 10000 ), fail append-only dimatikan ( appendonly no ), dan appendfsync ialah everysec . Justeru, crash boleh menghilangkan semua yang dihantar sejak snapshot terakhir. Jika job anda penting, aktifkan fail append-only dalam redis.conf :
appendonly yes
appendfsync everysec
maxmemory-policy noeviction
Baris terakhir itu memang layak diuji berasingan, kerana ia antara cara paling mudah untuk kehilangan job tanpa anda sedar. Saya mulakan Redis dengan had memori 3 MB, push 20,000 job sambil beberapa cache key turut memenuhi memori, dan bandingkan dua eviction policy:
Dengan allkeys-lru , yang merupakan tetapan biasa untuk Redis yang digunakan sebagai cache, Redis meng-evict key queue sendiri dan aplikasi tidak pernah tahu. Dengan noeviction, dispatch gagal secara terang-terangan, yang anda boleh catch, alert dan baiki. Itulah sebabnya queue tidak sepatutnya berkongsi Redis instance dengan cache yang memerlukan eviction. Jalankan instance berasingan, atau guna database driver. Nombor tepat bergantung pada had memori kecil saya, tetapi tingkah laku itulah intinya.
3. Pemulihan crash berfungsi sama pada kedua-duanya
Kedua-dua driver menggunakan lease. Apabila worker mengambil sesuatu job, ia menandainya sebagai reserved sehingga retry_after saat berlalu. Jika worker mati sebelum job itu dipadam, job tersebut akan kembali tersedia dengan attempts yang meningkat. Saya mengujinya dengan menetapkan retry_after kepada 2 saat pada setiap driver: job itu tidak dapat diambil semula serta-merta, dan 3 saat kemudian ia kembali dengan attempts = 2 pada kedua-dua driver.
Perkara yang perlu anda perhatikan ialah hubungannya dengan job timeout anda. Dokumentasi Laravel menyatakan timeout sesuatu job perlu sentiasa lebih rendah daripada nilai retry_after; kalau tidak, job itu mungkin dimulakan semula sebelum selesai atau sebelum timeout. Nilai default job timeout ialah 60 saat, dan default retry_after dalam config yang disertakan ialah 90. Jika anda menaikkan timeout job kepada lima minit, naikkan retry_after melebihi nilai itu; kalau tidak, dua worker akan menjalankan job yang sama.
4. Ciri yang hanya berfungsi (atau berfungsi lebih baik) pada satu sisi
- Horizon hanya untuk Redis. Dokumentasi menyatakan Horizon memerlukan queue connection anda ditetapkan kepada redis, dan buat masa ini ia tidak serasi dengan Redis Cluster. Ia menambah dashboard dengan metrik throughput, runtime dan wait-time, serta worker balancing (auto, simple, false). Dengan driver database, anda memantau melalui logs, query anda sendiri dan table failed jobs.
- Unique jobs dan WithoutOverlapping memerlukan cache driver yang menyokong atomic locks, tetapi senarai itu termasuk database dan file, bukan hanya Redis. Anda tidak perlu Redis queue untuk ciri-ciri ini. Terdapat varian Redis (RateLimitedWithRedis, ThrottlesExceptionsWithRedis), dan dokumentasi menyifatkannya lebih cekap jika anda sudah menggunakan Redis.
- Redis Cluster memerlukan nama queue yang mengandungi hash tag, seperti {default}, supaya semua keys bagi satu queue berada dalam satu slot. Selain itu, Redis queue driver tidak menyokong pilihan serializer dan compression untuk Redis.
- Failover. Config yang disertakan dengan Laravel 13.35 mengandungi failover queue connection yang mencuba database, kemudian deferred. Jika anda menggunakan Horizon dengan failover list yang mengandungi database, dokumentasi meminta anda menjalankan proses queue:work database biasa bersama Horizon, kerana Horizon hanya menguruskan Redis queues.
- Batch dan job yang gagal disimpan dalam jadual pangkalan data (job_batches, failed_jobs) tanpa mengira driver, jadi anda tetap memerlukan migrasi tersebut. Bersihkan batch dengan queue:prune-batches.
5. Kos operasi
- Driver pangkalan data: tiada apa baharu yang perlu dijalankan, tetapi trafik job bersaing dengan query aplikasi anda untuk connection dan I/O. Worker polling menambah beban latar belakang yang berterusan. Perhatikan had connection anda: setiap worker memegang satu connection.
- Driver Redis: perkhidmatan baharu yang perlu dijalankan, dipantau, dilindungi dan disaiz, dengan tetapan memori dan persistence tersendiri. Sebagai gantinya, trafik queue tidak lagi menyentuh pangkalan data utama anda.
Menetapkan driver pangkalan data dengan betul
# .env
QUEUE_CONNECTION=database
# Optional: keep job traffic off the primary database
# (this gives up transactional dispatch; use after_commit)
# DB_QUEUE_CONNECTION=queue_db
DB_QUEUE_RETRY_AFTER=90
php artisan make:queue-table # only if your app lacks the jobs migration
php artisan migrate
php artisan queue:work --tries=3 --max-time=3600
Pastikan pelayan pangkalan data cukup baharu untuk menyokong SKIP LOCKED (MySQL 8.0.1+, MariaDB 10.6+, PostgreSQL 9.5+), jalankan bilangan worker yang sesuai dengan had connection anda, dan gunakan –max-time atau –max-jobs supaya Supervisor kerap memulakan semula worker dan mereka melepaskan memori yang terkumpul, seperti yang dicadangkan oleh dokumentasi.
Menetapkan driver Redis dengan betul
# .env
QUEUE_CONNECTION=redis
REDIS_QUEUE_CONNECTION=queue # a Redis connection defined in config/database.php
REDIS_QUEUE_RETRY_AFTER=120
# phpredis (ext-redis) or predis/predis ~3.0 must be installed
// config/database.php: a dedicated Redis connection for queues
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'queue' => [
'host' => env('REDIS_QUEUE_HOST', '127.0.0.1'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_QUEUE_DB', '2'),
],
],
Pada pelayan Redis: tetapkan appendonly yes jika job penting, maxmemory-policy noeviction, dan had memori dengan ruang lebihan serta amaran sebelum had itu dicapai. Tetapkan block_for kepada nombor positif dan jalankan worker dengan –sleep yang rendah, seperti ditunjukkan di atas. Pilih retry_after yang lebih besar daripada timeout job terpanjang anda. Jika anda menggunakan Horizon, jalankan php artisan horizon:terminate pada setiap deploy supaya pemantau proses anda memulakan semula Horizon dengan kod baharu.
Beralih daripada satu driver ke driver lain
Menukar QUEUE_CONNECTION hanya mempengaruhi ke mana job baharu pergi. Job yang sudah berada dalam stor lama kekal di situ dan tiada siapa yang memprosesnya. Urutan selamat:
- Deploy dengan QUEUE_CONNECTION baharu, supaya dispatch baharu pergi ke driver baharu.
- Mulakan worker untuk connection baharu.
- Kekalkan satu worker pada connection lama sehingga ia kosong, menggunakan argumen connection: php artisan queue:work database –stop-when-empty.
- Mulakan semula worker dengan php artisan queue:restart (atau php artisan reload pada Laravel 13) supaya setiap proses menjalankan konfigurasi baharu.
Kesilapan biasa
- Menggunakan instance cache Redis untuk queue dengan eviction policy. Job hilang secara senyap, seperti ditunjukkan di atas.
- Membiarkan block_for tidak ditetapkan dan tertanya-tanya mengapa Redis tidak lebih pantas bertindak. Secara lalai, Redis melakukan polling seperti pangkalan data.
- Menetapkan timeout job yang panjang tanpa menaikkan retry_after. Job itu berjalan dua kali.
- Menghantar ke Redis dalam transaksi dan membaca row dalam job. Guna after_commit.
- Tidak restart workers selepas deploy. Workers itu terus menjalankan kod lama (dibincangkan dalam artikel kesilapan produksi saya).
- Menjalankan database driver pada MySQL atau MariaDB lama. Tanpa SKIP LOCKED, workers yang berjalan serentak akan berebut row yang sama.
Soalan Lazim
Adakah database queue driver sesuai untuk produksi?
Ya. Ia driver yang disokong dan default dalam config bawaan Laravel. Ia sesuai sehingga anda perlukan ratusan job pendek per saat, Horizon, atau beban lebih rendah pada database utama.
Berapa banyak job per saat yang boleh ditangani database driver?
Dalam ujian saya, satu worker pada MariaDB tempatan mengambil dan memadam sekitar 530 job kosong per saat, dan empat worker sekitar 870. Hardware, network dan tempoh job anda akan mengubah angka ini. Jika job anda mengambil masa sebenar untuk berjalan, driver jarang menjadi had.
Bolehkah saya guna Redis untuk cache dan queues pada masa yang sama?
Boleh, tetapi guna connection atau database Redis yang berasingan, dan tetapkan eviction policy yang tidak boleh menyentuh queue keys, yang secara praktikal bermakna satu instance berasingan untuk queue atau noeviction untuk kedua-duanya.
Adakah tukar kepada Redis akan menjadikan job saya lebih laju?
Ia menjadikan proses mengambil dan menyelesaikan job lebih murah (sekitar 0.3 ms berbanding 1.9 ms per job dalam ujian saya), dan dengan block_for ia menghilangkan kelewatan polling. Ia tidak menjadikan kod job anda lebih laju.
Rujukan: Laravel: Queues, Laravel: Horizon, Laravel: Deployment dan DatabaseQueue source.
Asalnya diterbitkan di DEV Talk.
Sumber Rujukan:
- Artikel asal: Dev.to Laravel
- Diterjemah daripada artikel asal ke Bahasa Melayu oleh AI Editorial – haqis.com