คืนที่ผมลบฟีเจอร์ตัวเองทิ้งโดยไม่ตั้งใจ แล้วต้องรื้อฐานข้อมูลทั้งระบบใหม่เพื่อรองรับคนแปลกหน้า

U-PRO Build Journal — ตอนที่ 1

นี่คือตอนแรกของซีรีส์ที่เล่าเรื่องการสร้าง U-PRO ระบบบริหารจัดการหน้าร้านสำหรับอู่ซ่อมรถ สร้างคนเดียว เขียนจากประวัติ commit จริง — ทั้งการตัดสินใจ ความผิดพลาด และราคาที่ต้องจ่าย เริ่มต้นในคืนที่ฐานข้อมูลทั้งระบบต้องเปลี่ยนโครงสร้างใหม่หมด

มีความตื่นตระหนกแบบหนึ่งที่เกิดขึ้นตอนรู้ตัวว่าเพิ่งลบโค้ด 835 บรรทัดทิ้งไปเฉยๆ — โค้ดที่เขียนเองกับมือ ตั้งใจเขียนเมื่อครึ่งชั่วโมงก่อนหน้านั้นเอง — และไม่เข้าใจด้วยซ้ำว่ามันเกิดขึ้นได้ยังไง จนกว่าจะได้นั่งไล่ดู diff

นั่นคือคืนวันอังคารกลางเดือนกรกฎาคม ตอนนั้นผมกำลังสร้าง U-PRO ระบบบริหารจัดการหน้าร้านสำหรับอู่ซ่อมรถ มาได้สามสัปดาห์ คนเดียวล้วนๆ ไม่มีผู้ร่วมก่อตั้ง ไม่มีทีมวิศวกร มีแค่ผมกับกองการตัดสินใจที่ไม่มีใครมาช่วยตรวจทานให้ ผมเพิ่ง merge การแก้ไข navigation เล็กๆ อย่างปุ่มลูกศรในหน้าดูรูปเข้ามา แต่ระหว่างนั้นเอง หน้าแก้ไขข้อมูลรถดันไปหยิบโค้ดจาก branch ระบบยืนยันตัวตนที่ยังทำไม่เสร็จติดมาด้วย หน้านั้นพังแบบมองด้วยตาเปล่าไม่รู้เลย มันแค่มีโค้ดคนละเวอร์ชันซ้อนทับอยู่ข้างใต้

ผมแก้มัน แล้วแก้อีกรอบเพราะรอบแรกไม่ตรงจุด และในการแก้ครั้งที่สองนี่เอง ระหว่างที่กำลัง "กู้คืนเวอร์ชันที่ถูกต้อง" ผมกลับลบโมดูล platform-admin ทั้งโมดูลทิ้งไปด้วย — ทั้ง API routes หน้า admin เอง ไลบรารีตัวช่วย รวมเกือบ 600 บรรทัดที่ไม่เกี่ยวกับบั๊กที่กำลังไล่ตามเลย หายไปในคอมมิตเดียว เพราะผมรีบและเชื่อใจเครื่องมือ merge ว่ามันรู้ว่า "ถูกต้อง" หมายถึงอะไร

ผมจับได้ประมาณหนึ่งนาทีให้หลัง รีบ revert ทันที แต่หนึ่งนาทีนั้นแหละที่ผมยังคิดถึงอยู่เรื่อยๆ เพราะมันคือช่วงเวลาที่ผมเข้าใจอะไรบางอย่างเกี่ยวกับการทำงานคนเดียว ซึ่งต่อให้อ่านทฤษฎีวิศวกรรมซอฟต์แวร์มามากแค่ไหนก็เตรียมใจไม่ได้ — เมื่อเป็นคนเดียวที่ตรวจงานตัวเอง ช่องว่างระหว่าง "ทำพลาด" กับ "มีคนชี้ว่าพลาด" หายไปเป็นศูนย์ ไม่มีสายตาคู่ที่สองคอยช่วยดู มีแค่เราเองที่จะสังเกตเห็น หรือไม่เห็นก็ได้

มือที่แข็งค้างเหนือคีย์บอร์ดในห้องทำงานมืดยามดึก จอแล็ปท็อปแสดง diff โค้ดสีแดง-เขียวหนาแน่น

ปัญหาที่ใหญ่กว่านั้น ซ่อนอยู่หลังปัญหาเล็กๆ

โครงสร้างฐานข้อมูลเรืองแสงแตกร้าวแยกออกเป็นห้องแยกต่างหาก

อุบัติเหตุตอน merge ครั้งนั้นไม่ใช่เรื่องหลัก มันเป็นแค่อาการ เรื่องจริงคือสิ่งที่ผมกำลังสร้างมาตลอดทั้งเดือนนั้น และทำไมคืนแบบนั้นถึงแทบเลี่ยงไม่ได้เลย

U-PRO เริ่มต้นแบบ single-tenant — อู่เดียว ชุดข้อมูลเดียว มีสมมติฐานแฝงอยู่ในเกือบทุก query ว่าคนที่ดูข้อมูลนี้ทำงานที่อู่นั้นอู่เดียว เพราะมันมีอู่เดียวจริงๆ สมมติฐานนี้ใช้ได้ดีในช่วงแรก มันทำให้ผมทำงานเร็ว ไม่ต้องคิดเรื่องการแยกข้อมูลหรือใครเห็นอะไรได้บ้าง เพราะมี "ใคร" อยู่แค่คนเดียว

แต่โจทย์ทั้งหมดของ U-PRO คือต้องใช้งานได้กับหลายอู่พร้อมกัน — เจ้าของต่างคน ทีมต่างกัน ลูกค้าต่างกัน ที่ไม่ควรเห็นข้อมูลของกันแม้แต่ไบต์เดียว shortcut แบบ single-tenant จึงมีวันหมดอายุตั้งแต่วันแรก ทุกสัปดาห์ที่ยังสร้างฟีเจอร์ต่อบนฐานเดิมคือหนี้ที่สะสมเพิ่ม ซึ่งสักวันต้องจ่ายคืนในปฏิบัติการใหญ่ครั้งเดียว — รื้อสมมติฐาน "มีอู่เดียว" ทิ้ง แล้วแทนที่ด้วย "มีหลายอู่ และฐานข้อมูลต้องบังคับกำแพงกั้นระหว่างพวกเขาเอง"

ปฏิบัติการนั้นคือสิ่งที่ commit message ช่วงกรกฎาคมเรียกไว้ตรงๆ ว่า "migrate production to full multi-tenant system (auth, jobs, customer portal, reports)" ไม่ใช่ชื่อเก๋ๆ ไม่ใช่รหัสลับ แค่คำอธิบายตรงไปตรงมาว่ากำลังรื้อพื้นที่ยืนของระบบที่รันจริง แล้วเปลี่ยนเป็นพื้นใหม่ที่ทุกส่วนต้องรู้ว่า "อู่ไหน" เป็นคำถามที่ทุก query ต้องตอบให้ได้

ถ้าใครไม่เคยสร้างอะไรแบบนี้มาก่อน คำว่า "ย้ายระบบเป็น multi-tenant" ฟังดูเบากว่าความยุ่งยากจริงมาก มันไม่ใช่การกดสวิตช์เดียวจบ แต่คือทุกหน้าที่แสดงรายการรถต้องมีตัวกรองใหม่ — จาก "แสดงรถทั้งหมด" เป็น "แสดงเฉพาะรถของอู่ที่คนล็อกอินอยู่สังกัดจริง" คือทุกฟอร์มสร้างงานซ่อมต้องประทับตราอู่ให้ถูกทุกครั้ง เพราะพลาดแล้วไม่มี error ให้เห็น มันแค่เงียบๆ ผูกงานซ่อมเข้ากับอู่ผิด คือระบบล็อกอินเองต้องรู้ก่อนแสดงอะไร ว่าคุณมีสิทธิ์ดูอู่ไหน — auth, งานซ่อม, พอร์ทัลลูกค้า, รายงาน ทั้งสี่ส่วนนี้ถูกสร้างมาก่อนหน้านั้นบนสมมติฐานว่าไม่ต้องถามคำถามนี้เลย ช่วงนั้นทั้งหมดผมใช้ไปกับการแทรกคำถามนี้เข้าทั้งสี่ส่วนพร้อมกัน ในระบบที่มีข้อมูลจริงอยู่แล้ว

"ย้ายระบบไปพร้อมใช้งานจริง" หน้าตาเป็นยังไงกันแน่

