Animated Architecture Patterns

Expand and Contract

เปลี่ยน schema หรือ API ทีละขั้นแบบ backward-compatible: expand, migrate แล้วค่อย contract

หรือที่เรียกกันว่า Parallel Change

Changes, in order 1 database ALTER TABLE customers RENAME COLUMN phone TO phone_number; takes effect at once 2 application · same release deploy v2 reads and writes phone_number ✕ v1 is still running, and itasks for phone 1 database ALTER TABLE customers ADD COLUMN phone_number text; nullable: v1 just ignores it 2 application deploy v1.1 writes both columns, still reads phone v1 and v1.1 overlap for a whilenew rows now get both values 1 database · backfill UPDATE customers SET phone_number = phone WHERE id BETWEEN ? AND ?; small batches, with pauses 2 application deploy v1.2 reads phone_number, still writes both only once the columns agree 1 application deploy v2 reads and writes phone_number only now nothing uses phone 2 database · a later release ALTER TABLE customers DROP COLUMN phone; one-way: it cannot be undone Application replaced one instance at a time instance 1 instance 2 instance 3 phone phone phone_number not read, not written customers table id name 1Ana 2Raj 3Mei 4Omar 5Zoe no column named phone phone dropped phone_number NULLNULL NULLNULL 555-0105 555-0105 phone phone_number 555-0101 555-0102 555-0103 555-0104 batch 1 batch 2 555-0101 555-0102 555-0103 555-0104 v1 v1.1 v1.2 v2 write ↓ write ↓ write ↓ read ↑read ↑read ↑ ✓ queries succeed ✕ unknown column "phone" v1 v1.1 v1.2 v2 write ↓ write ↓ write ↓ read ↑read ↑read ↑ ✓ queries succeed ✕ unknown column "phone" v1 v1.1 v1.2 v2 write ↓ write ↓ write ↓ read ↑read ↑read ↑ ✓ queries succeed ✕ unknown column "phone" Safety check way back ✕ v1 fails on this schema ✕ redeploying v1 does nothelp: it still needs phone ✓ v1 and v1.1 both work ✓ redeploy v1 ✓ 5 of 5 rows agree ✓ switch reads back ✓ nothing uses phone none once it is dropped,so this step comes last IN ONE STEP rename and deploy together IN ONE STEP rename and deploy together 1 · EXPAND add the new beside the old 1 · EXPAND add the new beside the old 2 · MIGRATE backfill, compare, switch reads 2 · MIGRATE backfill, compare, switch reads 3 · CONTRACT stop using the old, then drop it 3 · CONTRACT stop using the old, then drop it not backward compatible backward compatible and reversible: the previous version keeps working destructive: last, and on its own

ขั้นที่ 1 · Rename รวดเดียวแล้วพัง Release หนึ่งเปลี่ยนชื่อ phone เป็น phone_number แล้ว ship v2 ที่ใช้ชื่อใหม่ การเปลี่ยนชื่อมีผลทันที แต่ instance ถูกเปลี่ยนทีละตัว instance ไหนที่ยังเป็น v1 ก็เลยไปขอ column ที่ไม่มีแล้ว query ก็ fail ด้วย error แบบ unknown column จะ rollback แอปพลิเคชันก็ไม่ช่วย: v1 ต้องใช้ column เก่า แต่ schema เปลี่ยนไปแล้ว อะไรก็ตามที่อ่านตารางนี้ก็เจอแบบเดียวกัน เช่น report, batch job และ service อื่น ๆ

ขั้นที่ 2 · Expand: เพิ่ม column ใหม่ เริ่มกันใหม่จากตารางตั้งต้น ขั้นแรกเพิ่ม phone_number เป็น column แบบ nullable ไว้ข้าง phone: เป็นการเปลี่ยนแบบเพิ่มเข้าไปที่ทำได้เร็ว v1 ก็แค่ไม่สนมัน จากนั้น roll out v1.1 ที่เขียนทุกค่าลง ทั้งสอง column แต่ยังอ่านจาก phone อยู่ v1 กับ v1.1 รันคู่กันบน schema นี้ได้ ถ้าจะถอยก็แค่ redeploy v1 ตอนนี้แถวใหม่มีค่าทั้งสอง column แล้ว ส่วนแถวเก่ายังเป็น NULL ใน column ใหม่อยู่

