การแก้ไข schema
schema ไม่เคยเสร็จสมบูรณ์ ฟีเจอร์ใหม่ต้องการ column ใหม่ ตัวเก่ากลายเป็นของล้าสมัย และกฎเข้มขึ้นเมื่อคุณรู้ว่าข้อมูลจริงหน้าตาเป็นอย่างไร statement ALTER TABLE เปลี่ยนรูปร่างของ table ที่มีอยู่ในที่เดิมโดยเก็บ row ที่คุณมีไว้ เช่นเดียวกับ CREATE TABLE มันคือ DDL และมักถูกใช้ผ่านขั้นตอน migration เพื่อให้การเปลี่ยนแปลงมี version และทำซ้ำได้
เราทำต่อกับ table products คราวนี้มี 1,000 row เพื่อให้ migration มีอะไรให้เคี้ยว ทุกอย่างด้านล่างคือ output จริงจาก PostgreSQL 18.6 (postgres:18.6, aarch64, Docker Desktop บน Mac, กันยายน 2026) จากบนลงล่างในฐานข้อมูลเปล่าหนึ่งตัว ตัวเลขเวลามาจาก Mac เครื่องนี้ คำถามสำคัญสำหรับทุก statement ไม่ใช่แค่ “ใช้ได้ไหม” แต่คือ “มัน rewrite table ไหม และถือ lock อะไร?”
-- sql/products.sqlCREATE TABLE products ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, price numeric(10, 2) NOT NULL);INSERT INTO products (name, price)SELECT 'product-' || g, (g % 50) + 0.99 FROM generate_series(1, 1000) g;SELECT count(*) AS rows, min(price), max(price) FROM products;CREATE TABLEINSERT 0 1000 rows | min | max------+------+------- 1000 | 0.99 | 49.99(1 row)การเพิ่มและลบ column
หัวข้อที่มีชื่อว่า “การเพิ่มและลบ column”ADD COLUMN ต่อ column ใหม่เข้าไปในทุก row ที่มีอยู่ ถ้าไม่ให้ default row ที่มีอยู่จะได้ null ใน column ใหม่ DROP COLUMN ลบ column และข้อมูลของมันถาวร คุณรวมหลายการเปลี่ยนแปลงใน statement เดียวโดยคั่นด้วยจุลภาคได้ ซึ่งเก็บการแก้ไขที่เกี่ยวข้องไว้ในขั้นเดียวแบบ atomic
-- sql/add-drop-column.sqlALTER TABLE products ADD COLUMN description text;SELECT count(description) AS non_null_descriptions, count(*) AS rows FROM products;ALTER TABLE products DROP COLUMN description;ALTER TABLE products ADD COLUMN sku text, ADD COLUMN in_stock boolean NOT NULL DEFAULT true;SELECT count(*) FILTER (WHERE in_stock) AS in_stock_true, count(sku) AS sku_filled FROM products;ALTER TABLE non_null_descriptions | rows-----------------------+------ 0 | 1000(1 row)
ALTER TABLEALTER TABLE in_stock_true | sku_filled---------------+------------ 1000 | 0(1 row)ใน pgAdmin: หลังแก้ไข table ให้ refresh โหนด Columns ใน browser tree เพื่อดูการเปลี่ยนแปลง หรือใช้กล่อง Properties ของ table เพื่อเพิ่มและลบ column ผ่านฟอร์ม
การเปลี่ยน type ของ column
หัวข้อที่มีชื่อว่า “การเปลี่ยน type ของ column”ALTER COLUMN ... TYPE แปลง column เป็น type ใหม่ PostgreSQL แปลงหลายกรณีได้อัตโนมัติ แต่เมื่อการเปลี่ยนไม่ชัดเจน คุณต้องใส่นิพจน์ USING ที่บอกว่าจะแปลงแต่ละค่าที่มีอยู่อย่างไร
-- sql/change-type.sqlUPDATE products SET sku = 'SKU-' || lpad(id::text, 4, '0');ALTER TABLE products ALTER COLUMN sku TYPE varchar(32);ALTER TABLE products ALTER COLUMN price TYPE numeric(12, 2) USING price::numeric(12, 2);\d productsUPDATE 1000ALTER TABLEALTER TABLE Table "public.products" Column | Type | Collation | Nullable | Default----------+-----------------------+-----------+----------+------------------------------ id | bigint | | not null | generated always as identity name | text | | not null | price | numeric(12,2) | | not null | sku | character varying(32) | | | in_stock | boolean | | not null | trueIndexes: "products_pkey" PRIMARY KEY, btree (id)ถ้าค่าใดแปลงไม่ได้ ทั้ง statement ล้มเหลวและไม่มีอะไรเปลี่ยน:
-- @expect-error 22P02ALTER TABLE products ALTER COLUMN sku TYPE integer USING sku::integer;ERROR: invalid input syntax for type integer: "SKU-0001"USING คือวิธีบอกว่าจะทำอย่างไรกับค่าเหล่านั้น:
-- sql/using-ok.sqlALTER TABLE products ADD COLUMN sku_num text;UPDATE products SET sku_num = sku;ALTER TABLE products ALTER COLUMN sku_num TYPE integer USING substr(sku_num, 5)::integer;SELECT sku, sku_num FROM products WHERE id IN (1, 1000) ORDER BY id;ALTER TABLE products DROP COLUMN sku_num;ALTER TABLEUPDATE 1000ALTER TABLE sku | sku_num----------+--------- SKU-0001 | 1 SKU-1000 | 1000(2 rows)
ALTER TABLEมัน rewrite table ไหม?
หัวข้อที่มีชื่อว่า “มัน rewrite table ไหม?”การแปลงอาจรันทุก row ดังนั้นบน table ใหญ่อาจใช้เวลาและถือ lock แต่ไม่ใช่ทุก ALTER ที่ rewrite table และการรู้ว่าตัวไหนทำคือหัวใจทั้งหมด การ rewrite สร้างสำเนาใหม่ของไฟล์ table ซึ่งเห็นได้จาก pg_relation_filenode ที่เปลี่ยน นี่คือ matrix รันทีละ statement กับ table เดียวกัน (แต่ละแถวใช้หลังแถวก่อนหน้า):
-- sql/rewrite-matrix.sqlCREATE TABLE rw (id int, t text, v varchar(32), n numeric(10, 2), i int);INSERT INTO rw SELECT g, 'row-' || g, 'v' || g, g, g FROM generate_series(1, 1000) g;
CREATE FUNCTION rewrites(ddl text) RETURNS text LANGUAGE plpgsql AS $$DECLARE before_node oid := pg_relation_filenode('rw');BEGIN EXECUTE ddl; RETURN CASE WHEN pg_relation_filenode('rw') = before_node THEN 'no rewrite' ELSE 'REWRITE' END;END $$;
SELECT ddl, rewrites(ddl) AS resultFROM (VALUES (1, 'ALTER TABLE rw ALTER COLUMN n TYPE numeric(12,2)'), (2, 'ALTER TABLE rw ALTER COLUMN v TYPE varchar(64)'), (3, 'ALTER TABLE rw ALTER COLUMN v TYPE text'), (4, 'ALTER TABLE rw ALTER COLUMN t TYPE varchar(32)'), (5, 'ALTER TABLE rw ALTER COLUMN t TYPE varchar(8)'), (6, 'ALTER TABLE rw ALTER COLUMN n TYPE numeric(12,3)'), (7, 'ALTER TABLE rw ALTER COLUMN i TYPE bigint'), (8, 'ALTER TABLE rw ADD COLUMN c1 int DEFAULT 0'), (9, 'ALTER TABLE rw ADD COLUMN c2 timestamptz DEFAULT now()'), (10, 'ALTER TABLE rw ADD COLUMN c3 uuid DEFAULT gen_random_uuid()'), (11, 'ALTER TABLE rw ADD COLUMN c4 bigint GENERATED ALWAYS AS (i * 2) STORED'), (12, 'ALTER TABLE rw ADD COLUMN c5 bigint GENERATED ALWAYS AS (i * 2)'), (13, 'ALTER TABLE rw DROP COLUMN c1'), (14, 'ALTER TABLE rw ALTER COLUMN id SET NOT NULL')) AS t(step, ddl)ORDER BY step;CREATE TABLEINSERT 0 1000CREATE FUNCTION ddl | result------------------------------------------------------------------------+------------ ALTER TABLE rw ALTER COLUMN n TYPE numeric(12,2) | no rewrite ALTER TABLE rw ALTER COLUMN v TYPE varchar(64) | no rewrite ALTER TABLE rw ALTER COLUMN v TYPE text | no rewrite ALTER TABLE rw ALTER COLUMN t TYPE varchar(32) | REWRITE ALTER TABLE rw ALTER COLUMN t TYPE varchar(8) | REWRITE ALTER TABLE rw ALTER COLUMN n TYPE numeric(12,3) | REWRITE ALTER TABLE rw ALTER COLUMN i TYPE bigint | REWRITE ALTER TABLE rw ADD COLUMN c1 int DEFAULT 0 | no rewrite ALTER TABLE rw ADD COLUMN c2 timestamptz DEFAULT now() | no rewrite ALTER TABLE rw ADD COLUMN c3 uuid DEFAULT gen_random_uuid() | REWRITE ALTER TABLE rw ADD COLUMN c4 bigint GENERATED ALWAYS AS (i * 2) STORED | REWRITE ALTER TABLE rw ADD COLUMN c5 bigint GENERATED ALWAYS AS (i * 2) | no rewrite ALTER TABLE rw DROP COLUMN c1 | no rewrite ALTER TABLE rw ALTER COLUMN id SET NOT NULL | no rewrite(14 rows)อ่านให้ละเอียด เพราะคำแนะนำที่คุ้นเคยบางข้อผิด:
- การเพิ่ม precision ของ
numericที่ scale เท่าเดิม การขยายvarchar(n)และการเปลี่ยนvarcharเป็นtextเป็นแค่ metadata - การเปลี่ยน ไปเป็น ขีดจำกัดความยาว (
textเป็นvarchar(32)) การลดvarchar(n)การเปลี่ยน scale ของnumericและintegerเป็นbigintrewrite table - การเพิ่ม column ที่มี default เป็นค่าคงที่หรือ
now()เป็นแค่ metadata (fast default, PostgreSQL 11+) ส่วน default แบบ volatile เช่นgen_random_uuid()rewrite generated column แบบSTOREDrewrite ส่วนแบบ virtual (ค่าเริ่มต้นของ PostgreSQL 18) ไม่ rewrite DROP COLUMNแค่ทำเครื่องหมายว่า column ถูก dropSET NOT NULLสแกน table เพื่อตรวจแต่ไม่ rewrite
กฎอยู่ใน หมายเหตุของ ALTER TABLE matrix ข้างบนคือสิ่งที่เซิร์ฟเวอร์นี้ทำจริง
Lock: “บล็อกชั่วครู่” จริง ๆ แล้วหมายถึงอะไร
หัวข้อที่มีชื่อว่า “Lock: “บล็อกชั่วครู่” จริง ๆ แล้วหมายถึงอะไร”ทุกรูปแบบข้างบนถือ lock ACCESS EXCLUSIVE บน table ซึ่งเป็น lock ที่แรงที่สุด ขัดแย้งกับทุกอย่างรวมถึง SELECT ธรรมดา ถือจนจบ transaction และเวลา rewrite ถูกใช้ไปภายในนั้น:
-- sql/lock-modes.sqlBEGIN;ALTER TABLE products ALTER COLUMN name TYPE varchar(100);SELECT mode FROM pg_locks WHERE relation = 'products'::regclass AND pid = pg_backend_pid() AND mode LIKE '%Exclusive%' ORDER BY mode;ROLLBACK;BEGINALTER TABLE mode--------------------- AccessExclusiveLock(1 row)
ROLLBACKปัญหาที่ซ่อนอยู่คือ lock queue ALTER TABLE ต้องรอทุก transaction ที่แตะ table อยู่แล้ว และระหว่างที่มันรอ ทุกอย่างที่มาถึงทีหลังจะต่อคิวอยู่ข้างหลัง มัน แม้แต่ ADD COLUMN ที่เสร็จทันทีก็หยุดทราฟฟิกทั้งหมดได้ถ้ามี transaction ยาวเปิดอยู่ รันจริงด้วยสาม session (แต่ละตัวเป็น psql แยก): session A ถือการอ่านใน transaction 5 วินาที session B ลอง ALTER TABLE session C รัน SELECT ธรรมดาสองวินาทีหลัง A เริ่ม session D ดู pg_stat_activity:
-- session A: holds an ordinary read lock inside a transaction for 5 secondsBEGIN a_rows-------- 1000(1 row)
COMMIT
-- session B (starts 1 s later): the migrationTiming is on.ALTER TABLETime: 3995.829 ms (00:03.996)
-- session C (starts 2 s later): a plain SELECT, nothing to do with the migrationTiming is on. c_rows-------- 1000(1 row)
Time: 2993.559 ms (00:02.994)
-- session D (looks at 3.5 s): who is waiting? query | state | wait_event_type | wait_event------------------------------------+--------+-----------------+------------ ALTER TABLE rw ADD COLUMN x int; | active | Lock | relation SELECT count(*) AS c_rows FROM rw; | active | Lock | relation(2 rows)C แค่อ่านอย่างเดียวและถูกบล็อก 3 วินาทีเพราะต่อคิวหลัง B ซึ่งต่อคิวหลัง A ตัวป้องกันคือ lock_timeout: ยอมแพ้เร็วแล้วลองใหม่ แทนที่จะบล็อก table ของทุกคน:
-- session B, same as before but with lock_timeout = 500msSETTiming is on.ERROR: canceling statement due to lock timeoutTime: 502.981 ms
-- session CTiming is on. c_rows-------- 1000(1 row)
Time: 1.527 msmigration ล้มเหลวด้วย canceling statement due to lock timeout (SQLSTATE 55P03) หลัง 0.5 วินาที และ C รันใน 1.5 ms ใส่ SET lock_timeout ที่หัวของทุก migration บนระบบที่ใช้งานอยู่ แล้วลองใหม่
การเพิ่มและลบ constraint
หัวข้อที่มีชื่อว่า “การเพิ่มและลบ constraint”คุณผูก constraint กับ table หลังจากมันมีอยู่แล้วได้ หรือลบตามชื่อ การตั้งชื่อ constraint ตอนสร้าง ตามที่บทก่อนแนะนำ คือสิ่งที่ทำให้การลบสะอาด
-- sql/add-constraint.sqlALTER TABLE products ADD CONSTRAINT products_price_positive CHECK (price > 0);ALTER TABLE products DROP CONSTRAINT products_price_positive;การเพิ่ม constraint สแกน row ที่มีอยู่เพื่อยืนยันว่าทุกตัวผ่าน ถ้า row ใดไม่ผ่าน ทั้ง statement ถูกปฏิเสธและไม่มีอะไรเปลี่ยน (บทก่อนแสดง error นั้น) การสแกนเกิดภายใต้ lock ที่แรง NOT VALID แยกเป็นสองขั้น: เพิ่ม constraint เพื่อให้การเขียน ใหม่ ถูกตรวจทันที แล้วตรวจ row เก่าแยกภายใต้ lock ที่อ่อนกว่ามาก ก่อนอื่น เมื่อมี row เสียอยู่ (qty = -3):
-- sql/not-valid.sqlCREATE TABLE stock (id int PRIMARY KEY, qty int);INSERT INTO stock VALUES (1, 5), (2, -3);ALTER TABLE stock ADD CONSTRAINT stock_qty_nonneg CHECK (qty >= 0) NOT VALID;SELECT conname, convalidated FROM pg_constraint WHERE conname = 'stock_qty_nonneg';CREATE TABLEINSERT 0 2ALTER TABLE conname | convalidated------------------+-------------- stock_qty_nonneg | f(1 row)convalidated = f หมายความว่า row เก่ายังไม่ถูกตรวจ แต่ row ใหม่ถูกตรวจ:
-- @expect-error 23514INSERT INTO stock VALUES (3, -1);ERROR: new row for relation "stock" violates check constraint "stock_qty_nonneg"DETAIL: Failing row contains (3, -1).VALIDATE CONSTRAINT ปฏิเสธขณะมี row เสียอยู่:
-- @expect-error 23514ALTER TABLE stock VALIDATE CONSTRAINT stock_qty_nonneg;ERROR: check constraint "stock_qty_nonneg" of relation "stock" is violated by some rowแก้ข้อมูลแล้วค่อย validate ดู lock: ShareUpdateExclusiveLock ไม่บล็อกการอ่านหรือเขียน ต่างจาก AccessExclusiveLock ของ ADD CONSTRAINT ธรรมดา:
-- sql/validate.sqlUPDATE stock SET qty = 0 WHERE qty < 0;BEGIN;ALTER TABLE stock VALIDATE CONSTRAINT stock_qty_nonneg;SELECT mode FROM pg_locks WHERE relation = 'stock'::regclass AND pid = pg_backend_pid() AND mode LIKE '%Exclusive%' ORDER BY mode;COMMIT;SELECT conname, convalidated FROM pg_constraint WHERE conname = 'stock_qty_nonneg';UPDATE 1BEGINALTER TABLE mode-------------------------- ShareUpdateExclusiveLock(1 row)
COMMIT conname | convalidated------------------+-------------- stock_qty_nonneg | t(1 row)การเปลี่ยนชื่อ
หัวข้อที่มีชื่อว่า “การเปลี่ยนชื่อ”RENAME เปลี่ยนชื่อ table หรือ column เดียวโดยไม่แตะข้อมูล มันทันที แต่ไม่ใช่ทุกอย่างที่ตามการเปลี่ยนชื่อ view ถูกเก็บตามตัวตนของ object จึงตามไป (ด้านล่าง view ตอนนี้อ่านว่า product_code AS sku FROM catalog_items) ส่วน body ของฟังก์ชันเป็นแค่ข้อความและไม่ตาม:
-- sql/rename.sqlCREATE VIEW product_skus AS SELECT id, sku FROM products;CREATE FUNCTION sku_of(pid bigint) RETURNS text LANGUAGE sql AS $$ SELECT sku FROM products WHERE id = pid $$;ALTER TABLE products RENAME COLUMN sku TO product_code;ALTER TABLE products RENAME TO catalog_items;SELECT pg_get_viewdef('product_skus'::regclass) AS view_follows_the_rename;CREATE VIEWCREATE FUNCTIONALTER TABLEALTER TABLE view_follows_the_rename------------------------- SELECT id, + product_code AS sku+ FROM catalog_items;(1 row)-- @expect-error 42P01SELECT sku_of(1);ERROR: relation "products" does not existLINE 1: SELECT sku FROM products WHERE id = pid ^QUERY: SELECT sku FROM products WHERE id = pidCONTEXT: SQL function "sku_of" during inliningquery ของแอปพลิเคชัน รายงานที่บันทึกไว้ และ body ของฟังก์ชันต้องอัปเดตด้วยมือ คืนชื่อกลับสำหรับส่วนที่เหลือของบทเรียน:
-- sql/rename-back.sqlALTER TABLE catalog_items RENAME TO products;ALTER TABLE products RENAME COLUMN product_code TO sku;DROP VIEW product_skus;DROP FUNCTION sku_of(bigint);Migration ที่ปลอดภัย: กับดัก NOT NULL
หัวข้อที่มีชื่อว่า “Migration ที่ปลอดภัย: กับดัก NOT NULL”อุบัติเหตุ migration ที่พบบ่อยที่สุดคือการเพิ่ม column NOT NULL ให้ table ที่มี row อยู่แล้ว row เหล่านั้นไม่มีค่าให้ column ใหม่ constraint จึงถูกฝ่าฝืนทันทีและ statement ล้มเหลว
-- @expect-error 23502ALTER TABLE products ADD COLUMN currency text NOT NULL;ERROR: column "currency" of relation "products" contains null valuesมีสองทางที่ปลอดภัย: ให้ column มี DEFAULT เพื่อเติมทุก row ที่มีอยู่ในคราวเดียว (แค่ metadata ถ้าเป็นค่าคงที่) หรือเพิ่ม column แบบ nullable, backfill ค่า แล้วเข้มงวดเป็น NOT NULL ทางที่สองเป็นตัวเลือกมาตรฐานเมื่อไม่มี default ที่สมเหตุสมผลเพียงค่าเดียว เพราะให้คุณคำนวณค่าของแต่ละ row ก่อนล็อก column (เมื่อมี CHECK (region IS NOT NULL) ที่ validate แล้ว SET NOT NULL ข้ามการสแกน table ได้ ดู docs)
-- sql/not-null-paths.sql-- Path 1: a default fills every existing row in one stepALTER TABLE products ADD COLUMN currency text NOT NULL DEFAULT 'USD';
-- Path 2: add nullable, backfill, then enforceALTER TABLE products ADD COLUMN region text;UPDATE products SET region = 'global' WHERE region IS NULL;ALTER TABLE products ALTER COLUMN region SET NOT NULL;SELECT currency, region, count(*) FROM products GROUP BY 1, 2;ALTER TABLEALTER TABLEUPDATE 1000ALTER TABLE currency | region | count----------+--------+------- USD | global | 1000(1 row)การรัน migration ใน transaction
หัวข้อที่มีชื่อว่า “การรัน migration ใน transaction”statement ALTER TABLE ส่วนใหญ่ใน PostgreSQL เป็น transactional (DDL เป็น transactional ที่นี่ ต่างจากฐานข้อมูลอื่นบางตัว) คุณจึงครอบ migration หลายขั้นด้วย transaction ให้ apply แบบทั้งหมดหรือไม่เลยได้ ถ้าขั้นใดล้มเหลว ROLLBACK ทำให้ table กลับเหมือนเดิม:
-- sql/transactional-ddl.sqlBEGIN;ALTER TABLE products ADD COLUMN tmp_flag boolean;ALTER TABLE products DROP COLUMN region, DROP COLUMN currency;SELECT count(*) AS columns_inside_tx FROM information_schema.columns WHERE table_name = 'products';ROLLBACK;SELECT count(*) AS columns_after_rollback FROM information_schema.columns WHERE table_name = 'products';BEGINALTER TABLEALTER TABLE columns_inside_tx------------------- 6(1 row)
ROLLBACK columns_after_rollback------------------------ 7(1 row)ภายใน transaction table มีรูปร่างต่างออกไป (เพิ่มหนึ่ง column ลบสอง column) หลัง ROLLBACK ก็เหมือนไม่มีอะไรเกิดขึ้น ตัว migration เอง:
-- sql/migration-in-tx.sqlBEGIN;ALTER TABLE products ADD COLUMN region2 text;UPDATE products SET region2 = 'global' WHERE region2 IS NULL;ALTER TABLE products ALTER COLUMN region2 SET NOT NULL;COMMIT;SELECT count(*) AS migrated FROM products WHERE region2 = 'global';BEGINALTER TABLEUPDATE 1000ALTER TABLECOMMIT migrated---------- 1000(1 row)นี่คือ migration เดียวกันจากแต่ละ driver แต่ละตัวรันหนึ่งครั้งกับฐานข้อมูลชั่วคราวที่มี table products 100 row:
BEGIN;ALTER TABLE products ADD COLUMN region text;UPDATE products SET region = 'global' WHERE region IS NULL;ALTER TABLE products ALTER COLUMN region SET NOT NULL;COMMIT;const client = await pool.connect();try { await client.query('BEGIN'); await client.query('ALTER TABLE products ADD COLUMN region text'); await client.query("UPDATE products SET region = 'global' WHERE region IS NULL"); await client.query('ALTER TABLE products ALTER COLUMN region SET NOT NULL'); await client.query('COMMIT');} catch (e) { await client.query('ROLLBACK'); throw e;} finally { client.release();}with conn.transaction(): cur.execute("ALTER TABLE products ADD COLUMN region text") cur.execute("UPDATE products SET region = 'global' WHERE region IS NULL") cur.execute("ALTER TABLE products ALTER COLUMN region SET NOT NULL")# commits if the block succeeds, rolls back on exceptiontx, err := conn.Begin(ctx)if err != nil { return err}steps := []string{ "ALTER TABLE products ADD COLUMN region text", "UPDATE products SET region = 'global' WHERE region IS NULL", "ALTER TABLE products ALTER COLUMN region SET NOT NULL",}for _, s := range steps { if _, err := tx.Exec(ctx, s); err != nil { tx.Rollback(ctx) return err }}err = tx.Commit(ctx)let mut tx = pool.begin().await?;sqlx::query("ALTER TABLE products ADD COLUMN region text") .execute(&mut *tx) .await?;sqlx::query("UPDATE products SET region = 'global' WHERE region IS NULL") .execute(&mut *tx) .await?;sqlx::query("ALTER TABLE products ALTER COLUMN region SET NOT NULL") .execute(&mut *tx) .await?;tx.commit().await?;$ node app.mjs # pg 8.23.0 (100 rows in products){ count: '100' }
$ python app.py # psycopg 3.3.6(100,)
$ go run . # pgx v5.11.0 (the snippet sits inside a func returning error)migrated: 100
$ cargo run # sqlx 0.9.0migrated: 100เพราะสามขั้นรันภายใน transaction เดียว session อื่นจะไม่เห็น table ในสภาพ migrate ไปครึ่งเดียว
Reading AI-generated code
หัวข้อที่มีชื่อว่า “Reading AI-generated code”ผู้ช่วย AI ถูกขอให้เพิ่ม public identifier ให้ table ใหญ่ มันรู้ว่า PostgreSQL 11+ เพิ่ม column ที่มี default “ทันที” เขียนไว้ในคอมเมนต์ และเขียนสิ่งนี้:
🐛 หา bug ในโค้ดนี้
-- migration 0042_add_public_id.sql-- AI comment: "PostgreSQL 11+ adds columns with defaults instantly, so this is safe on a big table"ALTER TABLE big ADD COLUMN public_id uuid NOT NULL DEFAULT gen_random_uuid();เฉลย
คอมเมนต์จริงครึ่งเดียว จึงน่าเชื่อ default ที่เป็น ค่าคงที่ ทันที ส่วน gen_random_uuid() เป็น volatile PostgreSQL จึงต้องคำนวณค่าต่างกันให้ทุก row ที่มีอยู่และ rewrite ทั้ง table ภายใต้ ACCESS EXCLUSIVE (matrix ข้างบน แถว gen_random_uuid()) บน table หนึ่งล้าน row วัดบน Mac เครื่องนี้:
-- sql/big-table.sqlCREATE TABLE big (id int, payload text);INSERT INTO big SELECT g, md5(g::text) FROM generate_series(1, 1000000) g;ANALYZE big;-- sql/big-add-uuid.sql\timing onALTER TABLE big ADD COLUMN flag int DEFAULT 0;ALTER TABLE big ADD COLUMN created timestamptz DEFAULT now();ALTER TABLE big ADD COLUMN public_id uuid NOT NULL DEFAULT gen_random_uuid();\timing offTiming is on.ALTER TABLETime: 276.200 msALTER TABLETime: 1.043 msALTER TABLETime: 1080.700 ms (00:01.081)Timing is off.column ที่ default เป็นค่าคงที่และ now() ใช้ 276 ms และ 1 ms (ตัวเลขแรกรวมการรอ lock บน table ที่เพิ่งโหลด น่าจะเป็น autovacuum/analyze เบื้องหลัง ซึ่งก็คือ lock queue ที่กล่าวถึงก่อนหน้า) ส่วน column ที่ default เป็น volatile ใช้ 1,081 ms ทั้งหมดขณะ table ถูกล็อกจากการอ่านและเขียน เมื่อคูณกับ table จริง (index, TOAST, replica ที่ replay การ rewrite) นั่นคือเหตุขัดข้อง สูตรที่ปลอดภัยคือแยกการเปลี่ยนแปลง: เพิ่ม column โดยไม่มี default (ทันที) ตั้ง default สำหรับ row อนาคต (ทันที แค่ metadata) แล้ว backfill row ที่มีอยู่เป็นชุด ๆ และค่อยบังคับ NOT NULL:
-- sql/big-add-uuid-safe.sql\timing onALTER TABLE big ADD COLUMN public_id2 uuid;ALTER TABLE big ALTER COLUMN public_id2 SET DEFAULT gen_random_uuid();UPDATE big SET public_id2 = gen_random_uuid() WHERE id BETWEEN 1 AND 100000;\timing offSELECT count(public_id2) AS backfilled_so_far FROM big;Timing is on.ALTER TABLETime: 2.091 msALTER TABLETime: 0.550 msUPDATE 100000Time: 233.839 msTiming is off. backfilled_so_far------------------- 100000(1 row)รัน UPDATE ซ้ำสำหรับช่วง id ถัดไป (หนึ่ง transaction ต่อชุดทำให้ lock สั้นและให้ autovacuum ตามทัน) จากนั้นเพิ่ม CHECK (public_id2 IS NOT NULL) NOT VALID, VALIDATE มัน และ SET NOT NULL
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุก ALTER TABLE มีสองต้นทุน: งานมากแค่ไหน (metadata อย่างเดียว สแกน table หรือ rewrite ทั้งหมด) และ lock แรงแค่ไหน นานเท่าไร (รวมเวลาที่ต่อคิว) การเปลี่ยน type หรือ default ที่ต้องการรูปร่าง row กายภาพใหม่จะ rewrite ส่วนการเปลี่ยนที่แค่เปลี่ยน catalog หรือแค่ต้องอ่าน row ไม่ rewrite migration ที่ปลอดภัยคืออันที่ทำให้ทั้งสองต้นทุนเล็ก: ใช้รูปแบบถูก รอ lock ด้วย timeout และแยกทุกอย่างที่แพงเป็นขั้นที่ lock อ่อน
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”ALTER TABLEถือ lock บน table และ lock queue เปลี่ยนการเปลี่ยนแปลงระดับมิลลิวินาทีให้เป็นเหตุขัดข้องได้ เริ่มทุก migration บน production ด้วยSET lock_timeoutและพร้อมลองใหม่- การเพิ่ม column ที่มี
DEFAULTเป็นค่าคงที่ (หรือnow()) เร็วใน PostgreSQL สมัยใหม่เพราะไม่ rewrite ทุก row ส่วน default แบบ volatile และการเปลี่ยน type ส่วนใหญ่ rewrite - ครอบ migration หลายขั้นด้วย transaction เพื่อให้ความล้มเหลวกลางทาง rollback อย่างสะอาดและไม่ทิ้ง schema ที่เปลี่ยนไปครึ่งเดียว
- เพิ่ม constraint เป็น
NOT VALIDและVALIDATE CONSTRAINTแยกบน table ใหญ่ การ validate ใช้ lock ที่ไม่บล็อกทราฟฟิกปกติ DROP COLUMNและDROP CONSTRAINTย้อนกลับไม่ได้เมื่อ commit แล้ว ยืนยันว่าไม่มีอะไรพึ่งพาสิ่งที่คุณกำลังลบ และพึ่ง transaction ที่ rollback ได้ระหว่างตรวจ- การเปลี่ยนชื่อไม่อัปเดต body ของฟังก์ชัน โค้ดแอปพลิเคชัน หรือรายงานที่บันทึกไว้
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
migration แบบออนไลน์/ไม่บล็อก (แยกขั้น, NOT VALID, เป็นชุด) | ปลอดภัยแม้ในเวลาทำการปกติ ไม่กระทบทราฟฟิก | ต้องมีหลายขั้นและวางแผนมากขึ้นเพื่อเขียน |
ALTER TABLE ตรง ๆ | เขียนและรันเร็ว โค้ดสั้น | อาจถือ lock เข้มที่บล็อกทราฟฟิก production บน table ใหญ่ |
เพิ่ม column ที่มี DEFAULT เป็นค่าคงที่ | แค่ metadata — ไม่ rewrite ทุก row จึงเร็วมาก | ต้องเป็นค่าคงที่หรือ stable เท่านั้น gen_random_uuid() rewrite (วัดข้างบน) |
เปลี่ยน type ของ column ด้วย ALTER COLUMN ... TYPE | ตรงไปตรงมา statement เดียว | บางการเปลี่ยน rewrite table (ดู matrix) บางอันไม่ |
lock_timeout บน migration | migration ไม่ทำให้ทราฟฟิกกองอยู่ข้างหลัง | อาจล้มเหลวและต้องลองใหม่ |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “
ALTER COLUMN TYPEทุกอันต้อง rewrite table” หลายอันไม่ (matrix ข้างบน) เช่นการขยายnumeric(10,2)เป็นnumeric(12,2)ไม่ rewrite - “
ALTERที่เสร็จทันทีทำร้าย production ไม่ได้” มันยังต้องได้ lock ก่อน เมื่อต่อคิวหลัง transaction ยาวตัวเดียว มันบล็อกSELECTธรรมดาไป 3 วินาที (ข้างบน) - “PostgreSQL 11+ ทำให้
ADD COLUMN ... DEFAULTทุกแบบทันที” เฉพาะ default ที่ไม่ volatile - “การเปลี่ยนชื่อปลอดภัยเพราะ view อัปเดตเอง” view ตามไป แต่ body ของฟังก์ชันและ query ของแอปไม่ตาม
- “
NOT NULLต้อง rewrite” ต้องสแกน ไม่ใช่ rewrite ถ้ามี CHECK ที่ validate แล้วก็ข้ามแม้แต่การสแกนได้