งานแบบกระจาย: pull + lease + reaper กัน worker crash และ reconciliation loop กัน orphan
ทำไมการ "push" งานให้ worker ถึงเสียงานเมื่อ worker ตาย และวิธีพลิกให้ worker ตายกลายเป็น non-event ด้วย pull + lease + reaper พร้อม reconciliation loop ที่กัน orphan จาก state ในหน่วยความจำที่ผูก external resource
อ่าน ~8 นาที
ปัญหาเริ่มจากคำถามเดียว: worker ตายกลางงานแล้วงานหายไปไหน
ผมเคยดูแลระบบประมวลผลงานเบื้องหลังของแพลตฟอร์ม SaaS ไทยแห่งหนึ่ง ที่ตอนแรกออกแบบแบบ "push" ตรงไปตรงมา คือมี dispatcher คอยแจกงานให้ worker: พองานเข้ามา ก็เลือก worker สักตัวแล้วยิงงานไปให้ ฟังดูเรียบง่ายดี จนกระทั่งวันที่ worker ตัวหนึ่งถูก OOM kill กลางคัน แล้วงานที่มันถืออยู่ก็หายไปเงียบๆ ไม่มีใครรู้ว่ามันหาย ไม่มีใครทำงานนั้นต่อ ลูกค้ามารู้ทีหลังว่า "ทำไมคำสั่งของผมค้าง"
บทเรียนแรกที่ผมได้คือ: ในสถาปัตยกรรมแบบ push ตัวงานผูกชะตากับ worker ที่ถือมัน worker ตาย = งานตาย มันเป็น coupling ที่มองไม่เห็นจนกว่าจะเจ็บ บทความนี้จะเล่าวิธีพลิกโมเดลให้ worker ตายกลายเป็นเรื่องปกติ (non-event) และปัญหาที่ตามมาอีกชั้นเมื่อ state ของงานผูกกับ external resource
Thesis: พลิกจาก push เป็น pull + lease + reaper
แทนที่จะ push งานให้ worker ให้กลับด้าน: worker เป็นฝ่าย pull งานมาทำเอง เมื่อว่าง แล้วตอนหยิบงานให้ "จอง" (lease) งานนั้นไว้ชั่วคราว พร้อมกำหนดวันหมดอายุ ถ้า worker ตายกลางคัน มันจะหยุดต่อ lease งานก็หมดอายุเอง แล้วมีตัวกวาด (reaper) คอยคืนงานที่ lease หมดอายุกลับเข้าคิว
ผลของการพลิกโมเดลนี้:
- Worker failure = lease expiry = auto-requeue — ไม่ต้องมี logic พิเศษจับ worker ตาย ระบบ self-heal โดยธรรมชาติ
- Scale เป็น additive — เพิ่ม worker ก็แค่รันอีก instance ที่ pull จากคิวเดียวกัน ไม่ต้องแตะ central config ไม่ต้องบอก dispatcher ว่ามีใครเพิ่ม
- ไม่มี single point ที่ตัดสินใจแจกงาน — dispatcher ที่ฉลาดเกินไปคือจุดตายจุดเดียว pull model ทำให้ตรงนี้หายไป
Atomic claim: กัน 2 worker หยิบงานเดียวกัน
หัวใจของ pull model คือการหยิบงานต้อง atomic ไม่งั้น worker สองตัว pull พร้อมกันจะได้งานเดียวกันไปทำซ้ำ วิธีที่ผมใช้คือ compare-and-swap ระดับ document ผ่านคำสั่งที่ flip สถานะในคำสั่งเดียว (ตัวอย่างเป็น MongoDB แต่หลักการเดียวกันกับ UPDATE ... WHERE status = 'queued' ที่เช็ค affected rows หรือ Redis Lua script)
// worker pull งาน 1 ชิ้นแบบ atomic
const now = Date.now();
const leaseMs = 30_000;
const job = await Jobs.findOneAndUpdate(
{ status: 'queued' }, // เงื่อนไข: ต้องยังว่างอยู่
{
$set: {
status: 'leased',
leasedBy: workerId,
leaseExpiresAt: now + leaseMs,
},
$inc: { attempts: 1 }, // นับทุกครั้งที่หยิบ (สำคัญมาก ดูหัวข้อ poison job)
},
{ sort: { priority: -1, createdAt: 1 }, returnDocument: 'after' }
);
if (!job) return; // ไม่มีงานว่าง worker ก็นอนรอ poll รอบถัดไป
จุดที่ทำให้ปลอดภัยคือ filter status: 'queued' อยู่ใน operation เดียวกับการ set เป็น leased ถ้า worker B มาช้ากว่าเสี้ยววินาที เงื่อนไขจะไม่ match แล้วได้ null — ไม่มีทางที่สองตัวจะ claim สำเร็จพร้อมกัน database เป็นคน serialize ให้
Heartbeat ต่อ lease และ reaper คืนงาน
งานยาวกว่า lease ต้องต่ออายุระหว่างทำ ไม่งั้น reaper จะเข้าใจผิดว่า worker ตายทั้งที่ยังทำอยู่ ผมให้ worker heartbeat ต่อ lease เป็นระยะ (เช่นทุกครึ่งของ lease duration)
// worker ต่อ lease ระหว่างทำงานยาว
setInterval(async () => {
await Jobs.updateOne(
{ _id: job._id, leasedBy: workerId, status: 'leased' },
{ $set: { leaseExpiresAt: Date.now() + leaseMs } }
);
}, leaseMs / 2);
// reaper: รันเป็น cron/loop คืนงานที่ lease หมดอายุ
async function reap() {
await Jobs.updateMany(
{ status: 'leased', leaseExpiresAt: { $lt: Date.now() } },
{ $set: { status: 'queued', leasedBy: null } }
);
}
ข้อควรระวัง: heartbeat ต้องเช็ค leasedBy: workerId ด้วย ถ้า reaper คืนงานไปแล้วและ worker อื่นหยิบไป worker เดิมที่ค้างต่อ lease ต้องต่อไม่สำเร็จ จะได้รู้ตัวว่า "งานนี้ไม่ใช่ของฉันแล้ว" แล้วทิ้งผลลัพธ์ของตัวเองไป กันการเขียนทับ
Poison job: นับ lease-expiry เป็น attempt
นี่คือ pitfall ที่ผมเจ็บมาแล้ว ตอนแรกผมนับ attempts เฉพาะตอนงาน "fail แบบชัดเจน" (โยน exception) แต่ลืมนับตอน lease หมดอายุ ผลคือมีงานหนึ่งที่ทำให้ทุก worker crash (memory leak จาก payload ผิดปกติ) พอ worker ตาย งานก็ requeue พอ worker ใหม่หยิบไปก็ตายอีก วนไม่จบ กิน worker ทั้ง fleet ทีละตัว — นี่คือ poison job หรือ poison pill
บทเรียน: lease-expiry ต้องนับเป็น attempt เท่ากับ explicit fail เพราะจากมุมของระบบ "งานที่ทำให้ worker หายไปเงียบๆ" อันตรายกว่างานที่ fail อย่างสุภาพ นั่นคือเหตุผลที่ในโค้ด claim ด้านบนผม $inc: { attempts: 1 } ตั้งแต่ตอนหยิบ ไม่ใช่ตอนจบ
// reaper เวอร์ชันที่จัดการ poison job
async function reap() {
const expired = await Jobs.find({
status: 'leased',
leaseExpiresAt: { $lt: Date.now() },
}).toArray();
for (const job of expired) {
if (job.attempts >= MAX_ATTEMPTS) {
// เกิน budget → ยกออกจากคิว ไป dead-letter ให้คนดู
await Jobs.updateOne({ _id: job._id },
{ $set: { status: 'dead', deadReason: 'max-attempts-exceeded' } });
} else {
await Jobs.updateOne({ _id: job._id },
{ $set: { status: 'queued', leasedBy: null } });
}
}
}
แยก "งานล้มเหลว" ออกจาก "infra ล้มเหลว"
อีกบทเรียนที่ลึกกว่านั้น: ไม่ใช่ทุก failure เหมือนกัน งานที่ fail เพราะ เนื้องานผิด (payload ไม่ valid, business rule ไม่ผ่าน) กับงานที่ fail เพราะ infra สั่น (DB timeout, network blip, external API 503) ต้องนับคนละแกน ถ้ารวมเป็น counter เดียว คุณจะเผลอทิ้งงานดีๆ เพราะ network สะดุด 3 ครั้ง หรือ retry งานเสียๆ ไม่รู้จบ
- Infra failure → retry ได้ แต่ต้องมี
circuit breakerถ้า external service ล่ม การถล่ม retry คือการเติมเชื้อไฟ ควรถอยแล้วรอ - Work failure (เนื้องานผิด) → retry ไปก็เท่านั้น ควร escalate ให้คน หรือส่งเข้า review queue ไม่ใช่วน retry
ผมแยกเป็นคนละ counter, คนละ state, คนละ budget อย่างชัดเจน และที่ช่วยชีวิตมากคือแนวคิด phase checkpoint: งานยาวที่มีหลาย phase ให้ commit จุดที่ผ่านแล้วเป็น checkpoint (เช่นเก็บ SHA ของ output ที่ accepted) ถ้า run ทั้งก้อน crash รอบถัดไปเริ่มจาก checkpoint ล่าสุด ไม่ต้องทำใหม่ตั้งแต่ต้น checkpoint ที่ accepted แล้วรอดข้ามการ crash
ปัญหาชั้นถัดไป: orphan จาก state ใน memory ที่ผูก external resource
pull + lease + reaper แก้เรื่องงานหายได้ แต่มีปัญหาอีกแบบที่มันแก้ไม่ได้ตรงๆ คือเมื่อ worker ถือ state ในหน่วยความจำที่ผูกกับ external resource เช่น session ที่จอง connection ไว้กับระบบภายนอก, ไฟล์ชั่วคราวบนดิสก์, หรือ resource ที่ต้องถูกปิด/คืนอย่างชัดเจน
พอ worker crash ตรงนี้ database บอกว่างานหมด lease แล้ว requeue ได้ แต่ external resource ยังค้างเปิดอยู่ ไม่มีใครปิดมัน กลายเป็น orphan และถ้ามี race ระหว่าง reaper คืนงานกับ worker ที่กำลังจะสร้าง resource ใหม่ คุณอาจได้ orphan ซ้อน orphan
ทางแก้คือ reconciliation loop เชิงรุกหลายชั้น — อย่ารอให้ state ในหน่วยความจำถูกต้องเอง ให้มีตัวเทียบ "ความจริงที่ควรเป็น" กับ "ความจริงที่เป็นอยู่" แล้วแก้ให้ตรงเป็นระยะ ผมวางไว้ 3 ชั้น:
- Cleanup ตอน boot — worker เพิ่งขึ้นมา ให้กวาด resource ที่เป็นของ instance ตัวเองแต่ค้างจากรอบก่อนที่ตายไป
- Sweep ก่อนสร้าง resource ใหม่ — ก่อนจะ originate resource รอบใหม่ ตรวจว่ามีตัวเก่าค้างอยู่ไหม ถ้ามีให้ปิดก่อน กัน race ที่ leak
- Janitor แบบ TTL — loop เบื้องหลังที่กวาด resource ที่แก่เกิน TTL โดยไม่มี owner อ้างสิทธิ์ เป็น safety net ชั้นสุดท้าย
เงื่อนไขเหล็กของทุกชั้นคือ idempotent — เรียกซ้ำกี่รอบผลต้องเท่าเดิม เพราะ reconciliation จะรันซ้อนกันแน่นอน "ปิด resource ที่ปิดไปแล้ว" ต้องไม่พัง "คืนงานที่คืนไปแล้ว" ต้องไม่ทำงานซ้ำ
Dual-store write: saga แบบมือทำเมื่อไม่มี distributed transaction
ปัญหาสุดท้ายที่เกี่ยวกัน: บ่อยครั้งงานหนึ่งต้องเขียนสอง store ที่ไม่มี distributed transaction ร่วมกัน (เช่น DB + external API, หรือ DB + object storage) ถ้าเขียนตัวแรกสำเร็จแล้วตัวที่สอง fail คุณจะได้ state ที่ไม่ consistent
เมื่อไม่มี txn จริง ผมทำ saga แบบ manual ด้วยหลัก 2 ข้อ:
- จัดลำดับให้ตัวที่ rollback ยากอยู่ก่อน — เขียนตัวที่ย้อนยากที่สุด (เช่น external side-effect) ก่อน ถ้ามันสำเร็จค่อยเขียนตัวที่ย้อนง่าย ถ้าเขียนตัวหลัง fail เรายังชดเชยตัวแรกได้ ตรงข้ามกับการทำตัวย้อนยากทีหลังซึ่งจะติดกับ
- มี compensating action ทุกจุดที่ fail ได้ — ทุก step ที่มีสิทธิ์ fail ต้องมีคู่ของมันที่ย้อน step ก่อนหน้า และ compensating action ต้อง idempotent เหมือนกัน เพราะมันอาจถูกเรียกจาก retry หรือจาก reconciliation
สรุป: หลักการที่เอาไปใช้ได้จริง
- อย่า push งานให้ worker ให้ worker pull เอง + lease + reaper — worker ตายจะกลายเป็น non-event และ scale เป็น additive ไม่ต้องแตะ central config
- การหยิบงานต้อง atomic — flip
queued → leasedในคำสั่งเดียวที่มีเงื่อนไข กัน 2 worker ได้งานเดียวกัน - Heartbeat ต่อ lease โดยเช็ค ownership — งานยาวต้องต่ออายุ และต้องรู้ตัวเมื่อ lease ถูกยึดไปแล้ว
- นับ lease-expiry เป็น attempt เท่ากับ explicit fail — ไม่งั้น poison job จะวนกิน worker ทั้ง fleet เกิน budget ให้ส่งเข้า dead-letter
- แยก "งานล้มเหลว" ออกจาก "infra ล้มเหลว" — คนละ counter/budget infra retry มี circuit breaker, work failure escalate ให้คน; phase checkpoint ช่วยให้งานยาวรอด crash
- State ใน memory ที่ผูก external resource ต้องมี reconciliation loop เชิงรุก — cleanup ตอน boot, sweep ก่อนสร้างใหม่, janitor TTL และทุกชั้นต้อง idempotent
- Dual-store write ที่ไม่มี distributed txn = saga มือทำ — จัดลำดับให้ตัว rollback ยากอยู่ก่อน + มี compensating action ที่ idempotent ทุกจุด