ขั้นที่ 3 · Migrate: ย้ายข้อมูลเก่า Backfill คัดลอก phone ไปใส่ phone_number ให้แถวที่มีอยู่แล้ว ทำทีละ batch เล็ก ๆ และเว้นช่วงพักระหว่าง batch จะได้ไม่มี statement ไหน lock ทุกแถวพร้อมกัน พอเทียบแล้วเห็นว่าสอง column ตรงกันทุกแถว v1.2 ก็สลับไปอ่านจาก phone_number (ด้วย release เล็ก ๆ หรือจะให้ดีกว่าก็ใช้ feature flag) แต่ยังเขียนลงทั้งสองที่ และเพราะ column เก่ายังได้ค่าล่าสุดอยู่ตลอด จะถอยกลับก็แค่สลับการอ่านกลับไป

ขั้นที่ 4 · Contract: เก็บของเก่าทิ้ง พอไม่มีอะไรอ่าน phone แล้ว v2 ก็เลิกเขียนลงไป และพอไม่มีอะไรทั้งอ่านทั้งเขียนแล้ว release หลังจากนั้นก็ drop column นั้นทิ้ง ทุกขั้นก่อนหน้านี้ยังทำให้เวอร์ชันก่อนหน้าทำงานได้ และย้อนกลับได้ทุกขั้น การ drop เป็นขั้นเดียวที่ทำลายข้อมูล เลยต้องมาเป็นขั้นสุดท้ายและทำแยกไว้ต่างหาก v2 ก็คือเวอร์ชันเดียวกับที่พังในขั้นที่ 1: ปลายทางยังเหมือนเดิม ที่เปลี่ยนคือเส้นทางที่ไปถึง

ปัญหา

โค้ดกับรูปแบบข้อมูลที่โค้ดพึ่งพาไม่เคยถูกเปลี่ยนในวินาทีเดียวกัน ระหว่างทำ rolling update instance เก่ากับใหม่รับงานอยู่คู่กัน ส่วน canary release ก็เปิดทั้งสองเวอร์ชันไว้ตลอดช่วงที่ยังวิเคราะห์ผลอยู่ และ blue-green deployment ก็เปิด environment เก่าค้างไว้ก็เพื่อให้ส่ง traffic กลับไปได้นั่นเอง บางตัวที่อ่านข้อมูลก็ไม่ได้อยู่ใน deployment ของเราเลยด้วยซ้ำ เช่น job ทำ report, service ของทีมอื่นที่ใช้ตารางเดียวกัน หรือแอปมือถือที่ลงไว้ตั้งหลายเดือนแล้วแต่ยังเรียก API อยู่

การเปลี่ยนที่มีแต่โค้ดใหม่เข้าใจก็เลยทำให้อะไรก็ตามที่ยังรันโค้ดเก่าพังทันทีที่เปลี่ยน การเปลี่ยนชื่อ phone เป็น phone_number ใน release เดียวคือตัวอย่างที่เล็กที่สุด การเปลี่ยนชื่อมีผลทันที แต่การ roll out ไม่ได้เสร็จทันที ตลอดช่วงที่ roll out อยู่ instance เก่าทุกตัวก็เลย fail ด้วย error แบบ unknown column แล้วการเปลี่ยนนี้ยังปิดทางถอยด้วย: ถ้า rollback แอปพลิเคชัน โค้ดที่ต้องใช้ phone ก็จะเจอตารางที่ไม่มี column นี้แล้ว ส่วนการเปลี่ยนชื่อ column กลับก็เป็นการเปลี่ยนที่ไม่ compatible อีกครั้ง คราวนี้ instance ใหม่จะพังแทน

ทางออกแบบดั้งเดิมคือ maintenance window: หยุดทุกอย่าง เปลี่ยน schema แล้วเปิดเวอร์ชันใหม่ Expand and contract คือสิ่งที่เราทำเมื่อการหยุดระบบเป็นเรื่องที่รับไม่ได้

ทำงานยัง⁠ไง

