บทเรียน 142 · Rails · Day 4 Part 1

Background jobs — งานที่ผู้ใช้ไม่ควรรอ และทำไม "ทำพอดีครั้งเดียว" ไม่มีจริง

~20 นาทีอ่าน + ทดลองตามอีก ~10 นาที · ควรผ่าน บท 141 (จบบล็อก D) · Day 2 บท 112 (callback) · และ Go บท 21 · บท 26 มาก่อน

ตอนนี้ถ้าการกด "บันทึก" ต้องส่งอีเมล ย่อรูป และอื่น ๆ ไปด้วย ผู้ใช้จะยืนรอหน้าขาวจนครบทุกอย่าง · บทนี้จะทำให้หน้าค้าง 9 วินาทีให้เห็นกับตา แล้วแก้ให้เหลือราว 0.1 วินาทีด้วย background job
อ่านจบแล้วคุณจะ ตัดสินได้ว่างานไหนควรให้ผู้ใช้รอ งานไหนไม่ควร · สร้าง job และสั่ง perform_later ได้ · อธิบายได้ว่าทำไมงานในคิว หายได้หรือซ้ำได้ แต่รับประกัน "พอดีครั้งเดียว" ไม่ได้ — ประโยคข้อ ② ใน "3 ประโยคของวัน" ที่บท 136 บอกให้จำ

🔁 ทวนก่อนเริ่ม — บทนี้ใช้คำพวกนี้

🧰 เวอร์ชันที่ใช้ตรวจ: Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 18 (สไลด์และ repo ของอาจารย์เขียน Ruby 4.0.5–4.0.6 · Rails 8.1.3 — เรื่องในบทนี้ไม่ต่างกัน)
📍 โค้ดเริ่มจาก: ตอนจบบท 141 (rich text + รูปปก + prompts/04-content.md) ที่ commit แล้ว — git status --short ต้องว่าง · บทนี้ทดลองแล้วคืนทิ้งตอนจบ (ภารกิจท้ายบทพาเก็บกวาด) · ของจริงที่อยู่ถาวรเริ่มในบท 143
🖥 เปิดค้างไว้: docker compose up -d (ฐานข้อมูล + MinIO) · bin/dev หนึ่งหน้าต่าง — บทนี้อ่าน log จากหน้าต่างนี้ตลอด · อีกหน้าต่างไว้พิมพ์คำสั่ง · เบราว์เซอร์ที่ localhost:3000 · ยังไม่ต้องเปิดโปรแกรมทำ job แยก (เริ่มต้องเปิดในบท 143)

ผลรันทุกกล่องมาจากเครื่องจริง (สร้างบทความผ่านฟอร์มใน Chrome) · log ตัดบรรทัดที่ไม่เกี่ยว (SQL · บรรทัด ↳ · การโหลดหน้า) เป็น … และตัดคอมเมนต์ /*action=…*/ ท้าย SQL ออก · บรรทัด css.1 | Done in … (ตัวสร้าง CSS ของ Tailwind) จะแทรกขึ้นมาเป็นระยะในหน้าต่างของคุณ — ไม่เกี่ยวกับบทนี้ ตัดออกหมด · เวลา · เลข ms · Job ID · เลข id ของบทความ ของคุณจะต่าง

เปิดบล็อก E — งานเบื้องหลัง และประโยคข้อ ② ของวัน

บล็อก D (บท 136–141) ทำให้บทความมีเนื้อหาจริง · บล็อก E มีเป้าหมายเดียว: ตั้งเวลาเผยแพร่บทความ แล้วให้ระบบเผยแพร่เองพร้อมส่งอีเมลหาผู้ติดตาม · ก่อนจะทำแบบนั้นได้ ต้องรู้จัก "งานที่ทำทีหลัง โดยไม่มีใครยืนรอ" ก่อน — นั่นคือบทนี้

บทเรื่อง
142 (บทนี้)ทำไมต้องมีงานเบื้องหลัง · job ตัวแรก · ทำไม "ทำพอดีครั้งเดียว" ไม่มีจริง — ยังไม่มีโค้ดถาวร
143ออกแบบ job ให้รันซ้ำแล้วไม่พัง · คิวที่เก็บในฐานข้อมูล (Solid Queue)
144ลองใหม่เมื่อพัง · งานตามเวลา · อีเมลถึงผู้ติดตาม
145Lab — ระบบตั้งเวลาเผยแพร่ที่รันซ้ำแล้วไม่พัง

ประโยคข้อ ② ของวัน (สไลด์หน้า 32): "exactly-once ไม่มีอยู่จริง ออกแบบให้ทำซ้ำได้แทน" · ภาษาคน: ไม่มีระบบไหนรับประกันได้ว่างานจะถูกทำ พอดีหนึ่งครั้ง · ครึ่งหลังของบทนี้จะพิสูจน์ว่าทำไม

ลองทำให้หน้าค้างเอง — งาน 9 วินาทีใน request เดียว

สไลด์หน้า 16 ยกตัวอย่าง action create ที่บันทึกบทความแล้วทำต่ออีกสามอย่าง: ส่งอีเมลแจ้งผู้ติดตาม (3 วิ) · ส่งเข้าระบบค้นหา (2 วิ) · ย่อรูปปก (4 วิ) · แอปเรายังไม่มีทั้งสามอย่าง เลยจำลองด้วย sleep (สั่งให้โปรแกรมหยุดรอ N วินาที) — ความช้าเท่ากัน แค่ไม่ได้ทำงานจริง

ขั้น 1 · เปิด app/controllers/posts_controller.rb หา action create แล้วเติมสามบรรทัด sleep ใต้ if @post.save:

  def create
    @post = Post.new(post_params)

    respond_to do |format|
      if @post.save
        sleep 3 # จำลอง: ส่งอีเมลแจ้งผู้ติดตาม
        sleep 2 # จำลอง: ส่งเข้าระบบค้นหา
        sleep 4 # จำลอง: ย่อรูปปก
        format.html { redirect_to @post, notice: "สร้างบทความแล้ว" }
        # … บรรทัดที่เหลือเหมือนเดิม

