🔁 ทวนก่อนเริ่ม — คำจากบทก่อน ๆ ที่บทนี้ใช้
- ตาราง · แถว · คอลัมน์ (บท 106) — ลิ้นชัก posts · แฟ้มหนึ่งแผ่น · ช่องบนแฟ้ม
- migration (บท 108) — ใบสั่งช่างมาต่อเติมตู้ (เพิ่มลิ้นชัก เพิ่มช่อง) · รันด้วย
bin/rails db:migrate· รื้องานใบล่าสุดด้วยbin/rails db:rollback db/schema.rb(บท 108) — แปลนตู้ฉบับล่าสุด Rails เขียนให้เองหลัง migrate- console (บท 104, 107) — ครัวทดลองหลังร้าน · ทุกคำสั่งที่ไปถึงตู้ จะมีบรรทัด SQL โผล่ให้ดู พร้อมเวลาเป็น ms (มิลลิวินาที = 1/1000 วินาที)
- SQL (บท 106) — ภาษาที่คนดูแลสต็อกใช้สั่งตู้ · Active Record แปลคำสั่ง Ruby เป็น SQL ให้
🧰 ตรวจด้วย 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 · ขั้นตอนหลัก ๆ มีสามจังหวะ:
- เอาโค้ดใหม่ขึ้นเครื่อง server
- รัน
bin/rails db:migrateกับ database ตัวจริง (ตู้จริงของร้าน) - เริ่มแอปใหม่ (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
ActiveRecord::Base.connection.execute(…)— "ส่ง SQL นี้ไปให้ตู้ตรง ๆ ไม่ต้องแปลจาก Ruby" · ใช้เพราะเร็วกว่าสร้างทีละแถวด้วยPost.create!สองแสนรอบ<<~SQL…SQL— เขียนข้อความยาวหลายบรรทัด (heredoc) เริ่มที่<<~SQLจบที่บรรทัดSQL· ชื่อSQLตั้งเองได้INSERT INTO posts (title, …)— SQL แปลว่า "ใส่แฟ้มใหม่ลงลิ้นชัก posts โดยกรอกช่อง title, body, user_id, created_at, updated_at"SELECT … FROM generate_series(1, 200000) AS n— "สร้างตัวเลข 1 ถึง 200,000 เรียกแต่ละตัวว่า n แล้วทำแฟ้มหนึ่งแผ่นต่อหนึ่งตัวเลข" ·'บทความทดลอง ' || nคือเอาข้อความต่อกับตัวเลข ได้ชื่อ "บทความทดลอง 1" ถึง "บทความทดลอง 200000" ·now()= เวลาตอนนี้ (ใส่ช่อง created_at / updated_at)#{User.first.id}— ใส่ id ของผู้ใช้คนแรก เพราะทุกบทความต้องมีเจ้าของ (คอลัมน์ user_id ห้ามว่าง)
ผลจริง (จัดบรรทัดให้อ่านง่าย — จอคุณจะมีเลข 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
User Load … LIMIT 1— มาจากUser.firstที่เราแทรกไว้: "เรียงตาม id (ORDER BY= เรียงตาม ·ASC= จากน้อยไปมาก) แล้วเอาแผ่นแรกแผ่นเดียว" (LIMIT= เอาไม่เกินกี่แผ่น) → ได้ id 1 ไปใส่ใน SQL(632.8ms)— ใส่ 200,000 แผ่นใช้ไม่ถึง 1 วินาที · บรรทัด=> #<PG::Result …>คือใบตอบกลับจากตู้ อ่านแค่cmd_tuples=200000= "ใส่ไปแล้ว 200,000 แผ่น"SELECT COUNT(*) FROM "posts"— "นับจำนวนแฟ้มทั้งหมดในลิ้นชัก posts" · ได้ 200,006 = บทความทดลอง 200,000 + บทความจาก seeds 6
หาแบบไม่มีป้ายแท็บ
ลองหาบทความชื่อ "บทความทดลอง 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 ข้ามได้เลย):
- บรรทัด
EXPLAIN (ANALYZE) SELECT …— "ขอแฟ้มทุกช่องจากลิ้นชัก posts เฉพาะแผ่นที่ (WHERE) ช่อง title เท่ากับ 'บทความทดลอง 150000'" Seq Scan— ไล่ทีละแผ่นตั้งแต่แผ่นแรก (Seq = sequential = ตามลำดับ) · คำว่าParallelกับloops=3แปลว่าตู้แบ่งพนักงาน 3 คนช่วยกันไล่Rows Removed by Filter: 66668— แต่ละคนหยิบขึ้นมาดูแล้ววางคืนราว 66,668 แผ่น สามคนรวมกันเกือบ 200,000 แผ่น เพื่อหาแผ่นเดียว ·Execution Time: 9.842 msคือเวลาที่ใช้
ติดป้ายแท็บด้วย 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 Scan using index_posts_on_title— เปิดป้ายแท็บชื่อ index_posts_on_title แล้วตรงไปที่แผ่นนั้นเลย · ไม่มีบรรทัด Rows Removed แล้ว เพราะไม่ต้องหยิบแผ่นอื่นขึ้นมาดูExecution Time: 0.568 ms— จาก 9.8 ms เหลือ 0.57 ms (เร็วขึ้นราว 17 เท่าที่ 200,000 แถว · ตารางยิ่งใหญ่ ยิ่งต่างมาก)
- 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'
…
CREATE INDEX CONCURRENTLY cannot run inside a transaction block— Postgres บอกว่า "การสร้าง index แบบไม่ปิดลิ้นชัก ห้ามอยู่ใน transaction"- แต่เมื่อกี้เพิ่งเรียนว่า Rails ห่อ migration ทุกไฟล์ด้วย transaction ให้เองเสมอ → ชนกัน
.rb:3— บรรทัดที่ 3 ของไฟล์ คือบรรทัดadd_index·this and all later migrations canceled= ไฟล์นี้และไฟล์ที่ต่อจากนี้ไม่ถูกรัน (ตู้ไม่เปลี่ยนอะไร)
ทางแก้คือบอก 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 เท่า)
| อยากทำอะไร | ท่าตรง ๆ | แตะกี่แผ่น · ใครรอ | ท่าที่ปลอดภัยกว่า |
|---|---|---|---|
| สร้าง index | add_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) · แปลว่ามีช่วงหนึ่งที่ตู้เป็นแบบใหม่แล้ว แต่แอปยังเป็นโค้ดเก่า · ถ้าสลับลำดับ ก็จะมีช่วงที่โค้ดใหม่วิ่งอยู่กับตู้แบบเก่าแทน — หนีช่วงนี้ไม่พ้น
ประโยคหลักของสไลด์: ไม่มี deploy ไหนที่โค้ดกับตู้เปลี่ยนพร้อมกันได้ — ต้องออกแบบให้มีช่วงที่ของเก่ากับของใหม่อยู่ด้วยกันได้ · ท่ามาตรฐานคือแบ่งเป็น 3 เฟส แต่ละเฟสคือ deploy หนึ่งรอบ:
แฟ้มผู้ใช้ทุกแผ่นมีช่อง "ชื่อ" · อยากเปลี่ยนเป็นช่อง "ชื่อเต็ม" · เฟส 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], ["มานะ ใจดี", "มานะ ใจดี"]]
mana.update(…)— แก้แฟ้มของมานะแล้วเก็บทันที (บท 107)UPDATE "users" SET … WHERE "users"."id" = 3— "แก้แฟ้มในลิ้นชัก users เฉพาะแผ่นที่ id เป็น 3 ให้ช่อง name และ full_name เป็นค่าใหม่"User.pluck(:name, :full_name)— "ขอแค่สองช่องนี้จากทุกแผ่น" (pluck = เด็ดมาแค่บางช่อง · บท 113) · เห็นว่าคนเก่ายังมี full_name เป็นnil— เพราะเฟส 1 ดูแลแค่ข้อมูลที่เขียนใหม่ ของเก่ารอเฟส 2- ในแอปจริง การ "เขียนทั้งสองช่อง" มักอยู่ใน controller หรือให้ model ทำให้อัตโนมัติด้วย callback (บท 112)
เฟส 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
User.where(full_name: nil)— เฉพาะคนที่ full_name ยังว่าง · รันซ้ำได้ ไม่ทับคนที่ทำไปแล้ว.in_batches(of: 1000) do |batch| … end— แบ่งเป็นกองละ 1,000 แผ่น แล้วส่งแต่ละกองเข้า block ในชื่อbatch(block = ก้อนโค้ดdo … endบท 93 · in_batches ละเอียดบท 114)batch.update_all("full_name = name")— สั่งตู้ทีเดียวทั้งกองว่า "ให้ช่อง full_name เท่ากับช่อง name ของแผ่นตัวเอง" · ไม่ได้หยิบทีละแผ่นมาเข้า Ruby
ผลจริง (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" …
=> [["มานะ ใจดี", "มานะ ใจดี"], ["สมชาย", "สมชาย"], ["สมหญิง", "สมหญิง"]]
- บรรทัดแรก — "ขอเลข id ของแผ่นที่ full_name ว่าง (
IS NULL) เรียงตาม id เอาแค่ 1,000 แผ่นแรก" → ได้ id 1 กับ 2 - บรรทัดที่สอง — "แก้เฉพาะแผ่นที่ id อยู่ในกอง (1, 2)" · ถ้ามี 20 ล้านแถว จะเห็นสองบรรทัดนี้ซ้ำ 20,000 รอบ แต่ละรอบสั้น ๆ
- ผลสุดท้ายทุกคนมี full_name ครบ (ลำดับสลับเพราะไม่ได้สั่งเรียง ไม่ต้องสนใจ)
พอข้อมูลครบแล้ว ค่อยเปลี่ยนโค้ดให้อ่านจาก 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
self.ignored_columns—self.ที่อยู่ใน class ตรงนี้หมายถึงตัว class User ทั้ง class (ตั้งค่าให้ผู้ใช้ทุกคน ไม่ใช่คนใดคนหนึ่ง)+= ["name"]— เติม"name"เข้าไปในรายการเดิม (ย่อมาจากx = x + ["name"])- รวมกันแปลว่า "คนดูแลสต็อกของ User ให้ทำเป็นไม่เห็นช่อง name" ทั้งที่ช่องยังอยู่ในตู้
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 …
User.column_names— ไม่มี"name"แล้ว ทั้งที่ช่องยังอยู่ในตู้ตอนนั้นSELECT "users"."id", "users"."email", …— ปกติ Rails ขอ"users".*(ทุกช่อง) · พอมี ignored_columns มันระบุชื่อช่องทีละช่อง ข้าม nameINSERTไม่มี name ในวงเล็บแล้ว → ลบช่องทิ้งตอนไหนก็ไม่พัง →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'
…
raise— โยน error (บท 96) ·ActiveRecord::IrreversibleMigrationคือชนิด error ที่ Rails เตรียมไว้สำหรับ "ย้อนไม่ได้".rb:7= บรรทัดraise·migrate:statusยังเป็นup— ตู้ไม่ถูกแตะ คนที่สั่ง rollback รู้ทันทีว่าต้องหาทางอื่น (เช่นกู้จาก backup = สำเนาตู้ที่เก็บไว้ก่อนหน้า)- ทำไมดีกว่าปล่อย
downว่าง: ถ้า down ว่าง rollback จะ "ผ่าน" แต่ข้อมูลไม่ได้ย้อน — คนจะเข้าใจผิดว่าย้อนแล้ว
เทียบกับ 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 migration = 1 เรื่อง — พังแล้วรู้ทันทีว่าเรื่องไหน · โดยเฉพาะไฟล์ที่ปิด transaction
- ย้ายข้อมูลเยอะ ๆ ไม่ทำใน migration — ให้ไปทำในงานแยก (เช่น
bin/rails runnerหรือ rake task = คำสั่งที่เราเขียนเพิ่มเอง แล้วรันด้วยbin/rails ชื่องาน) แบบเฟส 2 ข้างบน · Rails Guides ก็แนะนำแบบเดียวกัน - ก่อน merge (รวมโค้ดของเราเข้ากิ่งหลักของทีม): rollback แล้ว migrate อีกรอบ — คำสั่งเดียวคือ
bin/rails db:migrate:redo(ผลจริง: เห็นrevertingตามด้วยmigrating) · ถ้า redo ไม่ผ่าน แปลว่า rollback ใช้ไม่ได้จริง - ตารางเกิน 1 ล้านแถว = คิดเรื่อง lock ทุกครั้ง — ถามคำถามประจำบท "แตะกี่แถว ใครต้องรอ"
ไม่แน่ใจว่า migration ของงานจริงอันตรายไหม → ส่งให้ senior หรือ AI สามอย่าง: ไฟล์ migration ทั้งไฟล์ · ตารางนั้นมีกี่แถว · มีคนเขียนตารางนี้บ่อยแค่ไหน แล้วถามคำถามประจำบทนี้
🧹 เก็บกวาดก่อนไปบทหน้า — ถ้าคุณลองตามบทนี้ในเครื่อง (ทำบางหัวข้อหรือทุกหัวข้อก็ได้) ทำ 4 ขั้นนี้ ตู้จะกลับเป็นแบบบท 107 เป๊ะ:
- ปิด console และ server ทุกหน้าต่างก่อน (ถ้ายังมีหน้าต่างไหนต่อตู้อยู่ ขั้นที่ 4 จะลบตู้ไม่ได้ ขึ้น
PG::ObjectInUse: ERROR: database "…" is being accessed by other users) - ลบไฟล์ใน
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 - ลบบรรทัด
self.ignored_columns += ["name"]ออกจากapp/models/user.rb(ถ้าใส่ไว้) 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 · ถ้าลองตามบทนี้ไปแล้ว ทำกล่อง "เก็บกวาดก่อนไปบทหน้า" ข้างบนก่อน)
bin/rails generate migration AddIndexToPostsTitle- เปิดไฟล์ที่ได้ เติม
add_index :posts, :title, algorithm: :concurrentlyในchange— ยังไม่ต้องใส่disable_ddl_transaction! bin/rails db:migrate→ จด error ที่เห็น- เติม
disable_ddl_transaction!ไว้ใต้บรรทัดclassแล้ว migrate อีกรอบ - เปิด
db/schema.rbหาบรรทัดที่มีคำว่าtitle - ลอง
bin/rails db:migrate:redoว่าย้อนแล้วทำใหม่ผ่านไหม - เก็บกวาด:
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 แปลว่า…
CREATE INDEX CONCURRENTLY cannot run inside a transaction blockตอนข้อ 4 →disable_ddl_transaction!ยังไม่เข้า · เช็คว่าอยู่ใน class แต่นอกdef changeและสะกดมี!ท้ายAnother migration is already named add_index_to_posts_titleตอนข้อ 1 → ไฟล์ชื่อนี้จากหัวข้อ index ยังอยู่ในdb/migrate/· ทำกล่องเก็บกวาดก่อน แล้วเริ่มข้อ 1 ใหม่PG::DuplicateTable: ERROR: relation "index_posts_on_title" already exists→ มี index ชื่อนี้อยู่แล้ว (จากตอนลองหัวข้อ index แล้วยังไม่ได้เก็บกวาด) · ทำกล่องเก็บกวาดก่อน แล้วเริ่มข้อ 1 ใหม่
ส่วน A — 3 คำที่สไลด์ใบ้ไว้คือ expand → migrate → contract · คั่นด้วย deploy อย่างน้อย 3–4 ครั้ง:
| เฟส | ตู้ (migration) | โค้ด | deploy |
|---|---|---|---|
| 1 · expand | add_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 ฝั่งอันตราย ข้อ 1 | change_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 · ไม่ได้รันในบทนี้) | ไม่ต้อง |
| 19 | Quiz 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 | ไม่ต้อง |
อ้างอิง
- Rails Guides — Active Record Migrations — อ่านหัวข้อ 3.12 Using the up/down Methods กับ 3.13 Throwing an error to prevent reverts (IrreversibleMigration) · แล้วข้ามไปหัวข้อ 10.2 Data Migrations ที่บอกว่าทำไมไม่ควรย้ายข้อมูลในไฟล์ migration
- Rails API — ActiveRecord::Migration.disable_ddl_transaction! — อ่านคำอธิบายสั้น ๆ ของ method นี้ · และ ignored_columns= ที่อธิบายว่าใช้ก่อนลบคอลัมน์
- PostgreSQL 17 — CREATE INDEX: Building Indexes Concurrently — อ่านย่อหน้าแรกของหัวข้อนี้ ที่เขียนว่าแบบปกติ คนอื่นยังอ่านตารางได้ แต่การ insert/update/delete จะรอจนสร้าง index เสร็จ — ตรงกับผลที่เราวัดได้
- PostgreSQL 17 — Table-Level Locks —
ไม่ต้องอ่านทั้งหน้า ดูแค่หัวข้อ
ACCESS EXCLUSIVEที่เขียนว่า ALTER TABLE หลายแบบใช้ lock ระดับนี้ ซึ่งกันทั้งอ่านและเขียน · และย่อหน้าถัดมาที่บอกว่า lock ค้างจนจบ transaction - strong_migrations (GitHub) — อ่านหัวข้อ Checks ด้านบน เป็นรายการท่าอันตรายพร้อมท่าที่ปลอดภัย · ดูหัวข้อ Removing a column กับ Renaming a column ประกอบหัวข้อ expand → contract
- pressly/goose (GitHub) — ค้นคำว่า
NO TRANSACTIONใน README · ตัวเดียวกับdisable_ddl_transaction!ฝั่ง Go (เคยเจอในบท 31)