อย่าเปลี่ยนโครงสร้างที่ใช้ร่วมกันแบบแก้ทับที่เดิม ให้วางรูปแบบใหม่ไว้ข้าง ๆ รูปแบบเก่า ย้ายทุกคนข้ามไป แล้วค่อยลบรูปแบบเก่าทิ้งเมื่อไม่มีใครใช้แล้ว Danilo Sato อธิบายเทคนิคนี้ไว้ในปี 2014 ในชื่อ parallel change หรือที่เรียกกันว่า expand and contract โดยมีสามเฟส:

  1. Expand เพิ่มโครงสร้างใหม่ไว้ข้างโครงสร้างเก่า การเพิ่มเข้าไปเป็นเรื่องปลอดภัย เพราะโค้ดที่ไม่รู้จัก column หรือ field ใหม่ก็แค่ไม่สนมัน จากนั้นให้แอปพลิเคชันเขียนลงทั้งสองที่
  2. Migrate ทำให้โครงสร้างใหม่มีข้อมูลครบ ด้วยการ backfill ข้อมูลที่เขียนไว้ก่อนเริ่ม dual write แล้วเช็กว่าของเก่ากับของใหม่ตรงกัน จากนั้นย้ายฝั่งที่อ่านข้ามไป ตอนนี้โครงสร้างเก่ายังถูกเขียนอยู่ มันเลยยังถูกต้อง และยังสลับกลับได้
  3. Contract พอไม่มีอะไรอ่านโครงสร้างเก่าแล้ว ก็เลิกเขียนลงไป พอไม่มีอะไรเขียนด้วยแล้ว ก็ลบมันทิ้ง

Sato บอกว่า database refactoring ส่วนใหญ่ก็ทำตาม pattern นี้ โดยเฟส migrate คือช่วงเปลี่ยนผ่านระหว่าง schema เดิมกับ schema ใหม่ Pramod Sadalage กับ Martin Fowler เรียกช่วงนี้ว่า transition phase ใน Evolutionary Database Design: คือช่วงที่ database รองรับทั้งวิธีเข้าถึงแบบเก่าและแบบใหม่ไปพร้อมกัน ส่วน Refactoring Databases ของ Scott Ambler กับ Pramod Sadalage รวบรวมการเปลี่ยนแต่ละแบบไว้เป็น catalog และ Rename Column ก็เป็นหนึ่งในนั้น

กฎที่อยู่เบื้องหลังเฟสเหล่านี้คือ ทุกขณะ schema ต้องใช้ได้ทั้งกับเวอร์ชันของแอปพลิเคชันที่กำลัง roll out และกับเวอร์ชันก่อนหน้า ทั้งสองเวอร์ชันรันอยู่ระหว่าง roll out ส่วนเวอร์ชันก่อนหน้าก็คือตัวที่ rollback จะพากลับมา การเปลี่ยนชื่อทำตามกฎนี้ในขั้นเดียวไม่ได้ แต่ขั้นเล็ก ๆ แต่ละขั้นต่อไปนี้ทำได้:

การเปลี่ยน Column ในตาราง อ่าน เขียน ทางถอย
เริ่มต้น: v1 phone phone phone
1. เพิ่ม column phone, phone_number (nullable) phone phone drop column ใหม่ทิ้ง
2. deploy v1.1 ทั้งสอง phone ทั้งสอง redeploy v1
3. backfill แล้วเทียบ ทั้งสอง ตอนนี้ค่าตรงกันแล้ว phone ทั้งสอง ไม่มีอะไรต้องย้อน
4. deploy v1.2 หรือสลับ flag ทั้งสอง phone_number ทั้งสอง สลับการอ่านกลับ
5. deploy v2 ทั้งสอง phone_number phone_number redeploy v1.2 ถอยได้แค่นี้
6. drop column phone_number phone_number phone_number ไม่มี

จากกฎนี้ได้ลำดับออกมาสองข้อ: เพิ่มก่อนใช้ และเลิกใช้ก่อนลบ ในเฟส expand ตัว schema เปลี่ยนก่อนแล้วโค้ดตามไป ส่วนในเฟส contract โค้ดเปลี่ยนก่อนแล้ว schema ค่อยตามไป การเปลี่ยนที่ 1 ถึง 5 ย้อนกลับได้ ส่วนการเปลี่ยนที่ 6 ทำลายข้อมูลและย้อนไม่ได้ เพราะแบบนี้มันถึงมาเป็นขั้นสุดท้าย ใน release ของมันเอง หลังจากเส้นทางใหม่รันบน production ไปสักพัก ตัว v2 ที่ ship ออกไปในที่สุดก็คือเวอร์ชันเดียวกับที่พังตอนลองเปลี่ยนในขั้นเดียว: ปลายทางไม่ได้เปลี่ยน ที่เปลี่ยนคือเส้นทางที่ไปถึง