ขั้น 2 · บันทึกไฟล์ (bin/dev โหลดโค้ดใหม่ให้เอง) · เปิด localhost:3000/posts/new กรอกหัวข้อ ทดลองงานช้า พิมพ์เนื้อหาอะไรก็ได้ เลือกหมวด (ไม่ต้องใส่รูป) แล้วกด บันทึก

หน้าจอจะนิ่งไปนานจนน่าสงสัยว่าเว็บพัง · ในเครื่องที่ตรวจ Chrome ใช้เวลา 9,181 ms นับจากกดปุ่มถึงเห็นหน้าบทความ · log ในหน้าต่าง bin/dev (10:20:50 web.1 | หน้าบรรทัด = เวลา + มาจากตัวเว็บ):

10:20:50 web.1  | Started POST "/posts" for ::1 at 2026-11-10 10:20:50 +0700
10:20:50 web.1  | Processing by PostsController#create as TURBO_STREAM
10:20:50 web.1  |   Parameters: {"authenticity_token" => "[FILTERED]", "post" => {"title" => "ทดลองงานช้า", "body" => "<div>ทดลองงานหลังร้าน</div>", "category_id" => "1"}, "commit" => "บันทึก"}
10:20:50 web.1  |   TRANSACTION (1.7ms)  BEGIN
10:20:50 web.1  |   …
10:20:50 web.1  |   Post Create (1.6ms)  INSERT INTO "posts" ("title", "description", "category_id", "created_at", "updated_at", "plain_text") VALUES ('ทดลองงานช้า', …) RETURNING "id"
10:20:50 web.1  |   …
10:20:50 web.1  |   TRANSACTION (0.9ms)  COMMIT
10:20:59 web.1  | Redirected to http://localhost:3000/posts/31
10:20:59 web.1  | ↳ app/controllers/posts_controller.rb:35:in 'block (2 levels) in PostsController#create'
10:20:59 web.1  | Completed 302 Found in 9049ms (ActiveRecord: 9.4ms (5 queries, 0 cached) | GC: 0.0ms)

นี่คือประโยคใต้โค้ดของสไลด์หน้า 16: ผู้ใช้หน้าค้าง 9 วินาที ทั้งที่งานของเขาเสร็จไปตั้งแต่วินาทีแรก · และถ้าระบบอีเมลล่มตอนนั้น ผู้ใช้จะได้หน้า error ทั้งที่บทความบันทึกไปแล้ว — คิดว่าไม่สำเร็จ แล้วกดบันทึกซ้ำ ได้บทความสองชิ้น

ยังไม่ต้องลบ sleep · หัวข้อถัดไปจะย้ายมันไปอยู่ที่ใหม่ · ถ้าคุณกดบันทึกซ้ำเพราะคิดว่าเว็บค้าง จะได้บทความ "ทดลองงานช้า" สองชิ้น — ปล่อยไว้ ภารกิจท้ายบทพาลบ

เส้นแบ่ง — "ถ้างานนี้ยังไม่เสร็จ ผู้ใช้เห็นหน้าถัดไปได้ไหม"

สไลด์หน้า 16 ให้คำถามข้อเดียวไว้ตัดสินทุกงานที่อยู่ใน action:

งานยังไม่เสร็จ ผู้ใช้เห็นหน้าถัดไปได้ไหมไปไหน
บันทึกบทความลงตู้ไม่ได้ — หน้าถัดไปคือหน้าบทความ ยังไม่มีบทความก็ไม่มีอะไรให้ดูใน request
ตรวจว่าหัวข้อไม่ว่าง (validation)ไม่ได้ — ต้องรู้ก่อนว่าจะโชว์หน้าบทความหรือโชว์ errorใน request
ส่งอีเมลแจ้งผู้ติดตามได้ — ช้าไป 5 วินาทีก็ไม่มีใครรู้เบื้องหลัง
ส่งเข้าระบบค้นหาได้ — ค้นเจอช้าไปไม่กี่วินาทีไม่มีใครเดือดร้อนเบื้องหลัง
ย่อรูปปกหลายขนาดได้ — หน้าบทความโชว์รูปเดิมไปก่อนได้เบื้องหลัง

ทวนบท 112: ตอนเรียน callback เราได้กฎว่า "callback ควรดูแลแค่แฟ้มของตัวเอง" · และบอกว่าอีเมลแจ้งผู้ติดตาม "ฝากให้ job ส่งทีหลัง" แต่ยังไม่ได้เล่าว่า job คืออะไร (บท 112 เขียนว่า "เรื่องของ Day 3" — ของจริงมาถึงตอนนี้) · job คือปลายทางของคำว่า "ฝาก" นั่นเอง

สไลด์ให้เหตุผลข้อที่สองด้วย: งานที่อยู่ใน job พังแล้วลองใหม่ได้ · งานที่อยู่ใน request พังแล้วหายไปเลย · ประโยคนี้จริงเมื่อคิวเก็บงานไว้ถาวรและตั้งให้ลองใหม่ — สองอย่างที่บท 143–144 จะตั้ง · ในบทนี้คุณจะเห็นว่ายังไม่จริง

background job คืออะไร — ใบงานหลังร้าน

background job คือ "งานที่จดไว้ให้ทำทีหลัง" · controller ไม่ทำเอง แค่เขียนใบงานแขวนไว้ แล้วตอบผู้ใช้ทันที · จะมีโปรแกรมอีกตัวมาหยิบใบงานไปทำตอนว่าง โดยไม่มีผู้ใช้คนไหนยืนรอ

ที่แขวนใบงานเรียกว่า คิว (queue = แถวรอ ใครมาก่อนได้ทำก่อน) · โปรแกรมที่มาหยิบใบงานไปทำเรียกว่า worker (คนงาน)

เทียบกับร้านอาหาร

