08 ต.ค. 2568·อ่าน 3 นาที

การให้หมายเลขใบแจ้งหนี้ที่ปลอดภัยต่อการประมวลผลพร้อมกันและป้องกันหมายเลขซ้ำ/ช่องว่าง

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

การให้หมายเลขใบแจ้งหนี้ที่ปลอดภัยต่อการประมวลผลพร้อมกันและป้องกันหมายเลขซ้ำ/ช่องว่าง

ปัญหาเกิดอะไรขึ้นเมื่อสองคนสร้างระเบียนพร้อมกัน

ลองนึกภาพออฟฟิศที่วุ่นวายตอน 16:55 น. สองคนทำใบแจ้งหนี้เสร็จและกดบันทึกภายในเวลาไม่กี่วินาที หน้าจอทั้งสองแสดง “Invoice #1042” ชั่วคราว ระเบียนหนึ่งบันทึกได้ อีกระเบียนล้มเหลว หรือแย่กว่านั้น ทั้งคู่ถูกเก็บด้วยหมายเลขเดียวกัน นี่เป็นอาการที่พบได้บ่อยที่สุดในโลกจริง: หมายเลขซ้ำที่ปรากฏเฉพาะเมื่อมีภาระพร้อมกันสูง

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

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

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

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

วิธีที่ดีในการกำหนดปัญหาคือถามว่าคุณต้องการรับประกันอะไรจากหมายเลขของคุณ:

  • ต้องไม่ซ้ำเลย (เอกลักษณ์เสมอ)
  • ควรเพิ่มขึ้นโดยส่วนใหญ่ (เป็นผลดี)
  • ต้องไม่ข้ามหมายเลข (เฉพาะเมื่อออกแบบให้เป็นเช่นนั้น)

เมื่อคุณเลือกกฎได้ การเลือกโซลูชันทางเทคนิคก็จะง่ายขึ้นมาก

ทำไมเกิดหมายเลขซ้ำและช่องว่าง

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

ปัญหาเริ่มเมื่อมีการบันทึกเกิดขึ้นเกือบพร้อมกัน คำขอทั้งสองอาจมาถึงขั้นตอน “รับหมายเลขถัดไป” ก่อนที่จะมีการแทรกถาวร หากทั้งคู่เห็นค่า "ถัดไป" เดียวกัน ทั้งสองจะพยายามเขียนหมายเลขเดียวกัน นั่นคือ race condition: ผลลัพธ์ขึ้นกับจังหวะเวลา ไม่ใช่ตรรกะ

ไทม์ไลน์ทั่วไปมีลักษณะดังนี้:

  • คำขอ A อ่านหมายเลขถัดไป: 1042
  • คำขอ B อ่านหมายเลขถัดไป: 1042
  • คำขอ A แทรกใบแจ้งหนี้ 1042
  • คำขอ B แทรกใบแจ้งหนี้ 1042 (หรือล้มเหลวหากมี unique rule บล็อกไว้)

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

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

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

  • ไคลเอนต์ API สร้างระเบียนแบบขนาน
  • การนำเข้ารันเป็นแบตช์
  • งานพื้นหลังสร้างใบแจ้งหนี้ตอนกลางคืน
  • การลองใหม่จากแอปมือถือที่เครือข่ายไม่เสถียร

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

ตัดสินใจกฎการให้หมายเลขก่อนเลือกโซลูชัน

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

เริ่มจากแยกสองเป้าหมายที่มักสับสนกัน:

  • Unique: ไม่มีใบแจ้งหนี้หรือตั๋วสองรายการที่มีหมายเลขเดียวกัน
  • Gapless: หมายเลขไม่ซ้ำและต่อเนื่องอย่างเคร่งครัด (ไม่มีหมายเลขหาย)

ระบบจริงหลายระบบมักมุ่งที่ Unique เท่านั้นและยอมรับช่องว่าง ช่องว่างอาจเกิดจากเหตุปกติ: ผู้ใช้เปิดร่างและทิ้งไป การชำระเงินล้มเหลวหลังจากสำรองหมายเลข หรือระเบียนถูกสร้างแล้วถูกยกเลิก สำหรับตั๋ว ช่องว่างมักไม่เป็นปัญหาเลย แม้สำหรับใบแจ้งหนี้ หลายทีมยอมรับช่องว่างได้ถ้ามีเส้นทางการตรวจสอบอธิบายได้ (voided, canceled, test ฯลฯ) การให้หมายเลขแบบ gapless ทำได้ แต่บังคับให้มีกฎเพิ่มและมักเพิ่มแรงเสียดทาน

