การออกแบบระบบที่ดี
(seangoedecke.com)- การออกแบบระบบที่ดี คือการออกแบบที่ดูไม่ซับซ้อนและแทบไม่ก่อปัญหาเป็นเวลานาน
- ส่วนที่ยากที่สุดของการออกแบบระบบคือการจัดการ สถานะ (state) และสิ่งสำคัญคือควรลดจำนวนคอมโพเนนต์ที่เก็บสถานะให้น้อยที่สุดเท่าที่ทำได้
- ฐานข้อมูลมักเป็นที่เก็บสถานะหลัก จึงต้องให้ความสำคัญกับ การออกแบบสคีมาและการทำดัชนี รวมถึงการแก้ปัญหาคอขวด
- การแคช การประมวลผลอีเวนต์ และงานเบื้องหลัง ควรถูกนำมาใช้อย่างระมัดระวังเพื่อประสิทธิภาพและการดูแลรักษา และไม่ควรใช้มากเกินไป
- แกนสำคัญของการสร้าง ระบบที่ยั่งยืนและเสถียร คือการเลือกใช้คอมโพเนนต์และวิธีการที่เรียบง่ายซึ่งผ่านการพิสูจน์มาเพียงพอ แทนการออกแบบที่ซับซ้อน
นิยามของการออกแบบระบบและแนวทางโดยรวม
- หากการออกแบบซอฟต์แวร์คือการประกอบโค้ด การออกแบบระบบ ก็คือกระบวนการประกอบบริการหลายประเภทเข้าด้วยกัน
- องค์ประกอบหลักของการออกแบบระบบ ได้แก่ แอปเซิร์ฟเวอร์ ฐานข้อมูล แคช คิว อีเวนต์บัส และพร็อกซี
- การออกแบบที่ดีมักทำให้เกิดปฏิกิริยาอย่าง "ไม่มีปัญหาอะไรเป็นพิเศษ" "จบได้ง่ายกว่าที่คิด" หรือ "ส่วนนี้ไม่ต้องกังวลก็ได้"
- ในทางกลับกัน การออกแบบที่ซับซ้อนและสะดุดตาอาจกำลังซ่อนปัญหารากฐาน หรือสะท้อนถึงการออกแบบที่มากเกินความจำเป็น
- แทนที่จะนำระบบซับซ้อนมาใช้ตั้งแต่ต้น ควรเริ่มจาก โครงสร้างเรียบง่ายขั้นต่ำที่ใช้งานได้จริง แล้วค่อย ๆ พัฒนาเพิ่ม จะได้ผลดีกว่า
การแยกความต่างระหว่าง state และ stateless
- ส่วนที่ยากที่สุดในงานออกแบบซอฟต์แวร์ก็คือ การจัดการสถานะ
- บริการที่ไม่เก็บข้อมูลและคืนผลลัพธ์ทันที เช่น การเรนเดอร์ PDF ของ GitHub ถือเป็นแบบ stateless
- ในทางกลับกัน บริการที่เขียนข้อมูลลงฐานข้อมูลคือบริการที่จัดการสถานะ
- ควรลดจำนวนคอมโพเนนต์ที่เก็บสถานะภายในระบบให้มากที่สุด เพราะช่วยลด ความซับซ้อนและโอกาสเกิดความขัดข้อง
- โครงสร้างที่แนะนำคือให้มีเพียงบริการเดียวที่รับผิดชอบการจัดการสถานะ ส่วนบริการอื่นให้เน้นบทบาทแบบ stateless เช่น การเรียก API หรือการส่งอีเวนต์
การออกแบบฐานข้อมูลและจุดคอขวด
การออกแบบสคีมาและดัชนี
- เพื่อจัดเก็บข้อมูล ควรมี การออกแบบสคีมาที่มนุษย์อ่านเข้าใจง่าย
- สคีมาที่ยืดหยุ่นเกินไป (เช่น เก็บทุกอย่างไว้ในคอลัมน์ JSON) อาจเพิ่มภาระให้ทั้งโค้ดแอปพลิเคชันและประสิทธิภาพ
- ควรกำหนด ดัชนีที่เหมาะสม ตามคอลัมน์ที่มีการคิวรีบ่อย การใส่ดัชนีให้ทุกอย่างกลับทำให้เกิดโอเวอร์เฮดโดยไม่จำเป็น
วิธีแก้ปัญหาคอขวด
- การเข้าถึงฐานข้อมูลมักเป็นคอขวดหนัก อยู่บ่อยครั้ง
- หากเป็นไปได้ การจัดการข้อมูลที่ซับซ้อนด้วย JOIN ภายในฐานข้อมูลแทนการทำในแอปพลิเคชันมักให้ประสิทธิภาพดีกว่า
- เมื่อใช้ ORM ต้องระวังความผิดพลาดจากการทำให้เกิดคิวรีภายในลูป
- ในบางกรณี การแยกคิวรีออกเป็นหลายส่วนเพื่อปรับภาระของฐานข้อมูลหรือความซับซ้อนของคิวรีก็เป็นอีกวิธีหนึ่ง
- กลยุทธ์ กระจาย read query ไปยัง read replica มีประสิทธิภาพในการลดภาระของโหนดหลักที่รับงานเขียน
- เมื่อมีคิวรีจำนวนมากถาโถมเข้ามา ธุรกรรมและงานเขียนอาจทำให้ฐานข้อมูลโอเวอร์โหลดได้ง่าย จึงควรพิจารณา การ throttle หรือจำกัดคิวรี
การแยกงานช้ากับงานเร็ว
- งานที่ผู้ใช้โต้ตอบด้วยต้องการ การตอบสนองภายในระดับไม่กี่ร้อยมิลลิวินาที
- สำหรับงานที่ใช้เวลานาน (เช่น การแปลง PDF ขนาดใหญ่) แนวทางที่มีประสิทธิภาพคือ ให้ส่วนที่จำเป็นน้อยที่สุดกับหน้าบ้านทันที แล้วส่งงานที่เหลือไปทำเบื้องหลัง
- งานเบื้องหลัง มักทำงานร่วมกันระหว่างคิว (เช่น Redis) และตัวรันงาน
- สำหรับงานที่ตั้งเวลาไว้ไกลล่วงหน้า การใช้ตารางใน DB แยกต่างหากและรันด้วย scheduler มักใช้งานได้จริงมากกว่า Redis
การแคช
- การแคช ช่วยลดต้นทุนและเพิ่มประสิทธิภาพเมื่อมีการคำนวณแบบเดิมหรือมีราคาแพงซ้ำ ๆ
- โดยทั่วไป วิศวกรจูเนียร์ที่เพิ่งเรียนเรื่องแคช มักอยากแคชทุกอย่าง ขณะที่วิศวกรที่มีประสบการณ์จะระมัดระวังมากกว่าในการนำแคชมาใช้
- แคชคือการเพิ่มสถานะรูปแบบใหม่ จึงมี ความเสี่ยงเรื่องการซิงก์ ความผิดพลาด และข้อมูล stale
- แนวทางที่เหมาะสมคือควรลองปรับปรุงประสิทธิภาพก่อน เช่น เพิ่มดัชนีให้คิวรี แล้วค่อยใช้การแคช
- สำหรับแคชขนาดใหญ่ อาจใช้วิธี บันทึกลง document storage เป็นระยะ เช่น S3/Azure Blob Storage แทน Redis/Memcached ได้เช่นกัน
การประมวลผลอีเวนต์
- บริษัทส่วนใหญ่มักมี event hub (เช่น Kafka) และให้บริการต่าง ๆ กระจายการประมวลผลบนพื้นฐานของอีเวนต์
- แทนที่จะใช้อีเวนต์พร่ำเพรื่อ การออกแบบ API แบบ request–response ที่เรียบง่ายมัก มีประโยชน์กว่าในด้านการบันทึกล็อกและการแก้ปัญหา
- การประมวลผลแบบอิงอีเวนต์เหมาะเมื่อผู้ส่งไม่จำเป็นต้องสนใจพฤติกรรมของผู้รับ หรือใน สถานการณ์ปริมาณสูงและยอมรับความหน่วงได้
วิธีส่งผ่านข้อมูล: Push และ Pull
- การส่งข้อมูลมี 2 แบบคือ Pull (ขอแล้วค่อยตอบ) และ Push (ส่งอัตโนมัติเมื่อมีการเปลี่ยนแปลง)
- แบบ Pull นั้นเรียบง่าย แต่มีปัญหาเรื่องการร้องขอซ้ำและโอเวอร์โหลดได้
- แบบ Push จะส่งข้อมูลไปยังไคลเอนต์ทันทีเมื่อข้อมูลฝั่งเซิร์ฟเวอร์เปลี่ยน จึง มีประสิทธิภาพและเหมาะกับการรักษาข้อมูลให้เป็นปัจจุบัน
- หากต้องรองรับไคลเอนต์จำนวนมาก ก็จำเป็นต้องขยายอินฟราสตรักเจอร์ให้เหมาะกับแต่ละรูปแบบ (เช่น event queue, cache server หลายตัว เป็นต้น)
โฟกัสที่ฮอตพาธ (Hot Paths)
- ฮอตพาธ หมายถึงเส้นทางที่สำคัญที่สุดในระบบและเป็นจุดที่ข้อมูลไหลผ่านจำนวนมาก
- ฮอตพาธมีทางเลือกน้อย และหากออกแบบผิดพลาดอาจก่อให้เกิด ปัญหาร้ายแรงต่อทั้งบริการ ดังนั้นต้องออกแบบอย่างรอบคอบ
- แทนที่จะทุ่มทรัพยากรให้ฟีเจอร์ย่อยที่มีตัวเลือกมากมาย ควร โฟกัสทรัพยากรด้านการออกแบบและการทดสอบไปที่ฮอตพาธ จะมีประสิทธิภาพกว่า
การบันทึกล็อก เมตริก และการติดตาม
- เพื่อวิเคราะห์สาเหตุเมื่อเกิดปัญหา ควรบันทึก ล็อกแบบละเอียดสำหรับเส้นทางที่ไม่ปกติ (unhappy path) อย่างจริงจัง
- จำเป็นต้องเก็บ ตัวชี้วัดด้าน observability พื้นฐาน เช่น ทรัพยากรระบบ (CPU/หน่วยความจำ) ขนาดคิว และเวลาของคำขอ/งาน
- ไม่ควรดูแค่ค่าเฉลี่ยเท่านั้น แต่ต้องสังเกต ตัวชี้วัดการกระจายอย่างค่า latency แบบ p95, p99 ด้วย เพราะคำขอที่ช้าเพียงส่วนน้อยอาจเป็นปัญหาสำคัญของผู้ใช้หลัก
คิลสวิตช์ การรีทราย และการกู้คืนความขัดข้อง
- การใช้ คิลสวิตช์ (หยุดระบบชั่วคราว) และการรีทรายอย่างมีกลยุทธ์เป็นสิ่งสำคัญ
- การรีทรายแบบไม่ยั้งคิดมีแต่จะเพิ่มภาระให้บริการอื่น ดังนั้นต้องควบคุมคำขอล่วงหน้าด้วย circuit breaker เป็นต้น จึงจะได้ผล
- การนำ Idempotency Key มาใช้ช่วยป้องกันงานซ้ำเมื่อมีการประมวลผลคำขอเดิมอีกครั้ง
- ในบางสถานการณ์ความขัดข้อง จำเป็นต้องเลือกว่าจะใช้ fail open หรือ fail closed ตัวอย่างเช่น rate limiting ควรเอนไปทาง fail open (ยอมให้ผ่าน) เพื่อลดผลกระทบต่อผู้ใช้ ขณะที่การยืนยันตัวตนจำเป็นต้องเป็น fail closed
สรุป
- แม้จะละหัวข้อบางอย่างไป เช่น การแยกบริการ การใช้คอนเทนเนอร์ VM หรือ tracing แต่การใช้ คอมโพเนนต์ที่ผ่านการพิสูจน์แล้ว ให้เหมาะกับจุดที่ควรใช้ ยังคงเป็นหนทางสู่การสร้างระบบที่เสถียรที่สุดในระยะยาว
- การออกแบบที่พิเศษทางเทคนิคนั้นเกิดขึ้นจริงน้อยมาก และ การออกแบบที่เรียบง่ายจนแทบน่าเบื่อ กลับเป็นสิ่งที่ถูกใช้ในงานจริงบ่อยที่สุด
- โดยแก่นแท้แล้ว การออกแบบระบบที่ดีคือกระบวนการประกอบวิธีการที่ผ่านการพิสูจน์เพียงพอเข้าด้วยกันอย่างปลอดภัยโดยไม่ทำให้ตัวมันเองโดดเด่นเกินไป
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
ฉันมักรู้สึกว่าตัวเองอยู่ลำพังกับความคิดนี้ วิศวกรมักมองระบบที่ซับซ้อนแล้วรู้สึกว่ามีอะไรน่าสนใจเยอะ จึงคิดว่า “นี่แหละกำลังมีการออกแบบระบบจริง ๆ!” แต่ความจริงแล้ว ระบบที่ซับซ้อนมักเป็นผลลัพธ์ของการขาดการออกแบบที่ดี ถ้าคุณกำลังหางาน คุณควรลืมข้อเท็จจริงนี้ไปให้หมดในระหว่างสัมภาษณ์ ฉันเองก็เคยพลาดเพราะพูดความคิดนี้อย่างตรงไปตรงมาในสัมภาษณ์ system design ในสัมภาษณ์แอปสตาร์ตอัปสมมุติ ฉันตอบประมาณว่า “ระดับ QPS แค่นี้ไม่ต้องสนใจ backpressure ก็ได้”, “ไม่จำเป็นต้องใช้คิวแทน cron job แน่นอนว่ามี trade-off”, “SQL vs NoSQL? ใช้อะไรที่ทีมถนัดที่สุดก็พอ” แต่ผู้สัมภาษณ์ไม่ได้ต้องการคำตอบแบบนี้ คุณต้องวาดกระดานขาวให้เต็ม และโชว์ดีไซน์ที่ซับซ้อนระดับ Kubernetes จัดการ Kubernetes อีกที ถึงจะส่งสัญญาณแบบที่พวกเขาต้องการได้
ฉันพูดในฐานะคนที่ผ่านการสัมภาษณ์ system design มาหลายร้อยครั้งและเทรนคนมาหลายคน คำตอบที่คุณยกมาส่งสัญญาณได้อ่อนมาก (ยกเว้นคำตอบเรื่องคิว) สิ่งที่ผู้สัมภาษณ์อยากรู้จริง ๆ คือ “ทำไม” คุณถึงตัดสินใจแบบนั้น คุณพิจารณาปัจจัยอะไรบ้าง และอยากฟังกระบวนการคิดของคุณ ถ้าคุณไม่อธิบายคำตอบอย่างละเอียด ผู้สัมภาษณ์ก็มีแนวโน้มจะคิดว่า “แทบไม่ได้ข้อมูลอะไรเลย” ดังนั้นผู้สมัครต้องส่งข้อมูลที่ผู้สัมภาษณ์ต้องการออกมาอย่างกระตือรือร้น อีกอย่าง ต่อให้เป็นผู้สัมภาษณ์ที่ดีก็ตาม ถ้าต้องคอยงัดคำตอบออกจากคุณ เขาก็จะจดว่า “เหตุผลพอฟังขึ้น แต่สื่อสารไม่มีประสิทธิภาพ” ทักษะการสื่อสารก็เป็นสิ่งที่ถูกประเมินเช่นกัน สุดท้าย ฉันไม่เห็นด้วยกับคำตอบเรื่อง SQL/NoSQL ประสบการณ์ของทีมก็สำคัญ แต่ความต่างระหว่างเทคโนโลยีนั้นชัดเจน และประสิทธิภาพก็แตกต่างกันมากตามสถานการณ์ คำตอบแบบนั้นทำให้ดูเหมือนมีประสบการณ์กับหลายบริบทไม่มากพอ
อย่างที่บอกว่า “การสัมภาษณ์เป็นถนนสองทาง” ฉันคิดว่าคำตอบของคุณสมเหตุสมผลมาก ถ้าฉันเป็นผู้สัมภาษณ์ ฉันน่าจะให้คะแนนสูงด้วยซ้ำ ตรงกันข้าม ถ้าบริษัทไหนปัดตกคุณเพราะคำตอบแบบนี้ ก็มีโอกาสสูงว่าบริษัทนั้นเองอาจไม่ค่อยดีนัก แต่ในโลกความจริง หลายครั้งเราจำเป็นต้องลงหลักปักฐานให้ได้เร็ว ดังนั้นก็ต้องบาลานซ์ให้ดี และปรับคำตอบไปทางที่อีกฝ่ายอยากได้บ้าง
คำแนะนำนี้ไม่ดีเลย ดีไซน์ที่เรียบง่ายแต่สง่างามไม่ได้เริ่มต้นจากการเมินปัญหาที่อาจเกิดขึ้น คำถามไล่ลึกไม่ใช่เวลาสำหรับพรั่งพรูเรื่องเกร็ดเทคนิค แต่เป็นสัญญาณว่าให้มาคุยกัน คำตอบของคุณไม่ได้แสดงความเฉียบแหลม แต่กลับให้ความรู้สึกว่ายังไม่โตพอในงานนี้ ไม่ใช่ความผิดของผู้สัมภาษณ์
ฉันเห็นด้วยกับประเด็น “การสัมภาษณ์เป็นถนนสองทาง” ที่คอมเมนต์ข้าง ๆ ชี้ไว้ แต่ผู้สัมภาษณ์ที่ดีควรพูดตรง ๆ ว่า “คำตอบนี้ก็ดีนะ แต่ตอนนี้ฉันกำลังทดสอบความรู้ในธีมนี้อยู่” ถ้าผู้สมัครเอาแต่พูดเรื่องคนละทิศคนละทางต่อไปเรื่อย ๆ นั่นกลับเป็นสัญญาณน่ากังวลมากกว่า
ฉันว่ามันเป็นตัวอย่างชัด ๆ ว่าทำไม LinkedIn-driven development ถึงมีอยู่จริง การไล่เรียงเทคโนโลยีจำนวนมากลงใน CV มันดูเท่กว่าการอธิบายว่าคุณใช้ Postgres ตัวเดียวกับ modular monolith ได้ดีแค่ไหนมาก
ฉันคิดว่านี่เป็นบทความที่ดีมาก แต่ก็อยากพูดถึงข้อจำกัดของ best practice พวกนี้ด้วย เช่น มีคำแนะนำว่า “อย่าให้ 5 บริการเขียนลงตารางเดียวกัน ให้ 4 ตัวเรียก API หรือยิงอีเวนต์ และมีแค่ 1 บริการที่เขียนตาราง” แต่โลกจริงมันไม่ได้แยกสวยแบบนั้นเสมอไป ถ้าทั้งห้าตัวเข้าถึง DB ได้ คุณก็กำลังสร้าง distributed system อยู่แล้ว เพียงแต่ DB มีเรื่องสิทธิ์, transaction และ custom query ให้มาโดยธรรมชาติ จึงไม่ต้องออกแบบอินเทอร์เฟซเพิ่มต่างหาก แต่ถ้าคุณสร้างอินเทอร์เฟซระดับสูงผ่านบริการเดียว ตอนนั้นคุณก็ต้องลงมือทำ auth, transaction และ exception handling เพิ่มเองอีก แบบนี้ในทางปฏิบัติมันไม่ได้เพิ่ม failure mode กับภาระการดูแล microservice หรือ? ในอีกด้านหนึ่ง การที่หลายบริการเข้าถึง DB เดียวกันอาจเป็น code smell ก็ได้ บางที DB นี้อาจเป็นร่องรอยของหลาย DB ที่ถูกยุบรวมกัน และบริการเองก็อาจลดเหลือแค่สองหรือสามตัวได้
สำหรับคำถามว่า “ได้อะไรขึ้นมา” API มีความยืดหยุ่นต่อการเปลี่ยนแปลงสูงกว่าการใช้ schema DB ร่วมกันมาก จากประสบการณ์ที่ทำกับหลายระบบ ถ้าเป็นโครงสร้างที่หลายบริการแชร์ DB เดียวกัน ฉันจะไม่ออกแบบแบบนั้นอีก มันอาจพอใช้ได้กับบริษัทเล็กช่วงต้นยุค 2000 แต่หลังจากนั้นฉันเห็นแต่เคสล้มเหลวมาโดยตลอด (ยกเว้นกรณีที่แยกเฉพาะเส้นทางอ่าน/เขียนภายในบริการเดียวกัน)
ฉันไม่เห็นด้วยกับแนวคิดที่ว่า DB เป็นอินเทอร์เฟซอยู่แล้วจึงไม่ต้องออกแบบเพิ่ม ถ้ามีหลาย client ใช้ DB เดียวกัน รูปแบบการเข้าถึงจะแตกต่างกัน และปัญหาเรื่อง migration ก็ใหญ่ขึ้น สุดท้ายก็ต้องมีการออกแบบเพิ่มอยู่ดี เช่น view, การจัดการสิทธิ์ ฯลฯ และภาระการดูแลก็สูงขึ้น ในสภาพอุดมคติ API สะอาดกว่ามาก โลกจริงมักปล่อยให้มีการเข้าถึง DB ตรง ๆ เพราะแรงกดดันในการปล่อยฟีเจอร์ให้เร็ว แต่รากของปัญหาคือหลายคนไม่อยากออกแบบใหม่ทั้งระบบให้รับกับความต้องการหรือดีไซน์ใหม่
เวลาต้องเปลี่ยนแปลง เป้าหมายคือทำให้ขอบเขตการประสานงานที่เกี่ยวข้องเล็กที่สุด เมื่อคุณต้องเปลี่ยนโครงสร้าง datastore คุณต้องควบคุมทุกส่วนที่เข้าถึงมัน ดังนั้นยิ่งมีเส้นทางเข้าถึงน้อย การเปลี่ยนก็ยิ่งง่าย ตัวอย่างเช่น ในงานจริงพอแยก DB ออก ปรากฏว่ามีมากกว่า 40 ทีมต้องแก้โค้ด ถ้าการเปลี่ยนนี้เกิดจาก “ความต้องการฟีเจอร์” ยังหนักขนาดนี้ ถ้าเป็นปัญหา “scalability” ตัวโปรดักต์เองอาจพังไปเลยก็ได้
มีคนเรียกโครงสร้างที่หลายบริการต่อกับ DB เดียวกันว่า “code smell” แต่ในทางกลับกัน ถ้าทุกบริการต้องมี DB แยกของตัวเองจริง ๆ ความพร้อมใช้งานอาจขยายจาก N ไปเป็น N ยกกำลัง M และในความเป็นจริงอาจยิ่งไม่เสถียรกว่าเดิม (ถ้าพูดในระดับ DB cluster)
เวลาจะ query ฐานข้อมูล วิธีที่มีประสิทธิภาพที่สุดก็คือ query ที่ DB โดยตรงจริง ๆ ถ้าต้องใช้ข้อมูลจากหลายตาราง ก็ควรใช้ join แทนที่จะ query ทีละชุดแล้วเอามารวมกันในแอปพลิเคชัน และฉันยังแนะนำให้ใช้ view หรือแม้แต่ stored procedure อย่างจริงจังด้วย view เป็นชั้น abstraction ของข้อมูล จึงช่วยเรื่องการออกแบบได้มาก และโค้ด SQL ถ้าเขียนดีก็อ่านง่ายและดูแลง่ายเช่นกัน
เพราะจุดนี้เอง ORMs ถึงก่อปัญหาไว้เยอะ การใช้ SQL view/custom query ตรง ๆ ในแต่ละ MVC view ของสภาพแวดล้อม SSR คือวิธีที่ทำให้เว็บบริการขนาดใหญ่มีทั้งประสิทธิภาพและความงาม คุณให้ RDBMS จัดการงานหนัก แล้วเว็บเซิร์ฟเวอร์ก็แค่ส่งผลลัพธ์ SQL ไปลงตารางได้เลย เพราะ RDBMS แบบ legacy อย่าง MSSQL หรือ Oracle มี optimization ในตัวเยอะมาก ตรงกันข้าม ORMs บังคับ object model เดียวจนแทบไม่เหลือความยืดหยุ่น
stored procedure ดูเหมือนมีประโยชน์ แต่ในโลกจริง ข้อจำกัดของภาษา (เช่น T-SQL) ทำให้ยากที่จะพัฒนาโดยรวมทุกอย่างในภาษาใหม่ที่ทีมคุ้นเคยร่วมกัน ตอนนี้ฉันกำลังดูแลโค้ดเบส T-SQL ขนาดใหญ่ ซึ่งทั้ง version control และเครื่องมือวิเคราะห์ก็ไม่ค่อยดีนัก โค้ดของคนเข้ามาใหม่อย่างน้อยยังพออ่านได้ แต่ T-SQL นี่ฝันร้ายเลย
ฉันไม่เห็นด้วย ในสถาปัตยกรรมสมัยใหม่ที่เน้น scalability จะดีกว่าถ้าทำ join ที่ backend หน้า DB โดยจัดให้ DB ทำแค่ index lookup ง่าย ๆ และให้ backend เป็นคน join เอง แบบนี้ขยาย DB ได้ดีกว่าและเร็วกว่า เพราะการเพิ่ม instance ของเซิร์ฟเวอร์ง่ายกว่าการขยาย DB ถ้าการ join มีข้อมูลมหาศาลจนต้องทำใน DB เท่านั้น ค่อยเปลี่ยนโครงสร้างตอนนั้นก็ได้ ถ้าคุณผลักการ join ไปถึงฝั่ง frontend ได้ การ cache ผลลัพธ์ก็ยิ่งมีประโยชน์และได้กำไรมากขึ้น
แน่ใจหรือ? สมมุติมีลูกค้า 10,000 คน คำสั่งซื้อ 1,000,000 รายการ ถ้าคุณ join แล้วส่งทั้งตารางลูกค้า 20 ฟิลด์ + ตารางคำสั่งซื้อ 5 ฟิลด์ออกมา คุณจะต้องส่ง 25,000,000 ฟิลด์ แต่ถ้าดึงแยกเป็นสอง query แล้วค่อย join เอง คุณจะมีแค่ 5,000,000 ฟิลด์จากฝั่งคำสั่งซื้อ + 200,000 ฟิลด์จากฝั่งลูกค้า ซึ่งดีกว่ามากทั้งด้านแบนด์วิดท์และประสิทธิภาพ
กฎนี้ใช้เป็นจุดเริ่มต้นได้ดี แต่คุณต้องรู้ด้วยว่าเมื่อไรควรยกเว้น แอปที่ฉันเคยดูแลมีโครงสร้างที่ join แล้วจำนวน record พุ่งแบบเรขาคณิต พอแยก query ออกกลับเร็วขึ้นมาก เพราะได้ประโยชน์จากการประมวลผล/กรองผลลัพธ์มากกว่าต้นทุน network overhead ต่อมาภายหลังก็เปลี่ยนไปเก็บทุกอย่างใน JSONB ซึ่งกลับดีขึ้นกว่าเดิมอีก
คุยเรื่องการออกแบบระบบที่ดี แต่กลับไม่พูดถึง problem domain เลยสักนิด ทำให้ฉันรู้สึกเสียดาย ส่วนที่สำคัญที่สุดและยากที่สุดของ system design คืออินเทอร์เฟซที่ระบบมอบให้ผู้ใช้ ท้ายที่สุดแล้วระบบซอฟต์แวร์คือการแลกเปลี่ยนปัญหาแบบ “ฉันจะให้ฟังก์ชันนี้กับคุณ แต่คุณต้องเข้าใจโครงสร้าง/โมเดลแบบนี้ตอบแทน” ความผิดพลาดในการออกแบบอินเทอร์เฟซมีต้นทุนสูงที่สุด และถ้าคุณไม่ได้ใช้เวลาส่วนใหญ่ไปกับการถกเรื่องอินเทอร์เฟซ คุณก็กำลังพลาดของสำคัญที่สุดอยู่ องค์ประกอบระบบอื่น ๆ นั้นภายหลังยังแก้ได้อีกมากโดยไม่ต้องกระทบผู้ใช้
ฉันรู้สึกอินมากกับประโยคที่ว่า “ดีไซน์ที่ดีมักไม่แสดงตัว ส่วนดีไซน์ที่แย่กลับดูเหมือนน่าเชื่อถือกว่า” ดูเหมือนว่าโครงสร้างการประเมินคนสายเทคนิคจะกลายเป็นการวัดจาก “ความซับซ้อน” จนเผลอสนับสนุนการ over-engineer ไปแล้ว หลัก KISS ถูกมองเห็นคุณค่าไม่พอมานานมาก
บางครั้งฉันย้อนกลับไปดูส่วนต่าง ๆ ในโค้ดเบสที่ตัวเองแทบไม่เคยต้องคิดถึงแล้วก็ปล่อยผ่านไป และนั่นแหละกลับเป็นร่องรอยของการออกแบบที่ดี
น่าเศร้าที่มันเป็นเรื่องจริง คนส่วนใหญ่มักหลงเสน่ห์โซลูชันซับซ้อนมากกว่า และถ้าคุณเสนอคำตอบที่เรียบง่ายก็อาจดูเหมือนไม่มีความสามารถ แต่ในความจริง โครงสร้างเรียบง่ายที่ดูแลง่ายมีส่วนต่อความสำเร็จของทั้งโปรเจกต์มากกว่าแน่นอนว่ามีปัญหาบางอย่างที่ซับซ้อนโดยธรรมชาติ แต่ส่วนใหญ่มันก็เป็นแค่เว็บแอปธรรมดา
สิ่งที่สำคัญที่สุดในการออกแบบ schema คือความยืดหยุ่น เพราะเมื่อข้อมูลสะสมมากขึ้น การเปลี่ยน schema จะยากมาก แต่ถ้าออกแบบให้ยืดหยุ่นเกินไป (จับทุกอย่างยัด JSON หรือทำโครงสร้าง EAV!) โค้ดแอปพลิเคชันจะซับซ้อนไม่มีที่สิ้นสุด และยังเพิ่มปัญหา performance แปลก ๆ เข้ามาอีก ดังนั้นโดยทั่วไปฉันชอบ schema ที่แค่มองโครงสร้างตารางก็พอเดาได้ทันทีว่าใช้ทำอะไร เป็น schema ที่คนอ่านเข้าใจง่าย ถ้าต้องเจอ EAV หรือ JSON column/table บ่อย ๆ ฉันแทบอยากเลิกเป็นนักพัฒนาไปเลย แน่นอนว่า EAV มีกรณีใช้งานที่เหมาะอยู่บ้าง แต่ส่วนใหญ่แล้วมันสร้างแต่ความสับสนในสนามจริง ปัญหา N+1, การสร้าง query แบบ dynamic, การเก็บ audit data ไว้ใน DB เดียวจนมันค่อย ๆ ถูกดูดเข้าไปเป็นส่วนหนึ่งของ business logic, สภาพแวดล้อม Oracle ที่ซับซ้อน, รวมถึงการแยกผิดว่าจะเก็บอะไรไว้ใน DB หรือไว้ในแอป ตัวแปรแต่ละอย่างแบบนี้ลดทอนคุณภาพชีวิตของนักพัฒนาอย่างมาก
ในประเด็นนี้ หนังสือ “SQL Antipatterns” ของ Bill Karwin อธิบายอันตรายและข้อจำกัดของแพตเทิร์น EAV ไว้ดีมาก ถึงอย่างนั้น บางครั้งเวลาวาด schema ยากจริง ๆ (เช่นใน Postgres ที่มีคอลัมน์ JSONB) ก็อาจใช้เป็นทางแก้ชั่วคราวได้ แต่ไม่ควรยกเป็นกฎต้นแบบ ถ้าทำ normalization ได้ ก็ควรเลือก normalization เสมอ
เรื่อง “ถ้าเก็บ audit data ไว้ใน DB เดียวกัน สุดท้ายมันจะกลายเป็นส่วนหนึ่งของ business logic จนลำบาก” นี่ทำให้ฉันสงสัยว่าทาง “ที่ถูกต้อง” คืออะไร DB แยก? หรือ storage ที่แยกขาดไปเลย?
สำหรับคำแนะนำที่ว่า “หลีกเลี่ยงไม่ให้ 5 บริการเขียนลงตารางเดียวกัน ให้ 4 ตัวเรียก API หรือยิงอีเวนต์ และมีแค่ 1 ตัวที่เขียน DB ตรง” ฉันคิดว่าทางที่ดีที่สุดคือออกแบบตั้งแต่แรกไม่ให้เกิดกรณีที่ 5 บริการต้องเขียนลงตารางเดียวกันเลย ถ้าเกิดขึ้นจริง ก็อาจแปลว่ามี logic ข้ามบริการที่ซ้อนทับกันอยู่มาก แบบนั้นควรถามต่อว่าทั้ง 5 บริการนี้จำเป็นต้องแยกกันจริงไหม หรือควรรวมเป็นตัวเดียวได้หรือเปล่า ในทางปฏิบัติ การให้ตารางข้อมูลแยกกันคนละชุด หรือรีแฟกเตอร์ใหม่ มักแก้ปัญหาได้ตรงกว่า
การแยกระหว่าง stateful/stateless เป็นหัวใจสำคัญของการแบ่งความรับผิดชอบระหว่างฝั่ง infra กับฝั่งพัฒนา ถ้ารันเป็น container แบบ stateless มักมีเรื่องที่ผิดพลาดจนหนักมากไม่เยอะ ดังนั้นถ้าพังก็มักแค่ deploy ใหม่ก็จบ ตราบใดที่หลีกเลี่ยงความผิดพลาดระดับทำลาย dataset ใน DB ได้ ส่วนใหญ่ก็ฟื้นกลับมาได้เร็ว คนที่มีประสบการณ์ในอาชีพ เวลา หรือความสม่ำเสมอต่างกัน ยังพอรับผิดชอบงานระดับนี้ได้ แต่พอเป็นส่วนที่มี state อย่างฐานข้อมูล, file storage ฯลฯ มันคนละเรื่องเลย ความผิดพลาดเพียงครั้งเดียวอาจทำให้ทั้งธุรกิจตกอยู่ในความเสี่ยง จึงควรให้คนเฉพาะทางที่มีประสบการณ์ภาคสนามสูงรับผิดชอบ ถึง DB จะทำงานได้ปกติ แต่ถ้าไม่มี backup ก็ถือว่าเสี่ยงหนักอยู่ดี ในโลกจริง ปัญหาประเภทนี้ไม่ใช่อะไรที่จะแก้ได้ด้วยการ deploy ไม่กี่นาที
สำหรับคำแนะนำที่ว่า “ใช้ timestamp แทน bool” ฉันรู้สึกว่ามันเป็นแนวทางที่กว้างเกินไปหรือเปล่า เช่น is_on → true, on_at → 1023030 ก็ชัดเจนดี แต่ is_a_bear → true, a_bear_at → 12312231231 ฟังดูประหลาด เพราะหมีส่วนใหญ่ไม่ได้ “กลายเป็นหมี” กันเมื่อไรแบบนั้น… มันน่าจะใช้ได้เฉพาะบางบริบทมากกว่า
ฉันคิดว่าแทบทุกกรณีใช้ timestamp หรือ integer แทน boolean จะดีกว่า โดยเฉพาะฟิลด์ที่มีสองสถานะ เพราะมันมักพัฒนาไปเป็น “การจัดประเภท” ในภายหลัง ตัวอย่างเช่น ถึงตอนนี้จะมีแค่หมี ก็ควรเผื่อขยายเป็น enum type ได้ในอนาคต และฟิลด์สถานะเองก็มักขยายจากแค่ active/inactive ไปเป็นสถานะอื่น เช่น stopped, deleted, paused เป็นต้น พอ boolean เพิ่มจำนวนมากขึ้น กลับยิ่งซับซ้อนกว่าเดิม integer ดีกว่า
ถ้าตีความตามคำกล่าวนี้ตรง ๆ ก็แปลว่าการใช้ boolean ใน DB เองเป็นกลิ่นไม่ดี ซึ่งฉันเห็นด้วย เพียงแต่วิธีแบบนี้ (แปลง bool → timestamp) มักใช้เพื่อความสะดวกในงาน join มากกว่า ไม่ใช่ “ทางออกที่สมบูรณ์” ถ้าการเปลี่ยนแปลงแบบเรียลไทม์สำคัญจริง ก็ควรมี audit table ตั้งแต่แรก เช่นเดียวกับ soft delete ที่ฉันมองว่าเป็นทางออกกึ่ง ๆ กลาง ๆ เจตนาที่แท้จริงคืออยากกันการลบ แต่จริง ๆ backup-restore ป้องกันได้มีประสิทธิภาพกว่า
ชนิดข้อมูล boolean ใช้พื้นที่น้อยกว่า ดังนั้นในบาง workload (เช่นข้อมูลจำนวนมากเพื่อการวิเคราะห์) มันจึงมีประสิทธิภาพกว่า และบางครั้งการเก็บค่าที่เป็น boolean ตามตรรกะก็ถูกต้องเหมาะสม เช่น ผลลัพธ์ของ process (ระบุว่าสำเร็จ/ล้มเหลว) การใช้ boolean ก็ใช้งานได้จริง
ฉันก็สงสัยว่าทำไมต้องแค่ boolean ที่ควรเปลี่ยนเป็น timestamp ด้วย isDarkTheme, paginationItems ฯลฯ ก็อาจอยากรู้เวลาเปลี่ยนเหมือนกัน มันให้ความรู้สึกเหมือนเป็น poor-man changelog มากกว่า
ถ้าเป็นกรณีแบบนั้น ใช้ค่า enum อย่าง Bear จะเหมาะกว่า
ถ้าคุณกำลังมองหาหนังสือที่จะช่วยเรียนรู้เรื่องการออกแบบระบบที่ดีในมุมที่เป็นนามธรรมมากขึ้น ฉันแนะนำอย่างแรงให้ลองอ่าน John Gall’s Systemantics ในฐานะวิศวกรแล้ว ฉันรู้สึกว่ามันเป็นหนังสือที่ควรอ่านจริง ๆ