บทเรียน 109 · Rails · Day 2

Migration ที่ไม่ทำร้านปิด — lock และ expand → migrate → contract

~25 นาที (ยาวกว่าบทอื่นนิดหน่อย เพราะมีของให้ลองสองหน้าต่าง) · ควรผ่าน บทเรียน 107 (console) และ บทเรียน 108 (migration พื้นฐาน) มาก่อน

ในบท 108 migration ของเรารันเสร็จใน 0.003 วินาที เพราะตารางมีแค่ 6 แถว และไม่มีใครใช้เว็บอยู่
บทนี้เล่าเรื่องของร้านที่เปิดอยู่จริง มีลูกค้าเต็มร้าน — migration หน้าตาธรรมดาบรรทัดเดียวอาจทำให้ทั้งเว็บค้างได้
อ่านจบแล้วคุณจะ: รู้ว่า lock กับ index คืออะไร (เห็นของจริงในเครื่องตัวเอง) · สร้าง index แบบไม่ทำให้ใครต้องรอ · และเปลี่ยนชื่อคอลัมน์ได้โดยไม่ต้องปิดเว็บ ด้วยท่า 3 เฟสที่ทีมจริงใช้กัน

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

🧰 ตรวจด้วย Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 17 (สไลด์ใช้ Ruby 4.0.6 · Rails 8.1.3 — รุ่นแก้บั๊กในสายเดียวกัน ใช้แทนกันได้)

📦 ตัวอย่างในบทนี้ใช้ตาราง users/posts/comments และข้อมูลตัวอย่าง (seeds) ชุดเดียวกับบท 107 · หัวข้อ index จะเติมบทความทดลอง 200,000 แผ่นให้ตารางใหญ่พอเห็นผล (วิธีลบทิ้งอยู่ในกล่อง "เก็บกวาดก่อนไปบทหน้า" ก่อนภารกิจ)

ผลทุกก้อนรันจริงแล้วก็อปมา · ย่อ path ยาวเป็น ~/wongnok_blog และตัดส่วนที่ไม่เกี่ยวเป็น … · เวลา (ms / วินาที) · เลข id · เลขเวลาหน้าชื่อไฟล์ migration ของคุณจะไม่ตรงกับในบท · ท้ายบรรทัด SQL ทุกบรรทัดจะมี /*application='WongnokBlog'*/ — เป็นแค่ป้ายบอกว่ามาจากแอปไหน ไม่ต้องสนใจ

ร้านที่ไม่เคยปิด — production, deploy และ downtime

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

เวลาเราเขียนโค้ดใหม่เสร็จ แล้วเอาขึ้น production เรียกว่า deploy · ขั้นตอนหลัก ๆ มีสามจังหวะ:

  1. เอาโค้ดใหม่ขึ้นเครื่อง server
  2. รัน bin/rails db:migrate กับ database ตัวจริง (ตู้จริงของร้าน)
  3. เริ่มแอปใหม่ (restart) ให้หันมาใช้โค้ดใหม่

ระหว่างนั้นลูกค้ายังเข้าเว็บอยู่ตลอด · ถ้ามีช่วงที่เว็บใช้ไม่ได้ เรียกว่า downtime (ช่วงร้านปิด) · บทนี้ทั้งบทคือ "ทำยังไงให้ downtime เป็นศูนย์"

สไลด์ยกตัวอย่างว่า: ตาราง posts มี 50 ล้านแถว · มีคน 3,000 คนออนไลน์ · migration ตัวหนึ่งรันนาน 40 วินาที — คำถามคือ 40 วินาทีนั้น 3,000 คนเจออะไร? ตอบได้ตอนจบหัวข้อ lock

🏪 เทียบให้เห็นภาพ — ต่อเติมตู้ระหว่างร้านเปิด

ในเครื่องเรา เหมือนเรียกช่างมาต่อเติมตู้ตอนร้านปิด ไม่มีใครเดือดร้อน · production คือร้านที่เปิด 24 ชั่วโมง ช่างต้องทำงานตอนที่พนักงานยังวิ่งหยิบแฟ้มจากตู้อยู่ตลอด
deploy = วันที่ช่างมาตามใบสั่ง แล้วพนักงานทั้งร้านเปลี่ยนไปใช้คู่มือฉบับใหม่ · downtime = ช่วงที่ร้านต้องแขวนป้าย "ปิดปรับปรุง"

ที่ไม่เหมือนกัน: ช่างในร้านจริงมองเห็นว่ามีพนักงานยืนรอ แล้วรีบทำ หรือหลบให้ก่อนได้ · แต่ migration มองไม่เห็นใครเลย มันทำงานของมันไปจนเสร็จ ส่วนคนที่รอก็ไม่ได้รับข้อความอะไร แค่เห็นหน้าเว็บหมุนค้าง

เทียบกับ Go: ปัญหานี้ไม่ใช่ของ Rails อย่างเดียว · ตอนทำ Go คุณ deploy ด้วยการรัน goose up แล้ว restart โปรแกรม (บท 31) — เจอเรื่องเดียวกันทุกข้อ เพราะต้นเหตุอยู่ที่ Postgres (ชื่อเล่นของ PostgreSQL = โปรแกรม database ตัวที่ทั้งแอป Go และแอป Rails ของเราใช้) ไม่ใช่ภาษา

🗣 ศัพท์ที่เพิ่งเรียนไป
production (prod)
ระบบจริงที่ผู้ใช้ใช้อยู่ · ตรงข้ามกับ development (เครื่องเรา) · ถาม senior ได้ว่า “ตาราง posts บน production มีกี่แถว migration นี้รันนานแค่ไหน”
deploy
การเอาโค้ดเวอร์ชันใหม่ขึ้น production · ใน Rails มักรวมการรัน migration ไว้ในขั้นตอนนี้ด้วย · ถามได้ว่า “ตอน deploy ทีมเรารัน db:migrate ก่อนหรือหลัง restart แอป”
downtime · zero-downtime deploy
downtime = ช่วงที่เว็บใช้ไม่ได้ · zero-downtime = deploy โดยไม่มีช่วงนั้นเลย · พิมพ์บอก AI ได้ว่า “write this migration for zero-downtime deploy on a large Postgres table”

index — ป้ายแท็บเรียงตัวอักษรในลิ้นชัก

ก่อนเข้าเรื่อง lock ต้องรู้จัก index ก่อน เพราะตัวอย่างของเราจะใช้มัน
ปกติเวลาสั่ง "หาบทความชื่อ X" ตู้ต้องหยิบแฟ้มทีละแผ่นจากแผ่นแรกจนแผ่นสุดท้ายแล้วดูว่าชื่อตรงไหม · index คือรายการเสริมที่ตู้ทำเก็บไว้ เรียงชื่อเป็นลำดับพร้อมบอกว่าแผ่นนั้นอยู่ตรงไหน — หาเจอเร็วโดยไม่ต้องไล่ทุกแผ่น
(บท 108 เรียกมันว่า "สารบัญ" ตอนเห็นบรรทัด t.index ใน schema.rb — เป็นของชิ้นเดียวกัน บทนี้ขอเรียกว่าป้ายแท็บ)

📇 เทียบให้เห็นภาพ — ป้ายแท็บ ก ข ค ในลิ้นชักแฟ้ม

ลิ้นชักที่ไม่มีป้ายแท็บ: จะหาแฟ้ม "ผัดกะเพรา" ต้องเปิดดูทีละแผ่น 200,000 แผ่น · ลิ้นชักที่ติดป้ายแท็บเรียงตัวอักษรไว้: เปิดไปที่แท็บ "ผ" แล้วหาต่อในกองเล็ก ๆ เจอในไม่กี่วินาที

ที่ไม่เหมือนกัน: ป้ายแท็บจริงติดครั้งเดียวจบ · แต่ index ใน database ต้องอัปเดตทุกครั้งที่มีแฟ้มใหม่เข้ามา — เก็บแฟ้มช้าลงนิดหนึ่ง และกินพื้นที่เพิ่ม · และ index ช่วยเฉพาะการหาด้วยช่องที่ติดป้ายไว้ (ติดป้ายตามชื่อเรื่อง ก็ไม่ช่วยหาตามยอดวิว)