ต่อมา ตัดสินใจขอบเขตของตัวนับ คำวลีเล็กๆ เปลี่ยนการออกแบบมาก:

  • ใช้ลำดับเดียวทั่วทั้งระบบ หรือแยกตาม company/tenant?
  • รีเซ็ตทุกปี หรือไม่เคยรีเซ็ต?
  • มีซีรีส์ต่างกันสำหรับใบแจ้งหนี้ vs เครดิตโน้ต vs ตั๋ว?
  • ต้องการฟอร์แมตที่คนอ่านง่าย (prefix, ตัวคั่น) หรือแค่หมายเลขภายใน?

ตัวอย่างชัดเจน: SaaS ที่มีหลายบริษัทลูกค้าอาจต้องการหมายเลขใบแจ้งหนี้ที่ไม่ซ้ำต่อบริษัทและรีเซ็ตต่อปี ในขณะที่ตั๋วไม่รีเซ็ตและไม่ซ้ำทั่วทั้งระบบ นั่นคือสองตัวนับต่างกันด้วยกฎต่างกัน แม้ UI จะดูคล้ายกัน

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

เขียนกฎเป็นข้อสั้นๆ ก่อนสร้าง:

  • ประเภทระเบียนใดใช้ลำดับนี้?
  • อะไรทำให้หมายเลขถือว่า “ถูกใช้” (ร่าง, ส่ง, ชำระแล้ว)?
  • ขอบเขตคืออะไร (global, per company, per year, per series)?
  • จัดการ voids และ corrections อย่างไร?

ใน AppMaster กฎแบบนี้ควรอยู่ใกล้กับโมเดลข้อมูลและกระบวนการธุรกิจ เพื่อทีมจะนำพฤติกรรมเดียวกันไปใช้ทุกที่ (API, เว็บ UI, มือถือ) โดยไม่แปลกใจ

แนวทางที่พบบ่อยและแต่ละแบบรับประกันอะไรบ้าง

เมื่อคนพูดถึง “การให้หมายเลขใบแจ้งหนี้” มักผสมสองเป้าหมายต่างกัน: (1) ไม่สร้างหมายเลขซ้ำสองครั้ง และ (2) ไม่มีช่องว่าง ส่วนใหญ่รับประกันข้อนแรกได้ง่าย ข้อสองยากกว่าเพราะช่องว่างอาจปรากฏเมื่อใดก็ตามที่ทรานแซกชันล้มเหลว ร่างถูกทิ้ง หรือระเบียนถูกยกเลิก

แนวทาง 1: Database sequence (เร็ว ได้ความเป็นเอกลักษณ์)

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

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

แนวทาง 2: Unique constraint พร้อมการลองใหม่ (ให้ฐานข้อมูลตัดสิน)

ที่นี่คุณสร้างหมายเลขที่คาดว่าจะเป็นแล้วบันทึก และอาศัย UNIQUE constraint เพื่อปฏิเสธการซ้ำ หากเกิดชน คุณลองใหม่ด้วยหมายเลขใหม่

วิธีนี้ใช้ได้ แต่จะดังขึ้นภายใต้การประมวลผลพร้อมกันสูง คุณอาจเห็นการลองใหม่มากขึ้น ทรานแซกชันล้มเหลวมากขึ้น และยอดที่ยากต่อการดีบัก นอกจากนี้ไม่รับประกัน gapless เว้นแต่จะรวมกับกฎการสำรองที่เคร่งครัด ซึ่งเพิ่มความซับซ้อน

แนวทาง 3: แถวตัวนับพร้อมการล็อก (มุ่งสู่ gapless)

ถ้าคุณต้องการจริงๆ ให้เป็น gapless รูปแบบที่ใช้กันทั่วไปคือมีตารางตัวนับเฉพาะ (แถวหนึ่งสำหรับแต่ละขอบเขต เช่น ต่อปีหรือแต่ละบริษัท) คุณล็อกแถวนั้นในทรานแซกชัน เพิ่มค่า แล้วใช้ค่าที่ได้