ลูกค้าสั่งข้าวผัด (request) · หัวหน้าเชฟผัดเสร็จ ยกจานให้ลูกค้า (response) — จบสำหรับลูกค้า · แต่ร้านยังมีงานที่ต้องทำต่อ: ล้างกระทะ · โทรสั่งวัตถุดิบ · ส่งข้าวกล่องไปบ้านลูกค้าอีกคน

เชฟไม่ทำเองตอนนั้น แค่เขียนใบงานหลังร้านแขวนไว้บนกระดาน (คิว) แล้วกลับไปรับออเดอร์ต่อ · พนักงานหลังร้าน (worker) เดินมาหยิบใบงานไปทำทีละใบ

ที่ไม่เหมือนกัน: ร้านจริงถ้าไม่มีพนักงานหลังร้าน ใบงานก็แขวนอยู่เฉย ๆ ไม่มีใครทำ — กับ Rails ก็เป็นแบบนั้นจริงในบท 143 (ต้องเปิด worker แยก) · แต่ในบทนี้ Rails ยังใช้ทางลัดที่ให้ "หัวหน้าเชฟพกผู้ช่วยติดตัวมาเอง" (หัวข้อหลังจากนี้) · และพนักงานในร้านจริงจำได้ว่าทำอะไรไปแล้ว ส่วน worker จำอะไรไม่ได้นอกจากที่เขียนบนใบงาน

เทียบกับ Go (บท 26 worker pool): channel jobs ที่กองงานไว้ = คิว · goroutine ที่วนรับงานจาก jobs = worker · ความต่างคือ worker pool ของ Go อยู่ในโปรแกรมเดียวกับคนส่งงาน · คิวของจริงที่ใช้ใน production (บท 143) อยู่นอกโปรแกรมเว็บ — ต่างกันตรงนี้แหละที่ทำให้งานไม่หาย

ศัพท์ที่เอาไปคุยต่อได้
background job
งานที่แยกออกจาก request ไปทำทีหลัง ผู้ใช้ไม่ต้องรอ · ถาม senior ได้ว่า "งานนี้ควรอยู่ใน request หรือทำเป็น background job ครับ"
queue (คิว)
ที่เก็บงานที่รอทำ เรียงตามลำดับ · คำที่ได้ยินบ่อย: "job ค้างในคิว" = ใบงานแขวนอยู่ ยังไม่มีใครหยิบ
worker
โปรแกรมที่หยิบงานจากคิวไปทำ · "worker ล่ม" = ไม่มีใครทำงาน ใบงานกองสูงขึ้นเรื่อย ๆ
enqueue
เอางานเข้าคิว (แขวนใบงาน) · พิมพ์บอก AI ได้ว่า "move the email sending out of the request and enqueue a background job instead"

job ตัวแรก — ย้าย 9 วินาทีออกไปหลังร้าน

ขั้น 1 · สร้างไฟล์ job ด้วยตัวสร้างไฟล์ของ Rails (รันในโฟลเดอร์โปรเจกต์):

$ bin/rails generate job slow_work
      invoke  test_unit
      create    test/jobs/slow_work_job_test.rb
      create  app/jobs/slow_work_job.rb

ได้สองไฟล์ · ไฟล์ test ปล่อยไว้ก่อน (เรื่อง test อยู่ใน Part 2 ของสไลด์) · ชื่อ slow_work กลายเป็นคลาส SlowWorkJob (Rails เติมคำว่า Job ท้ายชื่อให้เอง)

ขั้น 2 · เขียนงาน — เปิด app/jobs/slow_work_job.rb แก้ทั้งไฟล์เป็น:

class SlowWorkJob < ApplicationJob
  queue_as :default

  def perform(post_id)
    post = Post.find(post_id)
    sleep 3 # จำลอง: ส่งอีเมลแจ้งผู้ติดตาม
    sleep 2 # จำลอง: ส่งเข้าระบบค้นหา
    sleep 4 # จำลอง: ย่อรูปปก
    Rails.logger.info "งานหลังร้านของบทความ #{post.id} เสร็จแล้ว"
  end
end

ขั้น 3 · ให้ controller แขวนใบงานแทนทำเอง — กลับไปที่ create ลบ sleep สามบรรทัด ใส่บรรทัดเดียวแทน:

ก่อน — ทำเองใน request

      if @post.save
        sleep 3 # จำลอง: ส่งอีเมล…
        sleep 2 # จำลอง: ส่งเข้า…
        sleep 4 # จำลอง: ย่อรูปปก
        format.html { … }

หลัง — แขวนใบงาน

      if @post.save
        SlowWorkJob.perform_later(@post.id)
        format.html { … }

บันทึกไฟล์ทั้งสอง แล้วสร้างบทความใหม่หัวข้อ ทดลองใบงาน ผ่านฟอร์มเหมือนเดิม · ดู log ในหน้าต่าง bin/dev

คิดว่า ① บรรทัด Completed 302 Found in …ms จะได้ประมาณเท่าไร ② บรรทัด "งานหลังร้านของบทความ … เสร็จแล้ว" จะโผล่ก่อนหรือหลังบรรทัด Completed และห่างกันกี่วินาที?

ผลจริง (Chrome ใช้เวลา 108 ms นับจากกดปุ่มถึงเห็นหน้าบทความ · Job ID ตัดให้สั้นเป็น 16307e4e-…):

