20 ส.ค. 2568·อ่าน 2 นาที

การแก้ไขความขัดแย้งแบบ Offline-first สำหรับฟอร์ม ใน Kotlin + SQLite

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

การแก้ไขความขัดแย้งแบบ Offline-first สำหรับฟอร์ม ใน Kotlin + SQLite

เกิดอะไรขึ้นจริง ๆ เมื่อสองคนแก้ไขแบบออฟไลน์

ฟอร์มแบบ 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: เลือกวิธีใดวิธีหนึ่ง

ออกแบบโมเดลข้อมูลสำหรับซิงค์
ออกแบบข้อมูล PostgreSQL พร้อมฟิลด์เวอร์ชันและฟิลด์ตรวจสอบด้วย Data Designer แบบภาพของ AppMaster
เริ่มสร้าง

เมื่อคุณซิงค์ฟอร์มแบบ 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 isDone true พร้อม timestamp)

operation logs ลดความขัดแย้งได้เพราะหลายการกระทำไม่ทับซ้อน การต่อบันทึกไม่ค่อยขัดแย้งกับการต่อบันทึกอื่น ๆ แท็กที่เพิ่ม/ลบสามารถรวมเหมือนคณิตศาสตร์เซต แต่สำหรับฟิลด์ค่าหนึ่งค่า คุณยังต้องมีกฎต่อฟิลด์เมื่อสองการแก้ไขแข่งขันกัน

ข้อแลกเปลี่ยนคือความซับซ้อน: ต้องมี ID ปฏิบัติการที่คงที่ การเรียงลำดับ (ลำดับท้องถิ่นและเวลาของเซิร์ฟเวอร์) และกฎสำหรับปฏิบัติการที่ไม่ commute

การทำความสะอาด: บีบอัดหลังซิงค์สำเร็จ

operation logs โตขึ้น ดังนั้นวางแผนการย่อลง

แนวทางที่ใช้กันคือการบีบอัดต่อเรคคอร์ด: เมื่อปฏิบัติการทั้งหมดจนถึงเวอร์ชันเซิร์ฟเวอร์ที่รู้จักได้รับการยอมรับ ให้พับเป็นสแนปชอตใหม่ แล้วลบปฏิบัติการเก่า เก็บ tail สั้น ๆ เฉพาะหากคุณต้องการ undo, auditing, หรือ debug ง่ายขึ้น

ขั้นตอนการซิงค์ทีละขั้นสำหรับ Kotlin + SQLite

เลือกตัวเลือกการปรับใช้ของคุณ
ปรับใช้ไปยัง AppMaster Cloud, AWS, Azure, Google Cloud หรือโฮสต์เองจากโค้ดที่ส่งออกได้
ปรับใช้แอป

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

ลำดับปฏิบัติที่แนะนำ:

  1. เขียนการแก้ไขทุกครั้งลง SQLite ก่อน บันทึกการเปลี่ยนแปลงในธุรกรรมท้องถิ่นและมาร์กเรคคอร์ดเป็น pending_sync = 1 เก็บ local_updated_at และ server_version ที่ทราบล่าสุด

  2. ส่งเป็น patch ไม่ใช่เรคคอร์ดทั้งก้อน เมื่อการเชื่อมต่อกลับมา ส่งไอดีเรคคอร์ดพร้อมเฉพาะฟิลด์ที่เปลี่ยนแปลง พร้อม expected_version

  3. ให้เซิร์ฟเวอร์ปฏิเสธเมื่อเวอร์ชันไม่ตรงกัน หากเวอร์ชันเซิร์ฟเวอร์ไม่ตรงกับ expected_version มันจะคืน payload ความขัดแย้ง (เรคคอร์ดเซิร์ฟเวอร์ การเปลี่ยนแปลงที่เสนอ และฟิลด์ที่ต่างกัน) ถ้าเวอร์ชันตรง มันจะใช้ patch เพิ่มเวอร์ชัน และคืนเรคคอร์ดที่อัปเดต

  4. ใช้การรวมอัตโนมัติก่อน แล้วค่อยถามผู้ใช้ รันกฎการรวมระดับฟิลด์ ปฏิบัติต่อฟิลด์ที่ปลอดภัยเช่นบันทึกต่างจากฟิลด์ที่ละเอียดอ่อนเช่น status, price, หรือ assignee

  5. คอมมิตผลสุดท้ายและล้าง flag pending ไม่ว่าจะผสานอัตโนมัติหรือแก้ไขด้วยมือ ให้เขียนเรคคอร์ดสุดท้ายกลับไปยัง SQLite, อัปเดต server_version, ตั้ง pending_sync = 0, และบันทึกข้อมูลตรวจสอบพอที่จะอธิบายสิ่งที่เกิดขึ้นภายหลัง