นี่คือสิ่งที่ใกล้เคียงที่สุดกับ gapless ในการออกแบบฐานข้อมูลปกติ แต่มันมีต้นทุน: มันสร้าง "hot spot" เดียวที่ผู้เขียนทุกคนต้องรอ และเพิ่มความเสี่ยงจากความผิดพลาดในการดำเนินงาน (ทรานแซกชันยาว หมดเวลา และ deadlock)

แนวทาง 4: บริการสำรองหมายเลขแยกต่างหาก (สำหรับกรณีพิเศษ)

บริการ "numbering service" แยกต่างหากสามารถรวมกฎข้ามแอปหรือฐานข้อมูลหลายตัว เหมาะเมื่อคุณมีหลายระบบที่ต้องออกหมายเลขและไม่สามารถรวมเขียนกันได้

การแลกเปลี่ยนคือความเสี่ยงด้านการดำเนินงาน: คุณเพิ่มบริการอีกตัวที่ต้องถูกต้อง มีความพร้อมใช้งานสูง และสอดคล้อง

คิดภาพการรับประกันแบบปฏิบัติได้สำหรับการให้หมายเลขที่ปลอดภัยต่อการประมวลผลพร้อมกัน:

  • Sequence: ไม่ซ้ำ, เร็ว, ยอมรับช่องว่าง
  • Unique + retry: ไม่ซ้ำ, เรียบง่ายเมื่อโหลดต่ำ, อาจเกิด thrash เมื่อโหลดสูง
  • แถวตัวนับล็อก: สามารถเป็น gapless ได้, ช้าภายใต้การประมวลผลพร้อมกันสูง
  • บริการแยก: ยืดหยุ่นข้ามระบบ, มีความซับซ้อนและความเสี่ยงมากที่สุด

ถ้าคุณสร้างนี้ในเครื่องมือ no-code เช่น AppMaster ทางเลือกเดิมยังใช้ได้: ความถูกต้องอยู่ที่ฐานข้อมูล ตรรกะแอปช่วยจัดการการลองใหม่และข้อความผิดพลาด แต่การรับประกันสุดท้ายควรมาจากคอนสเตรนต์และทรานแซกชัน

ทีละขั้นตอน: ป้องกันการซ้ำด้วย sequence และ unique constraint

หยุดหมายเลขใบแจ้งหนี้ซ้ำ
สร้างแอปใบแจ้งหนี้และตั๋วด้วยกฎระดับฐานข้อมูลที่ทนต่อการประมวลผลพร้อมกันในสภาพการใช้งานจริง
ลองใช้ AppMaster

ถ้าจุดประสงค์หลักของคุณคือป้องกันการซ้ำ (ไม่ต้องการรับประกันว่าไม่มีช่องว่าง) รูปแบบที่ง่ายและเชื่อถือได้คือ: ให้ฐานข้อมูลสร้างไอดีภายใน และบังคับความเป็นเอกลักษณ์บนหมายเลขที่ผู้ใช้เห็น

เริ่มจากแยกแนวคิดสองอย่าง ใช้ค่าที่ฐานข้อมูลสร้าง (identity/sequence) เป็น primary key สำหรับการ join แก้ไข และการส่งออก เก็บ invoice_no หรือ ticket_no เป็นคอลัมน์แยกที่แสดงให้ผู้ใช้เห็น

การตั้งค่าที่เป็นประโยชน์ใน PostgreSQL

นี่คือวิธีการ PostgreSQL ทั่วไปที่เก็บตรรกะ "หมายเลขถัดไป" ไว้ในฐานข้อมูล ซึ่งการประมวลผลพร้อมกันจะถูกจัดการอย่างถูกต้อง

-- Internal, never-shown primary key
create table invoices (
  id bigint generated always as identity primary key,
  invoice_no text not null,
  created_at timestamptz not null default now()
);

-- Business-facing uniqueness guarantee
create unique index invoices_invoice_no_uniq on invoices (invoice_no);

-- Sequence for the visible number
create sequence invoice_no_seq;