10:21:06 web.1  | Started POST "/posts" for ::1 at 2026-11-10 10:21:06 +0700
10:21:06 web.1  | Processing by PostsController#create as TURBO_STREAM
10:21:06 web.1  |   …
10:21:06 web.1  |   TRANSACTION (1.2ms)  COMMIT
10:21:06 web.1  | [ActiveJob] Enqueued SlowWorkJob (Job ID: 16307e4e-…) to Async(default) with arguments: 32
10:21:06 web.1  | [ActiveJob] ↳ app/controllers/posts_controller.rb:32:in 'block in PostsController#create'
10:21:06 web.1  | Redirected to http://localhost:3000/posts/32
10:21:06 web.1  | Completed 302 Found in 28ms (ActiveRecord: 6.3ms (5 queries, 0 cached) | GC: 0.6ms)
10:21:06 web.1  | [ActiveJob] [SlowWorkJob] [16307e4e-…] Performing SlowWorkJob (Job ID: 16307e4e-…) from Async(default) enqueued at 2026-11-10T03:21:06.636126000Z with arguments: 32
10:21:06 web.1  | Started GET "/posts/32" for ::1 at 2026-11-10 10:21:06 +0700
10:21:06 web.1  |   …   (หน้าบทความ 32 วาดเสร็จ)
10:21:06 web.1  | Completed 200 OK in 28ms (Views: 15.2ms | ActiveRecord: 4.5ms (6 queries, 0 cached) | GC: 11.9ms)
10:21:06 web.1  |   …
10:21:15 web.1  | [ActiveJob] [SlowWorkJob] [16307e4e-…] งานหลังร้านของบทความ 32 เสร็จแล้ว
10:21:15 web.1  | [ActiveJob] [SlowWorkJob] [16307e4e-…] Performed SlowWorkJob (Job ID: 16307e4e-…) from Async(default) in 9031.67ms
  • ① Completed 302 Found in 28ms — จาก 9,049 ms เหลือ 28 ms · ผู้ใช้ได้หน้าบทความทันที
  • Enqueued SlowWorkJob … with arguments: 32 — แขวนใบงานแล้ว (บทความเลข 32) · บรรทัด ↳ …posts_controller.rb:32 บอกว่าแขวนจากบรรทัด 32 ของ controller
  • Performing SlowWorkJob — มีคนหยิบใบงานไปทำแล้ว · ทำพร้อม ๆ กับที่เบราว์เซอร์ขอหน้าบทความ (Started GET "/posts/32")
  • ② "เสร็จแล้ว" โผล่ทีหลัง ตอน 10:21:15 — 9 วินาทีหลังผู้ใช้ได้หน้าไปแล้ว · Performed … in 9031.67ms = งานใช้เวลาเท่าเดิม แค่ไม่มีใครต้องยืนรอ
  • Async(default) — ชื่อกระดาน default จาก queue_as · ส่วนคำว่า Async คือหัวข้อถัดไป

งานไม่ได้เร็วขึ้นเลย — แค่ย้ายไปทำตอนที่ไม่มีใครรอ

ตอนนี้ใบงานแขวนอยู่ที่ไหน — Async คือคิวในหน่วยความจำ

เราไม่ได้ตั้งค่าคิวเลย แต่ job ก็ทำงาน · ถามแอปได้ว่าตอนนี้ใช้คิวแบบไหน (หน้าต่างที่สอง):

$ bin/rails runner 'p ActiveJob::Base.queue_adapter_name'
"async"

bin/rails runner '…' = รันโค้ด Ruby หนึ่งบรรทัดในแอปแล้วจบ (ไม่ต้องเปิด console) · p = พิมพ์ค่าออกมา · ActiveJob::Base = แม่แบบตัวบนสุดของ job ทุกตัว (:: อ่านว่า "ที่อยู่ใน") · ได้ "async" · adapter คือ "ตัวเสียบ" ที่บอกว่าใบงานไปเก็บที่ไหน · development ถ้าไม่ตั้งอะไร ได้ async · ส่วน config/environments/production.rb (ค่าตั้งของ production = เครื่องจริงที่ผู้ใช้ใช้) ของเรามีบรรทัดนี้อยู่แล้ว:

  # Replace the default in-process and non-durable queuing backend for Active Job.
  config.active_job.queue_adapter = :solid_queue

คอมเมนต์บรรทัดบนบอกนิสัยของ async ไว้สองคำ: in-process (อยู่ใน process เดียวกับเว็บ — ตัวโปรแกรมที่กำลังรันอยู่ตัวเดียวกัน) และ non-durable (ไม่ทนทาน — ไม่ได้เขียนเก็บลงที่ไหน) · เอกสาร Rails เขียนตรง ๆ ว่า ถ้า process ดับหรือเครื่องรีเซ็ต งานที่ค้างอยู่หายหมด (ดูลิงก์ท้ายบท)

เทียบกับร้านอาหาร

async เหมือนหัวหน้าเชฟจดใบงานลงกระดาษโน้ตในกระเป๋าเสื้อ แล้วพาผู้ช่วยติดตัวมาเองยืนทำอยู่ข้าง ๆ ในครัวเดียวกัน · สะดวก ไม่ต้องติดกระดาน ไม่ต้องจ้างพนักงานหลังร้าน

แต่ถ้าปิดครัว (หยุด server) เชฟเดินออก — โน้ตกับผู้ช่วยหายไปพร้อมกัน ใบงานที่ยังไม่เสร็จไม่มีใครรู้ว่าเคยมี · บท 143 จะเปลี่ยนเป็น Solid Queue = ติดกระดานไว้ในตู้แฟ้ม (ตารางใน PostgreSQL) ปิดครัวแล้วกระดานยังอยู่

ที่ไม่เหมือนกัน: ผู้ช่วยของ async ไม่ได้มีคนเดียว — เป็นกลุ่มคนงานหลายคนในโปรแกรมเดียวกัน (ทำหลายใบงานพร้อมกันได้) · และ "ปิดครัว" ในโลกจริงไม่ได้เกิดแค่ตอนคุณกด Ctrl+C — ทุกครั้งที่ deploy (เอาเวอร์ชันใหม่ขึ้นเครื่องจริง) server ถูกปิดแล้วเปิดใหม่ นั่นคือเหตุที่ production ห้ามใช้ async

เทียบกับ Go (บท 21): async ก็คือ go f() ดี ๆ นี่เอง · ถ้าใน handler ของ Gin เราเขียน (ตัดมาสองบรรทัด ไม่ใช่ไฟล์เต็ม):

go sendEmail(post.ID) // แยกไปส่งอีเมล ไม่รอ
c.JSON(201, post)     // ตอบผู้ใช้ทันที