เทียบกับ Go: ใน บท 31 คุณเขียน SQL CREATE INDEX … เองในไฟล์ goose · และใน บท 27 เห็นป้าย `gorm:"index"` ติดท้าย field ของ struct · ฝั่ง Rails เขียนเป็น Ruby บรรทัดเดียว add_index แล้ว Rails แปลเป็น CREATE INDEX ให้

เตรียมลิ้นชักใหญ่ 200,000 แผ่น

ตาราง posts ของเรามีแค่ 6 แถว เร็วอยู่แล้วไม่ว่าจะทำอะไร · ต้องเติมบทความทดลองให้เยอะก่อน — เปิด bin/rails console แล้ววางโค้ดนี้ทั้งก้อน:

ActiveRecord::Base.connection.execute(<<~SQL)
  INSERT INTO posts (title, body, user_id, created_at, updated_at)
  SELECT 'บทความทดลอง ' || n, 'เนื้อหาทดลอง', #{User.first.id}, now(), now()
  FROM generate_series(1, 200000) AS n
SQL

ผลจริง (จัดบรรทัดให้อ่านง่าย — จอคุณจะมีเลข prompt ซ้อนอยู่หน้าบรรทัดที่วางเข้าไป):

  User Load (1.8ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1 /*application='WongnokBlog'*/
   (632.8ms)  INSERT INTO posts (title, body, user_id, created_at, updated_at)
SELECT 'บทความทดลอง ' || n, 'เนื้อหาทดลอง', 1, now(), now()
FROM generate_series(1, 200000) AS n
 /*application='WongnokBlog'*/
=> #<PG::Result:0x0000000125e3c490 status=PGRES_COMMAND_OK ntuples=0 nfields=0 cmd_tuples=200000>
wongnok-blog(dev):006> Post.count
  Post Count (7.3ms)  SELECT COUNT(*) FROM "posts" /*application='WongnokBlog'*/
=> 200006

หาแบบไม่มีป้ายแท็บ

ลองหาบทความชื่อ "บทความทดลอง 150000" แล้วขอให้ตู้เล่าว่ามันหายังไง ด้วย explain (explain = "ถ้าจะหาแบบนี้ คุณจะเดินหายังไง" · ใส่ :analyze = "ลองหาจริงแล้วจับเวลามาด้วย" · เจออีกทีละเอียดบท 113):

Post.where(title: "บทความทดลอง 150000").explain(:analyze)
  Post Load (21.0ms)  SELECT "posts".* FROM "posts" WHERE "posts"."title" = 'บทความทดลอง 150000' …
=>
EXPLAIN (ANALYZE) SELECT "posts".* FROM "posts" WHERE "posts"."title" = 'บทความทดลอง 150000' …
                                                     QUERY PLAN
---------------------------------------------------------------------------------------------------------------------
 Gather  (cost=1000.00..5944.99 rows=1146 width=101) (actual time=8.830..9.824 rows=1 loops=1)
   Workers Planned: 2
   Workers Launched: 2
   ->  Parallel Seq Scan on posts  (cost=0.00..4830.39 rows=478 width=101) (actual time=6.264..6.674 rows=0 loops=3)
         Filter: ((title)::text = 'บทความทดลอง 150000'::text)
         Rows Removed by Filter: 66668
 Planning Time: 0.059 ms
 Execution Time: 9.842 ms
(8 rows)

ตัวเลขเยอะ แต่อ่านแค่ 3 จุดก็พอ (บรรทัดอื่น เช่น Post Load กับ => สองบรรทัดแรก — Rails ค้นจริงหนึ่งรอบก่อนเล่า · Gather · cost=… · Workers ข้ามได้เลย):

ติดป้ายแท็บด้วย migration

สร้างใบสั่งช่างใบใหม่ (generator ของบท 108):

bin/rails generate migration AddIndexToPostsTitle

Rails สร้างไฟล์ว่าง ๆ ให้ (เลขหน้าชื่อไฟล์ของคุณจะต่าง) · เปิดไฟล์แล้วเติมบรรทัด add_index:

# db/migrate/20261104115002_add_index_to_posts_title.rb
class AddIndexToPostsTitle < ActiveRecord::Migration[8.1]
  def change
    add_index :posts, :title   # ติดป้ายแท็บในลิ้นชัก posts เรียงตามช่อง title
  end
end

รัน bin/rails db:migrate (ผลจริง):

== 20261104115002 AddIndexToPostsTitle: migrating =============================
-- add_index(:posts, :title)
   -> 15.6751s
== 20261104115002 AddIndexToPostsTitle: migrated (15.6752s) ===================

15.7 วินาที — นานกว่าที่เคยเห็นในบท 108 หลายพันเท่า · เพราะตู้ต้องหยิบแฟ้มทั้ง 200,000 แผ่นมาเรียงชื่อ (เครื่องผมเรียงตัวอักษรไทยช้าเป็นพิเศษ · ลองทำ index ช่องตัวเลข views ใช้แค่ราว 0.03 วินาที · เครื่องคุณอาจเร็วหรือช้ากว่านี้มาก)
ใน db/schema.rb จะมีบรรทัดใหม่โผล่ในส่วนของ posts: t.index ["title"], name: "index_posts_on_title"

ถามตู้ด้วยคำสั่งเดิมอีกรอบ (ผลจริง):

                                                          QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------
 Index Scan using index_posts_on_title on posts  (cost=0.42..8.44 rows=1 width=114) (actual time=0.549..0.550 rows=1 loops=1)
   Index Cond: ((title)::text = 'บทความทดลอง 150000'::text)
 Planning Time: 0.071 ms
 Execution Time: 0.568 ms
(4 rows)
🗣 ศัพท์ที่เพิ่งเรียนไป
index
โครงสร้างเสริมที่ database ทำไว้ให้หาข้อมูลตามคอลัมน์หนึ่งได้เร็ว โดยไม่ต้องไล่ทุกแถว · Rails ตั้งชื่อให้เองแบบ index_ตาราง_on_คอลัมน์ · ถาม senior ได้ว่า “คอลัมน์นี้ถูกใช้ใน WHERE บ่อย ควรมี index ไหม”
Seq Scan · Index Scan
สองวิธีหาของที่เห็นใน explain · Seq Scan = ไล่ทีละแถว · Index Scan = ใช้ index · พิมพ์บอก AI ได้ว่า “my EXPLAIN shows Seq Scan on a 200k-row table, here is the output” แล้วแปะผล explain ไปทั้งก้อน

🧹 ยังไม่ต้องลบบทความทดลอง — หัวข้อ lock ข้างล่างใช้ลิ้นชักใหญ่นี้ต่อ · วิธีเก็บกวาดทุกอย่างที่บทนี้ทำให้ตู้กลับเป็นแบบบท 107 รวมไว้ที่เดียว ในกล่อง "เก็บกวาดก่อนไปบทหน้า" ก่อนภารกิจ

15 วินาทีนั้น ใครต้องรอ — lock

ระหว่างที่ช่างกำลังทำงานกับลิ้นชัก database ต้องกันไม่ให้คนอื่นมาทำอะไรที่ขัดกัน ไม่งั้นงานช่างกับงานพนักงานจะทับกันจนข้อมูลเพี้ยน · การกันแบบนี้เรียกว่า lock
lock เองไม่ผิด — ปัญหาคือ lock ที่นาน บนตารางที่คนใช้ตลอด

อีกเรื่องที่ทำให้ lock นาน: Rails ห่อ migration ทั้งไฟล์ไว้ใน transaction (transaction = มัดหลายคำสั่งเป็นงานชิ้นเดียว "ทำครบทั้งหมด หรือไม่ทำเลย" ถ้าพังกลางทางให้ย้อนคืนทั้งหมด · ละเอียดบท 111) — และ lock จะค้างไว้จนกว่างานทั้งไฟล์จะเสร็จ

ลองให้เห็นจริง: เปิดสองหน้าต่างเทอร์มินัล · หน้าต่างที่ 1 สั่ง bin/rails db:rollback (แกะป้ายแท็บที่เพิ่งติดออกก่อน) แล้วรัน migration add_index ตัวเดิมอีกรอบ · ระหว่างที่มันยังทำงาน 15 วินาที หน้าต่างที่ 2 ซึ่งเปิด bin/rails console ไว้ พิมพ์สองคำสั่งต่อกัน

# หน้าต่างที่ 1 — ช่างกำลังติดป้ายแท็บ (ใช้เวลา ~15 วินาที)
bin/rails db:migrate

# หน้าต่างที่ 2 — พิมพ์ระหว่างที่หน้าต่างที่ 1 ยังไม่เสร็จ
Post.count                                     # อ่าน: นับแฟ้ม
Post.create!(title: "เขียนระหว่างช่างทำงาน",      # เขียน: เก็บแฟ้มใหม่
             body: "ทดสอบ", user: User.first)

คำสั่งไหนตอบทันที คำสั่งไหนต้องรอ? หรือรอทั้งคู่? หรือมี error?

ผลจริงจากหน้าต่างที่ 2 (ตัดรายละเอียดของแฟ้มที่ได้ออก):

wongnok-blog(dev):001> Post.count
  Post Count (27.7ms)  SELECT COUNT(*) FROM "posts" …
=> 200006
wongnok-blog(dev):002> Post.create!(title: "เขียนระหว่างช่างทำงาน", body: "ทดสอบ", user: User.first)
  User Load (0.8ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT 1 …
  TRANSACTION (0.4ms)  BEGIN …
  Post Create (12034.5ms)  INSERT INTO "posts" ("title", "body", "created_at", "updated_at", "published", "views", "user_id") VALUES ('เขียนระหว่างช่างทำงาน', 'ทดสอบ', …) RETURNING "id" …
  TRANSACTION (0.4ms)  COMMIT …
=> #<Post:0x00000001219e0f30 id: 200007, title: "เขียนระหว่างช่างทำงาน", …>
  • อ่าน (Post.count) ตอบทันที — 27.7 ms เหมือนปกติ
  • เขียน (INSERT = ใส่แฟ้มใหม่ · RETURNING "id" = ขอเลข id ของแผ่นใหม่กลับมา) รอ 12,034.5 ms = 12 วินาที — ตรงกับเวลาที่เหลือของช่าง · ไม่มี error เลย แค่ค้างเงียบ ๆ จนช่างเสร็จ แล้วค่อยเก็บได้
  • บรรทัดอื่น: User Load … LIMIT 1 คือ User.first เหมือนตอนเติมข้อมูล · BEGIN / COMMIT = เปิดงาน / ปิดงานของ transaction

นี่คือคำตอบของสไลด์: add_index แบบปกติปิดลิ้นชักไม่ให้ใครเก็บแฟ้มเพิ่ม (ยังอ่านได้) · ถ้ามันใช้ 40 วินาทีกับลูกค้า 3,000 คน — ทุกคนที่กดโพสต์ กดคอมเมนต์ หรือกดอะไรที่ต้องเขียนลงตาราง posts ในช่วงนั้น จะเห็นหน้าเว็บหมุนค้าง

บางคำสั่งหนักกว่านั้น — ปิดทั้งอ่านและเขียน · เช่นเปลี่ยนชนิดข้อมูลของคอลัมน์ (change_column) ผมลองเปลี่ยน views จาก integer เป็น bigint (ตัวเลขที่เก็บค่าได้ใหญ่กว่า) แล้วยังไม่ปิดงานต่ออีก 8 วินาที (เหมือนไฟล์ migration ที่มีงานอื่นต่อท้าย) · ระหว่างนั้นอีกหน้าต่างสั่งแค่ Post.count — รอไป 19 วินาที ทั้งที่เป็นแค่การนับ (ผลจริง · ไม่ต้องลองตาม)

🔒 เทียบให้เห็นภาพ — ปิดลิ้นชักไว้ระหว่างช่างทำงาน

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

ที่ไม่เหมือนกัน: lock ปิดแค่ ลิ้นชักที่ช่างทำ (ตาราง posts) — ลิ้นชัก users กับ comments ยังใช้ได้ปกติ · และพนักงานในร้านจริงเห็นช่างแล้วไปทำงานอื่นก่อนได้ · คำสั่งใน database ไม่รู้ว่ากำลังรออะไร มันยืนรอเงียบ ๆ จนกว่าจะได้ หรือจนหมดเวลา

🗣 ศัพท์ที่เพิ่งเรียนไป
lock (table lock)
การที่ database กันไม่ให้คนอื่นทำบางอย่างกับตารางชั่วคราว · มีหลายระดับ เช่น "blocks writes" (ห้ามเขียน อ่านได้) กับ "blocks reads and writes" (ห้ามทั้งคู่) · ถาม senior ได้ว่า “migration นี้ lock ตารางระดับไหน นานเท่าไหร่”
DDL transaction
DDL = คำสั่ง SQL กลุ่มที่เปลี่ยนโครงตู้ ไม่ใช่ข้อมูลในแฟ้ม: CREATE (สร้าง) · ALTER (ดัดแปลง) · DROP (รื้อทิ้ง) — ตาราง คอลัมน์ หรือ index · Rails ห่อ migration ทั้งไฟล์ด้วย transaction เดียว พังกลางทางย้อนคืนหมด แต่ lock ค้างจนจบไฟล์ · พิมพ์บอก AI ได้ว่า “does this Rails migration run inside a DDL transaction, and how long will it hold the lock?”

ติดป้ายแท็บแบบไม่ปิดลิ้นชัก — algorithm: :concurrently

Postgres มีวิธีสร้าง index อีกแบบ ชื่อ CONCURRENTLY (แปลว่า "ไปพร้อม ๆ กัน") — ช่างทำงานช้ากว่าเดิมนิดหน่อย แต่ไม่ห้ามใครเก็บแฟ้ม · ใน Rails สั่งด้วย algorithm: :concurrently
ลอง rollback ตัวเดิมก่อน (bin/rails db:rollback) แล้วแก้ไฟล์ db/migrate/…_add_index_to_posts_title.rb เป็นแบบนี้:

class AddIndexToPostsTitle < ActiveRecord::Migration[8.1]
  def change
    add_index :posts, :title, algorithm: :concurrently
  end
end

รัน bin/rails db:migrate — พังทันที (ผลจริง ตัดส่วนท้าย):

== 20261104115002 AddIndexToPostsTitle: migrating =============================
-- add_index(:posts, :title, {algorithm: :concurrently})
bin/rails aborted!
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)

PG::ActiveSqlTransaction: ERROR:  CREATE INDEX CONCURRENTLY cannot run inside a transaction block
~/wongnok_blog/db/migrate/20261104115002_add_index_to_posts_title.rb:3:in 'AddIndexToPostsTitle#change'
…

ทางแก้คือบอก Rails ว่า "ไฟล์นี้ไม่ต้องห่อด้วย transaction" ด้วยบรรทัด disable_ddl_transaction!:

class AddIndexToPostsTitle < ActiveRecord::Migration[8.1]
  disable_ddl_transaction!   # ไฟล์นี้ไม่ต้องห่อด้วย transaction

  def change
    add_index :posts, :title, algorithm: :concurrently
  end
end

รันซ้ำพร้อมเปิดหน้าต่างที่ 2 ไว้เหมือนเดิม (ผลจริงทั้งสองหน้าต่าง):

# หน้าต่างที่ 1
== 20261104115002 AddIndexToPostsTitle: migrating =============================
-- add_index(:posts, :title, {algorithm: :concurrently})
   -> 15.6863s
== 20261104115002 AddIndexToPostsTitle: migrated (15.6863s) ===================

# หน้าต่างที่ 2 — สั่งระหว่างที่หน้าต่างที่ 1 ยังทำงานอยู่
  Post Create (1.0ms)  INSERT INTO "posts" ("title", "body", …) VALUES ('เขียนระหว่างช่างทำงาน 2', 'ทดสอบ', …) RETURNING "id" …

ช่างยังใช้ 15.7 วินาทีเท่าเดิม แต่การเก็บแฟ้มใหม่ใช้ 1.0 ms แทน 12,034 ms — ไม่มีใครต้องรอ

⚠️ ลืม disable_ddl_transaction! = migration พังทันที (ข้อดีคือมันพังดัง ๆ ไม่ได้พังเงียบ) · และไฟล์ที่ปิด transaction แล้ว พังกลางทางจะไม่ย้อนคืนให้ — ใส่งานแค่เรื่องเดียวต่อไฟล์

เทียบกับ Go: คุณเจอ error ประโยคเดียวกันเป๊ะมาแล้วใน บท 31 · ฝั่ง goose แก้ด้วยการใส่ -- +goose NO TRANSACTION บรรทัดแรกของไฟล์ · ฝั่ง Rails คือ disable_ddl_transaction! — คนละภาษา แต่คุยกับ Postgres ตัวเดียวกัน

คำถามเดียวที่ต้องถามทุกครั้ง: "แตะกี่แถว และระหว่างนั้นใครต้องรอ"

สไลด์สรุปหลักไว้ประโยคเดียว: ก่อนรัน migration บนตารางใหญ่ ให้ถามว่าคำสั่งนี้ต้องแตะแฟ้มกี่แผ่น และระหว่างนั้นใครใช้ลิ้นชักนี้ไม่ได้
ตารางนี้ย่อ 5 ท่าจากสไลด์หน้า 16 · ไม่ต้องจำ แค่ดูว่าแต่ละท่าตอบคำถามนี้ยังไง · เวลาในวงเล็บคือผลจริงบนเครื่องผม ตาราง posts 200,000 แถว (ตาราง 50 ล้านแถวใหญ่กว่า 250 เท่า)

อยากทำอะไรท่าตรง ๆแตะกี่แผ่น · ใครรอท่าที่ปลอดภัยกว่า
สร้าง indexadd_indexอ่านทุกแผ่นมาเรียง · ห้ามเขียนจนเสร็จ (~15.7 วินาที)algorithm: :concurrently + disable_ddl_transaction!
เปลี่ยนชนิดข้อมูลchange_column :posts, :views, :bigintเขียนใหม่ทุกแผ่น · ห้ามทั้งอ่านและเขียน (~0.16 วินาที · แต่ถ้ามีป้ายแท็บติดอยู่ ต้องทำป้ายใหม่ด้วย นานขึ้นเป็น ~16 วินาที)คอลัมน์ใหม่ + ย้ายข้อมูล + สลับ (หัวข้อถัดไป)
ตั้งกฎว่าช่องนี้ห้ามว่าง (NOT NULL · ละเอียดบท 111)change_column_null :posts, :title, falseอ่านทุกแผ่นเพื่อตรวจว่าไม่มีช่องว่าง · ปิดลิ้นชักระหว่างตรวจ (~0.01 วินาที)มีท่าเลี่ยงหลายขั้น บทนี้ไม่สอน (strong_migrations ข้างล่างบอกให้ได้)
เพิ่มคอลัมน์พร้อมค่าเริ่มต้นadd_column … default: "draft"Postgres รุ่นใหม่ไม่แตะแผ่นเดิม จดค่าเริ่มต้นไว้ที่เดียว (ไม่ถึง 0.05 วินาที)ท่านี้ปลอดภัยอยู่แล้ว
ลบคอลัมน์remove_column :users, :nameเร็วมาก (~0.001 วินาที) แต่แอปที่ยังรันโค้ดเก่าอยู่พังบอก Rails ให้ลืมคอลัมน์ก่อน (ignored_columns) แล้วค่อยลบ

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

เปลี่ยนชื่อคอลัมน์โดยไม่ปิดร้าน — expand → migrate → contract

โจทย์ (สไลด์หน้า 17): เปลี่ยนชื่อคอลัมน์ name ของ users เป็น full_name โดยไม่มี downtime เลยแม้แต่วินาทีเดียว
ท่าที่คิดออกก่อนคือ rename_column :users, :name, :full_name บรรทัดเดียว — เร็วด้วย · แต่ปัญหาไม่ได้อยู่ที่ตู้ อยู่ที่จังหวะ deploy

ย้อนไปดูขั้นตอน deploy: รัน migration (ข้อ 2) ก่อน restart แอป (ข้อ 3) · แปลว่ามีช่วงหนึ่งที่ตู้เป็นแบบใหม่แล้ว แต่แอปยังเป็นโค้ดเก่า · ถ้าสลับลำดับ ก็จะมีช่วงที่โค้ดใหม่วิ่งอยู่กับตู้แบบเก่าแทน — หนีช่วงนี้ไม่พ้น

เวลา ────────────────────────────────────────────────────────▶ ตู้ (DB) [ มีช่อง name ] │ช่างรัน rename│ [ มีแต่ full_name ] แอป [ โค้ดเก่า: อ่าน/เขียน name ...................... ]│restart│[ โค้ดใหม่ ] ▲ ช่วงอันตราย: โค้ดเก่าหาช่อง name ไม่เจอ → error

ประโยคหลักของสไลด์: ไม่มี deploy ไหนที่โค้ดกับตู้เปลี่ยนพร้อมกันได้ — ต้องออกแบบให้มีช่วงที่ของเก่ากับของใหม่อยู่ด้วยกันได้ · ท่ามาตรฐานคือแบ่งเป็น 3 เฟส แต่ละเฟสคือ deploy หนึ่งรอบ:

เฟส 1 · EXPAND เฟส 2 · MIGRATE เฟส 3 · CONTRACT "เพิ่มของใหม่" "ย้ายข้อมูล + สลับการอ่าน" "รื้อของเก่า" ┌─────────────────────┐ ┌──────────────────────────┐ ┌─────────────────────────┐ │ + คอลัมน์ full_name │ │ ก็อป name → full_name │ │ เลิกเขียน name │ │ โค้ดเขียนทั้ง 2 ช่อง │ ─▶ │ ทีละ 1,000 แถว │ ─▶ │ บอก Rails ให้ลืม name │ │ โค้ดยังอ่าน name │ │ โค้ดเปลี่ยนไปอ่าน full_name │ │ (ignored_columns) │ │ │ │ ยังเขียนทั้ง 2 ช่อง │ │ รอบถัดไป: ลบคอลัมน์ name │ └─────────────────────┘ └──────────────────────────┘ └─────────────────────────┘ deploy ครั้งที่ 1 deploy ครั้งที่ 2 deploy ครั้งที่ 3 (+4) ทุกช่วงระหว่างเฟส: โค้ดเก่าก็ทำงานได้ โค้ดใหม่ก็ทำงานได้ → ไม่มีใครพัง
🔨 เทียบให้เห็นภาพ — ปรับปรุงร้านโดยไม่ปิดร้าน: ทำของใหม่ก่อน → ย้าย → ค่อยรื้อของเก่า

แฟ้มผู้ใช้ทุกแผ่นมีช่อง "ชื่อ" · อยากเปลี่ยนเป็นช่อง "ชื่อเต็ม" · เฟส 1: ช่างพิมพ์ช่อง "ชื่อเต็ม" เพิ่มลงบนแฟ้มทุกแผ่น (ช่องเดิมยังอยู่) แล้วสั่งพนักงานว่า "ต่อจากนี้กรอกทั้งสองช่อง แต่เวลาอ่านให้อ่านช่องเดิมไปก่อน" · เฟส 2: ค่อย ๆ ลอกชื่อจากช่องเดิมไปกรอกช่องใหม่ให้แฟ้มเก่าทีละกอง แล้วสั่งว่า "อ่านช่องใหม่ได้แล้ว" · เฟส 3: ประกาศทั้งร้านว่า "ลืมช่องเดิมไปได้เลย" รอให้ทุกคนรับทราบ แล้วค่อยลบช่องเดิมออก

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

เทียบกับ Go: ทำแบบเดียวกันเป๊ะ แค่เครื่องมือต่าง · goose 3 ไฟล์ (เพิ่มคอลัมน์ · ย้ายข้อมูล · ลบคอลัมน์) และช่วงกลาง struct ของ GORM มีทั้ง field Name และ FullName คู่กัน — เพราะปัญหาอยู่ที่จังหวะ deploy ไม่ใช่ภาษา

เฟส 1 · expand — เพิ่มคอลัมน์ใหม่ เขียนทั้งสองช่อง

generator เดาให้เองจากชื่อ AddFullNameToUsers + full_name:string (บท 108):

bin/rails generate migration AddFullNameToUsers full_name:string
# db/migrate/20261104115236_add_full_name_to_users.rb (ไฟล์ที่ได้จริง ไม่ต้องแก้)
class AddFullNameToUsers < ActiveRecord::Migration[8.1]
  def change
    add_column :users, :full_name, :string
  end
end

รัน bin/rails db:migrate — ได้ -> 0.0032s เพราะเพิ่มคอลัมน์ว่าง ๆ ไม่แตะแฟ้มเดิม (ท่าปลอดภัยในตาราง)

จากนี้โค้ดทุกที่ที่บันทึกชื่อ ต้องส่งค่าเดียวกันลงทั้งสองช่อง · ตัวอย่างใน console (ก่อนหน้านี้ผมพิมพ์ mana = User.find_by(email: "mana@example.com") ไว้แล้ว — หยิบแฟ้มของมานะมาถือไว้ในตัวแปร mana แบบบท 107 · ผลจริง):

wongnok-blog(dev):003> mana.update(name: "มานะ ใจดี", full_name: "มานะ ใจดี")
  TRANSACTION (1.9ms)  BEGIN …
  User Update (3.9ms)  UPDATE "users" SET "name" = 'มานะ ใจดี', "updated_at" = '2026-11-04 11:52:46.481872', "full_name" = 'มานะ ใจดี' WHERE "users"."id" = 3 …
  TRANSACTION (1.6ms)  COMMIT …
=> true
wongnok-blog(dev):004> User.pluck(:name, :full_name)
  User Pluck (2.4ms)  SELECT "users"."name", "users"."full_name" FROM "users" …
=> [["สมชาย", nil], ["สมหญิง", nil], ["มานะ ใจดี", "มานะ ใจดี"]]

เฟส 2 · migrate — ย้ายข้อมูลเก่าทีละกอง แล้วสลับไปอ่านช่องใหม่

ต้องก็อป name ไปใส่ full_name ให้คนเก่าทุกคน · สมมุติว่าบน production ตาราง users มี 20 ล้านแถว — ถ้าสั่งทีเดียวทั้งตาราง จะกลายเป็นงานใหญ่ที่ lock นาน → แบ่งเป็นกองละ 1,000 แถว แต่ละกองเสร็จเร็วแล้วปล่อยลิ้นชัก (ตัวอย่างนี้สั่งใน console · งานจริงใส่ในงานแยก เช่น bin/rails runner — ดูกฎข้อ 2 ท้ายบท):

User.where(full_name: nil).in_batches(of: 1000) do |batch|
  batch.update_all("full_name = name")
end

ผลจริง (users มีแค่ 3 คน ได้กองเดียว):

  User Pluck (2.3ms)  SELECT "users"."id" FROM "users" WHERE "users"."full_name" IS NULL ORDER BY "users"."id" ASC LIMIT 1000 …
  User Update All (2.2ms)  UPDATE "users" SET full_name = name WHERE "users"."full_name" IS NULL AND "users"."id" IN (1, 2) …
=> nil
wongnok-blog(dev):008> User.pluck(:name, :full_name)
  User Pluck (2.5ms)  SELECT "users"."name", "users"."full_name" FROM "users" …
=> [["มานะ ใจดี", "มานะ ใจดี"], ["สมชาย", "สมชาย"], ["สมหญิง", "สมหญิง"]]

พอข้อมูลครบแล้ว ค่อยเปลี่ยนโค้ดให้อ่านจาก full_name (เช่น view เปลี่ยนจาก user.name เป็น user.full_name) แต่ยังเขียนทั้งสองช่อง — เผื่อมีแอปตัวเก่าที่ยังไม่ restart ยังอ่าน name อยู่ · แล้ว deploy ครั้งที่ 2

เฟส 3 · contract — บอก Rails ให้ลืมช่องเก่า แล้วค่อยรื้อ

เลิกเขียน name ทุกที่ และเพิ่มบรรทัดเดียวใน model:

# app/models/user.rb
class User < ApplicationRecord
  self.ignored_columns += ["name"]   # ทำเหมือนคอลัมน์ name ไม่มีอยู่

  has_many :posts      # สองบรรทัดนี้มีอยู่แล้ว (ผูก User กับ posts/comments · บท 115)
  has_many :comments
end

deploy ครั้งที่ 3 · พอแน่ใจว่าแอปทุกตัว restart แล้ว ค่อยรื้อจริงด้วย migration (deploy ครั้งถัดไป):

bin/rails generate migration RemoveNameFromUsers name:string
# db/migrate/20261104115259_remove_name_from_users.rb (ไฟล์ที่ได้จริง)
class RemoveNameFromUsers < ActiveRecord::Migration[8.1]
  def change
    remove_column :users, :name, :string
  end
end

:string ท้ายบรรทัดไม่จำเป็นต่อการลบ แต่ทำให้ตอน rollback Rails รู้ว่าต้องสร้างช่องคืนเป็นชนิดอะไร (จะเห็นผลในหัวข้อ IrreversibleMigration)

⚠️ ถ้าทำตามในเครื่อง: หลังหัวข้อนี้ตาราง users ของคุณจะไม่มีช่อง name แล้ว แต่บทถัดไปยังใช้ name อยู่ · ไม่ต้องรีบแก้ตอนนี้ — กล่อง "เก็บกวาดก่อนไปบทหน้า" ก่อนภารกิจคืนตู้ให้ครบทุกอย่างในทีเดียว

🗣 ศัพท์ที่เพิ่งเรียนไป
expand → migrate → contract (expand/contract pattern)
ท่าเปลี่ยน schema โดยไม่มี downtime: เพิ่มของใหม่ → ย้ายข้อมูลและสลับการอ่าน → รื้อของเก่า · คั่นด้วย deploy ทุกเฟส · พิมพ์บอก AI ได้ว่า “plan an expand/contract migration to rename users.name to full_name, list each deploy separately”
backfill · batch
backfill = เติมข้อมูลให้คอลัมน์ใหม่ย้อนหลังสำหรับแถวที่มีอยู่แล้ว · batch = ทำทีละกอง · ถาม senior ได้ว่า “backfill ตารางนี้ควรใช้ batch ขนาดเท่าไหร่ และรันจากที่ไหน”
dual write
ช่วงที่โค้ดเขียนลงทั้งคอลัมน์เก่าและใหม่พร้อมกัน (เฟส 1–2) · ทำให้แอปตัวเก่ากับตัวใหม่อยู่ด้วยกันได้ · ถามได้ว่า “ตอนนี้เราอยู่ช่วง dual write หรือยัง ปิดได้หรือยัง”

ทำไมต้อง ignored_columns — แอปที่เปิดอยู่จำแปลนตู้เก่า

Rails จำรายชื่อช่องของตารางไว้ตอนใช้ครั้งแรก แล้วใช้รายชื่อนั้นไปตลอดจนกว่าจะ restart (ช่องมาจากตารางตอนรัน · บท 106) · ทดลองให้เห็น: ใช้ console ที่เปิดค้างไว้แทน "แอปตัวเก่าที่ยังไม่ restart"

# สมมุติว่าข้ามขั้น ignored_columns ไป · เปิด console ทิ้งไว้ แล้วให้ Rails รู้จัก User ก่อน
User.first

# อีกหน้าต่าง: bin/rails db:migrate  ← รัน RemoveNameFromUsers ลบช่อง name ทิ้ง

# กลับมาที่ console เดิม — โค้ดนี้ไม่ได้พูดถึง name เลยสักตัว
User.create!(full_name: "ปิติ", email: "piti@example.com")

โค้ดไม่ได้แตะ name เลย — คิดว่าสร้างผู้ใช้ได้ไหม?

พัง (ผลจริง ตัด stack trace — รายการบรรทัดโค้ดที่ error วิ่งผ่าน ยาวหลายสิบบรรทัด — ออก):

  TRANSACTION (2.0ms)  BEGIN …
  User Create (4.5ms)  INSERT INTO "users" ("name", "email", "created_at", "updated_at", "full_name") VALUES (NULL, 'piti@example.com', '2026-11-04 12:37:38.676789', '2026-11-04 12:37:38.676789', 'ปิติ') RETURNING "id" …
  TRANSACTION (0.8ms)  ROLLBACK …
(wongnok-blog):2:in '<main>': PG::UndefinedColumn: ERROR:  column "name" of relation "users" does not exist (ActiveRecord::StatementInvalid)
LINE 1: INSERT INTO "users" ("name", "email", "created_at", "updated...
                             ^
…
  • ดูในวงเล็บของ INSERT INTO "users" ("name", …) — Rails ใส่ช่อง name ไปเองเป็นช่องแรก ด้วยค่า NULL (ว่าง) เพราะยังจำว่าตารางมีช่องนี้ · ลูกศร ^ ใต้บรรทัด LINE 1 ชี้ตรงคำว่า name ที่ตู้หาไม่เจอ
  • ตู้ตอบ column "name" of relation "users" does not exist = "ลิ้นชัก users ไม่มีช่อง name" (relation = คำที่ Postgres ใช้เรียกตาราง และใช้เรียก index ด้วย) · ROLLBACK = ยกเลิกงานชิ้นนี้ทั้งหมด
  • บน production นี่คือทุกคนที่สมัครสมาชิกได้หน้า error จนกว่าแอปทุกตัวจะ restart

ทำซ้ำอีกรอบ คราวนี้ใส่ ignored_columns ใน model ก่อนเปิด console (ผลจริง):

wongnok-blog(dev):001> User.column_names
=> ["id", "email", "created_at", "updated_at", "full_name"]
wongnok-blog(dev):002> User.first
  User Load (1.4ms)  SELECT "users"."id", "users"."email", "users"."created_at", "users"."updated_at", "users"."full_name" FROM "users" ORDER BY "users"."id" ASC LIMIT 1 …
#   (อีกหน้าต่าง: bin/rails db:migrate ลบช่อง name)
wongnok-blog(dev):003> User.create!(full_name: "ปิติ", email: "piti@example.com")
  User Create (1.0ms)  INSERT INTO "users" ("email", "created_at", "updated_at", "full_name") VALUES ('piti@example.com', …, 'ปิติ') RETURNING "id" …
  TRANSACTION (0.5ms)  COMMIT …

เทียบกับ Go: GORM ก็พังแบบเดียวกัน แต่ต้นเหตุคนละที่ · GORM ใส่ทุก field ของ struct ลงใน INSERT — โปรแกรม Go ตัวเก่าที่ยังมี field Name อยู่จึงส่ง name ไปหาตู้ที่ไม่มีช่องนี้แล้ว · Rails ไม่มี struct · มันจำรายชื่อช่องจากตารางตอนใช้ครั้งแรก · ignored_columns คือวิธีบอกให้ "ลืม" ช่องนั้นก่อนถึงวันลบ

migration ที่ย้อนกลับไม่ได้ — IrreversibleMigration

ในบท 108 Rails เดาวิธีย้อน (rollback) จาก change ได้เอง (add_column ย้อนเป็น remove_column)
แต่ "ย้อนโครงได้" ไม่ได้แปลว่า "ข้อมูลกลับมา" · ผมลอง rollback ตัว RemoveNameFromUsers (ผลจริง):

== 20261104115259 RemoveNameFromUsers: reverting ==============================
-- add_column(:users, :name, :string)
   -> 0.0033s

# แล้วดูข้อมูล
[[nil, "มานะ ใจดี"], [nil, "สมชาย"], [nil, "สมหญิง"]]     # ← User.pluck(:name, :full_name)

ช่อง name กลับมาแล้ว แต่ว่างทุกแถว — ช่างต่อลิ้นชักคืนให้ได้ แต่แฟ้มที่ทิ้งไปแล้วไม่กลับมา

บางงาน Rails เดาวิธีย้อนไม่ได้เลย เช่นสั่ง SQL ตรง ๆ ด้วย execute · ตัวอย่าง: แปลงอีเมลทุกคนเป็นตัวพิมพ์เล็ก (ไฟล์ db/migrate/…_downcase_user_emails.rb):

class DowncaseUserEmails < ActiveRecord::Migration[8.1]
  def change
    execute "UPDATE users SET email = LOWER(email)"
  end
end

UPDATE users SET email = LOWER(email) = "แก้ทุกแผ่นในลิ้นชัก users ให้ช่อง email เป็นตัวพิมพ์เล็ก" · migrate ผ่าน แต่พอ rollback (ผลจริง ตัดท้าย):

== 20261104115338 DowncaseUserEmails: reverting ===============================
bin/rails aborted!
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)

This migration uses execute, which is not automatically reversible.
To make the migration reversible you can either:
1. Define #up and #down methods in place of the #change method.
2. Use the #reversible method to define reversible behavior.
…

Rails บอกตรง ๆ ว่า "ไฟล์นี้ใช้ execute ฉันย้อนให้เองไม่ได้" · ทางที่ 1 คือแยกเป็น up (ทำ) กับ down (ย้อน) เอง (บท 108) · งานนี้ย้อนไม่ได้จริง ๆ เพราะไม่มีใครรู้แล้วว่าอีเมลเดิมตัวไหนเป็นตัวใหญ่ → เขียน down ให้ระเบิดพร้อมเหตุผล:

class DowncaseUserEmails < ActiveRecord::Migration[8.1]
  def up
    execute "UPDATE users SET email = LOWER(email)"
  end

  def down
    raise ActiveRecord::IrreversibleMigration, "อีเมลตัวพิมพ์ใหญ่เดิมหายไปแล้ว ย้อนกลับไม่ได้"
  end
end

rollback อีกรอบ (ผลจริง ตัดท้าย):

== 20261104115338 DowncaseUserEmails: reverting ===============================
bin/rails aborted!
StandardError: An error has occurred, this and all later migrations canceled: (StandardError)

อีเมลตัวพิมพ์ใหญ่เดิมหายไปแล้ว ย้อนกลับไม่ได้

~/wongnok_blog/db/migrate/20261104115338_downcase_user_emails.rb:7:in 'DowncaseUserEmails#down'
…

เทียบกับ Go: goose ไม่มีการเดาขากลับ — ทุกไฟล์คุณเขียนท่อน -- +goose Down เองเสมอ (บท 31) · up / down ของ Rails คือท่าเดียวกันนั้น · ส่วน change คือของแถมที่ Rails เดาขากลับให้ เมื่อมันเดาได้

🗣 ศัพท์ที่เพิ่งเรียนไป
ignored_columns
รายชื่อคอลัมน์ที่บอกให้ model ทำเป็นไม่เห็น ใช้ก่อนลบคอลัมน์ เพื่อให้แอปที่ยังรันอยู่เลิกใส่คอลัมน์นั้นใน SQL · ถาม senior ได้ว่า “ใส่ ignored_columns แล้ว deploy ครบทุกเครื่องหรือยัง ลบคอลัมน์ได้หรือยัง”
IrreversibleMigration · up / down
error ที่บอกว่า migration นี้ย้อนกลับไม่ได้ · up = ทางไป down = ทางกลับ เขียนเองเมื่อ change เดาไม่ได้ · พิมพ์บอก AI ได้ว่า “this migration uses execute, write explicit up/down or raise IrreversibleMigration with a reason”

ของที่ช่วยกันไม่ให้พลาด — strong_migrations และกฎ 4 ข้อ

strong_migrations คือ gem (อุปกรณ์สำเร็จรูป บท 98) ที่ดักทุกคำสั่งใน migration ก่อนส่งไปถึงตู้ ถ้าเจอท่าอันตรายจากตารางข้างบน มันจะหยุด migration ทันที แล้วบอกท่าที่ปลอดภัยให้ · แอปของเราไม่ได้ติดตั้ง บทนี้แค่ให้รู้จัก (หน้า GitHub ของ gem)

ตัวอย่างข้อความจาก README ของ gem (ไม่ได้รันในบทนี้ · ตัดมาบางส่วน) — บังเอิญเป็นเรื่องเดียวกับที่เราเพิ่งพังเอง:

=== Dangerous operation detected #strong_migrations ===

Active Record caches attributes, which causes problems
when removing columns. Be sure to ignore the column:
…

คือ "Active Record จำรายชื่อช่องไว้ ลบคอลัมน์แล้วจะมีปัญหา ให้ใส่ ignored_columns ก่อน" — ตรงกับหัวข้อที่แล้วทุกคำ · ถ้าทีมของคุณใช้ gem นี้ migration อันตรายจะรันไม่ผ่านตั้งแต่เครื่องคุณ

กฎส่วนตัวของผู้สอน (สไลด์หน้า 18) — เลือก 4 ข้อที่ใช้ได้ตั้งแต่วันแรก:

  1. 1 migration = 1 เรื่อง — พังแล้วรู้ทันทีว่าเรื่องไหน · โดยเฉพาะไฟล์ที่ปิด transaction
  2. ย้ายข้อมูลเยอะ ๆ ไม่ทำใน migration — ให้ไปทำในงานแยก (เช่น bin/rails runner หรือ rake task = คำสั่งที่เราเขียนเพิ่มเอง แล้วรันด้วย bin/rails ชื่องาน) แบบเฟส 2 ข้างบน · Rails Guides ก็แนะนำแบบเดียวกัน
  3. ก่อน merge (รวมโค้ดของเราเข้ากิ่งหลักของทีม): rollback แล้ว migrate อีกรอบ — คำสั่งเดียวคือ bin/rails db:migrate:redo (ผลจริง: เห็น reverting ตามด้วย migrating) · ถ้า redo ไม่ผ่าน แปลว่า rollback ใช้ไม่ได้จริง
  4. ตารางเกิน 1 ล้านแถว = คิดเรื่อง lock ทุกครั้ง — ถามคำถามประจำบท "แตะกี่แถว ใครต้องรอ"

ไม่แน่ใจว่า migration ของงานจริงอันตรายไหม → ส่งให้ senior หรือ AI สามอย่าง: ไฟล์ migration ทั้งไฟล์ · ตารางนั้นมีกี่แถว · มีคนเขียนตารางนี้บ่อยแค่ไหน แล้วถามคำถามประจำบทนี้

🧹 เก็บกวาดก่อนไปบทหน้า — ถ้าคุณลองตามบทนี้ในเครื่อง (ทำบางหัวข้อหรือทุกหัวข้อก็ได้) ทำ 4 ขั้นนี้ ตู้จะกลับเป็นแบบบท 107 เป๊ะ:

  1. ปิด console และ server ทุกหน้าต่างก่อน (ถ้ายังมีหน้าต่างไหนต่อตู้อยู่ ขั้นที่ 4 จะลบตู้ไม่ได้ ขึ้น PG::ObjectInUse: ERROR: database "…" is being accessed by other users)
  2. ลบไฟล์ใน db/migrate/ ที่บทนี้สร้าง (มีเท่าที่คุณทำ): …_add_index_to_posts_title.rb · …_add_full_name_to_users.rb · …_remove_name_from_users.rb · …_downcase_user_emails.rb — ต้องเหลือ 5 ไฟล์จากบท 107
  3. ลบบรรทัด self.ignored_columns += ["name"] ออกจาก app/models/user.rb (ถ้าใส่ไว้)
  4. bin/rails db:migrate:reset แล้วตามด้วย bin/rails db:seed — คู่เดียวกับบท 107
$ bin/rails db:migrate:reset
Dropped database '…'
Created database '…'
== 20261104113601 CreatePosts: migrating ======================================
…
== 20261104113607 CreateComments: migrated (0.0033s) ==========================
$ bin/rails db:seed
$ bin/rails runner 'p [User.count, Post.count, Comment.count]; p User.column_names; p User.pluck(:name)'
[3, 6, 7]
["id", "name", "email", "created_at", "updated_at"]
["สมชาย", "สมหญิง", "มานะ"]

ผลจริง (ตัดกลางเป็น … · ชื่อฐานข้อมูลกับเลขไฟล์ของคุณจะต่าง) — ได้ users 3 · posts 6 · comments 7 และช่อง name กลับมาพร้อมชื่อเดิม · บทความทดลอง 200,000 แผ่นหายไปพร้อมตู้เก่า · db:migrate:reset สร้างตู้ใหม่จากไฟล์ใบสั่งทีละใบ แล้วเขียน db/schema.rb ใหม่ให้ตรงกับ 5 ใบนั้นเอง (ผมเทียบแล้ว ได้ schema.rb ตรงกับก่อนเริ่มบททุกตัวอักษร)

ภารกิจ

🎯 ภารกิจ · แผน 3 เฟสบนกระดาษ + ติดป้ายแท็บแบบไม่ปิดลิ้นชัก (~10 นาที)

ส่วน A — เขียนลงกระดาษ (มาจาก Quiz 2 ของสไลด์)

ตาราง users มี 20 ล้านแถว มีคนใช้ตลอดเวลา · หัวหน้าบอกว่า "เปลี่ยนชื่อคอลัมน์ email เป็น email_address ให้หน่อย ห้ามดับระบบ"
เขียนว่าจะทำกี่ขั้น · แต่ละขั้นมี migration อะไร · โค้ดอ่าน/เขียนช่องไหน · มี deploy คั่นกี่ครั้ง

ส่วน B — ลองในเครื่อง (แอปที่มีตาราง posts จากบท 107 · ถ้าลองตามบทนี้ไปแล้ว ทำกล่อง "เก็บกวาดก่อนไปบทหน้า" ข้างบนก่อน)

  1. bin/rails generate migration AddIndexToPostsTitle
  2. เปิดไฟล์ที่ได้ เติม add_index :posts, :title, algorithm: :concurrently ใน change — ยังไม่ต้องใส่ disable_ddl_transaction!
  3. bin/rails db:migrate → จด error ที่เห็น
  4. เติม disable_ddl_transaction! ไว้ใต้บรรทัด class แล้ว migrate อีกรอบ
  5. เปิด db/schema.rb หาบรรทัดที่มีคำว่า title
  6. ลอง bin/rails db:migrate:redo ว่าย้อนแล้วทำใหม่ผ่านไหม
  7. เก็บกวาด: bin/rails db:rollback แล้วลบไฟล์ migration นี้

ควรเห็น: ข้อ 3 ได้ CREATE INDEX CONCURRENTLY cannot run inside a transaction block · ข้อ 4 ได้ migrated · ข้อ 5 เห็น t.index ["title"], name: "index_posts_on_title" · ข้อ 6 เห็น reverting แล้ว migrating

ถ้าเจอ error แปลว่า…

ส่วน A — 3 คำที่สไลด์ใบ้ไว้คือ expand → migrate → contract · คั่นด้วย deploy อย่างน้อย 3–4 ครั้ง:

เฟสตู้ (migration)โค้ดdeploy
1 · expandadd_column :users, :email_address, :string (ไม่แตะแถวเดิม เร็ว)เขียนลง email และ email_address · ยังอ่านจาก emailครั้งที่ 1
2 · migrateไม่มี migration · backfill ในงานแยก: User.where(email_address: nil).in_batches(of: 1000) { |b| b.update_all("email_address = email") }เปลี่ยนไปอ่าน email_address · ยังเขียนทั้งสองช่องครั้งที่ 2
3 · contract(ยังไม่ลบ)เลิกเขียน email · ใส่ self.ignored_columns += ["email"]ครั้งที่ 3
3 · contract (ต่อ)remove_column :users, :email, :string— (ถ้าอยาก ลบบรรทัด ignored_columns ได้หลังจากนี้)ครั้งที่ 4 หรือรวมกับงานรอบถัดไป

ทำไมไม่ใช้ rename_column บรรทัดเดียว: ระหว่าง migrate กับ restart แอปตัวเก่ายังหาช่อง email อยู่ → ทุกคนที่ล็อกอินหรือสมัครได้ error (แบบเดียวกับ predict ของ ignored_columns) · แต้มพิเศษถ้าคุณเขียนว่า "ถ้า email เดิมมี index (เช่นห้ามอีเมลซ้ำ บท 111) ต้องสร้าง index ให้ email_address ด้วย algorithm: :concurrently ในเฟส 1 หรือ 2"

ส่วน B — ไฟล์สุดท้ายหน้าตาแบบนี้:

class AddIndexToPostsTitle < ActiveRecord::Migration[8.1]
  disable_ddl_transaction!

  def change
    add_index :posts, :title, algorithm: :concurrently
  end
end

ผลจริงบนแอปที่มี posts 6 แถว (เวลาของคุณจะต่าง):

$ bin/rails db:migrate
== 20261104115407 AddIndexToPostsTitle: migrating =============================
-- add_index(:posts, :title, {algorithm: :concurrently})
   -> 0.0060s
== 20261104115407 AddIndexToPostsTitle: migrated (0.0060s) ====================

$ bin/rails db:migrate:redo
== 20261104115407 AddIndexToPostsTitle: reverting =============================
-- remove_index(:posts, :title, {algorithm: :concurrently})
   -> 0.0115s
== 20261104115407 AddIndexToPostsTitle: reverted (0.0129s) ====================

== 20261104115407 AddIndexToPostsTitle: migrating =============================
-- add_index(:posts, :title, {algorithm: :concurrently})
   -> 0.0023s
== 20261104115407 AddIndexToPostsTitle: migrated (0.0023s) ====================

สังเกตตอนย้อน: Rails เดาวิธีย้อนเป็น remove_index แบบ concurrently ให้เอง (ลบป้ายแท็บโดยไม่ปิดลิ้นชักเหมือนกัน) · ตาราง 6 แถวเร็วทั้งสองแบบ ความต่างจะเห็นเฉพาะตารางใหญ่ — ถ้าอยากเห็นจริง ใช้วิธีเติม 200,000 แถวในหัวข้อ index

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

ทุกข้อมาจากผลที่รันจริงในบทนี้

ข้อ 1 · ตาราง posts ใหญ่มาก · ระหว่างที่ migration add_index :posts, :title (แบบปกติ ไม่มี concurrently) ยังทำงานอยู่ ผู้ใช้จะเจออะไร?

ตัวเลือกที่ 3 ถูก · ผลจริงในบท: Post.count ได้ใน 27.7 ms แต่ INSERT รอ 12,034 ms · ตัวเลือกที่ 1 ผิดเพราะถึง index จะไม่แก้ข้อมูล แต่ต้องกันไม่ให้มีแฟ้มใหม่เข้ามาระหว่างเรียง · ตัวเลือกที่ 2 ผิดสองจุด: การอ่านยังได้ และการรอไม่ขึ้น error มันค้างเงียบ ๆ · ตัวเลือกที่ 4 กลับด้านกับของจริง

จุดที่ต้องจำ: add_index ปกติ = ห้ามเขียน · ตารางใหญ่ใช้ algorithm: :concurrently

ข้อ 2 · เขียน add_index :posts, :title, algorithm: :concurrently แต่ลืมใส่ disable_ddl_transaction! แล้วสั่ง migrate — เกิดอะไรขึ้น?

ตัวเลือกที่ 1 ถูก · ผลจริง: PG::ActiveSqlTransaction: ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block แล้ว migration ถูกยกเลิก · ตัวเลือกที่ 2 และ 3 ผิดเพราะ Rails ไม่เดาแทนเรา — ส่งคำสั่ง concurrently ไปตรง ๆ แล้ว Postgres ปฏิเสธ · ตัวเลือกที่ 4 ผิดเพราะมันพังทันที ไม่ได้ค้าง

จุดที่ต้องจำ: concurrently มาคู่กับ disable_ddl_transaction! เสมอ · เหมือน -- +goose NO TRANSACTION ใน Go

ข้อ 3 · เปลี่ยน name → full_name แบบ 3 เฟส · ในเฟส 1 (เพิ่งเพิ่มคอลัมน์ full_name) ทำไมโค้ดยังอ่านจาก name อยู่ ไม่เปลี่ยนไปอ่าน full_name เลย?

ตัวเลือกที่ 2 ถูก · ผลจริงหลังเฟส 1: [["สมชาย", nil], ["สมหญิง", nil], ["มานะ ใจดี", "มานะ ใจดี"]] — มีแค่คนที่เพิ่งแก้ที่มี full_name · ถ้าเปลี่ยนไปอ่าน full_name ตอนนี้ หน้าเว็บจะโชว์ชื่อว่างให้คนเก่าทุกคน · ตัวเลือกที่ 1 ผิด — Rails เห็นคอลัมน์ใหม่ทันทีหลัง migrate · ตัวเลือกที่ 3 ผิด — การอ่านธรรมดาไม่ได้ล็อกตาราง · ตัวเลือกที่ 4 ผิด — ไม่มี index ก็อ่านได้ แค่ช้ากว่าเมื่อหาด้วยช่องนั้น

จุดที่ต้องจำ: สลับการอ่านได้หลัง backfill ครบ · ระหว่างนั้นเขียนทั้งสองช่อง

ข้อ 4 · ลบคอลัมน์ name ด้วย remove_column ไปแล้ว ต่อมาสั่ง bin/rails db:rollback — ข้อมูลชื่อของผู้ใช้เป็นยังไง?

ตัวเลือกที่ 4 ถูก · ผลจริง: rollback รัน add_column(:users, :name, :string) แล้ว User.pluck(:name, :full_name) ได้ [[nil, "มานะ ใจดี"], [nil, "สมชาย"], [nil, "สมหญิง"]] · ตัวเลือกที่ 1 ผิด — rollback คือ "สั่งช่างทำงานกลับด้าน" ไม่ใช่ย้อนเวลา ข้อมูลที่ทิ้งไปไม่กลับ · ตัวเลือกที่ 2 ผิด — ไฟล์นี้บอกชนิด :string ไว้ Rails จึงรู้วิธีสร้างคอลัมน์คืน (IrreversibleMigration เกิดกับงานที่เดาวิธีย้อนไม่ได้ เช่น execute) · ตัวเลือกที่ 3 ผิด — schema.rb คือแปลนตู้ (มีลิ้นชักอะไร ช่องอะไร) ไม่ได้เก็บข้อมูลในแฟ้มสักแผ่น

จุดที่ต้องจำ: rollback ได้ ≠ ข้อมูลกลับ · ก่อนลบคอลัมน์บน production ต้องมั่นใจว่าไม่มีใครใช้แล้ว (ignored_columns + เฟส 3)

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

หน้าสไลด์เขียนของจริงต้องสนใจไหม
16 ฝั่งอันตราย ข้อ 1change_column_default ใส่ค่าเริ่มต้นให้คอลัมน์เดิม = ล็อกตารางบน Postgres 17 ใช้ไม่กี่ ms (เครื่องผม 6–16 ms) บน 200,000 แถว ไม่แตะแถวเดิม (มีผลกับแถวที่เพิ่มใหม่เท่านั้น)ไม่ต้องกลัวเท่าที่สไลด์ว่า · ท่า add_column … default: ฝั่งขวาก็เร็วเหมือนกัน
16 ฝั่งปลอดภัย ข้อ 4"ใส่ CHECK ก่อน แล้วค่อย validate"เป็นท่าขั้นสูงหลายขั้นของการตั้ง NOT NULL บนตารางใหญ่ · บทนี้ไม่ได้สอนแค่รู้ว่ามี · constraint ละเอียดบท 111
17 กับ 18หน้า 17 backfill ด้วย User.in_batches · หน้า 18 บอก "ห้ามเรียก model ใน migration"ไม่ขัดกัน ถ้า backfill อยู่ในงานแยก (runner / rake task) ไม่ใช่ในไฟล์ migration — แบบที่บทนี้ทำสนใจ — อย่าเอาโค้ด in_batches ไปใส่ในไฟล์ migration
18 กล่อง strong_migrationsโชว์ข้อความ => ERROR: Adding an index non-concurrently …เป็นข้อความย่อของสไลด์ · ข้อความจริงของ gem ขึ้นต้นด้วย === Dangerous operation detected #strong_migrations === (ตาม README · ไม่ได้รันในบทนี้)ไม่ต้อง
19Quiz 2 คำถามปลายเปิด + คำถามต่อบทนี้แปลงข้อหลักเป็นภารกิจส่วน A · ข้อ "ห้ามเรียก Post.find_each ใน migration" คือแถวหน้า 17 กับ 18 ข้างบน · ข้อ "แก้ไฟล์ที่รันไปแล้ว" ตอบไว้ในบท 108 หัวข้อกับดัก · ข้อ schema.rb conflict บทนี้ไม่ได้ตอบ — อ่าน Rails Guides หัวข้อ 6.3 Schema Dumps and Source Controlไม่ต้อง
ทั้งชุดRuby 4.0.6 · Rails 8.1.3เครื่องตรวจใช้ Ruby 4.0.9 · Rails 8.1.4 · PostgreSQL 17ไม่ต้อง

อ้างอิง