ลำดับเดียวกันนี้ใช้ได้กับทุกการเปลี่ยนที่ไม่ใช่แค่การเพิ่มเข้าไปล้วน ๆ: เปลี่ยน type ของ column, แยก column หรือตารางเดียวเป็นสอง, รวมสองอันเป็นหนึ่ง หรือย้ายข้อมูลไปตารางอื่นหรือ store อื่น ที่ต่างกันมีแค่การแปลงข้อมูลข้างใน dual write กับ backfill

สามเฟสเดียวกันสำหรับ API และ event

field ใน response หรือ schema ของ event คือสัญญากับ consumer ที่เราอาจคุมไม่ได้ ช่วงที่ซ้อนกันเลยนานกว่า แต่รูปแบบการเปลี่ยนก็เหมือนเดิม

  • API Expand: เพิ่ม field, endpoint หรือเวอร์ชันใหม่ แล้วยังให้บริการตัวเก่าต่อไป client ที่ไม่สน field ที่ตัวเองไม่รู้จักจะไม่ได้รับผลกระทบ Migrate: ย้าย consumer ข้ามไป แล้ววัดว่าใครยังใช้ตัวเก่าอยู่ ถ้าสิ่งที่จะเลิกใช้เป็นทั้ง resource เช่น endpoint เก่าหรือ API เวอร์ชันเก่า ก็มีสอง RFC ที่กำหนด response header ไว้สำหรับขั้นนี้ header Deprecation (RFC 9745 อยู่ใน standards track ตั้งแต่มีนาคม 2025) บอกว่า resource นี้ deprecated แล้ว หรือจะ deprecated ตั้งแต่วันที่กำหนด ส่วน header Sunset (RFC 8594 แบบ informational ปี 2019) บอกเวลาที่คาดว่าหลังจากนั้นมันจะหยุดตอบ แล้ว header Link ที่มี rel="deprecation" หรือ rel="sunset" ก็ชี้ไปที่เอกสาร ส่วน header ทั้งสองตัวอธิบายตัว resource ไม่ใช่ field ใด field หนึ่งใน response ของมัน ถ้าเป็น field เดียวก็เลยไปติดป้ายไว้ใน API description แทน Contract: พอเลยวันที่กำหนดและไม่มี traffic แล้ว ก็ลบตัวเก่าทิ้ง
  • Event และ message producer กับ consumer deploy แยกกัน และ event เก่ายังค้างอยู่ใน log ตัว consumer ใหม่เลยต้องอ่าน event เก่าได้ ส่วน consumer เก่าก็ต้องรอดจาก event ใหม่ได้ Expand: เพิ่ม field ใหม่แบบ optional พร้อมค่า default แล้ว publish ทั้งสองแบบ Migrate: consumer ย้ายไปใช้ field ใหม่ Contract: เลิก publish ตัวเก่า ถ้าสิ่งที่เปลี่ยนคือความหมายของ event ไม่ใช่ field ของมัน ก็ publish event type ใหม่ไว้ข้าง ๆ ตัวเก่า แล้วค่อยเลิกใช้ตัวเก่าด้วยวิธีเดียวกัน

บน endpoint ที่อยู่ในเฟส migrate การประกาศนี้จะติดไปกับทุก response ค่าของ Deprecation คือ Unix timestamp ที่เขียนเป็น date แบบ structured field ส่วนค่าของ Sunset คือ HTTP date ที่ห้ามเร็วกว่านั้น และ link ก็พาไปที่บันทึกวิธี migrate:

Deprecation: @1798761600
Sunset: Thu, 01 Jul 2027 00:00:00 GMT
Link: <https://api.example.com/docs/customers-v1-retirement>; rel="deprecation"; type="text/html"

header ทั้งสองตัวเป็นแค่คำใบ้สำหรับคนที่ดูแล client: ตัว resource ยังทำงานเหมือนเดิมจนกว่าจะถูกลบออกจริง ๆ

แนวคิดนี้ขยายขึ้นหรือย่อลงก็ได้ การ migrate แบบ strangler fig ใช้มันกับทั้งระบบ (service ใหม่อยู่ข้าง monolith ย้าย route ไปทีละเส้น แล้วปลด monolith เป็นอย่างสุดท้าย) ส่วน branch by abstraction ใช้มันกับ implementation ภายใน codebase เดียว และ parallel run ก็คือขั้น "เทียบ" ที่โตขึ้นจนเป็น pattern ของตัวเอง Sato นับ canary release กับ blue-green deployment ว่าเป็นการใช้ parallel change ด้วย: สองเวอร์ชันอยู่คู่กัน ย้าย traffic ข้ามไป แล้วเอาตัวเก่าออก ส่วน anti-corruption layer แปลระหว่างสอง model ที่จะอยู่ต่อทั้งคู่ ขณะที่ expand and contract ใช้ตอนที่ตัวเก่าควรหายไป

ใช้ตอนไหนดี

  • เปลี่ยน schema ภายใต้ deployment แบบ zero-downtime ถ้าใช้ rolling, blue-green หรือ canary deployment อยู่ ทุกการเปลี่ยนที่ไม่ใช่แค่เพิ่มเข้าไปล้วน ๆ ต้องใช้วิธีนี้: เปลี่ยนชื่อ เปลี่ยน type แยกและรวม รวมถึงการทำ constraint ให้เข้มขึ้น เช่น NOT NULL หรือ uniqueness
  • database ที่หลายแอปพลิเคชันใช้ร่วมกัน โดยแต่ละตัวมีรอบ release ของตัวเอง รวมถึง report และ batch job ที่อ่านตารางตรง ๆ
  • สัญญาแบบ public และข้ามทีม: field ของ REST และ GraphQL, schema ของ message และ event, รูปแบบ configuration และ interface ของ library ที่ไหนก็ตามที่ consumer อัปเกรดตามรอบของตัวเอง เฟส migrate คือสิ่งที่ให้เวลาเขาทำแบบนั้น
  • ย้ายข้อมูลระหว่างตารางหรือ store เรื่องที่ Stripe เล่าตอนย้าย subscription ไปไว้ในตารางของมันเอง ก็ใช้สี่ท่าเดียวกัน: เขียนลงทั้งสองที่ สลับการอ่าน สลับการเขียน แล้วลบข้อมูลเก่าทิ้ง

ตอนที่ใช้แล้วเกินจำเป็น ถ้ามี instance เดียวและรับ maintenance window ได้ ก็หยุดแอปพลิเคชัน รัน migration แล้วเปิดเวอร์ชันใหม่: ช่วงซ้อนกันที่ pattern นี้จัดการจะไม่เกิดขึ้นเลย การเปลี่ยนที่เป็นการเพิ่มเข้าไปล้วน ๆ (column ใหม่แบบ nullable, field ใหม่แบบ optional, endpoint ใหม่) ก็คือ expand ที่ไม่มีอะไรต้อง contract และโครงสร้างที่ยังไม่มีอะไรพึ่งพา เช่น ตารางที่สร้างไว้ให้ feature ที่ยังไม่ launch ก็เปลี่ยนไปได้เลย