ผู้ใช้ได้คำตอบเร็วเหมือนกัน · แต่กฎของบท 21 ยังอยู่: โปรแกรมจบ = goroutine ที่ค้างตายเงียบ ๆ · และถ้า sendEmail พัง ไม่มีใครลองใหม่ให้ · go f() แก้ปัญหา "ผู้ใช้ต้องรอ" ได้ แต่ไม่ได้แก้ "งานหาย" — สิ่งที่คิวจริงต้องมีคือที่เก็บใบงานที่อยู่นอกโปรแกรม

ศัพท์ที่เอาไปคุยต่อได้
perform_later
สั่งให้ job ไปเข้าคิว ทำทีหลัง แล้วกลับมาทันที · คู่กับ perform ที่เป็นตัวงานจริง · log ฝั่งคนสั่งขึ้น Enqueued · ฝั่งคนทำขึ้น Performing / Performed
queue adapter
ตัวเสียบที่กำหนดว่าคิวจริงอยู่ที่ไหน (ในหน่วยความจำ · ในฐานข้อมูล · ใน Redis) · โค้ด job ไม่ต้องแก้เมื่อเปลี่ยน adapter · ถาม senior ได้ว่า "production เราใช้ queue adapter ตัวไหนครับ"
async adapter (in-process)
adapter ค่าเริ่มต้นของ development · ทำ job ด้วยคนงานในโปรแกรมเว็บเอง · restart แล้วงานค้างหาย · พิมพ์บอก AI ได้ว่า "don't rely on the async adapter in production, jobs are lost on restart"
durable
"ทนทาน" = เก็บงานไว้ในที่ที่รอดจากการปิดเปิดโปรแกรม (เช่น ตารางในฐานข้อมูล) · คิวที่ durable คือสิ่งที่บท 143 ตั้ง

ถึงตรงนี้คิดว่าแก้ได้แล้ว: แค่เปลี่ยนไปใช้คิวที่ durable งานก็ไม่หาย · ไม่หาย — แต่ได้ปัญหาใหม่มาแทน · ครึ่งหลังของบทว่าด้วยเรื่องนี้

exactly-once ไม่มีจริง — เลือกได้แค่ "อาจหาย" หรือ "อาจซ้ำ"

ใบงานหนึ่งใบต้องผ่านสองฝั่ง: คิว (ที่แขวนใบงาน) กับ worker (คนทำ) · สองฝั่งนี้คุยกันผ่านเครือข่ายหรือฐานข้อมูล ซึ่งขาดได้ และ worker ตายได้ทุกเมื่อ · คำถามคือ: คิวจะขีดฆ่าใบงาน (ถือว่าจบ) ตอนไหน — ก่อน worker ทำ หรือหลัง worker ทำ · มีแค่สองทาง (สไลด์หน้า 17):

at-most-once
"ไม่เกิน 1 ครั้ง"
at-least-once
"อย่างน้อย 1 ครั้ง"
exactly-once
"พอดี 1 ครั้ง"
ลำดับขีดฆ่าใบงานออกจากคิวก่อน แล้วค่อยทำทำให้เสร็จก่อน แล้วค่อยบอกคิวว่าเสร็จสองฝั่งต้องรู้ตรงกันเป๊ะทุกครั้ง
worker ตายกลางทางใบงานถูกฆ่าไปแล้ว → งานหายคิวยังไม่ได้ยินว่าเสร็จ → แจกใหม่ → งานซ้ำทำไม่ได้ เมื่อสายขาดได้หรือ worker ตายได้
เหมาะกับงานที่หายได้ เช่น นับคลิกค่าเริ่มต้นของคิวเกือบทุกตัว (สไลด์อ้าง)ไม่มีอยู่จริง

แล้วที่คนชอบพูดว่า "ระบบเรา exactly-once" คืออะไร · สไลด์ตอบว่า at-least-once + งานที่ทำซ้ำแล้วผลเหมือนเดิม — คิวส่งซ้ำได้ แต่ทำซ้ำแล้วไม่มีอะไรเสีย ผลรวมเลยดูเหมือนทำครั้งเดียว

เทียบกับร้านอาหาร

ใบงาน "ส่งข้าวกล่องไปบ้านคุณสมชาย" แขวนบนกระดาน · พนักงานส่งของหยิบไป ส่งถึงมือแล้ว เดินกลับมาจะติ๊กกระดานว่าเสร็จ — แต่เป็นลมล้มไปก่อนติ๊ก · พนักงานคนใหม่มาเห็นใบงานยังไม่ติ๊ก ก็หยิบไปส่งอีกรอบ · คุณสมชายได้ข้าวสองกล่อง = at-least-once · ถ้าร้านเปลี่ยนกฎเป็น "ติ๊กก่อนออกจากร้าน" แล้วพนักงานเป็นลมกลางทาง — กระดานบอกว่าเสร็จ แต่คุณสมชายไม่ได้ข้าวเลย = at-most-once

ที่ไม่เหมือนกัน: ในร้านจริงคุณสมชายโทรมาบอกได้ว่า "ได้แล้ว อย่าส่งอีก" · ในระบบจริงไม่มีใครโทรมาบอก — อีเมลที่ส่งออกไปแล้วเรียกคืนไม่ได้ · และคนใหม่ไม่ได้รู้ว่าคนเก่าเป็นลม เขาแค่เห็นว่านานเกินกำหนดแล้วยังไม่ติ๊ก (คิวรอจนหมดเวลา)