ตอนนี้สร้างหมายเลขสำหรับแสดงขณะแทรก (ไม่ใช่โดยการทำ "select max(invoice_no) + 1") หนึ่งในรูปแบบง่ายๆ คือฟอร์แมตค่าจาก sequence ภายใน INSERT:

insert into invoices (invoice_no)
values (
  'INV-' || lpad(nextval('invoice_no_seq')::text, 8, '0')
)
returning id, invoice_no;

แม้จะมีผู้ใช้ 50 คนกด "Create invoice" พร้อมกัน แต่แต่ละการแทรกจะได้ค่า sequence ที่ต่างกัน และ unique index จะป้องกันการซ้ำโดยไม่ได้ตั้งใจ

ทำอย่างไรเมื่อเกิดการชน

กับ sequence ธรรมดา การชนเกิดขึ้นไม่บ่อย มักเกิดเมื่อคุณเพิ่มกฎพิเศษ เช่น "รีเซ็ตต่อปี" "ต่อ tenant" หรือหมายเลขที่ผู้ใช้แก้ไขได้ นั่นเป็นเหตุผลที่ยังคงต้องมี unique constraint

ที่ระดับแอป ให้จัดการข้อผิดพลาด unique-violation ด้วยลูปลองใหม่เล็กๆ เก็บให้เรียบและมีขอบเขต:

  • พยายามแทรก
  • ถ้าได้ unique constraint error บน invoice_no ให้ลองใหม่
  • หยุดหลังจากลองไม่กี่ครั้งแล้วแสดงข้อผิดพลาดที่ชัดเจน

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

ลดช่องหน้าต่างของ race

อย่าคำนวณหมายเลขใน UI และอย่าสำรองหมายเลขโดยอ่านก่อนแล้วค่อยแทรก ทีละขั้นตอนให้สร้างหมายเลขให้ใกล้กับการเขียนฐานข้อมูลที่สุด

ถ้าคุณใช้ AppMaster กับ PostgreSQL ให้แบบจำลอง id เป็น identity primary key ใน Data Designer เพิ่ม unique constraint สำหรับ invoice_no และสร้าง invoice_no ระหว่าง create flow เพื่อให้เกิดพร้อมกับการ INSERT แบบนั้นฐานข้อมูลจะเป็นแหล่งความจริง และปัญหาการประมวลผลพร้อมกันจะถูกจำกัดในส่วนที่ PostgreSQL แข็งแรงที่สุด

ทีละขั้นตอน: สร้างตัวนับแบบไม่มีช่องว่างด้วยการล็อกแถว

ได้โค้ดจริงโดยไม่เกิดหนี้เทคนิค
รับโค้ดจริงสำหรับการผลิตโดยไม่สร้างหนี้ทางเทคนิค ขณะที่เก็บตรรกะธุรกิจไว้ที่เดียว
สร้างเลย

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

ก่อนอื่น ตัดสินใจขอบเขต หลายทีมต้องการลำดับแยกตามบริษัท ต่อปี หรือซีรีส์ ตัวนับเก็บหมายเลขล่าสุดสำหรับแต่ละขอบเขต

นี่คือรูปแบบปฏิบัติสำหรับการให้หมายเลขแบบ concurrency-safe โดยใช้การล็อกแถวใน PostgreSQL:

  1. สร้างตาราง เช่น number_counters โดยมีคอลัมน์เช่น company_id, year, series, last_number และคีย์เฉพาะบน (company_id, year, series)
  2. เริ่มทรานแซกชันฐานข้อมูล
  3. ล็อกแถวตัวนับสำหรับขอบเขตด้วย SELECT last_number FROM number_counters WHERE ... FOR UPDATE
  4. คำนวณ next_number = last_number + 1 อัพเดตแถวตัวนับเป็น last_number = next_number
  5. แทรกแถวใบแจ้งหนี้หรือตั๋วโดยใช้ next_number แล้ว commit

กุญแจคือ FOR UPDATE ภายใต้ภาระ คุณจะไม่ได้หมายเลขซ้ำ และคุณก็จะไม่เห็นช่องว่างจาก "ผู้ใช้สองคนได้หมายเลขเดียวกัน" เพราะทรานแซกชันที่สองจะไม่สามารถอ่านและเพิ่มแถวตัวนับเดียวกันได้จนกว่าทรานแซกชันแรกจะ commit (หรือ rollback) แทนที่คำขอที่สองจะต้องรอ นั่นคือราคาของการไม่มีช่องว่าง