ได้อะไร เสียอะไร

  • release มากขึ้นและใช้เวลาตามปฏิทินนานขึ้น การเปลี่ยนชื่อครั้งเดียวกลายเป็นหกการเปลี่ยน แต่ละอันต้องไปถึงทุก instance ก่อนจะเริ่มอันถัดไป ถ้าเป็น service เดียวก็ใช้ไม่กี่วัน ถ้ามี consumer ของทีมอื่น ช่วงเปลี่ยนผ่านก็นานเท่ากับที่ตัวที่ช้าที่สุดต้องใช้: Sadalage กับ Fowler บอกว่าบางองค์กรใช้สองสามเดือน บางองค์กรใช้เป็นปี การเปลี่ยนนี้เลยต้องมีเจ้าของไปตลอดช่วงนั้น
  • ของซ้ำชั่วคราว สอง column เก็บค่าเดียวกัน การเขียนทุกครั้งเป็นสองเท่า และโค้ดก็ต้องรู้จักทั้งสองตัว โค้ด dual write คือที่ที่ bug ซ่อนอยู่: เส้นทางเขียนเส้นเดียวที่หลุดไปก็ทำให้ column ไม่ตรงกัน เพราะแบบนี้การเทียบถึงเป็นขั้นหนึ่งที่ต้องทำ ไม่ใช่ตัวเลือก
  • มีสองตัวที่อาจเป็นความจริง ตราบที่ยังมีทั้งสอง column ต้องบอกให้ชัดว่าตัวไหนคือตัวหลัก: column เก่าจนกว่าจะสลับการอ่าน แล้วหลังจากนั้นคือ column ใหม่ อีกตัวเป็นสำเนาที่มีแค่ dual write เท่านั้นที่แก้ได้
  • ต้นทุนของการไม่ contract เฟส contract ไม่ได้เพิ่ม feature อะไร มันเลยเป็นเฟสที่โดนข้าม สิ่งที่ค้างอยู่คือ column ที่ไม่มีใครกล้า drop, โค้ด dual write ที่ทุก feature ใหม่ต้องระวัง และคนมาใหม่ที่บอกไม่ได้ว่า column ไหนคือตัวจริง Sato เตือนไว้ว่าการหยุดกลางทางทำให้ระบบแย่กว่าไม่เริ่มเลย ให้ตั้งงบสำหรับ contract ไว้ตั้งแต่ตอนวางแผน expand
  • ขั้นสุดท้ายเป็นทางเดียว ทุกอย่างก่อน drop ย้อนได้ด้วยการ redeploy หรือสลับการอ่านกลับ หลัง drop แล้ว column เก่าจะกลับมาได้ก็ต้องทำ expand กับ backfill ใหม่ หรือกู้จาก backup เท่านั้น นี่เป็นเหตุผลให้ทำขั้นนี้อย่างตั้งใจ ไม่ใช่เหตุผลให้เลี่ยงมัน
  • มันไม่ได้กำจัด lock ทุกตัว pattern นี้ทำให้แต่ละขั้น compatible กับโค้ดที่รันอยู่ แต่ขั้นนั้นจะเบาแค่ไหนบนตารางที่มีพันล้านแถว ขึ้นอยู่กับ database และนั่นคือเรื่องในหัวข้อข้างล่าง

ข้อควรรู้ตอนลงมือทำ

  • เขียนโค้ดที่ทนต่อการเพิ่มเข้ามา เฟส expand ปลอดภัยแค่กับโค้ดที่ระบุชื่อสิ่งที่ตัวเองใช้ SELECT * ที่ map ตามตำแหน่ง หรือ INSERT ที่ไม่มีรายชื่อ column จะพังเมื่อมี column โผล่มา และ API client ที่ปฏิเสธ field ที่ไม่รู้จักก็จะพังเมื่อมี field โผล่มา
  • เพิ่ม column โดยไม่ lock นาน PostgreSQL เพิ่ม column ได้โดยไม่ต้องเขียนตารางใหม่ ถ้า column นั้นไม่มีค่า default หรือมี default ที่ไม่ volatile ส่วน default แบบ volatile (UUID แบบสุ่ม, clock_timestamp()), stored generated column หรือ identity column จะทำให้มันเขียนตารางและ index ใหม่ทั้งหมด InnoDB ของ MySQL เพิ่มและ drop column ด้วย ALGORITHM=INSTANT เป็นค่า default ตั้งแต่ 8.0.29 ขึ้นไป แต่ทำแบบนี้ได้จำนวนจำกัดต่อตาราง ก่อนจะต้อง rebuild การเปลี่ยนที่แตะแค่ metadata ก็ยังต้องใช้ exclusive lock สั้น ๆ และใน PostgreSQL ถ้า ALTER TABLE ต้องรอ lock นี้อยู่หลัง transaction ที่ยาว ก็จะทำให้ทุก query ที่ตามมาบนตารางนั้นต้องรอไปด้วย เพราะฉะนั้นให้รัน migration ด้วย lock_timeout สั้น ๆ แล้ว retry
  • ใส่ constraint และ index ให้ column ใหม่ก่อนย้ายการอ่าน ใน PostgreSQL ตัว CREATE INDEX CONCURRENTLY สร้าง index ได้โดยไม่ block การเขียน มันสแกนตารางสองรอบ รันใน transaction block ไม่ได้ และถ้าล้มเหลวก็จะเหลือ index ที่ invalid ไว้ให้ drop แล้วลองใหม่ จากนั้น constraint แบบ unique หรือ primary key ก็รับ index นั้นไปใช้ได้ (ADD CONSTRAINT ... USING INDEX) ส่วน constraint แบบ foreign key กับ CHECK และตั้งแต่ PostgreSQL 18 ก็รวม NOT NULL ด้วย เพิ่มเป็น NOT VALID ได้ แบบนี้จะข้ามการสแกนแถวที่มีอยู่ แล้วค่อย validate ทีหลังภายใต้ lock ที่ไม่ block การเขียน ในเวอร์ชันก่อนหน้า CHECK (phone_number IS NOT NULL) ที่ validate แล้วจะทำให้ SET NOT NULL ข้ามการสแกนได้
  • Backfill เป็น batch และคุมจังหวะ ไล่ primary key เป็นช่วงละไม่กี่พันแถว ช่วงละหนึ่ง transaction สั้น ๆ แล้วพักระหว่าง batch และพักนานขึ้นเมื่อ replication lag เพิ่ม UPDATE ก้อนใหญ่ก้อนเดียวจะถือ row lock ไว้จนกว่าจะ commit, replicate เป็น transaction เดียว และใน PostgreSQL ก็ทิ้ง version ที่ตายแล้วของทุกแถวไว้ให้ vacuum ตามเก็บ ทำ statement ให้ idempotent เพื่อให้ job เริ่มใหม่ได้ (... WHERE phone_number IS DISTINCT FROM phone) และให้ database ก็อปค่าภายใน statement เดียว batch จะได้ไม่ไปเขียนทับค่าที่ใหม่กว่าที่แอปพลิเคชันเขียนไว้ เริ่ม backfill ก็ต่อเมื่อทุกตัวที่เขียนรักษาให้ทั้งสอง column ตรงกันแล้ว
  • Dual write: ในแอปพลิเคชันหรือใน trigger โค้ดแอปพลิเคชันที่เขียนทั้งสอง column ใน statement เดียวชัดเจน test ได้ และลบออกได้ด้วยการแก้โค้ด แต่มันครอบคลุมแค่ตัวที่เขียนที่รันโค้ดนั้น: instance เก่าระหว่าง roll out, service อื่น, script และ bulk load ยังเขียนแค่ column เดียว ส่วน database trigger ครอบคลุมทุกตัวที่เขียนตั้งแต่วินาทีที่ column มีอยู่ ใน transaction เดียวกัน แลกกับ logic ที่ซ่อนอยู่ใน database, งานเพิ่มในการเขียนทุกครั้ง และอีกหนึ่งอย่างที่ต้องลบในเฟส contract ถ้ามีหลายตัวที่เขียนที่เราคุมไม่ได้ ให้เลือก trigger นอกจากนี้ database ยังช่วยรักษาชื่อเก่าไว้ให้ตัวที่อ่านที่ยังเปลี่ยนไม่ได้ด้วย: ตัวอย่างของ Sadalage กับ Fowler เปลี่ยนชื่อตาราง แล้วทิ้ง view ไว้ในชื่อเก่า
  • เครื่องมือ online schema change ถ้า engine เปลี่ยนแบบแก้ทับที่เดิมให้ถูก ๆ ไม่ได้ เครื่องมืออย่าง gh-ost และ pt-online-schema-change (MySQL) จะสร้างสำเนาของตารางด้วย schema ใหม่ ก็อปแถวไปทีละ chunk คอยทำให้สำเนาเป็นปัจจุบัน (gh-ost อ่านจาก binary log ส่วน pt-online-schema-change ใช้ trigger) แล้วสลับสองตารางตอนท้าย โดยคุมจังหวะตาม load และ replication lag มันทำให้ขั้นหนึ่งปลอดภัยสำหรับตารางใหญ่ได้ แต่ไม่ได้ทำให้การเปลี่ยนที่ไม่ compatible กลายเป็น compatible: หลังเปลี่ยนชื่อด้วยการสลับตาราง โค้ดเก่าก็ยัง fail อยู่ดี ส่วน pgroll (PostgreSQL 14 ขึ้นไป) ทำให้ทั้ง pattern เป็นอัตโนมัติแทน การเริ่ม migration จะเพิ่ม column ใหม่ ติดตั้ง trigger ที่ก็อปการเขียนระหว่างเก่ากับใหม่ และ backfill เป็น batch ตัว schema แต่ละเวอร์ชันจะถูกเปิดออกมาเป็นชุดของ view ที่แอปพลิเคชันเลือกได้ด้วย search_path ของตัวเอง แล้วทีหลังก็ complete หรือ rollback migration ได้ด้วยคำสั่งเดียว
  • ตรวจก่อนสลับการอ่าน นับแถวที่สอง column ไม่ตรงกัน (ใน PostgreSQL คือ SELECT count(*) FROM customers WHERE phone_number IS DISTINCT FROM phone) และต้องได้ศูนย์ซ้ำหลายรอบ ขณะที่การเขียนยังเดินอยู่ ถ้าซับซ้อนกว่าการก็อป ก็ให้เทียบตอน runtime ด้วย: อ่านทั้งสองตัว ส่งค่าเก่ากลับไป แล้ว log ทุกครั้งที่ไม่ตรง Stripe ทำแบบนี้ด้วย library Scientist ของ GitHub
  • สลับการอ่านด้วย flag feature flag ย้ายการอ่านไป 1% ของ request แล้วค่อยย้ายทั้งหมด และย้ายกลับได้ในไม่กี่วินาทีโดยไม่ต้อง deploy มันใช้ได้แค่ตอนที่ dual write ยังเปิดอยู่ เพราะฉะนั้นให้เอา flag ออกก่อนเริ่มเฟส contract ไม่ใช่หลังจากนั้น
  • Contract เป็นขั้นแยกที่ตรวจแล้ว ก่อนที่ v2 จะเลิกเขียน column เก่า ต้องแน่ใจว่า column นั้นยอมให้ไม่มีค่าได้: drop constraint NOT NULL ของมัน หรือใส่ค่า default ให้ ส่วนก่อน drop ต้องพิสูจน์ว่าไม่มีอะไรใช้มันแล้ว: ค้นในโค้ด เช็ก query log และอย่าลืมตัวที่อ่านที่อยู่นอกแอปพลิเคชัน ORM ที่ระบุทุก column ด้วยชื่อต้องได้รับแจ้งก่อน อย่าง Rails ก็มี ignored_columns ไว้สำหรับเรื่องนี้โดยเฉพาะ การซ้อมที่ย้อนได้ เช่น เปลี่ยนชื่อ column ทิ้งไว้สักสองสามวัน จะช่วยหาตัวที่อ่านที่ทุกคนลืมไป ใน PostgreSQL ตัวการ drop เองเร็ว: column จะมองไม่เห็น แล้วพื้นที่ของมันจะถูกคืนเมื่อแถวถูกเขียนใหม่
  • ติดป้ายไว้ใน API description แล้วนับ OpenAPI ติดป้าย operation หรือ parameter ด้วย deprecated: true ส่วน GraphQL ติดป้าย field ด้วย directive @deprecated แล้วก็นับ request ของแต่ละ client สำหรับอะไรก็ตามที่กำลังจะเลิกใช้: เฟส contract เริ่มเมื่อตัวเลขนั้นเป็นศูนย์ หรือเมื่อเลยวัน sunset ที่ประกาศไว้
  • ให้เครื่องมือบังคับเรื่อง compatibility ของ event schema registry ปฏิเสธ schema ที่ไม่ compatible ได้ตั้งแต่ตอน register: Confluent Schema Registry เช็กแต่ละเวอร์ชันใหม่กับ compatibility mode ที่ตั้งไว้ ค่า default คือ BACKWARD (consumer ที่ใช้ schema ใหม่อ่านข้อมูลที่เขียนด้วย schema ก่อนหน้าได้) ใน Protocol Buffers หมายเลขของ field ที่ลบไปแล้วห้ามเอากลับมาใช้ใหม่ และ reserved ทำให้ compiler บังคับเรื่องนี้ให้