ศัพท์ที่เอาไปคุยต่อได้
at-most-once
รับประกันว่าไม่ซ้ำ แต่อาจหาย · ใช้กับงานที่หายได้ไม่เป็นไร
at-least-once
รับประกันว่าไม่หาย แต่อาจซ้ำ · ถาม senior ได้ว่า "คิวเราเป็น at-least-once ใช่ไหมครับ job ต้อง idempotent ทุกตัวหรือเปล่า"
exactly-once (delivery)
"ส่งพอดีครั้งเดียว" — เมื่อเครือข่ายขาดได้ ทำไม่ได้จริง · ถ้าเห็นเครื่องมือไหนโฆษณาคำนี้ ให้ถามว่า "หมายถึง at-least-once บวก dedup (กรองของซ้ำ) หรือ idempotency ใช่ไหม"
ack (acknowledge)
สัญญาณที่ worker บอกคิวว่า "ทำเสร็จแล้ว ขีดฆ่าได้" = การติ๊กกระดาน · คำถามทั้งหัวข้อนี้คือ "ack ก่อนทำ หรือหลังทำ"

worker ตายตอนไหน งานถึงซ้ำ — ช่องว่างที่ปิดไม่ได้

ไล่ตามเวลาแบบสไลด์หน้า 18 · คิวแบบ at-least-once (ทำก่อน แล้วค่อยบอกคิว) · ใบงานคือ "ส่งอีเมลแจ้งผู้ติดตาม":

t1 คิวส่งใบงานให้ worker t2 worker ส่งอีเมลออกไปแล้ว ┐ t3 worker กำลังจะบอกคิวว่าเสร็จ ┘ ← ช่องว่าง: งานทำไปแล้ว แต่ยังไม่มีใครรู้ t4 worker ตาย (deploy · หน่วยความจำเต็ม · เครื่องดับ) t5 คิวรอจนหมดเวลา → คิดว่างานยังไม่เสร็จ t6 คิวส่งใบงานเดิมให้ worker ตัวใหม่ t7 ลูกค้าได้อีเมลฉบับที่สอง

สลับลำดับก็ไม่ช่วย: ถ้าบอกคิวว่าเสร็จก่อน (t3 ขึ้นมาก่อน t2) แล้ว worker ตายตรงกลาง — คิวคิดว่าเสร็จ แต่อีเมลไม่เคยออก · เราแค่ย้ายจากปัญหา "ซ้ำ" ไปเป็นปัญหา "หาย" · เลือกได้แค่ว่าจะเจ็บทางไหน

คำถามที่ถูก — "ถ้าซ้ำแล้ว จะไม่เป็นไรได้ยังไง"

งานส่วนใหญ่หายไม่ได้ (อีเมลยืนยัน · เผยแพร่บทความตามเวลา) → เลือก at-least-once ซึ่งแปลว่าต้องยอมรับว่างานจะถูกทำซ้ำ · คำถามจึงเปลี่ยนจาก "จะกันไม่ให้ซ้ำยังไง" (ทำไม่ได้) เป็น "ถ้าซ้ำแล้วจะไม่เป็นไรได้ยังไง"

ซ้ำแล้วพัง

"บวกยอดวิวของบทความอีก 100" · รันครั้งเดียว +100 · รันซ้ำ +200 — ผลเปลี่ยนทุกครั้งที่ทำ

ซ้ำได้ไม่เป็นไร

"ตั้งสถานะบทความเป็นเผยแพร่แล้ว" · รันครั้งเดียวหรือสิบครั้ง ผลเหมือนเดิม — เหมือนพลิกป้ายหน้าร้านเป็น "เปิด" ซ้ำ ป้ายก็ยังเป็น "เปิด"

ฝั่งขวาคือ idempotent — คำเดียวกับที่เจอใน Go บท 36 (PUT ยิงซ้ำได้ POST ยิงซ้ำแล้วได้ของเบิ้ล) และ find_or_create_by! ใน seeds · แต่ "ส่งอีเมล" ไม่มีทางทำให้เป็นแบบฝั่งขวาโดยธรรมชาติ — ส่งสองครั้งก็สองฉบับ · 3 ท่าที่ใช้จริงในการทำให้ job แบบนี้ซ้ำได้ คือเรื่องของบท 143

🎯 ภารกิจ — ปิดครัวกลางงาน แล้วดูว่าใบงานไปไหน (~10 นาที)

พิสูจน์ด้วยตาว่า async เป็น "งานที่หายได้" · ต้องทำหัวข้อ "job ตัวแรก" ครบแล้ว (controller มี perform_later · job มี sleep รวม 9 วินาที)

  1. สร้างบทความใหม่หัวข้อ ทดลองกด Ctrl+C ผ่านฟอร์ม
  2. ทันทีที่เห็นหน้าบทความ (ภายใน 5 วินาที) สลับไปหน้าต่าง bin/dev แล้วกด Ctrl+C · อ่านบรรทัดท้าย ๆ ให้ครบ
  3. เปิดใหม่ด้วย bin/dev · รอ 20 วินาที ไม่ต้องทำอะไร · ดูว่ามีบรรทัด [ActiveJob] ขึ้นไหม
  4. เปิด localhost:3000/posts ดูว่าบทความ ทดลองกด Ctrl+C อยู่ไหม
  5. เก็บกวาด (หน้าต่างที่สอง):
    $ git restore app/controllers/posts_controller.rb
    $ rm app/jobs/slow_work_job.rb test/jobs/slow_work_job_test.rb
    $ git status --short
    git status --short ต้องไม่แสดงอะไรเลย · แล้วลบบทความทดลองทุกชิ้น (หัวข้อขึ้นต้นด้วย "ทดลอง") ด้วยปุ่ม ลบบทความ ในหน้าบทความ

ก่อนกดเฉลย: ข้อ 2 จะเห็นบรรทัด "งานหลังร้าน… เสร็จแล้ว" ไหม · จะเห็นคำว่า Performed ไหม · ข้อ 3 เปิดใหม่แล้วงานจะกลับมาทำต่อไหม · ข้อ 4 บทความอยู่ไหม?

ข้อ 2 — ผลจริง (กด Ctrl+C หลังงานเริ่ม 2 วินาที · Job ID ตัดสั้น):