การเริ่มต้นขอบเขตใหม่

คุณยังต้องมีแผนสำหรับครั้งแรกที่ขอบเขตปรากฏ (บริษัทใหม่, ปีใหม่, ซีรีส์ใหม่) ตัวเลือกสองอย่างที่พบได้ทั่วไป:

  • สร้างแถวตัวนับล่วงหน้า (เช่น สร้างแถวสำหรับปีถัดไปในเดือนธันวาคม)
  • สร้างตามต้องการ: พยายาม insert แถวตัวนับด้วย last_number = 0 และถ้ามันมีอยู่แล้ว ให้ fallback ไปที่ flow ล็อกแล้วเพิ่มปกติ

ถ้าคุณสร้างในเครื่องมือ no-code เช่น AppMaster เก็บลำดับ "ล็อก, เพิ่ม, แทรก" ไว้ภายในทรานแซกชันเดียวในตรรกะธุรกิจของคุณ เพื่อให้มันเกิดขึ้นทั้งหมดหรือไม่มีเลย

กรณีขอบ: ร่าง, บันทึกล้มเหลว, การยกเลิก, และการแก้ไข

ปัญหาการให้หมายเลขมักปรากฏในส่วนที่ยุ่งเหยิง: ร่างที่ไม่ถูกโพสต์, การบันทึกที่ล้มเหลว, ใบแจ้งหนี้ที่ถูก void, และระเบียนที่ถูกแก้ไขหลังจากมีผู้เห็นหมายเลขแล้ว ถ้าคุณต้องการการให้หมายเลขที่ปลอดภัยต่อการประมวลผลพร้อมกัน คุณต้องมีกฎชัดเจนว่าเมื่อใดหมายเลขถือว่า “จริง”

การตัดสินใจที่ใหญ่ที่สุดคือจังหวะเวลา ถ้าคุณกำหนดหมายเลขเมื่อใดก็ตามที่คนคลิก "New invoice" คุณจะได้ช่องว่างจากร่างที่ทิ้ง ถ้าคุณกำหนดเฉพาะเมื่อใบแจ้งหนี้เสร็จสมบูรณ์ (posted, issued, sent หรือตามความหมายในธุรกิจ) คุณจะเก็บหมายเลขได้แน่นขึ้นและอธิบายง่ายขึ้น

การบันทึกล้มเหลวและการ rollback คือที่ความคาดหวังมักขัดกับพฤติกรรมฐานข้อมูล ด้วย sequence ปกติ เมื่อหมายเลขถูกใช้แล้วมันถูกใช้ ถึงแม้ว่าทรานแซกชันจะล้มเหลว นั่นเป็นพฤติกรรมปกติและปลอดภัย แต่สามารถสร้างช่องว่างได้ ถ้านโยบายของคุณต้องการ gapless หมายเลขต้องถูกมอบหมายเฉพาะในขั้นสุดท้ายและเฉพาะเมื่อทรานแซกชัน commit นั่นหมายถึงปกติแล้วต้องล็อกแถวตัวนับเดียว เขียนหมายเลขสุดท้าย และ commit เป็นหน่วยเดียว ถ้าขั้นตอนไหนล้มเหลว จะไม่มีอะไรถูกมอบหมาย

การยกเลิกและ void แทบจะไม่ควร "ใช้หมายเลขซ้ำ" เก็บหมายเลขไว้และเปลี่ยนสถานะ ผู้สอบบัญชีและลูกค้าคาดหวังว่าประวัติจะคงที่ แม้เอกสารจะถูกแก้ไข

การแก้ไขง่ายกว่า: เมื่อหมายเลขปรากฏนอกระบบ ถือว่ามันถาวร อย่าเปลี่ยนหมายเลขใบแจ้งหนี้หรือตั๋วหลังจากที่แชร์ ส่งออก หรือพิมพ์แล้ว ถ้าต้องการแก้ไข ให้สร้างเอกสารใหม่และอ้างถึงตัวเดิม (เช่น เครดิตโน้ตหรือเอกสารทดแทน) แต่ไม่เขียนประวัติเดิมใหม่