ผมอยากพูดตรงๆ ว่านี่ไม่ใช่การอดหลับอดนอนคืนเดียวจบแบบดราม่า จะเล่าแบบนั้นก็ได้ มันจะเป็นเรื่องราวที่เรียบร้อยกว่า แต่ของจริงมันยุ่งเหยิงกว่านั้น และคิดว่าสะท้อนภาพจริงของการพัฒนาคนเดียวแบบเร่งรีบมากกว่า

การลงจริงครั้งแรกของ multi-tenant migration เกิดขึ้นบ่ายวันพฤหัสกลางเดือนกรกฎาคม — ไม่ยิ่งใหญ่ ไม่ใช่ตี 2 แค่วันทำงานปกติที่คอมมิต "migrate production to full multi-tenant system" ถูก push เข้าไป พร้อมไฟล์เปลี่ยนกว่าร้อยไฟล์ โค้ดกว่าหมื่นหนึ่งพันบรรทัด แต่นั่นไม่ใช่จุดจบ commit message เดียวกันเป๊ะปรากฏอีกครั้งสี่วันถัดมา แล้วอีกครั้ง อย่างน้อยครึ่งโหลตลอดสัปดาห์นั้น แต่ละครั้งคือการ sync งานอีกส่วนจาก staging เข้าสู่ production จริง

ตอนแรกผมอายนิดๆ ที่ต้องยอมรับแบบนี้ — "การย้ายระบบ" ไม่ควรตัดเปลี่ยนทีเดียวจบสวยๆ เหรอ แต่ยิ่งคิดตามยิ่งรู้สึกว่าความซ้ำๆ นี้แหละคือบทเรียนที่จริงที่สุด multi-tenancy ไม่ใช่ฟีเจอร์ที่ปล่อยครั้งเดียวจบ แต่เป็นคุณสมบัติที่ต้องเป็นจริงกับทุกอย่าง — ทุก query ทุก API route ทุกหน้าที่แสดงรายการ จะย้ายให้เสร็จในคอมมิตเดียวไม่มีทางเป็นไปได้ — เช่นเดียวกับที่ไม่มีใคร "ระมัดระวังจบในคอมมิตเดียว" ได้เหมือนกัน ต้องค่อยๆ ไล่หามุมที่ยังสมมติว่ามีอู่เดียว แก้ไปทีละมุม จนกว่าจะหามุมแบบนั้นไม่เจออีกแล้ว

บางการแก้ก็ไปทำของที่ใช้งานดีอยู่แล้วพังไปด้วย รอบ sync ครั้งหนึ่งย้อนฟีเจอร์สามอย่างที่ไม่เกี่ยวกับ multi-tenancy เลยกลับไปเป็นเวอร์ชันเก่าแบบเงียบๆ — ช่องกรอกชื่อบริษัท การแจ้งเตือนแนบรูป และองค์ประกอบเล็กๆ ที่แสดงว่าใครล็อกอินอยู่ ไม่มีใครแตะมันตั้งใจ แค่ตอน merge branch ใหญ่กลับเข้าโค้ดที่เดินหน้าต่อไปเรื่อยๆ บางอย่างโดนเขียนทับเงียบๆ ท่ามกลาง diff ก้อนใหญ่ ผมไม่ทันสังเกตจนบ่ายวันเดียวกันตอนไปหาฟีเจอร์พวกนั้นแล้วพบว่าหายไป ต้องเขียนคอมมิตตามหลังเพื่อกู้คืน

ถ้าจะมีภาพเดียวที่สรุปสัปดาห์นั้น มันไม่ใช่ "วิศวกรฮีโร่ย้ายฐานข้อมูลตอนตี 2" แต่ใกล้เคียงกับการอุดรูรั่วเรือขณะมันยังลอยอยู่ในน้ำ เจอรอยรั่วใหม่ทุกครั้งที่เพิ่งอุดรอยเก่าเสร็จ แล้วค่อยๆ ยอมรับว่านี่แหละคืองาน เมื่อไม่มีใครให้ส่งต่อได้

มือเก่าๆ กำลังกดผ้าอุดรอยรั่วบนตัวเรือไม้ที่แตกร้าวยามค่ำคืน ส่องด้วยแสงตะเกียง ขณะเรือยังลอยอยู่ในน้ำมืด

