คิวงาน Laravel บน shared host — อย่าเอาคิวไว้ Redis ตัวเดียวกับ cache
คิวที่หายเงียบเจ็บกว่าคิวที่ล้มเหลว เพราะของที่ล้มเหลวยังมีแถวให้ลองใหม่ ส่วนของที่ถูก evict ทิ้งไม่เหลือร่องรอยอะไรเลย — และนี่คือสิ่งที่จะเกิดขึ้นถ้าเอา job ไปฝากไว้บน Redis ก้อนที่ตั้งไว้ทำ cache
พอดีถึงคิวต้องเปิดใช้ queue ให้ Laravel ตัวหนึ่งที่รันอยู่บน shared hosting งานที่จะเข้าคิวก็ไม่หนักอะไร — เมลยืนยันอีเมล ประมวลผลสลิปหลังลูกค้าอัปโหลด ประมาณวันละไม่กี่สิบงาน เครื่องนั้นมี Redis แยกให้ทีละ user อยู่แล้ว (ผมเคยเล่าโครงสร้างนั้นไว้ในบทความ Redis ต่อ user ไปแล้ว) แอปก็ใช้ Redis ตัวนั้นทำ cache กับ session อยู่ ข้อเสนอแรกที่โผล่มาบนโต๊ะเลยเป็นทางที่ดูสะอาดที่สุด คือย้าย queue มาอยู่ Redis ด้วยกันให้ครบ stack แล้วแยกกันด้วย REDIS_DB คนละเบอร์
ผมปฏิเสธข้อเสนอนั้นครับ และบทความนี้คือเหตุผลทั้งหมดว่าทำไม รวมถึงของที่เอาไปใช้ต่อได้เลย — unit ของ systemd ที่ใช้คุม worker แทน Supervisor และรายการที่ต้องทำตอน deploy
ชื่อผู้ใช้ พาธ และชื่อฐานข้อมูลในบทความเป็นค่าตัวอย่าง โครงและ config เป็นของจริง
แยก REDIS_DB ไม่ได้แยกหน่วยความจำ
จุดที่คนเข้าใจผิดกันเยอะที่สุดคือคิดว่า REDIS_DB=0/1/2 มันแยกกันแบบ database จริง ๆ ไม่ใช่ครับ — มันเป็นแค่ namespace ของ key เท่านั้น สิ่งที่ไม่แยกตาม DB คือของสำคัญทั้งหมด
maxmemory และ maxmemory-policy เป็นค่าระดับ instance ทั้งคู่ ไม่มีคำสั่งไหนตั้งเพดานแยกรายเบอร์ DB ได้เลย พิสูจน์ได้ในสองบรรทัด
redis-cli -s /home/appuser/.redis/redis.sock CONFIG GET maxmemory maxmemory-policy
# 1) "maxmemory"
# 2) "125829120"
# 3) "maxmemory-policy"
# 4) "allkeys-lru"
redis-cli -s /home/appuser/.redis/redis.sock INFO keyspace
# db0:keys=812,expires=812,avg_ttl=0
# db1:keys=5140,expires=5140,avg_ttl=0
แปลว่าถ้า cache ใน DB 1 โตขึ้นจนชนเพดานของ instance เมื่อไหร่ ตัวที่โดน evict ไม่ได้ถูกเลือกจาก DB 1 อย่างเดียว — มันเลือกจากทั้งก้อน job ที่รออยู่ใน DB 2 มีสิทธิ์โดนหยิบทิ้งเท่า ๆ กับ key cache ธรรมดา
allkeys-lru กับ noeviction อยู่ในตัวเดียวกันไม่ได้
สองงานนี้ต้องการนโยบายที่ตรงข้ามกันโดยธรรมชาติ
ทิ้งของเก่าได้เสมอ
cache หายไม่ใช่เรื่องใหญ่ อย่างมากก็ query ใหม่ ช้าลงนิดหน่อย สิ่งที่แย่กว่าคือ Redis เต็มแล้วเขียนไม่ได้ ดังนั้นทิ้งของที่ไม่ค่อยใช้ทิ้งไปเถอะ
ห้ามทิ้งเด็ดขาด
job หายหนึ่งใบเท่ากับงานหายจริงหนึ่งงาน ลูกค้าไม่ได้เมลยืนยัน สลิปไม่ถูกตรวจ และไม่มีใครรู้ว่าเกิดขึ้น เต็มแล้วให้ปฏิเสธการเขียนดีกว่าเงียบ ๆ ทำหาย
เลือกได้ค่าเดียวต่อหนึ่ง instance ครับ ไม่มีทางประนีประนอม ถ้าตั้ง noeviction เพื่อรักษา job สิ่งที่จะเกิดคือพอ cache โตจนเต็ม แอปทั้งตัวจะเริ่มได้ OOM command not allowed when used memory > 'maxmemory' ทุกครั้งที่เขียน cache หรือ session — เว็บล่มทั้งเว็บ ถ้าตั้ง allkeys-lru เพื่อให้ cache ทำงานปกติ ก็แปลว่ายอมให้ job หายได้
ไม่มี exception ไม่มีบรรทัดใน laravel.log ไม่มีแถวใน failed_jobs — เพราะการถูก evict ไม่ใช่ความล้มเหลวในสายตาระบบคิว มันคือ key ที่หายไปเฉย ๆ worker ไม่เคยเห็น ตัว retry ไม่ทำงาน ผลคือลูกค้าโวยว่าไม่ได้เมลยืนยัน แล้วเราไล่ log ทั้งวันโดยไม่เจออะไรเลย
ตารางเทียบความเสี่ยง ก่อนตัดสินใจ
ผมชั่งสองทางด้วยเกณฑ์เดียวคือ "ถ้าพลาดแล้วเจ็บแค่ไหน" ไม่ใช่ "อันไหนดูโมเดิร์นกว่า"
เร็ว แต่หายเงียบได้
หยิบงานได้ทันที ไม่มี query · แต่ job หายได้เมื่อ memory ตึง แบบไม่มีร่องรอย · ต้องเฝ้า memory ตลอด · ยิ่งลูกค้าเยอะ cache ยิ่งโต โอกาสหายยิ่งสูง
ช้ากว่านิด แต่ไม่หาย
งานอยู่ในตาราง มี transaction ล้อมไว้ · หน่วงไม่เกินหนึ่งวินาทีต่อรอบ poll · ต้นทุน query แทบเป็นศูนย์ที่ปริมาณระดับนี้ · ตรวจของค้างได้ด้วย SQL ธรรมดา
ที่ปริมาณระดับวันละไม่กี่สิบงาน ข้อได้เปรียบด้านความเร็วของ Redis แทบไม่มีความหมายเลยครับ ต่างกันหลักมิลลิวินาทีในงานที่ผู้ใช้ไม่ได้นั่งรอดูอยู่แล้ว แต่ข้อเสียเรื่องงานหายมีความหมายเต็ม ๆ ส่วนตัวผมเลยสรุปว่าที่สเกลนี้ ใช้ database driver เท่านั้น ใครโหลดหนักกว่านี้มากค่อยว่ากันอีกที
QUEUE_CONNECTION=database
# cache/session ยังอยู่ Redis ตามเดิม — สองตัวนี้มี TTL อยู่แล้ว โดน evict ก็ไม่เจ็บ
CACHE_STORE=redis
SESSION_DRIVER=redis
REDIS_CLIENT=phpredis
REDIS_HOST=/home/appuser/.redis/redis.sock
REDIS_PORT=0
ทำไม database driver ถึงไม่ตายเวลามี worker หลายตัว
ข้อกังวลคลาสสิกของ database queue คือ worker หลายตัวแย่งงานใบเดียวกันแล้วชนกัน สมัยก่อนเป็นปัญหาจริงเพราะต้องล็อกแถวแล้วรอกันเป็นทอด ๆ แต่ตั้งแต่ MariaDB 10.6 เป็นต้นมามี FOR UPDATE SKIP LOCKED แล้ว ซึ่ง Laravel ใช้ให้อัตโนมัติ
-- สาระสำคัญของสิ่งที่ Laravel ยิงตอน worker หยิบงาน
SELECT * FROM jobs
WHERE queue = 'default'
AND (reserved_at IS NULL AND available_at <= 1786000000)
ORDER BY id ASC
LIMIT 1
FOR UPDATE SKIP LOCKED;
SKIP LOCKED แปลว่า "แถวไหนมีคนจองอยู่ ข้ามไปเลย อย่ารอ" — worker ตัวที่สองจะเดินข้ามงานที่ตัวแรกจองแล้วไปหยิบใบถัดไปทันที ไม่มีการรอ ไม่มี deadlock ก่อนจะเปิดใช้ตรวจเวอร์ชันให้ชัวร์ก่อน
mysql -e 'SELECT VERSION();'
# 10.6.25-MariaDB
ถ้าเครื่องยังเป็น 10.3 หรือ 10.4 อย่าเพิ่งรัน worker หลายตัวพร้อมกันครับ รันตัวเดียวไปก่อน ปลอดภัยกว่า
worker ด้วย systemd แทน Supervisor
เอกสาร Laravel แนะนำ Supervisor แต่บนเครื่องที่มี systemd อยู่แล้ว ผมไม่เห็นเหตุผลจะลง process manager เพิ่มอีกตัวให้ต้องดูแลอีกชั้น หน้าที่ที่ Supervisor ทำ (รันตอนบูต, ตายแล้วปลุกใหม่, เก็บ log) systemd ทำได้ครบและอ่าน log ผ่าน journal ได้เลย
# /etc/systemd/system/app-worker.service
[Unit]
Description=Laravel queue worker (app.example.com)
After=network.target mysqld.service
Requires=mysqld.service
[Service]
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/home/appuser/app-backend
# ใช้พาธเต็มของ php เสมอ — PATH ของ service ไม่เหมือน shell ของ user
ExecStart=/usr/local/bin/php /home/appuser/app-backend/artisan queue:work database \
--queue=default --sleep=3 --tries=3 --backoff=30 --max-time=3600
Restart=always
RestartSec=5
StandardOutput=append:/home/appuser/app-backend/storage/logs/worker.log
StandardError=inherit
# กันงานหลุดมือกลางคัน: ให้เวลา job ปัจจุบันทำจนจบก่อนค่อยดับ
KillSignal=SIGTERM
TimeoutStopSec=60
[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now app-worker
systemctl status app-worker --no-pager
journalctl -u app-worker -n 50 --no-pager
ค่าที่ผมคิดนานที่สุดคือ --max-time=3600 กับ TimeoutStopSec=60 ครับ ตัวแรกสั่งให้ worker ตายเองทุกชั่วโมงแล้วให้ systemd ปลุกใหม่ ได้ผลสองอย่างคือ memory leak ที่สะสมใน PHP ถูกล้างทิ้งเป็นรอบ ๆ และโค้ดใหม่หลัง deploy ถูกโหลดเข้ามาแน่นอนภายในหนึ่งชั่วโมงถึงจะลืมสั่งอะไรก็ตาม ส่วนตัวที่สองเผื่อเวลาให้งานที่ทำค้างอยู่จบก่อน ถ้าไม่ตั้ง systemd จะฆ่าเร็วเกินแล้ว job ค้างสถานะ reserved
# ถ้าอยากรันหลายตัว ใช้ template unit แทน แล้ว enable ทีละเบอร์
# /etc/systemd/system/[email protected] (ExecStart เหมือนเดิม)
# systemctl enable --now app-worker@1 app-worker@2
cron ตัวเดียวสำหรับ scheduler
งานตามเวลา (schedule:run) เป็นคนละเรื่องกับ queue แต่มักถูกลืมพร้อมกัน ใส่ cron ของ user ไว้บรรทัดเดียวจบ
* * * * * cd /home/appuser/app-backend && /usr/local/bin/php artisan schedule:run >> /dev/null 2>&1
บน DirectAdmin ที่มี PHP หลายเวอร์ชัน ค่า PATH ของ cron กับของ shell ตอน login ไม่เหมือนกัน เขียน `php artisan` เฉย ๆ มีสิทธิ์ไปโดน php คนละเวอร์ชันกับที่เว็บใช้ แล้วจะได้ error ประหลาดเรื่อง extension ที่หาไม่เจอ ระบุ `/usr/local/bin/php` ให้ชัดทั้งใน cron และใน unit เสมอ
deploy แล้วต้อง queue:restart ทุกครั้ง
ข้อนี้พลาดกันบ่อยที่สุดครับ queue:work เป็น process ที่อยู่ยาว มันโหลดโค้ดเข้า memory ตั้งแต่ตอนสตาร์ท แล้ววนหยิบงานไปเรื่อย ๆ โดยไม่โหลดไฟล์ใหม่อีกเลย แปลว่าหลัง deploy โค้ดใหม่ขึ้นเว็บแล้ว แต่ฝั่ง worker ยังรันโค้ดเก่าอยู่ใน RAM อาการที่ได้คือหน้าเว็บทำงานถูก แต่งานเบื้องหลังยังใช้ตรรกะเวอร์ชันก่อน หาสาเหตุยากมากเพราะไฟล์บนดิสก์ถูกต้องแล้วทุกอย่าง
#!/bin/bash
# scripts/deploy.sh (ลำดับสำคัญ — queue:restart ต้องอยู่หลัง cache ทุกตัว)
set -e
cd /home/appuser/app-backend
composer install --no-dev --optimize-autoloader
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan queue:restart # <- บอก worker ให้จบงานปัจจุบันแล้วดับตัวเอง
chmod 600 .env
queue:restart ไม่ได้ฆ่า process ทันทีนะครับ มันแค่วางสัญญาณไว้ใน cache ให้ worker เห็นแล้วออกอย่างสุภาพหลังทำงานใบปัจจุบันจบ — แล้ว systemd ที่ตั้ง Restart=always ไว้ก็ปลุกตัวใหม่ที่โหลดโค้ดใหม่ขึ้นมาแทน สองอย่างนี้ต้องมาคู่กัน ถ้าไม่มี Restart=always คำสั่งนี้จะกลายเป็นการปิด worker ทิ้งเฉย ๆ
`queue:restart` เขียนค่า timestamp ลง cache ดังนั้นถ้า cache ของ worker กับของเว็บชี้คนละที่ (คนละ user คนละ socket) สัญญาณจะไม่ถึงกัน worker จะรันโค้ดเก่าต่อไปเงียบ ๆ ตรวจง่าย ๆ ด้วยการดูว่า worker ถูกสตาร์ทใหม่จริงไหม: `systemctl show app-worker -p ActiveEnterTimestamp`
ตรวจว่าทั้งเส้นเดินจริง
ก่อนปิดงานผมจะยิงงานทดสอบเข้าไปหนึ่งใบแล้วดูให้ครบสามจุด — เข้าตาราง, ถูกหยิบ, ตารางว่าง
php artisan tinker --execute="\App\Jobs\PingJob::dispatch();"
mysql appuser_shop -e 'SELECT id, queue, attempts, created_at FROM jobs;'
sleep 5
mysql appuser_shop -e 'SELECT COUNT(*) AS pending FROM jobs; SELECT COUNT(*) AS failed FROM failed_jobs;'
journalctl -u app-worker -n 20 --no-pager
ถ้าอยากทดสอบเส้น retry ด้วย ให้เขียน job ที่โยน exception ทิ้งไว้ชั่วคราวหนึ่งตัว ยิงเข้าไปแล้วดูว่ามันวนครบ --tries=3 แล้วลงไปนอนใน failed_jobs จริงหรือเปล่า เพราะเส้นนี้แหละที่จะได้ใช้ตอนของจริงพัง
แล้วเมื่อไหร่ถึงจะย้ายกลับไป Redis
ผมไม่ได้บอกว่า Redis queue ไม่ดีนะครับ มันดีมากในที่ที่เหมาะสม เกณฑ์ที่ผมตั้งไว้สำหรับตัวเองคือสองข้อ — ปริมาณเกินระดับพันงานต่อวันจนต้นทุน poll เริ่มมีน้ำหนัก หรือต้องกระจาย worker ข้ามหลายเครื่อง เมื่อไหร่ที่ถึงจุดนั้น ทางที่ถูกคือขอRedis อีก instance หนึ่งต่างหากที่ตั้ง maxmemory-policy noeviction แยก socket แยกไฟล์ config ไม่ใช่กลับไปแชร์ก้อนเดิมโดยหวังว่า DB คนละเบอร์จะช่วย เพราะมันไม่ช่วย
# หน้าตาของ instance ที่จะเอาไว้ทำคิวโดยเฉพาะ (ตัวอย่าง)
port 0
unixsocket /home/appuser/.redis/redis-queue.sock
unixsocketperm 700
maxmemory 64mb
maxmemory-policy noeviction
appendonly yes
ใครจะแยก instance เพิ่มก็ต้องคุยกับคนดูแลเครื่องก่อนนะครับ เพราะมันหมายถึงของอีกชิ้นที่เขาต้องดูแลต่อทุก user ไม่ใช่แค่ของเรา ส่วนใครที่ยังอยู่ระดับวันละไม่กี่สิบงานแบบเดียวกับผม ตารางในฐานข้อมูลกับ worker ตัวเดียวก็จบแล้ว เอาเวลาที่ประหยัดได้ไปทำอย่างอื่นดีกว่า ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย