หนึ่ง service สองฐานข้อมูล: แยกหน้าที่ config ที่ engine อ่านเอง กับ domain data ที่ app เป็นเจ้าของให้เด็ดขาด
บาง service เลี่ยงการใช้ DB สองตัวคนละ paradigm ไม่ได้ เพราะ third-party engine ต้องการ schema ของมันเอง บทความนี้เล่าวิธีลากเส้น ownership boundary ให้ชัดว่าอะไรเขียนที่ไหน กัน drift และ engine พัง
อ่าน ~8 นาที
ปัญหา: บาง service เลี่ยง two-DB ไม่ได้ เพราะ engine ต้องการ schema ของมันเอง
ผมเคยดูแล service ตัวหนึ่งที่ต้องขับ third-party engine ตัวหนึ่ง (ในเคสผมคือ media/telephony engine แต่หลักการเดียวกันกับ search engine, workflow engine, หรือ auth provider ที่ host เอง) engine พวกนี้มีนิสัยร่วมกันอย่างหนึ่ง คือมัน อ่าน config ของตัวเองจากฐานข้อมูลโดยตรง ตาม schema ที่มันบังคับ ไม่ผ่าน API ของเราเลย คุณไม่มีสิทธิ์ออกแบบตารางพวกนั้น มันกำหนดชื่อ column, ชื่อ table, ชนิดข้อมูลมาให้หมดแล้ว หน้าที่ของคุณคือ "ป้อน" ข้อมูลให้ถูก format เท่านั้น
ทีนี้ service เดียวกันก็มี business data ของมันเอง เช่น ผู้ใช้, สิทธิ์, โครงสร้างองค์กร, อุปกรณ์, ประวัติ ซึ่งเป็นโลกที่ คุณเป็นเจ้าของ schema เต็มตัว อยากออกแบบยังไงก็ได้ ตอนแรกหลายคน (รวมผมในอดีต) จะคิดว่า "ก็ยัดทุกอย่างลง DB เดียวสิ จะได้ join ง่าย transaction เดียวจบ" — แล้วมันก็เจ็บ เพราะสองโลกนี้มี paradigm, lifecycle, และเจ้าของคนละคนกันโดยสิ้นเชิง
Thesis ของบทความนี้คือ: เมื่อ engine ต้องการ config DB ของมันเอง อย่าฝืนรวมกับ domain DB ของคุณ ให้ลากเส้น ownership boundary ให้ชัดว่า DB ไหน engine อ่านเอง (คุณแค่ feed) และ DB ไหน app เป็นเจ้าของ business logic แล้วบังคับว่า "อะไรเขียนที่ไหน" อย่างเคร่งครัด
แยกบทบาท: config DB ที่ engine อ่าน realtime vs domain DB ที่ app เป็นเจ้าของ
วิธีที่ผมใช้ตัดสินว่าข้อมูลตัวไหนควรอยู่ DB ไหน คือถามคำถามเดียว: "ใครคือ reader ที่แท้จริงของ record นี้?"
- Config DB (relational, engine-owned schema) — reader คือ engine เอง มันอ่านตลอดเวลา (realtime) เพื่อรู้ว่ามี endpoint ไหน, credential อะไร, route ยังไง record ในนี้ = "instruction สำหรับ engine" ไม่ใช่ข้อมูลธุรกิจ ตัวอย่างในระบบผมคือตาราง config ของ engine ที่เก็บ endpoint / auth / contact ซึ่ง engine เขียน field บางตัวเองด้วยซ้ำ (เช่น สถานะการ register, ค่า round-trip time) โดยที่ app ผมไม่ได้แตะ
- Domain DB (document, app-owned schema) — reader คือ app ของคุณ เก็บ business entity ทั้งหมด: user, org tree, project, device metadata, playlist, ประวัติ record ในนี้ = "ความจริงเชิงธุรกิจ" ที่ engine ไม่รู้จักและไม่จำเป็นต้องรู้
สังเกตประเด็นสำคัญ: บาง field ดูซ้ำกันข้าม DB เช่น "อุปกรณ์ตัวนี้ online ไหม" ในระบบผม ความจริงเรื่อง presence มาจาก config DB (engine เขียนตอนอุปกรณ์ register เข้ามา) ไม่ใช่จาก domain DB ถ้าผมไปเก็บ online flag ซ้ำใน domain DB แล้วพยายาม sync มันจะ drift ทันทีเพราะ engine เขียนถี่กว่ามาก บทเรียนคือ: presence/runtime state ที่ engine เป็นคนรู้ก่อน ให้อ่านจาก config DB โดยตรง อย่า mirror มาไว้ domain DB
อันตรายของการปน: เขียน domain logic ลง config DB = engine พังหรือ drift
ครั้งหนึ่งผมเคยเห็นคนพยายามยัด business field เพิ่มเข้าไปในตารางของ engine (เพราะ "มันมี record ของอุปกรณ์อยู่แล้วนี่ ขอเพิ่มอีก column เดียว") ผลคือ engine ที่ parse ตารางตาม schema ที่มันคาดหวัง เจอ column แปลกปลอมแล้ว behavior เพี้ยน บางเวอร์ชัน crash ตอน bootstrap บางเวอร์ชัน ignore เงียบๆ แล้วคุณก็ debug ไม่เจอว่าทำไม config บางอย่างไม่ทำงาน
อันตรายมี 3 ชั้น:
- Engine พัง — schema ที่ engine อ่านตรงคือ contract ที่คุณไม่ได้เขียน การเติม/แก้ column = แก้ contract ของคนอื่น engine อัปเกรดเวอร์ชันเมื่อไหร่ก็พังได้อีก
- Drift — พอ business logic เขียนทับ config field ที่ engine ก็เขียนเอง คุณจะได้ race: ใครเขียนทีหลังชนะ ค่าที่อ่านออกมาไม่ตรงกับความจริงฝั่งไหนเลย
- ownership กำกวม — ทีมงงว่า field นี้ใครแก้ได้ ทำให้ทุก migration กลายเป็นเรื่องน่ากลัว
กฎที่ผมยึด: config DB เป็น write-only จากฝั่ง app ในแง่ provisioning เท่านั้น app เขียนเฉพาะ record ที่ engine ต้องใช้ ตาม schema ที่ engine กำหนด และ ไม่เก็บ business meaning ใดๆ ลงไปเกินกว่าที่ engine ร้องขอ business meaning ทั้งหมดอยู่ domain DB ถ้าต้องโยง ให้ใช้ key ร่วม (เช่น username/id ที่เป็นทั้ง PK ฝั่ง config และ field ใน domain) เป็น convention ข้าม DB ไม่ใช่ foreign key จริง
Sync boundary: provisioning ที่ต้อง materialize domain → config อย่าง atomic
เมื่อสอง DB แยกกัน คำถามต่อไปคือ "ตอนสร้าง entity ใหม่ที่ต้องมีทั้งสองฝั่ง จะทำยังไงให้ไม่ค้างครึ่งๆ กลางๆ" เช่น สร้างอุปกรณ์ใหม่หนึ่งตัว = ต้องเขียน config ฝั่ง engine (เพื่อให้ engine รู้จัก) + เขียน metadata ฝั่ง domain (เพื่อให้ระบบธุรกิจรู้จัก) ถ้าเขียนฝั่งแรกสำเร็จแล้วฝั่งที่สอง fail คุณจะได้ orphan record ที่ engine รู้จักแต่ระบบธุรกิจไม่รู้จัก (หรือกลับกัน)
คุณไม่มี distributed transaction ข้าม relational + document เพราะฉะนั้นต้องออกแบบ provisioning ให้มี compensating rollback เอง หลักการคือ เขียน DB ที่ rollback ยากกว่า (มี transaction จริง) ก่อน แล้วค่อยเขียนอีกฝั่ง ถ้าฝั่งหลัง fail ให้ย้อนฝั่งแรกด้วยมือ
// provisioning อุปกรณ์ใหม่ = เขียน 2 DB โดยไม่มี global transaction
async function provisionDevice(input) {
// 1) เขียน config DB (engine-owned) ก่อน — ใช้ transaction จริงของ relational
// ถ้า step นี้ fail ก็ยังไม่มีอะไรค้าง ปล่อย throw ได้เลย
await pg.transaction(async (tx) => {
await tx.insert('ps_auths', { id: input.sipId, username: input.sipId, password: input.secret });
await tx.insert('ps_aors', { id: input.sipId });
await tx.insert('ps_endpoints', { id: input.sipId, aors: input.sipId, auth: input.sipId });
});
try {
// 2) เขียน domain DB (app-owned) — business metadata ที่ engine ไม่รู้จัก
await devices.insert({
deviceId: input.deviceId,
name: input.name,
orgUnitId: input.orgUnitId, // business ownership
sipUsername: input.sipId, // key ร่วมข้าม DB (convention ไม่ใช่ FK)
});
} catch (err) {
// 3) domain fail → ย้อน config DB คืน กัน orphan endpoint ค้างใน engine
await pg.transaction(async (tx) => {
await tx.delete('ps_endpoints', { id: input.sipId });
await tx.delete('ps_aors', { id: input.sipId });
await tx.delete('ps_auths', { id: input.sipId });
});
throw err; // โยนต่อให้ caller รู้ว่า provision ล้มเหลวทั้งก้อน
}
}
จุดที่คนพลาดบ่อยคือลืม step 3 แล้วปล่อยให้ engine มี endpoint ที่ไม่มี metadata คู่ พอมันพยายาม register เข้ามาก็จะเป็น ghost ที่ระบบธุรกิจตามหาต้นตอไม่เจอ อีกทางเลือกคือทำ reconciliation job เดินเทียบสอง DB เป็นระยะ แล้วลบตัวที่ orphan — เหมาะกับกรณีที่ inline rollback ก็ยัง fail ได้ (เช่น process ตายระหว่าง rollback)
Backup, migration, testing: recovery model ของสอง DB คนละเรื่องกัน
เมื่อยอมรับว่าเป็นคนละ DB แล้ว การดูแลก็ต้องแยกด้วย และนี่คือจุดที่ two-DB คุ้มค่าจริง เพราะ recovery model มันต่างกันโดยธรรมชาติ
- Backup: config DB มัก regenerable — ถ้าคุณเก็บ domain DB ไว้ครบ คุณ re-provision config DB ขึ้นใหม่ทั้งหมดได้จาก domain (เพราะ config เป็นแค่ projection ของ business data) ผมจึงถือ domain DB เป็น source of truth ที่ต้อง backup แบบจริงจัง ส่วน config DB เป็น derived state ที่ยอมสร้างใหม่ได้ ขอแค่มี provisioning script
- Migration: config DB migrate ตาม engine (engine อัปเวอร์ชัน = schema มัน migrate เอง คุณห้ามไปแก้มือ) ส่วน domain DB migrate ตาม business ของคุณ อย่ารวม migration ของสองฝั่งเป็นชุดเดียว มันคนละ lifecycle
- Testing: เวลาเทส business logic ให้ mock ฝั่ง engine-facing DB ออกไปเลย เพราะสิ่งที่คุณอยากเทสคือ "logic ของ app ถูกไหม" ไม่ใช่ "engine อ่าน schema ถูกไหม" (อันหลังเป็นความรับผิดชอบของ engine) ทำ interface บางๆ คั่น provisioning ไว้ แล้ว inject fake ตอนเทส domain — ทำให้ test เร็วและไม่ต้องยก engine จริงขึ้นมา
เมื่อไหร่ควรรวมกลับเป็น DB เดียว และเมื่อไหร่ไม่ควร
two-DB มีต้นทุน (provisioning ซับซ้อน, ไม่มี transaction ข้าม DB, ดูแลสองระบบ) เพราะฉะนั้นอย่าแยกเพราะเท่ ให้แยกเมื่อมันบังคับ เกณฑ์ตัดสินของผม:
- ต้องแยก เมื่อ third-party engine อ่าน DB ของมันตรงตาม schema ที่มันบังคับ (คุณไม่มีทางเลือก) หรือเมื่อ paradigm ต่างกันชัด (relational config ที่ engine query ถี่ vs document/graph domain ที่ยืดหยุ่น) หรือเมื่อ write pattern ต่างกันมาก (config เขียนถี่แบบ high-frequency presence vs domain เขียนนานๆ ที) การแยกช่วยเลี่ยง write amplification ปนกัน
- ควรรวมกลับ เมื่อไม่มี external engine อ่านตรง (คุณคุม reader ทั้งหมดเอง) และทั้งสองชุดข้อมูล lifecycle เดียวกัน แก้พร้อมกันเสมอ ต้องการ transaction คร่อม การมี DB เดียวลด operational overhead และได้ integrity ฟรี — อย่าแยก DB เพียงเพราะ "ข้อมูลคนละชนิด" ถ้า reader เป็นคนเดียวกันและ consistency สำคัญกว่า
หัวใจไม่ได้อยู่ที่จำนวน DB แต่อยู่ที่ ownership: มี engine ภายนอกที่เป็นเจ้าของ schema หนึ่งชุดหรือเปล่า ถ้ามี แยก ถ้าไม่มี รวมได้
สรุป
- ถาม "ใครคือ reader ที่แท้จริง" ของแต่ละ record — engine อ่านเอง = config DB, app อ่าน = domain DB
- config DB มี schema ที่ engine บังคับ อย่าเติม business field ลงไป จะทำ engine พังหรือ drift
- presence/runtime state ที่ engine รู้ก่อน ให้อ่านจาก config DB ตรง อย่า mirror ซ้ำไว้ domain DB
- โยงข้าม DB ด้วย key ร่วมเป็น convention ไม่ใช่ foreign key จริง
- provisioning ที่แตะสอง DB ต้องมี compensating rollback (เขียนฝั่งที่ transaction แข็งแรงก่อน) หรือ reconciliation job กัน orphan
- ถือ domain DB เป็น source of truth ที่ต้อง backup จริง ส่วน config DB เป็น derived state ที่ re-provision ใหม่ได้
- เทส domain logic โดย mock ฝั่ง engine-facing DB ออก — คนละความรับผิดชอบ
- แยก DB เมื่อ external engine บังคับ schema/paradigm/write-pattern ต่าง — รวมกลับเมื่อ reader เป็นคุณคนเดียวและ consistency สำคัญกว่า