นี่คือความเหนื่อยล้าแบบที่งานเขียนสาย build-in-public ไม่ค่อยพูดถึง เพราะมันไม่ดราม่าพอจะเป็นหัวข้อเด่น — ไม่ใช่ความเหนื่อยของคืนโหดๆ คืนเดียว แต่เป็นความเหนื่อยของสัปดาห์ที่ทุกวันคิดว่าเจอมุมสุดท้ายแล้ว แล้วก็ไปเปิดอีกหน้าที่ยังคิดว่าโลกนี้มีอู่เดียว พอกลางสัปดาห์ผมเลิกพูดว่า "นี่คือชิ้นสุดท้ายแล้ว" แม้กับตัวเอง เพราะพูดผิดมาหลายครั้งเกินกว่าประโยคนั้นจะมีความหมาย

ทำไมผมไม่รอจนกว่าจะ "ปลอดภัย"

เงาคนคนเดียวยืนอยู่ริมทางเดินแคบที่แขวนลอยเหนือความมืดมิด มือข้างหนึ่งวางอยู่บนคันโยกควบคุม

นี่คือคำถามที่ผมจะถามตัวเองถ้าอ่านจากมุมคนนอก ทำไมย้ายระบบที่กำลังรันจริงและมีผู้ใช้อยู่แล้วไปเป็น multi-tenant แทนที่จะสร้างให้ถูกต้องตั้งแต่แรก หรืออย่างน้อยแยกสภาพแวดล้อมแล้วตัดเปลี่ยนทีเดียว

"สร้างให้ถูกต้องตั้งแต่แรก" ไม่เคยเป็นทางเลือกจริง เพราะผมไม่รู้ว่า "ถูกต้อง" หน้าตาเป็นยังไงจนกว่าจะสร้างเวอร์ชันผิดขึ้นมาก่อน เวอร์ชัน single-tenant ไม่ใช่ความผิดพลาด มันคือวิธีที่ผมเรียนรู้ว่า workflow หน้าร้านต้องการอะไร ก่อนจะทำให้ใช้ได้กับหลายอู่ ผมไม่ได้สร้างจากหน้ากระดาษเปล่า ผมมี reference implementation วางอยู่ข้างๆ

ส่วนการแยกสภาพแวดล้อมแล้วตัดเปลี่ยนตามกำหนดเวลา — นั่นคือเวอร์ชันที่ผมมีทีมงานและมีช่วงเวลาปิดปรับปรุงระบบ ตอนนั้นผมไม่มีเลย มีแค่โค้ดเบส ผู้ใช้ยุคแรกกลุ่มหนึ่ง และงาน multi-tenant ค้างที่ยิ่งปล่อยยิ่งแพงขึ้นทุกสัปดาห์ การรอ "ให้ปลอดภัย" เท่ากับจ่ายดอกเบี้ยหนี้ก้อนนี้ไปเรื่อยๆ

ผมเลยย้ายระบบไปพร้อมใช้งานจริง โดยไม่มีแผน rollback ที่แน่นอนไปมากกว่า "revert คอมมิตแล้วหวังว่าจะไม่มีอะไรพัง" ไม่ใช่กลยุทธ์ที่แนะนำให้ทีมที่มีลูกค้าองค์กรและ SLA ทำตาม แต่เป็นกลยุทธ์ของคนสร้างคนเดียว เร็ว ที่สมเหตุสมผลก็ต่อเมื่อทางเลือกอื่น — หยุดพัฒนาฟีเจอร์หลายสัปดาห์เพื่อสร้างระบบคู่ขนาน — จะฆ่าแรงส่งของโปรเจกต์ไปก่อนที่มันจะมีลูกค้าให้เสียด้วยซ้ำ

มีเหตุผลเงียบๆ อีกข้อ คือกลางเดือนกรกฎาคมผมไม่รู้จริงๆ ว่าสุดท้ายจะมีกี่อู่มาใช้ระบบนี้ หรือเร็วแค่ไหน การมีสคริปต์ rollback ที่ทดสอบแล้ว กับช่วงเวลาตัดเปลี่ยนที่วางแผนไว้ล่วงหน้า จะคุ้มค่าก็ต่อเมื่อรู้แน่ชัดแล้วว่าระบบต้องรอดจากธุรกิจหลายสิบรายที่พึ่งพามันทุกวัน ผมยังไม่มีความแน่ใจระดับนั้น มีแค่สมมติฐานหนึ่งข้อและเวลาจำกัดก่อนต้องมีสัญญาณว่าคุ้มทำต่อไหม การเสียเวลาทั้งสัปดาห์สร้างโครงสร้างการย้ายระบบสำหรับสเกลที่ยังพิสูจน์ไม่ได้ว่าจะไปถึง คือการแก้ปัญหาที่อาจไม่มีวันเจอเลย

สิ่งที่เกือบพังหนัก และสิ่งที่ไม่พัง

ผมคงโกหกถ้าบอกว่าไม่มีอะไรพัง มีของพังจริง ฟีเจอร์ที่โดนย้อนกลับไปเงียบๆ คือหลักฐาน มีเวอร์ชันของเรื่องนี้ที่ความผิดพลาดแบบนี้เกิดในจุดอันตรายกว่านี้มาก — จุดตรวจสิทธิ์ถูกย้อนกลับแทนที่จะเป็นแค่ label หน้าจอ หรือกำแพงกั้นข้อมูลระหว่างอู่อ่อนแอลงระหว่าง merge แทนที่จะกู้คืนวันเดียวกัน ครั้งนี้ไม่ได้เกิดแบบนั้น แต่ผมเห็นชัดว่ามันเกิดได้ยังไง และภาพนั้นยังติดอยู่ในหัว

สิ่งที่ช่วยผมไว้จริงๆ ไม่ใช่แผน rollback มันเล็กกว่านั้น — ผมแค่คอยเฝ้าดู ย้อนกลับไปเช็คแอปหลังทุกรอบ sync ว่าอะไรอยู่จริง ไม่ใช่แค่สิ่งที่ commit message บอกว่าควรอยู่ นั่นคือวิธีที่จับฟีเจอร์ที่ถูกย้อนกลับได้ภายในไม่กี่ชั่วโมงแทนที่จะเป็นสัปดาห์ มันเป็นตัวทดแทนที่อ่อนมากสำหรับการตรวจสอบก่อน deploy ที่ควรมีจริงๆ แต่มันคือสิ่งที่ผมมี และใช้ได้ดีพอจะพาผ่านสัปดาห์นั้นไปโดยไม่มีอะไรค้างพังนาน

สิ่งที่เรียนรู้จากช่วงนั้นไม่ใช่เรื่องฐานข้อมูล แต่เป็นเรื่องรูปร่างของความเสี่ยงตอนสร้างของคนเดียว ทุกการตัดสินใจที่ "ประมาทชัดๆ" เดือนนั้น — merge โดยไม่มีใครรีวิวเต็มรูปแบบ ย้ายระบบ production โดยไม่มี rollback ที่ทดสอบแล้ว แก้ซ้อนแก้ตอนสี่ทุ่ม — ประมาทจริงถ้าวัดด้วยมาตรฐานทีมที่มีคนมากกว่า แต่ไม่ประมาทถ้าวัดด้วยมาตรฐานว่าอะไรทำให้ของเล็กๆ ที่มีประโยชน์ไปถึงมือเจ้าของอู่จริงก่อนเงินทุนหมด สับสนสองมาตรฐานนี้คือสาเหตุที่คนสร้างของคนเดียวหยุดชะงักหรือ burn out ไปเรื่อยๆ

โจทย์แลกเปลี่ยนนี้ไม่จบหลังเดือนแรก มันแค่เปลี่ยนรูปร่าง สามสัปดาห์แรกความเสี่ยงคือลบอะไรไปโดยไม่ทันสังเกต ผ่านไปสองสามเดือนความเสี่ยงจับยากขึ้นจากการเดินดูรอบแอปเฉยๆ เพราะไม่ใช่เรื่องว่าฟีเจอร์แสดงผลอยู่หรือหายไปอีกต่อไป แต่เป็นคำถามว่ากำแพงระหว่างแต่ละอู่รับน้ำหนักได้จริงไหมภายใต้แรงกดดันที่ยังไม่เคยทดสอบ

