การแก้ไขความขัดแย้งแบบ Offline-first สำหรับฟอร์ม ใน Kotlin + SQLite
เรียนรู้การแก้ไขความขัดแย้งในฟอร์มแบบ offline-first: กฎการรวมที่ชัดเจน, กระบวนการซิงค์ Kotlin + SQLite ที่เรียบง่าย และรูปแบบ UX ที่ใช้ได้จริงสำหรับความขัดแย้งในการแก้ไข

เกิดอะไรขึ้นจริง ๆ เมื่อสองคนแก้ไขแบบออฟไลน์
ฟอร์มแบบ offline-first ให้ผู้ใช้ดูและแก้ไขข้อมูลได้แม้เครือข่ายช้าหรือไม่พร้อมใช้งาน แทนที่จะรอเซิร์ฟเวอร์ แอปจะเขียนการเปลี่ยนแปลงลงในฐานข้อมูล SQLite ในเครื่องก่อน แล้วค่อยซิงค์ทีหลัง
นั่นทำให้การใช้งานรู้สึกทันที แต่ก็สร้างความจริงอย่างหนึ่ง: อุปกรณ์สองเครื่องอาจเปลี่ยนเรคคอร์ดเดียวกันโดยไม่รู้จักกัน
ความขัดแย้งทั่วไปเป็นแบบนี้: ช่างภาคสนามเปิดใบงานบนแท็บเล็ตที่ชั้นใต้ดินซึ่งไม่มีสัญญาณ เขาเปลี่ยนสถานะเป็น "เสร็จ" และเพิ่มบันทึกพร้อมกัน ในเวลาเดียวกันหัวหน้างานบนโทรศัพท์อีกเครื่องอัปเดตใบงานเดียวกัน เปลี่ยนผู้รับผิดชอบ และแก้ไขวันที่ส่ง ทั้งคู่กดบันทึก ทั้งคู่สำเร็จในเครื่อง ไม่มีใครทำผิด
เมื่อซิงค์เกิดขึ้น เซิร์ฟเวอร์ต้องตัดสินใจว่าเรคคอร์ด "จริง" คืออะไร หากคุณไม่จัดการความขัดแย้งอย่างชัดเจน มักพบผลลัพธ์ดังนี้:
- Last write wins: การซิงค์ล่าสุดเขียนทับการเปลี่ยนแปลงก่อนหน้าและข้อมูลของใครบางคนหายไป
- ความล้มเหลวแบบรุนแรง: ซิงค์ปฏิเสธการอัปเดตและแอปแสดงข้อผิดพลาดที่ไม่ช่วยเหลือ
- เรคคอร์ดซ้ำซ้อน: ระบบสร้างสำเนาที่สองเพื่อหลีกเลี่ยงการเขียนทับ ทำให้รายงานยุ่ง
- การรวมเงียบ: ระบบผสานการเปลี่ยนแปลง แต่ผสานฟิลด์ในทางที่ผู้ใช้ไม่คาดคิด
ความขัดแย้งไม่ใช่บั๊ก มันเป็นผลลัพธ์ที่คาดได้ของการให้คนทำงานโดยไม่มีการเชื่อมต่อแบบสด ซึ่งเป็นจุดประสงค์ของ offline-first
เป้าหมายมีสองอย่าง: ปกป้องข้อมูลและทำให้แอปใช้ง่าย ซึ่งโดยทั่วไปหมายถึงกฎการรวมที่ชัดเจน (มักระดับฟิลด์) และ UX ที่รบกวนผู้ใช้เฉพาะเมื่อจำเป็นจริง ๆ หากการแก้ไขสองฝั่งแตะคนละฟิลด์ คุณมักผสานเงียบได้ หากสองคนเปลี่ยนฟิลด์เดียวกัน แอปควรแสดงให้เห็นและช่วยให้ใครสักคนเลือกผลลัพธ์ที่ถูกต้อง
เลือกกลยุทธ์ความขัดแย้งให้ตรงกับข้อมูลของคุณ
ความขัดแย้งไม่ใช่ปัญหาทางเทคนิคเป็นหลัก แต่เป็นการตัดสินใจเชิงผลิตภัณฑ์เกี่ยวกับว่า "ถูกต้อง" หมายถึงอะไรเมื่อสองคนเปลี่ยนเรคคอร์ดเดียวกันก่อนซิงค์
สามกลยุทธ์ครอบคลุมแอปแบบออฟไลน์ส่วนใหญ่:
- Last write wins (LWW): ยอมรับการแก้ไขที่ใหม่ที่สุดและเขียนทับของเก่า
- Manual review: หยุดแล้วให้มนุษย์เลือกว่าจะเก็บค่าไหน
- Field-level merge: ผสานการเปลี่ยนแปลงทีละฟิลด์ และขอคำตัดสินเฉพาะเมื่อสองคนแตะฟิลด์เดียวกัน
LWW อาจใช้ได้เมื่อความเร็วสำคัญกว่าความถูกต้องสมบูรณ์และต้นทุนของการผิดพลาดต่ำ คิดถึงบันทึกภายใน แท็กที่ไม่สำคัญ หรือสถานะร่างที่แก้ซ้ำได้
Manual review เป็นตัวเลือกที่ปลอดภัยกว่าสำหรับฟิลด์ที่มีผลกระทบสูงที่แอปไม่ควรเดา: ข้อความทางกฎหมาย การยืนยันความสอดคล้อง ยอดเงินเงินเดือนและใบแจ้งหนี้ รายละเอียดธนาคาร คำแนะนำการใช้ยา และสิ่งใดก็ตามที่สามารถสร้างความรับผิดได้
Field-level merge มักเป็นค่าเริ่มต้นที่ดีที่สุดสำหรับฟอร์มที่บทบาทต่าง ๆ อัปเดตคนละส่วน ตัวแทนบริการแก้ไขที่อยู่ ขณะที่ฝ่ายขายอัปเดตวันที่ต่ออายุ การรวมต่อฟิลด์เก็บทั้งสองการเปลี่ยนแปลงโดยไม่รบกวนใคร แต่ถ้าผู้ใช้ทั้งสองแก้ไขวันที่ต่ออายุ ฟิลด์นั้นควรกระตุ้นให้มีการตัดสินใจ
ก่อนจะเริ่มลงมือ ให้เขียนลงไปว่า "ถูกต้อง" ของธุรกิจคุณหมายถึงอะไร เช็คลิสต์สั้น ๆ ช่วยได้:
- ฟิลด์ใดต้องสะท้อนค่าสถานะโลกจริงล่าสุดเสมอ (เช่น สถานะปัจจุบัน)?
- ฟิลด์ใดเป็นประวัติศาสตร์และไม่ควรถูกเขียนทับ (เช่น เวลาเมื่อส่งแล้ว)?
- ใครมีสิทธิ์เปลี่ยนแต่ละฟิลด์ (บทบาท ความเป็นเจ้าของ การอนุมัติ)?
- แหล่งความจริงเมื่อค่าขัดแย้งคืออะไร (อุปกรณ์ เซิร์ฟเวอร์ การอนุมัติของผู้จัดการ)?
- ถ้าเลือกผิดจะเกิดอะไรขึ้น (ความรำคาญเล็กน้อย vs ผลกระทบทางการเงินหรือกฎหมาย)?
เมื่อกฎชัดเจน โค้ดซิงค์มีงานอย่างเดียว: บังคับใช้กฎเหล่านั้น
กำหนดกฎการรวมต่อฟิลด์ ไม่ใช่ต่อหน้าจอ
เมื่อเกิดความขัดแย้ง มันไม่ค่อยจะกระทบทั้งฟอร์มอย่างเท่าเทียม คนหนึ่งอาจอัปเดตเบอร์โทรศัพท์ ในขณะที่อีกคนเพิ่มบันทึก ถ้าคุณปฏิบัติต่อเรคคอร์ดทั้งก้อนเป็น all-or-nothing คุณจะบังคับให้คนต้องทำงานที่ดีซ้ำ
การรวมระดับฟิลด์ทำนายได้ดีกว่าเพราะแต่ละฟิลด์มีพฤติกรรมที่รู้จัก UX ยังคงสงบและเร็ว
วิธีง่าย ๆ ในการเริ่มคือแยกฟิลด์เป็นหมวด "มักปลอดภัย" และ "มักไม่ปลอดภัย"
มักปลอดภัยที่จะรวมโดยอัตโนมัติ: บันทึกและความคิดเห็นภายใน, แท็ก, ไฟล์แนบ (มักเป็น union), และ timestamp อย่าง "last contacted" (มักเก็บค่าล่าสุด)
มักไม่ปลอดภัยที่จะรวมโดยอัตโนมัติ: สถานะ/state, ผู้รับผิดชอบ/assignee, ยอดรวม/ราคา, ธงการอนุมัติ, และจำนวนสินค้าคงคลัง
จากนั้นเลือกกฎลำดับความสำคัญต่อฟิลด์ ตัวเลือกทั่วไปคือ server wins, client wins, role wins (เช่น ผู้จัดการชนะตัวแทน), หรือ tie-breaker ที่กำหนดได้เช่น เวอร์ชันเซิร์ฟเวอร์ล่าสุด
คำถามสำคัญคือเกิดอะไรขึ้นเมื่อทั้งสองฝ่ายเปลี่ยนฟิลด์เดียวกัน สำหรับแต่ละฟิลด์ให้เลือกพฤติกรรมหนึ่ง:
- ผสานอัตโนมัติตามกฎที่ชัดเจน (เช่น แท็กเป็น union)
- เก็บทั้งสองค่า (เช่น ต่อบันทึกพร้อมผู้เขียนและเวลา)
- ทำเครื่องหมายให้รีวิว (เช่น สถานะและผู้รับผิดชอบต้องเลือก)
ตัวอย่าง: ตัวแทนบริการ A เปลี่ยน status จาก "Open" เป็น "Pending" ตัวแทน B แก้ไข notes และเพิ่มแท็ก "refund" ในการซิงค์ คุณสามารถผสาน notes และ tags ได้อย่างปลอดภัย แต่ไม่ควรผสาน status เงียบ ๆ ให้แจ้งเฉพาะ status โดยที่อย่างอื่นถูกผสานแล้ว
เพื่อป้องกันการโต้เถียงภายหลัง ให้บันทึกแต่ละกฎเป็นประโยคสั้น ๆ ต่อฟิลด์:
- "
notes: เก็บทั้งสอง ต่อท้ายด้วยรายการล่าสุด รวมผู้เขียนและเวลา." - "
tags: union, ลบเฉพาะถ้าลบทั้งสองฝั่งเท่านั้น." - "
status: หากเปลี่ยนทั้งสองฝั่ง ให้ต้องมีการเลือกโดยผู้ใช้." - "
assignee: ผู้จัดการชนะ ถ้าไม่มีให้เซิร์ฟเวอร์ชนะ."
กฎประโยคเดียวนี้จะเป็นแหล่งที่มาของความจริงสำหรับโค้ด Kotlin, คำสั่ง SQLite, และ UI ความขัดแย้ง
พื้นฐานโมเดลข้อมูล: เวอร์ชันและฟิลด์ตรวจสอบใน SQLite
ถ้าคุณต้องการให้ความขัดแย้งดูน่าเชื่อถือ ให้เพิ่มคอลัมน์เมตาดาทาขนาดเล็กในทุกตารางที่ซิงค์กับเซิร์ฟเวอร์ ถ้าไม่มีคุณจะบอกไม่ได้ว่ากำลังดูการแก้ไขใหม่ สำเนาเก่า หรอสองการแก้แย้งที่ต้องรวม
ขั้นต่ำที่ใช้งานได้สำหรับแต่ละเรคคอร์ดที่ซิงค์กับเซิร์ฟเวอร์:
id(primary key คงที่): อย่าใช้ซ้ำversion(integer): เพิ่มทุกครั้งที่เขียนสำเร็จบนเซิร์ฟเวอร์updated_at(timestamp): เวลาที่เรคคอร์ดถูกเปลี่ยนล่าสุดupdated_by(text หรือ user id): ใครเป็นคนเปลี่ยนล่าสุด
ในอุปกรณ์ ให้เพิ่มฟิลด์เฉพาะเครื่องเพื่อติดตามการเปลี่ยนแปลงที่ยังไม่ได้ยืนยันจากเซิร์ฟเวอร์:
dirty(0/1): มีการเปลี่ยนแปลงในเครื่องpending_sync(0/1): คิวเพื่ออัปโหลดแต่ยังไม่ยืนยันlast_synced_at(timestamp): ครั้งสุดท้ายที่แถวนี้ตรงกับเซิร์ฟเวอร์sync_error(text, ทางเลือก): สาเหตุล้มเหลวล่าสุดเพื่อแสดงใน UI
Optimistic concurrency เป็นกฎง่าย ๆ ที่ป้องกันการเขียนทับเงียบ: การอัปเดตแต่ละครั้งรวมเวอร์ชันที่คุณคิดว่ากำลังแก้ (expected_version) ถ้าเรคคอร์ดบนเซิร์ฟเวอร์ยังเป็นเวอร์ชันนั้น การอัปเดตจะยอมรับและเซิร์ฟเวอร์จะคืนเวอร์ชันใหม่ ถ้าไม่ ก็เป็นความขัดแย้ง
ตัวอย่าง: ผู้ใช้ A และ B ดาวน์โหลด version = 7 A ซิงค์ก่อน; เซิร์ฟเวอร์เพิ่มเป็น 8 เมื่อ B พยายามซิงค์ด้วย expected_version = 7 เซิร์ฟเวอร์ปฏิเสธเป็นความขัดแย้ง ดังนั้นแอปของ B จะรวมแทนที่จะเขียนทับ
สำหรับหน้าจอความขัดแย้งที่ดี ให้เก็บจุดเริ่มต้นร่วม: สิ่งที่ผู้ใช้แก้จาก ตัวอย่างสองวิธีที่ใช้กัน:
- เก็บ snapshot ของเรคคอร์ดล่าสุดที่ซิงค์ (คอลัมน์ JSON หนึ่งคอลัมน์หรือเทเบิลคู่ขนาน)
- เก็บ change log (แถวต่อการแก้ไขหรือฟิลด์ต่อการแก้ไข)
สแนปชอตง่ายกว่าและมักเพียงพอสำหรับฟอร์ม บันทึกการเปลี่ยนแปลงหนักกว่า แต่สามารถอธิบายได้อย่างชัดเจนว่ามีการเปลี่ยนแปลงอะไรทีละฟิลด์
อย่างไรก็ตาม UI ควรแสดงค่าทางเลือกสามค่าในแต่ละฟิลด์: การแก้ไขของผู้ใช้ ค่าเซิร์ฟเวอร์ปัจจุบัน และจุดเริ่มต้นร่วม
สแนปชอตเรคคอร์ดกับ change log: เลือกวิธีใดวิธีหนึ่ง
เมื่อคุณซิงค์ฟอร์มแบบ offline-first คุณสามารถอัปโหลดเรคคอร์ดทั้งก้อน (snapshot) หรือลิสต์ของปฏิบัติการ (change log) ทั้งสองทำงานได้กับ Kotlin และ SQLite แต่มีพฤติกรรมต่างกันเมื่อสองคนแก้ไขเรคคอร์ดเดียวกัน
ทางเลือก A: สแนปชอตทั้งเรคคอร์ด
ด้วยสแนปชอต ทุกการบันทึกเขียนสเตตล่าสุดทั้งก้อน (ทุกฟิลด์) ในการซิงค์คุณส่งเรคคอร์ดพร้อมหมายเลขเวอร์ชัน หากเซิร์ฟเวอร์เห็นว่าเวอร์ชันเก่า คุณจะมีความขัดแย้ง
นี่ง่ายสร้างและอ่านเร็ว แต่บ่อยครั้งสร้างความขัดแย้งใหญ่กว่าจำเป็น หากผู้ใช้ A แก้เบอร์โทรศัพท์ ในขณะที่ผู้ใช้ B แก้ที่อยู่ วิธี snapshot อาจถือเป็นการชนกันใหญ่ทั้งที่การแก้ไขไม่ทับซ้อน
ทางเลือก B: บันทึกการเปลี่ยนแปลง (operations)
ด้วย change log คุณเก็บสิ่งที่เปลี่ยน ไม่ใช่เรคคอร์ดทั้งก้อน แต่ละการแก้ไขในเครื่องกลายเป็นปฏิบัติการที่คุณสามารถเล่นซ้ำบนสเตตเซิร์ฟเวอร์ล่าสุด
ปฏิบัติการที่มักรวมได้ง่ายกว่า:
- ตั้งค่าฟิลด์ (set
emailเป็นค่าที่ใหม่) - ต่อบันทึก (append a note)
- เพิ่มแท็ก (add one tag)
- ลบแท็ก (remove one tag)
- ทำเครื่องหมายเช็คบ็อกให้เสร็จ (set
isDonetrue พร้อม timestamp)
operation logs ลดความขัดแย้งได้เพราะหลายการกระทำไม่ทับซ้อน การต่อบันทึกไม่ค่อยขัดแย้งกับการต่อบันทึกอื่น ๆ แท็กที่เพิ่ม/ลบสามารถรวมเหมือนคณิตศาสตร์เซต แต่สำหรับฟิลด์ค่าหนึ่งค่า คุณยังต้องมีกฎต่อฟิลด์เมื่อสองการแก้ไขแข่งขันกัน
ข้อแลกเปลี่ยนคือความซับซ้อน: ต้องมี ID ปฏิบัติการที่คงที่ การเรียงลำดับ (ลำดับท้องถิ่นและเวลาของเซิร์ฟเวอร์) และกฎสำหรับปฏิบัติการที่ไม่ commute
การทำความสะอาด: บีบอัดหลังซิงค์สำเร็จ
operation logs โตขึ้น ดังนั้นวางแผนการย่อลง
แนวทางที่ใช้กันคือการบีบอัดต่อเรคคอร์ด: เมื่อปฏิบัติการทั้งหมดจนถึงเวอร์ชันเซิร์ฟเวอร์ที่รู้จักได้รับการยอมรับ ให้พับเป็นสแนปชอตใหม่ แล้วลบปฏิบัติการเก่า เก็บ tail สั้น ๆ เฉพาะหากคุณต้องการ undo, auditing, หรือ debug ง่ายขึ้น
ขั้นตอนการซิงค์ทีละขั้นสำหรับ Kotlin + SQLite
กลยุทธ์ซิงค์ที่ดีส่วนใหญ่คือการเข้มงวดกับสิ่งที่คุณส่งและสิ่งที่ยอมรับกลับ เป้าหมายคือไม่เขียนทับข้อมูลที่ใหม่กว่าโดยไม่ได้ตั้งใจ และทำให้ความขัดแย้งชัดเจนเมื่อไม่สามารถรวมได้อย่างปลอดภัย
ลำดับปฏิบัติที่แนะนำ:
-
เขียนการแก้ไขทุกครั้งลง SQLite ก่อน บันทึกการเปลี่ยนแปลงในธุรกรรมท้องถิ่นและมาร์กเรคคอร์ดเป็น
pending_sync = 1เก็บlocal_updated_atและserver_versionที่ทราบล่าสุด -
ส่งเป็น patch ไม่ใช่เรคคอร์ดทั้งก้อน เมื่อการเชื่อมต่อกลับมา ส่งไอดีเรคคอร์ดพร้อมเฉพาะฟิลด์ที่เปลี่ยนแปลง พร้อม
expected_version -
ให้เซิร์ฟเวอร์ปฏิเสธเมื่อเวอร์ชันไม่ตรงกัน หากเวอร์ชันเซิร์ฟเวอร์ไม่ตรงกับ
expected_versionมันจะคืน payload ความขัดแย้ง (เรคคอร์ดเซิร์ฟเวอร์ การเปลี่ยนแปลงที่เสนอ และฟิลด์ที่ต่างกัน) ถ้าเวอร์ชันตรง มันจะใช้ patch เพิ่มเวอร์ชัน และคืนเรคคอร์ดที่อัปเดต -
ใช้การรวมอัตโนมัติก่อน แล้วค่อยถามผู้ใช้ รันกฎการรวมระดับฟิลด์ ปฏิบัติต่อฟิลด์ที่ปลอดภัยเช่นบันทึกต่างจากฟิลด์ที่ละเอียดอ่อนเช่น
status,price, หรือassignee -
คอมมิตผลสุดท้ายและล้าง flag pending ไม่ว่าจะผสานอัตโนมัติหรือแก้ไขด้วยมือ ให้เขียนเรคคอร์ดสุดท้ายกลับไปยัง SQLite, อัปเดต
server_version, ตั้งpending_sync = 0, และบันทึกข้อมูลตรวจสอบพอที่จะอธิบายสิ่งที่เกิดขึ้นภายหลัง
ตัวอย่าง: สองเซลส์รีพ์แก้คำสั่งซื้อแบบออฟไลน์ A เปลี่ยนวันที่ส่ง B เปลี่ยนเบอร์โทรลูกค้า ด้วย patch เซิร์ฟเวอร์สามารถยอมรับทั้งสองการเปลี่ยนแปลงได้สะอาด ๆ ถ้าทั้งคู่เปลี่ยนวันที่ส่ง ให้แสดงการตัดสินใจอย่างชัดเจนแทนที่จะบังคับให้กรอกทั้งฟอร์มใหม่
รักษาสัญญา UI ให้สอดคล้อง: "Saved" ควรหมายถึงบันทึกในเครื่องเสมอ "Synced" เป็นสถานะแยกชัดเจน
รูปแบบ UX ในการแก้ความขัดแย้งในฟอร์ม
ความขัดแย้งควรเป็นข้อยกเว้น ไม่ใช่กระบวนการปกติ เริ่มจากผสานอัตโนมัติในสิ่งที่ปลอดภัย แล้วขอคำตัดสินจากผู้ใช้เฉพาะเมื่อจำเป็นจริง ๆ
ทำให้ความขัดแย้งเกิดขึ้นน้อยด้วยค่าเริ่มต้นที่ปลอดภัย
ถ้าสองคนแก้ไขคนละฟิลด์ ให้ผสานโดยอัตโนมัติโดยไม่แสดง modal เก็บทั้งสองการเปลี่ยนแปลงและแสดงข้อความเล็ก ๆ ว่า "อัปเดตหลังซิงค์"
สงวนการแจ้งเฉพาะการชนกันจริง ๆ: ฟิลด์เดียวกันถูกเปลี่ยนโดยทั้งสองอุปกรณ์ หรือการเปลี่ยนแปลงหนึ่งขึ้นกับอีกฟิลด์หนึ่ง (เช่น สถานะพร้อมเหตุผลของสถานะ)
เมื่อจำเป็นต้องถาม ให้ทำให้เสร็จได้เร็ว
หน้าจอความขัดแย้งควรตอบสองข้อ: อะไรที่เปลี่ยน และอะไรจะถูกบันทึก เปรียบเทียบค่าข้าง ๆ กัน: "การแก้ของคุณ", "การแก้ของเขา", และ "ผลลัพธ์ที่บันทึก" หากมีแค่สองฟิลด์ที่ขัดแย้ง อย่าโชว์ทั้งฟอร์ม ให้กระโดดไปที่ฟิลด์นั้นและทำให้ส่วนที่เหลือเป็นแบบอ่านอย่างเดียว
จำกัดการกระทำให้เหลือเท่าที่จำเป็น:
- เก็บของฉัน
- เก็บของเขา
- แก้ไขผลลัพธ์สุดท้าย
- ตรวจทานทีละฟิลด์ (เฉพาะเมื่อจำเป็น)
การผสานบางส่วนเป็นจุดที่ UX มักยุ่ง ยกไฮไลต์เฉพาะฟิลด์ที่ขัดแย้งและติดป้ายแหล่งที่มาให้ชัดเจน ("ของคุณ" และ "ของเขา") เลือกตัวเลือกที่ปลอดภัยล่วงหน้าเพื่อให้ผู้ใช้ยืนยันและไปต่อได้
ตั้งความคาดหวังเพื่อไม่ให้ผู้ใช้รู้สึกติดค้าง บอกว่าเกิดอะไรขึ้นถ้าพวกเขาออก: เช่น "เราจะเก็บเวอร์ชันของคุณในเครื่องและพยายามซิงค์ใหม่ทีหลัง" หรือ "เรคคอร์ดนี้จะคงสถานะ Needs review จนกว่าคุณจะเลือก" ทำให้สถานะนั้นมองเห็นได้ในรายการเพื่อไม่ให้ความขัดแย้งหายไป
ถ้าคุณสร้างฟลูว์นี้ใน AppMaster แนวทาง UX เดียวกันนี้ยังใช้ได้: ผสานฟิลด์ที่ปลอดภัยก่อน แล้วแสดงขั้นตอนตรวจทอนแบบโฟกัสเฉพาะเมื่อฟิลด์ชนกัน
กรณียุ่งยาก: ลบ, ซ้ำ, และเรคคอร์ดที่ "หายไป"
ปัญหาซิงค์ที่ดูสุ่มส่วนใหญ่เกิดจากสามสถานการณ์: ใครสักคนลบในขณะที่อีกคนแก้ไข, สองอุปกรณ์สร้างสิ่งเดียวกันแบบออฟไลน์, หรือเรคคอร์ดถูกลบแล้วกลับมา สถานการณ์เหล่านี้ต้องมีกฎชัดเจนเพราะ LWW มักทำให้คนแปลกใจ
ลบ vs แก้ไข: ใครชนะ?
ตัดสินใจว่าการลบมีความสำคัญกว่าการแก้ไขหรือไม่ ในหลายแอปธุรกิจการลบชนะเพราะผู้ใช้คาดหวังว่าสิ่งที่ลบจะไม่อยู่แล้ว
ชุดกฎที่เป็นประโยชน์:
- ถ้าเรคคอร์ดถูกลบบนอุปกรณ์ใด ให้ถือว่าเป็นการลบทั่วทั้งระบบ แม้จะมีการแก้ไขในภายหลัง
- ถ้าต้องการให้การลบย้อนกลับได้ ให้แปลง "ลบ" เป็นสถานะ archived แทนการลบจริง
- ถ้ามีการแก้ไขมาถึงสำหรับเรคคอร์ดที่ถูกลบ ให้เก็บการแก้ไขในประวัติสำหรับการตรวจสอบ แต่ไม่คืนเรคคอร์ดกลับมา
การชนกันจากการสร้างแบบออฟไลน์และร่างซ้ำ
ฟอร์มแบบ offline-first มักสร้าง ID ชั่วคราว (เช่น UUID) ก่อนเซิร์ฟเวอร์จะให้ไอดีจริง การซ้ำเกิดเมื่อผู้ใช้สร้างร่างสองรายการสำหรับสิ่งเดียวกัน (ใบเสร็จเดียวกัน ตั๋วเดียวกัน)
ถ้าคุณมี natural key ที่คงที่ (หมายเลขใบเสร็จ บาร์โค้ด อีเมล+วันที่) ให้ใช้ตรวจจับการชน ถ้าไม่มี ให้ยอมรับว่าซ้ำจะเกิดขึ้นและให้ตัวเลือกรวมง่าย ๆ ภายหลัง
เคล็ดลับการใช้งาน: เก็บทั้ง local_id และ server_id ใน SQLite เมื่อเซิร์ฟเวอร์ตอบ ให้เขียนแผนที่และเก็บจนกว่าคุณแน่ใจว่าไม่มีการเปลี่ยนแปลงคิวที่ยังอ้างถึง local ID
ป้องกันการ "เกิดใหม่" หลังซิงค์
การเกิดใหม่เกิดขึ้นเมื่ออุปกรณ์ A ลบเรคคอร์ด แต่อุปกรณ์ B ออฟไลน์แล้วอัปโหลดสำเนาเก่ามาเป็น upsert ทำให้มันกลับมา
การแก้คือ tombstone แทนที่จะลบแถวทันที ให้มาร์กเป็นลบด้วย deleted_at (มักรวม deleted_by และ delete_version) ในการซิงค์ ให้ถือ tombstone เป็นการเปลี่ยนจริงที่สามารถเขียนทับสเตตที่ไม่ลบเก่าได้
ตัดสินใจระยะเวลาที่เก็บ tombstone ถ้าผู้ใช้สามารถออฟไลน์เป็นสัปดาห์ ให้เก็บนานกว่านั้น ลบทิ้งเมื่อมั่นใจว่าอุปกรณ์ที่ใช้งานได้ซิงค์ข้ามการลบนั้นแล้ว
ถ้าคุณรองรับ undo ให้ถือ undo เป็นการเปลี่ยนอีกครั้ง: ล้าง deleted_at และเพิ่มเวอร์ชัน
ข้อผิดพลาดทั่วไปที่ทำให้ข้อมูลหายหรือผู้ใช้หงุดหงิด
ความล้มเหลวของซิงค์หลายอย่างมาจากสมมติฐานเล็ก ๆ ที่เงียบ ๆ เขียนทับข้อมูลดีๆ
ข้อผิดพลาด 1: เชื่อถือเวลาของอุปกรณ์เพื่อเรียงลำดับการแก้ไข
โทรศัพท์อาจมีนาฬิกาผิด เขตเวลามีการเปลี่ยน และผู้ใช้เปลี่ยนนาฬิกาเอง หากคุณเรียงการเปลี่ยนแปลงด้วย timestamp ของอุปกรณ์ สักวันหนึ่งคุณจะนำการแก้ไขมาใช้ผิดลำดับ
เลือกใช้เวอร์ชันที่ออกโดยเซิร์ฟเวอร์ (monotonic serverVersion) และให้ timestamp ของไคลเอนต์แสดงผลเท่านั้น หากต้องใช้เวลา ให้เพิ่มการป้องกันและปรับความขัดแย้งบนเซิร์ฟเวอร์
ข้อผิดพลาด 2: ใช้ LWW โดยไม่ตั้งใจกับฟิลด์ที่ละเอียดอ่อน
LWW ดูเรียบง่ายจนกระทั่งมันกระทบฟิลด์ที่ไม่ควรถูก "ชนะ" โดยคนที่ซิงค์ทีหลัง สถานะ ยอดรวม การอนุมัติ และการมอบหมายมักต้องมีกฎชัดเจนหรือการรีวิวด้วยคน
เช็คลิสต์ความปลอดภัยสำหรับฟิลด์เสี่ยงสูง:
- ปฏิบัติการแปลงสถานะเป็น state machine ไม่ใช่แก้ไขอิสระ
- คำนวณยอดใหม่จากรายการย่อย อย่าผสานยอดรวมเป็นตัวเลขดิบ
- สำหรับเคาน์เตอร์ ให้รวมโดยใช้เดลต้า ไม่ใช่เลือกผู้ชนะ
- สำหรับความเป็นเจ้าของหรือ assignee ให้ต้องยืนยันเมื่อลงตัว
ข้อผิดพลาด 3: เขียนทับค่าบนเซิร์ฟเวอร์ที่ใหม่กว่าด้วยข้อมูลแคชเก่า
เกิดเมื่อไคลเอนต์แก้สแนปชอตเก่าแล้วอัปโหลดเรคคอร์ดทั้งก้อน เซิร์ฟเวอร์ยอมรับและการเปลี่ยนแปลงใหม่บนเซิร์ฟเวอร์หายไป
แก้ที่รูปแบบข้อมูลที่ส่ง: ส่งเฉพาะฟิลด์ที่เปลี่ยน (หรือ change log) บวกเวอร์ชันฐานที่คุณแก้ ถ้าเวอร์ชันฐานล้าหลัง เซิร์ฟเวอร์จะปฏิเสธหรือบังคับให้รวม
ข้อผิดพลาด 4: ไม่มีประวัติว่าใครเปลี่ยนอะไร
เมื่อเกิดความขัดแย้ง ผู้ใช้ต้องการคำตอบว่า: ฉันเปลี่ยนอะไร และคนอื่นเปลี่ยนอะไร ไม่มีข้อมูลผู้แก้ไขและประวัติฟิลด์ต่อฟิลด์ หน้าจอความขัดแย้งของคุณจะกลายเป็นการเดา
เก็บ updatedBy, เวลาอัปเดตฝั่งเซิร์ฟเวอร์ถ้ามี และอย่างน้อยประวัติง่าย ๆ ต่อฟิลด์
ข้อผิดพลาด 5: UI ความขัดแย้งที่บังคับให้เทียบทั้งเรคคอร์ด
การให้คนเทียบทั้งเรคคอร์ดเป็นเรื่องหมดแรง ความขัดแย้งส่วนใหญ่มีแค่หนึ่งถึงสามฟิลด์ แสดงเฉพาะฟิลด์ที่ขัดแย้ง ล่วงหน้าเลือกตัวเลือกที่ปลอดภัย และให้ผู้ใช้ยอมรับส่วนที่เหลือโดยอัตโนมัติ
ถ้าคุณสร้างฟอร์มในเครื่องมือ no-code เช่น AppMaster มุ่งผลลัพธ์เดียวกัน: แก้ความขัดแย้งระดับฟิลด์เพื่อให้ผู้ใช้ตัดสินใจชัดเจนแค่ครั้งเดียวแทนการเลื่อนทั้งฟอร์ม
เช็คลิสต์ด่วนและขั้นตอนต่อไป
ถ้าต้องการให้การแก้ไขแบบออฟไลน์รู้สึกปลอดภัย ให้ถือความขัดแย้งเป็นสถานะปกติไม่ใช่ข้อผิดพลาด ผลลัพธ์ที่ดีที่สุดมาจากกฎที่ชัดเจน การทดสอบที่ทำซ้ำได้ และ UX ที่อธิบายสิ่งที่เกิดขึ้นเป็นภาษาง่าย ๆ
ก่อนเพิ่มฟีเจอร์ ตรวจสอบให้แน่ใจว่าพื้นฐานเหล่านี้ถูกล็อคไว้:
- สำหรับแต่ละชนิดเรคคอร์ด กำหนดกฎการรวมต่อฟิลด์ (LWW, เก็บ max/min, ต่อ, union, หรือถามเสมอ)
- เก็บเวอร์ชันที่ควบคุมโดยเซิร์ฟเวอร์บวก
updated_atที่คุณควบคุม และยืนยันระหว่างการซิงค์ - รันการทดสอบสองอุปกรณ์ที่ทั้งสองแก้ไขเรคคอร์ดเดียวกันแบบออฟไลน์ แล้วซิงค์ในลำดับทั้งสอง (A แล้ว B, B แล้ว A) ผลลัพธ์ควรคาดเดาได้
- ทดสอบความขัดแย้งยาก: ลบ vs แก้ไข และ แก้ไข vs แก้ไขในฟิลด์ต่างกัน
- ทำให้สถานะมองเห็นได้: แสดง Synced, Pending upload, และ Needs review
สร้างต้นแบบฟลูว์เต็มจากฟอร์มจริงหนึ่งแบบ ไม่ใช่หน้าจอเดโม ใช้สถานการณ์สมจริง: ช่างภาคสนามอัปเดตบันทึกงานบนโทรศัพท์ ขณะที่ผู้ส่งงานแก้ไขชื่องานบนแท็บเล็ต ถ้าทั้งคู่แตะคนละฟิลด์ ให้ผสานอัตโนมัติและแสดงคำใบ้เล็ก ๆ ว่า "อัปเดตจากอุปกรณ์อีกเครื่อง" ถ้าทั้งคู่แตะฟิลด์เดียวกัน ให้ไปยังหน้าจอตรวจทานง่าย ๆ ที่มีสองตัวเลือกและพรีวิวชัดเจน
เมื่อคุณพร้อมสร้างแอปมือถือเต็มและ API ฝั่งเซิร์ฟเวอร์ร่วมกัน AppMaster (appmaster.io) สามารถช่วยได้ คุณสามารถโมเดลข้อมูล กำหนดตรรกะธุรกิจ และสร้าง UI เว็บและเนทีฟจากที่เดียว แล้วปรับใช้หรือส่งออกซอร์สโค้ดเมื่อกฎซิงค์ของคุณมั่นคง
คำถามที่พบบ่อย
ความขัดแย้งเกิดขึ้นเมื่อสองอุปกรณ์แก้ไขเรคคอร์ดที่สำรองโดยเซิร์ฟเวอร์เดียวกันในขณะที่ออฟไลน์ (หรือก่อนที่อุปกรณ์ใดจะซิงค์) และเซิร์ฟเวอร์พบว่าการอัปเดตทั้งสองอิงกับเวอร์ชันเก่า ระบบจึงต้องตัดสินใจว่าค่าจริงสุดท้ายควรเป็นค่าใดสำหรับแต่ละฟิลด์ที่แตกต่างกัน
เริ่มจาก การรวมระดับฟิลด์ เป็นค่าเริ่มต้นสำหรับฟอร์มในงานธุรกิจส่วนใหญ่ เพราะบทบาทต่าง ๆ มักแก้ไขคนละส่วนของฟอร์มและคุณมักเก็บทั้งสองการเปลี่ยนแปลงโดยไม่รบกวนใคร ใช้ การรีวิวด้วยคน เฉพาะกับฟิลด์ที่อาจทำให้เกิดความเสียหายจริง ๆ (เงิน, การอนุมัติ, ข้อกำหนด) และใช้ last write wins เฉพาะกับฟิลด์ความเสี่ยงต่ำที่ยอมรับการสูญเสียค่าก่อนหน้าได้
ถ้าการแก้ไขสองด้านส่งผลต่อคนละฟิลด์ ให้รวมโดยอัตโนมัติและไม่รบกวนผู้ใช้ ถ้าการแก้ไขสองด้านเปลี่ยนฟิลด์เดียวกันเป็นค่าสองค่า ฟิลด์นั้นควรกระตุ้นให้ตัดสินใจ เพราะการเลือกอัตโนมัติอาจทำให้ใครสักคนตกใจ จำกัดขอบเขตการตัดสินใจโดยแสดงเฉพาะฟิลด์ที่ขัดแย้ง ไม่ใช่ทั้งฟอร์ม
ถือ version เป็นเคาน์เตอร์ที่เพิ่มขึ้นของเซิร์ฟเวอร์สำหรับเรคคอร์ด และให้ไคลเอนต์ส่ง expected_version กับการอัปเดตทุกครั้ง ถ้าเวอร์ชันปัจจุบันของเซิร์ฟเวอร์ไม่ตรงกัน ให้ปฏิเสธพร้อม payload ความขัดแย้งแทนการเขียนทับ กฎเดียวนี้ป้องกัน “การสูญเสียข้อมูลโดยเงียบ” แม้เมื่อสองอุปกรณ์ซิงค์ในลำดับต่างกัน
ขั้นต่ำที่ใช้งานได้คือ id ที่คงที่, version ที่ควบคุมโดยเซิร์ฟเวอร์, และ updated_at/updated_by ที่เซิร์ฟเวอร์กำหนด เพื่ออธิบายว่ามีการเปลี่ยนแปลงอะไร ในอุปกรณ์ให้ติดตามว่าบันทึกนั้นถูกแก้ไขและรออัปโหลดหรือไม่ (เช่น pending_sync) และเก็บเวอร์ชันเซิร์ฟเวอร์ล่าสุด ถ้าไม่มีข้อมูลเหล่านี้ คุณจะตรวจจับความขัดแย้งหรือแสดงหน้าจอแก้ปัญหาได้ยาก
ส่ง เฉพาะฟิลด์ที่เปลี่ยนแปลง (patch) พร้อม expected_version ของฐานที่คุณแก้ไข การอัปโหลดเรคคอร์ดทั้งก้อนจะทำให้การแก้ไขเล็กน้อยที่ไม่ทับซ้อนกลายเป็นความขัดแย้งโดยไม่จำเป็น และเพิ่มความเสี่ยงที่ค่าบนเซิร์ฟเวอร์ใหม่กว่าจะถูกเขียนทับด้วยข้อมูลเก่า การส่งแพตช์ยังทำให้เห็นชัดว่าฟิลด์ไหนต้องมีกฎการรวม
สแนปชอตง่ายกว่า: เก็บสเตตล่าสุดทั้งก้อนแล้วเทียบกับเซิร์ฟเวอร์ในภายหลัง บันทึกการเปลี่ยนแปลง (change log) ยืดหยุ่นกว่า: เก็บเป็นปฏิบัติการเช่น “ตั้งค่าฟิลด์” หรือ “ต่อข้อความ” แล้วเล่นซ้ำบนสเตตเซิร์ฟเวอร์ล่าสุด ซึ่งมักรวมได้ดีกว่าสำหรับบันทึก โน้ต และแท็ก เลือกสแนปชอตถ้าต้องการทำให้เร็วขึ้น เลือก change log ถ้าการรวมเกิดบ่อยและต้องการรายละเอียดว่าใครเปลี่ยนอะไร
ตัดสินใจก่อนว่าการลบมีความแรงกว่าการแก้ไขหรือไม่ เพราะผู้ใช้คาดหวังพฤติกรรมที่สอดคล้อง สำหรับหลายแอปธุรกิจ ค่าเริ่มต้นที่ปลอดภัยคือใช้ tombstone (มาร์กด้วย deleted_at และเวอร์ชัน) เพื่อป้องกันไม่ให้อัปโหลดเก่าทำให้เรคคอร์ดกลับมามีชีวิตอีกครั้ง หากต้องการย้อนกลับได้ ให้ใช้สถานะ “archived” แทนการลบจริง
ข้อผิดพลาดที่พบบ่อยคือการเรียงลำดับการแก้ไขด้วยเวลาของอุปกรณ์ เพราะนาฬิกาเพี้ยนและเขตเวลาเปลี่ยน ใช้เวอร์ชันที่เซิร์ฟเวอร์ออกเป็นหลัก ถ้าใช้เวลาจากไคลเอนต์ ให้เพิ่มการตรวจสอบและไกล่เกลี่ยบนเซิร์ฟเวอร์ อย่าใช้ LWW กับฟิลด์ที่เสี่ยง เช่น สถานะ ยอดรวม การมอบหมาย; ให้มีกฎชัดเจนหรือให้คนรีวิว อย่าอัปโหลดสแนปชอตเก่าทั้งก้อนโดยไม่ส่งเฉพาะการเปลี่ยนแปลง พร้อมเก็บบันทึกว่าใครเปลี่ยนอะไรเพื่อช่วยแสดงความแตกต่างเมื่อมีความขัดแย้ง
รักษาสัญญาว่า “บันทึกแล้ว” หมายถึงบันทึกในเครื่อง และแสดงสถานะแยกสำหรับ “ซิงค์แล้ว” เพื่อให้ผู้ใช้เข้าใจ ถ้าสร้างใน AppMaster ให้ตั้งกฎการรวมต่อฟิลด์ในตรรกะผลิตภัณฑ์ก่อน แล้วรวมฟิลด์ที่ปลอดภัยโดยอัตโนมัติ และส่งเฉพาะการชนกันของฟิลด์ไปยังหน้าตรวจทานแบบโฟกัส ทดสอบด้วยสองอุปกรณ์ที่แก้ไขเรคคอร์ดเดียวกันแบบออฟไลน์แล้วซิงค์ในลำดับทั้งสองด้านเพื่อยืนยันผลลัพธ์