10:22:41 web.1  | [ActiveJob] Enqueued SlowWorkJob (Job ID: 7ffe7c1f-…) to Async(default) with arguments: 34
10:22:41 web.1  |   …
10:22:41 web.1  | Completed 302 Found in 34ms (ActiveRecord: 7.4ms (5 queries, 0 cached) | GC: 0.0ms)
10:22:41 web.1  | [ActiveJob] [SlowWorkJob] [7ffe7c1f-…] Performing SlowWorkJob (Job ID: 7ffe7c1f-…) from Async(default) enqueued at 2026-11-10T03:22:41.978400000Z with arguments: 34
10:22:41 web.1  |   …   (หน้าบทความ 34 วาดเสร็จ)
10:22:44 system | SIGINT received, starting shutdown
10:22:44 web.1  | - Gracefully stopping, waiting for requests to finish
10:22:44 web.1  | === puma shutdown: 2026-11-10 10:22:44 +0700 ===
10:22:44 web.1  | - Goodbye!
10:22:44 web.1  | Exiting
10:22:44 web.1  | [ActiveJob] [SlowWorkJob] [7ffe7c1f-…] Performed SlowWorkJob (Job ID: 7ffe7c1f-…) from Async(default) in 2158.22ms
10:22:44 system | sending SIGTERM to all processes
10:22:44 css.1  | exited with code 0
10:22:44 web.1  | exited with code 0
  • ไม่มีบรรทัด "งานหลังร้านของบทความ 34 เสร็จแล้ว" — งานโดนตัดกลางคัน
  • แต่มีคำว่า Performed … in 2158.22ms — ทำไปได้แค่ 2 วินาที (ยังอยู่ใน sleep 3 แรก) แล้ว Rails เขียนบรรทัดปิดงานให้ตอนโปรแกรมปิด · ลองแล้ว 3 ครั้ง ได้แบบนี้ทุกครั้ง · อย่าเชื่อคำว่า Performed อย่างเดียว ดูหลักฐานว่างานทำครบจริง
  • SIGINT received — SIGINT คือสัญญาณ "ขอให้หยุด" ที่ Ctrl+C ส่งไป · บรรทัดที่ขึ้นต้นด้วย system คือตัว bin/dev เอง (foreman ที่เปิดทุกโปรแกรมพร้อมกัน — บท 120)
  • sending SIGTERM to all processes — SIGTERM คือสัญญาณ "ปิดตัวได้แล้ว" อีกแบบหนึ่ง · bin/dev ส่งให้ทุกโปรแกรมที่มันเปิดไว้ · css.1 คือตัวคอยสร้างไฟล์ Tailwind ใหม่ ปิดไปพร้อมกัน
  • Gracefully stopping, waiting for requests to finish — Puma (ตัวรับ request) รอให้ request ที่ค้างเสร็จ · ไม่ได้รอ job

ข้อ 3 — ไม่กลับมา · ผลจริง: เปิดใหม่ถึงบรรทัด Use Ctrl-C to stop แล้วรอ 25 วินาที ไม่มีบรรทัด [ActiveJob] สักบรรทัด · ใบงานอยู่ในหน่วยความจำของโปรแกรมที่ปิดไปแล้ว ไม่มีที่ไหนจำไว้ว่าเคยมี

ข้อ 4 — บทความอยู่ · บันทึกเสร็จ (COMMIT) ก่อนแขวนใบงาน · ผลคือ บทความมี แต่งานหลังร้านของมันหายไปเงียบ ๆ — ถ้าเป็นอีเมลจริง ผู้ติดตามจะไม่ได้อีเมลและไม่มีใครรู้ · นี่คือ "งานหาย" แบบเดียวกับ go f() ที่ main จบก่อน · บท 143 แก้ด้วยคิวที่เก็บใบงานในฐานข้อมูล

ถ้าเจออาการนี้ แปลว่า…

  • เห็น "งานหลังร้านของบทความ … เสร็จแล้ว" ก่อนบรรทัด SIGINT received — กด Ctrl+C ช้ากว่า 9 วินาที งานเลยเสร็จทัน · ลองใหม่ให้ไวขึ้น
  • git status --short ยังเห็น ?? test/jobs/ — ยังลบไฟล์ test ไม่ครบ · ดูชื่อไฟล์ใน test/jobs/ แล้วลบให้หมด (ห้ามใช้ git restore . หรือ git clean แบบทั้งโฟลเดอร์ ถ้ายังมีงานอื่นที่ไม่ได้ commit)
  • กดบันทึกแล้วได้หน้า error · log มี NameError (uninitialized constant PostsController::SlowWorkJob) — ลบไฟล์ job ไปแล้วแต่ controller ยังเรียกอยู่ · ทำบรรทัดแรกของข้อ 5 (git restore) · ลองแล้ว: บทความถูกบันทึกไปแล้วก่อนพัง (พังตอนแขวนใบงาน หลัง COMMIT) ลบทิ้งด้วยปุ่มได้เลย

ทดสอบความเข้าใจ

ใน action create ของบทความ งานไหนควรย้ายไปเป็น background job?

ตัวเลือกที่ 3 ถูก — อีเมลยังไม่ออก ผู้ใช้ก็เห็นหน้าบทความได้ตามปกติ

ตัวเลือกที่ 1 ผิด — ไม่มีบทความในตู้ หน้าถัดไปก็ไม่มีอะไรให้ดู (และจริง ๆ ใช้แค่ไม่กี่ ms — log บอก ActiveRecord: 9.4ms) · ตัวเลือกที่ 2 ผิด — ต้องรู้ผลก่อนว่าจะโชว์หน้าบทความหรือโชว์ error · ตัวเลือกที่ 4 ผิด — redirect ต้องรู้ URL ตอนตอบ จะทำทีหลังไม่ได้

จุดที่ต้องจำ: ถามว่า "ถ้างานนี้ยังไม่เสร็จ ผู้ใช้เห็นหน้าถัดไปได้ไหม" · ได้ = ส่งเข้า job · ไม่ได้ = ทำใน request

development ใช้ adapter async · job กำลังทำได้ครึ่งทาง แล้วคุณกด Ctrl+C ที่ bin/dev แล้วเปิดใหม่ · เกิดอะไรขึ้น?