และนั่นคือคำถามเดียวกันที่การย้ายระบบเปิดขึ้นมา คำถามที่ผมยังตอบไม่ได้เต็มที่แม้การย้ายจะลงเสร็จทางเทคนิคแล้ว — ผมเพิ่งตัดสินใจเอาข้อมูลของอู่ต่างเจ้าที่ไม่เกี่ยวข้องกันไปใส่ในฐานข้อมูลเดียวกัน เชื่อใจกำแพงตรวจสอบสิทธิ์ให้กั้นพวกเขาออกจากกัน แทนที่จะแยกฐานข้อมูลต่ออู่ไปเลย ในทางทฤษฎีการแยกฐานข้อมูลฟังดูปลอดภัยกว่า — ความผิดพลาดของอู่หนึ่งรั่วไปหาอีกอู่ไม่ได้ถ้าไม่ได้อยู่ที่เดียวกันตั้งแต่แรก แต่การแยกฐานข้อมูลก็แปลว่าต้องรัน migration แยก จัดการ backup แยก คูณด้วยจำนวนอู่ทุกรายที่สมัครเข้ามา เป็นต้นทุนที่สเกลแย่มากสำหรับธุรกิจที่อู่เล็กควรลองใช้ระบบได้โดยไม่ต้องตั้ง infrastructure แยกให้ทีละราย

ผมเลยเลือกเส้นทางฐานข้อมูลร่วม ที่การแยกข้อมูลถูกบังคับด้วยโค้ดและนโยบาย ไม่ใช่การแยกทางกายภาพ ส่วนหนึ่งเพราะสร้างต่อยอดได้เร็วกว่า อีกส่วนเพราะเชื่อว่ามันคือสถาปัตยกรรมระยะยาวที่ถูกต้องสำหรับสเกลไปหาธุรกิจเล็กจำนวนมากในต้นทุนถูก แต่ความเชื่อไม่ใช่การพิสูจน์ "ผมเลือกกำแพงที่ถูกกว่า" กับ "ผมพิสูจน์แล้วว่ากำแพงที่ถูกกว่านี้รับน้ำหนักได้จริง" เป็นคนละประโยคกัน และช่องว่างตรงนั้นคือจุดที่ตอนที่ 2 จะเริ่มเล่าต่อพอดี

การเชื่อใจกำแพงนั้นเป็นการตัดสินใจที่ถูกต้อง หรือแค่เร็วกว่ากันแน่ ตอนนั้นผมยังไม่รู้ สิ่งที่รู้ ตอนยืนอยู่ปลายช่วงเวลานั้นในเดือนกรกฎาคม กับระบบที่ในที่สุดปฏิบัติต่อ "อู่ไหน" เป็นคำถามจริง ไม่ใช่สมมติฐาน คือผมเพิ่งตัดสินใจอะไรบางอย่างที่การตัดสินใจอื่นๆ เกี่ยวกับสถาปัตยกรรมผลิตภัณฑ์นี้จะถูกสร้างต่อยอดขึ้นไปบนมันจากนี้ ไม่มีเวอร์ชันไหนของหกเดือนถัดไปที่จะย้อนกลับไปแก้มันได้แบบไม่เจ็บตัว

ร่างที่เหนื่อยล้าพักผ่อนบนเก้าอี้ขณะแสงรุ่งอรุณจางๆ เริ่มส่องเข้ามา ข้างแล็ปท็อปที่สงบนิ่งแล้ว และห้องเรืองแสงหลายห้องที่ปิดผนึกมั่นคงแล้ว


นี่คือตอนที่ 1 ของ U-PRO Build Journal — บันทึกการสร้างระบบบริหารจัดการหน้าร้านสำหรับอู่ซ่อมรถแบบเรียลไทม์ เขียนจากประวัติ commit จริงและการตัดสินใจที่เกิดขึ้นระหว่างทาง ตอนที่ 2 จะพาไปดูว่าทำไม multi-tenancy แบบฐานข้อมูลร่วมถึงชนะทางเลือกที่ดูปลอดภัยกว่าอย่างการให้แต่ละอู่มีฐานข้อมูลของตัวเอง พร้อมตอบคำถามที่ทิ้งไว้ — โมเดลการแยกข้อมูลของ U-PRO วันนี้หน้าตาเป็นแบบไหน และปลอดภัยแค่ไหนกันแน่ ไม่ใช่แค่หน้าตาของคืนแรกที่ตัดสินใจเรื่องนี้