ชุดกฎปฏิบัติที่ทีมหลายทีมยอมรับ:

  • ร่างไม่มีหมายเลขสุดท้าย (ใช้ internal ID หรือ "DRAFT")
  • มอบหมายหมายเลขเฉพาะตอน "Post/Issue" ภายในทรานแซกชันเดียวกับการเปลี่ยนสถานะ
  • Voids และ cancellations เก็บหมายเลขไว้ แต่มีสถานะและเหตุผลชัดเจน
  • หมายเลขที่พิมพ์/ส่งอีเมลแล้วจะไม่เปลี่ยน
  • การนำเข้ารักษาหมายเลขเดิมและอัพเดตตัวนับให้เริ่มหลังค่าสูงสุดที่นำเข้าแล้ว

การย้ายข้อมูลและการนำเข้าต้องการความระมัดระวังเป็นพิเศษ ถ้าคุณย้ายจากระบบอื่น ให้นำหมายเลขใบแจ้งหนี้เดิมเข้ามาในสภาพเดิม แล้วตั้งตัวนับให้เริ่มหลังค่าสูงสุดที่นำเข้า นอกจากนี้ตัดสินใจว่าจะจัดการกับฟอร์แมตรวม (เช่น prefix ต่างกันตามปี) อย่างไร โดยปกติจะดีกว่าถ้าจัดเก็บ "display number" ให้ตรงกับที่เคยเป็น และเก็บ primary key ภายในแยกต่างหาก

ตัวอย่าง: helpdesk สร้างตั๋วเร็ว แต่หลายรายการเป็นร่าง กำหนดหมายเลขตั๋วเฉพาะเมื่อเอเยนต์กด "Send to customer" นั่นหลีกเลี่ยงการใช้หมายเลขกับร่างที่ทิ้ง และทำให้ลำดับที่เห็นสอดคล้องกับการสื่อสารจริงกับลูกค้า ในเครื่องมือ no-code อย่าง AppMaster แนวคิดเดียวกัน: เก็บร่างเป็นระเบียนโดยไม่มีหมายเลขสาธารณะ แล้วสร้างหมายเลขสุดท้ายในขั้นตอน "submit" ของกระบวนการธุรกิจที่ทำการ commit สำเร็จ

ความผิดพลาดที่พบบ่อยซึ่งทำให้เกิดซ้ำหรืือช่องว่างที่น่าตกใจ

ทำให้ร่างปลอดภัยตามการออกแบบ
ทำให้ร่างปลอดภัยโดยการออกแบบ ให้มีขั้นตอน finalize ที่มอบหมายหมายเลขเมื่อบันทึกเป็นทางการเท่านั้น
สร้างเลย

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

ข้อผิดพลาดคลาสสิกคือการใช้ SELECT MAX(number) + 1 ในโค้ดแอป มันดูดีในการทดสอบผู้ใช้คนเดียว แต่สองคำขอสามารถอ่าน MAX เดียวกันก่อนใครคนใดคนหนึ่ง commit ทั้งคู่จะสร้างค่า next เดียวกัน และคุณได้หมายเลขซ้ำ แม้คุณเพิ่ม "เช็คแล้วลองใหม่" คุณยังอาจสร้างโหลดเพิ่มและยอดที่ยากจะคาดการณ์เมื่อทราฟฟิกสูง

แหล่งที่มาของหมายเลขซ้ำอีกอย่างคือการสร้างหมายเลขที่ฝั่งไคลเอนต์ (เบราว์เซอร์หรือมือถือ) ก่อนบันทึก ไคลเอนต์ไม่รู้ว่าผู้ใช้คนอื่นกำลังทำอะไรและไม่สามารถสำรองหมายเลขได้อย่างปลอดภัยถ้าการบันทึกล้มเหลว หมายเลขที่ไคลเอนต์สร้างเหมาะกับป้ายชั่วคราว เช่น "Draft 12" แต่ไม่เหมาะกับรหัสใบแจ้งหนี้หรือตั๋วอย่างเป็นทางการ