ตัวเลือกที่ 2 ถูก — ภารกิจพิสูจน์แล้ว: ไม่มีบรรทัด "เสร็จแล้ว" · เปิดใหม่ 25 วินาทีไม่มี [ActiveJob] · แต่บทความอยู่

ตัวเลือกที่ 1 ผิด — async เก็บใบงานในหน่วยความจำ ปิดโปรแกรมแล้วไม่มีที่ไหนจำ · ตัวเลือกที่ 3 ผิด — บทความ COMMIT ไปแล้วก่อนแขวนใบงาน สองเรื่องนี้ไม่ผูกกัน · ตัวเลือกที่ 4 ผิด — log บอก waiting for requests to finish คือรอ request ไม่ใช่ job (และ log ยังขึ้น Performed … in 2158.22ms ทั้งที่งานไม่ครบ)

จุดที่ต้องจำ: async = go f() ของ Rails · เร็ว ใช้ง่าย แต่โปรแกรมดับ = งานหาย → ห้ามใช้ใน production

คิวแบบ at-least-once · worker ส่งอีเมลสำเร็จ แล้วดับไปก่อนบอกคิวว่าเสร็จ · ผลคืออะไร?

ตัวเลือกที่ 4 ถูก — คือ t2 → t4 → t7 ในแผนภาพ · ช่องว่างระหว่าง "ทำแล้ว" กับ "บอกแล้ว" ปิดไม่ได้

ตัวเลือกที่ 1 ผิด — อีเมลออกไปแล้วตั้งแต่ t2 การยืนยันกับคิวไม่เกี่ยวกับอีเมล · ตัวเลือกที่ 2 ผิด — คิวรู้แค่ว่ายังไม่มีใครบอกว่าเสร็จ ไม่รู้ว่าข้างนอกเกิดอะไร · ตัวเลือกที่ 3 ผิด — อีเมลที่ส่งออกไปแล้วเรียกคืนไม่ได้ (ที่ไม่เหมือนกันของภาพพนักงานส่งของ)

จุดที่ต้องจำ: at-least-once = ไม่หายแต่อาจซ้ำ · คิวเกือบทุกตัวเลือกแบบนี้ → job ต้องทนการทำซ้ำได้

เพื่อนเสนอว่า "งั้นให้ worker บอกคิวว่าเสร็จก่อน แล้วค่อยส่งอีเมล จะได้ไม่ซ้ำ" · ข้อไหนอธิบายถูก?

ตัวเลือกที่ 1 ถูก — สลับลำดับ = เปลี่ยนจาก at-least-once เป็น at-most-once · ย้ายปัญหาจาก "ซ้ำ" ไปเป็น "หาย"

ตัวเลือกที่ 2 ผิด — ช่องว่างยังอยู่ แค่ย้ายไปอยู่อีกฝั่ง · ตัวเลือกที่ 3 ผิด — คิวได้ยินว่าเสร็จแล้วจึงไม่แจกซ้ำ ปัญหาคือหาย ไม่ใช่ซ้ำ · ตัวเลือกที่ 4 ผิด — ลำดับนี้เป็นเรื่องการออกแบบคิว ไม่ใช่กฎบังคับของ Rails · ประเด็นคือไม่ว่าลำดับไหน "พอดีครั้งเดียว" ก็ไม่มี

จุดที่ต้องจำ: เลือกได้แค่ว่าจะเจ็บทางไหน · งานที่หายไม่ได้ → ยอมซ้ำ แล้วออกแบบให้ "ซ้ำแล้วไม่เป็นไร" (บท 143)

ถามกลับได้เลย

ติดตรงไหนถามได้ทุกเรื่อง เช่น "ถ้า job ไปแก้บทความที่ผู้ใช้เพิ่งลบไปแล้วจะเกิดอะไร" · "แล้ว job ที่พังกลางทางไปไหน" (บท 144 ตอบเต็ม ๆ) · หรือวาง log ของคุณมาให้ดูด้วยกัน

ถ้าเปิดสไลด์ดูประกอบ

หน้าสไลด์เขียนของจริง (Rails 8.1.4 · แอปของคอร์ส)ต้องสนใจไหม
15"Solid Queue — ทำไม Rails 8 ใช้ตารางใน DB เป็นคิว"บทนี้ยังเป็น async (development ค่าเริ่มต้น) · production ของเราตั้ง :solid_queue ไว้แล้ว · ติดตั้งจริงบท 143ไม่ต้อง
16NotifyMailer.new_post(@post).deliver_now # 3 วิ · SearchIndex.push · variant(...).processedแอปเรายังไม่มี mailer และระบบค้นหา · ตัวเลข 3/2/4 วิ เป็นตัวอย่าง · บทนี้จำลองด้วย sleep และวัดได้ 9,049 ms จริงไม่ต้อง — อย่าพิมพ์ตาม (ได้ uninitialized constant)
16NotifyFollowersJob.perform_later(@post) (ส่งทั้งแฟ้ม)บทนี้ส่ง @post.id · เหตุผลอยู่ในบท 143ยังไม่ต้อง
16"งานที่อยู่ใน job พังแล้ว retry ได้"จริงเมื่อคิวเก็บงานถาวรและตั้งให้ลองใหม่ (บท 143–144) · ตอนนี้ (async) ปิดโปรแกรมแล้วงานหาย — ภารกิจพิสูจน์แล้วสนใจ
16"ย้อนกลับไปวันพฤหัส … เดี๋ยววันเสาร์จะเล่าว่า job คืออะไร"เว็บนี้คือ Day 2 บท 112 (บทนั้นเขียนว่า "Day 3") → ตอนนี้ Day 4ไม่ต้อง
17"ค่าเริ่มต้นของคิวเกือบทุกตัวในโลก" (at-least-once)บทนี้ไม่ได้ตรวจคิวทุกตัว · เขียนว่า "สไลด์อ้าง" · ส่วน Solid Queue ดูในบท 143–144ไม่ต้อง

อ้างอิง