ตัวอย่าง: สองเซลส์รีพ์แก้คำสั่งซื้อแบบออฟไลน์ A เปลี่ยนวันที่ส่ง B เปลี่ยนเบอร์โทรลูกค้า ด้วย patch เซิร์ฟเวอร์สามารถยอมรับทั้งสองการเปลี่ยนแปลงได้สะอาด ๆ ถ้าทั้งคู่เปลี่ยนวันที่ส่ง ให้แสดงการตัดสินใจอย่างชัดเจนแทนที่จะบังคับให้กรอกทั้งฟอร์มใหม่

รักษาสัญญา UI ให้สอดคล้อง: "Saved" ควรหมายถึงบันทึกในเครื่องเสมอ "Synced" เป็นสถานะแยกชัดเจน

รูปแบบ UX ในการแก้ความขัดแย้งในฟอร์ม

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

ทำให้ความขัดแย้งเกิดขึ้นน้อยด้วยค่าเริ่มต้นที่ปลอดภัย

ถ้าสองคนแก้ไขคนละฟิลด์ ให้ผสานโดยอัตโนมัติโดยไม่แสดง modal เก็บทั้งสองการเปลี่ยนแปลงและแสดงข้อความเล็ก ๆ ว่า "อัปเดตหลังซิงค์"

สงวนการแจ้งเฉพาะการชนกันจริง ๆ: ฟิลด์เดียวกันถูกเปลี่ยนโดยทั้งสองอุปกรณ์ หรือการเปลี่ยนแปลงหนึ่งขึ้นกับอีกฟิลด์หนึ่ง (เช่น สถานะพร้อมเหตุผลของสถานะ)

เมื่อจำเป็นต้องถาม ให้ทำให้เสร็จได้เร็ว

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

จำกัดการกระทำให้เหลือเท่าที่จำเป็น:

  • เก็บของฉัน
  • เก็บของเขา
  • แก้ไขผลลัพธ์สุดท้าย
  • ตรวจทานทีละฟิลด์ (เฉพาะเมื่อจำเป็น)

การผสานบางส่วนเป็นจุดที่ UX มักยุ่ง ยกไฮไลต์เฉพาะฟิลด์ที่ขัดแย้งและติดป้ายแหล่งที่มาให้ชัดเจน ("ของคุณ" และ "ของเขา") เลือกตัวเลือกที่ปลอดภัยล่วงหน้าเพื่อให้ผู้ใช้ยืนยันและไปต่อได้

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

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

กรณียุ่งยาก: ลบ, ซ้ำ, และเรคคอร์ดที่ "หายไป"

สร้างสแต็กทั้งหมดในที่เดียว
สร้าง backend, เว็บ และแอปมือถือพร้อมใช้งานจาก workspace แบบ no-code เดียว
ลองเลย

ปัญหาซิงค์ที่ดูสุ่มส่วนใหญ่เกิดจากสามสถานการณ์: ใครสักคนลบในขณะที่อีกคนแก้ไข, สองอุปกรณ์สร้างสิ่งเดียวกันแบบออฟไลน์, หรือเรคคอร์ดถูกลบแล้วกลับมา สถานการณ์เหล่านี้ต้องมีกฎชัดเจนเพราะ 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, manual review หรือ field-level merge?

เริ่มจาก การรวมระดับฟิลด์ เป็นค่าเริ่มต้นสำหรับฟอร์มในงานธุรกิจส่วนใหญ่ เพราะบทบาทต่าง ๆ มักแก้ไขคนละส่วนของฟอร์มและคุณมักเก็บทั้งสองการเปลี่ยนแปลงโดยไม่รบกวนใคร ใช้ การรีวิวด้วยคน เฉพาะกับฟิลด์ที่อาจทำให้เกิดความเสียหายจริง ๆ (เงิน, การอนุมัติ, ข้อกำหนด) และใช้ last write wins เฉพาะกับฟิลด์ความเสี่ยงต่ำที่ยอมรับการสูญเสียค่าก่อนหน้าได้

เมื่อไรที่แอปควรถามผู้ใช้ให้แก้ความขัดแย้ง?

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

เวอร์ชันของเรคคอร์ดช่วยป้องกันการเขียนทับเงียบ ๆ ได้อย่างไร?

ถือ version เป็นเคาน์เตอร์ที่เพิ่มขึ้นของเซิร์ฟเวอร์สำหรับเรคคอร์ด และให้ไคลเอนต์ส่ง expected_version กับการอัปเดตทุกครั้ง ถ้าเวอร์ชันปัจจุบันของเซิร์ฟเวอร์ไม่ตรงกัน ให้ปฏิเสธพร้อม payload ความขัดแย้งแทนการเขียนทับ กฎเดียวนี้ป้องกัน “การสูญเสียข้อมูลโดยเงียบ” แม้เมื่อสองอุปกรณ์ซิงค์ในลำดับต่างกัน