ช่องว่างทำให้ทีมประหลาดใจที่คิดว่า sequence เป็น gapless ใน PostgreSQL sequences ออกแบบมาสำหรับความเป็นเอกลักษณ์ ไม่ใช่ความต่อเนื่องสมบูรณ์ หมายเลขสามารถข้ามได้เมื่อทรานแซกชัน rollback เมื่อคุณ prefetch IDs หรือเมื่อฐานข้อมูลรีสตาร์ท นั่นเป็นพฤติกรรมปกติ ถ้าความต้องการจริงๆ ของคุณคือ "ไม่มีหมายเลขซ้ำ" sequence + unique constraint มักเป็นคำตอบที่เหมาะสม ถ้าคุณต้องการจริงๆ ให้ไม่มีช่องว่าง คุณต้องใช้รูปแบบอื่น (มักเป็นการล็อกแถว) และยอมรับการแลกเปลี่ยนในระดับ throughput

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

นี่คือความผิดพลาดที่ควรตรวจสอบเมื่อทำการใช้งานการให้หมายเลขที่ปลอดภัยต่อการประมวลผลพร้อมกัน:

  • ใช้ MAX + 1 (หรือ "หาเลขล่าสุด") โดยไม่มีคอนสเตรนต์ระดับฐานข้อมูล
  • สร้างหมายเลขสุดท้ายที่ฝั่งไคลเอนต์ แล้วพยายาม "แก้ขัด" ทีหลัง
  • คาดหวังว่า PostgreSQL sequences จะไม่มีช่องว่าง แล้วมองช่องว่างเป็นข้อผิดพลาด
  • ล็อกตัวนับร่วมเดียวสำหรับทุกอย่าง แทนที่จะ partition ตัวนับตามความเหมาะสม
  • ทดสอบแค่ผู้ใช้คนเดียว ทำให้ race condition ไม่ปรากฏจนกว่าจะเปิดใช้งานจริง

เคล็ดลับทดสอบจริง: รันการทดสอบการประมวลผลพร้อมกันที่สร้าง 100 ถึง 1,000 ระเบียนพร้อมกัน แล้วตรวจหาหมายเลขซ้ำและช่องว่างที่ไม่คาดคิด ถ้าคุณสร้างในเครื่องมือ no-code อย่าง AppMaster กฎเดียวกันใช้ได้: ตรวจสอบให้แน่ใจว่าหมายเลขสุดท้ายถูกมอบหมายในทรานแซกชันฝั่งเซิร์ฟเวอร์ ไม่ใช่ใน flow ของ UI

การตรวจสอบด่วนก่อนส่งออกสู่ผู้ใช้

ไปแบบไม่มีช่องว่างเมื่อจำเป็น
นำรูปแบบ counter row และการล็อกด้วย FOR UPDATE เข้าไปในธุรกรรมเดียวเมื่อจำเป็นต้องไม่มีช่องว่าง
สร้างแอป

ก่อนปล่อยการให้หมายเลขใบแจ้งหนี้หรือตั๋ว ให้ทำการตรวจสอบด่วนในส่วนที่มักพังเมื่อมีการใช้งานจริง เป้าหมายเรียบง่าย: แต่ละระเบียนได้หมายเลขธุรกิจเพียงหมายเลขเดียว และกฎของคุณยังคงถูกต้องแม้เมื่อ 50 คนกด "Create" พร้อมกัน

นี่คือเช็คลิสต์ก่อนส่งออกสำหรับการให้หมายเลขที่ปลอดภัยต่อการประมวลผลพร้อมกัน:

  • ยืนยันว่าฟิลด์หมายเลขธุรกิจมี unique constraint ในฐานข้อมูล (ไม่ใช่แค่การเช็คที่ UI) นี่คือแนวป้องกันสุดท้ายหากสองคำขอชนกัน
  • ตรวจสอบว่าหมายเลขถูกมอบหมายภายในทรานแซกชันฐานข้อมูลเดียวกับการบันทึกระเบียน ถ้าการมอบหมายหมายเลขกับการบันทึกแยกข้ามคำขอ คุณจะเห็นหมายเลขซ้ำในที่สุด
  • ถ้าต้องการหมายเลขแบบไม่มีช่องว่าง ให้มอบหมายหมายเลขเฉพาะเมื่อระเบียนถูกยืนยัน (เช่น เมื่อออก invoice ไม่ใช่ตอนสร้างร่าง) ร่าง ฟอร์มที่ถูกทิ้ง และการชำระเงินล้มเหลวเป็นสาเหตุหลักของช่องว่าง
  • เพิ่มกลยุทธ์การลองใหม่สำหรับการชนที่เกิดได้ยาก แม้มีการล็อกแถวหรือ sequence คุณอาจเจอ serialization error, deadlock, หรือ unique violation ในกรณีมุมมอง การลองใหม่แบบเรียบง่ายพร้อม backoff ระยะสั้นมักเพียงพอ
  • ทดสอบความทนทานด้วยการสร้างพร้อมกัน 20 ถึง 100 รายการผ่านทุกจุดเชื่อมต่อ: UI, API สาธารณะ, และ bulk imports ทดสอบรูปแบบจริงเช่น surges, เครือข่ายช้า, และ double submits