ควรมีเมตาดาต้าอะไรบ้างในทุกตาราง SQLite ที่ซิงค์?

ขั้นต่ำที่ใช้งานได้คือ id ที่คงที่, version ที่ควบคุมโดยเซิร์ฟเวอร์, และ updated_at/updated_by ที่เซิร์ฟเวอร์กำหนด เพื่ออธิบายว่ามีการเปลี่ยนแปลงอะไร ในอุปกรณ์ให้ติดตามว่าบันทึกนั้นถูกแก้ไขและรออัปโหลดหรือไม่ (เช่น pending_sync) และเก็บเวอร์ชันเซิร์ฟเวอร์ล่าสุด ถ้าไม่มีข้อมูลเหล่านี้ คุณจะตรวจจับความขัดแย้งหรือแสดงหน้าจอแก้ปัญหาได้ยาก

ควรซิงค์ทั้งเรคคอร์ดหรือเฉพาะฟิลด์ที่เปลี่ยนแปลง?

ส่ง เฉพาะฟิลด์ที่เปลี่ยนแปลง (patch) พร้อม expected_version ของฐานที่คุณแก้ไข การอัปโหลดเรคคอร์ดทั้งก้อนจะทำให้การแก้ไขเล็กน้อยที่ไม่ทับซ้อนกลายเป็นความขัดแย้งโดยไม่จำเป็น และเพิ่มความเสี่ยงที่ค่าบนเซิร์ฟเวอร์ใหม่กว่าจะถูกเขียนทับด้วยข้อมูลเก่า การส่งแพตช์ยังทำให้เห็นชัดว่าฟิลด์ไหนต้องมีกฎการรวม

ควรเก็บสแนปชอตหรือบันทึกการเปลี่ยนแปลงสำหรับการแก้ไขแบบออฟไลน์?

สแนปชอตง่ายกว่า: เก็บสเตตล่าสุดทั้งก้อนแล้วเทียบกับเซิร์ฟเวอร์ในภายหลัง บันทึกการเปลี่ยนแปลง (change log) ยืดหยุ่นกว่า: เก็บเป็นปฏิบัติการเช่น “ตั้งค่าฟิลด์” หรือ “ต่อข้อความ” แล้วเล่นซ้ำบนสเตตเซิร์ฟเวอร์ล่าสุด ซึ่งมักรวมได้ดีกว่าสำหรับบันทึก โน้ต และแท็ก เลือกสแนปชอตถ้าต้องการทำให้เร็วขึ้น เลือก change log ถ้าการรวมเกิดบ่อยและต้องการรายละเอียดว่าใครเปลี่ยนอะไร

ควรจัดการความขัดแย้งแบบลบ vs แก้ไขอย่างไร?

ตัดสินใจก่อนว่าการลบมีความแรงกว่าการแก้ไขหรือไม่ เพราะผู้ใช้คาดหวังพฤติกรรมที่สอดคล้อง สำหรับหลายแอปธุรกิจ ค่าเริ่มต้นที่ปลอดภัยคือใช้ tombstone (มาร์กด้วย deleted_at และเวอร์ชัน) เพื่อป้องกันไม่ให้อัปโหลดเก่าทำให้เรคคอร์ดกลับมามีชีวิตอีกครั้ง หากต้องการย้อนกลับได้ ให้ใช้สถานะ “archived” แทนการลบจริง

ข้อผิดพลาดที่พบบ่อยที่สุดที่ทำให้ข้อมูลซิงค์หายมีอะไรบ้าง?

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

จะสร้าง UX แบบเป็นมิตรกับความขัดแย้งได้อย่างไรเมื่อใช้ AppMaster?

รักษาสัญญาว่า “บันทึกแล้ว” หมายถึงบันทึกในเครื่อง และแสดงสถานะแยกสำหรับ “ซิงค์แล้ว” เพื่อให้ผู้ใช้เข้าใจ ถ้าสร้างใน AppMaster ให้ตั้งกฎการรวมต่อฟิลด์ในตรรกะผลิตภัณฑ์ก่อน แล้วรวมฟิลด์ที่ปลอดภัยโดยอัตโนมัติ และส่งเฉพาะการชนกันของฟิลด์ไปยังหน้าตรวจทานแบบโฟกัส ทดสอบด้วยสองอุปกรณ์ที่แก้ไขเรคคอร์ดเดียวกันแบบออฟไลน์แล้วซิงค์ในลำดับทั้งสองด้านเพื่อยืนยันผลลัพธ์

ง่ายต่อการเริ่มต้น
สร้างบางสิ่งที่ น่าทึ่ง

ทดลองกับ AppMaster ด้วยแผนฟรี
เมื่อคุณพร้อม คุณสามารถเลือกการสมัครที่เหมาะสมได้

เริ่ม