วิธีการตรวจสอบด่วน: จำลองช่วงเวลาสนับสนุนที่วุ่นวาย: สองเอเยนต์เปิดฟอร์ม "New ticket" หนึ่งส่งจากเว็บแอป ในขณะที่ job นำเข้าตั๋วจากกล่องอีเมลในเวลาเดียวกัน หลังรัน ตรวจสอบว่าหมายเลขทั้งหมดไม่ซ้ำ อยู่ในรูปแบบที่ถูก และความล้มเหลวไม่ทิ้งระเบียนที่บันทึกครึ่งกลาง

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

ตัวอย่าง: ตั๋ว helpdesk ที่วุ่นวายและสิ่งที่ควรทำต่อ

ลองนึกถึงศูนย์ช่วยเหลือที่เอเยนต์สร้างตั๋วทั้งวัน ในขณะที่ integration สร้างตั๋วจากแชตและอีเมลด้วย ทุกคนคาดหวังหมายเลขตั๋วเช่น T-2026-000123 และคาดหวังว่าหมายเลขแต่ละหมายเลขชี้ไปยังตั๋วเดียวเท่านั้น

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

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

การให้หมายเลขแบบ gapless เปลี่ยนเวิร์กโฟลว์ หากคุณต้องการไม่มีช่องว่าง ปกติคุณจะไม่สามารถมอบหมายหมายเลขสุดท้ายเมื่อสร้างตั๋วครั้งแรก (ร่าง) แทนที่จะสร้างตั๋วด้วยสถานะ Draft และ ticket_number เป็น NULL มอบหมายหมายเลขเฉพาะเมื่อ finalized เพื่อให้การบันทึกล้มเหลวและร่างที่ทิ้งไม่เผาจำนวน

การออกแบบตารางง่ายๆ อาจเป็นดังนี้:

  • tickets: id, created_at, status (Draft, Open, Closed), ticket_number (nullable), finalized_at
  • ticket_counters: key (เช่น "tickets_2026"), next_number

ใน AppMaster คุณสามารถแบบจำลองนี้ใน Data Designer ด้วยชนิด PostgreSQL แล้วสร้างตรรกะใน Business Process Editor:

  • Create Ticket: insert ticket with status=Draft and no ticket_number
  • Finalize Ticket: start a transaction, lock the counter row, set ticket_number, increment next_number, commit
  • Test: run two “Finalize” actions at the same time and confirm you never get duplicates

สิ่งที่ควรทำต่อ: เริ่มจากกฎของคุณ (unique เท่านั้น vs gapless จริงๆ) ถ้าคุณยอมรับช่องว่างได้ sequence ของฐานข้อมูล + unique constraint มักเพียงพอและทำให้ flow ง่าย ถ้าต้องการ gapless ย้ายการให้หมายเลขไปยังขั้นตอน finalization และถือว่าร่างเป็นสถานะสำคัญ จากนั้นทดสอบโหลดด้วยหลายเอเยนต์ที่กดพร้อมกันและ integration ที่ส่งชุดข้อมูล เพื่อเห็นพฤติกรรมก่อนผู้ใช้จริงจะเห็น

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

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

เริ่ม
การให้หมายเลขใบแจ้งหนี้ที่ปลอดภัยต่อการประมวลผลพร้อมกันและป้องกันหมายเลขซ้ำ/ช่องว่าง | AppMaster